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.
This commit is contained in:
winewill authored and winewill committed 2026-09-24 14:24:39 +08:00
1 parent 4745934683
commit e6f135f15c
17 files changed
+174 -47

No files matched your search

@@ -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
@@ -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
@@ -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 上的确认框在任务对话框中显示应用图标,且始终为浅色,因为该系统控件不跟随应用主题。安装程序和卸载程序针对应用仍在运行的文案指向托盘。
## 备选方案
@@ -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
@@ -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
@@ -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)部分取代;这里的菜单声明仍然有效。
## 考虑过的替代方案
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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 默认未开启定时任务,定时任务的说明只在该功能开启后出现;提醒只在已加载的会话中触发,未加载的会话既不计入,也要等到打开后才会继续。
## 关键技术决策
+1 -1
View File
@@ -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 }
+1 -2
View File
@@ -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'
+21 -10
View File
@@ -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) }
}
}
+28 -12
View File
@@ -60,8 +60,8 @@ let focusPrimaryWindow = (): void => {}
let stopForRecovery = async (): Promise<void> => {}
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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() })
})
+12
View File
@@ -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<boolean> | 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<boolean> {
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<boolean> {
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
}
}
+18 -2
View File
@@ -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))
})
+50 -2
View File
@@ -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()
})
@@ -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<DesktopQuitInspection>()
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<MessageBoxReturnValue>()
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)
})
})