# 架构说明 ## 组成 `dsh-tauri-launcher` 是一个**双面 Web 插件**(组合包),加一个**Tauri 桌面应用工程**: ``` ┌──────────────────────────── DSH Web 进程(Node) ────────────────────────────┐ │ 宿主半 lib/index.js(cordis 插件,inject: webServer) │ │ · /api/dsh-tauri-launcher/state GET 状态+诊断 │ │ · /api/dsh-tauri-launcher/set-desktop POST 启动/退出桌面应用 │ │ · /api/dsh-tauri-launcher/set-shortcut POST 创建桌面快捷方式 │ │ (所有路由仅接受回环请求) │ └──────────────────────────────────────────────────────────────────────────────┘ ▲ 同源 fetch(127.0.0.1:3080) ▲ subprocess 服务(无沙箱) │ │ spawn / PowerShell ┌───────┴─────────────────────┐ ┌────────────┴──────────────┐ │ 浏览器半 lib/client.js │ │ 桌面应用(Tauri) │ │ · settings.section 分区 │ │ · 1 秒轮询:写心跳+查退出 │ │ 「桌面启动」 │ │ · 托盘/设置:自启、快捷键 │ │ · 开关/按钮/弹窗/诊断 │ │ · 退出标记消费即退出 │ └─────────────────────────────┘ └───────────────────────────┘ ``` ## 标记文件协议(桌面应用 exe 同目录) | 文件 | 写入方 | 语义 | | --- | --- | --- | | `.dsh-heartbeat` | 桌面应用(每秒) | Unix 时间戳;插件在 `freshSecs` 秒内读到新鲜值即判定运行中;文件残留不删,靠时间戳判活 | | `.dsh-quit` | 插件 | 内容 `1` 且 60 秒内新鲜 → 桌面应用**仅退出自身**(Harness 进程保留),消费时自行删除;插件在确认退出后写回 `0` 取消残留标记 | 设计要点: - 插件跑在沙箱内,`tasklist` 等进程探测不可用 → 用文件时间戳判活; - 心跳新鲜窗口必须**大于写入周期**(默认 4 秒 vs 1 秒写入),留出读取抖动余量; - 退出确认双信号:退出标记被自删(快速确认,≤3.5 秒)或心跳过期(兜底,~4-6 秒);标记未消费且心跳过期则 `Stop-Process` 强杀兜底。 ### 候选目录探测(协议落在哪一份副本上) 标记文件与桌面应用配置都在 exe 同目录,所以「插件认哪一份副本」直接决定状态能否 同步:桌面应用可能有多个副本(npm 包内 `launcher/bin`、本地 `build.ps1` 产物), 而 DSH 是被哪一份拉起的并不确定。插件按优先级探测候选目录: 1. 行配置 `launcherExe` 所在目录; 2. 运行中的 `dsh-launcher` 进程目录(`Get-Process -Name dsh-launcher` 取 `Path`); 3. 桌面快捷方式 `DeepSeek Harness.lnk` 的目标目录(WScript.Shell 读 `TargetPath`); 4. 行配置 `launcherDirs`; 5. 本次插件运行中发现过的目录;6. 包内 `launcher/bin`。 - `exeDirs()` 只保留**确实含 `dsh-launcher.exe`** 的目录,失效的快捷方式目标自动跳过; `pickExe()` 取列表首项,因此“启动哪一份”也遵循同一优先级(快捷方式目标优先于包内副本); - 运行中实例与快捷方式目标用 subprocess 探测(**采集模式**:`stdio.stdout = { maxBytes }` + `handle.collected.stdout.readFrom(0)`),脚本前置 `[Console]::OutputEncoding = UTF8`——PowerShell 5.1 默认按控制台代码页写重定向输出, 非 ASCII 路径会乱码; - 探测结果缓存 5 秒(状态轮询与启停等待循环都会反复取候选目录);探测失败时 静默回退到第 4/6 项,不改变旧版行为; - 发现过的目录会话内**记住**(最近使用在前,上限 8):实例退出后其心跳文件变成 「过期」而非「不存在」,状态因此稳定落在「已停止」,退出流程也不会中途丢目标。 ## 启动/退出时序 **启动**:残留退出标记为 `1` 时先取消 → `subprocess.spawn` 拉起 exe(stdio 用**收集 模式**:`{stdin:'ignore', stdout:{maxBytes}, stderr:{maxBytes}}`,graceMs 3000;理由见 「关键实现约束」第 6 条)→ 等心跳新鲜(20 秒窗口)→ 若快捷方式缺失则创建。拉起前把该 exe 目录记入候选列表,避免探测缓存仍是旧值时漏看新实例的心跳;应用若在就绪窗口内退出, 把退出码与它自己的输出作为错误返回(`appExit` / `appOutput` 两行进诊断)。 **退出**:写 `.dsh-quit`=`1`(写进**探测到的实例目录**,见上节)→ 双信号确认 (≤6 秒)→ 清除残留标记 → 删除桌面快捷方式;确认失败走 `Stop-Process -Name dsh-launcher -Force` 强杀(TerminateProcess,不影响 Harness 进程)。 桌面应用侧:勾选「退出时结束 Harness 进程」时,托盘/标记退出统一经 `begin_exit` ——先销毁主窗口与设置窗口(`close_visible_windows`,屏幕上只留反馈窗口),再弹出 紧凑 dialog 式独立 `exiting` 进度窗口(spinner + “正在退出 DeepSeek Harness 进程…”), 然后在后台线程执行 taskkill 清理,避免同步终止阻塞 UI 导致动画白屏;未勾选(默认) 则立即退出、不显示动画。 ## 快捷方式联动 - 快捷方式状态 = 桌面 `.lnk` 文件是否存在(与桌面应用自带设置同源,天然同步); - 创建/删除/存在性判断走 subprocess+PowerShell(WScript.Shell / Test-Path / Remove-Item),桌面路径用 `[Environment]::GetFolderPath('Desktop')` 解析 (适配 OneDrive 重定向),退出码回传结果,带 5 秒存在性缓存。 ## 关键实现约束(踩过的坑) 1. `subprocess.spawn` 的 `stdio` 必须是**对象**(每流 `ignore/pipe/inherit`), 字符串会触发 `undefined.maxBytes` 校验错误;`graceMs` 必填且为正有限数。 2. 插件直写非工作区路径会被沙箱拒绝 → 所有“写”操作(标记文件、快捷方式) 一律走无沙箱的 subprocess 子进程。 3. 关闭流程**不能覆写心跳文件**——覆写会让“等待退出”探测立即误判“已停止”, 进而在桌面应用轮询前取消退出标记。 4. 设置分区导航图标由外壳按分区 id 硬编码映射,未知 id 回退齿轮;本插件用 CSS(导航第 5 项)替换为显示器图标——依赖分区排序,外壳升级后可能失效, 属可接受降级(退回齿轮)。 5. 客户端错误需**常驻显示**(自动刷新不得清空),诊断信息仅在出错时展示。 6. **拉起桌面应用必须用收集模式 stdio,不能用 `inherit`**(2026-09-15 实测):DSH 自身 可能是被桌面应用拉起的,它的 stdout/stderr 就是桌面应用创建的管道(`harness.rs` 用 `Stdio::piped()` 拉起 `dsh web`)。桌面应用退出后读端消失,此后以 `inherit` 拉起的 子进程继承的是**已断开的手柄**,一写即 panic 退出(实测 `exit code 101`;现象是「设置 里打开开关桌面端起不来,关闭方向却一直正常」——关闭只写标记文件)。收集模式让管道由 DSH 侧持有,顺带把应用启动期输出留给诊断。 ## 外壳页承载与 DSH 鉴权适配(2026-09-09) 桌面应用主窗口**不再**直接使用 tauri 资产协议(`http://tauri.localhost`),改由内置 静态服务承载于 `http://127.0.0.1:3081`(`src-tauri/src/shell_server.rs`,编译期 `include_str!` 内嵌 `ui/` 三文件)。 - **为什么换**:新版 `dsh web` 的会话 cookie 为 `SameSite=Strict`,而 SameSite 比较 「站点」时**忽略端口、只比主机**。`tauri.localhost` 与 DSH 的 `127.0.0.1:3080` 跨站 → WebView2 拒收 iframe 内 token 握手 303 响应的 `Set-Cookie` → iframe 永久 白页(仅显示一行 401 英文提示)。改到 `127.0.0.1:3081` 后二者同站(同主机、不同 端口),cookie 正常签发与发送。 - **启动顺序**:服务在 `main.rs` 中**同步 bind 完成后**才让 tauri 建窗口;异步 bind 会与建窗口竞态,主窗口命中 `ERR_CONNECTION_REFUSED` 且不重试(表现为白窗)。 - **ACL**:外壳页对 tauri 而言是远程 origin,其应用命令必须显式授权 (`permissions/launcher.toml` 的 `allow-launcher-commands` + capability 的 `remote.urls`),否则 IPC 报 `Command X not allowed by ACL`。 - **鉴权握手**:`launch_dsh` 捕获子进程 stdout 的 `dsh web: ` 行并导航 iframe 完成 token→cookie 交换;旧版 DSH 仍走裸 `GET /` 的 `__DSH_BOOT__` 指纹路径。 接管外部实例但 WebView2 无 cookie 时,外壳提示条提供「关闭并重启」 (`restart_dsh_external`:netstat 定位占用进程 → taskkill → 自启动完成握手)。 - settings/exiting 两个辅助窗口仍走 tauri 资产协议(不涉及 DSH cookie)。 - **外壳 origin 与插件侧校验(2026-09-12 修正)**:外壳改由 `127.0.0.1:3081` 承载 后,插件**不能只依赖** `document.referrer` 推导父 origin——实测(DSH 0.1.5 + WebView2)iframe 经 token 303 握手后 `document.referrer` 为**空串**,推导值退化 为兜底常量 `http://tauri.localhost`,与外壳实际 origin 不符,导致「外壳 → 插件」 的消息(导航命令、系统主题)被 `event.origin` 校验全部丢弃(症状:◀/▶ 点击无 响应、Windows 主题不跟随;探针实测 34 次 ping 全部 match=false)。 现行为:`isShellMessage()` 以 `event.source === window.parent` 作身份校验,origin 按「referrer 推导值 **或** 已知外壳白名单(`http://127.0.0.1:3081`、 `http://tauri.localhost`)」收口。**改动外壳端口时须同步更新插件侧 `SHELL_ORIGINS`**(两侧常量需保持一致)。 ## 外壳 ↔ 插件消息协议(postMessage) 外壳页(`ui/main.js`,承载于 `http://127.0.0.1:3081`)与 iframe 内插件的双向通道。 两侧各自定义同一套常量(外壳与插件的 `MSG`),字段以本节为准: | 方向 | type | payload | 旧字段(兼容期) | | --- | --- | --- | --- | | 外壳 → 插件 | `navCommand` | `{ dir: 'back' \| 'forward' \| 'ping' }` | `__tbNav` | | 外壳 → 插件 | `systemTheme` | `{ scheme: 'light' \| 'dark' }` | `__tbSystemTheme` | | 插件 → 外壳 | `navStatus` | `{ back, forward }` | `__tbNavStatus` | | 插件 → 外壳 | `themeSync` | `{ scheme, bg, fg, menuBg, menuBorder, sep, danger }` | `__dshLauncherTheme: 1` + 同名字段 | 信封格式 `{ v: 1, type, payload }`。**兼容期**(2026-09 起):发送端双发(信封 + 旧字段),接收端双解析(`readEnvelope(data, type)` 优先,失配回退旧字段),未知 `type` 静默忽略——因此旧 exe + 新插件、新 exe + 旧插件均可工作;下个大版本移除 旧字段。 其他约定: - 双方都校验 `event.origin`:外壳比对 iframe URL 的 origin,插件比对由 `document.referrer` 推导的父 origin(兜底 `http://tauri.localhost`); - 插件 → 外壳用 `postMessage(..., '*')` 投递(WebView2 对虚拟主机的精确 targetOrigin 匹配有丢弃嫌疑),安全性由接收端 origin 校验保证; - 浏览器直开(非 iframe)时插件自动不启用导航与系统主题跟随。 ## 协议超时与常量清单 | 常量 | 值 | 位置 | 语义 | | --- | --- | --- | --- | | 心跳写入周期 | 1 秒 | `markers.rs` | 桌面应用写 `.dsh-heartbeat` | | 心跳新鲜窗口 | 4 秒(`freshSecs`) | `lib/index.js` | 插件判定“运行中” | | 退出标记新鲜窗口 | 60 秒 | `markers.rs` | `.dsh-quit` 内容 `1` 的时效 | | 启动等待心跳 | 20 秒 | `lib/index.js` | 拉起 exe 后的就绪窗口 | | 退出确认 | 12 × 500ms(≈6 秒) | `lib/index.js` | 双信号确认上限 | | 启动就绪等待 | 180 秒 | `harness.rs` | 等 `dsh web` 就绪 | | 接管重启端口释放等待 | 10 秒 | `harness.rs` | 终止外部实例后等端口 | | 外壳 ping 周期 | 3 秒 | `ui/main.js` | 导航状态自愈 | | 服务重试梯子 | 12 × 500ms | `lib/client.js` | theme / sessions 就绪重试 | | 跳转锁超时 | 1.5 秒 | `lib/client.js` | 会话栈 pendingJump 兜底 | | 快捷方式存在性缓存 | 5 秒 | `lib/index.js` | 减少 PowerShell 调用 | ## 标题栏与窗口外壳 主窗口无边框(`decorations: false`):DSH 就绪前显示启动卡片(检查/安装/启动/错误 各 phase),就绪后隐藏卡片、显示 40px 自绘标题栏并用 iframe 承载 DSH(不再整页跳转)。 - **三键**:最小化 / 最大化还原 / 关闭;关闭被 Rust 侧 `CloseRequested` 拦截为「隐藏到托盘」, 真正退出走托盘菜单或标题栏菜单的「退出」; - **◀/▶**:会话导航(见下节),另支持 Alt+←/→ 与鼠标侧键;可用性由插件回传后置灰; - **文件菜单**:设置 / 重新加载页面 / 在浏览器中打开 / 退出;**帮助菜单**:官网 / 文档。 外链 URL 表唯一维护在 Rust 侧(`EXTERNAL_LINKS`),前端只传 key(`dsl` 为本地 DSH, 地址取自应用状态以带上 token); - **窗口拖动**:`data-tauri-drag-region`;capability 必须显式包含 `core:window:allow-start-dragging`(`core:window:default` 不含,缺失时拖动被静默拒绝)。 ## 标题栏主题跟随链路 目标:标题栏与 DSH 配色一致,并在 DSH 偏好为「跟随系统」时随 Windows 主题切换。 1. 插件读 DSH **真实生效**的 token(`getComputedStyle(document.body)` 的 `--dsw-*`; 内置主题快照的 tokens 是空表,只能读计算样式,必要时手动解一层 `var()` 链), 连同 `scheme` 经 postMessage 发给外壳; 2. 外壳 `applyTitlebarTheme()` 逐项写入 `--tb-*` 变量,颜色经 `pickColor()` 形状校验, 非法值回退内置色板; 3. **读取必须延后到当前任务之后**(`setTimeout 0`):DSH 的 ThemePresenter 写 `body[data-ds-dark-theme]` 与本插件的 `theme/change` 监听器同处一次**同步**派发, 谁先注册不保证,同步读会拿到上一个主题的 token(表现为标题栏滞后一个主题); 另有 `body` 主题属性的 MutationObserver 兜底与同状态签名去重; 4. 插件缺席(未安装 / 浏览器直开)时,外壳回退 `matchMedia('(prefers-color-scheme: dark)')`; 插件一旦上报过主题(`pluginThemeApplied`)即不再介入。 ## Windows 系统主题跟随 WebView2 的 `prefers-color-scheme` 只有宿主显式设置 `PreferredColorScheme` 才会随 Windows 变化,而 tauri/wry 未暴露该能力(wry#806)→ DSH 的 `system` 偏好会冻结在 启动值,`matchMedia` 的 change 监听永不触发。补偿链路: - Rust 每 2 秒查注册表 `AppsUseLightTheme`,变化时刷新托盘/窗口图标并向主窗口 `emit("launcher-system-theme")`; - 外壳转发给 iframe 内插件;插件在偏好为 `system` 且未处于该方案时,用 `Object.defineProperty` 覆盖 ThemeRuntime `media.matches` 为真实系统值并 `publish()`。 **偏好零改写**:不调用 `setTheme()`,用户设置面板不会被改动;用户固定了具体主题时让位。 ### 任务栏按钮图标的来源(2026-09-13 实测,重要) **Win11 任务栏按钮画的是「应用标识」的图标,不是窗口图标。** 实测(25H2 / 26200.9445): 切换系统主题时,主窗口的四个图标槽(`WM_GETICON` BIG/SMALL、`GCLP_HICON/HICONSM`) 全部换成了新句柄,托盘图标也跟着变,但任务栏按钮纹丝不动——连 `ITaskbarList::DeleteTab + AddTab` 强制重建按钮也不变。 取按钮像素比对后确认:按钮主体是 `(250,250,250)`,正好等于 exe 内嵌的 `icons/icon.png`,而窗口图标当时是 `(15,17,21)`(浅色主题用黑鲸)→ **任务栏画的是 exe 内嵌图标**。所以: - `set_icon`(ICON_SMALL)、`WM_SETICON ICON_BIG`、`GCLP_HICON/HICONSM` 这几条路径 实际影响的是**标题栏与 Alt-Tab**,改不动任务栏按钮(v1.0.6/v1.0.7 两次修复因此都 没治本;深色任务栏上白色鲸鱼看着正常,一切到浅色就“失效”); - 托盘图标走的是另一条 shell 通道,**是**跟随主题的; - 任务栏按钮要显示什么,只能由 **exe 内嵌图标**(`icons/icon.ico`)决定,运行时不改; 因此应用图标本身必须是**浅色/深色任务栏都看得清**的配色(现为品牌蓝 `#4D6BFE`), `icon-black.png`/`icon-white.png` 只服务于托盘与窗口图标; - `main.rs` 之外,`run()` 在**建窗口之前**调用 `SetCurrentProcessExplicitAppUserModelID("com.dsh.launcher")`(与 `tauri.conf.json` 的 `identifier` 一致,有单元测试钉住):不显式设置时 Windows 按 exe 推导身份,任务栏更容易只认 exe 图标;这也是社区修任务栏图标问题的标准前置步骤。 ## 会话导航栈(应用层) DSH 是 React SPA,切会话不产生浏览器历史(`history.back()` 会退回 iframe 加载前的空白页), 故用应用层双栈实现前进/后退: - **状态源**:插件 `ctx.get('sessions').list`(SnapshotStore:`getSnapshot()` 给出 `current` 与 `currentAddress`,`subscribe(fn)` 返回退订函数);跳转用 `open(id)` / `openSubagent(address)`; - **语义**:非跳转来源的 current 变化 → 上一个会话压 backStack 并清空 forwardStack; ◀ 弹 backStack(当前会话压 forwardStack),▶ 反向;栈上限 50、连续重复跳过; - **锁**:跳转前记录 pendingJump,避免自身触发的变化被当成用户切换重复压栈; 1.5 秒超时兜底解锁并重新同步快照; - **状态回传**:插件在初始化与每次变化后上报 `navStatus`,外壳据此置灰按钮; 外壳每 3 秒 ping 一次实现自愈(单次消息丢失可恢复)。 ## 设置 / 退出进度窗口与预建约束 主窗口 iframe 加载跨源 DSH 之后,主线程**同步 build 第二个 webview 会死锁** (WebView2 多窗口竞态:实测 `build()` 永不返回、事件循环停摆)。因此: - `settings` 与 `exiting` 两个辅助窗口在启动早期**预建**(`visible(false)` 防闪现), 之后只 `show()` 复用;**退出路径绝不做现建兜底**(预建失败宁可没有动画); - `exiting` 为紧凑 dialog(`skip_taskbar` + `always_on_top`),由 `exiting.js` 按内容 高度 `setSize`(需要 capability `core:window:allow-set-size`);其样式自带于 `exiting.css`,不依赖外壳样式表; - 退出动画期间先 `destroy()` 主窗口与设置窗口(`close()` 会被「隐藏到托盘」拦截), 再显示进度窗口;清理(taskkill 等)放到后台任务,避免阻塞 UI 导致动画白屏。 ## 状态模型(浏览器侧) `desktop: true | false | null`(运行中/已停止/状态未知)+ `shortcut: bool`。 切换操作走乐观 UI:点击即切开关位置并显示“正在启动…/正在退出…”,确认后定型; 成功后跳过即时刷新防止心跳窗口内回跳,由 10 秒周期刷新收敛。