diff --git a/apps/desktop/src/main/__tests__/about-update-status.test.ts b/apps/desktop/src/main/__tests__/about-update-status.test.ts index 34380aa5a4..6cc2d1c3ab 100644 --- a/apps/desktop/src/main/__tests__/about-update-status.test.ts +++ b/apps/desktop/src/main/__tests__/about-update-status.test.ts @@ -105,7 +105,7 @@ test('the nightly steady states each read as themselves', () => { }); }); -test('a failure names the step that failed and offers the check again', () => { +test('a failure names the step and offers the corresponding recovery', () => { const download = aboutUpdateRow( { state: 'error', @@ -120,7 +120,7 @@ test('a failure names the step that failed and offers the check again', () => { assert.deepEqual(download, { label: '下载更新失败', description: '网络错误:ECONNRESET', - action: 'check', + action: 'retry', }); const check = aboutUpdateRow( diff --git a/apps/desktop/src/main/__tests__/app-update-service.test.ts b/apps/desktop/src/main/__tests__/app-update-service.test.ts index 805f0b3c54..23e9859e20 100644 --- a/apps/desktop/src/main/__tests__/app-update-service.test.ts +++ b/apps/desktop/src/main/__tests__/app-update-service.test.ts @@ -119,6 +119,7 @@ function updateInfo(version: string) { } function createHarness(input: { + currentVersion?: string; isPackaged?: boolean; updater?: FakeUpdater; clock?: FakeClock; @@ -139,7 +140,7 @@ function createHarness(input: { const updater = input.updater ?? new FakeUpdater(); const clock = input.clock ?? new FakeClock(); const service = createAppUpdateService({ - currentVersion: '1.0.0', + currentVersion: input.currentVersion ?? '1.0.0', isPackaged: input.isPackaged ?? true, updateChannel: input.updateChannel ?? 'release', updater: updater as unknown as AppUpdater, @@ -371,28 +372,29 @@ describe('AppUpdateService', () => { updater.checkCalls += 1; updater.emit('checking-for-update'); updater.emit('update-available', updateInfo('1.1.0')); - if (updater.checkCalls === 1) { - const downloadPromise = new Promise((_resolve, reject) => { - rejectFirstDownload = reject; - }); - return { - isUpdateAvailable: true, - updateInfo: updateInfo('1.1.0'), - versionInfo: updateInfo('1.1.0'), - downloadPromise, - cancellationToken: { - cancel: () => { - cancellationCalls += 1; - rejectFirstDownload(new Error('download cancelled for retry')); - }, + const downloadPromise = new Promise((_resolve, reject) => { + rejectFirstDownload = reject; + }); + return { + isUpdateAvailable: true, + updateInfo: updateInfo('1.1.0'), + versionInfo: updateInfo('1.1.0'), + downloadPromise, + cancellationToken: { + cancel: () => { + cancellationCalls += 1; + rejectFirstDownload(new Error('download cancelled for retry')); }, - }; - } + }, + }; + }; + updater.downloadUpdate = async () => { + updater.downloadCalls += 1; updater.emit('update-downloaded', { ...updateInfo('1.1.0'), downloadedFile: '/tmp/maka-update.zip', }); - return { isUpdateAvailable: true }; + return []; }; const { clock, service } = createHarness({ updater, @@ -405,7 +407,8 @@ describe('AppUpdateService', () => { assert.equal((await service.retryUpdateDownload()).state, 'downloaded'); assert.equal(cancellationCalls, 1); - assert.equal(updater.checkCalls, 2); + assert.equal(updater.checkCalls, 1); + assert.equal(updater.downloadCalls, 1); assert.equal(statuses.some((status) => status.state === 'error'), false); }); @@ -422,19 +425,113 @@ describe('AppUpdateService', () => { message: 'proxy disconnected', }); + updater.downloadUpdate = async () => { + updater.downloadCalls += 1; + updater.emit('update-downloaded', { + ...updateInfo('1.1.0'), + downloadedFile: '/tmp/maka-update.zip', + }); + return []; + }; + assert.equal((await service.retryUpdateDownload()).state, 'downloaded'); + assert.equal(updater.checkCalls, 0); + assert.equal(updater.downloadCalls, 1); + }); + + test('retries a failed background download without repeating the GitHub feed check', async () => { + const updater = new FakeUpdater(); + const statuses: AppUpdateStatus[] = []; updater.checkForUpdates = async () => { updater.checkCalls += 1; updater.emit('checking-for-update'); + updater.emit('update-available', updateInfo('1.1.0')); + const downloadPromise = new Promise((_resolve, reject) => { + setImmediate(() => { + updater.emit('error', new Error('connection reset')); + reject(new Error('connection reset')); + }); + }); + return { isUpdateAvailable: true, downloadPromise }; + }; + updater.downloadUpdate = async () => { + updater.downloadCalls += 1; updater.emit('update-downloaded', { ...updateInfo('1.1.0'), downloadedFile: '/tmp/maka-update.zip', }); - return { isUpdateAvailable: true }; + return []; }; - assert.equal((await service.retryUpdateDownload()).state, 'downloaded'); + const { clock, service } = createHarness({ updater, onStatusChange: (status) => statuses.push(status) }); + + service.start(); + await clock.runNext(); + await settleUpdateVerification(); + assert.equal(clock.pending().some((timer) => timer.delayMs === 5_000), true); + assert.equal(statuses.some((entry) => entry.state === 'error'), false); + const retry = clock.pending().find((timer) => timer.delayMs === 5_000); + assert.ok(retry); + retry.cleared = true; + retry.callback(); + await settleUpdateVerification(); + assert.equal(service.getStatus().state, 'downloaded'); + assert.equal(updater.checkCalls, 1); + assert.equal(updater.downloadCalls, 1); + }); + + test('reports a download failure after the background retry also fails', async () => { + const updater = new FakeUpdater(); + updater.checkForUpdates = async () => { + updater.checkCalls += 1; + updater.emit('checking-for-update'); + updater.emit('update-available', updateInfo('1.1.0')); + return { isUpdateAvailable: true, downloadPromise: Promise.reject(new Error('first failure')) }; + }; + updater.downloadUpdate = async () => { + updater.downloadCalls += 1; + updater.emit('error', new Error('second failure')); + throw new Error('second failure'); + }; + const statuses: AppUpdateStatus[] = []; + const { clock, service } = createHarness({ updater, onStatusChange: (status) => statuses.push(status) }); + + await service.checkForUpdatesNow(); + await settleUpdateVerification(); + const retry = clock.pending().find((timer) => timer.delayMs === 5_000); + assert.ok(retry); + retry.cleared = true; + retry.callback(); + await settleUpdateVerification(); + assert.deepEqual(service.getStatus(), { + state: 'error', + currentVersion: '1.0.0', + latestVersion: '1.1.0', + operation: 'download', + message: 'second failure', + }); + assert.equal(statuses.filter((entry) => entry.state === 'error').length, 1); assert.equal(updater.checkCalls, 1); }); + test('a Nightly download retry reuses the known prerelease without a new check', async () => { + const updater = new FakeUpdater(); + const version = '1.0.0-dev.2.20260924'; + const { service } = createHarness({ updater, currentVersion: '1.0.0-dev.1.20260923' }); + updater.emit('update-available', updateInfo(version)); + updater.emit('error', new Error('connection reset')); + updater.downloadUpdate = async () => { + updater.downloadCalls += 1; + updater.emit('update-downloaded', { + ...updateInfo(version), + downloadedFile: `/tmp/Maka-${version}-mac-arm64.zip`, + }); + return []; + }; + + assert.equal((await service.retryUpdateDownload()).state, 'downloaded'); + assert.equal(updater.checkCalls, 0); + assert.equal(updater.downloadCalls, 1); + }); + test('recovers from a transient check failure on the built-in retry', async () => { const updater = new FakeUpdater(); const statuses: AppUpdateStatus[] = []; diff --git a/apps/desktop/src/main/app-update-service.ts b/apps/desktop/src/main/app-update-service.ts index 1433308b1d..bcdda22c63 100644 --- a/apps/desktop/src/main/app-update-service.ts +++ b/apps/desktop/src/main/app-update-service.ts @@ -97,6 +97,7 @@ const UPDATE_CHECK_ON_FOCUS_MIN_INTERVAL_MS = 15 * 60 * 1000; */ const UPDATE_CHECK_RETRY_DELAY_MS = 2_000; const UPDATE_CHECK_MAX_ATTEMPTS = 2; +const UPDATE_DOWNLOAD_RETRY_DELAY_MS = 5_000; /** * Harness-only override for the update feed (`MAKA_UPDATE_TEST_FEED`). @@ -189,12 +190,15 @@ function mockStatus(currentVersion: string, latestVersion: string, state: AppUpd export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateService { let status: AppUpdateStatus = { state: 'idle', currentVersion: deps.currentVersion }; let latestInfo: UpdateInfo | undefined; + let hasAvailableUpdate = false; let checkInFlight: Promise | null = null; let activeDownload: { promise: Promise; cancellationToken?: UpdateCheckResult['cancellationToken']; cancelledForRetry: boolean; } | undefined; + let downloadRetryTimer: unknown; + let downloadRetryPending = false; let activeVerification: Promise | undefined; let checkTimer: unknown; let checkRetryTimer: unknown; @@ -235,13 +239,17 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer const publishError = ( operation: Extract['operation'], error: unknown, - ): AppUpdateStatus => publish({ - state: 'error', - currentVersion: deps.currentVersion, - latestVersion: latestVersion(), - operation, - message: error instanceof Error ? error.message : String(error), - }); + ): AppUpdateStatus => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[app-update] ${operation} failed: ${message.slice(0, 500)}`); + return publish({ + state: 'error', + currentVersion: deps.currentVersion, + latestVersion: latestVersion(), + operation, + message, + }); + }; const rollbackInstallHandoff = (): void => { const handoff = installHandoff; @@ -249,21 +257,35 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer handoff?.rollback(); }; - const trackAutoDownload = (result: UpdateCheckResult | null): void => { - if (!result?.downloadPromise) return; + const trackDownload = ( + promise: Promise, + cancellationToken: UpdateCheckResult['cancellationToken'] | undefined, + retryOnFailure: boolean, + ): void => { const tracked = { - promise: result.downloadPromise, - cancellationToken: result.cancellationToken, + promise, + cancellationToken, cancelledForRetry: false, }; activeDownload = tracked; void tracked.promise .catch((error) => { - if ( - activeDownload === tracked && - !tracked.cancelledForRetry && - status.state !== 'error' - ) { + if (activeDownload !== tracked || tracked.cancelledForRetry || disposed) return; + if (retryOnFailure) { + downloadRetryPending = true; + console.warn('[app-update] download failed; retrying once:', error instanceof Error ? error.message : String(error)); + publish({ + state: 'available', + currentVersion: deps.currentVersion, + latestVersion: latestVersion() ?? deps.currentVersion, + }); + downloadRetryTimer = clock.setTimeout(() => { + downloadRetryTimer = undefined; + downloadRetryPending = false; + if (disposed || status.state === 'downloaded' || status.state === 'installing') return; + startDownload(false); + }, UPDATE_DOWNLOAD_RETRY_DELAY_MS); + } else if (status.state !== 'error') { publishError('download', error); } }) @@ -272,6 +294,22 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer }); }; + const startDownload = (retryOnFailure: boolean): void => { + try { + trackDownload(updater.downloadUpdate(), undefined, retryOnFailure); + } catch (error) { + publishError('download', error); + } + }; + + const clearDownloadRetry = (): void => { + if (downloadRetryTimer !== undefined) { + clock.clearTimeout(downloadRetryTimer); + downloadRetryTimer = undefined; + } + downloadRetryPending = false; + }; + updater.autoDownload = true; updater.autoInstallOnAppQuit = false; updater.allowPrerelease = deps.updateChannel === 'nightly'; @@ -286,6 +324,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer }); updater.on('update-available', (info) => { latestInfo = info; + hasAvailableUpdate = true; const version = updateInfoVersion(info) ?? deps.currentVersion; publish({ state: 'available', @@ -295,6 +334,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer }); updater.on('update-not-available', (info) => { latestInfo = info; + hasAvailableUpdate = false; publish({ state: 'not-available', currentVersion: deps.currentVersion, @@ -345,10 +385,13 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer ? 'download' : 'check'; if (operation === 'install') rollbackInstallHandoff(); - // A check failure with retry attempts still owed is transient: hold it - // back and let the scheduled retry produce the final word. Download and - // install errors always surface immediately. + // A check failure with retry attempts still owed is transient. Download + // errors are handled by the tracked promise, which can retry once before + // publishing the final failure. Install errors surface immediately. if (operation === 'check' && checkAttemptsRemaining > 0) return; + // electron-updater emits this event before rejecting downloadUpdate(). + // Let the tracked promise decide whether to retry or publish the failure. + if (operation === 'download' && (activeDownload || downloadRetryPending)) return; publishError(operation, error); }); @@ -361,7 +404,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer } if (status.state === 'verifying' || status.state === 'downloaded' || status.state === 'installing') return status; if (status.state === 'downloading' && !allowDuringDownload) return status; - if (activeDownload && !allowDuringDownload) return status; + if ((activeDownload || downloadRetryPending) && !allowDuringDownload) return status; if (checkInFlight) return checkInFlight; lastCheckStartedAt = now(); // Each attempt propagates its rejection; the publish-or-retry decision @@ -370,7 +413,9 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer updater .checkForUpdates() .then(async (result) => { - trackAutoDownload(result); + if (result?.downloadPromise) { + trackDownload(result.downloadPromise, result.cancellationToken, true); + } const verification = activeVerification; if (verification) await verification.catch(() => undefined); return status; @@ -436,6 +481,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer clock.clearTimeout(checkTimer); checkTimer = undefined; } + clearDownloadRetry(); // Deliberately leaves the retry timer running: its callback checks // `disposed` and settles the in-flight check, so `checkInFlight` is not // left dangling and its .finally cleanup still runs. @@ -449,15 +495,34 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer return status; } if (checkInFlight) await checkInFlight; + const afterCheck = currentStatus(); + if (afterCheck.state === 'verifying' || afterCheck.state === 'downloaded' || afterCheck.state === 'installing') return afterCheck; + clearDownloadRetry(); const download = activeDownload; if (download) { download.cancelledForRetry = true; download.cancellationToken?.cancel(); await download.promise.catch(() => undefined); const settled = currentStatus(); - if (settled.state === 'downloaded' || settled.state === 'installing') return settled; + if (settled.state === 'verifying' || settled.state === 'downloaded' || settled.state === 'installing') return settled; + } + // A failed download leaves electron-updater's updateInfoAndProvider in + // place. Reusing it avoids another GitHub Atom request, which can fail + // independently of the asset download. Re-check only if no update was found. + if (!hasAvailableUpdate || !latestInfo) { + return checkForUpdates(true); } - return checkForUpdates(true); + publish({ + state: 'available', + currentVersion: deps.currentVersion, + latestVersion: updateInfoVersion(latestInfo) ?? deps.currentVersion, + }); + startDownload(false); + const pending = activeDownload?.promise; + if (pending) await pending.catch(() => undefined); + const verification = activeVerification; + if (verification) await verification.catch(() => undefined); + return status; } async function installUpdate(input: AppUpdateInstallRequest): Promise { diff --git a/apps/desktop/src/renderer/features/app-update/ui/app-update-projection-context.ts b/apps/desktop/src/renderer/features/app-update/ui/app-update-projection-context.ts index 7ab9300b2f..02d2419456 100644 --- a/apps/desktop/src/renderer/features/app-update/ui/app-update-projection-context.ts +++ b/apps/desktop/src/renderer/features/app-update/ui/app-update-projection-context.ts @@ -24,6 +24,7 @@ export interface AppUpdateAboutProjection { readonly status: AppUpdateStatus | null; readonly checking: boolean; readonly checkForUpdates: () => Promise; + readonly retryUpdateDownload: () => Promise; /** The sidebar footer's restart, offered here too; undefined until an update is downloaded. */ readonly installDownloadedUpdate: (() => void) | undefined; /** True from the install request until it settles, including while the active-tasks dialog is open. */ diff --git a/apps/desktop/src/renderer/features/app-update/ui/app-update-provider.tsx b/apps/desktop/src/renderer/features/app-update/ui/app-update-provider.tsx index 5087757e3a..59874a2d92 100644 --- a/apps/desktop/src/renderer/features/app-update/ui/app-update-provider.tsx +++ b/apps/desktop/src/renderer/features/app-update/ui/app-update-provider.tsx @@ -140,6 +140,7 @@ export function AppUpdateProvider(props: { readonly children?: ReactNode }) { status: controller.status, checking: controller.checking, checkForUpdates: controller.commands.checkForUpdates, + retryUpdateDownload: controller.commands.retryUpdateDownload, installDownloadedUpdate, installPending, }), diff --git a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts index 0c6234e962..eea0f5a094 100644 --- a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts @@ -236,6 +236,7 @@ export type SettingsPreferencesCopy = { keyboardShortcutsOpen: string; reportIssueLabel: string; checkForUpdates: string; + retryUpdateDownload: string; checkingForUpdates: string; updateIdle: string; updateNotAvailable: string; @@ -356,6 +357,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { reportIssueLabel: '报告问题', reportIssueHelp: '带上诊断信息去 GitHub Issues,回复更快。', reportIssueOpen: '打开', keyboardShortcuts: '键盘快捷键', keyboardShortcutsHelp: 'Maka 支持的全部快捷键一览。', keyboardShortcutsOpen: '查看', checkForUpdates: '检查更新', + retryUpdateDownload: '重试下载', checkingForUpdates: '正在检查更新…', updateIdle: '尚未检查更新', updateNotAvailable: '已是最新版本', @@ -454,6 +456,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { loadFailed: '載入關於資訊失敗', loading: '正在載入關於頁', unavailable: '無法載入關於資訊', copied: '已複製診斷資訊', pasteHint: '檢查內容後,可直接貼上到問題報告', copyFailed: '複製失敗', clipboardUnavailable: '剪貼簿不可用或被系統拒絕。', supportTitle: '支援', copyAction: '複製', reportIssueHelp: '帶上診斷資訊去 GitHub Issues,回覆更快。', reportIssueOpen: '開啟', channelSummaries: { dev: '本地開發建構,不檢查更新。', nightly: '每日建構的預發佈版,自動更新到最新 nightly,會覆蓋正式版安裝。', release: '正式發佈版,自動接收穩定更新。' }, copyDiagnostics: '複製診斷資訊', copyHelp: '複製版本、平臺、隱藏主目錄後的工作區路徑,以及近期脫敏的 Desktop 與 Runtime Host 記錄;僅寫入剪貼簿,不會自動上傳。', keyboardShortcuts: '鍵盤快捷鍵', keyboardShortcutsHelp: 'Maka 支援的全部快捷鍵一覽。', keyboardShortcutsOpen: '檢視', reportIssueLabel: '報告問題', checkForUpdates: '檢查更新', + retryUpdateDownload: '重試下載', checkingForUpdates: '正在檢查更新…', updateIdle: '尚未檢查更新', updateNotAvailable: '已是最新版本', @@ -523,6 +526,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { reportIssueLabel: 'Report an issue', reportIssueHelp: 'Open a GitHub issue with your diagnostics attached — replies come faster.', reportIssueOpen: 'Open', keyboardShortcuts: 'Keyboard shortcuts', keyboardShortcutsHelp: 'Every shortcut Maka responds to.', keyboardShortcutsOpen: 'View', checkForUpdates: 'Check for updates', + retryUpdateDownload: 'Retry download', checkingForUpdates: 'Checking for updates…', updateIdle: 'No update check has run yet', updateNotAvailable: 'You are on the latest version', diff --git a/apps/desktop/src/renderer/settings/about-settings-page.tsx b/apps/desktop/src/renderer/settings/about-settings-page.tsx index 69b1e137f3..14306f6a27 100644 --- a/apps/desktop/src/renderer/settings/about-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/about-settings-page.tsx @@ -87,7 +87,9 @@ function AboutUpdateStatusRow(props: { async function checkForUpdates() { if (!checkUpdateGuard.begin('check')) return; try { - const status = await update.checkForUpdates(); + const status = await (row.action === 'retry' + ? update.retryUpdateDownload() + : update.checkForUpdates()); if (status.state === 'error') { toast.error( copy.updateFailed[status.operation], @@ -120,7 +122,7 @@ function AboutUpdateStatusRow(props: { isDisabled={row.action === 'busy'} isLoading={update.checking || row.action === 'checking'} onClick={() => void checkForUpdates()} - label={copy.checkForUpdates} + label={row.action === 'retry' ? copy.retryUpdateDownload : copy.checkForUpdates} /> ); diff --git a/apps/desktop/src/renderer/settings/about-update-status.ts b/apps/desktop/src/renderer/settings/about-update-status.ts index 73c09d31ce..4713648beb 100644 --- a/apps/desktop/src/renderer/settings/about-update-status.ts +++ b/apps/desktop/src/renderer/settings/about-update-status.ts @@ -51,7 +51,7 @@ export interface AboutUpdateRow { * the updater is working on its own) or the restart once an update is * downloaded (`install`). */ - readonly action: 'check' | 'checking' | 'busy' | 'install'; + readonly action: 'check' | 'retry' | 'checking' | 'busy' | 'install'; } /** @@ -59,9 +59,7 @@ export interface AboutUpdateRow { * * The service refuses a check while a download is in flight or an update sits * downloaded (app-update-service.ts), so 检查更新 is disabled rather than - * offered there. A failed download is re-fetched by the same check (the updater - * downloads on its own once it sees a release), so the page needs no second - * retry control next to the sidebar's. + * offered there. A failed download uses the known update provider directly. */ export function aboutUpdateRow( status: AppUpdateStatus | null, @@ -110,7 +108,7 @@ export function aboutUpdateRow( return { label: copy.updateFailed[status.operation], description: options.errorDetail ? options.errorDetail(status.message) : status.message, - action: 'check', + action: status.operation === 'download' ? 'retry' : 'check', }; } }