From e6f135f15cf11ee1a8f3b3f9d0b258504426ae30 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:16:03 +0800 Subject: [PATCH] fix(desktop): harden quit bypass and background-notice bookkeeping after review - Only the definitive Windows session-end message bypasses the quit confirmation; the macOS power-off flag clears on the next focus or show because another application can cancel that shutdown. - A bypassing quit disposes the pending confirmation so a late inspection result opens no box and an answered box cannot approve. - Cancelling a quit that closing the welcome window started shows the welcome window again. - The first-hide notice records its marker only after delivery and withdraws it on failure; the Windows quit dialog uses the multi-size ICO. - windowShown settles when the awaited window is destroyed. - Cross-link the partially superseded window-menus note; tidy docs. --- ...background-and-quit-confirmation.i18n.yaml | 4 +- ...ose-to-background-and-quit-confirmation.md | 4 +- ...-to-background-and-quit-confirmation.zh.md | 4 +- .../2026-09-16-desktop-window-menus.i18n.yaml | 4 +- .../2026-09-16-desktop-window-menus.md | 2 +- .../2026-09-16-desktop-window-menus.zh.md | 2 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 6 +-- apps/desktop/README.zh.md | 6 +-- apps/desktop/electron-builder.config.d.mts | 2 +- apps/desktop/scripts/render-tray-icon.ts | 3 +- apps/desktop/src/background-notice.ts | 31 +++++++---- apps/desktop/src/main.ts | 40 +++++++++----- apps/desktop/src/quit-confirmation.ts | 12 +++++ apps/desktop/tests/background-notice.spec.ts | 20 ++++++- apps/desktop/tests/main-startup.spec.ts | 52 ++++++++++++++++++- apps/desktop/tests/quit-confirmation.spec.ts | 25 +++++++++ 17 files changed, 174 insertions(+), 47 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.i18n.yaml index 0b6d5b54fc..19175415de 100644 --- a/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.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 .agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.md -2026-09-23-desktop-close-to-background-and-quit-confirmation.md: 972edca121d00a39bc0f3439e8493d460a3b4095 -2026-09-23-desktop-close-to-background-and-quit-confirmation.zh.md: 3f43fa3d6051c92e89a2c420e18be13650d51c4b +2026-09-23-desktop-close-to-background-and-quit-confirmation.md: b26b040613f154185b7b98436d30cb4fb0171f46 +2026-09-23-desktop-close-to-background-and-quit-confirmation.zh.md: e4391a49a8079316bc2111b90344f3bcdad5800c diff --git a/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.md b/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.md index 972edca121..b26b040613 100644 --- a/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.md +++ b/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-09-23-desktop-close-to-background-and-quit-confirmation.zh.md) -Window timing and shutdown ownership otherwise follow [showing the window before the Host starts](2026-09-09-desktop-immediate-window-and-direct-start.md); the task rule reuses the [update restart check](2026-08-25-electron-desktop-packaging-and-updates.md). +Window timing and shutdown ownership otherwise follow [showing the window before the Host starts](2026-09-09-desktop-immediate-window-and-direct-start.md); the task rule reuses the [update restart check](2026-08-25-electron-desktop-packaging-and-updates.md). This partially supersedes the [standard macOS window menus](../bug-fix/2026-09-16-desktop-window-menus.md) decision: ⌘W no longer destroys the window through Electron's role, and Dock activation reopens the hidden window when no window is visible instead of only when none exists. That note's menu declarations stay in force. ## Problem @@ -16,7 +16,7 @@ Closing the main window hides it on both platforms; the page and the Host keep r Every ordinary quit entry asks the Host over the private IPC channel for two facts: active tasks under the update-restart rule, and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded in this run. Both absent, the quit proceeds silently; otherwise a native message box without an owner window shows one of three fixed explanations with Quit as the default and Cancel on Esc. Repeated quit requests join the open box, the copy is frozen while it is open, and approval does not re-inspect. A Host that is not ready cannot run tasks and the quit proceeds; an inspection failure or a missed two-second deadline counts as running tasks because a needless prompt is cheaper than a silent interruption. The installer restart, fatal recovery, the development restart command, and operating-system session end skip the confirmation. -The Windows tray bitmaps are rendered from the vector icon at seven sizes and committed as an ICO; the confirmation on Windows uses the application icon in a task dialog and stays light because the control does not follow the application theme. The installer and uninstaller copy for a running application now points at the tray. +The Windows tray bitmaps are rendered from the vector icon at seven sizes and committed as an ICO; the confirmation on Windows uses the application icon in a task dialog and stays light because the control does not follow the application theme. The installer and uninstaller copy for a running application points at the tray. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.zh.md b/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.zh.md index 3f43fa3d60..e4391a49a8 100644 --- a/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-09-23-desktop-close-to-background-and-quit-confirmation.md) | 中文 -窗口时序和关闭归属仍遵循[在 Host 启动前显示窗口](2026-09-09-desktop-immediate-window-and-direct-start.zh.md);任务判定复用[更新重启检查](2026-08-25-electron-desktop-packaging-and-updates.zh.md)。 +窗口时序和关闭归属仍遵循[在 Host 启动前显示窗口](2026-09-09-desktop-immediate-window-and-direct-start.zh.md);任务判定复用[更新重启检查](2026-08-25-electron-desktop-packaging-and-updates.zh.md)。本记录部分取代[标准 macOS 窗口菜单](../bug-fix/2026-09-16-desktop-window-menus.zh.md)决策:⌘W 不再通过 Electron 的 role 销毁窗口,Dock 激活在没有可见窗口时重新打开隐藏窗口,而不再只在没有任何窗口时才创建。该记录中的菜单声明仍然有效。 ## 问题 @@ -16,7 +16,7 @@ Status: implemented 所有普通退出入口都通过私有 IPC 通道向 Host 询问两项事实:按更新重启口径判定的运行中任务,以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出;否则弹出一个没有父窗口的原生消息框,显示三种固定说明之一,"退出"为默认按钮,Esc 对应"取消"。重复的退出请求并入已打开的弹框,弹框打开期间文案冻结,批准后不再重新查询。Host 未就绪时不可能有任务在跑,直接退出;查询失败或超过两秒截止时间按运行中任务处理,因为多提示一次的代价低于无声中断。安装更新的重启、致命错误恢复、开发版重启命令和操作系统会话结束跳过确认。 -Windows 托盘位图由矢量图标按七种尺寸分别渲染并提交为 ICO;Windows 上的确认框在任务对话框中显示应用图标,且始终为浅色,因为该系统控件不跟随应用主题。安装程序和卸载程序针对应用仍在运行的文案改为指向托盘。 +Windows 托盘位图由矢量图标按七种尺寸分别渲染并提交为 ICO;Windows 上的确认框在任务对话框中显示应用图标,且始终为浅色,因为该系统控件不跟随应用主题。安装程序和卸载程序针对应用仍在运行的文案指向托盘。 ## 备选方案 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml index eb76836a1c..fdf261af26 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml @@ -9,8 +9,8 @@ en: 75c201a0a687b59d zh: 34593038f673d06f /agent-note-desktop-standard-macos-window-menus/decision: - en: 1797b5695e873e5a - zh: 3376fda4fc36aaae + en: 0ce30c3a1e6202d7 + zh: badac0021fae4a6f /agent-note-desktop-standard-macos-window-menus/alternatives-considered: en: 957382dbfb6cac48 zh: 2db98b7dbf3e01c0 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md index 34eff83707..d8f8bdb9cc 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md @@ -12,7 +12,7 @@ The Desktop shell replaces Electron's default application menu with a custom tem On macOS the template declares `{ role: 'windowMenu' }` after the Edit menu, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus. The [page-close decision](../architecture/2026-09-21-desktop-page-close-shortcuts.md) owns the custom File menu and close behavior. -Electron supplies English defaults for the Window and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). Minimize and hide execute through Electron's roles without custom handlers. +Electron supplies English defaults for the Window and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). Minimize and hide execute through Electron's roles without custom handlers. Close and activation behavior is partly superseded by [close-to-background](../architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.md); the menu declarations here remain in effect. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md index c3cb3e5d89..c85ab23df1 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md @@ -12,7 +12,7 @@ Desktop shell 用自定义模板替换了 Electron 的默认应用菜单,该 macOS 上的模板在 Edit 菜单之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。[页面关闭决策](../architecture/2026-09-21-desktop-page-close-shortcuts.zh.md)负责自定义 File 菜单和关闭行为。 -Electron 为 Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。最小化和隐藏通过 Electron 的 role 执行,没有自定义处理器。 +Electron 为 Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。最小化和隐藏通过 Electron 的 role 执行,没有自定义处理器。 关闭与激活行为已被[关闭时隐藏窗口](../architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.zh.md)部分取代;这里的菜单声明仍然有效。 ## 考虑过的替代方案 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 0bf366669c..891c77512b 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: eff1938598576ffa - zh: 2bcb2e0dec0139cc + en: 41636fae7eef4348 + zh: 28f0a7b2f170a911 /deepseek-harness-desktop/key-technical-decisions: en: 069fdfd72d2ea26a zh: 76ae549d43c99bb4 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 665d7f9a0e..c4a9e0e1ce 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -24,11 +24,11 @@ Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Sh Closing the main window (macOS close button and ⌘W; Windows ×, Alt+F4, and the taskbar Close window command) hides it without confirmation. The page and the Host keep running, tasks continue, and the next show presents the same document with its session, drafts, and scroll position; a fullscreen macOS window leaves fullscreen before hiding. The window returns through the Dock icon, a second launch, or `dsh://open` on macOS, and through the tray on Windows. Minimize is unchanged. Closing the welcome window before the workspace opens quits on Windows and, on macOS, keeps the application in the Dock without a window. -Windows keeps a tray icon for the whole run. Its tooltip is the product name, a single click shows and focuses the window, and the context menu offers Open DeepSeek Harness and Quit DeepSeek Harness in the shell locale. The first hide of each installation posts one silent system notification saying the application is still running; clicking it opens the window. The marker lives under Electron's userData, which in-place updates keep and the uninstaller removes, so a reinstall shows the notice again. The tray bitmaps are `resources/tray-windows.ico`, rendered from `resources/icon-windows.svg` at 16, 20, 24, 32, 40, 48, and 64 pixels by `pnpm run render:tray-icon`, and packaged as `resources/tray.ico`. macOS ships no menu bar icon. +Windows keeps a tray icon for the whole run. Its tooltip is the product name, a single click shows and focuses the window, and the context menu offers Open DeepSeek Harness and Quit DeepSeek Harness in the shell locale. The first hide of each installation posts one silent system notification saying the application is still running; clicking it opens the window. The marker is written once the notice reaches the system and removed again on a delivery failure, so an unsupported or failed notification retries on a later launch. It lives under Electron's userData, which in-place updates keep and the uninstaller removes, so a reinstall shows the notice again. The tray bitmaps are `resources/tray-windows.ico`, rendered from `resources/icon-windows.svg` at 16, 20, 24, 32, 40, 48, and 64 pixels by `pnpm run render:tray-icon`, and packaged as `resources/tray.ico`. macOS ships no menu bar icon. -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 raises it instead of stacking a second one, the explanation does not change when work starts or ends, Quit stops the application without re-inspecting, and Cancel changes nothing. +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 (macOS power-off notification, Windows session-end messages). 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. 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. ## Key technical decisions diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 294dfdb5af..ceeffcb911 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -24,11 +24,11 @@ Desktop Host 的 Platform API 请求与更新策略请求用相同的 Platform 关闭主窗口(macOS 的关闭按钮和 ⌘W;Windows 的 ×、Alt+F4 和任务栏"关闭窗口")只隐藏窗口,不弹确认。页面和 Host 继续运行,任务不受影响,下次显示时仍是原来的文档,会话、草稿和滚动位置都保留;macOS 全屏窗口先退出全屏再隐藏。macOS 通过 Dock 图标、再次启动或 `dsh://open` 找回窗口,Windows 通过托盘找回。最小化行为不变。进入工作区前关闭欢迎窗口,Windows 上走退出流程,macOS 上应用留在 Dock 中且没有窗口。 -Windows 在整个运行期间常驻托盘图标。悬停提示为产品名,单击显示并聚焦窗口,右键菜单提供壳语言下的"打开 DeepSeek Harness"和"退出 DeepSeek Harness"。每次安装后第一次隐藏窗口时发送一条静音系统通知,说明应用仍在运行;点击通知打开窗口。标记文件位于 Electron 的 userData 下,应用内更新保留它,卸载程序删除它,因此重装后会再提示一次。托盘位图是 `resources/tray-windows.ico`,由 `pnpm run render:tray-icon` 从 `resources/icon-windows.svg` 按 16、20、24、32、40、48、64 像素分别渲染,打包为 `resources/tray.ico`。macOS 不提供菜单栏图标。 +Windows 在整个运行期间常驻托盘图标。悬停提示为产品名,单击显示并聚焦窗口,右键菜单提供壳语言下的"打开 DeepSeek Harness"和"退出 DeepSeek Harness"。每次安装后第一次隐藏窗口时发送一条静音系统通知,说明应用仍在运行;点击通知打开窗口。标记文件在通知交给系统后才写入,投递失败时再删除,因此系统不支持或投递失败的通知会在之后的启动中重试。它位于 Electron 的 userData 下,应用内更新保留它,卸载程序删除它,因此重装后会再提示一次。托盘位图是 `resources/tray-windows.ico`,由 `pnpm run render:tray-icon` 从 `resources/icon-windows.svg` 按 16、20、24、32、40、48、64 像素分别渲染,打包为 `resources/tray.ico`。macOS 不提供菜单栏图标。 -所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭强制更新窗口或欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会把它提到前面而不叠加新弹框;任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。 +所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭强制更新窗口或欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会并入同一弹框而不叠加新弹框(macOS 上还会把它提到前面;Electron 不暴露 Windows 任务对话框的句柄);任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。取消由关闭欢迎窗口引起的退出时,欢迎窗口会重新显示。 -以下情况跳过确认:安装更新的重启已确认过任务中断、致命错误恢复对话框中的退出或重启、开发版"重启应用与 Host"命令,以及操作系统关机、重启或注销(macOS 关机通知、Windows 会话结束消息)。窗口隐藏期间完成的用户主动发起的更新下载,把"安装并重启"确认推迟到窗口再次显示时;强制更新流程沿用其任务栏和 Dock 提醒。Windows 安装程序和卸载程序在应用仍在运行时提示用户先在系统托盘中退出。Desktop 默认未开启定时任务,定时任务的说明只在该功能开启后出现;提醒只在已加载的会话中触发,未加载的会话既不计入,也要等到打开后才会继续。 +以下情况跳过确认:安装更新的重启已确认过任务中断、致命错误恢复对话框中的退出或重启、开发版"重启应用与 Host"命令,以及操作系统关机、重启或注销:Windows 在确定性的会话结束消息上设置该状态;macOS 在关机通知上设置,而其他应用仍可能取消这次关机,因此主窗口下一次获得焦点或显示时会清除它。窗口隐藏期间完成的用户主动发起的更新下载,把"安装并重启"确认推迟到窗口再次显示时;强制更新流程沿用其任务栏和 Dock 提醒。Windows 安装程序和卸载程序在应用仍在运行时提示用户先在系统托盘中退出。Desktop 默认未开启定时任务,定时任务的说明只在该功能开启后出现;提醒只在已加载的会话中触发,未加载的会话既不计入,也要等到打开后才会继续。 ## 关键技术决策 diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index 85a6b9acca..3ccec5b190 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -21,7 +21,7 @@ export interface DesktopElectronBuilderConfig { readonly extraResources: readonly [ { readonly from: string, readonly to: 'runtime' }, { readonly from: string, readonly to: 'icon.png' }, - ...({ readonly from: string, readonly to: 'tray.ico' } | never)[], + ...{ readonly from: string, readonly to: 'tray.ico' }[], ] readonly mac: { readonly extendInfo: { readonly NSMicrophoneUsageDescription: string } diff --git a/apps/desktop/scripts/render-tray-icon.ts b/apps/desktop/scripts/render-tray-icon.ts index 3365661105..ce9fc6824d 100644 --- a/apps/desktop/scripts/render-tray-icon.ts +++ b/apps/desktop/scripts/render-tray-icon.ts @@ -5,8 +5,7 @@ * bundled bitmaps by display scale. Each size is rasterized from the vector * source separately instead of downscaling one large bitmap, which keeps edges * crisp at every scale. The committed `resources/tray-windows.ico` is the output; - * rerun `pnpm --filter @deepseek-ai/dsh-desktop exec tsx scripts/render-tray-icon.ts` - * after changing the vector source. + * rerun `pnpm run render:tray-icon` in `apps/desktop` after changing the vector source. */ import { readFile, writeFile } from 'node:fs/promises' diff --git a/apps/desktop/src/background-notice.ts b/apps/desktop/src/background-notice.ts index 814c8cc6cb..78ea2e1004 100644 --- a/apps/desktop/src/background-notice.ts +++ b/apps/desktop/src/background-notice.ts @@ -1,6 +1,6 @@ /** One-time Windows notice that closing the window left the application running in the tray. */ -import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' import { Notification } from 'electron' import type { DesktopLocale } from './locale.ts' @@ -8,8 +8,8 @@ import type { DesktopLocale } from './locale.ts' /** Marker location and the actions the notice can trigger. */ export interface DesktopBackgroundNoticeOptions { /** - * Marker written the first time the notice is shown. Lives under Electron's userData, which the - * uninstaller removes and in-place updates keep, so each installation sees the notice once. + * Marker written once the notice has been handed to the system. Lives under Electron's userData, + * which the uninstaller removes and in-place updates keep, so each installation sees the notice once. */ readonly markerPath: string readonly locale: () => DesktopLocale @@ -26,17 +26,15 @@ export class DesktopBackgroundNotice { constructor(private readonly options: DesktopBackgroundNoticeOptions) {} /** - * Show the notice after the first hide of this installation. System notification permissions or a - * focus mode can suppress it; the window stays hidden and the tray stays available either way. + * Show the notice after the first hide of this installation. The marker is written only after the + * notice reaches the system, and a delivery failure removes it again, so an unsupported or failed + * notification retries on a later launch. A focus mode can still swallow a delivered notice; the + * window stays hidden and the tray stays available either way. */ show(): void { if (this.shown) return this.shown = true if (existsSync(this.options.markerPath)) return - try { - mkdirSync(dirname(this.options.markerPath), { recursive: true }) - writeFileSync(this.options.markerPath, '') - } catch (error) { console.warn('desktop tray: could not record the background notice', error) } try { if (!Notification.isSupported()) return const { messages } = this.options.locale() @@ -45,6 +43,7 @@ export class DesktopBackgroundNotice { notification.on('failed', () => { if (this.notification === notification) this.notification = undefined notification.removeAllListeners() + this.forget() }) notification.once('click', () => { if (this.notification !== notification) return @@ -53,6 +52,18 @@ export class DesktopBackgroundNotice { this.options.open() }) notification.show() - } catch (error) { console.warn('desktop tray: background notice unavailable', error) } + } catch (error) { + console.warn('desktop tray: background notice unavailable', error) + return + } + try { + mkdirSync(dirname(this.options.markerPath), { recursive: true }) + writeFileSync(this.options.markerPath, '') + } catch (error) { console.warn('desktop tray: could not record the background notice', error) } + } + + private forget(): void { + try { rmSync(this.options.markerPath, { force: true }) } + catch (error) { console.warn('desktop tray: could not reset the background notice', error) } } } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 43df5427f9..2823879069 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -60,8 +60,8 @@ let focusPrimaryWindow = (): void => {} let stopForRecovery = async (): Promise => {} let shuttingDown = false /** - * Set by quit entries that must not ask: crash recovery, the development restart - * command, and the installer handoff. Ordinary quits inspect the Host first. + * Set by quit entries that must not ask: crash recovery exit and restart, and the + * development restart command. The installer handoff has its own before-quit branch. */ let skipQuitConfirmation = false let windowsLanguage: string | undefined @@ -339,7 +339,11 @@ async function main(): Promise { let mandatoryUI: DesktopMandatoryUpdateWindow | undefined let policyAuth: DesktopPolicyTestAuth | undefined let tray: DesktopTray | undefined - /** The operating system is shutting down or logging off: windows close and the quit skips its confirmation. */ + /** + * The operating system is ending the session: the quit skips its confirmation. Windows sets it + * on the definitive session-end message. macOS sets it on the power-off notification, which + * another application can still cancel, so the next focus or show of the main window clears it. + */ let sessionEnding = false const isQuitting = (): boolean => quitting const currentMainWindow = (): BrowserWindow | undefined => mainWindow @@ -607,6 +611,7 @@ async function main(): Promise { const window = currentDialogWindow() if (window === undefined || window.isDestroyed() || window.isVisible()) { resolve(); return } window.once('show', () => { resolve() }) + window.once('closed', () => { resolve() }) }) protocol.handle(SCHEME, (request) => { @@ -923,13 +928,12 @@ async function main(): Promise { tray?.relabel() } refreshApplicationMenu() + const trayIconPath = development ? join(app.getAppPath(), 'resources', 'tray-windows.ico') : join(process.resourcesPath, 'tray.ico') if (process.platform === 'win32') { // The tray is the way back to a hidden window; without it, relaunching the application still focuses it. try { - tray = new DesktopTray({ - iconPath: development ? join(app.getAppPath(), 'resources', 'tray-windows.ico') : join(process.resourcesPath, 'tray.ico'), - locale: currentDesktopLocale, open: () => { focusPrimaryWindow() }, quit: () => { app.quit() }, - }) + tray = new DesktopTray({ iconPath: trayIconPath, locale: currentDesktopLocale, + open: () => { focusPrimaryWindow() }, quit: () => { app.quit() } }) } catch (error) { console.warn('desktop tray: unavailable', error) } } const backgroundNotice = process.platform === 'win32' @@ -941,9 +945,12 @@ async function main(): Promise { inspect: () => backend.host?.inspectQuit(), // No owner window: a hidden window stays hidden and the native box is its own top-level window. show: options => dialog.showMessageBox(options), - // macOS can raise the open alert with the application; the Windows task dialog owns its own activation. + // macOS raises the open alert with the application; Electron exposes no handle to the Windows task + // dialog, so a repeated request there only joins the open decision. focus: () => { if (process.platform === 'darwin') app.focus({ steal: true }) }, - ...(process.platform === 'win32' ? { icon: nativeImage.createFromPath(applicationIconPath) } : {}), + // The task dialog draws its main icon at the system icon size; the multi-size ICO yields that + // size directly, where the 1024 px PNG would be scaled down by GDI. + ...(process.platform === 'win32' ? { icon: nativeImage.createFromPath(trayIconPath) } : {}), }) if (process.platform === 'win32') { @@ -1016,9 +1023,14 @@ async function main(): Promise { hideMainWindow(window) }) if (process.platform === 'win32') { - // Shutdown, restart, and log-off must not wait on a confirmation. - window.on('query-session-end', () => { sessionEnding = true }) + // Shutdown, restart, and log-off must not wait on a confirmation. query-session-end is only a + // question that another application can veto without any follow-up message, so it does not count. window.on('session-end', () => { sessionEnding = true }) + } else { + // The macOS power-off notification arrives before the terminate request; a cancelled shutdown + // leaves the process running, and user attention on the window shows the session continues. + window.on('focus', () => { sessionEnding = false }) + window.on('show', () => { sessionEnding = false }) } window.on('closed', () => { if (mainWindow === window) mainWindow = undefined }) window.webContents.on('console-message', (details) => { @@ -1170,6 +1182,7 @@ async function main(): Promise { quitting = true shuttingDown = true updateJournal?.action('quit-requested') + quitConfirmation.dispose() tray?.dispose() stopAccount?.() if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.hide() @@ -1197,7 +1210,10 @@ async function main(): Promise { event.preventDefault() if (skipQuitConfirmation || sessionEnding) { finishQuit(); return } void quitConfirmation.confirm().then((approved) => { - if (approved && !quitting) finishQuit() + if (quitting) 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() }) }) diff --git a/apps/desktop/src/quit-confirmation.ts b/apps/desktop/src/quit-confirmation.ts index f89befcd37..25c97ad9e5 100644 --- a/apps/desktop/src/quit-confirmation.ts +++ b/apps/desktop/src/quit-confirmation.ts @@ -37,6 +37,7 @@ export interface DesktopQuitConfirmationOptions { /** One quit decision at a time; repeated quit requests join the open confirmation instead of stacking. */ export class DesktopQuitConfirmation { private pending: Promise | undefined + private disposed = false /** @param options - Locale, inspection, and native dialog collaborators. */ constructor(private readonly options: DesktopQuitConfirmationOptions) {} @@ -47,6 +48,7 @@ export class DesktopQuitConfirmation { * @returns true to quit now, false when the user cancelled. */ confirm(): Promise { + if (this.disposed) return Promise.resolve(false) if (this.pending !== undefined) { this.options.focus() return this.pending @@ -56,6 +58,13 @@ export class DesktopQuitConfirmation { return pending } + /** + * The application is quitting through a path that does not ask: a pending decision resolves to + * false without opening a box, and later requests do the same. An already open native box has no + * close API and disappears with the process. + */ + dispose(): void { this.disposed = true } + private async decide(): Promise { const inspection = this.options.inspect() if (inspection === undefined) return true @@ -63,6 +72,7 @@ export class DesktopQuitConfirmation { console.warn('desktop quit: task inspection unavailable', error) return 'unknown' as const })) + if (this.disposed) return false if (prompt === undefined) return true const { messages } = this.options.locale() const windows = (this.options.platform ?? process.platform) === 'win32' @@ -79,6 +89,8 @@ export class DesktopQuitConfirmation { cancelId: 1, noLink: true, }) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- A bypassing quit can dispose while the box is open. + if (this.disposed) return false return result.response === 0 } } diff --git a/apps/desktop/tests/background-notice.spec.ts b/apps/desktop/tests/background-notice.spec.ts index 8b8d05b350..e9dcf3c0bf 100644 --- a/apps/desktop/tests/background-notice.spec.ts +++ b/apps/desktop/tests/background-notice.spec.ts @@ -66,16 +66,32 @@ it('still notifies once in this process when the marker cannot be written', () = expect(native.notices).toHaveLength(1) }) -it('does nothing visible without notification support or after a delivery failure', () => { +it('keeps the notice for a later launch when notifications are unsupported or delivery fails', () => { native.Notice.isSupported.mockReturnValue(false) const f = setup() f.notice.show() + f.notice.show() expect(native.notices).toHaveLength(0) - expect(existsSync(f.markerPath)).toBe(true) + expect(existsSync(f.markerPath)).toBe(false) native.Notice.isSupported.mockReturnValue(true) const g = setup() g.notice.show() + expect(existsSync(g.markerPath)).toBe(true) native.notices[0]!.emit('failed') + expect(existsSync(g.markerPath)).toBe(false) native.notices[0]!.emit('click') expect(g.open).not.toHaveBeenCalled() + // One attempt per process even after the marker was withdrawn. + g.notice.show() + expect(native.notices).toHaveLength(1) +}) + +it('does not record a notice the system refused to construct', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const f = setup() + native.Notice.isSupported.mockImplementation(() => { throw new Error('toast host unavailable') }) + f.notice.show() + expect(native.notices).toHaveLength(0) + expect(existsSync(f.markerPath)).toBe(false) + expect(console.warn).toHaveBeenCalledWith('desktop tray: background notice unavailable', expect.any(Error)) }) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 8b9a5c609b..4846582702 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -1133,6 +1133,48 @@ describe('desktop main startup', () => { expect(window.show).toHaveBeenCalledOnce() }) + it('skips the confirmation during a macOS shutdown but asks again once a cancelled shutdown returns focus', async () => { + vi.stubGlobal('process', { ...process, platform: 'darwin', arch: 'arm64', resourcesPath: 'desktop-test-resources' }) + const host = await readyWorkspace() + const window = harness.windows[0]! + host.inspectQuit.mockResolvedValue({ activeTasks: true, scheduledTasks: false }) + harness.dialog.showMessageBox.mockResolvedValue({ response: 1, checkboxChecked: false }) + harness.powerMonitor.emit('shutdown') + // Another application vetoed the shutdown; the user comes back to the window. + window.emit('focus') + window.close() + expect(window.isDestroyed()).toBe(false) + harness.app.quit() + await vi.advanceTimersByTimeAsync(0) + expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce() + expect(host.stop).not.toHaveBeenCalled() + harness.powerMonitor.emit('shutdown') + harness.app.quit() + await vi.advanceTimersByTimeAsync(0) + expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce() + await host.stopping.promise + host.exited.resolve() + await harness.quitCompleted.promise + }) + + it('drops a pending confirmation when a bypassing quit starts first', async () => { + harness.app.isPackaged = false + const host = await readyWorkspace() + const inspected = Promise.withResolvers<{ activeTasks: boolean; scheduledTasks: boolean }>() + host.inspectQuit.mockReturnValue(inspected.promise) + harness.app.quit() + await vi.advanceTimersByTimeAsync(0) + const restart = applicationMenuItems().find(item => item.label === en.restartAppHostMenu)! + ;(restart as { click: () => void }).click() + await vi.advanceTimersByTimeAsync(0) + inspected.resolve({ activeTasks: true, scheduledTasks: true }) + await vi.advanceTimersByTimeAsync(0) + expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() + await host.stopping.promise + host.exited.resolve() + await harness.quitCompleted.promise + }) + it('asks before quitting when the Host reports interruptible work and cancels without stopping anything', async () => { const host = await readyWorkspace() const window = harness.windows[0]! @@ -1187,8 +1229,13 @@ describe('desktop main startup', () => { const host = await readyWorkspace() const window = harness.windows[0]! host.inspectQuit.mockResolvedValue({ activeTasks: true, scheduledTasks: false }) + // The session-end question alone proves nothing: another application can veto it silently. window.emit('query-session-end', { reasons: ['shutdown'] }) window.close() + expect(window.isDestroyed()).toBe(false) + expect(window.hide).toHaveBeenCalledOnce() + window.emit('session-end', { reasons: ['shutdown'] }) + window.close() expect(window.isDestroyed()).toBe(true) // Electron follows the last closed window with window-all-closed, which quits on Windows. harness.app.quit() @@ -1813,13 +1860,14 @@ describe('desktop main startup', () => { expect(window.urls).toEqual(['dsh-app://app/']) }) - it('ignores clean renderer exits and exits of a closed window', async () => { + it('ignores clean renderer exits and exits of a destroyed window', async () => { await import('../src/main.ts') await harness.preparing.promise const window = harness.windows[0]! window.webContents.emit('render-process-gone', {}, { reason: 'clean-exit' }) - window.close() + window.destroy() window.webContents.emit('render-process-gone', {}, { reason: 'crashed' }) + await vi.advanceTimersByTimeAsync(0) expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() }) diff --git a/apps/desktop/tests/quit-confirmation.spec.ts b/apps/desktop/tests/quit-confirmation.spec.ts index 0e485db186..e7843f6b71 100644 --- a/apps/desktop/tests/quit-confirmation.spec.ts +++ b/apps/desktop/tests/quit-confirmation.spec.ts @@ -85,4 +85,29 @@ describe('DesktopQuitConfirmation', () => { expect(f.confirmation.confirm()).not.toBe(first) expect(inspect).toHaveBeenCalledTimes(2) }) + + it('resolves a pending decision to false without a box once disposed, and refuses later requests', async () => { + const inspected = Promise.withResolvers() + const f = setup('darwin', () => inspected.promise) + const pending = f.confirmation.confirm() + f.confirmation.dispose() + inspected.resolve({ activeTasks: true, scheduledTasks: false }) + expect(await pending).toBe(false) + expect(f.shown).toEqual([]) + expect(await f.confirmation.confirm()).toBe(false) + expect(f.focus).not.toHaveBeenCalled() + }) + + it('does not approve a quit from a box answered after disposal', async () => { + const answered = Promise.withResolvers() + const confirmation = new DesktopQuitConfirmation({ + locale: () => resolveDesktopLocale('en'), inspect: async () => ({ activeTasks: true, scheduledTasks: false }), + focus: () => {}, platform: 'darwin', show: () => answered.promise, + }) + const pending = confirmation.confirm() + await Promise.resolve() + confirmation.dispose() + answered.resolve({ response: 0, checkboxChecked: false }) + expect(await pending).toBe(false) + }) })