diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 4f2317b1ef..3778fa7c85 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 @@ -48,8 +48,8 @@ en: 8efe3498dbe8686d zh: a88c16f20a9430f3 /deepseek-harness-desktop/updates: - en: acbd12b8fc5ec5fd - zh: 031e15f931479f27 + 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 2821d2ed5a..5520b62967 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. @@ -354,6 +354,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 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. 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. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 8fc5ba35e0..66dc8e16e8 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`,以免被误当作发布产物。 @@ -356,6 +356,8 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up ## 更新 +Windows 下载完成后的更新确认说明应用会在安装期间关闭、完成后自动打开,并提示期间不要重复启动。安装器携带 `--updated` 重启应用并直接打开工作区时,壳会将主窗口前置并聚焦一次,不启用永久置顶。启动进入欢迎页时会清除此请求,使后续登录保留正常的激活行为。普通启动和其他平台不执行此前置步骤。 + 原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放背景模糊、输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。 打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留蒙层与背景模糊。 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/src/locale.ts b/apps/desktop/src/locale.ts index 5fbe23f7e6..7c589df70b 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -78,6 +78,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', @@ -211,6 +213,8 @@ export const zh = { updateDownload: '下载更新', updateDownloadedTitle: 'DeepSeek Harness v{version} 下载完成', updateDownloadedDetail: '安装包已下载完毕,点击“安装并重启”,即刻重启客户端,开始部署。', + updateDownloadedTitleWindows: '新版本 v{version} 已准备就绪', + updateDownloadedDetailWindows: '更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。', updateClose: '关闭', updateAcknowledge: '确定', updateLater: '稍后更新', @@ -299,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 63caff12ed..0b43146683 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' @@ -311,6 +311,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 @@ -527,11 +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(locale.messages.updateDownloadedTitle, { version: updates.state.version ?? '' }), - detail: active ? locale.messages.updateActiveTasksDetail - : 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, } @@ -979,6 +981,11 @@ async function main(): Promise { window.webContents.send(DESKTOP_IPC.enterWorkspace) } welcomeWindow = undefined + if (raiseAfterUpdate) { + raiseAfterUpdate = false + window.moveTop() + window.focus() + } if (activate && development && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') { window.webContents.openDevTools({ mode: 'detach' }) } @@ -1046,6 +1053,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/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/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/fixtures/update-dialogs.mjs b/apps/desktop/tests/fixtures/update-dialogs.mjs index e748f54db7..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,10 +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(messages.updateDownloadedTitle, { version: available.version }), - detail: active ? messages.updateActiveTasksDetail : 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 84b042e7f8..ea1e6c52b8 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -83,9 +83,12 @@ 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() + readonly setAlwaysOnTop = vi.fn() readonly restore = vi.fn() readonly setSize = vi.fn() readonly getBounds = vi.fn(() => ({ x: 0, y: 0, width: 800, height: 700 })) @@ -995,6 +998,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 window.shown.promise + 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') + const replacement = harness.windows[1]! + await replacement.shown.promise + 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]! 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() 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