From 474593468347d06f4d614b6e0b7d270827dd82c3 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:43:45 +0800 Subject: [PATCH 1/6] feat(desktop): hide the window on close and confirm interruptible quits Closing the main window hides it on macOS and Windows; the page and the Host keep running and the next show resumes the same document. Windows gets a permanent tray icon (open / quit) and a one-time notification the first time the window is hidden after installation. Every ordinary quit entry asks the Host over a new quit-inspection IPC request for active tasks (same rule as the update restart check) and armed scheduled reminders (schedule family of workspace/session-activity for loaded sessions). Nothing to interrupt quits silently; otherwise a native message box explains what stops, with Quit as the default and Cancel on Esc. Inspection failure or a missed two-second deadline counts as running tasks. The installer restart, fatal recovery, the development restart command, and OS session end skip the confirmation. Also: defer the downloaded-update confirmation while the window is hidden, render the tray ICO from the vector icon, and point the installer's running-application message at the tray. Closes #4013 --- ...background-and-quit-confirmation.i18n.yaml | 6 + ...ose-to-background-and-quit-confirmation.md | 35 ++++ ...-to-background-and-quit-confirmation.zh.md | 35 ++++ apps/desktop-host/package.json | 4 +- apps/desktop-host/src/index.ts | 23 ++- apps/desktop-host/src/quit-inspection.ts | 40 +++++ apps/desktop-host/src/update-tasks.ts | 21 ++- .../tests/quit-inspection.spec.ts | 103 +++++++++++ apps/desktop-host/tsconfig.json | 2 + apps/desktop/README.i18n.yaml | 7 +- apps/desktop/README.md | 12 +- apps/desktop/README.zh.md | 12 +- apps/desktop/electron-builder.config.d.mts | 1 + apps/desktop/installer/strings.nsh | 4 +- apps/desktop/package.json | 2 + apps/desktop/resources/tray-windows.ico | Bin 0 -> 13132 bytes .../scripts/electron-builder-config.mjs | 2 + apps/desktop/scripts/render-tray-icon.ts | 119 +++++++++++++ apps/desktop/src/background-notice.ts | 58 +++++++ apps/desktop/src/host-process.ts | 68 ++++++-- apps/desktop/src/locale.ts | 18 ++ apps/desktop/src/main.ts | 126 ++++++++++++-- apps/desktop/src/quit-confirmation.ts | 84 +++++++++ apps/desktop/src/tray.ts | 48 ++++++ apps/desktop/tests/background-notice.spec.ts | 81 +++++++++ apps/desktop/tests/host-process.spec.ts | 21 ++- .../desktop/tests/installer-packaging.spec.ts | 7 + apps/desktop/tests/main-startup.spec.ts | 163 +++++++++++++++++- apps/desktop/tests/quit-confirmation.spec.ts | 88 ++++++++++ apps/desktop/tests/tray-icon.spec.ts | 43 +++++ apps/desktop/tests/tray.spec.ts | 72 ++++++++ apps/desktop/tests/welcome-startup.spec.ts | 3 + pnpm-lock.yaml | 9 + 33 files changed, 1270 insertions(+), 47 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.md create mode 100644 .agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.zh.md create mode 100644 apps/desktop-host/src/quit-inspection.ts create mode 100644 apps/desktop-host/tests/quit-inspection.spec.ts create mode 100644 apps/desktop/resources/tray-windows.ico create mode 100644 apps/desktop/scripts/render-tray-icon.ts create mode 100644 apps/desktop/src/background-notice.ts create mode 100644 apps/desktop/src/quit-confirmation.ts create mode 100644 apps/desktop/src/tray.ts create mode 100644 apps/desktop/tests/background-notice.spec.ts create mode 100644 apps/desktop/tests/quit-confirmation.spec.ts create mode 100644 apps/desktop/tests/tray-icon.spec.ts create mode 100644 apps/desktop/tests/tray.spec.ts 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 new file mode 100644 index 0000000000..0b6d5b54fc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 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 new file mode 100644 index 0000000000..972edca121 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.md @@ -0,0 +1,35 @@ +# Agent Note: Hide the Desktop window on close and confirm interruptible quits + +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). + +## Problem + +Closing the Desktop window destroyed it. On macOS the Host kept running but the Dock rebuilt a fresh page, losing the current session, draft, and scroll position; on Windows the close quit the application and cut running tasks without any warning. Quit itself never asked, although the Host knows which agents, jobs, and scheduled reminders would stop. + +## Decision + +Closing the main window hides it on both platforms; the page and the Host keep running and the next show presents the same document. Windows gets a permanent tray icon (single click opens, context menu offers Open and Quit) because Windows users expect × to quit and need a visible way back, plus one per-installation notification the first time the window is hidden. macOS keeps the Dock and ships no menu bar icon. Closing the welcome window before the workspace opens quits on Windows and drops the unused main window on macOS, so the Dock rebuilds the welcome. + +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. + +## Alternatives considered + +**Keep quitting on close and add a confirmation there.** It asks on every close, and Windows users would still lose background work whenever they dismissed the window. Hiding matches the product requirement that tasks continue. + +**Render the confirmation in the shell's HTML overlay.** The overlay needs a visible parent window and a working renderer; the quit must also work when the window is hidden or the page is stuck, so the native dialog is the reliable choice. + +**Reuse the update-tasks IPC request with a flag.** The quit needs a second fact and a shorter deadline; a distinct request keeps the update admission lock semantics untouched and lets both share one correlation id space. + +**Downscale one large bitmap for the tray.** Rasterizing the vector source per size keeps edges crisp at 100 % through 400 % display scale, which a single downscale does not guarantee. + +## Consequences + +Users can close the window freely; background tasks and scheduled reminders continue, and reopening restores the same page. Quitting warns only when it interrupts something. Windows carries a tray icon for the whole run, and a hidden window that finishes a user-initiated update download defers its install confirmation until the window is shown again. Reminders in sessions never loaded during the run are neither counted nor resumed until those sessions open, and Desktop does not enable scheduled tasks by default, so the scheduled-task copy appears only once that feature is on. + +Verification covers the four inspection outcomes, inspection failure and deadline, each quit entry's bypass or confirmation, the close-to-hide path with the tray and the first-hide notice, the deferred update prompt, the Host-side inspection, the IPC correlation and deadline, and the committed tray icon's bitmap set. Windows tray and dialog behavior needs a manual pass on a Windows machine at several display scales. 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 new file mode 100644 index 0000000000..3f43fa3d60 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Hide the Desktop window on close and confirm interruptible quits + +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)。 + +## 问题 + +关闭 Desktop 窗口会销毁它。macOS 上 Host 继续运行,但 Dock 会重建一个全新页面,丢失当前会话、草稿和滚动位置;Windows 上关闭即退出应用,正在运行的任务被直接切断,没有任何提示。退出本身从不询问,尽管 Host 知道哪些 agent、后台任务和定时提醒会因此停止。 + +## 决策 + +两个平台上关闭主窗口都只隐藏它;页面和 Host 继续运行,下次显示仍是同一个文档。Windows 增加常驻托盘图标(单击打开,右键菜单提供"打开"和"退出"),因为 Windows 用户习惯 × 即退出,需要一个看得见的回路;每次安装后第一次隐藏窗口时再发一条通知。macOS 沿用 Dock,不提供菜单栏图标。进入工作区前关闭欢迎窗口,Windows 上退出,macOS 上丢弃未使用的主窗口,让 Dock 重建欢迎窗口。 + +所有普通退出入口都通过私有 IPC 通道向 Host 询问两项事实:按更新重启口径判定的运行中任务,以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出;否则弹出一个没有父窗口的原生消息框,显示三种固定说明之一,"退出"为默认按钮,Esc 对应"取消"。重复的退出请求并入已打开的弹框,弹框打开期间文案冻结,批准后不再重新查询。Host 未就绪时不可能有任务在跑,直接退出;查询失败或超过两秒截止时间按运行中任务处理,因为多提示一次的代价低于无声中断。安装更新的重启、致命错误恢复、开发版重启命令和操作系统会话结束跳过确认。 + +Windows 托盘位图由矢量图标按七种尺寸分别渲染并提交为 ICO;Windows 上的确认框在任务对话框中显示应用图标,且始终为浅色,因为该系统控件不跟随应用主题。安装程序和卸载程序针对应用仍在运行的文案改为指向托盘。 + +## 备选方案 + +**保留关闭即退出,只在关闭时加确认。** 每次关闭都要询问,而 Windows 用户只要想收起窗口,后台工作就仍会丢失。隐藏才符合"任务继续运行"的产品要求。 + +**用壳的 HTML 遮罩渲染确认框。** 遮罩需要可见的父窗口和正常工作的渲染进程;退出必须在窗口隐藏或页面卡死时也能完成,因此原生对话框才是可靠选择。 + +**在 update-tasks IPC 请求上加一个标志复用。** 退出需要第二项事实和更短的截止时间;独立请求不触碰更新准入锁的语义,同时两者共用一个关联 id 空间。 + +**为托盘缩小一张大位图。** 按尺寸分别栅格化矢量源,才能在 100% 到 400% 缩放下都保持边缘清晰,单次缩小无法保证这一点。 + +## 影响 + +用户可以随意关闭窗口;后台任务和定时提醒继续运行,重新打开时恢复同一页面。退出只在会中断工作时提醒。Windows 在整个运行期间携带托盘图标;窗口隐藏期间完成的用户主动发起的更新下载,把安装确认推迟到窗口再次显示时。本次运行中从未加载的会话里的提醒既不计入,也要等到这些会话打开后才会继续,而 Desktop 默认未开启定时任务,因此定时任务文案只在该功能开启后出现。 + +验证覆盖四种查询结果、查询失败与截止时间、每个退出入口的跳过或确认、带托盘和首次隐藏通知的关闭即隐藏路径、推迟的更新提示、Host 侧查询、IPC 关联与截止时间,以及已提交托盘图标的位图集合。Windows 托盘和对话框的表现需要在 Windows 机器上以多种显示缩放人工验收。 diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index 78678127d0..25fae4257e 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -19,8 +19,10 @@ "@deepseek-ai/dsh-host-webserver": "workspace:*", "@deepseek-ai/dsh-home-paths": "workspace:*", "@deepseek-ai/dsh-jobs": "workspace:*", + "@deepseek-ai/dsh-schedule": "workspace:*", "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*", "@deepseek-ai/dsh-skill-office": "workspace:*", - "@deepseek-ai/dsh-deepseek-account": "workspace:*" + "@deepseek-ai/dsh-deepseek-account": "workspace:*", + "@deepseek-ai/dsh-workspace": "workspace:*" } } diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index ecbff835ce..e9e4e13b29 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -11,6 +11,7 @@ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import * as desktopOffice from './office.ts' import { installDesktopUpdateTaskControl } from './update-tasks.ts' +import { installDesktopQuitInspection } from './quit-inspection.ts' import { installPlatformSessionPublisher } from './platform-session.ts' import { installOfficeEngineResolution } from './office-engine.ts' @@ -39,7 +40,10 @@ async function main(): Promise { }), }) let stopping: Promise | undefined - const control: { updateTasks?: ReturnType } = {} + const control: { + updateTasks?: ReturnType + quitInspection?: ReturnType + } = {} const send = (message: object): Promise => new Promise((resolve, reject) => { if (!process.connected || process.send === undefined) { resolve(); return } process.send(message, (error) => { if (error === null) resolve(); else reject(error) }) @@ -54,6 +58,22 @@ async function main(): Promise { process.on('message', (message: unknown) => { if (typeof message !== 'object' || message === null || !('type' in message)) return if (message.type === 'shutdown') { void stop(); return } + if (message.type === 'quit-inspection') { + if (!('requestId' in message) || !Number.isSafeInteger(message.requestId)) return + const requestId = message.requestId + void (async () => { + try { + if (stopping !== undefined || control.quitInspection === undefined) throw new Error('desktop quit: Host is unavailable') + const inspection = await control.quitInspection() + await send({ type: 'quit-inspection', requestId, ...inspection }) + } catch (error) { + // The shell treats an unknown state as interruptible work and asks before quitting. + await send({ type: 'quit-inspection', requestId, activeTasks: true, scheduledTasks: false, + error: error instanceof Error ? error.message : String(error) }) + } + })().catch((error: unknown) => { console.error(error) }) + return + } if (message.type !== 'update-tasks' || !('requestId' in message) || !Number.isSafeInteger(message.requestId) || !('action' in message) || !['inspect', 'lock', 'unlock'].includes(String(message.action))) return void (async () => { @@ -70,6 +90,7 @@ async function main(): Promise { process.once('disconnect', () => { void stop() }) const { ctx } = await application control.updateTasks = installDesktopUpdateTaskControl(ctx) + control.quitInspection = installDesktopQuitInspection(ctx) await ctx.plugin(desktopOffice, { runtimeDir, source: process.argv[4] ?? join(runtimeDir, '..', 'runtime', 'primary-runtime'), diff --git a/apps/desktop-host/src/quit-inspection.ts b/apps/desktop-host/src/quit-inspection.ts new file mode 100644 index 0000000000..944bdbaf62 --- /dev/null +++ b/apps/desktop-host/src/quit-inspection.ts @@ -0,0 +1,40 @@ +/** Quit-time inspection of interruptible work and armed scheduled reminders for the Electron shell. */ + +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-workspace' +import type {} from '@deepseek-ai/dsh-schedule' +import { hasDesktopActiveTasks } from './update-tasks.ts' + +/** What quitting the Host now would affect. */ +export interface DesktopQuitInspection { + /** Work the shared update-restart check also counts: running agents, queued messages, live jobs. */ + readonly activeTasks: boolean + /** A loaded session holds a scheduled reminder whose timer is armed in this process. */ + readonly scheduledTasks: boolean +} + +/** + * Register the quit inspector on the owning Host context. + * @param ctx - Booted Desktop profile context; disposal makes the inspector reject. + * @returns Inspector reporting active tasks together with armed reminders of the loaded sessions + * (the `schedule` family of `workspace/session-activity`); reminders in sessions that were never + * loaded during this run cannot fire and are not counted. + */ +export function installDesktopQuitInspection(ctx: Context): () => Promise { + let stopped = false + ctx.effect(() => () => { stopped = true }) + return async () => { + if (stopped) throw new Error('desktop quit: Host is stopping') + const agents = ctx.get('agents') + const jobs = ctx.get('jobs') + if (agents === undefined || jobs === undefined) throw new Error('desktop quit: task services are unavailable') + const liveAgents = agents.list() + const activeTasks = hasDesktopActiveTasks(liveAgents, jobs) + let scheduledTasks = false + for (const agent of liveAgents) { + const activity = await ctx.waterfall('workspace/session-activity', { sessionId: agent.id }, () => Promise.resolve([])) + if (activity.some(entry => entry.kind === 'schedule')) { scheduledTasks = true; break } + } + return { activeTasks, scheduledTasks } + } +} diff --git a/apps/desktop-host/src/update-tasks.ts b/apps/desktop-host/src/update-tasks.ts index 2b2bbf4a33..6e37a8aed8 100644 --- a/apps/desktop-host/src/update-tasks.ts +++ b/apps/desktop-host/src/update-tasks.ts @@ -5,6 +5,21 @@ import type {} from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-jobs' import type {} from '@deepseek-ai/dsh-client-connection' +/** + * Whether stopping the Host now would interrupt work: a generating or tool-running + * agent (including subagents and turns waiting for approval), queued inbox + * messages, or a running or stopping background job. + * @param liveAgents - Current agent roster. + * @param jobs - Job registry queried for the global roster and each agent's own jobs. + * @returns true when any of those conditions holds. + */ +export function hasDesktopActiveTasks(liveAgents: ReturnType, jobs: Context['jobs']): boolean { + return liveAgents.some(agent => agent.status === 'running' + || agent.inbox.nextTurn.length > 0 || agent.inbox.nextStep.length > 0) + || [undefined, ...liveAgents].some(agent => jobs.list(agent?.id) + .some(job => job.status === 'running' || job.status === 'stopping')) +} + /** * Register update admission on the owning Host context. * @param ctx - Booted Desktop profile context; disposal removes the request listener. @@ -42,10 +57,6 @@ export function installDesktopUpdateTaskControl(ctx: Context): (action: 'inspect if (stopped) throw new Error('desktop update: Host is stopping') if (generation !== lockGeneration) throw new Error('desktop update: admission lock was superseded') } - const liveAgents = agents.list() - return liveAgents.some(agent => agent.status === 'running' - || agent.inbox.nextTurn.length > 0 || agent.inbox.nextStep.length > 0) - || [undefined, ...liveAgents].some(agent => jobs.list(agent?.id) - .some(job => job.status === 'running' || job.status === 'stopping')) + return hasDesktopActiveTasks(agents.list(), jobs) } } diff --git a/apps/desktop-host/tests/quit-inspection.spec.ts b/apps/desktop-host/tests/quit-inspection.spec.ts new file mode 100644 index 0000000000..6151e56f95 --- /dev/null +++ b/apps/desktop-host/tests/quit-inspection.spec.ts @@ -0,0 +1,103 @@ +import { Context } from '@deepseek-ai/cordis' +import { JobId, JobRegistry } from '@deepseek-ai/dsh-jobs' +import type { JobStatus, JobView } from '@deepseek-ai/dsh-jobs' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionActivity } from '@deepseek-ai/dsh-workspace' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { installDesktopQuitInspection } from '../src/quit-inspection.ts' + +type AgentState = { id: SessionId; status: 'idle' | 'running'; inbox: { nextTurn: object[]; nextStep: object[] } } + +let ctx: Context +let inspect: ReturnType +const agents: AgentState[] = [] +const jobs = new Map() +/** Activity each provider family reports per session; the inspector must count only `schedule`. */ +const activity = new Map() + +/** A registry whose only served member is the per-owner roster the inspector reads. */ +class RosterOnlyJobRegistry extends JobRegistry { + readonly events = { subscribe: () => () => {} } + start(): never { throw new Error('unsupported') } + list(caller?: SessionId): JobView[] { + return (jobs.get(caller) ?? []).map((status, index) => ({ + id: JobId(`bash-${index + 1}`), + kind: 'bash', + label: 'sleep 60', + ...caller === undefined ? {} : { owner: caller }, + status, + startedAt: 0, + output: { total: 0, earliest: 0 }, + })) + } + get(): never { throw new Error('unsupported') } + read(): never { throw new Error('unsupported') } + readAt(): never { throw new Error('unsupported') } + kill(): never { throw new Error('unsupported') } + wait(): never { throw new Error('unsupported') } + remove(): never { throw new Error('unsupported') } + attachController(): () => void { return () => {} } +} + +function agent(id: string, status: 'idle' | 'running' = 'idle'): AgentState { + return { id: SessionId(id), status, inbox: { nextTurn: [], nextStep: [] } } +} + +beforeEach(() => { + agents.length = 0 + jobs.clear() + activity.clear() + ctx = new Context() + // Narrow service doubles exercise the inspector against a real Cordis event lifecycle. + ctx.provide('agents', { list: () => agents } as never) + new RosterOnlyJobRegistry(ctx) + ctx.on('workspace/session-activity', async ({ sessionId }, next) => [...activity.get(sessionId) ?? [], ...await next()]) + inspect = installDesktopQuitInspection(ctx) +}) + +afterEach(async () => { await ctx.fiber.dispose() }) + +describe('Desktop Host quit inspection', () => { + it('reports neither active nor scheduled work for idle loaded sessions', async () => { + agents.push(agent('quiet')) + activity.set(SessionId('quiet'), [{ kind: 'turn' }]) + expect(await inspect()).toEqual({ activeTasks: false, scheduledTasks: false }) + }) + + it.each(['running', 'nextTurn', 'nextStep', 'job'] as const)('counts %s work with the same rule as the update restart check', async (kind) => { + const owner = agent('busy') + if (kind === 'running') owner.status = 'running' + else if (kind === 'job') jobs.set(owner.id, ['stopping']) + else owner.inbox[kind].push({ queued: true }) + agents.push(owner) + expect(await inspect()).toEqual({ activeTasks: true, scheduledTasks: false }) + }) + + it('counts an armed schedule family in any loaded session and stops at the first one', async () => { + const asked: SessionId[] = [] + ctx.on('workspace/session-activity', async ({ sessionId }, next) => { asked.push(sessionId); return next() }) + agents.push(agent('first'), agent('second'), agent('third')) + activity.set(SessionId('second'), [{ kind: 'schedule', items: [{ id: 'reminder-1', label: 'stand-up' }] }]) + expect(await inspect()).toEqual({ activeTasks: false, scheduledTasks: true }) + expect(asked).toEqual([SessionId('first'), SessionId('second')]) + }) + + it('reports both when a running session also holds a reminder', async () => { + agents.push(agent('both', 'running')) + activity.set(SessionId('both'), [{ kind: 'job', items: [{ id: 'bash-1' }] }, { kind: 'schedule', items: [{ id: 'reminder-2' }] }]) + expect(await inspect()).toEqual({ activeTasks: true, scheduledTasks: true }) + }) + + it('does not classify unavailable task services as idle', async () => { + const empty = new Context() + try { + await expect(installDesktopQuitInspection(empty)()).rejects.toThrow('task services are unavailable') + } finally { await empty.fiber.dispose() } + }) + + it('refuses inspection after disposal', async () => { + expect(await inspect()).toEqual({ activeTasks: false, scheduledTasks: false }) + await ctx.fiber.dispose() + await expect(inspect()).rejects.toThrow('Host is stopping') + }) +}) diff --git a/apps/desktop-host/tsconfig.json b/apps/desktop-host/tsconfig.json index 865875e338..31c64f179b 100644 --- a/apps/desktop-host/tsconfig.json +++ b/apps/desktop-host/tsconfig.json @@ -11,6 +11,8 @@ { "path": "../../vendor/cordis" }, { "path": "../../packages/core/agent" }, { "path": "../../packages/jobs/jobs" }, + { "path": "../../packages/schedule/schedule" }, + { "path": "../../packages/workspace/workspace" }, { "path": "../../packages/skill/skill-office" }, { "path": "../../packages/util/home-paths" }, { "path": "../../packages/skill/tool-workspace-dependencies" }, diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 1152fefd53..0bf366669c 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -5,6 +5,9 @@ /deepseek-harness-desktop: en: 45561fc0dd3a1b20 zh: f4b03355d4bd0693 +/deepseek-harness-desktop/closing-the-window-and-quitting: + en: eff1938598576ffa + zh: 2bcb2e0dec0139cc /deepseek-harness-desktop/key-technical-decisions: en: 069fdfd72d2ea26a zh: 76ae549d43c99bb4 @@ -48,8 +51,8 @@ en: 8efe3498dbe8686d zh: a88c16f20a9430f3 /deepseek-harness-desktop/updates: - en: a40f7aa2b00ed6fb - zh: d852a2d9de576dc5 + en: c2cf8db67af2ecf7 + zh: 5df4cd47ecc7edf9 /deepseek-harness-desktop/updates/mandatory-update-policy: en: 2f789a4949fafb12 zh: d8b28c01cfee11b7 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index a4ee56e847..665d7f9a0e 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -20,6 +20,16 @@ Desktop microphone access is restricted to audio requests from the primary `dsh- Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Shift+I on Windows to toggle DevTools for the focused application page, including in packaged builds. These native shortcuts use hidden application-menu items. Update overlays and packaged embedded browser guests disable DevTools. +## Closing the window and quitting + +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. + +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. + +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. + ## Key technical decisions The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation. @@ -364,7 +374,7 @@ Packaged applications check fixed Nightly asynchronously at startup. Ordinary po `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries. -The lower-left account row displays localized availability, a spinner with download percentage, verification, readiness, or a persistent red retry action with an accessible tooltip. This Web-embedded copy follows the active in-application language; native dialogs use the Desktop shell locale. The collapsed sidebar shows a dot on its top expand button. Connection status takes priority. Selecting an available release starts downloading immediately. Successful preparation automatically opens a shell-owned restart confirmation; closing it retains readiness without reopening the dialog. Selecting the ready entry opens confirmation again. Running agents, queued input, and running or stopping jobs trigger an interruption warning in that confirmation. API requests alone do not trigger the warning. After approval, the Host locks new requests, drains admitted requests, and rechecks tasks, including work created by an admitted write. A drain that exceeds the control-request deadline refuses installation and unlocks admission. Unknown task status, newly started work without interruption approval, or unsuccessful graceful teardown prevents installation. Ordinary quit hides the product window before stopping the Host, ignores new focus requests during teardown, and never installs an update. The next launch reconciles the version-bound runtime through the existing startup and recovery path. +The lower-left account row displays localized availability, a spinner with download percentage, verification, readiness, or a persistent red retry action with an accessible tooltip. This Web-embedded copy follows the active in-application language; native dialogs use the Desktop shell locale. The collapsed sidebar shows a dot on its top expand button. Connection status takes priority. Selecting an available release starts downloading immediately. Successful preparation automatically opens a shell-owned restart confirmation; closing it retains readiness without reopening the dialog. Selecting the ready entry opens confirmation again. Running agents, queued input, and running or stopping jobs trigger an interruption warning in that confirmation. API requests alone do not trigger the warning. After approval, the Host locks new requests, drains admitted requests, and rechecks tasks, including work created by an admitted write. A drain that exceeds the control-request deadline refuses installation and unlocks admission. Unknown task status, newly started work without interruption approval, or unsuccessful graceful teardown prevents installation. Ordinary quit asks about interruptible work as described under Closing the window and quitting, then hides the product window before stopping the Host, ignores new focus requests during teardown, and never installs an update. The next launch reconciles the version-bound runtime through the existing startup and recovery path. If task teardown fails after confirmed Host exit, installation is refused and the shell restores the current-version Host before allowing another restart confirmation. Installer launch failure after a clean Host stop uses the same recovery. After the replacement Host authenticates, the shell reloads the existing application URL so the Web page obtains its current port, cookie, and boot injections; page-load failure opens native fatal recovery. An unconfirmed process exit never permits a replacement Host. The downloaded target remains available for retry. A known mandatory policy remains blocking throughout recovery; unsuccessful Host restoration opens the native fatal-recovery dialog. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 108e7790e7..294dfdb5af 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -20,6 +20,16 @@ Desktop Host 的 Platform API 请求与更新策略请求用相同的 Platform 按 F12(多媒体功能键键盘上为 Fn+F12)、macOS 的 Command+Option+I 或 Windows 的 Ctrl+Shift+I,可切换当前获得焦点的应用页面的 DevTools,打包版本同样支持。这些原生快捷键通过隐藏的应用菜单项注册。更新遮罩和打包版本的内嵌浏览器禁用 DevTools。 +## 关闭窗口与退出 + +关闭主窗口(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 不提供菜单栏图标。 + +所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭强制更新窗口或欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会把它提到前面而不叠加新弹框;任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。 + +以下情况跳过确认:安装更新的重启已确认过任务中断、致命错误恢复对话框中的退出或重启、开发版"重启应用与 Host"命令,以及操作系统关机、重启或注销(macOS 关机通知、Windows 会话结束消息)。窗口隐藏期间完成的用户主动发起的更新下载,把"安装并重启"确认推迟到窗口再次显示时;强制更新流程沿用其任务栏和 Dock 提醒。Windows 安装程序和卸载程序在应用仍在运行时提示用户先在系统托盘中退出。Desktop 默认未开启定时任务,定时任务的说明只在该功能开启后出现;提醒只在已加载的会话中触发,未加载的会话既不计入,也要等到打开后才会继续。 + ## 关键技术决策 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。 @@ -366,7 +376,7 @@ Windows 下载完成后的更新确认说明应用会在安装期间关闭、完 `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。 -左下角账户行显示本地化的更新可用状态、加载图标与下载百分比、验证、就绪状态,或带可访问提示的持久红色重试操作。嵌入 Web 界面的文案跟随应用内当前语言;原生弹窗使用 Desktop 壳语言。侧栏收起时,顶部展开按钮显示圆点。连接状态优先展示。选择可用版本即开始下载。准备成功后自动打开壳拥有的重启确认;关闭后保留就绪状态,不重复弹窗。选择就绪入口可再次打开确认。运行中的 agent、排队输入,以及运行中或停止中的后台任务都会在该确认中触发中断警告。仅有 API 请求不会触发警告。用户批准后,Host 锁定新请求,等待已接收的请求结束,再检查任务,包括已接收写操作创建的工作。等待超过控制请求截止时间时,拒绝安装并解除准入锁。任务状态未知、未获中断授权的新任务,或未成功完成正常收尾,都会阻止安装。常规退出会在停止 Host 前隐藏产品窗口,在收尾期间忽略新的聚焦请求,且从不安装更新。下次启动通过已有的启动与恢复流程校准版本绑定的运行时。 +左下角账户行显示本地化的更新可用状态、加载图标与下载百分比、验证、就绪状态,或带可访问提示的持久红色重试操作。嵌入 Web 界面的文案跟随应用内当前语言;原生弹窗使用 Desktop 壳语言。侧栏收起时,顶部展开按钮显示圆点。连接状态优先展示。选择可用版本即开始下载。准备成功后自动打开壳拥有的重启确认;关闭后保留就绪状态,不重复弹窗。选择就绪入口可再次打开确认。运行中的 agent、排队输入,以及运行中或停止中的后台任务都会在该确认中触发中断警告。仅有 API 请求不会触发警告。用户批准后,Host 锁定新请求,等待已接收的请求结束,再检查任务,包括已接收写操作创建的工作。等待超过控制请求截止时间时,拒绝安装并解除准入锁。任务状态未知、未获中断授权的新任务,或未成功完成正常收尾,都会阻止安装。常规退出先按"关闭窗口与退出"一节所述询问可中断的工作,再在停止 Host 前隐藏产品窗口,在收尾期间忽略新的聚焦请求,且从不安装更新。下次启动通过已有的启动与恢复流程校准版本绑定的运行时。 若任务收尾失败但已确认 Host 退出,安装会被拒绝,壳会在允许再次确认重启前恢复当前版本的 Host。Host 正常停止后的安装器启动失败使用同一恢复路径。替代 Host 启动并完成认证后,壳重新加载原有应用地址,让 Web 页面获取当前端口、Cookie 和启动注入数据;页面加载失败时打开原生致命故障恢复弹窗。未确认进程退出时,绝不允许启动替代 Host。已下载目标保留以供重试。已知强更策略在恢复过程中继续阻塞;Host 恢复失败打开原生致命故障恢复弹窗。 diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index d1cae61b6a..85a6b9acca 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -21,6 +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 mac: { readonly extendInfo: { readonly NSMicrophoneUsageDescription: string } diff --git a/apps/desktop/installer/strings.nsh b/apps/desktop/installer/strings.nsh index f2f2b08191..174ebfa28b 100644 --- a/apps/desktop/installer/strings.nsh +++ b/apps/desktop/installer/strings.nsh @@ -27,8 +27,8 @@ LangString INSTALLER_THEME_ERROR ${LANG_ENGLISH} "Use /THEME=auto, /THEME=light LangString INSTALLER_THEME_ERROR ${LANG_SIMPCHINESE} "主题参数须为 /THEME=auto、/THEME=light 或 /THEME=dark。" LangString INSTALLER_FAILED ${LANG_ENGLISH} "Installation did not finish. Close this installer and retry. If the problem persists, download the installer again." LangString INSTALLER_FAILED ${LANG_SIMPCHINESE} "安装未完成,请关闭安装程序后重试。如仍然失败,请重新下载安装包。" -LangString INSTALLER_RUNNING ${LANG_ENGLISH} "DeepSeek Harness is running. Close the application, then run this installer again." -LangString INSTALLER_RUNNING ${LANG_SIMPCHINESE} "DeepSeek Harness 正在运行。请先关闭应用,再重新运行安装程序。" +LangString INSTALLER_RUNNING ${LANG_ENGLISH} "DeepSeek Harness is running. Quit it from the system tray and try again." +LangString INSTALLER_RUNNING ${LANG_SIMPCHINESE} "DeepSeek Harness 正在运行,请先在系统托盘中退出应用后重试。" LangString INSTALLER_PATH_INVALID ${LANG_ENGLISH} "Choose a full local folder path. Drive roots, system folders, links and special characters are not supported." LangString INSTALLER_PATH_INVALID ${LANG_SIMPCHINESE} "请选择本地磁盘上的完整文件夹路径,不能使用磁盘根目录、系统目录、链接目录或包含特殊字符的路径。" LangString INSTALLER_PATH_OWNERSHIP ${LANG_ENGLISH} "Choose an empty folder or the registered DeepSeek Harness installation folder." diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0326324fae..0695bb6ebd 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -14,6 +14,7 @@ "start": "tsx scripts/dev.ts --skip-build", "test:updates:local": "pnpm run build && node scripts/test-local-updater.mjs", "prepare:runtime": "tsx scripts/prepare-runtime.ts", + "render:tray-icon": "tsx scripts/render-tray-icon.ts", "prepare:primary-runtime": "tsx scripts/prepare-primary-runtime.ts", "sign:primary-runtime": "tsx scripts/sign-primary-runtime.ts", "cache:windows-signatures": "node scripts/manage-windows-signature-cache.mjs", @@ -62,6 +63,7 @@ "js-yaml": "^4.2.0", "koffi": "^3.1.0", "pnpm": "11.7.0", + "sharp": "^0.35.3", "typescript": "^6.0.3", "@deepseek-ai/dsh-deepseek-account": "workspace:*", "@types/ws": "^8.18.1", diff --git a/apps/desktop/resources/tray-windows.ico b/apps/desktop/resources/tray-windows.ico new file mode 100644 index 0000000000000000000000000000000000000000..7e6b7881e1b30facf6f1e1a28d7f1122fa8178d1 GIT binary patch literal 13132 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 p``td+WIqio=ob*AZn;oGD@ { + if (entry.size < 1 || entry.size > 256) throw new Error(`tray icon: unsupported bitmap edge ${String(entry.size)}`) + const { width, height } = pngDimensions(entry.png) + if (width !== entry.size || height !== entry.size) { + throw new Error(`tray icon: bitmap ${String(index)} is ${String(width)}x${String(height)}, expected ${String(entry.size)}`) + } + const at = ICON_DIRECTORY_BYTES + ICON_ENTRY_BYTES * index + // 256 px is encoded as 0 in the one-byte edge fields. + header.writeUInt8(entry.size % 256, at) + header.writeUInt8(entry.size % 256, at + 1) + header.writeUInt8(0, at + 2) + header.writeUInt8(0, at + 3) + header.writeUInt16LE(1, at + 4) + header.writeUInt16LE(32, at + 6) + header.writeUInt32LE(entry.png.length, at + 8) + header.writeUInt32LE(offset, at + 12) + offset += entry.png.length + }) + return Buffer.concat([header, ...entries.map(entry => entry.png)]) +} + +/** + * Read the bitmaps back out of one ICO file. + * @param ico - Bytes written by {@link packIco} or another PNG-entry ICO producer. + * @returns entries in directory order, each PNG checked against its declared edge. + */ +export function unpackIco(ico: Buffer): IcoEntry[] { + if (ico.length < ICON_DIRECTORY_BYTES || ico.readUInt16LE(0) !== 0 || ico.readUInt16LE(2) !== 1) { + throw new Error('tray icon: not an ICO file') + } + const count = ico.readUInt16LE(4) + return Array.from({ length: count }, (_, index) => { + const at = ICON_DIRECTORY_BYTES + ICON_ENTRY_BYTES * index + const declared = ico.readUInt8(at) + const size = declared === 0 ? 256 : declared + const length = ico.readUInt32LE(at + 8) + const offset = ico.readUInt32LE(at + 12) + const png = ico.subarray(offset, offset + length) + const { width, height } = pngDimensions(png) + if (width !== size || height !== size) throw new Error(`tray icon: entry ${String(index)} declares ${String(size)} but holds ${String(width)}x${String(height)}`) + return { size, png } + }) +} + +/** + * Rasterize the vector source at each tray size. + * @param svg - SVG document with a 1024-unit square viewBox. + * @param sizes - Bitmap edges to render. + * @returns PNG entries in the given order. + */ +export async function renderTrayIconEntries(svg: Buffer, sizes: readonly number[] = TRAY_ICON_SIZES): Promise { + return Promise.all(sizes.map(async size => ({ + size, + png: await sharp(svg, { density: SOURCE_DENSITY * size / SOURCE_EDGE }).resize(size, size).png().toBuffer(), + }))) +} + +function pngDimensions(png: Buffer): { width: number; height: number } { + if (png.length < 24 || !png.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) throw new Error('tray icon: bitmap is not a PNG stream') + return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) } +} + +async function main(): Promise { + const entries = await renderTrayIconEntries(await readFile(TRAY_ICON_PATHS.source)) + await writeFile(TRAY_ICON_PATHS.output, packIco(entries)) + console.info(`tray icon: wrote ${TRAY_ICON_PATHS.output} with ${entries.map(entry => String(entry.size)).join(', ')} px bitmaps`) +} + +if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main() diff --git a/apps/desktop/src/background-notice.ts b/apps/desktop/src/background-notice.ts new file mode 100644 index 0000000000..814c8cc6cb --- /dev/null +++ b/apps/desktop/src/background-notice.ts @@ -0,0 +1,58 @@ +/** One-time Windows notice that closing the window left the application running in the tray. */ + +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { Notification } from 'electron' +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. + */ + readonly markerPath: string + readonly locale: () => DesktopLocale + /** Show and focus the primary window when the notice is clicked. */ + readonly open: () => void +} + +/** Shows once per installation, and at most once per process even when the marker cannot be written. */ +export class DesktopBackgroundNotice { + private shown = false + private notification: Notification | undefined + + /** @param options - Marker path, locale reader, and the click action. */ + 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(): 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() + const notification = new Notification({ title: messages.backgroundNoticeTitle, body: messages.backgroundNoticeBody, silent: true }) + this.notification = notification + notification.on('failed', () => { + if (this.notification === notification) this.notification = undefined + notification.removeAllListeners() + }) + notification.once('click', () => { + if (this.notification !== notification) return + this.notification = undefined + notification.removeAllListeners() + this.options.open() + }) + notification.show() + } catch (error) { console.warn('desktop tray: background notice unavailable', error) } + } +} diff --git a/apps/desktop/src/host-process.ts b/apps/desktop/src/host-process.ts index 8b3a901a69..948a88e6b0 100644 --- a/apps/desktop/src/host-process.ts +++ b/apps/desktop/src/host-process.ts @@ -28,8 +28,26 @@ type DesktopHostEvent = ReadyEvent | FatalEvent | PlatformSessionEvent | { reado readonly requestId: number readonly active: boolean readonly error?: string +} | { + readonly type: 'quit-inspection' + readonly requestId: number + readonly activeTasks: boolean + readonly scheduledTasks: boolean + readonly error?: string } +/** Correlated answer to one shell control request. */ +type DesktopHostControlResponse = Extract + +/** What quitting now would affect, as reported by the Host. */ +export interface DesktopQuitInspection { + readonly activeTasks: boolean + readonly scheduledTasks: boolean +} + +/** Quit inspection deadline; a slower Host counts as unknown work and the shell asks before quitting. */ +export const QUIT_INSPECTION_DEADLINE_MS = 2_000 + const MAX_HOST_DIAGNOSTIC_CHARS = 64 * 1024 function isDesktopHostEvent(message: unknown): message is DesktopHostEvent { @@ -64,6 +82,9 @@ function isDesktopHostEvent(message: unknown): message is DesktopHostEvent { case 'update-tasks': return Number.isSafeInteger(candidate.requestId) && typeof candidate.active === 'boolean' && (candidate.error === undefined || typeof candidate.error === 'string') + case 'quit-inspection': + return Number.isSafeInteger(candidate.requestId) && typeof candidate.activeTasks === 'boolean' + && typeof candidate.scheduledTasks === 'boolean' && (candidate.error === undefined || typeof candidate.error === 'string') default: return false } @@ -128,7 +149,10 @@ export class DesktopHostProcess { private stopping = false private shutdownCompleted = false private nextControlId = 1 - private readonly taskQueries = new Map void; reject: (error: Error) => void }>() + private readonly controlRequests = new Map void + reject: (error: Error) => void + }>() /** * @param node - Absolute Electron executable in Node mode. @@ -193,9 +217,9 @@ export class DesktopHostProcess { } else if (message.type === 'fatal') this.fail(new DesktopHostFatalError(message.message, message.diagnostic)) else { - const query = this.taskQueries.get(message.requestId) - if (message.error === undefined) query?.resolve(message.active) - else query?.reject(new Error(message.error)) + const request = this.controlRequests.get(message.requestId) + if (message.error === undefined) request?.resolve(message) + else request?.reject(new Error(message.error)) } }) child.once('error', (error) => { this.fail(error) }) @@ -217,21 +241,41 @@ export class DesktopHostProcess { * an unanswered drain fails at the control-request deadline without authorizing installation. */ async updateTasks(action: 'inspect' | 'lock' | 'unlock'): Promise { + const response = await this.control({ type: 'update-tasks', action }, 10_000, 'desktop update: task inspection timed out') + if (response.type !== 'update-tasks') throw new Error('desktop update: Host answered with a different control response') + return response.active + } + + /** + * Ask the Host what quitting now would interrupt. + * @returns Active tasks and armed scheduled reminders; rejects when the Host is unavailable or misses + * {@link QUIT_INSPECTION_DEADLINE_MS}, and the shell then asks before quitting. + */ + async inspectQuit(): Promise { + const response = await this.control({ type: 'quit-inspection' }, QUIT_INSPECTION_DEADLINE_MS, 'desktop quit: inspection timed out') + if (response.type !== 'quit-inspection') throw new Error('desktop quit: Host answered with a different control response') + return { activeTasks: response.activeTasks, scheduledTasks: response.scheduledTasks } + } + + private async control( + request: { readonly type: 'update-tasks'; readonly action: 'inspect' | 'lock' | 'unlock' } | { readonly type: 'quit-inspection' }, + deadlineMs: number, deadlineMessage: string, + ): Promise { const child = this.child if (child === undefined || !child.connected || this.failureReported || this.stopping) { - throw new Error('desktop update: Host is unavailable') + throw new Error(`${request.type === 'update-tasks' ? 'desktop update' : 'desktop quit'}: Host is unavailable`) } const requestId = this.nextControlId++ let timer: ReturnType | undefined try { - return await new Promise((resolve, reject) => { - this.taskQueries.set(requestId, { resolve, reject }) - timer = setTimeout(() => { reject(new Error('desktop update: task inspection timed out')) }, 10_000) - child.send({ type: 'update-tasks', requestId, action }, (error) => { if (error !== null) reject(error) }) + return await new Promise((resolve, reject) => { + this.controlRequests.set(requestId, { resolve, reject }) + timer = setTimeout(() => { reject(new Error(deadlineMessage)) }, deadlineMs) + child.send({ ...request, requestId }, (error) => { if (error !== null) reject(error) }) }) } finally { clearTimeout(timer) - this.taskQueries.delete(requestId) + this.controlRequests.delete(requestId) } } @@ -266,8 +310,8 @@ export class DesktopHostProcess { private fail(error: Error): void { this.onPlatformSession?.(null) this.readyReject(error) - for (const query of this.taskQueries.values()) query.reject(error) - this.taskQueries.clear() + for (const request of this.controlRequests.values()) request.reject(error) + this.controlRequests.clear() if (!this.failureReported && !this.stopping) { this.failureReported = true try { this.onFailure?.(error) } catch (listenerError) { diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 7c589df70b..2648cc497a 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -11,6 +11,15 @@ export const en = { hideOtherApplications: 'Hide Others', showAllApplications: 'Show All', quitApplication: 'Quit DeepSeek Harness', + openApplication: 'Open DeepSeek Harness', + quit: 'Quit', + cancel: 'Cancel', + quitTitle: 'Quit DeepSeek Harness?', + quitActiveTasks: 'Running tasks will be interrupted.', + quitScheduledTasks: 'Scheduled tasks will not run while the app is closed.', + quitActiveAndScheduledTasks: 'Running tasks will be interrupted, and scheduled tasks will not run while the app is closed.', + backgroundNoticeTitle: 'DeepSeek Harness is still running', + backgroundNoticeBody: 'Running tasks will continue. Reopen or quit it from the system tray.', edit: 'Edit', menuBar: 'Application menu', delete: 'Delete', @@ -146,6 +155,15 @@ export const zh = { hideOtherApplications: '隐藏其他', showAllApplications: '显示全部', quitApplication: '退出 DeepSeek Harness', + openApplication: '打开 DeepSeek Harness', + quit: '退出', + cancel: '取消', + quitTitle: '退出 DeepSeek Harness?', + quitActiveTasks: '当前正在运行的任务将会中断', + quitScheduledTasks: '应用关闭期间,定时任务不会运行', + quitActiveAndScheduledTasks: '当前正在运行的任务将会中断,且应用关闭期间,定时任务不会运行', + backgroundNoticeTitle: 'DeepSeek Harness 仍在后台运行', + backgroundNoticeBody: '正在运行的任务不会中断。可在系统托盘中重新打开或退出。', edit: '编辑', menuBar: '应用菜单', delete: '删除', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0b43146683..43df5427f9 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -12,6 +12,7 @@ import { ipcMain, Menu, powerMonitor, + nativeImage, nativeTheme, net, protocol, @@ -51,10 +52,18 @@ import { readDesktopRuntime } from './runtime-tree.ts' import { DesktopBrowserGuests } from './browser-guests.ts' import { installDesktopShortcuts } from './keyboard.ts' import { DesktopUpdateOverlays } from './update-overlay.ts' +import { DesktopQuitConfirmation } from './quit-confirmation.ts' +import { DesktopTray } from './tray.ts' +import { DesktopBackgroundNotice } from './background-notice.ts' 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. + */ +let skipQuitConfirmation = false let windowsLanguage: string | undefined /** * Whether the backend has reached ready: false until the first ready, back to @@ -72,6 +81,11 @@ app.setAppLogsPath() function currentDesktopLocale(): ReturnType { return resolveDesktopLocale(windowsLanguage ?? app.getLocale()) } +/** Quit without the task confirmation; the caller has already decided the application must stop. */ +function quitWithoutConfirmation(): void { + skipQuitConfirmation = true + app.quit() +} const recovery = new DesktopFatalRecovery({ messages: () => currentDesktopLocale().messages, show: options => dialog.showMessageBox(options), @@ -81,8 +95,8 @@ const recovery = new DesktopFatalRecovery({ const backupPath = await manager.disableAllPlugins() console.info('Desktop profile recovery completed:', { profilePatchBackup: backupPath ?? null, homePatch: 'unchanged' }) }, - exit: () => { app.quit() }, - restart: () => { app.relaunch(); app.quit() }, + exit: () => { quitWithoutConfirmation() }, + restart: () => { app.relaunch(); quitWithoutConfirmation() }, writeReport: (error, source) => persistCrashReport(error, source), }) @@ -324,6 +338,9 @@ async function main(): Promise { let mandatoryPolicy: DesktopMandatoryUpdatePolicy | undefined 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. */ + let sessionEnding = false const isQuitting = (): boolean => quitting const currentMainWindow = (): BrowserWindow | undefined => mainWindow const ordinaryDialogs = new Set() @@ -446,6 +463,7 @@ async function main(): Promise { } }, updateTasks: (action: 'inspect' | 'lock' | 'unlock') => host.updateTasks(action), + inspectQuit: () => host.inspectQuit(), } }, (state) => { if (state.phase === 'error') reportFatal(state.failure, 'host') @@ -578,8 +596,18 @@ async function main(): Promise { const state = await updates.download(version) if (state.phase !== 'ready' || quitting) return state // Only a completed user-driven download opens this prompt; cancelling installation does not reopen it. + // A confirmation on a hidden window would go unseen, so it waits for the next show; the mandatory + // flow keeps its own taskbar and Dock attention instead. + if (!isMandatory()) await windowShown() + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- A quit can begin while the show is awaited. + if (quitting) return state return updates.install(version) } + const windowShown = (): Promise => new Promise((resolve) => { + const window = currentDialogWindow() + if (window === undefined || window.isDestroyed() || window.isVisible()) { resolve(); return } + window.once('show', () => { resolve() }) + }) protocol.handle(SCHEME, (request) => { const url = new URL(request.url) @@ -838,14 +866,15 @@ async function main(): Promise { updates.dispose() }) + const applicationIconPath = development ? join(app.getAppPath(), 'resources', 'icon-windows.png') + : join(process.resourcesPath, 'icon.png') app.setAboutPanelOptions({ applicationName: 'DeepSeek Harness', applicationVersion: app.getVersion(), // The release has no separate build number; omit Electron's bundle version. version: '', copyright: '', - iconPath: development ? join(app.getAppPath(), 'resources', 'icon-windows.png') - : join(process.resourcesPath, 'icon.png'), + iconPath: applicationIconPath, }) // A custom application menu replaces Electron's default menu, so macOS needs // its standard menus and application hide commands declared explicitly. @@ -874,7 +903,7 @@ async function main(): Promise { { label: currentDesktopLocale().messages.restartAppHostMenu, click: () => { if (quitting) return app.relaunch() - app.quit() + quitWithoutConfirmation() } }, ] : [], { type: 'separator' }, @@ -891,8 +920,31 @@ async function main(): Promise { label: darwin ? app.name : currentDesktopLocale().messages.application, submenu: [...applicationItems(), ...devToolsItems], }, ...platformMenus()])) + tray?.relabel() } refreshApplicationMenu() + 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() }, + }) + } catch (error) { console.warn('desktop tray: unavailable', error) } + } + const backgroundNotice = process.platform === 'win32' + ? new DesktopBackgroundNotice({ markerPath: join(app.getPath('userData'), 'background-notice-shown'), + locale: () => locale, open: () => { focusPrimaryWindow() } }) + : undefined + const quitConfirmation = new DesktopQuitConfirmation({ + locale: () => locale, + 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. + focus: () => { if (process.platform === 'darwin') app.focus({ steal: true }) }, + ...(process.platform === 'win32' ? { icon: nativeImage.createFromPath(applicationIconPath) } : {}), + }) if (process.platform === 'win32') { ipcMain.handle(DESKTOP_IPC.windowsMenu, (event, name: unknown, x: unknown, y: unknown) => { @@ -941,12 +993,33 @@ async function main(): Promise { }) } + const hideMainWindow = (window: BrowserWindow): void => { + if (process.platform === 'darwin' && window.isFullScreen()) { + // Hiding a fullscreen window leaves an empty black space; leave fullscreen first. + window.once('leave-full-screen', () => { if (!window.isDestroyed()) window.hide() }) + window.setFullScreen(false) + } else { + window.hide() + } + backgroundNotice?.show() + } const createMainWindow = (): BrowserWindow => { const window = createWindow(appPreload, false, true) mainWindow = window browserGuests.bind(window, (guest, name) => shortcuts.attachGuest(window, guest, name)) shortcuts.attach(window) window.on('focus', automaticCheck) + // Closing hides: the page and the Host keep running, and the next show resumes the same document. + window.on('close', (event) => { + if (quitting || shellInstallerOwnsQuit || sessionEnding) return + event.preventDefault() + hideMainWindow(window) + }) + if (process.platform === 'win32') { + // Shutdown, restart, and log-off must not wait on a confirmation. + window.on('query-session-end', () => { sessionEnding = true }) + window.on('session-end', () => { sessionEnding = true }) + } window.on('closed', () => { if (mainWindow === window) mainWindow = undefined }) window.webContents.on('console-message', (details) => { if (details.level !== 'error') return @@ -1038,7 +1111,11 @@ async function main(): Promise { }) window.once('closed', () => { if (welcomeWindow === window) welcomeWindow = undefined - if (!enteredWorkspace && !recovery.active) mainWindow?.close() + if (enteredWorkspace || recovery.active) return + // Nothing runs before the workspace opens. macOS keeps the Dock convention and drops the + // unused main window so the next activation rebuilds the welcome; elsewhere the close quits. + if (process.platform === 'darwin') mainWindow?.destroy() + else app.quit() }) if (isQuitting() || recovery.active || enteredWorkspace) window.close() else mainWindow?.hide() @@ -1082,25 +1159,18 @@ async function main(): Promise { if (url === 'dsh://open' || url === 'dsh://open/') focusPrimaryWindow() }) - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) focusPrimaryWindow() + app.on('activate', (_event, hasVisibleWindows) => { + if (!hasVisibleWindows) focusPrimaryWindow() }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() }) - app.on('before-quit', (event) => { + if (process.platform !== 'win32') powerMonitor.on('shutdown', () => { sessionEnding = true }) + const finishQuit = (): void => { + quitting = true shuttingDown = true updateJournal?.action('quit-requested') - if (shellInstallerOwnsQuit) { - updateDialog.dispose() - mandatoryUI?.dispose() - // Installation preparation already awaited Platform storage cleanup. - void platformView.dispose().catch((error: unknown) => { console.error(error) }) - return - } - if (quitting) return - event.preventDefault() - quitting = true + tray?.dispose() stopAccount?.() if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.hide() if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.hide() @@ -1111,6 +1181,24 @@ async function main(): Promise { // A Platform cleanup failure is logged without cutting the remaining Host shutdown short. platformView.dispose().catch((error: unknown) => { console.error(error) })]) .catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() }) + } + app.on('before-quit', (event) => { + if (shellInstallerOwnsQuit) { + shuttingDown = true + updateJournal?.action('quit-requested') + tray?.dispose() + updateDialog.dispose() + mandatoryUI?.dispose() + // Installation preparation already awaited Platform storage cleanup. + void platformView.dispose().catch((error: unknown) => { console.error(error) }) + return + } + if (quitting) return + event.preventDefault() + if (skipQuitConfirmation || sessionEnding) { finishQuit(); return } + void quitConfirmation.confirm().then((approved) => { + if (approved && !quitting) finishQuit() + }).catch((error: unknown) => { console.error(error); if (!quitting) finishQuit() }) }) mainWindow = createMainWindow() diff --git a/apps/desktop/src/quit-confirmation.ts b/apps/desktop/src/quit-confirmation.ts new file mode 100644 index 0000000000..f89befcd37 --- /dev/null +++ b/apps/desktop/src/quit-confirmation.ts @@ -0,0 +1,84 @@ +/** Native quit confirmation; quitting proceeds silently only when the Host reports nothing to interrupt. */ + +import type { MessageBoxOptions, MessageBoxReturnValue, NativeImage } from 'electron' +import type { DesktopQuitInspection } from './host-process.ts' +import type { DesktopLocale, DesktopMessages } from './locale.ts' + +/** Locale key of the explanation shown before quitting, or none when quitting interrupts nothing. */ +export type DesktopQuitPrompt = Extract | undefined + +/** + * Choose the confirmation copy for one inspection result. + * @param inspection - Host answer, or `unknown` when the inspection failed or missed its deadline. + * @returns the explanation key; an unknown state warns about running tasks rather than quitting silently. + */ +export function resolveDesktopQuitPrompt(inspection: DesktopQuitInspection | 'unknown'): DesktopQuitPrompt { + if (inspection === 'unknown') return 'quitActiveTasks' + if (inspection.activeTasks && inspection.scheduledTasks) return 'quitActiveAndScheduledTasks' + if (inspection.activeTasks) return 'quitActiveTasks' + if (inspection.scheduledTasks) return 'quitScheduledTasks' + return undefined +} + +/** Main-process collaborators of the confirmation. */ +export interface DesktopQuitConfirmationOptions { + readonly locale: () => DesktopLocale + /** Start one Host inspection; undefined while no Host is ready, when nothing can be running and the quit proceeds. */ + readonly inspect: () => Promise | undefined + /** Native message box without an owner window: hidden windows stay hidden and the box comes to the front. */ + readonly show: (options: MessageBoxOptions) => Promise + /** Bring the open confirmation to the front when quit is requested again while it is open. */ + readonly focus: () => void + readonly platform?: NodeJS.Platform + /** Application icon for the Windows task dialog; macOS composes the app icon onto its warning icon itself. */ + readonly icon?: NativeImage +} + +/** One quit decision at a time; repeated quit requests join the open confirmation instead of stacking. */ +export class DesktopQuitConfirmation { + private pending: Promise | undefined + + /** @param options - Locale, inspection, and native dialog collaborators. */ + constructor(private readonly options: DesktopQuitConfirmationOptions) {} + + /** + * Decide whether the quit may proceed. The inspection runs once per decision; work that starts or ends + * while the confirmation is open does not change its copy, and approval never re-inspects. + * @returns true to quit now, false when the user cancelled. + */ + confirm(): Promise { + if (this.pending !== undefined) { + this.options.focus() + return this.pending + } + const pending = this.decide().finally(() => { if (this.pending === pending) this.pending = undefined }) + this.pending = pending + return pending + } + + private async decide(): Promise { + const inspection = this.options.inspect() + if (inspection === undefined) return true + const prompt = resolveDesktopQuitPrompt(await inspection.catch((error: unknown) => { + console.warn('desktop quit: task inspection unavailable', error) + return 'unknown' as const + })) + if (prompt === undefined) return true + const { messages } = this.options.locale() + const windows = (this.options.platform ?? process.platform) === 'win32' + // Button order follows each platform: macOS lays buttons out right-to-left from index 0, so + // "Quit" sits right of "Cancel"; the Windows task dialog keeps array order, "Quit" left of "Cancel". + const result = await this.options.show({ + type: windows ? 'none' : 'warning', + ...(windows && this.options.icon !== undefined ? { icon: this.options.icon } : {}), + title: messages.aboutProduct, + message: messages.quitTitle, + detail: messages[prompt], + buttons: [messages.quit, messages.cancel], + defaultId: 0, + cancelId: 1, + noLink: true, + }) + return result.response === 0 + } +} diff --git a/apps/desktop/src/tray.ts b/apps/desktop/src/tray.ts new file mode 100644 index 0000000000..b2ce416bbc --- /dev/null +++ b/apps/desktop/src/tray.ts @@ -0,0 +1,48 @@ +/** Windows system tray: the always-present way back to a hidden window and the explicit quit entry. */ + +import { Menu, nativeImage, Tray } from 'electron' +import type { DesktopLocale } from './locale.ts' + +/** Main-process actions the tray triggers; both run the same paths as the window and application menu. */ +export interface DesktopTrayOptions { + /** Multi-size ICO rendered by `scripts/render-tray-icon.ts`; Windows picks the bitmap for the display scale. */ + readonly iconPath: string + readonly locale: () => DesktopLocale + /** Show and focus the primary window. */ + readonly open: () => void + /** Request quit through the same confirmation as every other quit entry. */ + readonly quit: () => void +} + +/** Tray icon present for the whole run, not only while the window is hidden. */ +export class DesktopTray { + private tray: Tray | undefined + + /** @param options - Icon path, locale reader, and the open and quit actions. */ + constructor(private readonly options: DesktopTrayOptions) { + const tray = new Tray(nativeImage.createFromPath(options.iconPath)) + this.tray = tray + tray.on('click', () => { options.open() }) + this.relabel() + } + + /** Rebuild the tooltip and context menu in the current locale. */ + relabel(): void { + const tray = this.tray + if (tray === undefined) return + const { messages } = this.options.locale() + tray.setToolTip(messages.aboutProduct) + tray.setContextMenu(Menu.buildFromTemplate([ + { label: messages.openApplication, click: () => { this.options.open() } }, + { type: 'separator' }, + { label: messages.quitApplication, click: () => { this.options.quit() } }, + ])) + } + + /** Remove the icon; called once the quit is confirmed so no dead icon outlives the process. */ + dispose(): void { + const tray = this.tray + this.tray = undefined + tray?.destroy() + } +} diff --git a/apps/desktop/tests/background-notice.spec.ts b/apps/desktop/tests/background-notice.spec.ts new file mode 100644 index 0000000000..8b8d05b350 --- /dev/null +++ b/apps/desktop/tests/background-notice.spec.ts @@ -0,0 +1,81 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { DesktopBackgroundNotice } from '../src/background-notice.ts' +import { resolveDesktopLocale } from '../src/locale.ts' + +const native = await vi.hoisted(async () => { + const { EventEmitter } = await import('node:events') + const notices: Notice[] = [] + class Notice extends EventEmitter { + static isSupported = vi.fn(() => true) + show = vi.fn() + constructor(readonly options: unknown) { super(); notices.push(this) } + } + return { notices, Notice } +}) +vi.mock('electron', () => ({ Notification: native.Notice })) + +const roots: string[] = [] +afterEach(() => { + native.notices.length = 0 + vi.clearAllMocks() + vi.restoreAllMocks() + native.Notice.isSupported.mockReturnValue(true) + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function setup(markerPath = join(mkdtempSync(join(tmpdir(), 'dsh-desktop-notice-')), 'state', 'background-notice-shown')) { + roots.push(join(markerPath, '..', '..')) + const open = vi.fn() + const notice = new DesktopBackgroundNotice({ markerPath, locale: () => resolveDesktopLocale('zh'), open }) + return { notice, open, markerPath } +} + +it('notifies once per installation, records the marker, and opens the window on click', () => { + const f = setup() + f.notice.show() + f.notice.show() + expect(existsSync(f.markerPath)).toBe(true) + expect(native.notices).toHaveLength(1) + expect(native.notices[0]!.options).toEqual({ + title: 'DeepSeek Harness 仍在后台运行', body: '正在运行的任务不会中断。可在系统托盘中重新打开或退出。', silent: true, + }) + expect(native.notices[0]!.show).toHaveBeenCalledOnce() + native.notices[0]!.emit('click') + native.notices[0]!.emit('click') + expect(f.open).toHaveBeenCalledOnce() + // The next process of the same installation finds the marker. + const again = new DesktopBackgroundNotice({ markerPath: f.markerPath, locale: () => resolveDesktopLocale('zh'), open: f.open }) + again.show() + expect(native.notices).toHaveLength(1) +}) + +it('still notifies once in this process when the marker cannot be written', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-notice-')) + roots.push(root) + // A file where the marker's directory should be makes the marker write fail. + writeFileSync(join(root, 'blocker'), '') + const open = vi.fn() + const blocked = new DesktopBackgroundNotice({ markerPath: join(root, 'blocker', 'background-notice-shown'), locale: () => resolveDesktopLocale('zh'), open }) + blocked.show() + blocked.show() + expect(console.warn).toHaveBeenCalledWith('desktop tray: could not record the background notice', expect.anything()) + expect(native.notices).toHaveLength(1) +}) + +it('does nothing visible without notification support or after a delivery failure', () => { + native.Notice.isSupported.mockReturnValue(false) + const f = setup() + f.notice.show() + expect(native.notices).toHaveLength(0) + expect(existsSync(f.markerPath)).toBe(true) + native.Notice.isSupported.mockReturnValue(true) + const g = setup() + g.notice.show() + native.notices[0]!.emit('failed') + native.notices[0]!.emit('click') + expect(g.open).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/tests/host-process.spec.ts b/apps/desktop/tests/host-process.spec.ts index ff28283bd6..daeddc74d0 100644 --- a/apps/desktop/tests/host-process.spec.ts +++ b/apps/desktop/tests/host-process.spec.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { DesktopHostFatalError, DesktopHostProcess, DesktopHostUncleanExitError } from '../src/host-process.ts' +import { DesktopHostFatalError, DesktopHostProcess, DesktopHostUncleanExitError, QUIT_INSPECTION_DEADLINE_MS } from '../src/host-process.ts' const roots: string[] = [] const hosts: DesktopHostProcess[] = [] @@ -34,6 +34,12 @@ process.on('message', message => { process.send({ type: 'update-tasks', requestId: message.requestId, active: message.action === 'lock' }) return } + if (message.type === 'quit-inspection') { + // Ids divisible by three never answer; the others report scheduled work for odd ids. + if (message.requestId % 3 === 0) return + process.send({ type: 'quit-inspection', requestId: message.requestId, activeTasks: false, scheduledTasks: message.requestId % 2 === 1 }) + return + } if (message.type !== 'shutdown') return server.close(() => { writeFileSync(join(process.argv[3], 'stopped'), '') @@ -77,6 +83,19 @@ describe('desktop host process', () => { await expect(host.updateTasks('inspect')).rejects.toThrow('Host is unavailable') }) + it('correlates quit inspections with task requests and fails an unanswered one at its own deadline', async () => { + const host = hostProcess(projectWithHost()) + await expect(host.inspectQuit()).rejects.toThrow('desktop quit: Host is unavailable') + await host.start() + // Request ids 1 and 2: the fixture answers by id parity, so both control kinds share one id space. + expect(await Promise.all([host.inspectQuit(), host.updateTasks('inspect')])) + .toEqual([{ activeTasks: false, scheduledTasks: true }, false]) + const started = Date.now() + await expect(host.inspectQuit()).rejects.toThrow('desktop quit: inspection timed out') + expect(Date.now() - started).toBeGreaterThanOrEqual(QUIT_INSPECTION_DEADLINE_MS - 50) + expect(await host.inspectQuit()).toEqual({ activeTasks: false, scheduledTasks: false }) + }, 15_000) + it.each([ 'process.exit(17)', 'process.exit(0)', diff --git a/apps/desktop/tests/installer-packaging.spec.ts b/apps/desktop/tests/installer-packaging.spec.ts index 3e42ed5a63..15a4610d98 100644 --- a/apps/desktop/tests/installer-packaging.spec.ts +++ b/apps/desktop/tests/installer-packaging.spec.ts @@ -40,6 +40,13 @@ describe('installer preparation preserves application dependencies', () => { const aboutIcon = config.extraResources.find(resource => resource.to === 'icon.png') expect(aboutIcon).toBeDefined() expect(readFileSync(aboutIcon!.from)).toEqual(readFileSync(new URL('../resources/icon-windows.png', import.meta.url))) + // Only the Windows package carries the tray bitmaps; macOS keeps the Dock. + const trayIcon = config.extraResources.find(resource => resource.to === 'tray.ico') + if (platform === 'win32') { + expect(readFileSync(trayIcon!.from)).toEqual(readFileSync(new URL('../resources/tray-windows.ico', import.meta.url))) + } else { + expect(trayIcon).toBeUndefined() + } const packager = new Packager({ projectDir: tmpdir() }) // A foreign source-build target avoids rebuilding modules; the real dependency ownership decision still runs. Object.defineProperties(packager, { diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index ea1e6c52b8..8b9a5c609b 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -102,6 +102,7 @@ const harness = await vi.hoisted(async () => { isDestroyed() { return this.destroyed } fullscreen = false isFullScreen() { return this.fullscreen } + readonly setFullScreen = vi.fn((flag: boolean) => { this.fullscreen = flag; this.emit(flag ? 'enter-full-screen' : 'leave-full-screen') }) minimized = false isMinimized() { return this.minimized } visible = true @@ -126,6 +127,7 @@ const harness = await vi.hoisted(async () => { } class FakeHost { readonly updateTasks = vi.fn(async (_action: 'inspect' | 'lock' | 'unlock') => false) + readonly inspectQuit = vi.fn(async () => ({ activeTasks: false, scheduledTasks: false })) url = 'http://127.0.0.1:3080/?token=test' fetch = vi.fn(async () => Response.json({ hasApiKey: true, writable: true, localePreference: null })) readonly ready = deferred() @@ -159,6 +161,7 @@ const harness = await vi.hoisted(async () => { setAsDefaultProtocolClient: vi.fn(), exit: vi.fn(), relaunch: vi.fn(), + focus: vi.fn(), quit: vi.fn(() => { const event = { preventDefault: vi.fn() } app.emit('before-quit', event) @@ -170,9 +173,17 @@ const harness = await vi.hoisted(async () => { }) let accountListener: ((state: AccountView) => void) | undefined const nativeTheme = { themeSource: 'system', shouldUseDarkColors: false } + const trays: FakeTray[] = [] + class FakeTray extends EventEmitter { + readonly setToolTip = vi.fn() + readonly setContextMenu = vi.fn() + readonly destroy = vi.fn() + constructor(readonly image: unknown) { super(); trays.push(this) } + } + const backgroundNotice = { show: vi.fn(), markerPath: undefined as string | undefined } return { failWindow(error: Error) { windowFailure = error }, - windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, + windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, trays, FakeTray, backgroundNotice, menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall, platformDispose, platformCloseAndWait, @@ -213,6 +224,8 @@ const harness = await vi.hoisted(async () => { reset() { accountListener = undefined windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners() + trays.length = 0 + backgroundNotice.markerPath = undefined powerMonitor.removeAllListeners() app.isPackaged = true windowFailure = undefined @@ -265,7 +278,13 @@ vi.mock('electron', () => ({ } }, protocol: { registerSchemesAsPrivileged: vi.fn(), handle: harness.protocolHandle }, powerMonitor: harness.powerMonitor, + Tray: harness.FakeTray, + nativeImage: { createFromPath: (path: string) => ({ path }) }, })) +vi.mock('../src/background-notice.ts', () => ({ DesktopBackgroundNotice: class { + constructor(options: { markerPath: string }) { harness.backgroundNotice.markerPath = options.markerPath } + readonly show = harness.backgroundNotice.show +} })) vi.mock('node:fs/promises', async (importOriginal) => { const original = await importOriginal() return { ...original, readFile: vi.fn((path: Parameters[0], encoding?: 'utf8') => { @@ -378,6 +397,9 @@ afterEach(async () => { harness.prepared.resolve() for (const host of harness.hosts) { host.ready.resolve(); host.exited.resolve() } harness.app.quit() + // The quit decides asynchronously; a Host that starts meanwhile must still be released. + await vi.advanceTimersByTimeAsync(0) + for (const host of harness.hosts) { host.ready.resolve(); host.exited.resolve() } await harness.quitCompleted.promise vi.restoreAllMocks() vi.clearAllTimers() @@ -1043,12 +1065,23 @@ describe('desktop main startup', () => { .toMatchFileSnapshot(`./expected/update-restart-${platform}-${language}.json`) }) - it('hides the workspace before intentional Host shutdown can look like reconnection', async () => { + /** Like readyForUpdate, but past backend readiness and the workspace reveal, so the Host answers quit inspections. */ + async function readyWorkspace() { const host = await readyForUpdate() + await Promise.resolve(invoke(DESKTOP_IPC.boot)) + return host + } + + it('hides the workspace before intentional Host shutdown can look like reconnection', async () => { + const host = await readyWorkspace() const window = harness.windows[0]! window.show.mockClear() window.focus.mockClear() harness.app.quit() + // The quit inspects the Host before hiding; nothing to interrupt means no dialog. + await vi.advanceTimersByTimeAsync(0) + expect(host.inspectQuit).toHaveBeenCalledOnce() + expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() expect(window.hide).toHaveBeenCalledOnce() await host.stopping.promise harness.app.emit('second-instance') @@ -1056,6 +1089,132 @@ describe('desktop main startup', () => { expect(window.focus).not.toHaveBeenCalled() host.exited.resolve() await harness.quitCompleted.promise + expect(harness.trays[0]!.destroy).toHaveBeenCalledOnce() + }) + + it('hides the main window on close, keeps the Host running, and notifies once about the tray', async () => { + const host = await readyWorkspace() + const window = harness.windows[0]! + expect(harness.trays).toHaveLength(1) + expect(harness.trays[0]!.image).toEqual({ path: join('desktop-test-resources', 'tray.ico') }) + expect(harness.backgroundNotice.markerPath).toBe(join('desktop-test-userData', 'background-notice-shown')) + window.show.mockClear() + window.close() + expect(window.isDestroyed()).toBe(false) + expect(window.hide).toHaveBeenCalledOnce() + expect(host.stop).not.toHaveBeenCalled() + expect(harness.app.quit).not.toHaveBeenCalled() + expect(harness.backgroundNotice.show).toHaveBeenCalledOnce() + harness.trays[0]!.emit('click') + expect(window.show).toHaveBeenCalledOnce() + expect(window.focus).toHaveBeenCalled() + harness.app.emit('second-instance') + expect(window.show).toHaveBeenCalledTimes(2) + // Locale changes relabel the tray together with the application menu. + const relabels = harness.trays[0]!.setContextMenu.mock.calls.length + harness.ipcOn.mock.calls.find(call => call[0] === DESKTOP_IPC.localeChanged)![1]({ sender: window.webContents, senderFrame: window.webContents.mainFrame }, 'zh') + expect(harness.trays[0]!.setContextMenu.mock.calls.length).toBe(relabels + 1) + }) + + it('leaves macOS fullscreen before hiding on close and reopens the hidden window on activate', async () => { + vi.stubGlobal('process', { ...process, platform: 'darwin', arch: 'arm64', resourcesPath: 'desktop-test-resources' }) + await readyWorkspace() + const window = harness.windows[0]! + expect(harness.trays).toHaveLength(0) + window.fullscreen = true + window.close() + expect(window.setFullScreen).toHaveBeenCalledWith(false) + expect(window.hide).toHaveBeenCalledOnce() + expect(harness.backgroundNotice.show).not.toHaveBeenCalled() + window.show.mockClear() + harness.app.emit('activate', {}, false) + expect(window.show).toHaveBeenCalledOnce() + harness.app.emit('activate', {}, true) + expect(window.show).toHaveBeenCalledOnce() + }) + + 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]! + host.inspectQuit.mockResolvedValue({ activeTasks: true, scheduledTasks: true }) + harness.dialog.showMessageBox.mockResolvedValue({ response: 1, checkboxChecked: false }) + harness.app.quit() + await vi.advanceTimersByTimeAsync(0) + expect(harness.dialog.showMessageBox).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ + message: en.quitTitle, detail: en.quitActiveAndScheduledTasks, buttons: [en.quit, en.cancel], defaultId: 0, cancelId: 1, + })) + expect(window.hide).not.toHaveBeenCalled() + expect(host.stop).not.toHaveBeenCalled() + expect(harness.trays[0]!.destroy).not.toHaveBeenCalled() + // A second request while the box is open joins it instead of stacking another. + const pending = Promise.withResolvers() + harness.dialog.showMessageBox.mockReturnValue(pending.promise) + harness.app.quit() + await vi.advanceTimersByTimeAsync(0) + harness.app.quit() + await vi.advanceTimersByTimeAsync(0) + expect(harness.dialog.showMessageBox).toHaveBeenCalledTimes(2) + expect(host.inspectQuit).toHaveBeenCalledTimes(2) + pending.resolve({ response: 0, checkboxChecked: false }) + await vi.advanceTimersByTimeAsync(0) + expect(window.hide).toHaveBeenCalledOnce() + await host.stopping.promise + host.exited.resolve() + await harness.quitCompleted.promise + }) + + it('warns about running tasks when the inspection fails and quits directly on session end or a development restart', async () => { + harness.app.isPackaged = false + const host = await readyWorkspace() + host.inspectQuit.mockRejectedValue(new Error('desktop quit: inspection timed out')) + harness.dialog.showMessageBox.mockResolvedValue({ response: 1, checkboxChecked: false }) + harness.app.quit() + await vi.advanceTimersByTimeAsync(0) + expect(harness.dialog.showMessageBox).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ detail: en.quitActiveTasks })) + expect(host.stop).not.toHaveBeenCalled() + const restart = applicationMenuItems().find(item => item.label === en.restartAppHostMenu)! + harness.dialog.showMessageBox.mockClear() + ;(restart as { click: () => void }).click() + await vi.advanceTimersByTimeAsync(0) + expect(harness.app.relaunch).toHaveBeenCalledOnce() + expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() + await host.stopping.promise + host.exited.resolve() + await harness.quitCompleted.promise + }) + + it('does not block a Windows session end on the quit confirmation', async () => { + const host = await readyWorkspace() + const window = harness.windows[0]! + host.inspectQuit.mockResolvedValue({ activeTasks: true, scheduledTasks: false }) + window.emit('query-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() + await vi.advanceTimersByTimeAsync(0) + expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() + expect(host.inspectQuit).not.toHaveBeenCalled() + await host.stopping.promise + host.exited.resolve() + await harness.quitCompleted.promise + }) + + it('defers the downloaded-update confirmation until the hidden window is shown again', async () => { + await readyWorkspace() + const window = harness.windows[0]! + harness.updateState = { phase: 'available', version: '1.0.1' } + harness.updateDownload.mockImplementation(async () => { harness.updateState = { phase: 'ready', version: '1.0.1' }; return harness.updateState }) + window.close() + window.visible = false + const opened = invoke(DESKTOP_IPC.updatesOpen, 'app') as Promise + await vi.advanceTimersByTimeAsync(0) + expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1') + expect(harness.updateInstall).not.toHaveBeenCalled() + window.visible = true + window.emit('show') + await opened + expect(harness.updateInstall).toHaveBeenCalledWith('1.0.1') }) it('waits for Platform view storage cleanup before an ordinary quit completes', async () => { diff --git a/apps/desktop/tests/quit-confirmation.spec.ts b/apps/desktop/tests/quit-confirmation.spec.ts new file mode 100644 index 0000000000..0e485db186 --- /dev/null +++ b/apps/desktop/tests/quit-confirmation.spec.ts @@ -0,0 +1,88 @@ +import type { MessageBoxOptions, MessageBoxReturnValue, NativeImage } from 'electron' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { DesktopQuitInspection } from '../src/host-process.ts' +import { resolveDesktopLocale } from '../src/locale.ts' +import { DesktopQuitConfirmation, resolveDesktopQuitPrompt } from '../src/quit-confirmation.ts' + +afterEach(() => { vi.restoreAllMocks() }) + +function setup(platform: NodeJS.Platform, inspect: () => Promise | undefined, locale = 'zh') { + const shown: MessageBoxOptions[] = [] + let answer: MessageBoxReturnValue = { response: 0, checkboxChecked: false } + const focus = vi.fn() + const icon: NativeImage = { isEmpty: () => false } as never + const confirmation = new DesktopQuitConfirmation({ + locale: () => resolveDesktopLocale(locale), inspect, focus, platform, icon, + show: async (options) => { shown.push(options); return answer }, + }) + return { confirmation, shown, focus, icon, answer: (value: number) => { answer = { response: value, checkboxChecked: false } } } +} + +describe('quit prompt selection', () => { + it.each([ + [{ activeTasks: false, scheduledTasks: false }, undefined], + [{ activeTasks: true, scheduledTasks: false }, 'quitActiveTasks'], + [{ activeTasks: false, scheduledTasks: true }, 'quitScheduledTasks'], + [{ activeTasks: true, scheduledTasks: true }, 'quitActiveAndScheduledTasks'], + ['unknown', 'quitActiveTasks'], + ] as const)('maps %j to %s', (inspection, prompt) => { + expect(resolveDesktopQuitPrompt(inspection)).toBe(prompt) + }) +}) + +describe('DesktopQuitConfirmation', () => { + it('quits silently when nothing is running and no Host is ready', async () => { + const idle = setup('darwin', async () => ({ activeTasks: false, scheduledTasks: false })) + expect(await idle.confirmation.confirm()).toBe(true) + const starting = setup('darwin', () => undefined) + expect(await starting.confirmation.confirm()).toBe(true) + expect([...idle.shown, ...starting.shown]).toEqual([]) + }) + + it('shows the macOS alert with the warning icon, Chinese copy, and Quit as the default right-hand button', async () => { + const f = setup('darwin', async () => ({ activeTasks: true, scheduledTasks: true })) + expect(await f.confirmation.confirm()).toBe(true) + expect(f.shown).toEqual([{ + type: 'warning', title: 'DeepSeek Harness', message: '退出 DeepSeek Harness?', + detail: '当前正在运行的任务将会中断,且应用关闭期间,定时任务不会运行', + buttons: ['退出', '取消'], defaultId: 0, cancelId: 1, noLink: true, + }]) + }) + + it('shows the Windows task dialog with the application icon and plain buttons in English', async () => { + const f = setup('win32', async () => ({ activeTasks: false, scheduledTasks: true }), 'en') + f.answer(1) + expect(await f.confirmation.confirm()).toBe(false) + expect(f.shown).toEqual([{ + type: 'none', icon: f.icon, title: 'DeepSeek Harness', message: 'Quit DeepSeek Harness?', + detail: 'Scheduled tasks will not run while the app is closed.', + buttons: ['Quit', 'Cancel'], defaultId: 0, cancelId: 1, noLink: true, + }]) + }) + + it('warns about running tasks when the inspection fails', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const f = setup('darwin', async () => { throw new Error('desktop quit: inspection timed out') }) + f.answer(1) + expect(await f.confirmation.confirm()).toBe(false) + expect(f.shown.map(options => options.detail)).toEqual(['当前正在运行的任务将会中断']) + expect(console.warn).toHaveBeenCalledWith('desktop quit: task inspection unavailable', expect.any(Error)) + }) + + it('joins an open confirmation instead of stacking a second one and inspects once per decision', async () => { + const inspected = Promise.withResolvers() + const inspect = vi.fn(() => inspected.promise) + const f = setup('darwin', inspect) + const first = f.confirmation.confirm() + const second = f.confirmation.confirm() + expect(second).toBe(first) + expect(f.focus).toHaveBeenCalledOnce() + expect(inspect).toHaveBeenCalledOnce() + inspected.resolve({ activeTasks: true, scheduledTasks: false }) + expect(await first).toBe(true) + expect(f.shown).toHaveLength(1) + // The decision is closed: the next request starts a fresh inspection. + expect(f.confirmation.confirm()).not.toBe(first) + expect(inspect).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/desktop/tests/tray-icon.spec.ts b/apps/desktop/tests/tray-icon.spec.ts new file mode 100644 index 0000000000..6247c13ce6 --- /dev/null +++ b/apps/desktop/tests/tray-icon.spec.ts @@ -0,0 +1,43 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { packIco, TRAY_ICON_PATHS, TRAY_ICON_SIZES, unpackIco, type IcoEntry } from '../scripts/render-tray-icon.ts' + +/** Smallest valid-looking PNG stream: signature plus an IHDR chunk declaring the given edge. */ +function pngStub(width: number, height = width): Buffer { + const png = Buffer.alloc(33) + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(png) + png.writeUInt32BE(13, 8) + png.write('IHDR', 12) + png.writeUInt32BE(width, 16) + png.writeUInt32BE(height, 20) + return png +} + +describe('tray icon packaging', () => { + it('packs PNG entries into a Vista-style ICO directory and reads them back', () => { + const entries: IcoEntry[] = [{ size: 16, png: pngStub(16) }, { size: 256, png: pngStub(256) }] + const ico = packIco(entries) + expect(ico.readUInt16LE(2)).toBe(1) + expect(ico.readUInt16LE(4)).toBe(2) + // 256 px is encoded as 0 in the one-byte edge fields. + expect([ico.readUInt8(6 + 16), ico.readUInt8(6 + 17)]).toEqual([0, 0]) + expect(ico.readUInt32LE(6 + 12)).toBe(6 + 32) + expect(unpackIco(ico)).toEqual(entries) + }) + + it('rejects bitmaps that disagree with their declared edge, oversized edges, and non-PNG data', () => { + expect(() => packIco([{ size: 16, png: pngStub(24) }])).toThrow('is 24x24, expected 16') + expect(() => packIco([{ size: 512, png: pngStub(512) }])).toThrow('unsupported bitmap edge 512') + expect(() => packIco([{ size: 16, png: Buffer.from('not a png stream, long enough to be read') }])).toThrow('not a PNG stream') + expect(() => unpackIco(Buffer.from('BM'))).toThrow('not an ICO file') + const forged = packIco([{ size: 16, png: pngStub(16) }]) + forged.writeUInt8(20, 6) + expect(() => unpackIco(forged)).toThrow('declares 20 but holds 16x16') + }) + + it('ships one crisp bitmap per supported display scale in the committed tray icon', () => { + const entries = unpackIco(readFileSync(TRAY_ICON_PATHS.output)) + expect(entries.map(entry => entry.size)).toEqual([...TRAY_ICON_SIZES]) + for (const entry of entries) expect(entry.png.length).toBeGreaterThan(100) + }) +}) diff --git a/apps/desktop/tests/tray.spec.ts b/apps/desktop/tests/tray.spec.ts new file mode 100644 index 0000000000..11c3a48865 --- /dev/null +++ b/apps/desktop/tests/tray.spec.ts @@ -0,0 +1,72 @@ +import type { MenuItemConstructorOptions } from 'electron' +import { afterEach, expect, it, vi } from 'vitest' +import { resolveDesktopLocale } from '../src/locale.ts' +import { DesktopTray } from '../src/tray.ts' + +const native = await vi.hoisted(async () => { + const { EventEmitter } = await import('node:events') + const trays: FakeTray[] = [] + class FakeTray extends EventEmitter { + readonly setToolTip = vi.fn() + readonly setContextMenu = vi.fn() + readonly destroy = vi.fn() + constructor(readonly image: unknown) { super(); trays.push(this) } + } + const menus: MenuItemConstructorOptions[][] = [] + return { + trays, FakeTray, menus, + createFromPath: vi.fn((path: string) => ({ path })), + buildFromTemplate: vi.fn((template: MenuItemConstructorOptions[]) => { menus.push(template); return { template } }), + } +}) +vi.mock('electron', () => ({ + Tray: native.FakeTray, + nativeImage: { createFromPath: native.createFromPath }, + Menu: { buildFromTemplate: native.buildFromTemplate }, +})) + +afterEach(() => { native.trays.length = 0; native.menus.length = 0; vi.clearAllMocks() }) + +function setup(locale = 'en') { + let current = resolveDesktopLocale(locale) + const open = vi.fn() + const quit = vi.fn() + const tray = new DesktopTray({ iconPath: 'C:/app/resources/tray.ico', locale: () => current, open, quit }) + return { tray, open, quit, native: native.trays[0]!, setLocale: (next: string) => { current = resolveDesktopLocale(next) } } +} + +function labels(menu: MenuItemConstructorOptions[]): (string | undefined)[] { + return menu.map(item => item.type === 'separator' ? 'separator' : item.label) +} + +it('shows the application icon with its name as the tooltip and an Open / Quit menu', () => { + const f = setup() + expect(native.createFromPath).toHaveBeenCalledWith('C:/app/resources/tray.ico') + expect(f.native.image).toEqual({ path: 'C:/app/resources/tray.ico' }) + expect(f.native.setToolTip).toHaveBeenCalledWith('DeepSeek Harness') + expect(labels(native.menus[0]!)).toEqual(['Open DeepSeek Harness', 'separator', 'Quit DeepSeek Harness']) + expect(f.native.setContextMenu).toHaveBeenCalledWith({ template: native.menus[0] }) +}) + +it('opens the window on a single click and routes menu entries to the open and quit actions', () => { + const f = setup() + f.native.emit('click') + expect(f.open).toHaveBeenCalledOnce() + const menu = native.menus[0]! + ;(menu[0] as { click: () => void }).click() + ;(menu[2] as { click: () => void }).click() + expect(f.open).toHaveBeenCalledTimes(2) + expect(f.quit).toHaveBeenCalledOnce() +}) + +it('relabels the menu in the current locale and ignores relabel after disposal', () => { + const f = setup() + f.setLocale('zh') + f.tray.relabel() + expect(labels(native.menus[1]!)).toEqual(['打开 DeepSeek Harness', 'separator', '退出 DeepSeek Harness']) + f.tray.dispose() + f.tray.dispose() + expect(f.native.destroy).toHaveBeenCalledOnce() + f.tray.relabel() + expect(native.menus).toHaveLength(2) +}) diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 6e1f469eef..7518cc74af 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -96,7 +96,10 @@ vi.mock('electron', () => ({ }, dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn() }, Menu: { buildFromTemplate: state.menu, setApplicationMenu: vi.fn() }, + nativeImage: { createFromPath: (path: string) => ({ path }) }, })) +// The Windows tray relabels through Menu as well; keep the menu call counts below platform-neutral. +vi.mock('../src/tray.ts', () => ({ DesktopTray: class { relabel() {} dispose() {} } })) vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: '/profile' }) })) vi.mock('../src/project-manager.ts', () => ({ DesktopProjectManager: class { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bcc5c5e0a..3f39c63fdc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -669,6 +669,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + sharp: + specifier: ^0.35.3 + version: 0.35.3(@types/node@22.20.0) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -705,12 +708,18 @@ importers: '@deepseek-ai/dsh-jobs': specifier: workspace:* version: link:../../packages/jobs/jobs + '@deepseek-ai/dsh-schedule': + specifier: workspace:* + version: link:../../packages/schedule/schedule '@deepseek-ai/dsh-skill-office': specifier: workspace:* version: link:../../packages/skill/skill-office '@deepseek-ai/dsh-tool-workspace-dependencies': specifier: workspace:* version: link:../../packages/skill/tool-workspace-dependencies + '@deepseek-ai/dsh-workspace': + specifier: workspace:* + version: link:../../packages/workspace/workspace '@deepseek-ai/libreoffice-kit': specifier: ^0.1.0 version: 0.1.0 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 2/6] 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) + }) }) From eec2b5fa6ed8f0d34f1ad09347992059c75b175f Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:47:58 +0800 Subject: [PATCH 3/6] fix(desktop): enlarge whale artwork in Windows tray icon --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 ++ apps/desktop/README.zh.md | 2 ++ apps/desktop/resources/icon-windows.svg | 2 +- apps/desktop/resources/tray-windows.ico | Bin 13132 -> 15698 bytes apps/desktop/scripts/render-tray-icon.ts | 11 ++++++++--- 6 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 891c77512b..4f37d399a4 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: 41636fae7eef4348 - zh: 28f0a7b2f170a911 + en: bc82af4f34ac7d6d + zh: 5e97cdf7f293b3fa /deepseek-harness-desktop/key-technical-decisions: en: 069fdfd72d2ea26a zh: 76ae549d43c99bb4 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index c4a9e0e1ce..8e019d9a48 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -30,6 +30,8 @@ Every ordinary quit entry — ⌘Q, the application menu, the Dock menu, the Win 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 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. + ## Key technical decisions The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index ceeffcb911..104f9e2225 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -30,6 +30,8 @@ Windows 在整个运行期间常驻托盘图标。悬停提示为产品名,单 以下情况跳过确认:安装更新的重启已确认过任务中断、致命错误恢复对话框中的退出或重启、开发版"重启应用与 Host"命令,以及操作系统关机、重启或注销:Windows 在确定性的会话结束消息上设置该状态;macOS 在关机通知上设置,而其他应用仍可能取消这次关机,因此主窗口下一次获得焦点或显示时会清除它。窗口隐藏期间完成的用户主动发起的更新下载,把"安装并重启"确认推迟到窗口再次显示时;强制更新流程沿用其任务栏和 Dock 提醒。Windows 安装程序和卸载程序在应用仍在运行时提示用户先在系统托盘中退出。Desktop 默认未开启定时任务,定时任务的说明只在该功能开启后出现;提醒只在已加载的会话中触发,未加载的会话既不计入,也要等到打开后才会继续。 +托盘渲染器以底板中心为基准将鲸鱼放大 20%,保留背景和宽高比;应用和安装器图标保持原有比例。 + ## 关键技术决策 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。 diff --git a/apps/desktop/resources/icon-windows.svg b/apps/desktop/resources/icon-windows.svg index eb07a50158..212560ed20 100644 --- a/apps/desktop/resources/icon-windows.svg +++ b/apps/desktop/resources/icon-windows.svg @@ -3,7 +3,7 @@ - + diff --git a/apps/desktop/resources/tray-windows.ico b/apps/desktop/resources/tray-windows.ico index 7e6b7881e1b30facf6f1e1a28d7f1122fa8178d1..e60f1cb885b78eb6f20d449b2c453b27a26907f9 100644 GIT binary patch delta 15411 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

