# DSH 插件开发与版本适配手册 > **用途**:在本机开发、移植、排查 DeepSeek Harness(DSH)插件时的操作手册。 > **来源**:`dsh-plugin-backup` 适配 **DSH Desktop 0.2.0(内嵌 dsh 0.2.0-rc.2)** 的完整实测流程。 > 文中所有命令、路径、判定结果均在本机真实执行/核实过;实测日期 **2026-10-11**,平台 Windows 11 + Git Bash / PowerShell 7。 > 面向的宿主:**Desktop 0.2.0(0.2.0-rc.2 运行时)** 与 **0.1.7-rc.2 CLI** 两条线,两条都已复核。 --- ## 1. 结论速览(TL;DR) | 问题 | 结论 | |---|---| | Desktop 0.2.0「装了没反应 / 安装被拒」最常见原因 | 0.2.0 新增**安装前兼容性预检**:`peerDependencies` 里任何 `@deepseek-ai/dsh*` 条目不满足当前运行版本 → 安装阶段直接拒绝、加载阶段跳过该 bundle | | 为什么会不满足 | 旧插件常把 dsh 依赖**精确锁**在某个 rc(如 `"0.1.7-rc.2"`),它只满足 0.1.7 线,不满足 0.2.0 线 | | 推荐声明 | 两条分支同时覆盖两条线:`"^0.1.7-rc.2 \|\| ^0.2.0-rc.1"`;`engines.dsh` 同步该窗口。别写成单条 `>=0.1.7-rc.2 <0.3.0`——它过得了运行时预检,却过不了 npm 的预发布规则(§4.5),在 0.2.0-rc 主机上 `ERESOLVE` | | 需要改代码吗 | 本次实测**不需要**。0.1.7-rc.2 → 0.2.0-rc.2 的核心包 JS 逐字节一致(§6),差异只在构建产物 CSS 哈希与个别 UI 组件的内部实现 | | 如何快速自查一个插件能否装上新桌面端 | 用桌面端自带 CLI 建 **scratch profile**:装 → `--dump-config` → 真实启动 → curl 插件路由 + GUI(§5) | | 临时豁免(不改包) | `dsh plugin allow-version`,按 `插件名@版本` + 精确 dsh 版本授权 | --- ## 2. 环境与关键路径速查 | 项 | 路径 / 值 | |---|---| | Desktop 安装根 | `%LOCALAPPDATA%\Programs\DeepSeek Harness` | | Desktop 版本文件 | `resources\runtime\primary-runtime\runtime.json` → `desktopVersion: 0.2.0-rc.2`(另含 node 24.21.0 / pnpm 11.7.0 / python 3.12.14) | | 内嵌 dsh 运行时(app.asar) | `resources\app.asar` → `dsh/node_modules/@deepseek-ai/**`(240 个包;`dsh/package.json` = `@deepseek-ai/dsh-desktop-runtime@0.2.0-rc.2`) | | Desktop 自带 CLI | `resources\runtime\cli\bin\dsh.cmd`(以 `ELECTRON_RUN_AS_NODE=1` 跑 `dsh-desktop-host/lib/cli.js`) | | 全局 npm 的 dsh(另一条线) | `%APPDATA%\npm\dsh.cmd`;本机为 **0.1.7-rc.2** | | DSH_HOME | `%USERPROFILE%\.dsh`(可用环境变量 `DSH_HOME` 覆盖) | | profile 目录 | `$DSH_HOME\profiles\\`(`package.json` 的 `dsh.profile.bundles` + `cordis.patch.yml` 用户层) | | profile 安装副本 | `$DSH_HOME\profiles\\node_modules\\`(`file:` 安装是**快照拷贝**,改源码要重装才生效) | | 插件管理器操作日志 | `$DSH_HOME\profiles\\.plugin-manager\logs\operation-*\pnpm.log`(**安装被拒的原因就写在这里**) | | 市场兼容性事实缓存 | `$DSH_HOME\profiles\\.dsh-market\discovery-compatibility-v1.json`(每个市场插件的 `enginesDsh`/`peerDependencies`,写范围时可参考) | | 备份/数据目录(本插件) | `%USERPROFILE%\dsh_backups` | --- ## 3. Desktop 0.2.0 运行时解剖 - **profile 归属**:`desktop` profile 由 Electron 应用**独占管理**——用 CLI 对它执行 `--dump-config` 会报 `profile "desktop" is managed exclusively by the Electron application`。 要检查配置请用 **scratch profile**(§5.5),不要尝试直接 dump desktop。 - **模块解析**:插件里 `import '@deepseek-ai/xxx'` 由**运行时**(app.asar 内的 dsh,即 installAnchor) 解析,profile 里不需要(也不该)再装这些包;profile 的 `node_modules` 只放插件自身与第三方依赖。 - **两半插件的发现**: - 宿主半 → profile 的 `dsh.profile.bundles` 指向包名,包内 `dsh.bundle.patch` 给出补丁文件; - 浏览器半 → 包 `package.json` 的 `dsh.client`(`platform: "web"`)+ `exports["./client"]`, 由运行时汇总进页面 `__DSH_BOOT__` 的 boot manifest; - Typert 远端面 → `exports["./typert"]`,由 `dsh-typert-loader` 自动注册进 `ctx.typert`。 - **桌面端启动链**:`DeepSeek Harness.exe` → `dsh-desktop-host` → `runProfile` → `app-boot` 装载各 bundle → 预检(§4)→ Cordis 挂载 → Web 前端。任何 bundle 被预检拒绝都会出现在启动输出与插件管理器日志里。 --- ## 4. 安装前兼容性预检(0.2.0 关键机制) 0.2.0 的 `@deepseek-ai/dsh-app-boot` 提供 `evaluatePluginCompatibility(manifest, exemptions, runtimeVersion)`: ### 4.1 规则 1. 只检查名字为 `@deepseek-ai/dsh` 或以 `@deepseek-ai/dsh-` 开头的 **peerDependencies** (`@deepseek-ai/cordis`、`@deepseek-ai/schemastery` 不在检查范围)。 2. `workspace:^` / `workspace:~` / `workspace:*` 视为当前运行版本。 3. 判定用 `semver.satisfies(runtimeVersion, range, { includePrerelease: true })`——**预发布版参与匹配**。 4. 不满足时: - 安装阶段:插件管理器**拒绝安装**(`pnpm.log` 里可见完整文案); - 加载阶段:`loadProfileDirectory` 跳过该 bundle;行级预检把该行 `disabled = true`。 5. `dsh.manifestVersion`、`engines.dsh` 目前**只作声明**,安装器/加载器不强制校验 (官方 `dsh-package-manifest` README 原文);但请让它们与 peer 窗口保持一致,方便读者与市场工具判断。 ### 4.2 判定矩阵(用桌面端真实的 evaluator 实测) | peer 范围写法 | 运行时预检 0.1.7-rc.2 | 运行时预检 0.2.0-rc.2 | npm 解析 0.2.0-rc.2 | |---|---|---|---| | `0.1.7-rc.2`(精确锁) | ✅ | ❌ 被拒 | ✅ | | `>=0.1.7-rc.2 <0.3.0` | ✅ | ✅ | ❌ `ERESOLVE`(§4.5) | | `^0.1.7-rc.2 \|\| ^0.2.0-rc.1`(**推荐**) | ✅ | ✅ | ✅ | | `*` | ✅ | ✅ | ✅(但表达力为零,不推荐) | 后两列必须分开看:运行时预检与包管理器用的是**两套预发布语义**,一个写法在一边通过不代表另一边通过。 ### 4.3 实际会看到的两种错误形态 安装期(插件管理器,原文摘录): ``` dsh: installation rejected: Plugin dsh-plugin-backup@0.1.4 is incompatible with dsh 0.2.0-rc.2: peerDependencies {"@deepseek-ai/dsh-tools":"0.1.7-rc.2","@deepseek-ai/dsh-typert-protocol":"0.1.7-rc.2"}. Running it may cause crashes or data loss. … To accept this risk explicitly, grant the exact-version exemption for dsh-plugin-backup@0.1.4 on dsh 0.2.0-rc.2 with `dsh plugin allow-version` … ``` 加载期(启动输出 / stderr):`disabling profile plugin : Plugin @ is incompatible with dsh …` ### 4.4 豁免(应急,不替代修包) 豁免是**非交互**的、按 `插件名@版本` + **精确 dsh 版本**生效,共三条子命令(从 0.2.0 CLI 源码核实): ```powershell # 授予(--accept-risk 必带;缺参会直接报 usage 错) dsh plugin --profile desktop allow-version @ --dsh-version 0.2.0-rc.2 --accept-risk # 查看当前豁免表 / 撤销 dsh plugin --profile desktop version-exemptions dsh plugin --profile desktop revoke-version @ --dsh-version 0.2.0-rc.2 ``` 被拒的安装在 `pnpm.log` 末尾会直接打印一条可复制的 `allow-version …` 命令(CLI 的 `runPlugin` 对 `result.incompatible` 自动拼好)。豁免记录存在 profile 的 `compatibility.json`(`$DSH_HOME\profiles\\compatibility.json`),不写进包清单, 也不与包版本混存——这正是为了能精准撤销。**修好范围后应立即撤销豁免**。 ### 4.5 npm 的预发布准入规则(`>=0.1.7-rc.2 <0.3.0` 为什么会失败) node-semver 默认(不带 `includePrerelease`)**只有当范围内某个比较符与该预发布版拥有完全相同的 `major.minor.patch` 元组、且该比较符自身也带预发布标签时**,才放行该预发布版。`>=0.1.7-rc.2 <0.3.0` 的两个边界分别在 `0.1.7` 与 `0.3.0` 元组上,`0.2.0-rc.1` / `0.2.0-rc.2` 于两侧都不匹配任何带预发布 标签的比较符,因此被静默排除: | 版本 | `>=0.1.7-rc.2 <0.3.0`(npm 默认) | `>=0.1.7-rc.2 <0.3.0`(`includePrerelease: true`) | |---|---|---| | `0.1.7` | ✅ | ✅ | | `0.2.0`(正式版) | ✅ | ✅ | | `0.2.0-rc.1` / `0.2.0-rc.2` | ❌ | ✅ | 后果取决于解析器:`pnpm` 对可选 peer 宽容,`dsh plugin add` 走 pnpm 因而一直没暴露问题;`npm install` 则直接失败,且报错指向宿主自带的 `@deepseek-ai/dsh-*` 副本,容易误判成宿主的问题: ``` npm error ERESOLVE unable to resolve dependency tree npm error Could not resolve dependency: npm error peerOptional @deepseek-ai/dsh-typert-protocol@">=0.1.7-rc.2 <0.3.0" from dsh-plugin-backup@1.0.0 ``` `^0.1.7-rc.2 || ^0.2.0-rc.1` 把边界放到 `0.1.7` 与 `0.2.0` 两个元组上(caret 展开后的下界自带预发布标签), 于是两侧预发布版都能匹配;`||` 分支列表是官方文档推荐的写法,同时也满足运行时预检。**写范围时两个 语义都要过**,并把这两组断言写进离线测试(5.4),否则日后改窄又会静默复发。 --- ## 5. 适配与验证流程(六步,本插件实测走通) ### 5.1 步骤 1:确认两条线的运行时版本 ```powershell # 桌面端内嵌运行时 Get-Content "$env:LOCALAPPDATA\Programs\DeepSeek Harness\resources\runtime\primary-runtime\runtime.json" # → desktopVersion / node / pnpm # 桌面端 CLI 版本与用法 & "$env:LOCALAPPDATA\Programs\DeepSeek Harness\resources\runtime\cli\bin\dsh.cmd" --help # 全局 npm 的 dsh(可能是另一条线) dsh --version ``` ### 5.2 步骤 2:解包 app.asar,核对 API 面 用附录 A 的脚本把桌面端运行时里要核对的包解出来: ```bash node asar-extract.mjs "<...>/resources/app.asar" /tmp/dsh-020 \ "dsh/node_modules/@deepseek-ai/dsh-tools/" node asar-extract.mjs "<...>/resources/app.asar" /tmp/dsh-020 \ "dsh/node_modules/@deepseek-ai/dsh-client-ui-primitives/" ``` 与插件当前使用的版本(如 `~/.dsh/profiles/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/` 下的 0.1.7-rc.2)逐文件 diff: ```bash diff <旧包>/lib/index.js /tmp/dsh-020/dsh/node_modules/@deepseek-ai/<包>/lib/index.js ``` **优先读 `lib/types/*.d.ts`**——那是契约的第一手来源;再用真实 JS 主体确认实现是否变了(CSS 类名哈希、注释里的构建路径不影响行为)。 ### 5.3 步骤 3:修改声明层 `package.json`: ```json { "engines": { "dsh": "^0.1.7-rc.2 || ^0.2.0-rc.1", "node": ">=22" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.4", "@deepseek-ai/schemastery": "~3.18.4", "@deepseek-ai/dsh-tools": "^0.1.7-rc.2 || ^0.2.0-rc.1", "@deepseek-ai/dsh-typert-protocol": "^0.1.7-rc.2 || ^0.2.0-rc.1" } } ``` 同时升级包自身版本号,并在离线测试里**锁住这个窗口**(见 5.4),防止日后又被改窄。 ### 5.4 步骤 4:离线测试 ```bash npm test # node --test;本次 50/50 通过 npm run check # node --check 各入口文件 ``` 建议新增一条回归用例,覆盖**两套预发布语义**,因为一个写法可能只过其中一套(§4.5): - 运行时预检语义(`includePrerelease: true`)必须放行:`0.1.7-rc.2`、`0.2.0-rc.2`、`0.2.1-alpha.1` - npm 解析语义(默认)必须放行:`0.1.7-rc.2`、`0.2.0-rc.1`、`0.2.0-rc.2`、`0.2.0` - 两者都必须排除:`0.1.7-rc.1`、`0.3.0`(预检下为 `0.3.0-rc.1`) - `engines.dsh` 与 peer 窗口必须逐字一致 实现时别只比 `>=`/`<` 两个端点(本插件最初的用例就是这么写的,于是漏掉了 npm 的预发布准入规则)。 要么直接依赖 `semver`,要么把手写判定与真实 `semver.satisfies` 做一次交叉核对。 ### 5.5 步骤 5:scratch profile 验证(不碰用户的 desktop) ```powershell $dsh = "$env:LOCALAPPDATA\Programs\DeepSeek Harness\resources\runtime\cli\bin\dsh.cmd" # 1) 从 web 模板初始化一个临时 profile(同时会打印一份完整组合配置) & $dsh --profile scratchweb --from-default-profile web --dump-config # 2) 安装插件(这一步过不了预检就会像 §4.3 一样被拒) & $dsh plugin --profile scratchweb add file:D:/path/to/your-plugin # 3) 检查组合结果:插件行与解析后的 config 应出现在输出里 & $dsh --profile scratchweb --dump-config | Select-String -Context 0,16 your-plugin # 4) 真实启动(独立端口,不干扰正在使用的实例) & $dsh --profile scratchweb --no-open --port 19399 # 输出会给出带 token 的 URL:dsh web: http://127.0.0.1:19399/?token=… ``` > 注意: > - `dsh web --profile scratchweb` 是**错的**(`web` 本身已是 profile 名,会报 “select a profile only once”); > 正确写法是 `dsh --profile scratchweb`。 > - `--from-default-profile` 有**初始化副作用**:即使配着 `--help` 也会把 profile 建出来,别再拿它当只读查询。 > - 安装输出里的 `[WARN] Issues with peer dependencies found.` 属**预期噪音**:dsh 的 peer 由运行时提供、 > 不在 profile 的 node_modules 里,pnpm 无法就地校验;真正的门是 §4 的预检与真实启动(都通过即可)。 ### 5.6 步骤 6:真实启动 + HTTP + GUI 验证 ```bash # 宿主半是否挂载:直接打插件自己注册的路由(返回插件专属文案即为成功) curl.exe -s -i "http://127.0.0.1:19399/" # 浏览器半是否收录:带 token 换 cookie 后抓首页,在 __DSH_BOOT__ 里找插件行 TOKEN=$(grep -o 'token=[A-Za-z0-9_-]*' boot.log | head -1 | cut -d= -f2) curl.exe -s -c cookie.txt "http://127.0.0.1:19399/?token=$TOKEN" -o /dev/null curl.exe -s -b cookie.txt "http://127.0.0.1:19399/" > page.html grep -o '{"id":""[^}]*}' page.html ``` `__DSH_BOOT__` 里应出现(示例为 `dsh-plugin-backup` 的实测值): ```json {"id":"dsh-plugin-backup","url":"plugins/??dsh-plugin-backup/client.js&rev=…","rev":"…", "inject":["@deepseek-ai/dsh-client-ui-settings","@deepseek-ai/dsh-client-locale"], "external":["react","react/jsx-runtime","@deepseek-ai/dsh-client-ui-primitives","@deepseek-ai/dsh-client-ui-slots"]} ``` 再用浏览器打开带 token 的 URL 做 GUI 走查(设置面板 → 插件页面 → 一次真实写操作)。 本次 GUI 实测覆盖:页面完整渲染(含真实数据列表)、下载菜单展开、点「立即备份」真实产出 102.6 MB / 18404 文件的归档并即时验证通过、列表由 1 份刷新为 2 份。 ### 5.7 收尾 ```powershell # 停掉验证实例 Get-CimInstance Win32_Process -Filter "Name like '%DeepSeek%'" | Where-Object { $_.CommandLine -like '*scratchweb*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force } # 删掉临时 profile(其安装副本一并消失;scratch profile 本来就是一次性) Remove-Item -Recurse -Force "$env:USERPROFILE\.dsh\profiles\scratchweb" ``` 正式发布到桌面端时:**刷新 desktop profile 的安装副本**(`file:` 快照不会自动同步): ```powershell & $dsh plugin --profile desktop remove your-plugin & $dsh plugin --profile desktop add file:D:/path/to/your-plugin # 逐文件核对 SHA256 一致 ``` --- ## 6. 已核实的 API 面差异表(0.1.7-rc.2 → 0.2.0-rc.2) 对桌面端 app.asar 内的 0.2.0-rc.2 与 profile 里的 0.1.7-rc.2 逐文件对比的结论: | 包 | 结论 | |---|---| | `dsh-tools` | **JS 主体逐字节一致**(`defineTool`、服务 key `tools` 不变) | | `dsh-typert-protocol` | 一致(`TypertRemoteService`、`remote.$mount` 契约不变) | | `dsh-typert-loader` | 一致(`validateTypertManifest` 的校验规则相同:strict codec、`typeSymbol`、invocation 字段集) | | `dsh-typert-registry` | 一致 | | `dsh-client-ui-slots` | 一致(`slots.inject` / `slots.register`、`settings.section` 仍是 list 槽位) | | `dsh-client-ui-settings` | 一致(`settings.section` 声明与注入契约不变;0.2.0 另起 `configForms` 机制并存) | | `dsh-client-connection` | 一致 | | `dsh-client-modules` | 一致(boot 协议、`dsh.client` 的 `platform/inject/external/immediately` 解析规则不变) | | `dsh-host-webserver` | 一致(`register({ kind:'exact', path, handler })`、服务 key `webServer`) | | `dsh-client-locale` | 仅 CSS 模块哈希差异;`register` / `bind` / `resolveText` 不变 | | `dsh-client-ui-settings-general` | 仅 CSS 模块哈希差异 | | `dsh-client-ui-primitives` | **有差异但不破坏**:`Button/Input/Checkbox/Switch/Tag/StateDot/Menu` 的 props 签名一致;新增 `MenuGroup`;`Input` 改为 `forwardRef`;`TextShimmer` 内部重构 | 0.2.0 Web 前端的 **shell 模块表(seed words)**:`react`、`react/jsx-runtime`、`react-dom`、 `react-dom/client`、`@deepseek-ai/cordis`、`@deepseek-ai/dsh-client-store`、 `@deepseek-ai/dsh-client-ui-slots`、`@deepseek-ai/dsh-client-ui-primitives`、 `@deepseek-ai/dsh-client-ui-dockkit` —— `dsh.client.external` 只能出现表中的名字(或 boot 图里其它插件的行)。 --- ## 7. 插件两面契约速查(写新插件用) ### 7.1 目录与 package.json 模板 ``` my-plugin/ ├── package.json # dsh.bundle + dsh.client + exports(./client, ./typert) ├── cordis.patch.yml # - insert: [{ id, name, config }] ├── index.js # 宿主半:name / inject / Config / apply ├── typert.js # (可选)./typert 宿主 Typert 描述符 + Service └── client.js # 浏览器半:window.__ModuleLoader__.load({ id, factory }) ``` ```json { "name": "dsh-my-plugin", "version": "0.1.0", "type": "module", "main": "./index.js", "exports": { ".": "./index.js", "./client": "./client.js", "./typert": "./typert.js", "./cordis.patch.yml": "./cordis.patch.yml", "./package.json": "./package.json" }, "files": ["index.js", "client.js", "typert.js", "cordis.patch.yml"], "dsh": { "manifestVersion": 1, "bundle": { "patch": "./cordis.patch.yml" }, "client": { "platform": "web", "external": [ "react", "react/jsx-runtime", "@deepseek-ai/dsh-client-ui-primitives", "@deepseek-ai/dsh-client-ui-slots" ], "inject": ["@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-locale"] } }, "engines": { "dsh": "^0.1.7-rc.2 || ^0.2.0-rc.1", "node": ">=22" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.4", "@deepseek-ai/schemastery": "~3.18.4", "@deepseek-ai/dsh-tools": "^0.1.7-rc.2 || ^0.2.0-rc.1" } } ``` 要点: - `dsh.bundle.patch` 是**组合层**(profile 的 bundles 列表引用包名);`cordis.patch.yml` 里 `- insert: [{ id, name, config }]` 的 `id` 也是设置小节/配置表单的命名空间。 - `dsh.client.platform` 必须是 `"web"` 才会被 Web 端收录;`immediately: true` 表示不阻塞首屏、 尽早预取(不设则按需加载)。 - 官方建议:凡是**要与宿主共享实例**的 dsh 包(如 `dsh-tools`、`dsh-typert-protocol`), 同时写进 `peerDependencies` 与 `devDependencies`(本地测试要用真包)。 ### 7.2 宿主半(index.js) ```js export const name = 'plugin-'; export const inject = []; // 全部可选服务都留空,用子 fiber 等 export const Config = /* schemastery */ z.object({ /* … */ }); export function apply(ctx, config) { ctx.get('backup'); // 读服务一律 ctx.get('name'),不要属性读 ctx.inject(['tools'], (c) => { /* 服务晚到再挂载 */ }); ctx.inject(['webServer'], (c) => { const dispose = c.get('webServer').register({ kind: 'exact', path: '/x', handler }); c.effect(() => dispose, 'route'); }); ctx.effect(() => () => { /* 释放定时器/锁 */ }, 'lifetime'); return service; // 有服务提供时返回实例 } ``` 规则(都踩过坑): - **服务读取**:Cordis 的 `ctx` 是代理,`ctx.tools` 这种**属性读**在未注入该名字的 fiber 上会抛 `cannot get property "…" without inject`;一律 `ctx.get('...')`。 - **依赖等待**:可缺席/晚到的服务(`tools`、`webServer`)不要写进 `inject`(会永久 pending), 用 `ctx.inject([...], cb)` 子 fiber 等它出现。 - **重复激活保护**:profile 重组可能在本进程内再次 `apply`;先 `ctx.get('')`, 已存在则告警并复用,别让新 fiber 因 `service has been registered` 而失败。 - `defineTool` 的 schema:可选参数**省略** `required`(写 `required: false` 会被拒)。 ### 7.3 Typert 远端面(./typert) ```js export const TYPERT = { package, face: 'host', schemas: [...], model: {...}, invocations: [...] }; export class MyService extends TypertRemoteService { … } // 方法即远端方法 export const TYPERT_REMOTE = { package, descriptors: TYPERT.invocations }; // 客户端复用同表 ``` - 描述符必须是 **strict codec**(`mode: 'strict'` + `typeSymbol` + `create()`)。 - **发现方式**:`dsh-typert-loader` 会扫描每个挂载的 Loader 条目,发现包 `exports["./typert"]`(值为**字符串**或一层 `{ default: 字符串 }` 对象)后动态导入其 `TYPERT` 导出并校验、注册;条目卸载时同步撤回。loader 的 `inject` 是 `['typert', 'loader']`,并需要 `ctx.baseUrl`(配置树锚点)才能解析包路径。 **无 `./typert` 导出的贡献可以改用手动注册**:`ctx.typert.register(...)` (手写 wire schema、测试、非 Loader 组合走这条路)。 - `TYPERT` 的**校验规则**(`validateTypertManifest`,报错都会点名包与缺陷): - `manifest.package` 必须等于包名、`face` 必须为 `'host'`、`schemas` 必须是数组, 每项有字符串 `name` 与 `create()` 工厂; - `model` 必须有 `services` / `events` / `objects` 三个数组;service 需要 `tags`(数组)、字符串 `key`、`exportName`,每个 member 需要 `name`、`signature`、 `kind`(枚举 `property|method|getter|setter|call|construct|index`),type 需要 `name` + `declaration`; - `invocations` 每项需要 `id` / `service` / `namespace` / `method` 四个字符串, `invocation.kind` 只能是 `'direct'` 或 `'context'`(后者还要求 `wire` + strict codec), 参数的 `source` 只能是 `'json'` 或 `'lookup'`(wire 名不得重复),`result` 必须 strict codec;`sourceLocation`(若给出)`line`/`column` 必须 ≥1 的正整数。 - **线上方法名避开客户端载体的保留成员**(`remove` / `has` / `install` / `installDirect` / `installScoped` / `assertMethodAvailable` / `ctx` / `empty` / `invokeRemote` / `methods` / `name` / `namespace`),撞名会在挂载时被拒(`method "x/remove" conflicts with its namespace service`)。 本插件把它命名为 `deleteBackup`。 - 返回形状要与声明一致:例如"偏好读写"返回 `Preferences`(不含 `rows/total/state`)而 "页面快照"返回 `Snapshot`;客户端别把前者当后者用。 - `TypertRemoteService`(0.2.0 未变)构造时 `super(ctx, serviceKey, options)`,并把 `bindTypertRemote(this, key, { namespace })` 结果挂到 `typertRemote` 字段—— **Gateway 靠这个字段做 source-mode 发现**,所以服务必须扩展该基类而不是自造 Service。 可用 `options.namespace` 把 wire 命名空间与 Cordis 服务 key 拆开(默认同名)。 ### 7.4 浏览器半(client.js) ```js window.__ModuleLoader__.load({ id: 'dsh-my-plugin', factory: (require) => { const react = require('react'); const primitives = require('@deepseek-ai/dsh-client-ui-primitives'); /* … */ function apply(context) { context.effect(() => context.locale.register(NS, { zh, en }), 'dictionaries'); context.remote.$mount(TYPERT_REMOTE); // 挂载自己的远端命名空间 const ns = context.get('remote.my-namespace'); // 之后一律 ctx.get 读它(不能写进 inject,会死锁) context.slots.inject('settings.section', () => context.slots.register({ name: 'settings.section', id: 'my-page', order: 25, label: () => context.locale.bind(NS)('nav'), locale: NS, inject: () => ({ /* 传给页面组件的操作集 */ }), }, MySection)); } return { apply, inject: ['slots', 'locale', 'remote'], /* … */ }; }, }); ``` - 工厂是**惰性**的:脚本执行只登记 factory,首次 import 才求值;CSS 注入写在 factory 内部。 - `require` 只能拿到 `dsh.client.external` 列出的名字(必须在 shell 模块表内)或同图插件的行。 - 给设置页包一层**自己的错误边界**:槽位对渲染异常会"除名"(abdicate)条目,刷新前不再恢复; 自己兜住才能降级显示并重试。 - 写操作统一"动作完成后重读快照",不要拿动作返回值当整页快照渲染。 ### 7.5 服务与槽位命名速查 | 名字 | 侧 | 说明 | |---|---|---| | `tools` | 宿主 | 模型工具注册表;`register(defineTool({...}))` | | `webServer` | 宿主 | HTTP 路由注册;`register({ kind:'exact'\|'prefix', path, handler })` | | `typert` | 宿主 | Typert 注册表;`./typert` 导出由 loader 自动注册 | | `slots` | 浏览器 | 槽位账本;`inject(slot, () => register(...))` | | `locale` | 浏览器 | 文案;`register(ns, {zh,en})`、`bind(ns)`、`resolveText(text)` | | `remote` | 浏览器 | 远端载体;`$mount(face)` 挂载自己的命名空间 | | `settings.section` | 浏览器 | 设置面板页面槽位(list,需 `id`;`label`/`locale`/`inject`) | --- ## 8. 本次实测结果记录(dsh-plugin-backup 0.2.0) | 项目 | 结果 | |---|---| | 根因证据 | `profiles\desktop\.plugin-manager\logs\operation-zIqIli\pnpm.log` 完整记录拒绝原因(peer 精确锁) | | API 面核对 | §6 全表;核心包逐字节一致 → 判定为"仅声明层修复" | | 声明修复 | peer `>=0.1.7-rc.2 <0.3.0`;`engines.dsh` 同步;版本 `0.1.4 → 0.2.0` | | 该修复的后续更正(1.0.0 实测) | 上面那条范围只过得了**运行时预检**,过不了 npm 的预发布规则:`0.2.0-rc.1`/`0.2.0-rc.2` 被静默排除,在 0.2.0-rc 主机上 `npm install` 报 `ERESOLVE`(`pnpm` 与 `dsh plugin add` 宽容,所以当时没暴露)。1.0.0 起改为 `^0.1.7-rc.2 \|\| ^0.2.0-rc.1`,并把「两套语义都要过」写成回归测试(§4.5、§5.4) | | 离线测试 | **50/50 通过**(新增 peer 窗口回归 1 条);`npm run check` 通过 | | 兼容判定复核 | 用桌面端真实 `evaluatePluginCompatibility()` 跑矩阵:新声明在 0.1.7-rc.2 与 0.2.0-rc.2 都判 compatible;旧精确锁在 0.2.0 判 incompatible | | Desktop 安装 | `dsh plugin --profile desktop add file:…` **安装成功**;`package.json` 的 `dependencies` 与 `dsh.profile.bundles` 均含该插件 | | scratch 验证 | 从 web 模板建 scratch profile → 安装 → `--dump-config` 正确解析插件行与默认 config(`maxArchiveBytes: 536870912` 等) | | 真实启动 | 0.2.0-rc.2 运行时、独立端口 19399:无 FAILED fiber、无 "entry did not activate";`GET /dsh-plugin-backup/download` 返回插件自己的 404 文案(宿主半已挂载) | | 浏览器收录 | `__DSH_BOOT__` 含插件行(id/inject/external 见 §5.6) | | GUI 走查 | 设置 → 数据备份 完整渲染:真实备份列表(29400 文件 / 145.9 MB)、下载菜单四项、GitHub 卡;点「立即备份」真实创建 `dsh-backup-20261011-163402`(102.6 MB / 18404 文件,即时验证通过),列表刷新为 2 份 | | 行为核对 | 删除"最新备份"被拒属**插件的保留策略**(需 `force`,UI 不发 force),非兼容问题 | | 副本一致性 | desktop 安装副本与工程 `package.json / index.js / typert.js / client.js / cordis.patch.yml / README* / docs/verify.md / lib/tools.js` SHA256 全部一致 | | 清理 | 临时实例已停、临时 profile 已删、测试备份已删、测试用插件副本已刷新为最终版 | --- ## 9. 常见坑清单(跨版本累积) | # | 现象 | 原因 / 处理 | |---|---|---| | 1 | 桌面端拒装,日志报 peer 不满足 | peer 精确锁某个 rc;改范围(§4),或临时 `allow-version` | | 2 | `cannot get property "…" without inject` | 属性读服务;改 `ctx.get('…')` | | 3 | 插件整体不激活(路由静默消失) | 早到/晚到的服务(如 `webServer` 晚 ~0.8s 可用)在 `apply` 里读到 `undefined` 就跳过;改 `ctx.inject([...], cb)` 子 fiber | | 4 | `service "x" has been registered` | profile 重组重复激活;`apply` 开头 `ctx.get` 复用已有实例 | | 5 | `method "x/remove" conflicts with its namespace service` | 远端方法撞客户端保留成员名;改名(如 `deleteBackup`) | | 6 | `defineTool` 拒绝 schema | 可选参数省略 `required`(写 `required: false` 会被拒) | | 7 | 保存后整片白屏且刷新才恢复 | 把动作返回值(如 `Preferences`)当整页快照渲染 → 槽位除名;动作后重读快照 + 载荷归一化 + 自备错误边界 | | 8 | 页面报渲染异常后入口永久消失 | 槽位对崩溃条目会 abdicate(刷新才回来);自己的错误边界兜住 | | 9 | `file:` 安装改源码不生效 | profile 里是快照;`remove` + `add` 重装并核对 SHA256 | | 10 | `dsh web --profile X` 报 "select a profile only once" | `web` 本身是 profile 名;写 `dsh --profile X` | | 11 | 想 dump desktop profile 被拒 | desktop 由 Electron 独占;用 scratch profile 检查 | | 12 | `--from-default-profile` 配 `--help` 也建了 profile | 该参数有初始化副作用,别当只读查询用 | | 13 | 安装输出 `[WARN] Issues with peer dependencies found` | 预期噪音(dsh peer 由运行时提供);真正的门是预检 + 真实启动 | | 14 | 客户端 require 未知模块 | `dsh.client.external` 只能列 shell 模块表里的名字(§6 表末) | --- ## 附录 A:app.asar 解包脚本(asar-extract.mjs) ```js // Extract files from an Electron app.asar archive by path prefix. // Usage: node asar-extract.mjs [prefix] [--list] import { readFileSync, mkdirSync, writeFileSync } from 'node:fs' import { dirname, join, posix } from 'node:path' const [asarPath, outRoot, prefix = '', mode = ''] = process.argv.slice(2) if (!asarPath || !outRoot) { console.error('usage: node asar-extract.mjs [prefix] [--list]') process.exit(1) } const buf = readFileSync(asarPath) // asar pickle header: [u32 4][u32 8+jsonLen][u32 jsonLen+4][u32 jsonLen][json bytes...] const jsonSize = buf.readUInt32LE(12) const header = JSON.parse(buf.subarray(16, 16 + jsonSize).toString('utf8')) const dataStart = 16 + ((jsonSize + 3) & ~3) const listOnly = mode === '--list' const matches = [] function walk(node, prefixPath) { for (const [name, entry] of Object.entries(node.files ?? {})) { const p = prefixPath ? posix.join(prefixPath, name) : name if (entry.files) { walk(entry, p); continue } if (prefix && !p.startsWith(prefix)) continue matches.push([p, entry]) } } walk(header, '') for (const [p, entry] of matches) { if (listOnly) { console.log(String(entry.size).padStart(10), p); continue } const out = join(outRoot, p) mkdirSync(dirname(out), { recursive: true }) const off = Number(entry.offset) writeFileSync(out, buf.subarray(dataStart + off, dataStart + off + entry.size)) } if (!listOnly) console.log(`extracted ${matches.length} file(s) to ${outRoot}`) ``` --- ## 附录 B:兼容性探针(用宿主自己的判定器验证声明) 预检逻辑在 `@deepseek-ai/dsh-app-boot` 里。把桌面端 app.asar 的 `dsh/node_modules` 解出来, 再用下面两个脚本,就能在**不启动桌面端**的情况下验证任意 peer 声明组合在两代运行时下的判定。 `compat-run.mjs`:反复运行探针,自动把缺失的依赖从 asar 补齐,直到能跑通。 它同时处理两种缺失形态——裸包(`Cannot find package 'x'`)与尚未解出的**目标模块 绝对路径**(`Cannot find module '/dsh/node_modules/…'`),因此可以直接对着 一个空目录冷启动。(本机实测:冷启动自动补 11 个依赖后探针正常输出。) ```js // Usage: node compat-run.mjs import { execFileSync } from 'node:child_process' import { join } from 'node:path' const [ASAR, OUTROOT, PROBE] = process.argv.slice(2) const EXTRACT = join(import.meta.dirname, 'asar-extract.mjs') /** Extract one dependency subtree from the asar. */ function extract(prefix) { try { const listing = execFileSync(process.execPath, [EXTRACT, ASAR, OUTROOT, prefix, '--list'], { encoding: 'utf8' }) if (listing.trim() === '') return false execFileSync(process.execPath, [EXTRACT, ASAR, OUTROOT, prefix], { stdio: 'ignore' }) return true } catch { return false } } /** Try to satisfy one missing specifier, bare or an absolute module path. */ function satisfy(missing) { if (missing.startsWith('@') || (!missing.includes('/') && !missing.includes('\\'))) { return extract(`dsh/node_modules/${missing}/`) || extract(`dsh/node_modules/@deepseek-ai/${missing}/`) } // Absolute module path: derive `dsh/node_modules///` from it. const normalized = missing.replace(/\\/g, '/') const marker = '/dsh/node_modules/' const at = normalized.indexOf(marker) if (at === -1) return false const parts = normalized.slice(at + marker.length).split('/') const pkg = parts[0].startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0] return extract(`dsh/node_modules/${pkg}/`) } for (let round = 0; round < 80; round += 1) { try { console.log(execFileSync(process.execPath, [PROBE, OUTROOT, process.env.OLD_BOOT], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })) process.exit(0) } catch (error) { const text = String(error.stderr ?? '') + String(error.stdout ?? '') const bare = /Cannot find package '([^']+)'/.exec(text) const module = /Cannot find module '([^']+)'/.exec(text) const missing = bare?.[1] ?? module?.[1] if (missing === undefined) { console.error(text.slice(0, 2000)); process.exit(1) } if (!satisfy(missing)) { console.error(`cannot locate ${missing} in the asar`); process.exit(1) } console.error(`extracted for ${missing}`) } } ``` `compat-probe.mjs`:把候选声明逐个交给真实判定器(0.2.0 与 0.1.7 两份都试)。 ```js // Usage: node compat-probe.mjs [oldAppBootIndexJs] const [outRoot, oldBootPath] = process.argv.slice(2) const boot = `${outRoot}/dsh/node_modules/@deepseek-ai/dsh-app-boot/lib/index.js` const { evaluatePluginCompatibility, pluginCompatibilityWarning, getDshRuntimeVersion } = await import(new URL(`file:///${boot}`).href) const candidates = { 'exact pin (旧)': { name: 'dsh-plugin-backup', version: '0.1.4', peerDependencies: { '@deepseek-ai/dsh-tools': '0.1.7-rc.2' } }, // Preflight accepts this one but npm does not: 0.2.0-rc.* is dropped by the // prerelease admission rule, so `npm install` fails with ERESOLVE (§4.5). 'single window (避坑)': { name: 'dsh-plugin-backup', version: '0.2.0', peerDependencies: { '@deepseek-ai/dsh-tools': '>=0.1.7-rc.2 <0.3.0' } }, 'alternative list': { name: 'dsh-plugin-backup', version: '1.0.0', peerDependencies: { '@deepseek-ai/dsh-tools': '^0.1.7-rc.2 || ^0.2.0-rc.1' } }, } const runtime = getDshRuntimeVersion() console.log('runtime under test:', runtime) for (const [label, manifest] of Object.entries(candidates)) { const issue = evaluatePluginCompatibility(manifest, {}, runtime) console.log(`\n== ${label} ==\n ${issue === undefined ? 'compatible' : 'INCOMPATIBLE: ' + pluginCompatibilityWarning(issue)}`) } if (oldBootPath !== undefined) { const old = await import(new URL(`file:///${oldBootPath}`).href) console.log('\nold runtime:', old.getDshRuntimeVersion()) for (const [label, manifest] of Object.entries(candidates)) { const issue = old.evaluatePluginCompatibility(manifest, {}, old.getDshRuntimeVersion()) console.log(` ${label}: ${issue === undefined ? 'compatible' : 'INCOMPATIBLE'}`) } } ``` 本机实测输出(节选):`exact pin` 在 0.2.0-rc.2 报 incompatible(与安装日志一致); `range` 与 `alternative list` 在两代运行时都 compatible。 --- ## 附录 C:命令备忘 ```powershell # —— 版本与安装状态 —— & $dsh --version & $dsh plugin --profile desktop list cat "$env:USERPROFILE\.dsh\profiles\desktop\package.json" # bundles + dependencies # —— 安装 / 重装(file: 快照语义)—— & $dsh plugin --profile desktop add file:D:/path/to/plugin & $dsh plugin --profile desktop remove plugin-name # 再 add 才会刷新副本 # —— scratch 验证 —— & $dsh --profile scratchweb --from-default-profile web --dump-config & $dsh plugin --profile scratchweb add file:D:/path/to/plugin & $dsh --profile scratchweb --dump-config | Select-String -Context 0,16 plugin-name & $dsh --profile scratchweb --no-open --port 19399 # —— 运行中实例核对 —— curl.exe -s -i "http://127.0.0.1:19399/" curl.exe -s -c cookie.txt "http://127.0.0.1:19399/?token=" -o /dev/null curl.exe -s -b cookie.txt "http://127.0.0.1:19399/" | Select-String '"{""id"":""your-plugin""' # —— 副本一致性 —— foreach ($f in 'package.json','index.js','client.js','typert.js','cordis.patch.yml') { $a = (Get-FileHash "D:/path/to/plugin/$f").Hash $b = (Get-FileHash "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\plugin-name\$f").Hash if ($a -eq $b) { "OK $f" } else { "DIFF $f" } } ``` --- *本手册由 2026-10-11 的 0.2.0 适配会话产出;细节验证记录见 [verify.md](verify.md) 的 §5.5。*