feat(desktop): hide the window on close and confirm interruptible quits
Closing the main window hides it on macOS and Windows; the page and the Host keep running and the next show resumes the same document. Windows gets a permanent tray icon (open / quit) and a one-time notification the first time the window is hidden after installation. Every ordinary quit entry asks the Host over a new quit-inspection IPC request for active tasks (same rule as the update restart check) and armed scheduled reminders (schedule family of workspace/session-activity for loaded sessions). Nothing to interrupt quits silently; otherwise a native message box explains what stops, with Quit as the default and Cancel on Esc. Inspection failure or a missed two-second deadline counts as running tasks. The installer restart, fatal recovery, the development restart command, and OS session end skip the confirmation. Also: defer the downloaded-update confirmation while the window is hidden, render the tray ICO from the vector icon, and point the installer's running-application message at the tray. Closes #4013
This commit is contained in:
1 parent
31da6718a9
commit
4745934683
33 files changed
+1270
-47
No files matched your search
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.md
|
||||
2026-09-23-desktop-close-to-background-and-quit-confirmation.md: 972edca121d00a39bc0f3439e8493d460a3b4095
|
||||
2026-09-23-desktop-close-to-background-and-quit-confirmation.zh.md: 3f43fa3d6051c92e89a2c420e18be13650d51c4b
|
||||
+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).
|
||||
|
||||
## Problem
|
||||
|
||||
Closing the Desktop window destroyed it. On macOS the Host kept running but the Dock rebuilt a fresh page, losing the current session, draft, and scroll position; on Windows the close quit the application and cut running tasks without any warning. Quit itself never asked, although the Host knows which agents, jobs, and scheduled reminders would stop.
|
||||
|
||||
## Decision
|
||||
|
||||
Closing the main window hides it on both platforms; the page and the Host keep running and the next show presents the same document. Windows gets a permanent tray icon (single click opens, context menu offers Open and Quit) because Windows users expect × to quit and need a visible way back, plus one per-installation notification the first time the window is hidden. macOS keeps the Dock and ships no menu bar icon. Closing the welcome window before the workspace opens quits on Windows and drops the unused main window on macOS, so the Dock rebuilds the welcome.
|
||||
|
||||
Every ordinary quit entry asks the Host over the private IPC channel for two facts: active tasks under the update-restart rule, and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded in this run. Both absent, the quit proceeds silently; otherwise a native message box without an owner window shows one of three fixed explanations with Quit as the default and Cancel on Esc. Repeated quit requests join the open box, the copy is frozen while it is open, and approval does not re-inspect. A Host that is not ready cannot run tasks and the quit proceeds; an inspection failure or a missed two-second deadline counts as running tasks because a needless prompt is cheaper than a silent interruption. The installer restart, fatal recovery, the development restart command, and operating-system session end skip the confirmation.
|
||||
|
||||
The Windows tray bitmaps are rendered from the vector icon at seven sizes and committed as an ICO; the confirmation on Windows uses the application icon in a task dialog and stays light because the control does not follow the application theme. The installer and uninstaller copy for a running application now points at the tray.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep quitting on close and add a confirmation there.** It asks on every close, and Windows users would still lose background work whenever they dismissed the window. Hiding matches the product requirement that tasks continue.
|
||||
|
||||
**Render the confirmation in the shell's HTML overlay.** The overlay needs a visible parent window and a working renderer; the quit must also work when the window is hidden or the page is stuck, so the native dialog is the reliable choice.
|
||||
|
||||
**Reuse the update-tasks IPC request with a flag.** The quit needs a second fact and a shorter deadline; a distinct request keeps the update admission lock semantics untouched and lets both share one correlation id space.
|
||||
|
||||
**Downscale one large bitmap for the tray.** Rasterizing the vector source per size keeps edges crisp at 100 % through 400 % display scale, which a single downscale does not guarantee.
|
||||
|
||||
## Consequences
|
||||
|
||||
Users can close the window freely; background tasks and scheduled reminders continue, and reopening restores the same page. Quitting warns only when it interrupts something. Windows carries a tray icon for the whole run, and a hidden window that finishes a user-initiated update download defers its install confirmation until the window is shown again. Reminders in sessions never loaded during the run are neither counted nor resumed until those sessions open, and Desktop does not enable scheduled tasks by default, so the scheduled-task copy appears only once that feature is on.
|
||||
|
||||
Verification covers the four inspection outcomes, inspection failure and deadline, each quit entry's bypass or confirmation, the close-to-hide path with the tray and the first-hide notice, the deferred update prompt, the Host-side inspection, the IPC correlation and deadline, and the committed tray icon's bitmap set. Windows tray and dialog behavior needs a manual pass on a Windows machine at several display scales.
|
||||
+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)。
|
||||
|
||||
## 问题
|
||||
|
||||
关闭 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 机器上以多种显示缩放人工验收。
|
||||
@@ -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: eff1938598576ffa
|
||||
zh: 2bcb2e0dec0139cc
|
||||
/deepseek-harness-desktop/key-technical-decisions:
|
||||
en: 069fdfd72d2ea26a
|
||||
zh: 76ae549d43c99bb4
|
||||
@@ -48,8 +51,8 @@
|
||||
en: 8efe3498dbe8686d
|
||||
zh: a88c16f20a9430f3
|
||||
/deepseek-harness-desktop/updates:
|
||||
en: a40f7aa2b00ed6fb
|
||||
zh: d852a2d9de576dc5
|
||||
en: c2cf8db67af2ecf7
|
||||
zh: 5df4cd47ecc7edf9
|
||||
/deepseek-harness-desktop/updates/mandatory-update-policy:
|
||||
en: 2f789a4949fafb12
|
||||
zh: d8b28c01cfee11b7
|
||||
|
||||
+11
-1
@@ -20,6 +20,16 @@ Desktop microphone access is restricted to audio requests from the primary `dsh-
|
||||
|
||||
Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Shift+I on Windows to toggle DevTools for the focused application page, including in packaged builds. These native shortcuts use hidden application-menu items. Update overlays and packaged embedded browser guests disable DevTools.
|
||||
|
||||
## Closing the window and quitting
|
||||
|
||||
Closing the main window (macOS close button and ⌘W; Windows ×, Alt+F4, and the taskbar Close window command) hides it without confirmation. The page and the Host keep running, tasks continue, and the next show presents the same document with its session, drafts, and scroll position; a fullscreen macOS window leaves fullscreen before hiding. The window returns through the Dock icon, a second launch, or `dsh://open` on macOS, and through the tray on Windows. Minimize is unchanged. Closing the welcome window before the workspace opens quits on Windows and, on macOS, keeps the application in the Dock without a window.
|
||||
|
||||
Windows keeps a tray icon for the whole run. Its tooltip is the product name, a single click shows and focuses the window, and the context menu offers Open DeepSeek Harness and Quit DeepSeek Harness in the shell locale. The first hide of each installation posts one silent system notification saying the application is still running; clicking it opens the window. The marker lives under Electron's userData, which in-place updates keep and the uninstaller removes, so a reinstall shows the notice again. The tray bitmaps are `resources/tray-windows.ico`, rendered from `resources/icon-windows.svg` at 16, 20, 24, 32, 40, 48, and 64 pixels by `pnpm run render:tray-icon`, and packaged as `resources/tray.ico`. macOS ships no menu bar icon.
|
||||
|
||||
Every ordinary quit entry — ⌘Q, the application menu, the Dock menu, the Windows tray and caption Application menu, and the quits caused by closing the mandatory-update or welcome window — asks the Host what it would interrupt. The Host answers over the private IPC channel with two facts: active tasks under the same rule as the update restart check (running agents including subagents and approval waits, queued messages, running or stopping jobs), and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded during this run. Neither fact, the quit proceeds silently. Otherwise a native message box without an owner window — hidden windows stay hidden — shows **Quit DeepSeek Harness?** with one of three localized explanations: running tasks will be interrupted, scheduled tasks will not run while the application is closed, or both. Quit is the default button and Cancel answers Esc; macOS places Cancel left of Quit, Windows places Quit left of Cancel, and the Windows task dialog shows the application icon and stays light regardless of the application theme. A Host that has not reached ready or has failed cannot run tasks, so the quit proceeds without asking. An inspection failure or a Host that misses the two-second deadline is treated as running tasks. While the box is open, another quit request raises it instead of stacking a second one, the explanation does not change when work starts or ends, Quit stops the application without re-inspecting, and Cancel changes nothing.
|
||||
|
||||
The confirmation is skipped when the installer restart already confirmed task interruption, when the fatal-recovery dialog exits or restarts, when the development Restart App and Host command runs, and during operating-system shutdown, restart, or log-off (macOS power-off notification, Windows session-end messages). A user-initiated update download that finishes while the window is hidden defers its Install and Restart confirmation until the window is shown again; the mandatory flow keeps its taskbar and Dock attention. The Windows installer and uninstaller tell a user whose application is still running to quit it from the system tray. Desktop does not enable scheduled tasks by default, so the scheduled-task explanations appear only once that feature is on; reminders fire only in loaded sessions, and unloaded sessions are neither counted nor resumed until they are opened.
|
||||
|
||||
## Key technical decisions
|
||||
|
||||
The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation.
|
||||
@@ -364,7 +374,7 @@ Packaged applications check fixed Nightly asynchronously at startup. Ordinary po
|
||||
|
||||
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries.
|
||||
|
||||
The lower-left account row displays localized availability, a spinner with download percentage, verification, readiness, or a persistent red retry action with an accessible tooltip. This Web-embedded copy follows the active in-application language; native dialogs use the Desktop shell locale. The collapsed sidebar shows a dot on its top expand button. Connection status takes priority. Selecting an available release starts downloading immediately. Successful preparation automatically opens a shell-owned restart confirmation; closing it retains readiness without reopening the dialog. Selecting the ready entry opens confirmation again. Running agents, queued input, and running or stopping jobs trigger an interruption warning in that confirmation. API requests alone do not trigger the warning. After approval, the Host locks new requests, drains admitted requests, and rechecks tasks, including work created by an admitted write. A drain that exceeds the control-request deadline refuses installation and unlocks admission. Unknown task status, newly started work without interruption approval, or unsuccessful graceful teardown prevents installation. Ordinary quit hides the product window before stopping the Host, ignores new focus requests during teardown, and never installs an update. The next launch reconciles the version-bound runtime through the existing startup and recovery path.
|
||||
The lower-left account row displays localized availability, a spinner with download percentage, verification, readiness, or a persistent red retry action with an accessible tooltip. This Web-embedded copy follows the active in-application language; native dialogs use the Desktop shell locale. The collapsed sidebar shows a dot on its top expand button. Connection status takes priority. Selecting an available release starts downloading immediately. Successful preparation automatically opens a shell-owned restart confirmation; closing it retains readiness without reopening the dialog. Selecting the ready entry opens confirmation again. Running agents, queued input, and running or stopping jobs trigger an interruption warning in that confirmation. API requests alone do not trigger the warning. After approval, the Host locks new requests, drains admitted requests, and rechecks tasks, including work created by an admitted write. A drain that exceeds the control-request deadline refuses installation and unlocks admission. Unknown task status, newly started work without interruption approval, or unsuccessful graceful teardown prevents installation. Ordinary quit asks about interruptible work as described under Closing the window and quitting, then hides the product window before stopping the Host, ignores new focus requests during teardown, and never installs an update. The next launch reconciles the version-bound runtime through the existing startup and recovery path.
|
||||
|
||||
If task teardown fails after confirmed Host exit, installation is refused and the shell restores the current-version Host before allowing another restart confirmation. Installer launch failure after a clean Host stop uses the same recovery. After the replacement Host authenticates, the shell reloads the existing application URL so the Web page obtains its current port, cookie, and boot injections; page-load failure opens native fatal recovery. An unconfirmed process exit never permits a replacement Host. The downloaded target remains available for retry. A known mandatory policy remains blocking throughout recovery; unsuccessful Host restoration opens the native fatal-recovery dialog.
|
||||
|
||||
|
||||
@@ -20,6 +20,16 @@ Desktop Host 的 Platform API 请求与更新策略请求用相同的 Platform
|
||||
|
||||
按 F12(多媒体功能键键盘上为 Fn+F12)、macOS 的 Command+Option+I 或 Windows 的 Ctrl+Shift+I,可切换当前获得焦点的应用页面的 DevTools,打包版本同样支持。这些原生快捷键通过隐藏的应用菜单项注册。更新遮罩和打包版本的内嵌浏览器禁用 DevTools。
|
||||
|
||||
## 关闭窗口与退出
|
||||
|
||||
关闭主窗口(macOS 的关闭按钮和 ⌘W;Windows 的 ×、Alt+F4 和任务栏"关闭窗口")只隐藏窗口,不弹确认。页面和 Host 继续运行,任务不受影响,下次显示时仍是原来的文档,会话、草稿和滚动位置都保留;macOS 全屏窗口先退出全屏再隐藏。macOS 通过 Dock 图标、再次启动或 `dsh://open` 找回窗口,Windows 通过托盘找回。最小化行为不变。进入工作区前关闭欢迎窗口,Windows 上走退出流程,macOS 上应用留在 Dock 中且没有窗口。
|
||||
|
||||
Windows 在整个运行期间常驻托盘图标。悬停提示为产品名,单击显示并聚焦窗口,右键菜单提供壳语言下的"打开 DeepSeek Harness"和"退出 DeepSeek Harness"。每次安装后第一次隐藏窗口时发送一条静音系统通知,说明应用仍在运行;点击通知打开窗口。标记文件位于 Electron 的 userData 下,应用内更新保留它,卸载程序删除它,因此重装后会再提示一次。托盘位图是 `resources/tray-windows.ico`,由 `pnpm run render:tray-icon` 从 `resources/icon-windows.svg` 按 16、20、24、32、40、48、64 像素分别渲染,打包为 `resources/tray.ico`。macOS 不提供菜单栏图标。
|
||||
|
||||
所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭强制更新窗口或欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会把它提到前面而不叠加新弹框;任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。
|
||||
|
||||
以下情况跳过确认:安装更新的重启已确认过任务中断、致命错误恢复对话框中的退出或重启、开发版"重启应用与 Host"命令,以及操作系统关机、重启或注销(macOS 关机通知、Windows 会话结束消息)。窗口隐藏期间完成的用户主动发起的更新下载,把"安装并重启"确认推迟到窗口再次显示时;强制更新流程沿用其任务栏和 Dock 提醒。Windows 安装程序和卸载程序在应用仍在运行时提示用户先在系统托盘中退出。Desktop 默认未开启定时任务,定时任务的说明只在该功能开启后出现;提醒只在已加载的会话中触发,未加载的会话既不计入,也要等到打开后才会继续。
|
||||
|
||||
## 关键技术决策
|
||||
|
||||
设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。
|
||||
@@ -366,7 +376,7 @@ Windows 下载完成后的更新确认说明应用会在安装期间关闭、完
|
||||
|
||||
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。
|
||||
|
||||
左下角账户行显示本地化的更新可用状态、加载图标与下载百分比、验证、就绪状态,或带可访问提示的持久红色重试操作。嵌入 Web 界面的文案跟随应用内当前语言;原生弹窗使用 Desktop 壳语言。侧栏收起时,顶部展开按钮显示圆点。连接状态优先展示。选择可用版本即开始下载。准备成功后自动打开壳拥有的重启确认;关闭后保留就绪状态,不重复弹窗。选择就绪入口可再次打开确认。运行中的 agent、排队输入,以及运行中或停止中的后台任务都会在该确认中触发中断警告。仅有 API 请求不会触发警告。用户批准后,Host 锁定新请求,等待已接收的请求结束,再检查任务,包括已接收写操作创建的工作。等待超过控制请求截止时间时,拒绝安装并解除准入锁。任务状态未知、未获中断授权的新任务,或未成功完成正常收尾,都会阻止安装。常规退出会在停止 Host 前隐藏产品窗口,在收尾期间忽略新的聚焦请求,且从不安装更新。下次启动通过已有的启动与恢复流程校准版本绑定的运行时。
|
||||
左下角账户行显示本地化的更新可用状态、加载图标与下载百分比、验证、就绪状态,或带可访问提示的持久红色重试操作。嵌入 Web 界面的文案跟随应用内当前语言;原生弹窗使用 Desktop 壳语言。侧栏收起时,顶部展开按钮显示圆点。连接状态优先展示。选择可用版本即开始下载。准备成功后自动打开壳拥有的重启确认;关闭后保留就绪状态,不重复弹窗。选择就绪入口可再次打开确认。运行中的 agent、排队输入,以及运行中或停止中的后台任务都会在该确认中触发中断警告。仅有 API 请求不会触发警告。用户批准后,Host 锁定新请求,等待已接收的请求结束,再检查任务,包括已接收写操作创建的工作。等待超过控制请求截止时间时,拒绝安装并解除准入锁。任务状态未知、未获中断授权的新任务,或未成功完成正常收尾,都会阻止安装。常规退出先按"关闭窗口与退出"一节所述询问可中断的工作,再在停止 Host 前隐藏产品窗口,在收尾期间忽略新的聚焦请求,且从不安装更新。下次启动通过已有的启动与恢复流程校准版本绑定的运行时。
|
||||
|
||||
若任务收尾失败但已确认 Host 退出,安装会被拒绝,壳会在允许再次确认重启前恢复当前版本的 Host。Host 正常停止后的安装器启动失败使用同一恢复路径。替代 Host 启动并完成认证后,壳重新加载原有应用地址,让 Web 页面获取当前端口、Cookie 和启动注入数据;页面加载失败时打开原生致命故障恢复弹窗。未确认进程退出时,绝不允许启动替代 Host。已下载目标保留以供重试。已知强更策略在恢复过程中继续阻塞;Host 恢复失败打开原生致命故障恢复弹窗。
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface DesktopElectronBuilderConfig {
|
||||
readonly extraResources: readonly [
|
||||
{ readonly from: string, readonly to: 'runtime' },
|
||||
{ readonly from: string, readonly to: 'icon.png' },
|
||||
...({ readonly from: string, readonly to: 'tray.ico' } | never)[],
|
||||
]
|
||||
readonly mac: {
|
||||
readonly extendInfo: { readonly NSMicrophoneUsageDescription: string }
|
||||
|
||||
@@ -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",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 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,119 @@
|
||||
/**
|
||||
* Render the Windows tray icon 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 --filter @deepseek-ai/dsh-desktop exec tsx scripts/render-tray-icon.ts`
|
||||
* 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.
|
||||
* @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[]> {
|
||||
return Promise.all(sizes.map(async size => ({
|
||||
size,
|
||||
png: await sharp(svg, { density: SOURCE_DENSITY * size / SOURCE_EDGE }).resize(size, size).png().toBuffer(),
|
||||
})))
|
||||
}
|
||||
|
||||
function pngDimensions(png: Buffer): { width: number; height: number } {
|
||||
if (png.length < 24 || !png.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) throw new Error('tray icon: bitmap is not a PNG stream')
|
||||
return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) }
|
||||
}
|
||||
|
||||
async function main(): Promise<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,58 @@
|
||||
/** One-time Windows notice that closing the window left the application running in the tray. */
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { Notification } from 'electron'
|
||||
import type { DesktopLocale } from './locale.ts'
|
||||
|
||||
/** Marker location and the actions the notice can trigger. */
|
||||
export interface DesktopBackgroundNoticeOptions {
|
||||
/**
|
||||
* Marker written the first time the notice is shown. Lives under Electron's userData, which the
|
||||
* uninstaller removes and in-place updates keep, so each installation sees the notice once.
|
||||
*/
|
||||
readonly markerPath: string
|
||||
readonly locale: () => DesktopLocale
|
||||
/** Show and focus the primary window when the notice is clicked. */
|
||||
readonly open: () => void
|
||||
}
|
||||
|
||||
/** Shows once per installation, and at most once per process even when the marker cannot be written. */
|
||||
export class DesktopBackgroundNotice {
|
||||
private shown = false
|
||||
private notification: Notification | undefined
|
||||
|
||||
/** @param options - Marker path, locale reader, and the click action. */
|
||||
constructor(private readonly options: DesktopBackgroundNoticeOptions) {}
|
||||
|
||||
/**
|
||||
* Show the notice after the first hide of this installation. System notification permissions or a
|
||||
* focus mode can suppress it; the window stays hidden and the tray stays available either way.
|
||||
*/
|
||||
show(): void {
|
||||
if (this.shown) return
|
||||
this.shown = true
|
||||
if (existsSync(this.options.markerPath)) return
|
||||
try {
|
||||
mkdirSync(dirname(this.options.markerPath), { recursive: true })
|
||||
writeFileSync(this.options.markerPath, '')
|
||||
} catch (error) { console.warn('desktop tray: could not record the background notice', error) }
|
||||
try {
|
||||
if (!Notification.isSupported()) return
|
||||
const { messages } = this.options.locale()
|
||||
const notification = new Notification({ title: messages.backgroundNoticeTitle, body: messages.backgroundNoticeBody, silent: true })
|
||||
this.notification = notification
|
||||
notification.on('failed', () => {
|
||||
if (this.notification === notification) this.notification = undefined
|
||||
notification.removeAllListeners()
|
||||
})
|
||||
notification.once('click', () => {
|
||||
if (this.notification !== notification) return
|
||||
this.notification = undefined
|
||||
notification.removeAllListeners()
|
||||
this.options.open()
|
||||
})
|
||||
notification.show()
|
||||
} catch (error) { console.warn('desktop tray: background notice unavailable', error) }
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
backgroundNoticeTitle: 'DeepSeek Harness is still running',
|
||||
backgroundNoticeBody: 'Running tasks will continue. Reopen or quit it from the system tray.',
|
||||
edit: 'Edit',
|
||||
menuBar: 'Application menu',
|
||||
delete: 'Delete',
|
||||
@@ -146,6 +155,15 @@ export const zh = {
|
||||
hideOtherApplications: '隐藏其他',
|
||||
showAllApplications: '显示全部',
|
||||
quitApplication: '退出 DeepSeek Harness',
|
||||
openApplication: '打开 DeepSeek Harness',
|
||||
quit: '退出',
|
||||
cancel: '取消',
|
||||
quitTitle: '退出 DeepSeek Harness?',
|
||||
quitActiveTasks: '当前正在运行的任务将会中断',
|
||||
quitScheduledTasks: '应用关闭期间,定时任务不会运行',
|
||||
quitActiveAndScheduledTasks: '当前正在运行的任务将会中断,且应用关闭期间,定时任务不会运行',
|
||||
backgroundNoticeTitle: 'DeepSeek Harness 仍在后台运行',
|
||||
backgroundNoticeBody: '正在运行的任务不会中断。可在系统托盘中重新打开或退出。',
|
||||
edit: '编辑',
|
||||
menuBar: '应用菜单',
|
||||
delete: '删除',
|
||||
|
||||
+107
-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, the development restart
|
||||
* command, and the installer handoff. Ordinary quits inspect the Host first.
|
||||
*/
|
||||
let skipQuitConfirmation = false
|
||||
let windowsLanguage: string | undefined
|
||||
/**
|
||||
* Whether the backend has reached ready: false until the first ready, back to
|
||||
@@ -72,6 +81,11 @@ app.setAppLogsPath()
|
||||
function currentDesktopLocale(): ReturnType<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,9 @@ 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 shutting down or logging off: windows close and the quit skips its confirmation. */
|
||||
let sessionEnding = false
|
||||
const isQuitting = (): boolean => quitting
|
||||
const currentMainWindow = (): BrowserWindow | undefined => mainWindow
|
||||
const ordinaryDialogs = new Set<AbortController>()
|
||||
@@ -446,6 +463,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 +596,18 @@ 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() })
|
||||
})
|
||||
|
||||
protocol.handle(SCHEME, (request) => {
|
||||
const url = new URL(request.url)
|
||||
@@ -838,14 +866,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 +903,7 @@ async function main(): Promise<void> {
|
||||
{ label: currentDesktopLocale().messages.restartAppHostMenu, click: () => {
|
||||
if (quitting) return
|
||||
app.relaunch()
|
||||
app.quit()
|
||||
quitWithoutConfirmation()
|
||||
} },
|
||||
] : [],
|
||||
{ type: 'separator' },
|
||||
@@ -891,8 +920,31 @@ async function main(): Promise<void> {
|
||||
label: darwin ? app.name : currentDesktopLocale().messages.application,
|
||||
submenu: [...applicationItems(), ...devToolsItems],
|
||||
}, ...platformMenus()]))
|
||||
tray?.relabel()
|
||||
}
|
||||
refreshApplicationMenu()
|
||||
if (process.platform === 'win32') {
|
||||
// The tray is the way back to a hidden window; without it, relaunching the application still focuses it.
|
||||
try {
|
||||
tray = new DesktopTray({
|
||||
iconPath: development ? join(app.getAppPath(), 'resources', 'tray-windows.ico') : join(process.resourcesPath, 'tray.ico'),
|
||||
locale: currentDesktopLocale, open: () => { focusPrimaryWindow() }, quit: () => { app.quit() },
|
||||
})
|
||||
} catch (error) { console.warn('desktop tray: unavailable', error) }
|
||||
}
|
||||
const backgroundNotice = process.platform === 'win32'
|
||||
? new DesktopBackgroundNotice({ markerPath: join(app.getPath('userData'), 'background-notice-shown'),
|
||||
locale: () => locale, open: () => { focusPrimaryWindow() } })
|
||||
: undefined
|
||||
const quitConfirmation = new DesktopQuitConfirmation({
|
||||
locale: () => locale,
|
||||
inspect: () => backend.host?.inspectQuit(),
|
||||
// No owner window: a hidden window stays hidden and the native box is its own top-level window.
|
||||
show: options => dialog.showMessageBox(options),
|
||||
// macOS can raise the open alert with the application; the Windows task dialog owns its own activation.
|
||||
focus: () => { if (process.platform === 'darwin') app.focus({ steal: true }) },
|
||||
...(process.platform === 'win32' ? { icon: nativeImage.createFromPath(applicationIconPath) } : {}),
|
||||
})
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
ipcMain.handle(DESKTOP_IPC.windowsMenu, (event, name: unknown, x: unknown, y: unknown) => {
|
||||
@@ -941,12 +993,33 @@ async function main(): Promise<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()
|
||||
}
|
||||
backgroundNotice?.show()
|
||||
}
|
||||
const createMainWindow = (): BrowserWindow => {
|
||||
const window = createWindow(appPreload, false, true)
|
||||
mainWindow = window
|
||||
browserGuests.bind(window, (guest, name) => shortcuts.attachGuest(window, guest, name))
|
||||
shortcuts.attach(window)
|
||||
window.on('focus', automaticCheck)
|
||||
// Closing hides: the page and the Host keep running, and the next show resumes the same document.
|
||||
window.on('close', (event) => {
|
||||
if (quitting || shellInstallerOwnsQuit || sessionEnding) return
|
||||
event.preventDefault()
|
||||
hideMainWindow(window)
|
||||
})
|
||||
if (process.platform === 'win32') {
|
||||
// Shutdown, restart, and log-off must not wait on a confirmation.
|
||||
window.on('query-session-end', () => { sessionEnding = true })
|
||||
window.on('session-end', () => { sessionEnding = true })
|
||||
}
|
||||
window.on('closed', () => { if (mainWindow === window) mainWindow = undefined })
|
||||
window.webContents.on('console-message', (details) => {
|
||||
if (details.level !== 'error') return
|
||||
@@ -1038,7 +1111,11 @@ async function main(): Promise<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 +1159,18 @@ 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
|
||||
tray?.dispose()
|
||||
stopAccount?.()
|
||||
if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.hide()
|
||||
if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.hide()
|
||||
@@ -1111,6 +1181,24 @@ async function main(): Promise<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
|
||||
updateJournal?.action('quit-requested')
|
||||
tray?.dispose()
|
||||
updateDialog.dispose()
|
||||
mandatoryUI?.dispose()
|
||||
// Installation preparation already awaited Platform storage cleanup.
|
||||
void platformView.dispose().catch((error: unknown) => { console.error(error) })
|
||||
return
|
||||
}
|
||||
if (quitting) return
|
||||
event.preventDefault()
|
||||
if (skipQuitConfirmation || sessionEnding) { finishQuit(); return }
|
||||
void quitConfirmation.confirm().then((approved) => {
|
||||
if (approved && !quitting) finishQuit()
|
||||
}).catch((error: unknown) => { console.error(error); if (!quitting) finishQuit() })
|
||||
})
|
||||
|
||||
mainWindow = createMainWindow()
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/** Native quit confirmation; quitting proceeds silently only when the Host reports nothing to interrupt. */
|
||||
|
||||
import type { MessageBoxOptions, MessageBoxReturnValue, NativeImage } from 'electron'
|
||||
import type { DesktopQuitInspection } from './host-process.ts'
|
||||
import type { DesktopLocale, DesktopMessages } from './locale.ts'
|
||||
|
||||
/** Locale key of the explanation shown before quitting, or none when quitting interrupts nothing. */
|
||||
export type DesktopQuitPrompt = Extract<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
|
||||
|
||||
/** @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.pending !== undefined) {
|
||||
this.options.focus()
|
||||
return this.pending
|
||||
}
|
||||
const pending = this.decide().finally(() => { if (this.pending === pending) this.pending = undefined })
|
||||
this.pending = pending
|
||||
return pending
|
||||
}
|
||||
|
||||
private async decide(): Promise<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 (prompt === undefined) return true
|
||||
const { messages } = this.options.locale()
|
||||
const windows = (this.options.platform ?? process.platform) === 'win32'
|
||||
// Button order follows each platform: macOS lays buttons out right-to-left from index 0, so
|
||||
// "Quit" sits right of "Cancel"; the Windows task dialog keeps array order, "Quit" left of "Cancel".
|
||||
const result = await this.options.show({
|
||||
type: windows ? 'none' : 'warning',
|
||||
...(windows && this.options.icon !== undefined ? { icon: this.options.icon } : {}),
|
||||
title: messages.aboutProduct,
|
||||
message: messages.quitTitle,
|
||||
detail: messages[prompt],
|
||||
buttons: [messages.quit, messages.cancel],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
noLink: true,
|
||||
})
|
||||
return result.response === 0
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { DesktopBackgroundNotice } from '../src/background-notice.ts'
|
||||
import { resolveDesktopLocale } from '../src/locale.ts'
|
||||
|
||||
const native = await vi.hoisted(async () => {
|
||||
const { EventEmitter } = await import('node:events')
|
||||
const notices: Notice[] = []
|
||||
class Notice extends EventEmitter {
|
||||
static isSupported = vi.fn(() => true)
|
||||
show = vi.fn()
|
||||
constructor(readonly options: unknown) { super(); notices.push(this) }
|
||||
}
|
||||
return { notices, Notice }
|
||||
})
|
||||
vi.mock('electron', () => ({ Notification: native.Notice }))
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
native.notices.length = 0
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks()
|
||||
native.Notice.isSupported.mockReturnValue(true)
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function setup(markerPath = join(mkdtempSync(join(tmpdir(), 'dsh-desktop-notice-')), 'state', 'background-notice-shown')) {
|
||||
roots.push(join(markerPath, '..', '..'))
|
||||
const open = vi.fn()
|
||||
const notice = new DesktopBackgroundNotice({ markerPath, locale: () => resolveDesktopLocale('zh'), open })
|
||||
return { notice, open, markerPath }
|
||||
}
|
||||
|
||||
it('notifies once per installation, records the marker, and opens the window on click', () => {
|
||||
const f = setup()
|
||||
f.notice.show()
|
||||
f.notice.show()
|
||||
expect(existsSync(f.markerPath)).toBe(true)
|
||||
expect(native.notices).toHaveLength(1)
|
||||
expect(native.notices[0]!.options).toEqual({
|
||||
title: 'DeepSeek Harness 仍在后台运行', body: '正在运行的任务不会中断。可在系统托盘中重新打开或退出。', silent: true,
|
||||
})
|
||||
expect(native.notices[0]!.show).toHaveBeenCalledOnce()
|
||||
native.notices[0]!.emit('click')
|
||||
native.notices[0]!.emit('click')
|
||||
expect(f.open).toHaveBeenCalledOnce()
|
||||
// The next process of the same installation finds the marker.
|
||||
const again = new DesktopBackgroundNotice({ markerPath: f.markerPath, locale: () => resolveDesktopLocale('zh'), open: f.open })
|
||||
again.show()
|
||||
expect(native.notices).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('still notifies once in this process when the marker cannot be written', () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-notice-'))
|
||||
roots.push(root)
|
||||
// A file where the marker's directory should be makes the marker write fail.
|
||||
writeFileSync(join(root, 'blocker'), '')
|
||||
const open = vi.fn()
|
||||
const blocked = new DesktopBackgroundNotice({ markerPath: join(root, 'blocker', 'background-notice-shown'), locale: () => resolveDesktopLocale('zh'), open })
|
||||
blocked.show()
|
||||
blocked.show()
|
||||
expect(console.warn).toHaveBeenCalledWith('desktop tray: could not record the background notice', expect.anything())
|
||||
expect(native.notices).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does nothing visible without notification support or after a delivery failure', () => {
|
||||
native.Notice.isSupported.mockReturnValue(false)
|
||||
const f = setup()
|
||||
f.notice.show()
|
||||
expect(native.notices).toHaveLength(0)
|
||||
expect(existsSync(f.markerPath)).toBe(true)
|
||||
native.Notice.isSupported.mockReturnValue(true)
|
||||
const g = setup()
|
||||
g.notice.show()
|
||||
native.notices[0]!.emit('failed')
|
||||
native.notices[0]!.emit('click')
|
||||
expect(g.open).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -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,17 @@ const harness = await vi.hoisted(async () => {
|
||||
})
|
||||
let accountListener: ((state: AccountView) => void) | undefined
|
||||
const nativeTheme = { themeSource: 'system', shouldUseDarkColors: false }
|
||||
const trays: FakeTray[] = []
|
||||
class FakeTray extends EventEmitter {
|
||||
readonly setToolTip = vi.fn()
|
||||
readonly setContextMenu = vi.fn()
|
||||
readonly destroy = vi.fn()
|
||||
constructor(readonly image: unknown) { super(); trays.push(this) }
|
||||
}
|
||||
const backgroundNotice = { show: vi.fn(), markerPath: undefined as string | undefined }
|
||||
return {
|
||||
failWindow(error: Error) { windowFailure = error },
|
||||
windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme,
|
||||
windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, trays, FakeTray, backgroundNotice,
|
||||
menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall,
|
||||
platformDispose,
|
||||
platformCloseAndWait,
|
||||
@@ -213,6 +224,8 @@ const harness = await vi.hoisted(async () => {
|
||||
reset() {
|
||||
accountListener = undefined
|
||||
windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners()
|
||||
trays.length = 0
|
||||
backgroundNotice.markerPath = undefined
|
||||
powerMonitor.removeAllListeners()
|
||||
app.isPackaged = true
|
||||
windowFailure = undefined
|
||||
@@ -265,7 +278,13 @@ vi.mock('electron', () => ({
|
||||
} },
|
||||
protocol: { registerSchemesAsPrivileged: vi.fn(), handle: harness.protocolHandle },
|
||||
powerMonitor: harness.powerMonitor,
|
||||
Tray: harness.FakeTray,
|
||||
nativeImage: { createFromPath: (path: string) => ({ path }) },
|
||||
}))
|
||||
vi.mock('../src/background-notice.ts', () => ({ DesktopBackgroundNotice: class {
|
||||
constructor(options: { markerPath: string }) { harness.backgroundNotice.markerPath = options.markerPath }
|
||||
readonly show = harness.backgroundNotice.show
|
||||
} }))
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return { ...original, readFile: vi.fn((path: Parameters<typeof original.readFile>[0], encoding?: 'utf8') => {
|
||||
@@ -378,6 +397,9 @@ afterEach(async () => {
|
||||
harness.prepared.resolve()
|
||||
for (const host of harness.hosts) { host.ready.resolve(); host.exited.resolve() }
|
||||
harness.app.quit()
|
||||
// The quit decides asynchronously; a Host that starts meanwhile must still be released.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
for (const host of harness.hosts) { host.ready.resolve(); host.exited.resolve() }
|
||||
await harness.quitCompleted.promise
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllTimers()
|
||||
@@ -1043,12 +1065,23 @@ describe('desktop main startup', () => {
|
||||
.toMatchFileSnapshot(`./expected/update-restart-${platform}-${language}.json`)
|
||||
})
|
||||
|
||||
it('hides the workspace before intentional Host shutdown can look like reconnection', async () => {
|
||||
/** Like readyForUpdate, but past backend readiness and the workspace reveal, so the Host answers quit inspections. */
|
||||
async function readyWorkspace() {
|
||||
const host = await readyForUpdate()
|
||||
await Promise.resolve(invoke(DESKTOP_IPC.boot))
|
||||
return host
|
||||
}
|
||||
|
||||
it('hides the workspace before intentional Host shutdown can look like reconnection', async () => {
|
||||
const host = await readyWorkspace()
|
||||
const window = harness.windows[0]!
|
||||
window.show.mockClear()
|
||||
window.focus.mockClear()
|
||||
harness.app.quit()
|
||||
// The quit inspects the Host before hiding; nothing to interrupt means no dialog.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(host.inspectQuit).toHaveBeenCalledOnce()
|
||||
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
|
||||
expect(window.hide).toHaveBeenCalledOnce()
|
||||
await host.stopping.promise
|
||||
harness.app.emit('second-instance')
|
||||
@@ -1056,6 +1089,132 @@ describe('desktop main startup', () => {
|
||||
expect(window.focus).not.toHaveBeenCalled()
|
||||
host.exited.resolve()
|
||||
await harness.quitCompleted.promise
|
||||
expect(harness.trays[0]!.destroy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides the main window on close, keeps the Host running, and notifies once about the tray', async () => {
|
||||
const host = await readyWorkspace()
|
||||
const window = harness.windows[0]!
|
||||
expect(harness.trays).toHaveLength(1)
|
||||
expect(harness.trays[0]!.image).toEqual({ path: join('desktop-test-resources', 'tray.ico') })
|
||||
expect(harness.backgroundNotice.markerPath).toBe(join('desktop-test-userData', 'background-notice-shown'))
|
||||
window.show.mockClear()
|
||||
window.close()
|
||||
expect(window.isDestroyed()).toBe(false)
|
||||
expect(window.hide).toHaveBeenCalledOnce()
|
||||
expect(host.stop).not.toHaveBeenCalled()
|
||||
expect(harness.app.quit).not.toHaveBeenCalled()
|
||||
expect(harness.backgroundNotice.show).toHaveBeenCalledOnce()
|
||||
harness.trays[0]!.emit('click')
|
||||
expect(window.show).toHaveBeenCalledOnce()
|
||||
expect(window.focus).toHaveBeenCalled()
|
||||
harness.app.emit('second-instance')
|
||||
expect(window.show).toHaveBeenCalledTimes(2)
|
||||
// Locale changes relabel the tray together with the application menu.
|
||||
const relabels = harness.trays[0]!.setContextMenu.mock.calls.length
|
||||
harness.ipcOn.mock.calls.find(call => call[0] === DESKTOP_IPC.localeChanged)
|
||||
expect(harness.trays[0]!.setContextMenu.mock.calls.length).toBe(relabels + 1)
|
||||
})
|
||||
|
||||
it('leaves macOS fullscreen before hiding on close and reopens the hidden window on activate', async () => {
|
||||
vi.stubGlobal('process', { ...process, platform: 'darwin', arch: 'arm64', resourcesPath: 'desktop-test-resources' })
|
||||
await readyWorkspace()
|
||||
const window = harness.windows[0]!
|
||||
expect(harness.trays).toHaveLength(0)
|
||||
window.fullscreen = true
|
||||
window.close()
|
||||
expect(window.setFullScreen).toHaveBeenCalledWith(false)
|
||||
expect(window.hide).toHaveBeenCalledOnce()
|
||||
expect(harness.backgroundNotice.show).not.toHaveBeenCalled()
|
||||
window.show.mockClear()
|
||||
harness.app.emit('activate', {}, false)
|
||||
expect(window.show).toHaveBeenCalledOnce()
|
||||
harness.app.emit('activate', {}, true)
|
||||
expect(window.show).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('asks before quitting when the Host reports interruptible work and cancels without stopping anything', async () => {
|
||||
const host = await readyWorkspace()
|
||||
const window = harness.windows[0]!
|
||||
host.inspectQuit.mockResolvedValue({ activeTasks: true, scheduledTasks: true })
|
||||
harness.dialog.showMessageBox.mockResolvedValue({ response: 1, checkboxChecked: false })
|
||||
harness.app.quit()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(harness.dialog.showMessageBox).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
|
||||
message: en.quitTitle, detail: en.quitActiveAndScheduledTasks, buttons: [en.quit, en.cancel], defaultId: 0, cancelId: 1,
|
||||
}))
|
||||
expect(window.hide).not.toHaveBeenCalled()
|
||||
expect(host.stop).not.toHaveBeenCalled()
|
||||
expect(harness.trays[0]!.destroy).not.toHaveBeenCalled()
|
||||
// A second request while the box is open joins it instead of stacking another.
|
||||
const pending = Promise.withResolvers<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 })
|
||||
window.emit('query-session-end', { reasons: ['shutdown'] })
|
||||
window.close()
|
||||
expect(window.isDestroyed()).toBe(true)
|
||||
// Electron follows the last closed window with window-all-closed, which quits on Windows.
|
||||
harness.app.quit()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
|
||||
expect(host.inspectQuit).not.toHaveBeenCalled()
|
||||
await host.stopping.promise
|
||||
host.exited.resolve()
|
||||
await harness.quitCompleted.promise
|
||||
})
|
||||
|
||||
it('defers the downloaded-update confirmation until the hidden window is shown again', async () => {
|
||||
await readyWorkspace()
|
||||
const window = harness.windows[0]!
|
||||
harness.updateState = { phase: 'available', version: '1.0.1' }
|
||||
harness.updateDownload.mockImplementation(async () => { harness.updateState = { phase: 'ready', version: '1.0.1' }; return harness.updateState })
|
||||
window.close()
|
||||
window.visible = false
|
||||
const opened = invoke(DESKTOP_IPC.updatesOpen, 'app') as Promise<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 () => {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { MessageBoxOptions, MessageBoxReturnValue, NativeImage } from 'electron'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { DesktopQuitInspection } from '../src/host-process.ts'
|
||||
import { resolveDesktopLocale } from '../src/locale.ts'
|
||||
import { DesktopQuitConfirmation, resolveDesktopQuitPrompt } from '../src/quit-confirmation.ts'
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
function setup(platform: NodeJS.Platform, inspect: () => Promise<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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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