From d2a5490fa0ba480cf31f08cf1496fb9a0a53c5f6 Mon Sep 17 00:00:00 2001 From: winewill Date: Wed, 23 Sep 2026 21:25:54 +0800 Subject: [PATCH 1/3] fix(desktop): clarify Windows updates and raise the restarted window --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 2 + apps/desktop/src/locale.ts | 4 ++ apps/desktop/src/main.ts | 13 ++++- .../expected/update-restart-darwin-en-US.json | 7 +++ .../expected/update-restart-darwin-zh-CN.json | 7 +++ .../expected/update-restart-win32-en-US.json | 7 +++ .../expected/update-restart-win32-zh-CN.json | 7 +++ .../desktop/tests/fixtures/update-dialogs.mjs | 7 ++- apps/desktop/tests/main-startup.spec.ts | 47 +++++++++++++++++++ 11 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/tests/expected/update-restart-darwin-en-US.json create mode 100644 apps/desktop/tests/expected/update-restart-darwin-zh-CN.json create mode 100644 apps/desktop/tests/expected/update-restart-win32-en-US.json create mode 100644 apps/desktop/tests/expected/update-restart-win32-zh-CN.json diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index d25e3c5ef8..05acf88242 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: ec3f2f44d5e49db00bd595588a0594bcee0a1538 -README.zh.md: 50fba340a0edc114a75d4b6363b8e6c42be0c45f +README.md: 56d91711cdc83fcfd947aacdaff4beed07a31aa1 +README.zh.md: 990ea2087b005228dc223c24cacb9cb7e71a6efe diff --git a/apps/desktop/README.md b/apps/desktop/README.md index ec3f2f44d5..56d91711cd 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -344,6 +344,8 @@ An unpacked artifact contains Electron, the materialized dsh production tree, pn ## Updates +On Windows, the downloaded-update confirmation explains that the application closes during installation, reopens automatically, and should not be launched again while updating. An installer restart carrying `--updated` raises and focuses the main window once after the workspace opens, without enabling always-on-top. Ordinary launches and other platforms do not use this foregrounding step. + Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the backdrop and background blur. `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 50fba340a0..990ea2087b 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -344,6 +344,8 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up ## 更新 +Windows 下载完成后的更新确认说明应用会在安装期间关闭、完成后自动打开,并提示期间不要重复启动。安装器携带 `--updated` 重启应用时,壳会在工作区打开后将主窗口前置并聚焦一次,不启用永久置顶。普通启动和其他平台不执行此前置步骤。 + 打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留蒙层与背景模糊。 `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。 diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 7a1c935a8d..27ff27c714 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -75,6 +75,8 @@ export const en = { updateDownload: 'Download update', updateDownloadedTitle: 'DeepSeek Harness v{version} downloaded', updateDownloadedDetail: 'The update package has downloaded. Select “Install and Restart” to restart the app and begin installation.', + updateDownloadedTitleWindows: 'New version v{version} is ready', + updateDownloadedDetailWindows: 'The app will close temporarily during the update and reopen automatically when it is complete.\n\nThe update may take some time. Please wait and do not launch the app again during installation.', updateClose: 'Close', updateAcknowledge: 'OK', updateLater: 'Update later', @@ -205,6 +207,8 @@ export const zh = { updateDownload: '下载更新', updateDownloadedTitle: 'DeepSeek Harness v{version} 下载完成', updateDownloadedDetail: '安装包已下载完毕,点击“安装并重启”,即刻重启客户端,开始部署。', + updateDownloadedTitleWindows: '新版本 v{version} 已准备就绪', + updateDownloadedDetailWindows: '更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。', updateClose: '关闭', updateAcknowledge: '确定', updateLater: '稍后更新', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 6690a3c69c..76f799030b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -308,6 +308,8 @@ async function main(): Promise { let mainWindow: BrowserWindow | undefined let welcomeWindow: BrowserWindow | undefined let enteredWorkspace = false + // NSIS passes --updated when it launches the application after installation. + let raiseAfterUpdate = process.platform === 'win32' && process.argv.includes('--updated') let shellInstallerOwnsQuit = false let requireCleanStop = false let updateStoppedHost = false @@ -510,9 +512,11 @@ async function main(): Promise { const active = await host.updateTasks('inspect') const confirmation: Electron.MessageBoxOptions = { type: active ? 'warning' : 'info', title: locale.messages.updateTitle, - message: active ? locale.messages.updateActiveTasks : formatDesktopMessage(locale.messages.updateDownloadedTitle, { version: updates.state.version ?? '' }), + message: active ? locale.messages.updateActiveTasks : formatDesktopMessage( + process.platform === 'win32' ? locale.messages.updateDownloadedTitleWindows : locale.messages.updateDownloadedTitle, + { version: updates.state.version ?? '' }), detail: active ? locale.messages.updateActiveTasksDetail - : locale.messages.updateDownloadedDetail, + : process.platform === 'win32' ? locale.messages.updateDownloadedDetailWindows : locale.messages.updateDownloadedDetail, buttons: active ? [locale.messages.updateStopTasks, locale.messages.updateLater] : [locale.messages.installAndRestart], defaultId: 1, cancelId: 1, } @@ -940,6 +944,11 @@ async function main(): Promise { window.webContents.send(DESKTOP_IPC.enterWorkspace) } welcomeWindow = undefined + if (raiseAfterUpdate) { + raiseAfterUpdate = false + window.moveTop() + window.focus() + } if (development && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') { window.webContents.openDevTools({ mode: 'detach' }) } diff --git a/apps/desktop/tests/expected/update-restart-darwin-en-US.json b/apps/desktop/tests/expected/update-restart-darwin-en-US.json new file mode 100644 index 0000000000..599f39fa68 --- /dev/null +++ b/apps/desktop/tests/expected/update-restart-darwin-en-US.json @@ -0,0 +1,7 @@ +{ + "message": "DeepSeek Harness v0.1.99 downloaded", + "detail": "The update package has downloaded. Select “Install and Restart” to restart the app and begin installation.", + "buttons": [ + "Install and Restart" + ] +} diff --git a/apps/desktop/tests/expected/update-restart-darwin-zh-CN.json b/apps/desktop/tests/expected/update-restart-darwin-zh-CN.json new file mode 100644 index 0000000000..d7ea2daa57 --- /dev/null +++ b/apps/desktop/tests/expected/update-restart-darwin-zh-CN.json @@ -0,0 +1,7 @@ +{ + "message": "DeepSeek Harness v0.1.99 下载完成", + "detail": "安装包已下载完毕,点击“安装并重启”,即刻重启客户端,开始部署。", + "buttons": [ + "安装并重启" + ] +} diff --git a/apps/desktop/tests/expected/update-restart-win32-en-US.json b/apps/desktop/tests/expected/update-restart-win32-en-US.json new file mode 100644 index 0000000000..0e8af77ca2 --- /dev/null +++ b/apps/desktop/tests/expected/update-restart-win32-en-US.json @@ -0,0 +1,7 @@ +{ + "message": "New version v0.1.99 is ready", + "detail": "The app will close temporarily during the update and reopen automatically when it is complete.\n\nThe update may take some time. Please wait and do not launch the app again during installation.", + "buttons": [ + "Install and Restart" + ] +} diff --git a/apps/desktop/tests/expected/update-restart-win32-zh-CN.json b/apps/desktop/tests/expected/update-restart-win32-zh-CN.json new file mode 100644 index 0000000000..104f40b266 --- /dev/null +++ b/apps/desktop/tests/expected/update-restart-win32-zh-CN.json @@ -0,0 +1,7 @@ +{ + "message": "新版本 v0.1.99 已准备就绪", + "detail": "更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。", + "buttons": [ + "安装并重启" + ] +} diff --git a/apps/desktop/tests/fixtures/update-dialogs.mjs b/apps/desktop/tests/fixtures/update-dialogs.mjs index e748f54db7..0282a26c75 100644 --- a/apps/desktop/tests/fixtures/update-dialogs.mjs +++ b/apps/desktop/tests/fixtures/update-dialogs.mjs @@ -40,8 +40,11 @@ export async function qualifyUpdateDialogs(root, fixture) { for (const active of [false, true]) { const options = { title: messages.updateTitle, - message: active ? messages.updateActiveTasks : formatDesktopMessage(messages.updateDownloadedTitle, { version: available.version }), - detail: active ? messages.updateActiveTasksDetail : messages.updateDownloadedDetail, + message: active ? messages.updateActiveTasks : formatDesktopMessage( + process.platform === 'win32' ? messages.updateDownloadedTitleWindows : messages.updateDownloadedTitle, + { version: available.version }), + detail: active ? messages.updateActiveTasksDetail + : process.platform === 'win32' ? messages.updateDownloadedDetailWindows : messages.updateDownloadedDetail, buttons: active ? [messages.updateStopTasks, messages.updateLater] : [messages.installAndRestart], cancelId: 1, } f.restart(async () => (await dialogs.show(parent, options)).response === 0) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 9cd00f74c4..9daa99d93e 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -78,6 +78,8 @@ const harness = await vi.hoisted(async () => { readonly show = vi.fn() readonly hide = vi.fn() readonly focus = vi.fn() + readonly moveTop = vi.fn() + readonly setAlwaysOnTop = vi.fn() readonly restore = vi.fn() readonly setSize = vi.fn() readonly setTitleBarOverlay = vi.fn() @@ -932,6 +934,51 @@ describe('desktop main startup', () => { return harness.hosts[0]! } + it.each([ + ['win32', ['--updated'], true], + ['win32', [], false], + ['darwin', ['--updated'], false], + ['linux', ['--updated'], false], + ] as const)('raises the first workspace only for a Windows installer restart (%s, %j)', async (platform, args, raises) => { + vi.stubGlobal('process', { ...process, platform, argv: ['desktop', ...args] }) + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + expect(window.moveTop).not.toHaveBeenCalled() + harness.prepared.resolve() + await harness.hostStarted.promise + harness.hosts[0]!.ready.resolve() + await vi.advanceTimersByTimeAsync(0) + expect(window.show).toHaveBeenCalledOnce() + expect(window.moveTop).toHaveBeenCalledTimes(raises ? 1 : 0) + expect(window.focus).toHaveBeenCalledTimes(raises ? 1 : 0) + expect(window.setAlwaysOnTop).not.toHaveBeenCalled() + if (raises) { + expect(window.show.mock.invocationCallOrder[0]).toBeLessThan(window.moveTop.mock.invocationCallOrder[0]!) + } + window.destroy() + harness.app.emit('second-instance') + await vi.advanceTimersByTimeAsync(0) + const replacement = harness.windows[1]! + expect(replacement.show).toHaveBeenCalledOnce() + expect(replacement.moveTop).not.toHaveBeenCalled() + expect(replacement.setAlwaysOnTop).not.toHaveBeenCalled() + }) + + it.each([ + ['win32', 'zh-CN'], ['win32', 'en-US'], ['darwin', 'zh-CN'], ['darwin', 'en-US'], + ] as const)('records the restart confirmation on %s in %s', async (platform, language) => { + vi.stubGlobal('process', { ...process, platform }) + vi.spyOn(harness.app, 'getPreferredSystemLanguages').mockReturnValue([language]) + await readyForUpdate() + harness.updateState = { phase: 'ready', version: '0.1.99' } + harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 1 }) + await expect(harness.prepareUpdate()).resolves.toBe(false) + const { message, detail, buttons } = harness.dialog.showMessageBox.mock.lastCall![0] as MessageBoxOptions + await expect(JSON.stringify({ message, detail, buttons }, null, 2) + '\n') + .toMatchFileSnapshot(`./expected/update-restart-${platform}-${language}.json`) + }) + it('hides the workspace before intentional Host shutdown can look like reconnection', async () => { const host = await readyForUpdate() const window = harness.windows[0]! From a67119845d4f1cc0b4baf552aaa41300f245464a Mon Sep 17 00:00:00 2001 From: winewill Date: Thu, 24 Sep 2026 01:17:20 +0800 Subject: [PATCH 2/3] fix(desktop): align update fixtures and clear deferred startup focus --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/src/locale.ts | 18 ++++++++++++++++++ apps/desktop/src/main.ts | 12 ++++++------ apps/desktop/tests/fixtures/update-dialogs.mjs | 10 ++++------ .../tests/fixtures/workspace-updates.mjs | 7 ++++--- apps/desktop/tests/main-startup.spec.ts | 7 ++++--- apps/desktop/tests/welcome-startup.spec.ts | 15 ++++++++++++++- 9 files changed, 54 insertions(+), 23 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 934c13fa16..7bfcfed3a5 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -48,8 +48,8 @@ en: 8efe3498dbe8686d zh: a88c16f20a9430f3 /deepseek-harness-desktop/updates: - en: 3165f47019b67cda - zh: 73e209b372e59d43 + en: a40f7aa2b00ed6fb + zh: d852a2d9de576dc5 /deepseek-harness-desktop/updates/mandatory-update-policy: en: 2f789a4949fafb12 zh: d8b28c01cfee11b7 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 26516b5628..c1175659fe 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -354,7 +354,7 @@ An unpacked artifact contains Electron, the materialized dsh production tree, pn ## Updates -On Windows, the downloaded-update confirmation explains that the application closes during installation, reopens automatically, and should not be launched again while updating. An installer restart carrying `--updated` raises and focuses the main window once after the workspace opens, without enabling always-on-top. Ordinary launches and other platforms do not use this foregrounding step. +On Windows, the downloaded-update confirmation explains that the application closes during installation, reopens automatically, and should not be launched again while updating. An installer restart carrying `--updated` raises and focuses the main window once when startup opens the workspace directly, without enabling always-on-top. Startup into the welcome page discards that request so a later login keeps its normal activation behavior. Ordinary launches and other platforms do not use this foregrounding step. Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Closing an overlay releases its background blur, input interception, and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 55dd003a7e..16f8ddbee7 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -356,7 +356,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up ## 更新 -Windows 下载完成后的更新确认说明应用会在安装期间关闭、完成后自动打开,并提示期间不要重复启动。安装器携带 `--updated` 重启应用时,壳会在工作区打开后将主窗口前置并聚焦一次,不启用永久置顶。普通启动和其他平台不执行此前置步骤。 +Windows 下载完成后的更新确认说明应用会在安装期间关闭、完成后自动打开,并提示期间不要重复启动。安装器携带 `--updated` 重启应用并直接打开工作区时,壳会将主窗口前置并聚焦一次,不启用永久置顶。启动进入欢迎页时会清除此请求,使后续登录保留正常的激活行为。普通启动和其他平台不执行此前置步骤。 原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放背景模糊、输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。 diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index bd27dbe54d..7c589df70b 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -303,3 +303,21 @@ export function formatDesktopMessage( ): string { return message.replaceAll(/\{([^{}]+)\}/gu, (placeholder, key: string) => values[key] ?? placeholder) } + +/** + * Select localized copy for an ordinary downloaded-update confirmation. + * @param messages - Selected Desktop dictionary. + * @param version - Prepared update version without a leading v. + * @param platform - Operating system presenting the confirmation. + * @returns The versioned title and installation guidance. + */ +export function desktopUpdateReadyConfirmation( + messages: DesktopMessages, + version: string, + platform: string, +): { message: string; detail: string } { + return { + message: formatDesktopMessage(platform === 'win32' ? messages.updateDownloadedTitleWindows : messages.updateDownloadedTitle, { version }), + detail: platform === 'win32' ? messages.updateDownloadedDetailWindows : messages.updateDownloadedDetail, + } +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2e3aa65904..b10ff30716 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -28,7 +28,7 @@ import { installDesktopDirectoryPicker } from './directory-picker.ts' import { installMicrophonePermissions } from './microphone-permissions.ts' import { DesktopBackendController } from './backend-controller.ts' import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts' -import { formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale } from './locale.ts' +import { desktopUpdateReadyConfirmation, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale } from './locale.ts' import { claimDesktopSingleInstance } from './single-instance.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts' @@ -529,13 +529,11 @@ async function main(): Promise { const host = backend.host if (host === undefined) throw new DesktopUpdatePreparationError('tasks-unavailable', locale.messages.updateTasksUnavailable) const active = await host.updateTasks('inspect') + const ready = desktopUpdateReadyConfirmation(locale.messages, updates.state.version ?? '', process.platform) const confirmation: Electron.MessageBoxOptions = { type: active ? 'warning' : 'info', title: locale.messages.updateTitle, - message: active ? locale.messages.updateActiveTasks : formatDesktopMessage( - process.platform === 'win32' ? locale.messages.updateDownloadedTitleWindows : locale.messages.updateDownloadedTitle, - { version: updates.state.version ?? '' }), - detail: active ? locale.messages.updateActiveTasksDetail - : process.platform === 'win32' ? locale.messages.updateDownloadedDetailWindows : locale.messages.updateDownloadedDetail, + message: active ? locale.messages.updateActiveTasks : ready.message, + detail: active ? locale.messages.updateActiveTasksDetail : ready.detail, buttons: active ? [locale.messages.updateStopTasks, locale.messages.updateLater] : [locale.messages.installAndRestart], defaultId: 1, cancelId: 1, } @@ -1053,6 +1051,8 @@ async function main(): Promise { windowsLanguage = locale.id refreshApplicationMenu() if (!enteredWorkspace && needsWelcome({ loggedIn: state.loggedIn, hasApiKey: state.hasApiKey })) { + // A later login must retain its own activation policy instead of replaying startup focus. + raiseAfterUpdate = false await showWelcome() } else { await enterWorkspace() diff --git a/apps/desktop/tests/fixtures/update-dialogs.mjs b/apps/desktop/tests/fixtures/update-dialogs.mjs index 0282a26c75..9cb07be7c9 100644 --- a/apps/desktop/tests/fixtures/update-dialogs.mjs +++ b/apps/desktop/tests/fixtures/update-dialogs.mjs @@ -6,7 +6,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { app, BrowserWindow, protocol } from 'electron' import { DesktopUpdateDialog } from '../../lib/types/update-dialog.js' -import { formatDesktopMessage, resolveDesktopLocale } from '../../lib/types/locale.js' +import { desktopUpdateReadyConfirmation, resolveDesktopLocale } from '../../lib/types/locale.js' async function rendered(window) { await window.webContents.executeJavaScript(`new Promise((resolve, reject) => { @@ -38,13 +38,11 @@ export async function qualifyUpdateDialogs(root, fixture) { await f.coordinator.download(available.version) assert.equal(f.installations.length, 0) for (const active of [false, true]) { + const ready = desktopUpdateReadyConfirmation(messages, available.version, process.platform) const options = { title: messages.updateTitle, - message: active ? messages.updateActiveTasks : formatDesktopMessage( - process.platform === 'win32' ? messages.updateDownloadedTitleWindows : messages.updateDownloadedTitle, - { version: available.version }), - detail: active ? messages.updateActiveTasksDetail - : process.platform === 'win32' ? messages.updateDownloadedDetailWindows : messages.updateDownloadedDetail, + message: active ? messages.updateActiveTasks : ready.message, + detail: active ? messages.updateActiveTasksDetail : ready.detail, buttons: active ? [messages.updateStopTasks, messages.updateLater] : [messages.installAndRestart], cancelId: 1, } f.restart(async () => (await dialogs.show(parent, options)).response === 0) diff --git a/apps/desktop/tests/fixtures/workspace-updates.mjs b/apps/desktop/tests/fixtures/workspace-updates.mjs index 51e930cb2f..b136c79ce0 100644 --- a/apps/desktop/tests/fixtures/workspace-updates.mjs +++ b/apps/desktop/tests/fixtures/workspace-updates.mjs @@ -11,7 +11,7 @@ import updaterModule from 'electron-updater' import { createUpdateServer } from './update-server.mjs' import { fixture } from './workspace-update-adapters.mjs' import { DesktopUpdateHttpExecutor } from '../../lib/types/update-http-executor.js' -import { resolveDesktopLocale } from '../../lib/types/locale.js' +import { desktopUpdateReadyConfirmation, resolveDesktopLocale } from '../../lib/types/locale.js' const root = process.env.DSH_WORKSPACE_UPDATE_ROOT assert.ok(root) @@ -252,7 +252,8 @@ async function qualify() { assert.equal(BrowserWindow.getAllWindows().some(window => window.webContents.getURL() === 'dsh-app://shell/update-dialog.html'), false) await screenshot(mainWindow, 'downloading.png') server.release() - const ready = await dialogWith(messages.updateDownloadedTitle.replace('{version}', '0.1.6-nightly.1')) + const confirmation = desktopUpdateReadyConfirmation(messages, '0.1.6-nightly.1', process.platform) + const ready = await dialogWith(confirmation.message) assert.equal(fixture.installations.length, 0) await screenshot(ready, 'install-confirmation.png') cases.push('sidebar-retry-direct-download-and-separate-install-dialog') @@ -337,7 +338,7 @@ async function qualify() { await waitFor(() => fixture.host !== stoppedHost && fixture.readyHosts.has(fixture.host), 'replacement Host after non-graceful shutdown') await waitFor(async () => !await fixture.host.updateTasks('inspect'), 'replacement Host ready without active work') await press(mainWindow, `document.querySelector('button[data-error="true"]')`) - const retryInstall = await dialogWith(messages.updateDownloadedTitle.replace('{version}', '0.1.6-nightly.1')) + const retryInstall = await dialogWith(confirmation.message) assert.equal(fixture.installations.length, 0) await screenshot(retryInstall, 'recovered-install-confirmation.png') await press(retryInstall, `document.getElementById('close')`) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 255cfe9e5d..a52cd60e0b 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -77,7 +77,8 @@ const harness = await vi.hoisted(async () => { if (channel === 'dsh-desktop:mandatory-state' && state.policy?.blocking) policyBlocked.resolve() }), }) - readonly show = vi.fn() + readonly shown = deferred() + readonly show = vi.fn(() => { this.shown.resolve() }) readonly hide = vi.fn() readonly focus = vi.fn() readonly moveTop = vi.fn() @@ -989,7 +990,7 @@ describe('desktop main startup', () => { harness.prepared.resolve() await harness.hostStarted.promise harness.hosts[0]!.ready.resolve() - await vi.advanceTimersByTimeAsync(0) + await window.shown.promise expect(window.show).toHaveBeenCalledOnce() expect(window.moveTop).toHaveBeenCalledTimes(raises ? 1 : 0) expect(window.focus).toHaveBeenCalledTimes(raises ? 1 : 0) @@ -999,8 +1000,8 @@ describe('desktop main startup', () => { } window.destroy() harness.app.emit('second-instance') - await vi.advanceTimersByTimeAsync(0) const replacement = harness.windows[1]! + await replacement.shown.promise expect(replacement.show).toHaveBeenCalledOnce() expect(replacement.moveTop).not.toHaveBeenCalled() expect(replacement.setAlwaysOnTop).not.toHaveBeenCalled() diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 0629083a8a..6e1f469eef 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -26,6 +26,7 @@ const state = vi.hoisted(() => ({ showWorkspace: vi.fn(), showInactiveWorkspace: vi.fn(), focusWorkspace: vi.fn(), + moveTopWorkspace: vi.fn(), openDevTools: vi.fn(), closeWelcome: vi.fn(), welcomeLocale: undefined as DesktopLocale | undefined, @@ -77,6 +78,7 @@ vi.mock('electron', () => ({ isMinimized() { return false } restore = vi.fn() focus = state.focusWorkspace + moveTop = state.moveTopWorkspace hide = vi.fn() show = state.showWorkspace showInactive = state.showInactiveWorkspace @@ -157,10 +159,18 @@ afterEach(() => { vi.clearAllTimers() vi.useRealTimers() vi.unstubAllEnvs() + vi.unstubAllGlobals() vi.restoreAllMocks() }) -it('starts the Host for welcome onboarding and opens the workspace on skip without quitting', async () => { +it.each([false, true])('starts welcome onboarding without carrying update focus into login or skip (Windows update=%s)', async (updated) => { + vi.resetModules() + vi.clearAllMocks() + state.preference = 'zh' + state.hasApiKey = false + state.operations = undefined + state.accountState.mockResolvedValue({ status: 'signed-out', attempt: null, links: { usageUrl: '', topUpUrl: '' } }) + if (updated) vi.stubGlobal('process', { ...process, platform: 'win32', argv: ['desktop', '--updated'] }) vi.useFakeTimers() vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', '/development-profile') vi.stubEnv('DSH_DESKTOP_NODE_BINARY', '/runtime/node') @@ -217,6 +227,8 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho await state.operations!.skip() expect(state.loadWorkspace).not.toHaveBeenCalled() expect(state.showWorkspace).toHaveBeenCalledOnce() + expect(state.moveTopWorkspace).not.toHaveBeenCalled() + expect(state.focusWorkspace).not.toHaveBeenCalled() expect(state.windowOptions).toMatchObject({ ...(process.platform === 'darwin' ? { titleBarStyle: 'hiddenInset', trafficLightPosition: { x: 16, y: 18 }, vibrancy: 'sidebar' } : {}), webPreferences: { contextIsolation: true, sandbox: true }, @@ -271,6 +283,7 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho await vi.waitFor(() => { expect(state.showInactiveWorkspace).toHaveBeenCalledOnce() }) expect(state.showWorkspace).not.toHaveBeenCalled() expect(state.focusWorkspace).not.toHaveBeenCalled() + expect(state.moveTopWorkspace).not.toHaveBeenCalled() expect(state.openDevTools).not.toHaveBeenCalled() state.appListeners.get('open-url')!({ preventDefault: vi.fn() }, 'dsh://open') expect(state.showWorkspace).toHaveBeenCalledOnce() From 037bed1f41c3bfdf34605d6ddda01ce43e3bd6d5 Mon Sep 17 00:00:00 2001 From: winewill Date: Thu, 24 Sep 2026 11:49:01 +0800 Subject: [PATCH 3/3] fix(desktop): reject running app before installer extraction --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/installer/lifecycle.nsh | 1 + apps/desktop/scripts/installer.nsh | 4 ++ .../tests/expected/windows-installer.json | 5 +- .../desktop/tests/windows-installer-smoke.ps1 | 50 ++++++++++--------- 7 files changed, 38 insertions(+), 30 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 7bfcfed3a5..c94707b758 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -33,8 +33,8 @@ en: f461b255133384cc zh: 2b9db718f22fdd04 /deepseek-harness-desktop/package/runtime-file-selection: - en: 7d72469be4975afe - zh: 2f644b582cb581be + en: f0e345fd89868803 + zh: fd2c775ad23fa2f0 /deepseek-harness-desktop/package/upload-updates: en: c64d7fe2fe2534b7 zh: 2188e1ae99ab4b33 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index c1175659fe..a1aa5b4549 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -205,7 +205,7 @@ Windows release qualification also runs [directory and replacement checks](scrip pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir -FrameLibrary apps/desktop/.desktop-build/targets/win-x64/installer-ui/window-frame.dll ``` -The Windows installer extracts the new version beside the installation directory, stops the old application, and replaces directories through same-volume renames. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow. +The Windows installer checks for a running application at startup and after destination selection, before extracting the new version beside the installation directory. It checks again before replacing directories through same-volume renames. A running application blocks installation; update launches allow up to ten seconds for it to exit. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow. When extraction fails, the installer writes a report with the 7-Zip result and its complete error output to the updater cache directory as `%LOCALAPPDATA%\-updater\installer-logs\extract-failure-.log` (currently `@deepseek-aidsh-desktop-updater`) and shows the first error line with a **Copy error details** button; silent installs write only the report. Unsigned Windows builds (`DSH_DESKTOP_UNSIGNED=1`) name their installer `deepseek-harness--win-x64-unsigned.exe` so they cannot be mistaken for release artifacts. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 16f8ddbee7..238efc80ac 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -207,7 +207,7 @@ Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检 pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir -FrameLibrary apps/desktop/.desktop-build/targets/win-x64/installer-ui/window-frame.dll ``` -Windows 安装器先将新版本解压到安装目录旁边,再退出旧应用并通过同卷目录改名完成替换。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。 +Windows 安装器在启动时和选定目标目录后检查应用是否正在运行,通过检查后才将新版本解压到安装目录旁边。通过同卷目录改名替换前,安装器会再次检查。运行中的应用会阻止安装;更新启动允许等待应用退出,最长十秒。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。 解压失败时,安装器会把 7-Zip 的结果和完整错误输出写入更新缓存目录 `%LOCALAPPDATA%\<按包名派生>-updater\installer-logs\extract-failure-<时间戳>.log`(当前为 `@deepseek-aidsh-desktop-updater`),并在弹窗中显示首条错误行和 **复制错误信息** 按钮;静默安装只写入报告。未签名的 Windows 构建(`DSH_DESKTOP_UNSIGNED=1`)会将安装包命名为 `deepseek-harness-<版本>-win-x64-unsigned.exe`,以免被误当作发布产物。 diff --git a/apps/desktop/installer/lifecycle.nsh b/apps/desktop/installer/lifecycle.nsh index 452c4d2662..525fde4080 100644 --- a/apps/desktop/installer/lifecycle.nsh +++ b/apps/desktop/installer/lifecycle.nsh @@ -50,6 +50,7 @@ Function InstallerBeforeInstall SetErrorLevel 2 Quit ${EndIf} + Call InstallerCheckAppRunning FunctionEnd Function InstallerProgressShow diff --git a/apps/desktop/scripts/installer.nsh b/apps/desktop/scripts/installer.nsh index 5980287859..0f4d859e78 100644 --- a/apps/desktop/scripts/installer.nsh +++ b/apps/desktop/scripts/installer.nsh @@ -23,6 +23,9 @@ ManifestDPIAware true !include "${INSTALLER_SOURCE_DIR}\theme.nsh" !include "${INSTALLER_SOURCE_DIR}\pages.nsh" !include "${INSTALLER_SOURCE_DIR}\lifecycle.nsh" + Function InstallerCheckAppRunning + !insertmacro customCheckAppRunning + FunctionEnd !endif !macroend @@ -63,6 +66,7 @@ ManifestDPIAware true File "/oname=$PLUGINSDIR\brand-dark.bmp" "${INSTALLER_BUILD_DIR}\brand-dark.bmp" File "/oname=$PLUGINSDIR\brand-dark-2x.bmp" "${INSTALLER_BUILD_DIR}\brand-dark-2x.bmp" File "/oname=$PLUGINSDIR\window-frame.dll" "${INSTALLER_BUILD_DIR}\window-frame.dll" + Call InstallerCheckAppRunning ${If} ${Silent} Call InstallerPreflight ${If} $InstallerError != "" diff --git a/apps/desktop/tests/expected/windows-installer.json b/apps/desktop/tests/expected/windows-installer.json index a785e81b47..031352a046 100644 --- a/apps/desktop/tests/expected/windows-installer.json +++ b/apps/desktop/tests/expected/windows-installer.json @@ -10,8 +10,9 @@ "registered-directory-with-trailing-separators", "registered-directory-and-checked-launch", "launch-failure-retry-and-prompt-dismissal", - "progress-remains-monotonic-across-native-resets", - "running-app-preserved-and-native-progress-hidden", + "running-app-rejected-before-welcome-and-extraction", + "silent-running-app-rejected-before-extraction", + "app-started-on-welcome-rejected-before-extraction", "silent-update-retains-directory-and-ignores-unrelated-process", "invalid-destination-rejection", "uninstall" diff --git a/apps/desktop/tests/windows-installer-smoke.ps1 b/apps/desktop/tests/windows-installer-smoke.ps1 index b4e6ff9e6c..67a8aa38df 100644 --- a/apps/desktop/tests/windows-installer-smoke.ps1 +++ b/apps/desktop/tests/windows-installer-smoke.ps1 @@ -77,6 +77,7 @@ function Finish-Setup([Diagnostics.Process]$Process, [bool]$Launch, [string]$The while ([InstallerCapture]::FindButton($Process.Id, $copy.INSTALLER_FINISH) -eq [IntPtr]::Zero) { if ($Process.HasExited -or $timer.Elapsed.TotalSeconds -gt 30) { throw 'Installer did not complete' } $visible = [InstallerCapture]::VisibleText($Process.Id) + if ($visible.Contains('msctls_progress32') -ne $expected.nativeProgressVisible) { throw 'Stock green progress bar is visible' } if ($visible -match 'HarnessInstallerProgress[^\r\n]*?(\d+)%') { $percent = [int]$Matches[1] if ($percent -lt $previous) { throw 'Installation progress went backwards' } @@ -181,33 +182,34 @@ try { $results.Add('registered-directory-and-checked-launch') $results.Add('launch-failure-retry-and-prompt-dismissal') - $process = Start-Setup dark - Click-Control $process $copy.INSTALLER_INSTALL - [void](Wait-Control $process $copy.INSTALLER_RUNNING -Dialog) - $visible = [InstallerCapture]::VisibleText($process.Id) - if ($visible.Contains('msctls_progress32') -ne $expected.nativeProgressVisible) { throw 'Stock green progress bar is visible' } - if (-not $visible.Contains('HarnessInstallerProgress')) { throw 'Custom progress page is missing' } - $window = [InstallerCapture]::Find($process.Id) - $source = [InstallerCapture]::FindClass($window, 'msctls_progress32') - if ($source -eq [IntPtr]::Zero) { throw 'Stock progress source is missing' } - # Directory staging finishes before the running-app prompt; promotion has not started. - if ([InstallerCapture]::GetProp($window, 'HarnessInstaller.Stage').ToInt32() -ne 1) { throw 'Running-app prompt reached the wrong installation stage' } - $previous = [InstallerCapture]::Progress($window) - foreach ($sample in @(@(100, 95), @(100, 59), @(1000, 0), @(1000, 950), @(100, 59), @(100, 100))) { - [void][InstallerCapture]::SendMessage($source, 0x406, [IntPtr]::Zero, [IntPtr]$sample[0]) - [void][InstallerCapture]::SendMessage($source, 0x402, [IntPtr]$sample[1], [IntPtr]::Zero) - $percent = [InstallerCapture]::Progress($window) - if ($percent -lt $previous -or $percent -ge 100) { throw "Progress regressed or completed before success: $previous -> $percent" } - $previous = $percent + function Assert-RunningRejected([Diagnostics.Process]$Setup) { + [void](Wait-Control $Setup $copy.INSTALLER_RUNNING -Dialog) + $visible = [InstallerCapture]::VisibleText($Setup.Id) + if ($visible.Contains('HarnessInstallerProgress')) { throw 'Running-app rejection started progress UI' } + if (Get-ChildItem -LiteralPath $OutputDirectory -Directory -Filter 'Installed App.new-*') { throw 'Running-app rejection staged payload files' } + Dismiss $Setup $copy.INSTALLER_RUNNING + if (-not $Setup.WaitForExit(10000) -or $Setup.ExitCode -ne 2 -or $app.HasExited) { throw 'Running application was not preserved' } } - if ($previous -gt 94) { throw 'Internal progress escaped the extraction stage' } - $results.Add('progress-remains-monotonic-across-native-resets') - [void][InstallerCapture]::Save([InstallerCapture]::Find($process.Id), (Join-Path $OutputDirectory 'dark-progress.png')) - Dismiss $process $copy.INSTALLER_RUNNING - if (-not $process.WaitForExit(10000) -or $app.HasExited) { throw 'Running application was not preserved' } + $process = Start-Process -FilePath $Installer -ArgumentList ('/THEME=dark /D=' + $installPath) -PassThru -WindowStyle Hidden + $processes.Add($process) + Assert-RunningRejected $process + $results.Add('running-app-rejected-before-welcome-and-extraction') + Run-Silent ('/S /D=' + $installPath) 2 + if ($app.HasExited) { throw 'Silent rejection stopped the running application' } + if (Get-ChildItem -LiteralPath $OutputDirectory -Directory -Filter 'Installed App.new-*') { throw 'Silent rejection staged payload files' } + $results.Add('silent-running-app-rejected-before-extraction') Dismiss $app 'Installer test application is running.' if (-not $app.WaitForExit(10000)) { throw 'Test application did not exit' } - $results.Add('running-app-preserved-and-native-progress-hidden') + + $process = Start-Setup dark + $app = Start-Process -FilePath $appPath -PassThru -WindowStyle Hidden + $processes.Add($app) + [void](Wait-Control $app 'Installer test application is running.' -Dialog) + Click-Control $process $copy.INSTALLER_INSTALL + Assert-RunningRejected $process + Dismiss $app 'Installer test application is running.' + if (-not $app.WaitForExit(10000)) { throw 'Test application did not exit' } + $results.Add('app-started-on-welcome-rejected-before-extraction') $otherPath = Join-Path $OutputDirectory 'Other Installation' New-Item -ItemType Directory -Path $otherPath | Out-Null