# dsh-notch-win 设计文档 | 项 | 值 | | :--- | :--- | | 目标产物 | Windows 端 DSH 灵动岛(插件 + 桌面 app) | | 包名 / 插件 id / settings namespace | `@dsh-external/dsh-notch-win` / `dsh-notch-win` / `dsh-notch-win` | | 状态 | 代码完成(单测 118/118 全绿);真机显示、穿透与跳转已验证 | | 平台 | Windows only(macOS 明确不做;Swift 端不纳入本仓库) | --- ## 1. 目标与非目标 ### 目标 1. 一个**浮在所有窗口之上**的灵动岛:折叠态一小条,悬停展开 HUD,DSH 最小化/失焦时仍然可见。 2. 显示**粗粒度**的 agent 过程:状态(思考中 / 调用工具 / 已完成)+ 耗时 + 轮次/步骤 + 工具计数 + 上下文占用 + 子代理数 + 当前待办。 3. **状态计算全部搬到宿主进程**(DSH node 侧),订阅 DSH 自己的事件流,不再读磁盘会话文件。 4. **设置页与原生 DSH 插件设置页观感一致**,落在「设置 → 插件」分区里作为一个原生标签页。 5. **点击跳转**:点岛上的会话 → DSH 切到该会话(`uiWorkspace.openSession`,见 §8.1)。 ### 非目标 - macOS / 物理刘海 / NSPanel / Swift 构建链。 - 自动下载安装启用 `exe`、校验和、TLS 代理 CA、安装链自检(**明确砍掉**,见 §3-D5)。 - 把 thinking 正文、工具参数全文搬进 IPC(隐私面 + 体积;中等粒度只要一行摘要)。 - 修改 DSH 源码(不做,见 §5-C1)。 - 新增 DSH session 事件类型(做不到,见 §5-C5)。 --- ## 2. 结论速览 ### 2.1 架构 ``` ┌──────────────────────────── DSH 进程 ────────────────────────────┐ │ │ agent/status │ 宿主半边 lib/index.js │ session/event│ ┌────────────────────────────────────────────┐ │ ───────────────┼──▶│ 状态机 state.js │ │ (DSH 事件流) │ │ · 订阅 agent/status + session/event │ │ │ │ · 采纳扫描 ctx.agents.list()/ctx.sessions │ │ │ │ · 被请求时才懒算(150ms 节流)→ 一段 JSON │ │ │ └───────────────┬────────────────────────────┘ │ │ │ │ │ ┌──────▼───────┐ ┌──────────────────────┐ │ │ │ server.js │ │ 渲染半边 lib/client.js│ │ │ │ node:http │◀──────▶│ · settings.plugins.tab│ │ │ │ 127.0.0.1 │ 同机 │ · 命令轮询→uiWorkspace │ │ │ │ :47411.. │ │ · config 读写 │ │ │ └──────┬───────┘ └──────────────────────┘ │ └───────────────────┼──────────────────────────────────────────────┘ │ HTTP(回环,X-Notch 头 + Host 栅栏) ┌───────────────▼────────────────┐ │ 岛 app DSHNotch.exe (Electron)│ │ · 拉 /v1/state,画岛 │ │ · POST /v1/jump │ │ · endpoint-.json 发现端口 │ └────────────────────────────────┘ ``` - **两处跨进程**:① DSH 宿主 ↔ DSH 渲染(插件两半,历史结构,只服务设置页 + 跳转执行);② DSH ↔ 岛 app(产品定义:必须独立顶层窗口)。 - **一条通道**:宿主半边起一个回环 HTTP,同时服务岛 app 和插件自己的渲染半边;状态只从 DSH 事件流来,不触碰会话文件。 - **一处不跨进程**:状态计算。数据在宿主内存里就有,不需要去磁盘上反推。 ### 2.2 不纳入本实现的部分 - Windows app 只保留窗口、托盘、穿透与视图层;状态采集、配置持久化、跳转执行回到宿主或 DSH 本身。 - 不包含会话文件解析(zstd / jsonl / projection cache)、目录扫描与 mtime 启发式。 - 不包含自动下载、校验和、安装与自更新链路。 - macOS / Swift 端不纳入本仓库。 ### 2.3 这样做的收益 | 目标 | 做法 | | :--- | :--- | | 状态实时且开销可预期 | 宿主内存状态机 + 一个 ≤4 KB 的 JSON 端点;空闲零定时器 | | 不依赖未文档化的文件布局 | 不直接读取会话文件或投影缓存文件;投影值只经 DSH 服务做富化 | | 配置只有一个权威 | DSH profile 配置;UI 只发一个 patch | | 设置可持久化 | DSH 0.2.x 的 `Config` 导出与自动表单(历史版本差异见 §5 注) | | 端点永不复用死实例 | 每实例一个 `endpoint-.json`,连接前校验 pid 活性 | | 写通道有最低限度的防护 | 只绑回环 + Host 栅栏 + `X-Notch` 头强制预检 + 回显 Origin | --- ## 3. 决策记录(已与你确认) | # | 决策 | 取舍 | | :--- | :--- | :--- | | D1 | 目录 `p4\dsh-notch-win`,id/包名 `dsh-notch-win` | 明确 Windows 专属,为将来 `dsh-notch-mac` 留位 | | D2 | 设置页注册到 `settings.plugins.tab` | 与原生「插件配置」并排,外壳由 DSH 渲染 | | D3 | 配置权威源 = DSH 原生 `~/.dsh/settings.yaml` | schema 校验 + 热重载 + 单一权威 | | D4 | 岛信息粒度 = 中等 | 计算在宿主侧,代价低;不含 thinking 正文 | | D5 | 砍掉自动安装,只启动已存在的 exe | 删掉下载器/校验和/TLS/EPERM 全部失败面 | | D6 | 设置项:`enabled` `hideWhenIdle` `jumpEnabled` `expandOnHover` `glowEffect` `scale` `autoLaunchApp` | 另加一个必要的 `appPath`(见 §7.9,因为 D5 取消了自动安装) | --- ## 4. 设计依据与验证 > 本节记录设计时依赖的 DSH 侧事实与保留的交互原则。开工时可跳过,评审时不要跳过。 ### 4.1 数据从哪来 | 数据 | 来源 | 是否需要跨进程 | | :--- | :--- | :--- | | 状态 | DSH 宿主内存(`agent/status` + `session/event`) | 不需要,数据就在宿主里 | | 跳转 | 渲染进程的 `uiWorkspace.openSession(id)` | 需要(导航能力在渲染进程) | | 配置 | DSH profile 配置 / `settings.yaml` | 需要(写入口在宿主) | ### 4.2 实测证据(本机) - 会话日志是多帧 zstd:一个 2,597,629 字节的日志里有 **1372 帧**;`zlib.zstdDecompressSync(整个文件)` 只还原帧 #1 → **1 行**,必须自己逐帧解。这也是本设计不把会话文件当实时源的原因之一。 - `dsh-session-projection-cache` 的写策略:`turn/end` 必写 + 会话释放必写 + 每 200 事件/5000 ms 节流。它是**轮次边界的检查点,不是实时源** → `sessionStats.openStep` / `pendingCalls` 几乎恒定为空。 - 因此状态只订阅 DSH 事件流;投影缓存仅用于富化(`contextPressure` / `tokenUsage` / `sessionStats`),且只在需要时读。 ### 4.3 保留的交互设计 - 岛的视觉语言、DOM 骨架、穿透三防线、`rev` 去重、自适应轮询时延。 - 渲染半边用 `window.__ModuleLoader__.load({id, factory})` 手写 bundle —— 这是第三方插件携带渲染半边的可行形态(无构建步骤,依据见附录 A 的 `dsh-client-modules`)。 --- ## 5. DSH 侧硬约束(不可协商,均有源码出处) | # | 约束 | 出处 | 后果 | | :--- | :--- | :--- | :--- | | **C1** | 原生「插件配置」卡片需要 namespace 出现在 `dsh-host-apiproxy` 的**硬编码白名单**里(`WEB_SETTINGS_NAMESPACES` / `PRODUCT_SETTINGS_NAMESPACES`),且源码注释写明"让插件自己 `settings.register()` 就能暴露配置"是 **deferred work** | `@deepseek-ai/dsh-host-apiproxy/lib/index.js:884-896, 1001, 2410-2415` | 第三方插件**拿不到**原生卡片。要么改 DSH,要么自己画设置页 → 我们用 `settings.plugins.tab` | | **C2** | 注册 settings namespace 的正确写法是 `ctx.inject(["settings"], (settingsCtx) => settingsCtx.settings.register(settingsNamespace(ns), Schema))` | `@deepseek-ai/dsh-client-ui-theme/lib/index.js:71-74` | **不能**直接写 `ctx.settings.register(...)`:apply 时刻该服务可能尚未就绪 → `undefined` | | **C3** | 插件没有轻量自有 RPC:Typert 需要 TypeScript 代码生成产物;`ctx.remote.$on` 的键面由**宿主组合**的 `TypertRemoteEventSelection` 收窄 | `@deepseek-ai/dsh-typert-protocol/README.zh.md`、`dsh-client-connection/README.zh.md` | 宿主 → 渲染进程的自定义消息**只能**自己开通道;回环 HTTP 是可行解 | | **C4** | `session/event` 是 `@mode emit`、不 await,listener 抛错只被 `logger.warn` 吞掉 | `@deepseek-ai/dsh-session/lib/types/*.d.ts`(`'session/event'(session, event)`) | handler 必须**同步、快、绝不抛**;任何 I/O 都在独立定时器里做 | | **C5** | 持久化读路径拒绝未知事件类型(除非带 `ignorable`);"给下游插件事件留注册面"是 deferred | `@deepseek-ai/dsh-session/known-event-types.d.ts:7-18` | **不要**想着"往会话日志里写个自定义事件让客户端看到" | | **C6** | `agent/status` 是 Scope 过滤派发(`Scoped`) | `@deepseek-ai/dsh-agent/lib/types/runtime-types.d.ts:160-172`;`dsh-scope` 允许未打标签的 listener;harness 自用在 `dsh-agent/src/invariant.ts` 用 `{ global: true }` | 订阅必须**全局**,否则收不到别的 agent 的转换 | | **C7** | 热重载**不会**重放 `agent/created` | `dsh-session-telemetry/README`(adoption sweep 注意事项) | 加载时必须做一次采纳扫描(§7.5) | | **C8** | `dsh-host-webserver` 的 route 注册存在,但"Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch",插件 route 是否在 Electron 渲染进程可达**未经验证** | `dsh-host-webserver/README.zh.md:7` | 用自建回环 HTTP,不用 `webServer.register`;可达性见 R1 / R1b,需真机验证 | > **⚠ 2026-10 现状:本节 C2 与附录 A 的 settings 结论只对 DSH 0.1.x 成立。** > 安装的桌面版 DSH `0.2.1-rc.2`(本机安装目录,构建 commit `332895e0`)里, > `@deepseek-ai/dsh-settings` 只导出 `SettingsConflictError` / `SettingsForms` / > `redactSecrets`:**没有 `settingsNamespace`,也没有 `register` / `SettingsScope`**。 > 设置页表单改为 DSH 读取插件导出的 `Config` 自动生成;配置由 profile 条目持久化, > cordis 在 `apply(ctx, config)` 的第二参数把已校验的值交给插件。 > > 早期版本在 `lib/config.js` 顶部静态导入 `settingsNamespace`, > ESM 链接阶段直接抛 `does not provide an export named 'settingsNamespace'`, > **整个宿主半边不会加载** → 没有 `host.log`、没有回环端口、`syncApp()` 不跑 → > 岛 app 不会被拉起。修复:命名空间退化为普通字符串、配置从 `apply` 第二参数 > 播种(`createConfigStore().seed()`)、`ctx.inject(["settings"])` 里按 > `typeof settings.register === "function"` 分支保留 0.1.x 路径。详见 > `lib/config.js` 文件头与 `README.md` 的「DSH 版本兼容」。 --- ## 6. 总体架构 ### 6.1 组件 | 组件 | 位置 | 语言/形态 | 职责 | | :--- | :--- | :--- | :--- | | 宿主半边 | `lib/index.js` (+ `state.js` `server.js` `config.js` `paths.js` `log.js` `protocol.js`) | ESM,node | 订阅事件、算状态、跑 HTTP、持有配置、拉起 app | | 渲染半边 | `lib/client.js` | 手写浏览器 bundle,`__ModuleLoader__.load` | 注册设置页;轮询命令 → `uiWorkspace.openSession`;上报当前会话 | | 岛 app | `app/` | Electron(打包后无运行时 npm 依赖) | 拉状态、画岛、发跳转、托盘、穿透 | ### 6.2 通道 只有一条:**宿主半边自建的回环 HTTP**(`node:http`,绑 `127.0.0.1`)。 - 岛 app:`GET /v1/state`(自适应 250 / 1000 / 3000 ms)、`POST /v1/jump`、`POST /v1/ack`。 - 渲染半边:`GET /v1/state`(设置页的只读诊断,1000 ms)、`GET /v1/commands`(500 ms 轮询跳转)、`POST /v1/config`、`POST /v1/focus`。 - 双方都用 `GET /v1/health` 做**端口探测**(只认 `protocol` 对得上的那一个)。 - 端点发现:宿主启动时绑定端口(从 47411 起试 12 个),把 `endpoint-.json` 原子写到 `%LOCALAPPDATA%\dsh-notch-win\`,内容 `{protocol, port, pid, startedAt, version}`。**永不复用别人的端口**,死进程留下的端点会被识别并清理。 - 岛 app 在每次需要时**重读**该目录(不监听、不用 `fs.watch`),逐个校验"pid 活着 + health 回的 pid 与文件名一致 + 协议版本一致",并顺手删掉死掉的端点文件;多个候选取 `startedAt` 最新者。 - 渲染半边没有端点文件可读,所以它按 47411..47422 **顺序探测**(见 R7 的限制)。 ### 6.3 生命周期时序 ``` DSH 启动 └─ 宿主 apply() ├─ ctx.inject(["settings"]) → register(dsh-notch-win, Schema) → SettingsScope ├─ 采纳扫描:ctx.agents.list() 里 running 的 → 建条目(补上 running 起始时间) ├─ ctx.on("agent/status", h, { global: true }) ├─ ctx.on("session/event", h) ├─ 起 HTTP server → 写 endpoint-.json └─ autoLaunchApp && appPath 存在 → spawn(DSHNotch.exe)(app 自持单实例锁,重复 spawn 无害) 用户点岛上某会话 └─ app POST /v1/jump {sessionId} └─ 宿主入 ring(cap 32 / TTL 45 s),seq++ └─ 渲染半边 GET /v1/commands?since=seq → 取到 → uiWorkspace.openSession(id) 用户在 DSH 里切会话 └─ 渲染半边订阅 ctx.sessions.list → POST /v1/focus {sessionId} └─ 宿主 primaryId = id → 排序把主会话顶到第一条 → 岛高亮它 用户在设置页改开关 └─ 渲染半边 POST /v1/config {patch} └─ 宿主 SettingsScope.update(patch) → settings.yaml └─ scope.watch → 内存 config 更新 └─ 岛下一次轮询(≤1 s)从快照里拿到新 config → 应用(缩放/发光/隐藏) DSH 退出 └─ dispose:关 server、删 endpoint-.json └─ 岛发现所有端点都不可用 → 显示「未连接宿主」,托盘保留 ``` --- ## 7. 宿主半边 `lib/` ### 7.1 文件划分 | 文件 | 职责 | 备注 | | :--- | :--- | :--- | | `lib/index.js` | 插件入口:`apply(ctx, config)`、`inject`、生命周期、把下面几件接起来 | 必须**保持小**,逻辑都在别的文件里 | | `lib/protocol.js` | 协议常量:路径、状态枚举、字段名、`PROTOCOL_VERSION` | 纯数据,宿主与 app 共用 | | `lib/state.js` | 纯函数状态机:`reduce(state, eventLike)` / `snapshot(state)` / `revOf(snapshot)` | **零 I/O,可单测** | | `lib/server.js` | `node:http` 服务、路由、Host/Origin 栅栏、`X-Notch` 检查、JSON 编解码 | 只依赖 `node:http`/`node:crypto` | | `lib/config.js` | schema 定义 + `SettingsScope` 包装(`get/watch/update` + 内存镜像) | 唯一的配置权威 | | `lib/log.js` | 诊断日志(文件 + console) | 见 §7.11 | ### 7.2 订阅规格 ```js // 全局:agent/status 走 Scope 过滤派发,必须显式全局(C6) ctx.on("agent/status", (payload) => { // payload: { agent: Agent, status: "idle" | "running" };agent.id 即 SessionId safely(() => ingest(state, { kind: "status", agentId: payload.agent.id, status: payload.status })); }, { global: true }); // 全局:会话事件(C4 → 只做内存写入,绝不 I/O、绝不抛) ctx.on("session/event", (session, event) => { // event: { type, seq, time, data } // ⚠️ 负载在 event.data 里,**不是**平铺在 event 上。这一条曾经搞错过, // 后果是所有 payload 字段都读到 undefined,状态永远是"思考中"。 safely(() => ingest(state, { kind: "event", sessionId: session.id, event })); }); ``` `safely(fn)` = `try { fn() } catch (e) { log.warn(...) }`,包住**每一个** handler 体,绝不让异常冒到 DSH(emit 模式下会被 `logger.warn` 吞,但我们要保证状态机不被污染)。 `SessionEvent` 的权威定义在 `@deepseek-ai/dsh-session` 的 `lib/types/types.d.ts`(`SessionEventMap` 与 `SessionEvent`)。下表"负载字段"一列指的都是 `event.data` 内的字段。 **事件 → 状态变更表** | 事件(`event.type`) | `event.data` 字段 | 作用 | | :--- | :--- | :--- | | `agent/status`(不是 session 事件) | `{agent, status}` | `running`:建/取条目,`elapsedFrom = now`,`status = "thinking"`,清 `stopped`;`idle`:`status = "done"`,**保持到用户 ack 或下一轮开始**,不再自动回 `idle` | | `agent/disposed` | `{agent}` | 移除条目 | | `agent/created` | `{agent}` | 建条目(热重载不重放 → 靠 §7.5 扫描兜底) | | `turn/start` | `{turn}` | `entry.turn = turn`;`entry.step = null`;`entry.toolCount = 0`;清 `inFlight`;清 `stopped` | | `step/start` | `{turn, step}` | `entry.turn/step`;若 `inFlight` 空 → `status = "thinking"` | | `tool/call` | `{turn, step, callId, name, arguments}` | `inFlight.set(callId, name)`;`status = "tool"`;`toolName = name`;`toolCount++`;`detailText = summarize(name, arguments)` | | `tool/result` | `{turn, step, message, error?}` | 负载里**没有** `callId` —— 配对信息在 `data.message.content[i].toolCallId`(`ToolResultBlock`)。删掉对应 `inFlight`;空则 `status = "thinking"` | | `todo/write` | `{todos}` | `todoTotal/todoDone/todoText`(整体快照,最新胜) | | `assistant/message` | `{turn, step, message, usage?}` | `usage.inputTokens + cacheReadTokens + cacheWriteTokens` → `contextTokens`(分子;分母见 `request/context`) | | `request/context` | `{contextWindow}` | `contextLimit = contextWindow`(token-meter `contextPressure` 投影的分母) | | `turn/end` | `{turn, reason}` | 清 `toolName`/`inFlight`;`reason.kind === "aborted"` → `entry.stopped = true`(用户主动停止),其余 false;**不**改 `status`(状态只由 `agent/status` 驱动) | 每个会话另有 `lastSeq` 重放保护:`event.seq` 不增就直接丢弃。会话恢复会把历史事件按序重放,而我们订阅的时机可能晚于事件本身,两种情况都不允许状态倒退。 > **关键设计点**:`status` 的"执行中/执行完成"只由 `agent/status` 驱动。`turn/end` 不等于 agent 结束(agent 可能继续下一轮),但它的 `reason.kind` 用来区分"已完成"与"已停止"。`done` 不再定时回 `idle`,保持到用户点击表头确认(ack)或下一轮开始。 ### 7.3 状态模型 ```js // 内存态(宿主私有) Map, // 未配对的 tool/call toolCount, contextFraction, // 0..1 | null todoTotal, todoDone, todoText, detailText, lastSeq, // 去重用:忽略 seq 回退(重放/恢复) updatedAt, }> primaryId // 渲染半边上报的"当前会话" ``` ### 7.4 状态机 ``` agent/status=running │ ▼ ┌─────────► thinking ◄────────────┐ │ │ │ │ │ step/start │ │ tool/call │ tool/result 且 inFlight 空 │ │ ▼ │ │ │ tool ────────────┘ │ │ │ │ agent/status=idle │ ▼ │ done ──(用户 ack / 下一轮)──► idle │ │ │ └─ stopped=true 时显示“已停止”(turn/end reason=aborted) └─────────────────────────────────────────── agent/status=running(再次) ``` - `idle` 的条目**不参与** HUD 列表(除非 `hideWhenIdle=false` 且用户想看历史 → 由 app 侧决定,宿主照样把 `idle` 条目发出去但排在后面)。 - 条目上限 `MAX_ENTRIES = 8`,排序:`running` 优先 → `primaryId` 优先 → `updatedAt` 倒序。 ### 7.5 采纳扫描(adoption sweep) `apply()` 里做一次,**在**挂 listener **之后**(避免窗口期丢事件): ```js for (const agent of ctx.agents.list()) { // Agent[],见 §15 seed({ id: agent.id, status: agent.status }); // "running" | "idle" } ``` 这一步**必须**在挂完 listener 之后:热重载时 `agent/created` 不会重放,不扫的话"已经在跑的会话"要等到它下一次状态变化才出现。 每次 `enrich(now)`(也在懒计算路径上)顺带补两件事: - **子代理计数**:一遍扫完 `ctx.sessions.list()`,`header.origin === 'subagent'` 且 `header.parentSession` 指向父会话、且 `ctx.agents.get(child).status === 'running'` 的,给父会话累加 `subagentRunning`。 - **标题**(优先级从高到低,前一级拿不到才往下走):① DSH 会话标题(`session/title` 事件,或富化时读 `sessionTitle.get(session).title`,`labelSource:"title"`)→ ② 首条 `user/message` 的文本(截 40 字,`labelSource:"message"`)→ ③ `header.cwd` 的最后一段路径名(`C:\work\acme-api` → `acme-api`,`labelSource:"cwd"`)→ ④ `shortId`(前 8 位)。带 `labelSource` 是为了"只许升级不许降级":DSH 标题可以盖掉消息/目录名,后来的 `user/message` 可以盖掉目录名兜底,反过来都不行。**标题缺失不影响功能** —— 但这是整条链里最容易"什么都对就是名字不对"的一环,所以设置页把 `label` 直接显示出来便于核对。 - 已在跑的会话补 `elapsedFrom = now`(精确起点不可得,标注为近似)。 - 内存不无界增长:`EVICT_IDLE_MS = 600000`(10 分钟没动过的 **idle** 条目回收;`live` 条目**永不**回收)、`SESSION_MAP_MAX = 64`(兜底上限,超了先淘汰最旧的 idle)。 ### 7.6 计算与发布(**没有定时器**) - **完全没有发布定时器**:`state(sinceRev)` 被请求时才重算,且距上次计算 < `PUBLISH_THROTTLE_MS` 时直接复用上一次的结果。 - 所以 **idle 时 CPU 为零**:没有 `setInterval`、没有排队、没有"每 250 ms 醒来看看有没有变";渲染半边也不留自己的定时轮询节奏。 - `rev = fnv1a(stableStringify(snapshot))`,`stableStringify` 是键名排序的 JSON —— 保证同一份状态永远得到同一个 `rev`。 - `state(sinceRev)`:`sinceRev === rev` 时只回 `{unchanged:true, rev}`,不做序列化。 - 宿主侧**没有任何"推送"**:岛与设置页各自轮询。这样宿主的开销与"谁在看"完全无关。 - **没有 `done → idle` 的自动定时器**:`done`(以及 `stopped=true` 的“已停止”)保持到用户点击表头确认(ack)或下一轮开始。`DONE_HOLD_MS` 仅保留在协议常量里(兼容旧客户端),宿主不再使用。 ### 7.7 HTTP 端点规格 基址 `http://127.0.0.1:`,全部 `Content-Type: application/json; charset=utf-8`。 | 方法 | 路径 | 消费方 | 请求 | 响应 | | :--- | :--- | :--- | :--- | :--- | | `GET` | `/v1/health` | app / 渲染半边(探测) | — | `{ok:true, pid, protocol, version, startedAt, entries, live}` | | `GET` | `/v1/state` | app / 渲染半边 | `?rev=` 可选 | 见 §10;`rev` 相同时 `{unchanged:true, rev}` | | `POST` | `/v1/jump` | app | `{sessionId}` | `{ok:true, seq}`;`jumpEnabled=false` → 409 `feature-disabled`;同时 ack 该会话的 done/stopped | | `POST` | `/v1/ack` | app | `{sessionId}` | `{ok:true}`;把 done/stopped 刷回 idle | | `GET` | `/v1/commands` | 渲染半边 | `?since=` | `{seq, jumps:[{seq, sessionId}]}` | | `POST` | `/v1/config` | 渲染半边 | `{patch}` | `{ok:true, config, configSource}` | | `POST` | `/v1/focus` | 渲染半边 / app | `{sessionId\|null}` | `{ok:true}` | 错误统一:`{ok:false, code, message}`,HTTP 400/403/404/409。`code` ∈ `bad-json` `bad-request` `feature-disabled`(409)`forbidden-host` `forbidden-origin` `unknown-route` `not-found` `config-rejected`。 `jump` ring:`cap 32`、`TTL 45 s`,重放去重(同 `sessionId` 在 500 ms 内只保留一条)。用 ring 而不是"推送",是因为第三方插件没有宿主↔渲染的轻量 RPC(C3)。 ### 7.8 安全模型 | 措施 | 作用 | 实现 | | :--- | :--- | :--- | | 只绑 `127.0.0.1` | 不对外暴露 | `server.listen(port, "127.0.0.1")` | | **Host 栅栏** | 挡 DNS rebinding(Host 是重绑定唯一伪造不了的请求头) | `Host` 去端口后必须 ∈ `{127.0.0.1, localhost, [::1]}`,否则 403 | | **Origin 白名单 + 回显** | 挡跨站读 | `Origin` 为 `null`(Electron `file://`)、`dsh-app://app`(DSH 桌面渲染进程的自定义 scheme)或回环权威时才回显 `Access-Control-Allow-Origin: <原值>` + `Vary: Origin`;**永不用 `*`** | | **`X-Notch: 1` 自定义头** | 挡跨站写(强制预检,预检失败就发不出 POST) | 所有 `POST` 必须带该头,否则 403 | | **`Access-Control-Allow-Private-Network: true`** | Chromium 的 Private Network Access:`file://`(不透明来源)向回环发**带自定义头**的请求时,预检必须显式放行这一跳,否则 POST 会被浏览器直接拦掉 | 仅当请求带 `access-control-request-private-network: true` 时才回这个头 | | `sec-fetch-site: cross-site` 一律拒 | 双保险 | 直接 403 | | 不回传任何会话正文 | 减小泄漏面 | `detailText` 上限 120 字符、单行、去控制字符 | > `Access-Control-Allow-Private-Network` **不是可选的加固**:DSH 桌面版正是用 `file://` 加载 dist,少了它表现为"`GET` 能通、保存配置没反应"——最难查的那种半死状态。 > > 不引入 per-boot token:它需要把密钥分发给渲染半边,而第三方插件没有可靠的办法把密钥注入 Electron 渲染进程(C8 说明那层是 IPC 桥接的,不可控),于是"分发机制本身"会成为新的失败面。上面五条**无需密钥**且足够挡住浏览器发起的跨站请求;同机同用户的恶意进程不在威胁模型内。 ### 7.9 配置(settings namespace) ```js import { settingsNamespace } from "@deepseek-ai/dsh-settings"; import z from "@deepseek-ai/schemastery"; export const NS = settingsNamespace("dsh-notch-win"); export const ConfigSchema = z.object({ enabled: z.boolean().default(true), // 岛总开关(关=不显示且不拉起 app) hideWhenIdle: z.boolean().default(false), // 全部 idle 时岛消失 jumpEnabled: z.boolean().default(true), // 点击跳转 expandOnHover: z.boolean().default(true), // 悬停展开 glowEffect: z.boolean().default(true), // 状态发光/呼吸 scale: z.number().min(0.8).max(1.4).default(1), // 缩放 autoLaunchApp: z.boolean().default(true), // DSH 启动时拉起岛 app appPath: z.string().default(""), // exe 路径覆盖(空=按 §7.10 顺序探测) }); ``` 注册(**必须**是 C2 的写法): ```js // 不用 `export const inject = ["settings"]`:settings 永久缺席时整个插件会卡在 // pending(岛就不工作了)。局部注入让 apply 立刻跑,settings 迟到或不到都能降级。 ctx.inject(["settings"], (settingsCtx) => { scope = settingsCtx.settings.register(NS, ConfigSchema, { base: config || {} }); mirror = scope.get(); // 内存镜像 scope.watch((next) => { mirror = next; }); // 没有"变脏"要标:状态是懒算的(§7.6) source = "settings"; }); ``` - `appPath` 是**新增**的必要字段(D5 取消了自动安装,必须有人告诉插件 exe 在哪)。 - `settings` 服务不可用时(理论上不会,§16 已确认 `dsh-base` 挂了 `dsh-settings-file`):`mirror` 退化为 schema 默认值,`source` 记作 `"defaults"` 或 `"memory"`(用户改过就是后者),日志记一条 warn,`runtime.settingsError` 带出去给设置页显示。**不再有第二份配置文件兜底**。 ### 7.10 岛 app 的启动与发现 `appPath` 解析顺序(`lib/paths.js` 的 `appExeCandidates`): 1. `config.appPath`(非空) 2. `%LOCALAPPDATA%\dsh-notch-win\DSHNotch.exe` 3. `<插件包>/app/dist/win-unpacked/DSHNotch.exe`(electron-builder 的默认产出) 4. `<插件包>/app/dist/DSHNotch.exe`(自定义打包产出) 取第一个**真实存在**的(`resolveAppExe`,逐个 `existsSync`,读权限出错就当作不存在继续往下找)。 - `autoLaunchApp && enabled`:`spawn(exe, [], { detached: true, stdio: "ignore", env: islandAppEnv(process.env, dataDir()) }).unref()`。**无条件 spawn 是安全的** —— app 自己持有 Electron 单实例锁,第二次启动会立刻退出。 - **子进程环境必须先清洗**(`lib/launch.js`):DSH 桌面版用 `ELECTRON_RUN_AS_NODE=1` 跑自己的宿主进程;如果原样 `{ ...process.env }` 传给岛 app,Electron 不再启动 Chromium,而是把 `src/main.js` 当普通 Node 脚本执行,`require('electron')` 拿不到 `app/BrowserWindow`,进程 200ms 内退出。此时 `host.log` 仍会写「已拉起岛 app」(spawn 成功),但任务管理器没有进程、`%APPDATA%\dsh-notch-win` 也不更新 —— 必须删掉 `ELECTRON_RUN_AS_NODE`,并监听子进程 `exit` 把「启动后 Xms 就退出」写进日志。 - `DSH_NOTCH_NO_LAUNCH=1` 环境变量可跳过拉起(无头调试/测试用)。 - 找不到 exe:记一条 warn,**不重试**(不引入退避定时器);设置页的"运行状态"卡里显示探测结果(`appExe` / `appExists`)。 - 不在插件里做"下载 / 校验 / 安装 / 更新"(D7 已砍)。 ### 7.11 日志与诊断 - 位置:`%LOCALAPPDATA%\dsh-notch-win\host.log`(**不写进** app 的目录,两者不混;app 的端点文件也在这个目录里,但文件名区分得很清楚)。 - 每次 DSH 启动把上一份**轮转**成 `host.prev.log`(`renameSync`),而不是原地截断 —— 上一次崩溃前的日志是排查时的唯一线索,截断掉就永远看不到了。 - 只记:平台/node 版本、settings 注册结果、端口绑定结果、端点文件路径、app 启动结果、跳转入队(**只记 sessionId 前 8 位**)。 - **绝不记**配置值以外的内容、绝不记会话正文/工具参数。 - `DSH_NOTCH_DATA_DIR` 可以整体覆盖数据目录(日志与端点文件都跟着走)。**端到端测试必须用它**:`test/host.test.mjs` 跑的是真实的 `apply()`,不隔离的话测试诊断日志会混进用户真实的 `host.log`,而里面恰好有 `DSH_NOTCH_NO_LAUNCH=1,跳过拉起岛 app` 这种一看就像真实行为的行 —— 这件事真的发生过,并被读成"插件默认不拉起岛"。宿主拉起岛 app 时会把该变量显式传给子进程,所以调试时两侧看的是同一个目录。 ### 7.12 失败降级矩阵 | 失败 | 行为 | | :--- | :--- | | `settings` 服务缺席 | 配置落内存镜像(`configSource: "memory"`);设置页**照常可改**(改这一份内存值),并在卡片描述里明确写"重启即丢失";日志 warn | | 端口 12 个都占 | 不写 endpoint 文件,日志 warn;岛与设置页显示"未连接(重试中)";**不影响 DSH 本体** | | app exe 不存在 | 日志 warn + 设置页提示路径;岛不显示(其它功能全部照常) | | 某个 `session/event` 负载意外 | `safely` 吞掉;不影响其他条目 | | 渲染半边没有 `slots` 服务 | 不注册设置页,其余功能(跳转轮询、焦点上报)**照常**(`apply` 里显式判断,不靠 `inject` 卡住整个插件) | | `uiWorkspace.openSession` 抛错(会话已消失) | 跳转轮询里记一次 `lastError` 并继续;岛页脚回显"无法切换到会话 …" | | 状态机异常 | 单条目标记,整机不停 | --- ## 8. 渲染半边 `lib/client.js` 形态:手写浏览器 bundle,`window.__ModuleLoader__.load({ id: "@dsh-external/dsh-notch-win", factory })`(形态依据见 §4.3 与附录 A 的 `dsh-client-modules`)。 ```js // slots / sessions / uiWorkspace 都是渲染进程的根服务: // - slots 由槽位系统提供; // - sessions 提供列表/对象层(ClientSessions); // - uiWorkspace 提供导航(dsh-client-ui-workspace 的 UiWorkspaceService)。 // 声明注入会在服务就绪后才 apply;旧版 DSH 缺少 uiWorkspace 时等待而不是报错。 exports.inject = ["slots", "sessions", "uiWorkspace"]; ``` ### 8.1 跳转为什么是 `uiWorkspace.openSession(id)` 当前桌面版(DSH 0.2.1-rc.2 / 桌面 2.0.x)的导航 API 在 `dsh-client-ui-workspace` 的 `UiWorkspaceService` 上: ``` dsh-client-ui-workspace L1380: super(ctx, "uiWorkspace") dsh-client-ui-workspace L1470: openSession(target) { this.replaceMain(target, ...) } ``` `openSession` 会替换主区域并选中会话;侧栏点击、fork 跳转、toast 里的 "打开 fork 源"都走它。`sessions` 服务(`ClientSessions`)只负责列表/对象层 (`list`、`manager`、`scope`…),**没有 `open`**;导航统一走 `uiWorkspace.openSession`。 ```js if (ctx.uiWorkspace?.openSession) { ctx.uiWorkspace.openSession(jump.sessionId); } else if (ctx.sessions?.open) { ctx.sessions.open(jump.sessionId); // 旧版 DSH / 代理差异兜底 } ``` ### 8.2 上报"当前在看哪个会话" `dsh-client-runtime` 的 `sessions.list` 是一个 snapshot store(`getSnapshot()` / `subscribe(fn)`),`getSnapshot().current` 就是当前选中的会话 id(`createSnapshotStore` 定义于 `dsh-client-runtime` L8913)。 ```js const store = ctx.sessions.list; const off = store.subscribe(() => { const current = store.getSnapshot().current; // 变了才报,300ms 去抖 void post("/v1/focus", { sessionId: current }); }); ``` 这样岛能高亮"你正在看的那个会话",而不是只有跳转过的那个。上报是**尽力而为**:失败不重试、不影响任何可见功能。拿不到 store 就安静降级(`supported: false`)。 ### 8.3 设置页(`settings.plugins.tab`) 槽位契约(`dsh-cordis-client-runner` 的槽位清单 + `dsh-client-ui-settings-plugin-inventory` 的真实调用)要求: | 选项 | 必要性 | 说明 | | :--- | :--- | :--- | | `name` | 必填 | `"settings.plugins.tab"` | | `id` | **必填** | 自己的 cell key。**复用 DSH 自带 tab 的 id(`all` / `configurable`)会替换掉它,而不是并列一个** | | `order` | 可选 | 升序排列 | | `label` | 可选但实际必需 | tab 上显示的文字。不给就是一个无名标签 | ```js ctx.slots.inject("settings.plugins.tab", () => ctx.slots.register( { name: "settings.plugins.tab", id: PLUGIN_ID, order: 50, label: "灵动岛" }, () => h(SettingsTab, { channel }), )); ``` 为什么要 `label` 用字符串而不是 `() => "灵动岛"`:契约允许 `string | (() => string)`,thunk 的用途是随 locale 变化重读;本插件只有一个中文界面、不注册 locale 字典,给字符串更诚实。 - 外壳(分区标题、标签栏、滚动)由 DSH 渲染 → **观感天然一致**(D2)。 - 内容**不引入 `dsh-client-ui-primitives` 的组件**,而是自建但**完全复用 DSH 的主题令牌**(`--dsw-alias-border-l2` / `--dsw-alias-label-primary|secondary|tertiary|dimmed|error` / `--dsw-alias-bg-layer-2|3` / `--dsw-alias-bg-module-platform` / `--dsw-alias-brand-primary`),并以 `