桌面壳里,装着整个运行时
Electron 桌面端:签名资源里带一整套 dsh,私有 Desktop Host。桌面应用是一个包着整套 dsh Web 应用的 Electron 壳:RunAsNode 子进程跑共享 profile runner,主窗口加载 dsh-app://app/;签名、密封、更新、退出检查都由壳与 Host 之间的私有 IPC 承担。
本章以中文撰写
第 0 秒,你点开桌面应用。Electron 主进程起来,先派一个 RunAsNode 子进程跑 desktop-host——它加载 desktop profile、起 Host,默认端口 19387;主窗口这边,Electron 已经加载打包的 Web 入口 dsh-app://app/,一个共享加载页等着 Host 的 boot injections。Host 就绪后经私有 Node IPC 发来 { type: 'ready', url, injections },加载页把注入铺进页面,客户端就地启动,不跳转任何文档。你看到的是窗口,底下跑的是第 46 章那套 Web 应用——同一个运行时,一张 Electron 的脸。
这一章讲这张脸:桌面壳怎么把整套 dsh 装进签名资源、怎么和 Host 说话、退出和更新时怎么不丢任务。
一个壳,一个共享 profile runner
桌面应用的本质在 README 第一句:“The desktop application is an Electron shell around the complete dsh Web application.”。壳与 Host 的分工是:
- Electron 壳(
apps/desktop)负责窗口、菜单、托盘、更新、原生对话框、快捷键、平台文档嵌入; - Host(
apps/desktop-host起的 profile runner)负责全部业务——会话、Agent、审批、审计,一样不换。
desktop-host 的 main() 把启动串成四段:解析运行时目录与安装锚点(loadProfileDirectory,锚点是 runtimeDir/node_modules/@deepseek-ai/dsh/package.json)→ runProfile({ profile: 'desktop', args: ['--no-open', '--port', '19387'] }) → 装配桌面专属插件(更新任务控制、退出检查、平台会话发布、Office 引擎解析)→ 把 ready 消息送回壳。端口是钉死的 19387,与 Web 的 3080 分开,webserver.config.port 补丁可以覆盖。
壳与 Host 的通信是私有 Node IPC,词汇表很小,一共六种:
| IPC 消息 | 方向 | 内容 |
|---|---|---|
ready | Host → 壳 | 已认证的窗口 URL + collectIndexInjections() 的注入清单 |
fatal | Host → 壳 | 启动失败的 inspect 诊断,上限 64 KiB,头与堆栈都在 |
shutdown / shutdown-complete | 壳 → Host / Host → 壳 | 壳请求停止,Host 收尾后回执并 disconnect |
quit-inspection | 壳 → Host | 带 requestId,Host 回 { activeTasks, scheduledTasks },失败也回包 |
update-tasks | 壳 → Host | 带 requestId 与 inspect/lock/unlock,Host 回 { active } |
platform-session | Host → 壳 | 账号会话快照(或 null),供平台文档分区使用 |
一个细节很说明问题:fatal 诊断不靠 stderr。“stderr bytes and this IPC message race, and the shell reports the first failure it sees”——main() 的 catch 里把 inspect 后的完整错误经 IPC 发出去,上限 64 KiB。为什么不能只靠 stderr?因为壳读 stderr 和收 IPC 消息是两条并行的通道,先到哪条算哪条:stderr 里可能只有一行 Error: something went wrong,而完整堆栈还在 IPC 消息里慢一步;反过来,诊断内容太长时 stderr 可能被截断。把完整诊断放进一条带上限的 IPC 消息,壳拿到的要么是整段、要么明确知道被截了 64 KiB——不会像 stderr 那样只拿到半句。
窗口里的 HTTP 请求也不是直连宿主进程:Electron 把应用请求转发给已认证的 Web Host,丢弃描述 Node fetch 而非资源的连接级响应头(transfer-encoding、connection、keep-alive),插件包响应标 no-store(每次启动的修订只会堆积在 Chromium 磁盘缓存里);WebSocket 流连到那个 Host,凭据只对所属的应用窗口附加。
本节源码依据(6 处)
签名资源里,装着一整套 dsh
“签名资源里带一整套 dsh”不是宣传语,是打包流水线的字面事实。桌面包自带独立的 Node、Python、pnpm 发行——Python 里带着 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml、XlsxWriter 及完整依赖。安装锚点经 lock.json 钉死:解释器归档、Python 分发版本、各目标 wheel URL 和哈希,一个都不许漂;runtime.json 记录 Desktop 版本、目标、顶层解释器/包管理器版本、Python 分布映射,以及所选目标锁定载荷输入与装配格式的摘要(digest)。匹配的安装被复用;依赖或归档变了就整段替换——先完整暂存再换,换失败保留旧安装。
签名是这条流水线里最重的一步。Windows 侧的脚本清单就是证据:sign:primary-runtime、cache:windows-signatures、package:win:x64:unsigned、verify:mac-signature、check:package。把 signWindowsDesktopRuntime 的顺序读出来,它是一句代码一句代码地钉死的:
verifyDesktopRuntime(root, version) // 先核对当前树
signWindowsCode(root, options) // 再签全部 Windows 代码
writeDesktopRuntime(...) // 密封:写运行时描述符(含哈希)
verifyDesktopRuntime(root, version) // 密封后验一遍
smoke(signed) // 再跑 smoke
verifyDesktopRuntime(root, version) // 最后再验一遍
先签名、后密封,这个顺序是硬约束,不是风格问题。理由在职责上:密封哈希是对”将要执行的代码”的承诺,而签名是对”这份代码是谁发的”的证明——先封后签,签名盖在摘要上,可签的东西却还没被验证过;先签后封,签名覆盖的是将要密封的那份确切字节,密封时写进描述符的哈希就是对已签名代码的指纹。封装完再验一遍、跑 smoke、再验,是防”签名过程悄悄改动了树”:签名工具转码、加时间戳都可能改变字节,密封之后的两次验证确保描述符与最终落盘的树一致。
硬件令牌的使用方式也写死了:withWindowsSigningStage 用一个账号级独占文件句柄把整个签名阶段串行化——“Hold one account-wide exclusive file handle until a stage and its children have settled”,争用就等,其他获取错误在操作前停下;createWindowsTokenSigner 从环境里解析证书、签名工具、令牌 PIN 与密钥容器,把 secrets 隔离出来交给受监管的签名器。为什么令牌签名必须串行?因为硬件令牌本身是单会话设备:并发的签名请求会互相打断 PIN 输入、把令牌锁在错误的上下文里,而且签名是带时间戳的、不可撤销的动作——串行既保护硬件,也让审计事件一条条有明确顺序。与之对照,公共密钥检查是并行的:windows-signature-batch 的注释原话是 “Inspect every file through at most four processes, draining failures before returning”——每 128 个文件一组、拆成 4 组各 32 个并行检查,任何一组失败整组抛出(“before returning”:结果全部落定才算这一轮完成)。
PE 扫描本身还有一套拒绝规则:目录链接不是可签对象、畸形 MZ 头不是 PE(offset >= 64 且读出的四字节是 0x4550 才算)、带 PE 头的数据文件也被拒;preserveWindowsRuntimeSignature 只保留签名状态 Valid 且逐字节一致的副本——“Preserve a copied runtime executable only after signature and exact-byte verification”。signWindowsCode 对每个文件:已有有效签名且时间戳、指纹都匹配配置证书的就保留(L121),否则拒绝旧签名重新签(L116/L163)。最终一道 PE 签名审计 + 全新缓存 ASAR 载荷 + Host smoke 全部通过,才写发布完成记录。macOS 侧独立验证(verify:mac-signature),原生目标检查在暂存清理后和 macOS 签名后各跑一次。
这套”验证摘要再放行”的哲学在第 20 章(helperHash)和第 19 章(bootstrapHash)都见过:资源进执行世界之前,身份先过摘要。桌面端只是把同一原则搬到了整个应用壳上。
注:本节签名流水线的具体细节(PE 扫描顺序、批大小、令牌互斥)依据
apps/desktop/scripts/下的打包脚本与apps/desktop/README.md;本地未运行过真实 Windows 打包,行号证据可对照源码复核。
本节源码依据(12 处)
私有运行时,公共的沙箱哲学
桌面自带运行时的边界画得很细:$DSH_HOME/dsh-runtimes/dsh-primary-runtime(通常 ~/.dsh/...)首次使用按需离线安装,load_workspace_dependencies 工具返回绝对的解释器、pnpm 脚本与库路径;runtime/bin 只加进包安装进程的 PATH,不加进 Host PATH——PTC 和 agent shell 继承的环境是第 19 章那张从空开始的纸,桌面自带的私有工具不污染它。
这条隔离为什么值得单独说?因为环境是执行世界的边界。PTC 程序、Agent 的 bash、SSH 数据面(第 20 章)看到的 PATH 决定了它们能唤起什么:把桌面的私有 Node 放进 Host PATH,模型写的一段 run_code 就能 exec 到桌面捆绑的解释器,绕过打包时的签名与沙箱假设;反过来,包操作(pnpm install、Office 依赖安装)需要 Node,却不应依赖系统里的任何东西。所以桌面用 ELECTRON_RUN_AS_NODE 起一个私有 Node 启动器环境(process.execPath --expose-internals),它只服务包操作,Host 那张纸保持干净。
Office 技能(office-docx、office-pptx、office-xlsx)默认注册,用捆绑 Python 库创建与聚焦编辑,交付前重开文件跑共享结构检查器;技能资源拷到 ASAR 外的 runtime/office-skills,让 Python 读得到。Office 引擎有个细节:installOfficeEngineResolution 处理 ASAR 打包的运行时常驻——libreoffice-kit- 平台包在 ASAR 里时,原生子进程读不到,所以注册 Node 的 resolve hooks,把引擎可执行文件与资源路径重定向到 app.asar.unpacked 里的实体文件;worker 线程要装自己的 resolver,非 ASAR 的 prepared 目录直接跳过。
环境隔离的另一面是凭据。平台文档(嵌入式 WebContentsView,按平台 origin 与稳定账号 ID 的哈希分键)的账号凭据只经私有 Node IPC 送进 preload:主进程处理器只做发送者校验和内存读取,从不等待 Host、磁盘或网络;页面脚本拿到的是 getAuthToken()/getLocale() 两个同步 getter,读 preload 内存,不再过 IPC。sign-out、凭据更换、Host 关闭、视图关闭都会销毁文档;跨源导航被拦,HTTPS 链接要开新窗口就交给系统浏览器,不带嵌入会话和 token。installPlatformSessionPublisher 在账号 provider 的 lifetime 里订阅 account.watch,每次状态变化取一次 getPlatformSession 经私有 IPC 发布;provider 移除或流终止时发布 null 清掉壳里的旧凭据——“publish account sessions for each provider lifetime, clearing them on removal or stream termination”。
目录选择也分三档:桌面用附加到应用窗口的 Electron 原生文件夹对话框(并发请求共用一个对话框);普通 Web 用 Host 的 chooser;没有 zenity/kdialog 的 Linux 自动改用 browse。
本节源码依据(6 处)
退出之前,先问 Host 会打断什么
关窗口不等于退出。macOS 关窗(⌘W/关闭钮)和 Windows 关窗(×、Alt+F4、任务栏关闭)都只是隐藏:页面和 Host 继续跑,任务继续,下一次显示还是同一个文档,会话、草稿、滚动位置都在。Windows 首次隐藏先弹确认(覆盖层说明任务继续、可从托盘召回,确认写 background-close-confirmed 到 userData);macOS 靠 Dock、二次启动或 dsh://open 召回,Windows 靠托盘。
真正退出(⌘Q、应用菜单、Dock 菜单、托盘菜单、强制更新或欢迎窗引发的退出)走 quit-inspection。installDesktopQuitInspection 挂在 Host 的 effect 上,Host 停止后检查器直接拒绝;检查时问两个事实:
- 活动任务
hasDesktopActiveTasks(liveAgents, jobs):有 Agent 在running,或 inbox 的nextTurn/nextStep非空(排队的消息),或全局或任一 Agent 名下存在running/stopping的 job; - 已武装的定时提醒
scheduledTasks:对每个活 Agent 走一次workspace/session-activity瀑布,只要有一条kind === 'schedule'就为真——只算本运行加载过的会话,“reminders in sessions that were never loaded during this run cannot fire and are not counted”。
两个都没有,静默退出;否则弹原生询问框”Quit DeepSeek Harness?”,带三种本地化解释之一(任务会被打断/定时任务在应用关闭期间不会运行/两者)。询问框打开期间再点退出并入同一个框,不叠第二个;Host 没到 ready 或已失败不能跑任务,直接退出不询问;检查失败或 Host 错过两秒期限,按”有任务”处理。取消由关欢迎窗引发的退出会重新显示欢迎窗。
任务控制侧 update-tasks 提供 inspect/lock/unlock,逻辑在 installDesktopUpdateTaskControl:
inspect:只问hasDesktopActiveTasks;lock:置锁后,新到的 connection/request 直接 503,已接纳的请求记进pendingRequests排干(“Read requests are not tasks; admitted writes must finish before the final work check”),再用生成号(lockGeneration)防锁被并发替换;unlock:清锁、生成号 +1。
为什么更新要锁请求而不是直接杀任务?因为更新安装要在干净退出下接管:锁住入口,等已接纳的写请求落地,再检查有没有任务——有就请用户先停或等,没有就让安装器干净替换。这个”锁→排干→再查”的顺序,和退出检查的”先问再退”是同一套伦理:机器不替用户决定打断什么。
本节源码依据(5 处)
更新:新签名资源,一次干净的替换
更新走 electron-updater。用户发起的更新下载在窗口隐藏时完成,会推迟 Install and Restart 确认到窗口重新显示;强制更新流程保持任务栏和 Dock 注意。更新覆盖层(mandatory-update-frame)从创建到关闭会阻止 DevTools 和产品快捷键投递。安装前要等平台文档清理完成——“an update install waits for it before the installer takes over the exit”——因为退出检查、清理、安装是一条不丢任务的链。关闭视图会销毁文档、移除请求拦截器、调度同样的清理,未干净退出留下的认证在下一次文档加载前清掉;清理失败拒绝这次打开并挡住同账号,直到后续清理成功,其他账号继续开。
打包本身还有一道自检:desktop-bundle-imports 检查任何静态、动态或 require() 导入在打包应用里必须能解析——一个 rolldown 解析不了的导入会以外部说明符上船,启动时 ERR_MODULE_NOT_FOUND。签名扫描顺带拒绝畸形 MZ 文件和带 PE 头的假数据文件(上一节)。开发、仅准备、未签名构建不用硬件令牌,Windows 代码完整性策略可以挡掉它们——没有构建模式会关闭那道策略;DSH_DESKTOP_UNSIGNED=1 只允许 Windows 且只用于开发流水线(unsigned && resolvedPlatform !== 'win32' 直接报错)。
本节源码依据(3 处)
谁在窗口背后
把这一章和第 43 章、第 46 章、第 47 章连起来:dsh 的 Web 面是同一个运行时,桌面只是给这个 Web 面套了一个带签名、更新、托盘、原生对话框的 Electron 壳;Host 是同一个 profile runner,会话是同一个 append-only log,投影是同一套 mux 流。桌面独有的东西在壳这一层:签名资源、私有 IPC、退出检查、平台文档分区——而它的每一次”带一整套 dsh”,都从验证资源身份开始。
下一章看另一张面孔:第 49 章讲不从对话开始的会话——外部投递经认证变成普通根会话。