H)8)10G~)x=g&MF5J{Q?CnP zM%vDDFk+&GV;Gi!R|GI>lFU9)jI5-FP%kFg=AxN(eC-2=QTA+KjyH3?bI*W36@Q22 zP*j_N5g6kzlT6-)QM-XrmQa=P2DP*9#v2)A30H8uVTh<;-=eSe^vk#7ladyLq0oT- z@NrAbSs7Sa%i-n88k-9k+qE_l)Kz!W$qsRvUllOPx*;tZHeEO5nFHT!{D$bg zQMfyvgtoP$2B{q1ClhaB^zoQ`x0ynh55ksH(d+c9LaUdMjfSUxg57>Dh$T_BloXz_ z;Y-t2Ot2CA)O{$R**-np%x~UTQ-^9S79LuYDVi#Y_*im}EeMzmMnO!#BsB>Xsf$&; zOOHTpZr6t|S~8l+H~CbP^)SVw zvy%b5!&Rrtsh>17Wih0(C_74-QvND9I;(s&Ur+6|Ncxvr-&vYjz1fnoVgJ$MS0oY& zS+=nlI0rHZifA+KPr{Na5;jm>{x~%fpG?a5AV^|BjBh!%+a1;*X(cGnZi4 zA*m=NNev7Xghca(LgP_)aXQwar6r!!UwU>&v6kHSk`KiOD5$Tntv}OCj9qXGFP+dj+T;$ zf)b2-r)L)!DhI$z8cH67jFaUI6lUPgE&PIrQ!S9KFi3p9&@f|H3W6jYB_07R{Rt54 zH*#IF)kG4oy00lrz|eSZKw2vZxt77o`#t#hErRQ0>5=E}8$j}G^_@)t7f~UmH({V= zVAn;c9cIfC9>{|sY7j1S2`_GjVws7rdXXalpSt^DPt1qfSs$Kfb3yd&!=O{CzNT<2 z1WcW4@Iu_oiT*5B2=Eh!DvJc*fMdkk0>K;Jhd?$(+@!uRXe?@zpCve0+(Q;>X0cjr z_rl~UV4&bFP+F!7UpFrswz$hV>Qs3L>zVr*ee3GRtalmg5FkBzTxV3#47{=`$+!)B zdkJDdKvvM?Sr7F*-ZE~x_x?!OaMm@m@{NYBOia?8)4S3)gVd;J(0FFB7cdPfenm+nSNGkC9MOm*6d}rKsT{$TWHbuJEU!;lmrf(;;r&hF z!7!L0N%9_!zR8}`!!2aTOGGcA7O5x~Fco>40268oB?V-7(cZy}!SO*ff!my<@&c4^ z15*SPgyGA1rG`KbV0-P$d@3i8Q?#HXl*)HS11r7Y3M5y=^J}nk=M5nuDT5Kwk4E<7b*A zdTUC8A>k#Lild$S?d>R;+ExkUU1fT5lR4)H5buxE!2h$fCDtb>;`VtW@a`Z_ zyrlp_oK6zp^6)?1J@g<^oNSlpU1b1ZO9&sc#a5AHa%ujArh4xaDgF zLpWjOeND+4k}U^O-LH$~Wkl5rMNcwR$)=<5l^iKhuqg)?&r=m_GJS};h93nM{;Nm8 zFirsGdV~e+(>sVI5n&HjErB9Yg8k7Pjy6xZK!uds!7p&wM@?Jid0_B!ZXg5w*`Z0f z?5#P#E}-NX|9ywc|K%PY1+6`>dz<_6u!i3Ae2t}}?ib#hvS9h?%4N6*Q)ZPc6@ihr z1#Td81UH+=n;qWB5xJBoj9^{nwN;a0Dy;qQXkDp|b@=EP__XgOg!ZW{YcQFFp1~Nl zfA8H(%QNquKUe98H@3mNMNyrd3j=fGE!9T@Z6V#;jjBTh0k=CYE;mOTZE#8s z@Yx%3O}-o&xW89|N)iL35MtUMepD<#pVB&^J-P?EUoI}rf$GM1 zx`n#X-7A(R#xKx~@F=EO@tRnow3d}Q0-#Do$Q5uyK>N%q&*WTY?LA^$W8)#rp>N3u zh1y*TwC!_ENo3kNNO?8kNXO@dl_$-m77@V*+zOA4jrEXKgpCrJusY?Nf2q63I9wtu zS;zOhq7*R4@C?EL(h;g?F2`hs)qB28Inf}=qM|F}vLp^6b_n_Ab_e0qgQRuv7qCi0 zRt-NiR5sI(a9;Lcok3n+kBp%m+bA zaNo~X;TxGlh`8-*aHr|kI$l5dzh;qFoSs-HK<=jP|i)c9Sly*U#I$J}Ox zsw(K+v56=K;!xDR#2$g2iOg?pY>%9Abyz`aY$P`HyW_U`IlTn`)v8sB$w15x>f%G1 zr0H_e@!G`A85>{eA5=cn>$RFFtR8OpKw`A0f7Zl-Ng$%Q=oP!MGo{tYf7(JCI!e@c zw1nt|H{+HgYFfiXdg@2vU}wKR9j&O_G5VH(UZoext(9EQMSD44cZdrtNhA;{IBt`3 z_2!o6mRXpv<0lR7CF#1t`hnXH1sgNYT+PMbn~@8`v>5!%r$N#s^~rttYHjBYYYZGY zb$oq}m8{AdgD;mj#L)B8zQRc&z!JdC@u($D!-~N5Y^G@ zpzFAKJ57>@^k&4!-+F9=^!AmsyPXn@%94e?-MMOzg4qK92b0cOK-2_miF zl`QhpL@VC%!rmp2(Tq@z{)iEIce_C;qCmacdx38ZFcN}CiG%%B+|=nqIHG^pe6hX2 z-sMhT*({__ukgN)KB@-+I*{udwN}^oy_?t`>+6?UtWq(_CX7PUFR2x|-#C<0)P<M}2*inY#R2MLbcih|^QG2znfu<@eVC1tZeCYz0p6+RC!P0Ng?b~mvT$-63PpUc3%`ZuPUtJ=%A$Msc~!uwC6&5< z{Ocsy!0oL9>ktPEl<>==mjNtZ;gBzlq(SpDEurp;^2sGj5dk_8MT?YnwoppTUn`r7 zzS&my-QkY)r9~(pC*D?!XC_2OoSsXdg)kg$P3k`nPyW=EDqdZHpzCpOXkt$7hUE&V;Yxf9q%oU<)4 zCM8*vS7S@_>p+0HB+udf4=frfp~%IqaNm87;8Mqp&(UfSt2Ubxry@VyOIcd&4fx|_9KL9JT zPgqsNKzV=$I)x^_qAY}$DJ&CI&nat*uJ~Q%-w3hTb8oh7dr~|A>in4~;SsF5^H69-#^pZV7Uv?@rKPBNA3Ahv6+bSjqbrB6~6VGwjoArCMsQ(CM$*Q$EKcs;CxJ} zvy;j`kTAoMB36t?dtM7$V^`foa+Vr2EuDOTmwNV!*SjzJjyUY|7fZDw_PZw@1^@r+ zJ;~n?36nKRK0%Qr{uc*W1&(J%x}@9@40eBgPnD}(epr8z6RXXFqX@GK)pY8E+pC7} zAAx9HiKwePR*Y0RH0MS0)NdGQIHF^hwKbIDBe<`O}egX7B#- zo5caYL+aU;um9%ZioV5o`78U&y9DZcH6ko%&-qEe8H$W|f7&8cnlVEsPn*nDN)xsx zmt-Taa_d)HaE=LF-lQ2i(_?C+`#wp1{Uo5Uudr`Ti|AK-J2FFO=*yB|>fNT|2WyT#B##LX~vj*VL zz%8M|s|9Rw+!nhvf#ukdvJV5#slFcqDA}u{%8H1($NOTVVMUnr?StS?M20LDdGM?1n0J*HHwhl%@FJ55pVkl$$nTSAcK*={% zXe^q{tlP=c@qH(WdSA4Zo*GsSvWtpZhyF=5OBbdk{%>P%)=WoV@oH>4Y!yWRS&x4+(UqhdW!2; z+$h^8BOCg9!FDKnbi)T7(X<$6`*R18_<7pCun$@E*HTm=2N+nrBzFeK-V}sB+Rl&0OfUi(q$LhK%no_`PWZ+(IhsJr2{_3=ft zH|QYjP^O04(HBKfjVBs|*Icrz*ca;9{R$GpWp4jVXVc7NNKGLK#{k_NCb4c$V$ih+ zaRciBZheKU<6NH$KV0@iGY7kP*PJx0%73PjJ@d#!3=V$})BW-b<^oM;t-2JjeLK+~ zrubmIJi@spc)o=MasNWk*YYw=s@-MXcaCoA|88>scLDC#Z{k$AqbHgy_tf7JrL9&)KW+nfKxy$GFu3(74wPF%ntcId**pf z%Tdo`?BVJ7e4BAXoji)O?s>4RFO{gvhf}O#_)a14IK{Pqf8~dZbDjZpma* zhloZ}`9Zm2x(z;Hpti`0g$e}V99gojER`f6~ES+L46^PQyUu!tQX59=i1OUj!NUhIYjC{ z{wl6J=u0f<-Zn8i8Eo+dLnNub6ha5l7R4Oq_yfH(9{YMgAo1Q~lkm;Vz31N;1bqx= zooD=Ad{~OtY#@0Zr%Lczpi)J%pC8;0Ll{W%x5Qyw2ogk0bAbDu0O9a*>za!HXZYZE z&m>;6b1i7tZIA9?5=|Z|(SHm}W)r zK{SYw)H6%9cPjs^zz5Fv5;?qmC@8|_$F+;>?fv-J@5XKPaOzM?!MWx7PjZ4st`bEq z#uQ}%GrI(Mgo3SNx7r^HQFHty1I(nv=svQ7=%F$?2?;Vt_)XRr2OO&eGRTKL(VbBE zf{N;(A93I0eqdOg8`*grD~?{Rdu-z2HLzQDNa;$W+wKLx7xZrr-Y|FQEx9?fBZ4J3 zGi9P>NM)s@mc%2aa6B6*qWjb|Q(Kf%6S(4CaU{>F44?6Px#6$d>k9H>P7||>W zlM8)(kOi(%jD5hI4~@MsS=T4JI=1}&d3D_y)=Ywv@N=Y00*F!#fPn&TQR++PaeLO4 z@IEM99>7x@*}gT|KQXM+bFhp4x2De?aiR%C9vg1l6S*X!QbRj?!)i^=+nif^{*Fp3 z7O!U~w+-82S(-+BYaVXmDR3ZXj?^)VR48V*$V^cZkvf?xNjx>HfO9Uq!cG3L1&?u; z5T<=H#0y><%zz*;tI(SmTUwurbrlWlLq6rNO*wP#8_J zPHianzyNi^xJ4q4B(5#hN9;yD9k6AJ7?1@Qk0lpu1Rtp@GWIM}BB?+Lj2TRlF|S=} zd&TWQ6qC#<^jCq0_as@)EX~wTbPs42b zi8!es>S@H81;Qmx!qKh-9LiA<6Hbw_ILSpV3Vn3}*>2j%GHlzQSz2W>^#i6%nQVCl;v{SGRgK+~zVGwV#9YoyP%Znn#0Tox9c4FJ zaoYSM4qAEDFrZoiTMFkVTJeG++)uPRDoC=Qj$b(SH#0`}OIMY*u+_xhU5Y`@ks9om z@38Na$APvCkk0>?oDk?fLD6pi7ixNDpJ#a`q}*i;BC58$Q@vAZl)bVWmt4H0|7kWx zpb(E4h)8EZ*i+_(EePpL%8R2E2?(e)fV38I^hJPZz>+(O6&B@j?{c3=E{`ebJO@fYemwBKlHdGFL{S8N`Da{gIOX=!OG19VW0 zUR!8uP7K#l4AuHd5)i`5muVoGu@4viWRZ$g-Hw9b0!0~oDP(42N4v4PvCQ3bb|1pbUcV2Q6HNk!0K@IC5) z%>;8^!{{p%TD^CqEzsbG`64%nTLA9GkY|WR9qWkT>jm#S=l>HX2WxqT^H6 zHmciJ*u_MDIGKN6l^P+YSp+3cb-pE*UZDG$=cq5G8bL&X89p0k2rQIJROaAge;gF#NM z2$|hP_|Zwi{knyg?YV0gmPb_L6HF+V!i)r#z?eK24xs+NjHtq(!0>MZsh(z+L#J6r z;gER(Gi_oREn-))kWhd3FfEk+GBK6;p<15;$EQrEzx7y|HlyDz)M&OAypXpq>wEq{ z?Y4b9@<^gDw1ds)#=&xuh)Kf-mqR7zR<`E@1+ALmDU=B8W6o=~2So`Xi z>8=69FmcCp5gHg#1Cjix*Y?$^)y2sw`6|Od=A)Wx=;2kNFh}4aqCfgC5N9u#e+ed} z=niX9UN>nw2hGS+OMFBd14<#fmb-XRLER`o%1>aP)j+5BEsyO4gz!vg-c!67Y<+`3 zBKF%H*Ff-I+TZUw#H63b=w>0u@mY8KlB@UNE1O7DFv(K04fk62v7_g|#xmm_B-gIR zrBE4N?%elR`Cb?+LUw>8``g@cXZL<|UiX1ya4g`FPIL~bTRWs{d&^-UZV>w2jGL<0 z4M3Lt3jPBiSTYf*_R1s(^4_$L0CxGkYkG;8H+xVgO4SC zw-aSG3Wcfj^;W3}>BW8sD1g%V((#kNcxasoX`l&5qckqPR{XpRQw5<5)<%xfi9#lc z@MH1|Pkqi{+#Ql(0t1vo9T8WyKDc#}9ZMA5A*|I{!8`K?j+Sa~vQPowwWYYSBxz=l zqSukL*a2Cy=vmPcH~}#?y>e13ZY;Yu2sp6chNuMQ3dJCg$F78UOi=$YUhehE9DjB8 z4xC&_&NE6#mL>U$5Rpe!8yQ@%Q<3WP@t1~0PB`V?Jib?5qC#FjXAyr;1LK89oV|#) zFGGwy5RF|h(({mdE)#0Jf;YIqK)E<{`=y~DGUjaG2(E;bL8!bBHQXh9R)RuF40j<=a@*qKL)Oot<)fBQLWh0cx3(wa*Bw|mZy_&I?dZ-1GfX!BN#+Zq{c z^Z;x*zE6UtCs*Hx&5Y`OjNLr9Q6*ygiY8&^QZa!DNlLrl@t90T7f3)-D@&$lOwJdH z?SU;rxv{#A$53bHji(!^@s&P30W8tyS21n8vIn%ix7cxZr<2=^D5?>jE)0k|AmP^C zS!=WujTetePa=4H9IUqiMnFJ_;nU|kC>OyF6NL=X<1*@3dWlWWB}Dt*pO~GgWgdEX^N=`wk6 zgcWc*xn%(ys1@{tfn)qS{c>;MJ`GcZm^4!|r_aCoF*M!SSLipAF5AYv+wY&$(*eFT z6{zy~sXP+#JD#HBWumDT2C=|FWZ-ph3$THfhALtLxgx>qz2Gw@;oS9B2X#R4I94d4 zUl+F$^zB2wRk#k zsDKHKW&T=Bi@vl@p38Knncj2M4;Q{<fOy665#eBMW|cVA8>)l3K2ga6YaS+A+i>iA4EONDeQ-i1Dw4gJJ?`@ry!+C38T?k4JMo?jA&s- z(E8S^)>r0B#EQFddGfQ3y0liAk9bz>p-AL_@E;ft8xQQZFXOw9-EI6a&SfpokJ%eE zu!(4Eo%Vj?SB5wuT+9^m;1Q82KQ3ychun9O_W8)KsP1T?e|R?!%_VCVnNlaF$xH3- zc8W)vi>V4n5$Wq4KyUtKGKt^^111&QlSPMm}@5gt8 zfc}Gl-w22$+Qii>`aHgSG|tU!HD#QHwC1j$J>K?qKP%gl0F9d<3g8WE7EDubn>(`9 zN{!={bFLC?XUx2(8sNAB6l8=DBw)mn+tni}&x-Du)bcK%{xy+ry~nYa$7;zO8%tyu zB5WTal5B8^)BsonlMiEt)yg+e*dU|u{(~Nu$qQ+&w8JQs4V4)eW>SF=?oIEktnzC& zy!vHs*G)5#(nfFfZvlB7fd@zq)da$sol8qtF%P%nFhw^q7T0t@EbuMQx8dzwmqQE` zNmP)lgNL`%1PxF}os(-;?Q)2w8|O4lfuJzK#p&A3>Nodx)0M_X&0cRNXW9 zPhijOoy*>Bv;vofSOI3`N4@dmELfSMYZ-6|8<-9lNM6L|b&J~Pw6)(`y{uQ-SZW|r zF!MOaK1q>0{*gICidi(CTVO8uD=ziPFQ&MsU#sqpIn>~_OEWglrzwnjj+NnaPJkvl z;h6z~$dC88jJh@~{LkyqDkMnm2)PnCED%g5GPT>$&ExIOr6B@wXtM05(`#9qgjks? zKHv$r_7&f(>WQj@T^u^$(wOuYI<}O_&tA@5bTls6K&G!259HW(mR(m8CQApNJ%GL> z-UJlzSC@agd%GsW5Gl=Ve~?^d3E=W=-57wt=F@Q5ZG?HeKSMVk5Bnz_FZX*X0A+Y!s0>4*=!FRJzue3{Vxt<>v{G-_KuBWuW`WP!vOx-h=tCGha@x*R;+N%* zG!DwSd5p=c(ojKY2(@yM*eSQpP)H%k(b364nNwSH9&DEg1#%Dz3Q&9q6&)EBxIWv7 z5wiHXE%N@D-MGGas_VSg6dyEiC8$0pBzFEukHKy3zUL20*SSN8@LWi1T2&7+lqUd3 zSUWhQ&OxHO27X(HC{u^u>D2D#pCkvpZW1&mog$kwfd-fb48@{}A;WuDHVVg$`oT3p z*Wnz!V6%xBxDX#uBop(?89vqW37j4Lwz`7&d67s_#V{Y70>cv}d{QNfL z9>%W*<$1Tr8WKi5BB28>KA+zx9QGjm$iMLFD;TtG(X{4|z@{hK)F#@bP44My{1}We z_IHB#47zp)WHS7-`$v_t;c*{CP$BP7PW2n<7S*B)ECribfj}XQ0_0Sbj&Bbe*KOFX z^wHc(ZN8b}tRvE|Ync{2v{(~*AJnS1V00I};@IGMqJkC@MN>Z!8PetN|Elk>Z`J9Q zhDqp{;ZL^!6+`gF+S17sM2p5nOXc#Q%JTqPXlj=c!*JAb)DQ6IBG2oh<{n$YFQ?TS z(MJX7$;1h`pn&oiUQnF`FH(ew+BdQd^{=rSPDiSoGn#S^zo@|y`_Pn_Pj%n1VPb#< z0>X2etWsI5bnaj(-7;tJ{^^TOgR1#+sWa{nQfE0(nLgqKr?9QC0;#}^-kibaX8^u9 z6eOSn1L#^r*dg#T1DQhxR9K}lS><~3slXOb`R?G`&HYXtG$T2E{q?&@b^FO9fV#e2 zKT8&?l+M83AX0(1zh{!|uO_2{%E~|V7wr!lZg7zb4vb)`0u%Kh@Xw&qC7pPfkOG=C zfC6W$N#7-GOLeJm;_y6@F$LW~X{0K2RZas=<(J7&ZBpUS?J=d!TPfGd_zyjch{?`qrIj(v#k}m2iit~|L<_l01Q$qGlCN-RE#*&(dLwCS~=7U|% p^WdUkAz|9K8x^F|Jl6Zgu!&JOzH5T<-!G5 { + const source = svg.toString('utf8') + const glyph = ' ({ size, - png: await sharp(svg, { density: SOURCE_DENSITY * size / SOURCE_EDGE }).resize(size, size).png().toBuffer(), + png: await sharp(tray, { density: SOURCE_DENSITY * size / SOURCE_EDGE }).resize(size, size).png().toBuffer(), }))) } From 20dae9b6fa69291bd77ed02544999d5fb63873e9 Mon Sep 17 00:00:00 2001 From: winewill Date: Thu, 24 Sep 2026 12:01:00 +0800 Subject: [PATCH 4/6] test(desktop): align close behavior fixtures after master rebase --- ...background-and-quit-confirmation.i18n.yaml | 23 +++++++++++++++---- apps/desktop/tests/main-startup.spec.ts | 5 ++-- 2 files changed, 21 insertions(+), 7 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 19175415de..03212d5803 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 @@ -1,6 +1,19 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# 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: +# Bilingual-pair consistency record for 2026-09-23-desktop-close-to-background-and-quit-confirmation.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# 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: b26b040613f154185b7b98436d30cb4fb0171f46 -2026-09-23-desktop-close-to-background-and-quit-confirmation.zh.md: e4391a49a8079316bc2111b90344f3bcdad5800c +/agent-note-hide-the-desktop-window-on-close-and-confirm-interruptible-quits: + en: 9e029938c58bf143 + zh: f60f9cf2067a02ee +/agent-note-hide-the-desktop-window-on-close-and-confirm-interruptible-quits/problem: + en: 782e934449eae28c + zh: 0941465dff3a26d9 +/agent-note-hide-the-desktop-window-on-close-and-confirm-interruptible-quits/decision: + en: 245710bbcf2ac63a + zh: b040b5e42e43702d +/agent-note-hide-the-desktop-window-on-close-and-confirm-interruptible-quits/alternatives-considered: + en: ec34fbba0091f85c + zh: f5c971991056827a +/agent-note-hide-the-desktop-window-on-close-and-confirm-interruptible-quits/consequences: + en: 24a048493c352d13 + zh: 48f3c5f68c98fc11 diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 4846582702..a8cc75bff2 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -329,6 +329,7 @@ vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: class vi.mock('../src/platform-view.ts', async importOriginal => ({ ...await importOriginal(), DesktopPlatformView: class { + notifyLocaleChanged() {} setSession() {} setBounds() {} close() {} @@ -1097,7 +1098,7 @@ describe('desktop main startup', () => { const window = harness.windows[0]! expect(harness.trays).toHaveLength(1) expect(harness.trays[0]!.image).toEqual({ path: join('desktop-test-resources', 'tray.ico') }) - expect(harness.backgroundNotice.markerPath).toBe(join('desktop-test-userData', 'background-notice-shown')) + expect(harness.backgroundNotice.markerPath).toBe(join(harness.app.getPath('userData'), 'background-notice-shown')) window.show.mockClear() window.close() expect(window.isDestroyed()).toBe(false) @@ -1268,8 +1269,8 @@ describe('desktop main startup', () => { const host = await readyForUpdate() const disposal = harness.deferPlatformDispose() harness.app.quit() - expect(harness.platformDispose).toHaveBeenCalledOnce() await host.stopping.promise + expect(harness.platformDispose).toHaveBeenCalledOnce() host.exited.resolve() expect(harness.app.quit).toHaveBeenCalledOnce() disposal.resolve() From 5637311d5ab6724acf0026bac9f44ff3d8689794 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:16:59 +0800 Subject: [PATCH 5/6] fix(desktop): confirm the first close with the shared shell dialog --- ...background-and-quit-confirmation.i18n.yaml | 12 +- ...ose-to-background-and-quit-confirmation.md | 6 +- ...-to-background-and-quit-confirmation.zh.md | 6 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 4 +- apps/desktop/README.zh.md | 4 +- apps/desktop/src/background-notice.ts | 87 +++++------ apps/desktop/src/locale.ts | 8 +- apps/desktop/src/main.ts | 14 +- apps/desktop/src/update-dialog.ts | 3 + apps/desktop/tests/background-notice.spec.ts | 144 +++++++++--------- .../background-close-confirmation.json | 5 + .../desktop/tests/fixtures/update-dialogs.mjs | 45 +++++- apps/desktop/tests/main-startup.spec.ts | 43 +++++- apps/desktop/tests/update-dialog.spec.ts | 3 + 15 files changed, 229 insertions(+), 159 deletions(-) create mode 100644 apps/desktop/tests/expected/background-close-confirmation.json 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 03212d5803..28b2efa813 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 @@ -9,11 +9,11 @@ en: 782e934449eae28c zh: 0941465dff3a26d9 /agent-note-hide-the-desktop-window-on-close-and-confirm-interruptible-quits/decision: - en: 245710bbcf2ac63a - zh: b040b5e42e43702d + en: 54c0d8811a8a4390 + zh: 2baa107b3d25a138 /agent-note-hide-the-desktop-window-on-close-and-confirm-interruptible-quits/alternatives-considered: - en: ec34fbba0091f85c - zh: f5c971991056827a + en: 74dcbb66adffd7be + zh: 1be3d3fa8e68d81f /agent-note-hide-the-desktop-window-on-close-and-confirm-interruptible-quits/consequences: - en: 24a048493c352d13 - zh: 48f3c5f68c98fc11 + en: dc219aecdf03014e + zh: 621cd97cef5dc858 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 b26b040613..67c7bf92e0 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 @@ -12,7 +12,7 @@ Closing the Desktop window destroyed it. On macOS the Host kept running but the ## Decision -Closing the main window hides it on both platforms; the page and the Host keep running and the next show presents the same document. Windows gets a permanent tray icon (single click opens, context menu offers Open and Quit) because Windows users expect × to quit and need a visible way back, plus one per-installation notification the first time the window is hidden. macOS keeps the Dock and ships no menu bar icon. Closing the welcome window before the workspace opens quits on Windows and drops the unused main window on macOS, so the Dock rebuilds the welcome. +Closing the main window hides it on both platforms; the page and the Host keep running and the next show presents the same document. Windows gets a permanent tray icon (single click opens, context menu offers Open and Quit) because Windows users expect × to quit and need a visible way back, plus a first-close acknowledgement through the shared update-dialog overlay before hiding. Only Confirm records acknowledgement; cancellation keeps the window visible. Closing sends no system notification. macOS keeps the Dock and ships no menu bar icon. Closing the welcome window before the workspace opens quits on Windows and drops the unused main window on macOS, so the Dock rebuilds the welcome. 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. @@ -22,7 +22,7 @@ The Windows tray bitmaps are rendered from the vector icon at seven sizes and co **Keep quitting on close and add a confirmation there.** It asks on every close, and Windows users would still lose background work whenever they dismissed the window. Hiding matches the product requirement that tasks continue. -**Render the confirmation in the shell's HTML overlay.** The overlay needs a visible parent window and a working renderer; the quit must also work when the window is hidden or the page is stuck, so the native dialog is the reliable choice. +**Render the quit confirmation in the shell's HTML overlay.** The overlay needs a visible parent window and a working renderer; the quit must also work when the window is hidden or the page is stuck, so the native dialog is the reliable choice. **Reuse the update-tasks IPC request with a flag.** The quit needs a second fact and a shorter deadline; a distinct request keeps the update admission lock semantics untouched and lets both share one correlation id space. @@ -32,4 +32,4 @@ The Windows tray bitmaps are rendered from the vector icon at seven sizes and co Users can close the window freely; background tasks and scheduled reminders continue, and reopening restores the same page. Quitting warns only when it interrupts something. Windows carries a tray icon for the whole run, and a hidden window that finishes a user-initiated update download defers its install confirmation until the window is shown again. Reminders in sessions never loaded during the run are neither counted nor resumed until those sessions open, and Desktop does not enable scheduled tasks by default, so the scheduled-task copy appears only once that feature is on. -Verification covers the four inspection outcomes, inspection failure and deadline, each quit entry's bypass or confirmation, the close-to-hide path with the tray and the first-hide notice, the deferred update prompt, the Host-side inspection, the IPC correlation and deadline, and the committed tray icon's bitmap set. Windows tray and dialog behavior needs a manual pass on a Windows machine at several display scales. +Verification covers the four inspection outcomes, inspection failure and deadline, each quit entry's bypass or confirmation, the close-to-hide path with the tray and the first-close acknowledgement, the deferred update prompt, the Host-side inspection, the IPC correlation and deadline, and the committed tray icon's bitmap set. Windows tray and dialog behavior needs a manual pass on a Windows machine at several display scales. 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 e4391a49a8..e5a59bcd0b 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 @@ -12,7 +12,7 @@ Status: implemented ## 决策 -两个平台上关闭主窗口都只隐藏它;页面和 Host 继续运行,下次显示仍是同一个文档。Windows 增加常驻托盘图标(单击打开,右键菜单提供"打开"和"退出"),因为 Windows 用户习惯 × 即退出,需要一个看得见的回路;每次安装后第一次隐藏窗口时再发一条通知。macOS 沿用 Dock,不提供菜单栏图标。进入工作区前关闭欢迎窗口,Windows 上退出,macOS 上丢弃未使用的主窗口,让 Dock 重建欢迎窗口。 +两个平台上关闭主窗口都只隐藏它;页面和 Host 继续运行,下次显示仍是同一个文档。Windows 增加常驻托盘图标(单击打开,右键菜单提供"打开"和"退出"),因为 Windows 用户习惯 × 即退出,需要一个看得见的回路;首次关闭时先通过复用的更新弹窗确认,再隐藏窗口。只有点击“确认”才记录已读;取消时保持窗口可见。关闭窗口不发送系统通知。macOS 沿用 Dock,不提供菜单栏图标。进入工作区前关闭欢迎窗口,Windows 上退出,macOS 上丢弃未使用的主窗口,让 Dock 重建欢迎窗口。 所有普通退出入口都通过私有 IPC 通道向 Host 询问两项事实:按更新重启口径判定的运行中任务,以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出;否则弹出一个没有父窗口的原生消息框,显示三种固定说明之一,"退出"为默认按钮,Esc 对应"取消"。重复的退出请求并入已打开的弹框,弹框打开期间文案冻结,批准后不再重新查询。Host 未就绪时不可能有任务在跑,直接退出;查询失败或超过两秒截止时间按运行中任务处理,因为多提示一次的代价低于无声中断。安装更新的重启、致命错误恢复、开发版重启命令和操作系统会话结束跳过确认。 @@ -22,7 +22,7 @@ Windows 托盘位图由矢量图标按七种尺寸分别渲染并提交为 ICO **保留关闭即退出,只在关闭时加确认。** 每次关闭都要询问,而 Windows 用户只要想收起窗口,后台工作就仍会丢失。隐藏才符合"任务继续运行"的产品要求。 -**用壳的 HTML 遮罩渲染确认框。** 遮罩需要可见的父窗口和正常工作的渲染进程;退出必须在窗口隐藏或页面卡死时也能完成,因此原生对话框才是可靠选择。 +**用壳的 HTML 遮罩渲染退出确认框。** 遮罩需要可见的父窗口和正常工作的渲染进程;退出必须在窗口隐藏或页面卡死时也能完成,因此原生对话框才是可靠选择。 **在 update-tasks IPC 请求上加一个标志复用。** 退出需要第二项事实和更短的截止时间;独立请求不触碰更新准入锁的语义,同时两者共用一个关联 id 空间。 @@ -32,4 +32,4 @@ Windows 托盘位图由矢量图标按七种尺寸分别渲染并提交为 ICO 用户可以随意关闭窗口;后台任务和定时提醒继续运行,重新打开时恢复同一页面。退出只在会中断工作时提醒。Windows 在整个运行期间携带托盘图标;窗口隐藏期间完成的用户主动发起的更新下载,把安装确认推迟到窗口再次显示时。本次运行中从未加载的会话里的提醒既不计入,也要等到这些会话打开后才会继续,而 Desktop 默认未开启定时任务,因此定时任务文案只在该功能开启后出现。 -验证覆盖四种查询结果、查询失败与截止时间、每个退出入口的跳过或确认、带托盘和首次隐藏通知的关闭即隐藏路径、推迟的更新提示、Host 侧查询、IPC 关联与截止时间,以及已提交托盘图标的位图集合。Windows 托盘和对话框的表现需要在 Windows 机器上以多种显示缩放人工验收。 +验证覆盖四种查询结果、查询失败与截止时间、每个退出入口的跳过或确认、带托盘和首次关闭确认的关闭即隐藏路径、推迟的更新提示、Host 侧查询、IPC 关联与截止时间,以及已提交托盘图标的位图集合。Windows 托盘和对话框的表现需要在 Windows 机器上以多种显示缩放人工验收。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 4f37d399a4..dd50888d20 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: bc82af4f34ac7d6d - zh: 5e97cdf7f293b3fa + en: 709a7f63fbac58b6 + zh: 24f0f57997f88314 /deepseek-harness-desktop/key-technical-decisions: en: 069fdfd72d2ea26a zh: 76ae549d43c99bb4 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 8e019d9a48..e69942ef00 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -22,9 +22,9 @@ Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Sh ## Closing the window and quitting -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. +Closing the main window (macOS close button and ⌘W; Windows ×, Alt+F4, and the taskbar Close window command) hides it; Windows asks for acknowledgement before the first hide. 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 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. +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. Before the first hide, the shared update-dialog overlay explains that running tasks continue and the window can be reopened from the system tray. Confirm hides the window and writes `background-close-confirmed` under Electron userData; Escape, dismissal, or dialog failure keeps the window visible and does not record acknowledgement. Repeated close requests focus the existing shell dialog. In-place updates retain the marker and uninstall removes it. The old `background-notice-shown` marker does not suppress this confirmation. Closing the window sends no system notification. 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 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. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 104f9e2225..268b44f4ae 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -22,9 +22,9 @@ Desktop Host 的 Platform API 请求与更新策略请求用相同的 Platform ## 关闭窗口与退出 -关闭主窗口(macOS 的关闭按钮和 ⌘W;Windows 的 ×、Alt+F4 和任务栏"关闭窗口")只隐藏窗口,不弹确认。页面和 Host 继续运行,任务不受影响,下次显示时仍是原来的文档,会话、草稿和滚动位置都保留;macOS 全屏窗口先退出全屏再隐藏。macOS 通过 Dock 图标、再次启动或 `dsh://open` 找回窗口,Windows 通过托盘找回。最小化行为不变。进入工作区前关闭欢迎窗口,Windows 上走退出流程,macOS 上应用留在 Dock 中且没有窗口。 +关闭主窗口(macOS 的关闭按钮和 ⌘W;Windows 的 ×、Alt+F4 和任务栏"关闭窗口")会隐藏窗口;Windows 首次隐藏前需要确认。页面和 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 下写入 `background-close-confirmed`;Esc、关闭弹窗或加载失败均保持主窗口可见,不记录确认。重复关闭请求会聚焦已有壳弹窗。覆盖更新保留标记,卸载删除标记。旧的 `background-notice-shown` 标记不会跳过此确认。关闭窗口不发送系统通知。托盘位图是 `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 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会并入同一弹框而不叠加新弹框(macOS 上还会把它提到前面;Electron 不暴露 Windows 任务对话框的句柄);任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。取消由关闭欢迎窗口引起的退出时,欢迎窗口会重新显示。 diff --git a/apps/desktop/src/background-notice.ts b/apps/desktop/src/background-notice.ts index 78ea2e1004..c2a0007d2e 100644 --- a/apps/desktop/src/background-notice.ts +++ b/apps/desktop/src/background-notice.ts @@ -1,69 +1,56 @@ -/** One-time Windows notice that closing the window left the application running in the tray. */ +/** One-time Windows confirmation before hiding the application in the tray. */ -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' -import { Notification } from 'electron' +import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron' import type { DesktopLocale } from './locale.ts' -/** Marker location and the actions the notice can trigger. */ +/** Persistent acknowledgement and the shared shell dialog. */ export interface DesktopBackgroundNoticeOptions { - /** - * 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. - */ + /** Acknowledgement under Electron userData; updates retain it and uninstall removes it. */ readonly markerPath: string readonly locale: () => DesktopLocale - /** Show and focus the primary window when the notice is clicked. */ - readonly open: () => void + readonly show: (options: MessageBoxOptions) => Promise + readonly focus: () => void } -/** Shows once per installation, and at most once per process even when the marker cannot be written. */ +/** Only an explicit acknowledgement permits the first hide; cancelled prompts remain eligible. */ export class DesktopBackgroundNotice { - private shown = false - private notification: Notification | undefined + private acknowledged = false + private pending = false + private disposed = false - /** @param options - Marker path, locale reader, and the click action. */ + /** @param options - Marker path, localized copy, and shell dialog actions. */ constructor(private readonly options: DesktopBackgroundNoticeOptions) {} /** - * 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. + * Request a window hide, prompting until acknowledged and coalescing repeated requests. + * @param hide - Hide the still-owned window after acknowledgement, or immediately when already recorded. */ - show(): void { - if (this.shown) return - this.shown = true - if (existsSync(this.options.markerPath)) return - try { - if (!Notification.isSupported()) return - const { messages } = this.options.locale() - const notification = new Notification({ title: messages.backgroundNoticeTitle, body: messages.backgroundNoticeBody, silent: true }) - this.notification = notification - notification.on('failed', () => { - if (this.notification === notification) this.notification = undefined - notification.removeAllListeners() - this.forget() - }) - notification.once('click', () => { - if (this.notification !== notification) return - this.notification = undefined - notification.removeAllListeners() - this.options.open() - }) - notification.show() - } 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) } + close(hide: () => void): void { + if (this.disposed) return + if (this.pending) { this.options.focus(); return } + if (this.acknowledged || existsSync(this.options.markerPath)) { hide(); return } + this.pending = true + void this.confirm(hide) } - private forget(): void { - try { rmSync(this.options.markerPath, { force: true }) } - catch (error) { console.warn('desktop tray: could not reset the background notice', error) } + /** Ignore late dialog responses after application shutdown begins. */ + dispose(): void { this.disposed = true } + + private async confirm(hide: () => void): Promise { + try { + const { messages } = this.options.locale() + const result = await this.options.show({ type: 'info', title: messages.aboutProduct, + message: messages.backgroundNoticeBody, buttons: [messages.backgroundNoticeConfirm], defaultId: 0, cancelId: -1 }) + if (this.disposed || result.response !== 0) return + this.acknowledged = true + try { + mkdirSync(dirname(this.options.markerPath), { recursive: true }) + writeFileSync(this.options.markerPath, '') + } catch (error) { console.warn('desktop tray: could not record background confirmation', error) } + hide() + } catch (error) { console.warn('desktop tray: background confirmation unavailable', error) } + finally { this.pending = false } } } diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 2648cc497a..b3f9e535c9 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -18,8 +18,8 @@ export const en = { quitActiveTasks: 'Running tasks will be interrupted.', quitScheduledTasks: 'Scheduled tasks will not run while the app is closed.', quitActiveAndScheduledTasks: 'Running tasks will be interrupted, and scheduled tasks will not run while the app is closed.', - backgroundNoticeTitle: 'DeepSeek Harness is still running', - backgroundNoticeBody: 'Running tasks will continue. Reopen or quit it from the system tray.', + backgroundNoticeBody: 'Running tasks will continue. You can reopen the window from the system tray.', + backgroundNoticeConfirm: 'Confirm', edit: 'Edit', menuBar: 'Application menu', delete: 'Delete', @@ -162,8 +162,8 @@ export const zh = { quitActiveTasks: '当前正在运行的任务将会中断', quitScheduledTasks: '应用关闭期间,定时任务不会运行', quitActiveAndScheduledTasks: '当前正在运行的任务将会中断,且应用关闭期间,定时任务不会运行', - backgroundNoticeTitle: 'DeepSeek Harness 仍在后台运行', - backgroundNoticeBody: '正在运行的任务不会中断。可在系统托盘中重新打开或退出。', + backgroundNoticeBody: '正在运行的任务不会中断,可在系统托盘中重新打开窗口', + backgroundNoticeConfirm: '确认', edit: '编辑', menuBar: '应用菜单', delete: '删除', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2823879069..19816f7464 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -937,8 +937,8 @@ async function main(): Promise { } catch (error) { console.warn('desktop tray: unavailable', error) } } const backgroundNotice = process.platform === 'win32' - ? new DesktopBackgroundNotice({ markerPath: join(app.getPath('userData'), 'background-notice-shown'), - locale: () => locale, open: () => { focusPrimaryWindow() } }) + ? new DesktopBackgroundNotice({ markerPath: join(app.getPath('userData'), 'background-close-confirmed'), + locale: () => locale, show: ordinaryMessageBox, focus: () => { updateDialog.focus() } }) : undefined const quitConfirmation = new DesktopQuitConfirmation({ locale: () => locale, @@ -1008,7 +1008,6 @@ async function main(): Promise { } else { window.hide() } - backgroundNotice?.show() } const createMainWindow = (): BrowserWindow => { const window = createWindow(appPreload, false, true) @@ -1020,7 +1019,12 @@ async function main(): Promise { window.on('close', (event) => { if (quitting || shellInstallerOwnsQuit || sessionEnding) return event.preventDefault() - hideMainWindow(window) + if (updateDialog.isOpen) { updateDialog.focus(); return } + const hide = (): void => { + if (!quitting && !shellInstallerOwnsQuit && !sessionEnding && !window.isDestroyed()) hideMainWindow(window) + } + if (backgroundNotice === undefined) hide() + else backgroundNotice.close(hide) }) if (process.platform === 'win32') { // Shutdown, restart, and log-off must not wait on a confirmation. query-session-end is only a @@ -1183,6 +1187,7 @@ async function main(): Promise { shuttingDown = true updateJournal?.action('quit-requested') quitConfirmation.dispose() + backgroundNotice?.dispose() tray?.dispose() stopAccount?.() if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.hide() @@ -1198,6 +1203,7 @@ async function main(): Promise { app.on('before-quit', (event) => { if (shellInstallerOwnsQuit) { shuttingDown = true + backgroundNotice?.dispose() updateJournal?.action('quit-requested') tray?.dispose() updateDialog.dispose() diff --git a/apps/desktop/src/update-dialog.ts b/apps/desktop/src/update-dialog.ts index db0fd390e6..249294f8ae 100644 --- a/apps/desktop/src/update-dialog.ts +++ b/apps/desktop/src/update-dialog.ts @@ -48,6 +48,9 @@ export class DesktopUpdateDialog { /** Focus the current explanation or confirmation without replacing it or granting permission. */ focus(): void { this.active?.window.focus() } + /** Whether a shell prompt is awaiting a response. */ + get isOpen(): boolean { return this.active !== undefined } + /** * @param preload - Bundled isolated preload. * @param locale - Shell-owned copy or a reader of the current UI language. diff --git a/apps/desktop/tests/background-notice.spec.ts b/apps/desktop/tests/background-notice.spec.ts index e9dcf3c0bf..cfccf81dd3 100644 --- a/apps/desktop/tests/background-notice.spec.ts +++ b/apps/desktop/tests/background-notice.spec.ts @@ -1,97 +1,95 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron' import { afterEach, expect, it, vi } from 'vitest' import { DesktopBackgroundNotice } from '../src/background-notice.ts' import { resolveDesktopLocale } from '../src/locale.ts' -const native = await vi.hoisted(async () => { - const { EventEmitter } = await import('node:events') - const notices: Notice[] = [] - class Notice extends EventEmitter { - static isSupported = vi.fn(() => true) - show = vi.fn() - constructor(readonly options: unknown) { super(); notices.push(this) } - } - return { notices, Notice } -}) -vi.mock('electron', () => ({ Notification: native.Notice })) - const roots: string[] = [] afterEach(() => { - native.notices.length = 0 - vi.clearAllMocks() vi.restoreAllMocks() - native.Notice.isSupported.mockReturnValue(true) for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) -function setup(markerPath = join(mkdtempSync(join(tmpdir(), 'dsh-desktop-notice-')), 'state', 'background-notice-shown')) { - roots.push(join(markerPath, '..', '..')) - const open = vi.fn() - const notice = new DesktopBackgroundNotice({ markerPath, locale: () => resolveDesktopLocale('zh'), open }) - return { notice, open, markerPath } -} - -it('notifies once per installation, records the marker, and opens the window on click', () => { - const f = setup() - f.notice.show() - f.notice.show() - expect(existsSync(f.markerPath)).toBe(true) - expect(native.notices).toHaveLength(1) - expect(native.notices[0]!.options).toEqual({ - title: 'DeepSeek Harness 仍在后台运行', body: '正在运行的任务不会中断。可在系统托盘中重新打开或退出。', silent: true, - }) - expect(native.notices[0]!.show).toHaveBeenCalledOnce() - native.notices[0]!.emit('click') - native.notices[0]!.emit('click') - expect(f.open).toHaveBeenCalledOnce() - // The next process of the same installation finds the marker. - const again = new DesktopBackgroundNotice({ markerPath: f.markerPath, locale: () => resolveDesktopLocale('zh'), open: f.open }) - again.show() - expect(native.notices).toHaveLength(1) -}) - -it('still notifies once in this process when the marker cannot be written', () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) +function setup() { const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-notice-')) roots.push(root) - // A file where the marker's directory should be makes the marker write fail. - writeFileSync(join(root, 'blocker'), '') - const open = vi.fn() - const blocked = new DesktopBackgroundNotice({ markerPath: join(root, 'blocker', 'background-notice-shown'), locale: () => resolveDesktopLocale('zh'), open }) - blocked.show() - blocked.show() - expect(console.warn).toHaveBeenCalledWith('desktop tray: could not record the background notice', expect.anything()) - expect(native.notices).toHaveLength(1) -}) + const markerPath = join(root, 'background-close-confirmed') + const response = Promise.withResolvers() + const show = vi.fn<(options: MessageBoxOptions) => Promise>(() => response.promise) + const focus = vi.fn() + const hide = vi.fn() + const options = { markerPath, locale: () => resolveDesktopLocale('zh'), show, focus } + return { root, markerPath, response, show, focus, hide, options, notice: new DesktopBackgroundNotice(options) } +} -it('keeps the notice for a later launch when notifications are unsupported or delivery fails', () => { - native.Notice.isSupported.mockReturnValue(false) +it('keeps the window visible until confirmation and remembers acknowledgement across launches', async () => { const f = setup() - f.notice.show() - f.notice.show() - expect(native.notices).toHaveLength(0) + writeFileSync(join(f.root, 'background-notice-shown'), '') + f.notice.close(f.hide) + f.notice.close(f.hide) + expect(f.hide).not.toHaveBeenCalled() 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) + expect(f.show).toHaveBeenCalledExactlyOnceWith({ type: 'info', title: 'DeepSeek Harness', + message: '正在运行的任务不会中断,可在系统托盘中重新打开窗口', buttons: ['确认'], defaultId: 0, cancelId: -1 }) + expect(f.focus).toHaveBeenCalledOnce() + f.response.resolve({ response: 0, checkboxChecked: false }) + await vi.waitFor(() => { expect(f.hide).toHaveBeenCalledOnce() }) + expect(existsSync(f.markerPath)).toBe(true) + new DesktopBackgroundNotice(f.options).close(f.hide) + expect(f.hide).toHaveBeenCalledTimes(2) + expect(f.show).toHaveBeenCalledOnce() }) -it('does not record a notice the system refused to construct', () => { +it('keeps cancellation eligible for another close and never records it', async () => { + const f = setup() + f.notice.close(f.hide) + f.response.resolve({ response: -1, checkboxChecked: false }) + await f.response.promise + expect(f.hide).not.toHaveBeenCalled() + expect(existsSync(f.markerPath)).toBe(false) + f.show.mockResolvedValue({ response: 0, checkboxChecked: false }) + f.notice.close(f.hide) + await vi.waitFor(() => { expect(f.hide).toHaveBeenCalledOnce() }) + expect(f.show).toHaveBeenCalledTimes(2) +}) + +it('does not hide or record a late acknowledgement after disposal', async () => { + const f = setup() + f.notice.close(f.hide) + f.notice.dispose() + f.response.resolve({ response: 0, checkboxChecked: false }) + await f.response.promise + f.notice.close(f.hide) + expect(f.hide).not.toHaveBeenCalled() + expect(existsSync(f.markerPath)).toBe(false) + expect(f.show).toHaveBeenCalledOnce() +}) + +it('keeps acknowledgement in memory when its marker cannot be written', async () => { + const f = setup() vi.spyOn(console, 'warn').mockImplementation(() => {}) + writeFileSync(join(f.root, 'blocker'), '') + const notice = new DesktopBackgroundNotice({ ...f.options, markerPath: join(f.root, 'blocker', 'confirmed') }) + notice.close(f.hide) + f.response.resolve({ response: 0, checkboxChecked: false }) + await vi.waitFor(() => { expect(f.hide).toHaveBeenCalledOnce() }) + notice.close(f.hide) + expect(f.hide).toHaveBeenCalledTimes(2) + expect(f.show).toHaveBeenCalledOnce() + expect(console.warn).toHaveBeenCalledWith('desktop tray: could not record background confirmation', expect.anything()) +}) + +it('keeps the window visible after a dialog failure and permits another attempt', async () => { const f = setup() - native.Notice.isSupported.mockImplementation(() => { throw new Error('toast host unavailable') }) - f.notice.show() - expect(native.notices).toHaveLength(0) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + f.notice.close(f.hide) + f.response.reject(new Error('dialog unavailable')) + await vi.waitFor(() => { expect(console.warn).toHaveBeenCalled() }) + expect(f.hide).not.toHaveBeenCalled() expect(existsSync(f.markerPath)).toBe(false) - expect(console.warn).toHaveBeenCalledWith('desktop tray: background notice unavailable', expect.any(Error)) + f.show.mockResolvedValue({ response: 0, checkboxChecked: false }) + f.notice.close(f.hide) + await vi.waitFor(() => { expect(f.hide).toHaveBeenCalledOnce() }) }) diff --git a/apps/desktop/tests/expected/background-close-confirmation.json b/apps/desktop/tests/expected/background-close-confirmation.json new file mode 100644 index 0000000000..ad06c03b9e --- /dev/null +++ b/apps/desktop/tests/expected/background-close-confirmation.json @@ -0,0 +1,5 @@ +{ + "message": "正在运行的任务不会中断,可在系统托盘中重新打开窗口", + "buttons": ["确认"], + "detailHidden": true +} diff --git a/apps/desktop/tests/fixtures/update-dialogs.mjs b/apps/desktop/tests/fixtures/update-dialogs.mjs index 9cb07be7c9..c36fc71d49 100644 --- a/apps/desktop/tests/fixtures/update-dialogs.mjs +++ b/apps/desktop/tests/fixtures/update-dialogs.mjs @@ -1,11 +1,13 @@ /** Real isolated update dialogs; installer handoff is recorded by the caller's inert updater. */ import assert from 'node:assert/strict' import { once } from 'node:events' -import { readFile, writeFile } from 'node:fs/promises' +import { access, readFile, writeFile } from 'node:fs/promises' 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 { DesktopUpdateOverlays } from '../../lib/types/update-overlay.js' +import { DesktopBackgroundNotice } from '../../lib/types/background-notice.js' import { desktopUpdateReadyConfirmation, resolveDesktopLocale } from '../../lib/types/locale.js' async function rendered(window) { @@ -30,7 +32,7 @@ export async function qualifyUpdateDialogs(root, fixture) { return new Response(await readFile(new URL(`../../renderer/${name}`, import.meta.url)), { headers: { 'content-type': mime } }) }) const parent = new BrowserWindow({ show: true, width: 900, height: 650 }) - const dialogs = new DesktopUpdateDialog(fileURLToPath(new URL('../../lib/preload-update-dialog.cjs', import.meta.url)), locale) + const dialogs = new DesktopUpdateDialog(fileURLToPath(new URL('../../lib/preload-update-dialog.cjs', import.meta.url)), locale, new DesktopUpdateOverlays()) try { await parent.loadURL('data:text/html;charset=utf-8,Update dialog qualification

