Merge pull request #5015 from deepseek-harness/feat/close-window-quit-behavior
feat(desktop): 关闭窗口隐藏到后台,退出前按任务状态确认
This commit is contained in:
commit
3511c45426
41 files changed
+1523
-61
No files matched your search
+19
@@ -0,0 +1,19 @@
|
||||
# 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
|
||||
/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: 54c0d8811a8a4390
|
||||
zh: 2baa107b3d25a138
|
||||
/agent-note-hide-the-desktop-window-on-close-and-confirm-interruptible-quits/alternatives-considered:
|
||||
en: 74dcbb66adffd7be
|
||||
zh: 1be3d3fa8e68d81f
|
||||
/agent-note-hide-the-desktop-window-on-close-and-confirm-interruptible-quits/consequences:
|
||||
en: dc219aecdf03014e
|
||||
zh: 621cd97cef5dc858
|
||||
+35
@@ -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). 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
|
||||
|
||||
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 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.
|
||||
|
||||
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
|
||||
|
||||
**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 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.
|
||||
|
||||
**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-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.
|
||||
+35
@@ -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)。本记录部分取代[标准 macOS 窗口菜单](../bug-fix/2026-09-16-desktop-window-menus.zh.md)决策:⌘W 不再通过 Electron 的 role 销毁窗口,Dock 激活在没有可见窗口时重新打开隐藏窗口,而不再只在没有任何窗口时才创建。该记录中的菜单声明仍然有效。
|
||||
|
||||
## 问题
|
||||
|
||||
关闭 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 机器上以多种显示缩放人工验收。
|
||||
@@ -9,8 +9,8 @@
|
||||
en: 75c201a0a687b59d
|
||||
zh: 34593038f673d06f
|
||||
/agent-note-desktop-standard-macos-window-menus/decision:
|
||||
en: 1797b5695e873e5a
|
||||
zh: 3376fda4fc36aaae
|
||||
en: 0ce30c3a1e6202d7
|
||||
zh: badac0021fae4a6f
|
||||
/agent-note-desktop-standard-macos-window-menus/alternatives-considered:
|
||||
en: 957382dbfb6cac48
|
||||
zh: 2db98b7dbf3e01c0
|
||||
|
||||
@@ -12,7 +12,7 @@ The Desktop shell replaces Electron's default application menu with a custom tem
|
||||
|
||||
On macOS the template declares `{ role: 'windowMenu' }` after the Edit menu, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus. The [page-close decision](../architecture/2026-09-21-desktop-page-close-shortcuts.md) owns the custom File menu and close behavior.
|
||||
|
||||
Electron supplies English defaults for the Window and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). Minimize and hide execute through Electron's roles without custom handlers.
|
||||
Electron supplies English defaults for the Window and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). Minimize and hide execute through Electron's roles without custom handlers. Close and activation behavior is partly superseded by [close-to-background](../architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.md); the menu declarations here remain in effect.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Desktop shell 用自定义模板替换了 Electron 的默认应用菜单,该
|
||||
|
||||
macOS 上的模板在 Edit 菜单之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。[页面关闭决策](../architecture/2026-09-21-desktop-page-close-shortcuts.zh.md)负责自定义 File 菜单和关闭行为。
|
||||
|
||||
Electron 为 Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。最小化和隐藏通过 Electron 的 role 执行,没有自定义处理器。
|
||||
Electron 为 Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。最小化和隐藏通过 Electron 的 role 执行,没有自定义处理器。 关闭与激活行为已被[关闭时隐藏窗口](../architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.zh.md)部分取代;这里的菜单声明仍然有效。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
}),
|
||||
})
|
||||
let stopping: Promise<void> | undefined
|
||||
const control: { updateTasks?: ReturnType<typeof installDesktopUpdateTaskControl> } = {}
|
||||
const control: {
|
||||
updateTasks?: ReturnType<typeof installDesktopUpdateTaskControl>
|
||||
quitInspection?: ReturnType<typeof installDesktopQuitInspection>
|
||||
} = {}
|
||||
const send = (message: object): Promise<void> => 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<void> {
|
||||
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<void> {
|
||||
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'),
|
||||
|
||||
@@ -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<DesktopQuitInspection> {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -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<Context['agents']['list']>, 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)
|
||||
}
|
||||
}
|
||||
@@ -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<typeof installDesktopQuitInspection>
|
||||
const agents: AgentState[] = []
|
||||
const jobs = new Map<SessionId | undefined, JobStatus[]>()
|
||||
/** Activity each provider family reports per session; the inspector must count only `schedule`. */
|
||||
const activity = new Map<SessionId, SessionActivity[]>()
|
||||
|
||||
/** 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')
|
||||
})
|
||||
})
|
||||
@@ -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" },
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
/deepseek-harness-desktop:
|
||||
en: 45561fc0dd3a1b20
|
||||
zh: f4b03355d4bd0693
|
||||
/deepseek-harness-desktop/closing-the-window-and-quitting:
|
||||
en: fb852f3bd70d8602
|
||||
zh: 3d2b0a8d580d7f0a
|
||||
/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
|
||||
|
||||
+13
-1
@@ -20,6 +20,18 @@ 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; 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. 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.
|
||||
|
||||
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.
|
||||
|
||||
## 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 +376,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.
|
||||
|
||||
|
||||
@@ -20,6 +20,18 @@ Desktop Host 的 Platform API 请求与更新策略请求用相同的 Platform
|
||||
|
||||
按 F12(多媒体功能键键盘上为 Fn+F12)、macOS 的 Command+Option+I 或 Windows 的 Ctrl+Shift+I,可切换当前获得焦点的应用页面的 DevTools,打包版本同样支持。这些原生快捷键通过隐藏的应用菜单项注册。更新遮罩和打包版本的内嵌浏览器禁用 DevTools。
|
||||
|
||||
## 关闭窗口与退出
|
||||
|
||||
关闭主窗口(macOS 的关闭按钮和 ⌘W;Windows 的 ×、Alt+F4 和任务栏"关闭窗口")会隐藏窗口;Windows 首次隐藏前需要确认。页面和 Host 继续运行,任务不受影响,下次显示时仍是原来的文档,会话、草稿和滚动位置都保留;macOS 全屏窗口先退出全屏再隐藏。macOS 通过 Dock 图标、再次启动或 `dsh://open` 找回窗口,Windows 通过托盘找回。最小化行为不变。进入工作区前关闭欢迎窗口,Windows 上走退出流程,macOS 上应用留在 Dock 中且没有窗口。
|
||||
|
||||
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 任务对话框的句柄);任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。取消由关闭欢迎窗口引起的退出时,欢迎窗口会重新显示。
|
||||
|
||||
以下情况跳过确认:安装更新的重启已确认过任务中断、致命错误恢复对话框中的退出或重启、开发版"重启应用与 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。
|
||||
@@ -366,7 +378,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 恢复失败打开原生致命故障恢复弹窗。
|
||||
|
||||
|
||||
@@ -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' }[],
|
||||
]
|
||||
readonly mac: {
|
||||
readonly extendInfo: { readonly NSMicrophoneUsageDescription: string }
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<rect rx="192" width="1024" height="1024" transform="translate(40 32)" fill="#FBFBFB"/>
|
||||
<rect rx="192" width="1024" height="1024" transform="translate(40 32)" fill="url(#paint0_linear_1637_371)" fill-opacity="0.4"/>
|
||||
<rect rx="192" width="1024" height="1024" transform="translate(40 32)" fill="url(#paint1_linear_1637_371)" fill-opacity="0.8"/>
|
||||
<g filter="url(#filter1_ddi_1637_371)">
|
||||
<g id="tray-glyph" filter="url(#filter1_ddi_1637_371)">
|
||||
<path d="M968.002 326.025C959.5 321.85 955.842 329.804 950.865 333.835C949.162 335.143 947.722 336.834 946.283 338.393C933.859 351.673 919.336 360.407 900.376 359.363C872.649 357.804 848.963 366.525 828.035 387.759C823.586 361.583 808.812 345.951 786.313 335.936C774.536 330.729 762.64 325.51 754.401 314.186C748.645 306.112 747.074 297.127 744.195 288.274C742.36 282.936 740.538 277.465 734.385 276.554C727.718 275.51 725.103 281.112 722.476 285.803C712.006 304.95 707.952 326.039 708.349 347.405C709.26 395.463 729.54 433.742 769.823 460.962C774.404 464.094 775.579 467.212 774.14 471.771C771.394 481.153 768.119 490.257 765.241 499.639C763.406 505.624 760.66 506.933 754.256 504.329C732.154 495.08 713.049 481.417 696.175 464.874C667.524 437.138 641.633 406.536 609.325 382.566C601.733 376.964 594.154 371.758 586.298 366.802C553.33 334.773 590.616 308.464 599.251 305.333C608.282 302.082 602.393 290.877 573.214 291.009C544.048 291.141 517.364 300.906 483.353 313.935C478.389 315.891 473.147 317.318 467.786 318.494C436.917 312.64 404.873 311.332 371.377 315.111C308.332 322.141 257.975 351.964 220.953 402.876C176.471 464.081 166.014 533.624 178.835 606.153C192.302 682.594 231.291 745.887 291.194 795.372C353.328 846.68 424.876 871.813 506.498 866.99C556.076 864.122 611.279 857.489 673.532 804.74C689.23 812.55 705.708 815.681 733.052 818.02C754.111 819.976 774.391 816.976 790.076 813.726C814.674 808.519 812.971 785.726 804.072 781.564C731.995 747.962 747.826 761.638 733.435 750.565C770.06 707.198 825.263 662.139 846.85 516.156C848.553 504.567 847.114 497.273 846.85 487.892C846.718 482.157 848.025 479.95 854.574 479.303C872.623 477.215 890.156 472.273 906.238 463.42C952.938 437.892 971.765 395.965 976.215 345.7C976.875 338.01 976.083 330.069 967.976 326.039L968.002 326.025ZM561.067 778.432C491.222 723.477 457.343 705.374 443.347 706.154C430.263 706.933 432.626 721.918 435.505 731.682C438.515 741.315 442.436 747.962 447.929 756.418C451.718 762.021 454.346 770.345 444.14 776.609C421.641 790.549 382.533 771.918 380.698 771.006C335.174 744.182 297.109 708.757 270.293 660.316C244.389 613.698 229.35 563.685 226.868 510.302C226.208 497.406 230.01 492.847 242.831 490.508C259.704 487.376 277.106 486.729 293.98 489.2C365.264 499.625 425.959 531.523 476.844 582.052C505.878 610.831 527.861 645.213 550.491 678.815C574.561 714.492 600.452 748.49 633.42 776.358C645.066 786.122 654.348 793.548 663.247 799.019C636.431 802.018 591.698 802.666 561.08 778.445L561.067 778.432ZM594.55 562.905C594.55 557.171 599.132 552.625 604.888 552.625C606.195 552.625 607.371 552.876 608.414 553.273C609.853 553.801 611.16 554.581 612.203 555.744C614.038 557.567 615.081 560.17 615.081 562.905C615.081 568.64 610.5 573.185 604.743 573.185C598.987 573.185 594.537 568.627 594.537 562.905H594.55ZM698.539 616.301C691.871 619.037 685.19 621.375 678.787 621.64C668.845 622.155 657.992 618.125 652.103 613.17C642.953 605.479 636.404 601.185 633.658 587.773C632.483 582.039 633.13 573.185 634.186 568.111C636.536 557.171 633.922 550.141 626.212 543.759C619.927 538.553 611.952 537.112 603.185 537.112C599.911 537.112 596.914 535.685 594.682 534.509C591.025 532.686 588.015 528.127 590.893 522.525C591.804 520.701 596.254 516.275 597.297 515.495C609.206 508.716 622.937 510.936 635.625 516.01C647.403 520.833 656.288 529.686 669.109 542.186C682.193 557.289 684.543 561.465 692.003 572.789C697.892 581.642 703.252 590.759 706.909 601.185C709.128 607.699 706.249 613.038 698.539 616.288V616.301Z" fill="url(#paint2_linear_1637_371)" fill-opacity="0.9"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -144,6 +144,8 @@ export function createElectronBuilderConfig(
|
||||
extraResources: [
|
||||
{ from: buildPaths.runtime, to: 'runtime' },
|
||||
{ from: fileURLToPath(new URL('../resources/icon-windows.png', import.meta.url)), to: 'icon.png' },
|
||||
// Windows tray bitmaps; macOS keeps the Dock and ships no menu bar icon.
|
||||
...(packagesWindows ? [{ from: fileURLToPath(new URL('../resources/tray-windows.ico', import.meta.url)), to: 'tray.ico' }] : []),
|
||||
],
|
||||
mac: {
|
||||
icon: fileURLToPath(new URL('../resources/icon-macos.png', import.meta.url)),
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Render the Windows tray icon with an enlarged whale from `resources/icon-windows.svg`.
|
||||
*
|
||||
* The tray shows the icon at 16 logical pixels, so Windows picks one of the
|
||||
* 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 run render:tray-icon` in `apps/desktop` after changing the vector source.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import sharp from 'sharp'
|
||||
|
||||
/** Bitmap edge lengths bundled in the tray icon: 16 px at 100 % through 400 % display scale. */
|
||||
export const TRAY_ICON_SIZES = [16, 20, 24, 32, 40, 48, 64] as const
|
||||
|
||||
/** Vector source and committed output of the tray icon. */
|
||||
export const TRAY_ICON_PATHS = {
|
||||
source: fileURLToPath(new URL('../resources/icon-windows.svg', import.meta.url)),
|
||||
output: fileURLToPath(new URL('../resources/tray-windows.ico', import.meta.url)),
|
||||
} as const
|
||||
|
||||
/** Coordinate space of the vector source; sharp's SVG density is scaled against it. */
|
||||
const SOURCE_EDGE = 1024
|
||||
const SOURCE_DENSITY = 72
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
const ICON_DIRECTORY_BYTES = 6
|
||||
const ICON_ENTRY_BYTES = 16
|
||||
|
||||
/** One bitmap of an icon file. */
|
||||
export interface IcoEntry {
|
||||
readonly size: number
|
||||
/** Complete PNG stream; Windows Vista and later read PNG-compressed entries directly. */
|
||||
readonly png: Buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack PNG bitmaps into one ICO file.
|
||||
* @param entries - Bitmaps in ascending size; each PNG must be square with the declared edge.
|
||||
* @returns the ICO bytes.
|
||||
*/
|
||||
export function packIco(entries: readonly IcoEntry[]): Buffer {
|
||||
const header = Buffer.alloc(ICON_DIRECTORY_BYTES + ICON_ENTRY_BYTES * entries.length)
|
||||
header.writeUInt16LE(0, 0)
|
||||
header.writeUInt16LE(1, 2)
|
||||
header.writeUInt16LE(entries.length, 4)
|
||||
let offset = header.length
|
||||
entries.forEach((entry, index) => {
|
||||
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 and a `tray-glyph` group.
|
||||
* @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<IcoEntry[]> {
|
||||
const source = svg.toString('utf8')
|
||||
const glyph = '<g id="tray-glyph"'
|
||||
if (!source.includes(glyph)) throw new Error('tray icon: SVG requires a tray-glyph group')
|
||||
// Scale around the application tile center, retaining its background and the whale's aspect ratio.
|
||||
const tray = Buffer.from(source.replace(glyph, `${glyph} transform="translate(552 544) scale(1.2) translate(-552 -544)"`))
|
||||
return Promise.all(sizes.map(async size => ({
|
||||
size,
|
||||
png: await sharp(tray, { 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<void> {
|
||||
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()
|
||||
@@ -0,0 +1,56 @@
|
||||
/** One-time Windows confirmation before hiding the application in the tray. */
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
|
||||
import type { DesktopLocale } from './locale.ts'
|
||||
|
||||
/** Persistent acknowledgement and the shared shell dialog. */
|
||||
export interface DesktopBackgroundNoticeOptions {
|
||||
/** Acknowledgement under Electron userData; updates retain it and uninstall removes it. */
|
||||
readonly markerPath: string
|
||||
readonly locale: () => DesktopLocale
|
||||
readonly show: (options: MessageBoxOptions) => Promise<MessageBoxReturnValue>
|
||||
readonly focus: () => void
|
||||
}
|
||||
|
||||
/** Only an explicit acknowledgement permits the first hide; cancelled prompts remain eligible. */
|
||||
export class DesktopBackgroundNotice {
|
||||
private acknowledged = false
|
||||
private pending = false
|
||||
private disposed = false
|
||||
|
||||
/** @param options - Marker path, localized copy, and shell dialog actions. */
|
||||
constructor(private readonly options: DesktopBackgroundNoticeOptions) {}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
/** Ignore late dialog responses after application shutdown begins. */
|
||||
dispose(): void { this.disposed = true }
|
||||
|
||||
private async confirm(hide: () => void): Promise<void> {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -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<DesktopHostEvent, { readonly requestId: number }>
|
||||
|
||||
/** 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<number, { resolve: (active: boolean) => void; reject: (error: Error) => void }>()
|
||||
private readonly controlRequests = new Map<number, {
|
||||
resolve: (response: DesktopHostControlResponse) => 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<boolean> {
|
||||
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<DesktopQuitInspection> {
|
||||
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<DesktopHostControlResponse> {
|
||||
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<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await new Promise<boolean>((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<DesktopHostControlResponse>((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) {
|
||||
|
||||
@@ -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.',
|
||||
backgroundNoticeBody: 'Running tasks will continue. You can reopen the window from the system tray.',
|
||||
backgroundNoticeConfirm: 'Confirm',
|
||||
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: '当前正在运行的任务将会中断,且应用关闭期间,定时任务不会运行',
|
||||
backgroundNoticeBody: '正在运行的任务不会中断,可在系统托盘中重新打开窗口',
|
||||
backgroundNoticeConfirm: '确认',
|
||||
edit: '编辑',
|
||||
menuBar: '应用菜单',
|
||||
delete: '删除',
|
||||
|
||||
+130
-19
@@ -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<void> => {}
|
||||
let shuttingDown = false
|
||||
/**
|
||||
* 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
|
||||
/**
|
||||
* Whether the backend has reached ready: false until the first ready, back to
|
||||
@@ -72,6 +81,11 @@ app.setAppLogsPath()
|
||||
function currentDesktopLocale(): ReturnType<typeof resolveDesktopLocale> {
|
||||
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,13 @@ async function main(): Promise<void> {
|
||||
let mandatoryPolicy: DesktopMandatoryUpdatePolicy | undefined
|
||||
let mandatoryUI: DesktopMandatoryUpdateWindow | undefined
|
||||
let policyAuth: DesktopPolicyTestAuth | undefined
|
||||
let tray: DesktopTray | undefined
|
||||
/**
|
||||
* 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
|
||||
const ordinaryDialogs = new Set<AbortController>()
|
||||
@@ -446,6 +467,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
},
|
||||
updateTasks: (action: 'inspect' | 'lock' | 'unlock') => host.updateTasks(action),
|
||||
inspectQuit: () => host.inspectQuit(),
|
||||
}
|
||||
}, (state) => {
|
||||
if (state.phase === 'error') reportFatal(state.failure, 'host')
|
||||
@@ -578,8 +600,19 @@ async function main(): Promise<void> {
|
||||
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<void> => new Promise((resolve) => {
|
||||
const window = currentDialogWindow()
|
||||
if (window === undefined || window.isDestroyed() || window.isVisible()) { resolve(); return }
|
||||
window.once('show', () => { resolve() })
|
||||
window.once('closed', () => { resolve() })
|
||||
})
|
||||
|
||||
protocol.handle(SCHEME, (request) => {
|
||||
const url = new URL(request.url)
|
||||
@@ -838,14 +871,15 @@ async function main(): Promise<void> {
|
||||
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 +908,7 @@ async function main(): Promise<void> {
|
||||
{ label: currentDesktopLocale().messages.restartAppHostMenu, click: () => {
|
||||
if (quitting) return
|
||||
app.relaunch()
|
||||
app.quit()
|
||||
quitWithoutConfirmation()
|
||||
} },
|
||||
] : [],
|
||||
{ type: 'separator' },
|
||||
@@ -891,8 +925,33 @@ async function main(): Promise<void> {
|
||||
label: darwin ? app.name : currentDesktopLocale().messages.application,
|
||||
submenu: [...applicationItems(), ...devToolsItems],
|
||||
}, ...platformMenus()]))
|
||||
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: trayIconPath, 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-close-confirmed'),
|
||||
locale: () => locale, show: ordinaryMessageBox, focus: () => { updateDialog.focus() } })
|
||||
: 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 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 }) },
|
||||
// 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') {
|
||||
ipcMain.handle(DESKTOP_IPC.windowsMenu, (event, name: unknown, x: unknown, y: unknown) => {
|
||||
@@ -941,12 +1000,42 @@ async function main(): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
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()
|
||||
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
|
||||
// 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) => {
|
||||
if (details.level !== 'error') return
|
||||
@@ -1038,7 +1127,11 @@ async function main(): Promise<void> {
|
||||
})
|
||||
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 +1175,20 @@ async function main(): Promise<void> {
|
||||
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
|
||||
quitConfirmation.dispose()
|
||||
backgroundNotice?.dispose()
|
||||
tray?.dispose()
|
||||
stopAccount?.()
|
||||
if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.hide()
|
||||
if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.hide()
|
||||
@@ -1111,6 +1199,29 @@ async function main(): Promise<void> {
|
||||
// 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
|
||||
quitConfirmation.dispose()
|
||||
backgroundNotice?.dispose()
|
||||
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 (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 && !shellInstallerOwnsQuit) finishQuit() })
|
||||
})
|
||||
|
||||
mainWindow = createMainWindow()
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/** 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<keyof DesktopMessages, 'quitActiveTasks' | 'quitScheduledTasks' | 'quitActiveAndScheduledTasks'> | 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<DesktopQuitInspection> | undefined
|
||||
/** Native message box without an owner window: hidden windows stay hidden and the box comes to the front. */
|
||||
readonly show: (options: MessageBoxOptions) => Promise<MessageBoxReturnValue>
|
||||
/** 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<boolean> | undefined
|
||||
private disposed = false
|
||||
|
||||
/** @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<boolean> {
|
||||
if (this.disposed) return Promise.resolve(false)
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* The application is quitting through a path that does not ask: a pending decision resolves to
|
||||
* false without opening a box, and later requests do the same. An already open native box has no
|
||||
* close API and disappears with the process.
|
||||
*/
|
||||
dispose(): void { this.disposed = true }
|
||||
|
||||
private async decide(): Promise<boolean> {
|
||||
const inspection = this.options.inspect()
|
||||
if (inspection === undefined) return true
|
||||
const prompt = resolveDesktopQuitPrompt(await inspection.catch((error: unknown) => {
|
||||
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'
|
||||
// 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,
|
||||
})
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +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 roots: string[] = []
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function setup() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-notice-'))
|
||||
roots.push(root)
|
||||
const markerPath = join(root, 'background-close-confirmed')
|
||||
const response = Promise.withResolvers<MessageBoxReturnValue>()
|
||||
const show = vi.fn<(options: MessageBoxOptions) => Promise<MessageBoxReturnValue>>(() => 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 window visible until confirmation and remembers acknowledgement across launches', async () => {
|
||||
const f = setup()
|
||||
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)
|
||||
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('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()
|
||||
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)
|
||||
f.show.mockResolvedValue({ response: 0, checkboxChecked: false })
|
||||
f.notice.close(f.hide)
|
||||
await vi.waitFor(() => { expect(f.hide).toHaveBeenCalledOnce() })
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"message": "正在运行的任务不会中断,可在系统托盘中重新打开窗口",
|
||||
"buttons": ["确认"],
|
||||
"detailHidden": true
|
||||
}
|
||||
+43
-2
@@ -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,<title>Update dialog qualification</title><h1>Local updater</h1>')
|
||||
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()
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,18 @@ 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 = { 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,
|
||||
windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, trays, FakeTray, backgroundNotice, shellDialog,
|
||||
menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall,
|
||||
platformDispose,
|
||||
platformCloseAndWait,
|
||||
@@ -213,6 +225,9 @@ 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
|
||||
shellDialog.isOpen = false
|
||||
powerMonitor.removeAllListeners()
|
||||
app.isPackaged = true
|
||||
windowFailure = undefined
|
||||
@@ -265,7 +280,14 @@ 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 close = harness.backgroundNotice.close
|
||||
readonly dispose = harness.backgroundNotice.dispose
|
||||
} }))
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return { ...original, readFile: vi.fn((path: Parameters<typeof original.readFile>[0], encoding?: 'utf8') => {
|
||||
@@ -288,12 +310,13 @@ vi.mock('../src/host-process.ts', async importOriginal => ({
|
||||
...await importOriginal<typeof import('../src/host-process.ts')>(), 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<Electron.MessageBoxReturnValue>
|
||||
}
|
||||
cancel() {}
|
||||
focus() {}
|
||||
readonly focus = harness.shellDialog.focus
|
||||
dispose() {}
|
||||
} }))
|
||||
vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: class {
|
||||
@@ -310,6 +333,7 @@ vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: class
|
||||
vi.mock('../src/platform-view.ts', async importOriginal => ({
|
||||
...await importOriginal<typeof import('../src/platform-view.ts')>(),
|
||||
DesktopPlatformView: class {
|
||||
notifyLocaleChanged() {}
|
||||
setSession() {}
|
||||
setBounds() {}
|
||||
close() {}
|
||||
@@ -378,6 +402,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 +1070,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,14 +1094,210 @@ describe('desktop main startup', () => {
|
||||
expect(window.focus).not.toHaveBeenCalled()
|
||||
host.exited.resolve()
|
||||
await harness.quitCompleted.promise
|
||||
expect(harness.trays[0]!.destroy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
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-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.close).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)
|
||||
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()
|
||||
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.close).not.toHaveBeenCalled()
|
||||
window.show.mockClear()
|
||||
harness.app.emit('activate', {}, false)
|
||||
expect(window.show).toHaveBeenCalledOnce()
|
||||
harness.app.emit('activate', {}, true)
|
||||
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]!
|
||||
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<Electron.MessageBoxReturnValue>()
|
||||
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 })
|
||||
// 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()
|
||||
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<void>
|
||||
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 () => {
|
||||
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()
|
||||
@@ -1426,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<Electron.MessageBoxReturnValue>()
|
||||
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')
|
||||
@@ -1444,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()
|
||||
})
|
||||
|
||||
@@ -1654,13 +1906,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()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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<DesktopQuitInspection> | 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<DesktopQuitInspection>()
|
||||
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)
|
||||
})
|
||||
|
||||
it('resolves a pending decision to false without a box once disposed, and refuses later requests', async () => {
|
||||
const inspected = Promise.withResolvers<DesktopQuitInspection>()
|
||||
const f = setup('darwin', () => inspected.promise)
|
||||
const pending = f.confirmation.confirm()
|
||||
f.confirmation.dispose()
|
||||
inspected.resolve({ activeTasks: true, scheduledTasks: false })
|
||||
expect(await pending).toBe(false)
|
||||
expect(f.shown).toEqual([])
|
||||
expect(await f.confirmation.confirm()).toBe(false)
|
||||
expect(f.focus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not approve a quit from a box answered after disposal', async () => {
|
||||
const answered = Promise.withResolvers<MessageBoxReturnValue>()
|
||||
const confirmation = new DesktopQuitConfirmation({
|
||||
locale: () => resolveDesktopLocale('en'), inspect: async () => ({ activeTasks: true, scheduledTasks: false }),
|
||||
focus: () => {}, platform: 'darwin', show: () => answered.promise,
|
||||
})
|
||||
const pending = confirmation.confirm()
|
||||
await Promise.resolve()
|
||||
confirmation.dispose()
|
||||
answered.resolve({ response: 0, checkboxChecked: false })
|
||||
expect(await pending).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+9
@@ -669,6 +669,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1([email protected])
|
||||
sharp:
|
||||
specifier: ^0.35.3
|
||||
version: 0.35.3(@types/[email protected])
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user