From fbb3fb770f9a7bd31479b979d0af63d40e643d38 Mon Sep 17 00:00:00 2001 From: winewill Date: Thu, 24 Sep 2026 14:36:02 +0800 Subject: [PATCH] fix(desktop): cancel pending quit decisions during installer exit --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/src/main.ts | 5 +++-- apps/desktop/tests/main-startup.spec.ts | 24 +++++++++++++++++++++--- 5 files changed, 28 insertions(+), 9 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index dd50888d20..15cdaaf6eb 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -6,8 +6,8 @@ en: 45561fc0dd3a1b20 zh: f4b03355d4bd0693 /deepseek-harness-desktop/closing-the-window-and-quitting: - en: 709a7f63fbac58b6 - zh: 24f0f57997f88314 + en: fb852f3bd70d8602 + zh: 3d2b0a8d580d7f0a /deepseek-harness-desktop/key-technical-decisions: en: 069fdfd72d2ea26a zh: 76ae549d43c99bb4 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index e69942ef00..b6fb16c7bb 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -28,7 +28,7 @@ Windows keeps a tray icon for the whole run. Its tooltip is the product name, a Every ordinary quit entry — ⌘Q, the application menu, the Dock menu, the Windows tray and caption Application menu, and the quits caused by closing the mandatory-update or welcome window — asks the Host what it would interrupt. The Host answers over the private IPC channel with two facts: active tasks under the same rule as the update restart check (running agents including subagents and approval waits, queued messages, running or stopping jobs), and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded during this run. Neither fact, the quit proceeds silently. Otherwise a native message box without an owner window — hidden windows stay hidden — shows **Quit DeepSeek Harness?** with one of three localized explanations: running tasks will be interrupted, scheduled tasks will not run while the application is closed, or both. Quit is the default button and Cancel answers Esc; macOS places Cancel left of Quit, Windows places Quit left of Cancel, and the Windows task dialog shows the application icon and stays light regardless of the application theme. A Host that has not reached ready or has failed cannot run tasks, so the quit proceeds without asking. An inspection failure or a Host that misses the two-second deadline is treated as running tasks. While the box is open, another quit request joins it instead of stacking a second one (macOS also raises it; Electron exposes no handle to the Windows task dialog), the explanation does not change when work starts or ends, Quit stops the application without re-inspecting, and Cancel changes nothing. Cancelling a quit that closing the welcome window started shows the welcome window again. -The confirmation is skipped when the installer restart already confirmed task interruption, when the fatal-recovery dialog exits or restarts, when the development Restart App and Host command runs, and during operating-system shutdown, restart, or log-off: Windows sets this on the definitive session-end message; macOS sets it on the power-off notification, and because another application can still cancel that shutdown, the next focus or show of the main window clears it. A user-initiated update download that finishes while the window is hidden defers its Install and Restart confirmation until the window is shown again; the mandatory flow keeps its taskbar and Dock attention. The Windows installer and uninstaller tell a user whose application is still running to quit it from the system tray. Desktop does not enable scheduled tasks by default, so the scheduled-task explanations appear only once that feature is on; reminders fire only in loaded sessions, and unloaded sessions are neither counted nor resumed until they are opened. +The confirmation is skipped when the installer restart already confirmed task interruption, when the fatal-recovery dialog exits or restarts, when the development Restart App and Host command runs, and during operating-system shutdown, restart, or log-off: Windows sets this on the definitive session-end message; macOS sets it on the power-off notification, and because another application can still cancel that shutdown, the next focus or show of the main window clears it. Installer-owned quit cancels any pending ordinary quit decision; late inspection results and dialog answers cannot open another confirmation or repeat cleanup. A user-initiated update download that finishes while the window is hidden defers its Install and Restart confirmation until the window is shown again; the mandatory flow keeps its taskbar and Dock attention. The Windows installer and uninstaller tell a user whose application is still running to quit it from the system tray. Desktop does not enable scheduled tasks by default, so the scheduled-task explanations appear only once that feature is on; reminders fire only in loaded sessions, and unloaded sessions are neither counted nor resumed until they are opened. The tray renderer enlarges the whale by 20% around the tile center while preserving the background and aspect ratio; application and installer icons retain their original proportions. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 268b44f4ae..9db44494cd 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -28,7 +28,7 @@ Windows 在整个运行期间常驻托盘图标。悬停提示为产品名,单 所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭强制更新窗口或欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会并入同一弹框而不叠加新弹框(macOS 上还会把它提到前面;Electron 不暴露 Windows 任务对话框的句柄);任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。取消由关闭欢迎窗口引起的退出时,欢迎窗口会重新显示。 -以下情况跳过确认:安装更新的重启已确认过任务中断、致命错误恢复对话框中的退出或重启、开发版"重启应用与 Host"命令,以及操作系统关机、重启或注销:Windows 在确定性的会话结束消息上设置该状态;macOS 在关机通知上设置,而其他应用仍可能取消这次关机,因此主窗口下一次获得焦点或显示时会清除它。窗口隐藏期间完成的用户主动发起的更新下载,把"安装并重启"确认推迟到窗口再次显示时;强制更新流程沿用其任务栏和 Dock 提醒。Windows 安装程序和卸载程序在应用仍在运行时提示用户先在系统托盘中退出。Desktop 默认未开启定时任务,定时任务的说明只在该功能开启后出现;提醒只在已加载的会话中触发,未加载的会话既不计入,也要等到打开后才会继续。 +以下情况跳过确认:安装更新的重启已确认过任务中断、致命错误恢复对话框中的退出或重启、开发版"重启应用与 Host"命令,以及操作系统关机、重启或注销:Windows 在确定性的会话结束消息上设置该状态;macOS 在关机通知上设置,而其他应用仍可能取消这次关机,因此主窗口下一次获得焦点或显示时会清除它。安装器接管退出时会取消尚未结束的普通退出决策;晚到的查询结果和弹框答复不会再次打开确认框或重复清理。窗口隐藏期间完成的用户主动发起的更新下载,把"安装并重启"确认推迟到窗口再次显示时;强制更新流程沿用其任务栏和 Dock 提醒。Windows 安装程序和卸载程序在应用仍在运行时提示用户先在系统托盘中退出。Desktop 默认未开启定时任务,定时任务的说明只在该功能开启后出现;提醒只在已加载的会话中触发,未加载的会话既不计入,也要等到打开后才会继续。 托盘渲染器以底板中心为基准将鲸鱼放大 20%,保留背景和宽高比;应用和安装器图标保持原有比例。 diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 19816f7464..a59a8a8b35 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1203,6 +1203,7 @@ async function main(): Promise { app.on('before-quit', (event) => { if (shellInstallerOwnsQuit) { shuttingDown = true + quitConfirmation.dispose() backgroundNotice?.dispose() updateJournal?.action('quit-requested') tray?.dispose() @@ -1216,11 +1217,11 @@ async function main(): Promise { event.preventDefault() if (skipQuitConfirmation || sessionEnding) { finishQuit(); return } void quitConfirmation.confirm().then((approved) => { - if (quitting) return + if (quitting || shellInstallerOwnsQuit) return if (approved) { finishQuit(); return } // A quit that started from closing the welcome window destroyed it; a cancelled quit needs it back. if (!enteredWorkspace && !recovery.active) void showWelcome().catch((error: unknown) => { reportFatal(error, 'main') }) - }).catch((error: unknown) => { console.error(error); if (!quitting) finishQuit() }) + }).catch((error: unknown) => { console.error(error); if (!quitting && !shellInstallerOwnsQuit) finishQuit() }) }) mainWindow = createMainWindow() diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index b239837acd..e47edce02d 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -1660,13 +1660,25 @@ describe('desktop main startup', () => { await harness.handlers.get(MANDATORY_IPC.action)!(event, action, view.confirmation.version, view.confirmation.revision) } - it('closes the main window when the confirmed installer quits Electron', async () => { + it.each(['inspection', 'dialog'] as const)('closes the main window and cancels the pending quit %s when the installer quits Electron', async (pendingPhase) => { harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] } vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: { show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/', } }))) - const host = await readyForUpdate() + const host = await readyWorkspace() await harness.policyBlocked.promise + const inspected = Promise.withResolvers<{ activeTasks: boolean; scheduledTasks: boolean }>() + const answered = Promise.withResolvers() + host.inspectQuit.mockReturnValue(inspected.promise) + harness.dialog.showMessageBox.mockReturnValue(answered.promise) + harness.app.quit() + await vi.advanceTimersByTimeAsync(0) + expect(host.inspectQuit).toHaveBeenCalledOnce() + if (pendingPhase === 'dialog') { + inspected.resolve({ activeTasks: true, scheduledTasks: true }) + await vi.advanceTimersByTimeAsync(0) + expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce() + } const modal = harness.windows[0]! const preparing = harness.prepareUpdate() await answerMandatory('install') @@ -1678,9 +1690,15 @@ describe('desktop main startup', () => { harness.app.quit() await harness.quitCompleted.promise expect(modal.isDestroyed()).toBe(true) - expect(harness.app.quit).toHaveBeenCalledOnce() + expect(harness.app.quit).toHaveBeenCalledTimes(2) // The installer owns the exit, so Platform cleanup starts without holding the quit open. expect(harness.platformDispose).toHaveBeenCalledOnce() + inspected.resolve({ activeTasks: true, scheduledTasks: true }) + answered.resolve({ response: 0, checkboxChecked: false }) + await vi.advanceTimersByTimeAsync(0) + expect(harness.dialog.showMessageBox).toHaveBeenCalledTimes(pendingPhase === 'dialog' ? 1 : 0) + expect(harness.platformDispose).toHaveBeenCalledOnce() + expect(harness.app.quit).toHaveBeenCalledTimes(2) disposal.resolve() })