Skip to content
65 changes: 65 additions & 0 deletions packages/desktop-electron/src/main/index-updater-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,69 @@ describe("main updater source contracts", () => {
/rm\(pendingUpdateCacheDir\(\),\s*\{\s*recursive:\s*true,\s*force:\s*true\s*\}\)\s*\.catch\(\(\)\s*=>/,
)
})

test("broadcasts download progress to every open window", () => {
expect(source).toContain('autoUpdater.on("download-progress"')
Comment thread
Astro-Han marked this conversation as resolved.
expect(source).toMatch(/currentProgress\s*=\s*info\.percent\s*\/\s*100/)
expect(source).toMatch(/for\s*\(\s*const\s+win\s+of\s+BrowserWindow\.getAllWindows\(\)\s*\)/)
expect(source).toContain("win.setProgressBar(")
})

test("clears the progress bar on every updater terminal event", () => {
expect(source).toContain('autoUpdater.on("update-downloaded", clearProgressBar)')
expect(source).toContain('autoUpdater.on("update-not-available", clearProgressBar)')
expect(source).toContain('autoUpdater.on("update-cancelled", clearProgressBar)')
expect(source).toContain('autoUpdater.on("error"')
expect(source).toContain('logger.error("updater error"')
})

test("registers progress listeners only after the updater-disabled early return", () => {
const earlyReturnIndex = source.search(/if\s*\(\s*!UPDATER_ENABLED\s*\)\s*return/)
const listenerIndex = source.search(/autoUpdater\.on\("download-progress"/)
expect(earlyReturnIndex).toBeGreaterThan(0)
expect(listenerIndex).toBeGreaterThan(earlyReturnIndex)
})

test("reapplies current progress to a new window on ready-to-show", () => {
expect(source).toContain('win.once("ready-to-show"')
const hookIndex = source.search(/win\.once\("ready-to-show"/)
const reapplySlice = source.slice(hookIndex, hookIndex + 400)
expect(reapplySlice).toMatch(/if\s*\(\s*currentProgress\s*!==\s*null\s*\)/)
Comment thread
Astro-Han marked this conversation as resolved.
expect(reapplySlice).toMatch(/win\.setProgressBar\(currentProgress\)/)
})

test("failure dialog uses reason-specific copy and three recovery buttons", () => {
expect(source).toContain("labels.failed.reasonCopy[result.reason]")
expect(source).toContain("labels.failed.currentVersionUnaffected")
expect(source).toMatch(/\[result\.message,\s*labels\.failed\.currentVersionUnaffected\]/)
expect(source).toContain(
"[labels.failed.buttons.retry, labels.failed.buttons.openDownloadPage, labels.failed.buttons.later]",
)
expect(source).toContain("defaultId: 0")
expect(source).toContain("cancelId: 2")
})

test("failure dialog retry awaits recursion and logs rejection", () => {
expect(source).toMatch(
/try\s*\{\s*await\s+checkForUpdates\(alertOnFail\)\s*\}\s*catch\s*\(error\)\s*\{\s*logger\.error\("retry after update failure failed"/,
)
})

test("failure dialog open-download-page opens the releases URL", () => {
expect(source).toMatch(/const LATEST_RELEASE_URL = "https:\/\/github\.com\/Astro-Han\/pawwork\/releases\/latest"/)
expect(source).toMatch(/shell\.openExternal\(LATEST_RELEASE_URL\)/)
})

test("install-failure dialog uses the unified structure without a retry button", () => {
expect(source).toContain("labels.failed.installFailedMessage")
expect(source).toContain(
"[labels.failed.buttons.openDownloadPage, labels.failed.buttons.later]",
)
const installBlockStart = source.search(/catch\s*\(\s*error\s*\)\s*\{\s*logger\.error\("install update failed"/)
expect(installBlockStart).toBeGreaterThan(0)
const installSlice = source.slice(installBlockStart, installBlockStart + 800)
expect(installSlice).toContain("labels.failed.installFailedMessage")
expect(installSlice).toContain("labels.failed.currentVersionUnaffected")
expect(installSlice).not.toContain("labels.failed.buttons.retry")
})
})
76 changes: 72 additions & 4 deletions packages/desktop-electron/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,29 @@ const initEmitter = new EventEmitter()
let initStep: InitStep = { phase: "server_waiting" }

let mainWindow: BrowserWindow | null = null
let currentProgress: number | null = null
Comment thread
Astro-Han marked this conversation as resolved.

const LATEST_RELEASE_URL = "https://github.com/Astro-Han/pawwork/releases/latest"

async function openLatestReleasePage() {
try {
await shell.openExternal(LATEST_RELEASE_URL)
} catch (error) {
logger.error("open latest release page failed", error)
}
}

function applyProgressBar(value: number) {
for (const win of BrowserWindow.getAllWindows()) {
win.setProgressBar(value)
}
}

function clearProgressBar() {
currentProgress = null
applyProgressBar(-1)
}

let server: Server.Listener | null = null
const loadingComplete = defer<void>()
const deepLinkReadyWindows = new WeakSet<BrowserWindow>()
Expand Down Expand Up @@ -343,6 +366,11 @@ function focusMainWindow(options: { openIfMissing?: boolean } = {}) {
function openMainWindow() {
const win = createMainWindow()
mainWindow = win
win.once("ready-to-show", () => {
if (currentProgress !== null) {
win.setProgressBar(currentProgress)
}
})
win.on("focus", () => syncMenuLocaleForWindow(win))
win.on("closed", () => {
if (mainWindow !== win) return
Expand Down Expand Up @@ -588,6 +616,17 @@ function setupAutoUpdater() {
autoInstallOnAppQuit: autoUpdater.autoInstallOnAppQuit,
currentVersion: app.getVersion(),
})
autoUpdater.on("download-progress", (info) => {
currentProgress = info.percent / 100
applyProgressBar(currentProgress)
})
autoUpdater.on("update-downloaded", clearProgressBar)
autoUpdater.on("update-not-available", clearProgressBar)
autoUpdater.on("update-cancelled", clearProgressBar)
autoUpdater.on("error", (error) => {
logger.error("updater error", error)
clearProgressBar()
})
}

async function checkUpdate() {
Expand Down Expand Up @@ -631,11 +670,28 @@ async function checkForUpdates(alertOnFail: boolean) {
if (result.status === "failed") {
logger.log("no update decision", { reason: result.reason ?? "update check failed" })
if (!alertOnFail) return
await dialog.showMessageBox({
const copy = labels.failed.reasonCopy[result.reason] ?? labels.failed.fallbackMessage
const detail = [result.message, labels.failed.currentVersionUnaffected]
.filter(Boolean)
.join("\n\n")
const response = await dialog.showMessageBox({
type: "error",
message: result.message ?? labels.failed.fallbackMessage,
title: labels.failed.title,
message: copy,
detail,
buttons: [labels.failed.buttons.retry, labels.failed.buttons.openDownloadPage, labels.failed.buttons.later],
defaultId: 0,
cancelId: 2,
})
if (response.response === 0) {
try {
await checkForUpdates(alertOnFail)
} catch (error) {
logger.error("retry after update failure failed", error)
}
} else if (response.response === 1) {
Comment thread
Astro-Han marked this conversation as resolved.
await openLatestReleasePage()
}
return
}
if (!result.updateAvailable) {
Expand Down Expand Up @@ -673,11 +729,23 @@ async function checkForUpdates(alertOnFail: boolean) {
}
} catch (error) {
logger.error("install update failed", error)
await dialog.showMessageBox({
const response = await dialog.showMessageBox({
type: "error",
title: labels.failed.title,
message: error instanceof Error ? error.message : labels.failed.fallbackMessage,
message: labels.failed.installFailedMessage,
detail: [
error instanceof Error ? error.message : "",
Comment thread
Astro-Han marked this conversation as resolved.
labels.failed.currentVersionUnaffected,
]
.filter(Boolean)
.join("\n\n"),
buttons: [labels.failed.buttons.openDownloadPage, labels.failed.buttons.later],
defaultId: 0,
cancelId: 1,
})
if (response.response === 0) {
await openLatestReleasePage()
}
}
} else {
updater.dismissReady()
Expand Down
28 changes: 27 additions & 1 deletion packages/desktop-electron/src/main/updater-dialog-labels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ describe("updater dialog labels", () => {

expect(labels.busy.title).toBe("正在检查更新")
expect(labels.disabled.message).toBe("此构建不支持更新。")
expect(labels.failed.title).toBe("检查更新失败")
expect(labels.failed.title).toBe("更新失败")
expect(labels.none.message).toBe("PawWork 已是最新版本。")
expect(labels.ready.message("0.2.5")).toBe("更新 0.2.5 已下载。现在重启?")
expect(labels.ready.buttons).toEqual(["重启", "稍后"])
Expand All @@ -26,4 +26,30 @@ describe("updater dialog labels", () => {
expect(labels.busy.title).toBe("Update Check in Progress")
expect(labels.none.message).toBe("You're up to date.")
})

test("exposes reason-specific failed copy and recovery buttons in Simplified Chinese", () => {
const labels = updaterDialogLabels("zh")

expect(labels.failed.title).toBe("更新失败")
expect(labels.failed.installFailedMessage).toBe("安装失败。")
expect(labels.failed.reasonCopy.check).toBe("无法连接 GitHub。网络可能较慢或被阻断。")
expect(labels.failed.reasonCopy.download).toBe("下载未完成。")
expect(labels.failed.reasonCopy.metadata).toBe("更新信息不完整或无效。")
expect(labels.failed.reasonCopy.cache).toBe("缓存的更新处于异常状态。")
expect(labels.failed.currentVersionUnaffected).toBe("当前版本未受影响,可继续使用。")
expect(labels.failed.buttons).toEqual({ retry: "重试", openDownloadPage: "打开下载页", later: "稍后" })
})

test("exposes reason-specific failed copy in English", () => {
const labels = updaterDialogLabels("en")

expect(labels.failed.title).toBe("Update Failed")
expect(labels.failed.installFailedMessage).toBe("Installation failed.")
expect(labels.failed.reasonCopy.check).toBe("Could not reach GitHub. The network may be slow or blocked.")
expect(labels.failed.reasonCopy.download).toBe("The download did not complete.")
expect(labels.failed.reasonCopy.metadata).toBe("The update information was incomplete or invalid.")
expect(labels.failed.reasonCopy.cache).toBe("The cached update is in an unexpected state.")
expect(labels.failed.currentVersionUnaffected).toBe("Your current version is unaffected and continues to work.")
expect(labels.failed.buttons).toEqual({ retry: "Retry", openDownloadPage: "Open Download Page", later: "Later" })
})
})
33 changes: 30 additions & 3 deletions packages/desktop-electron/src/main/updater-dialog-labels.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import type { MenuLocale } from "./menu-labels"

type FailedLabels = {
title: string
fallbackMessage: string
installFailedMessage: string
reasonCopy: Partial<Record<"check" | "download" | "metadata" | "cache", string>>
currentVersionUnaffected: string
buttons: { retry: string; openDownloadPage: string; later: string }
}

type Labels = {
busy: { title: string; message: string }
disabled: { title: string; message: string }
failed: { title: string; fallbackMessage: string }
failed: FailedLabels
none: { title: string; message: string }
ready: { title: string; message: (version?: string) => string; buttons: [string, string] }
}
Expand All @@ -19,8 +28,17 @@ const labels: Record<MenuLocale, Labels> = {
message: "Updates are not available in this build.",
},
failed: {
title: "Update Check Failed",
title: "Update Failed",
fallbackMessage: "Failed to check for updates.",
installFailedMessage: "Installation failed.",
reasonCopy: {
check: "Could not reach GitHub. The network may be slow or blocked.",
download: "The download did not complete.",
metadata: "The update information was incomplete or invalid.",
cache: "The cached update is in an unexpected state.",
},
currentVersionUnaffected: "Your current version is unaffected and continues to work.",
buttons: { retry: "Retry", openDownloadPage: "Open Download Page", later: "Later" },
},
none: {
title: "No Updates",
Expand All @@ -42,8 +60,17 @@ const labels: Record<MenuLocale, Labels> = {
message: "此构建不支持更新。",
},
failed: {
title: "检查更新失败",
title: "更新失败",
fallbackMessage: "检查更新失败。",
installFailedMessage: "安装失败。",
reasonCopy: {
check: "无法连接 GitHub。网络可能较慢或被阻断。",
download: "下载未完成。",
metadata: "更新信息不完整或无效。",
cache: "缓存的更新处于异常状态。",
},
currentVersionUnaffected: "当前版本未受影响,可继续使用。",
buttons: { retry: "重试", openDownloadPage: "打开下载页", later: "稍后" },
},
none: {
title: "没有可用更新",
Expand Down
Loading