From ef71543237dac611d86e8a45f286653d7eb1dc70 Mon Sep 17 00:00:00 2001 From: digua Date: Fri, 26 Jun 2026 10:30:45 +0800 Subject: [PATCH] feat(electron): download updates silently Start stable desktop update downloads in the background during automatic checks, then prompt only after the update is ready to install. Keep manual update checks interactive so users still confirm the download after explicitly checking. --- apps/desktop/main/update.test.ts | 131 +++++++++++++++++++++++++++++++ apps/desktop/main/update.ts | 36 ++++++--- 2 files changed, 158 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/main/update.test.ts diff --git a/apps/desktop/main/update.test.ts b/apps/desktop/main/update.test.ts new file mode 100644 index 00000000..efcc0116 --- /dev/null +++ b/apps/desktop/main/update.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict' +import { EventEmitter } from 'node:events' +import { mock, test } from 'node:test' + +class FakeAutoUpdater extends EventEmitter { + autoDownload = false + autoInstallOnAppQuit = true + downloadCalls = 0 + checkCalls = 0 + quitInstallCalls = 0 + feedUrls: unknown[] = [] + + setFeedURL(url: unknown): void { + this.feedUrls.push(url) + } + + async checkForUpdates(): Promise { + this.checkCalls++ + } + + async downloadUpdate(): Promise { + this.downloadCalls++ + } + + quitAndInstall(): void { + this.quitInstallCalls++ + } +} + +type DialogCall = { + title?: string + message?: string + detail?: string + buttons?: string[] +} + +async function loadUpdaterModule() { + const autoUpdater = new FakeAutoUpdater() + const dialogCalls: DialogCall[] = [] + const logMessages: string[] = [] + + await mock.module('electron', { + namedExports: { + app: { + isPackaged: true, + }, + dialog: { + async showMessageBox(options: DialogCall) { + dialogCalls.push(options) + return { response: 1 } + }, + }, + }, + }) + await mock.module('electron-updater', { + namedExports: { autoUpdater }, + }) + await mock.module('@electron-toolkit/utils', { + namedExports: { platform: { isWindows: false } }, + }) + await mock.module('./logger', { + namedExports: { + logger: { + info(message: string) { + logMessages.push(message) + }, + error(message: string) { + logMessages.push(message) + }, + }, + }, + }) + await mock.module('./network/proxy', { + namedExports: { + getActiveProxyUrl: () => undefined, + }, + }) + await mock.module('./worker/workerManager', { + namedExports: { + closeWorkerAsync: async () => undefined, + }, + }) + await mock.module('./i18n', { + namedExports: { + t: (key: string, params?: Record) => `${key}${params?.version ? `:${params.version}` : ''}`, + }, + }) + + const mod = await import('./update.js') + return { + autoUpdater, + dialogCalls, + checkUpdate: mod.checkUpdate as (win: { webContents: { send: (...args: unknown[]) => void } }) => void, + manualCheckForUpdates: mod.manualCheckForUpdates as () => void, + } +} + +test('automatic stable updates download silently before showing install prompt', async () => { + const { autoUpdater, dialogCalls, checkUpdate, manualCheckForUpdates } = await loadUpdaterModule() + const sent: unknown[][] = [] + + checkUpdate({ + webContents: { + send: (...args: unknown[]) => sent.push(args), + }, + }) + + autoUpdater.emit('update-available', { version: '0.28.2' }) + await Promise.resolve() + + assert.equal(autoUpdater.downloadCalls, 1) + assert.equal(dialogCalls.length, 0) + assert.equal(autoUpdater.autoDownload, false) + assert.equal(autoUpdater.autoInstallOnAppQuit, false) + + autoUpdater.emit('update-downloaded', { version: '0.28.2' }) + await Promise.resolve() + + assert.equal(dialogCalls.length, 1) + assert.equal(dialogCalls[0]?.title, 'update.downloadComplete') + assert.equal(dialogCalls[0]?.message, 'update.readyToInstall') + + manualCheckForUpdates() + autoUpdater.emit('update-available', { version: '0.28.3' }) + await Promise.resolve() + + assert.equal(dialogCalls.length, 2) + assert.equal(dialogCalls[1]?.title, 'update.newVersionTitle:0.28.3') + assert.equal(dialogCalls[1]?.message, 'update.newVersionMessage:0.28.3') + assert.equal(autoUpdater.downloadCalls, 1) +}) diff --git a/apps/desktop/main/update.ts b/apps/desktop/main/update.ts index e910175b..499a0fdd 100644 --- a/apps/desktop/main/update.ts +++ b/apps/desktop/main/update.ts @@ -129,6 +129,26 @@ const checkUpdate = (win) => { // } let showUpdateMessageBox = false + let isDownloadingUpdate = false + + // 自动检查走静默下载;手动检查仍由用户确认后再下载。 + const startDownloadUpdate = (): void => { + if (isDownloadingUpdate) return + isDownloadingUpdate = true + autoUpdater + .downloadUpdate() + .then(() => { + console.log('wait for post download operation') + }) + .catch((downloadError) => { + // 下载失败记录到日志,不显示给用户 + logger.error(`[Update] Download update failed: ${downloadError}`) + }) + .finally(() => { + isDownloadingUpdate = false + }) + } + autoUpdater.on('update-available', (info) => { // win.webContents.send('show-message', 'electron:发现新版本') if (showUpdateMessageBox) return @@ -145,6 +165,12 @@ const checkUpdate = (win) => { return } + if (!isManualCheck) { + logger.info(`[Update] New version ${info.version} found, downloading silently`) + startDownloadUpdate() + return + } + showUpdateMessageBox = true dialog @@ -161,15 +187,7 @@ const checkUpdate = (win) => { .then((result) => { showUpdateMessageBox = false if (result.response === 0) { - autoUpdater - .downloadUpdate() - .then(() => { - console.log('wait for post download operation') - }) - .catch((downloadError) => { - // 下载失败记录到日志,不显示给用户 - logger.error(`[Update] Download update failed: ${downloadError}`) - }) + startDownloadUpdate() } }) })