Local updater

') const f = await fixture() @@ -113,6 +115,45 @@ export async function qualifyUpdateDialogs(root, fixture) { assert.deepEqual(f.installations, [[true, true]]) errorWindow.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' }) assert.equal((await pending).response, 0) + dialogs.cancel() + await once(errorWindow, 'closed', { signal: AbortSignal.timeout(10_000) }) + const markerPath = join(root, 'background-close-confirmed') + const notice = new DesktopBackgroundNotice({ markerPath, locale: () => locale, + show: options => dialogs.show(parent, options), focus: () => dialogs.focus() }) + try { + for (const confirm of [false, true]) { + parent.show() + const createdNotice = once(app, 'browser-window-created', { signal: AbortSignal.timeout(10_000) }) + const hidden = Promise.withResolvers() + notice.close(() => { parent.hide(); hidden.resolve() }) + const [, noticeWindow] = await createdNotice + await once(noticeWindow.webContents, 'did-finish-load', { signal: AbortSignal.timeout(10_000) }) + await rendered(noticeWindow) + assert.equal(parent.isVisible(), true) + assert.deepEqual(await noticeWindow.webContents.executeJavaScript(`({ + message: document.getElementById('title').textContent, + buttons: [...document.querySelectorAll('#actions button')].map(button => button.textContent), + detailHidden: document.getElementById('detail').hidden, + })`), JSON.parse(await readFile(new URL('../expected/background-close-confirmation.json', import.meta.url), 'utf8'))) + await assert.rejects(access(markerPath)) + const closedNotice = once(noticeWindow, 'closed', { signal: AbortSignal.timeout(10_000) }) + if (confirm) { + const name = 'background-close-confirmation.png' + await noticeWindow.webContents.executeJavaScript(`Promise.all(document.getAnimations().map(animation => animation.finished))`) + await writeFile(join(root, name), (await noticeWindow.webContents.capturePage()).toPNG()) + screenshots.push(name) + await noticeWindow.webContents.executeJavaScript("setTimeout(() => document.querySelector('.primary').click(), 0); undefined") + await hidden.promise + } else noticeWindow.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' }) + await closedNotice + assert.equal(parent.isVisible(), !confirm) + } + await access(markerPath) + parent.show() + notice.close(() => { parent.hide() }) + assert.equal(parent.isVisible(), false) + assert.equal(dialogs.isOpen, false) + } finally { notice.dispose() } return screenshots } finally { dialogs.dispose() diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index a8cc75bff2..b239837acd 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -180,10 +180,11 @@ const harness = await vi.hoisted(async () => { readonly destroy = vi.fn() constructor(readonly image: unknown) { super(); trays.push(this) } } - const backgroundNotice = { show: vi.fn(), markerPath: undefined as string | undefined } + const backgroundNotice = { close: vi.fn((hide: () => void) => { hide() }), dispose: vi.fn(), markerPath: undefined as string | undefined } + const shellDialog = { isOpen: false, focus: vi.fn() } return { failWindow(error: Error) { windowFailure = error }, - windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, trays, FakeTray, backgroundNotice, + windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, trays, FakeTray, backgroundNotice, shellDialog, menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall, platformDispose, platformCloseAndWait, @@ -226,6 +227,7 @@ const harness = await vi.hoisted(async () => { windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners() trays.length = 0 backgroundNotice.markerPath = undefined + shellDialog.isOpen = false powerMonitor.removeAllListeners() app.isPackaged = true windowFailure = undefined @@ -283,7 +285,8 @@ vi.mock('electron', () => ({ })) vi.mock('../src/background-notice.ts', () => ({ DesktopBackgroundNotice: class { constructor(options: { markerPath: string }) { harness.backgroundNotice.markerPath = options.markerPath } - readonly show = harness.backgroundNotice.show + readonly close = harness.backgroundNotice.close + readonly dispose = harness.backgroundNotice.dispose } })) vi.mock('node:fs/promises', async (importOriginal) => { const original = await importOriginal() @@ -307,12 +310,13 @@ vi.mock('../src/host-process.ts', async importOriginal => ({ ...await importOriginal(), DesktopHostProcess: harness.FakeHost, })) vi.mock('../src/update-dialog.ts', () => ({ DesktopUpdateDialog: class { + get isOpen() { return harness.shellDialog.isOpen } show(owner: { options: { modal?: boolean } }, options: unknown) { return (owner.options.modal ? harness.dialog.showMessageBox(owner, options) : harness.dialog.showMessageBox(options)) as Promise } cancel() {} - focus() {} + readonly focus = harness.shellDialog.focus dispose() {} } })) vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: class { @@ -1093,19 +1097,19 @@ describe('desktop main startup', () => { expect(harness.trays[0]!.destroy).toHaveBeenCalledOnce() }) - it('hides the main window on close, keeps the Host running, and notifies once about the tray', async () => { + it('routes closing through the tray confirmation and keeps the Host running', async () => { const host = await readyWorkspace() const window = harness.windows[0]! expect(harness.trays).toHaveLength(1) expect(harness.trays[0]!.image).toEqual({ path: join('desktop-test-resources', 'tray.ico') }) - expect(harness.backgroundNotice.markerPath).toBe(join(harness.app.getPath('userData'), 'background-notice-shown')) + expect(harness.backgroundNotice.markerPath).toBe(join(harness.app.getPath('userData'), 'background-close-confirmed')) window.show.mockClear() window.close() expect(window.isDestroyed()).toBe(false) expect(window.hide).toHaveBeenCalledOnce() expect(host.stop).not.toHaveBeenCalled() expect(harness.app.quit).not.toHaveBeenCalled() - expect(harness.backgroundNotice.show).toHaveBeenCalledOnce() + expect(harness.backgroundNotice.close).toHaveBeenCalledOnce() harness.trays[0]!.emit('click') expect(window.show).toHaveBeenCalledOnce() expect(window.focus).toHaveBeenCalled() @@ -1117,6 +1121,29 @@ describe('desktop main startup', () => { expect(harness.trays[0]!.setContextMenu.mock.calls.length).toBe(relabels + 1) }) + it('keeps the workspace visible while acknowledgement is pending and ignores a destroyed window', async () => { + await readyWorkspace() + const window = harness.windows[0]! + const approval = Promise.withResolvers<() => void>() + harness.backgroundNotice.close.mockImplementationOnce((hide) => { approval.resolve(hide) }) + window.close() + const hide = await approval.promise + expect(window.hide).not.toHaveBeenCalled() + window.destroy() + hide() + expect(window.hide).not.toHaveBeenCalled() + }) + + it('focuses an existing shell dialog instead of replacing it with a close confirmation', async () => { + await readyWorkspace() + const window = harness.windows[0]! + harness.shellDialog.isOpen = true + window.close() + expect(harness.shellDialog.focus).toHaveBeenCalledOnce() + expect(harness.backgroundNotice.close).not.toHaveBeenCalled() + expect(window.hide).not.toHaveBeenCalled() + }) + it('leaves macOS fullscreen before hiding on close and reopens the hidden window on activate', async () => { vi.stubGlobal('process', { ...process, platform: 'darwin', arch: 'arm64', resourcesPath: 'desktop-test-resources' }) await readyWorkspace() @@ -1126,7 +1153,7 @@ describe('desktop main startup', () => { window.close() expect(window.setFullScreen).toHaveBeenCalledWith(false) expect(window.hide).toHaveBeenCalledOnce() - expect(harness.backgroundNotice.show).not.toHaveBeenCalled() + expect(harness.backgroundNotice.close).not.toHaveBeenCalled() window.show.mockClear() harness.app.emit('activate', {}, false) expect(window.show).toHaveBeenCalledOnce() diff --git a/apps/desktop/tests/update-dialog.spec.ts b/apps/desktop/tests/update-dialog.spec.ts index 83003d0738..6eff5a82e3 100644 --- a/apps/desktop/tests/update-dialog.spec.ts +++ b/apps/desktop/tests/update-dialog.spec.ts @@ -62,7 +62,9 @@ function setup(locale: DesktopLocale | (() => DesktopLocale) = resolveDesktopLoc it('accepts only a displayed choice from its own main frame and retains cancellation outside the button list', async () => { const f = setup() + expect(dialogs!.isOpen).toBe(false) const pending = f.show() + expect(dialogs!.isOpen).toBe(true) const window = fixture.windows.at(-1)! expect(f.invoke(UPDATE_DIALOG_IPC.status)).toMatchObject({ closeLabel: '关闭', buttons: ['安装并重启'], cancelId: 1 }) const respond = fixture.handlers.get(UPDATE_DIALOG_IPC.respond)! @@ -71,6 +73,7 @@ it('accepts only a displayed choice from its own main frame and retains cancella for (const index of [-1, 2, '0', 0.5, NaN]) expect(() => f.invoke(UPDATE_DIALOG_IPC.respond, index)).toThrow(/invalid/) f.invoke(UPDATE_DIALOG_IPC.respond, 1) expect(await pending).toEqual({ response: 1, checkboxChecked: false }) + expect(dialogs!.isOpen).toBe(false) const next = f.show() f.invoke(UPDATE_DIALOG_IPC.respond, 0) expect((await next).response).toBe(0) From fbb3fb770f9a7bd31479b979d0af63d40e643d38 Mon Sep 17 00:00:00 2001 From: winewill Date: Thu, 24 Sep 2026 14:36:02 +0800 Subject: [PATCH 6/6] 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() })