# DSH Plugin Share v1 实施计划与现状 本文是当前代码的实现契约。若文档与代码冲突,以测试和当前源码为准;修改格式或安全边界时必须同时更新测试、README 和本文件。 ## 1. 目标与非目标 目标是把一个 DSH profile 中可移植的插件清单编码成离线文本分享码,并在 DSH Web 的“设置 → 插件”中完成导出、预览和导入。分享码只携带: - 来源类型:npm、GitHub 或 builtin; - 包名或 GitHub spec; - 可选版本/版本范围; - 启用状态。 v1 不携带插件配置、patch、备注、作者、时间戳、凭据、认证信息或本机路径。v1 也不提供云短码、签名、历史存档、PNG 或 `.dshpkg` 文件载体。 ## 2. DSH 官方 bundle 结构 本项目本身是一个可公开安装的 bundle,不是 profile: ```text package.json cordis.patch.yml icon.svg src/index.mjs # Host 半 src/client.js # Web Client 半 locale/*.json README.md PLAN.md ``` `package.json` 必须满足 DSH 官方组合包约定: - `dsh.bundle.patch` 为 `./cordis.patch.yml`; - `dsh.client.platform` 为 `web`; - `dsh.client.inject` 声明其依赖的设置插件入口; - `exports["."]` 指向 Host 入口,`exports["./client"]` 指向客户端 bundle; - patch 行 `name` 必须与包名 `dsh-plugin-share` 一致。 `cordis.patch.yml` 只插入 `dsh-plugin-share` 这一行,默认配置为空。Host 使用 `webServer` 和 `pluginManager`,Client 使用 `slots` 与 `locale`,不直接读取 profile 文件。 ## 3. D1 外层帧与 NP1 记录 组合码格式为: ```text D1 + base64url(frame) frame = formatVersion(1 byte) + algorithm(1 byte) + body + crc32(payload)(little-endian 4 bytes) payload = NP1-style binary records ``` - `formatVersion` 当前为 `1`;未知版本拒绝; - `algorithm=0` 表示 body 是原始 NP1 payload; - `algorithm=1` 表示 body 是 `deflate-raw` payload; - CRC32 校验未压缩的 payload,负责发现复制、截断和篡改造成的传输错误;它不是签名,也不提供身份认证; - base64url 必须是规范形式;允许复制粘贴时混入空白,但解码后仍须通过所有边界校验; - 帧上限为 64 KiB,NP1 payload 上限为 256 KiB,条目上限为 512。 NP1 风格记录使用 varint、相邻值公共前缀、6-bit 常用字符表和版本表。记录另外加入来源 kind 与 enabled 位: ```text kind: npm=0, github=1, builtin=2 enabled: boolean value: package name or github:owner/repo[#ref] version: null or validated version expression ``` 所有解码后的 wire 值还要经过 `normalizeEntry` 和来源白名单复核,不能仅因为 CRC 正确就交给安装器。 ### D1 + NP1 压缩决策(D1 + NP1 方案) - 少于 8 个条目的短清单固定使用原始 NP1 二进制; - 8 个或更多条目的长清单才尝试 `deflate-raw`; - 长清单只有在压缩结果严格短于原始 payload 时才选择 deflate,否则选择 raw; - 压缩算法不会改变 payload、CRC 或解码结果。 阈值由 `DEFLATE_MIN_ENTRIES` 定义,编码器和测试共同锁定该规则。 ## 4. 输入模型与来源白名单 `normalizeEntry` 只接受来源、名称/spec、版本、启用状态和 optional 标记。以下字段直接拒绝:`config`、`settings`、`patch`、`token`、`apiKey`、任意未知字段等。 允许: - npm 包名,可带版本、版本范围或 prerelease; - `github:owner/repo`,可选不含空白的 `#ref`; - builtin 包名和 enabled 状态。 拒绝: - `file:`、`link:`、`portal:`、`workspace:`; - 相对路径、Windows 或 Unix 绝对路径; - HTTP(S) tarball、任意 HTTP(S) URL、`git+...`、`git@...`、`ssh://...`; - npm alias、空值、超长值、空白/控制字符和不合法包名。 安装 spec 只从规范化条目生成,不接受调用方传入的任意安装参数。 ## 5. 采集:导出哪些、怎么过滤 Host 的 `collectEntries` 接收 `pluginManager.listBundles()` 的公开记录,以及由 bundle 记录组成的依赖映射;它不读取 `cordis.patch.yml`、配置文件、凭据文件或其他本机秘密文件。 过滤规则: - `installed === true && removable === true` 的第三方 bundle 进入候选; - `optional === true && installed !== true` 的官方可选 bundle,在 `includeOptional` 开启时作为 builtin 进入; - `removable === false` 的固定核心层跳过; - 缺失来源、别名、远端 Git、tarball、本地 spec 和不支持的依赖形状进入 `skipped`,不会进入分享码; - `pin` 默认开启,用已解析的 bundle 版本锁定 npm 条目;关闭时保留跟随最新版本的语义; - enabled 状态始终保留,但不传播配置内容。 导出前由 `encodeCode` 再次规范化所有公开条目,因此采集器的错误不会绕过输入校验。 ## 6. 导入:解析、预览、安装与回滚 流程固定为: ```text 粘贴 D1 → 解码/CRC/白名单校验 → 预览 → 用户确认 → 串行安装 → 报告 ``` Host 路由为 `/api/plugin-share`: - `GET /state`:读取 bundle 列表和当前可导出清单; - `POST /export`:生成 D1 分享码; - `POST /parse`:只解码并生成预览,不修改 profile; - `POST /import`:要求 `confirm: true` 后逐条应用;可选 `only: [index…]` 只应用被选中的预览行; - `POST /qr`:把一段合法组合码编码成二维码矩阵; - `POST /scan`:把一张图片里认出的组合码交回来; - `POST /cancel`:按 request id 转发取消请求。 **每个请求都先过信任闸**:`ctx.webServer.register` 只负责挂载路由,本身不校验调用方,所以上面每个 action 在进入之前都必须先调 `ctx.connection.requestRejection(req)`(Host/Origin 校验 + 浏览器登录 token 鉴权),返回 401/403 时直接结束响应,**不读请求体、不碰 pluginManager**。宿主半因此 `inject` 里必须带 `connection`。少了这一步,本机任意进程或跨站页面都能读到插件清单,而 `/import` 是能往 profile 里装包的——这是实测出来的缺口(当时我们的路由无凭据返回 200,而官方插件同类路由一律 401)。 `POST /qr` 的约定: - 入参是 `{ code }`;先去掉所有空白再解码校验,**只有合法的 D1 码才会被编码**,因此扫出来的文本一定是能直接粘回去的码; - 依次尝试 M、L 两个纠错级别,取第一个放得下的;都放不下返回 413 `code-too-long-for-qr`(v40-L 上限 2953 字节); - 出错分支固定为 `code-required` / 解码器的原因 / `code-too-long-for-qr`; - 返回 `{ ok, version, size, level, mask, modules }`,`modules` 是 `size` 行、每行 `size` 个 `0`/`1` 的字符串数组,不含静区。渲染交给客户端,Host 不产出图片。 `src/shared/qr.mjs` 是自己实现的字节模式二维码编码器(零依赖、离线): - 只支持字节模式——D1 码是 ASCII,够用;字符计数位按版本选 8 位或 16 位; - 采用标准的 RS 分块表(40 版本 × L/M/Q/H)与对齐图形表,Reed-Solomon 在 GF(256) 上算,格式信息用 BCH(15,5)、版本信息用 BCH(18,6); - 掩码按规范的四条罚分规则在 0–7 中择优; - 注意纠错级别的两套编号不能混用:RS 分块表的下标顺序是 L,M,Q,H,而格式信息里携带的两位级别码是 L=01、M=00、Q=11、H=10。 `src/shared/qr-scan.mjs` 是配套的扫码识别,同样是自研、零依赖、离线: - **支持的输入**:正对、干净的图片——本插件保存的 PNG/SVG 截图、聊天里收到的二维码截图、整屏截图里的一小块。**不支持**斜拍照片、明显模糊或残缺的图片;不支持时返回 `qr-not-found` / `qr-unreadable`,绝不猜。 - 图像侧:Otsu 阈值二值化 → 逐行找 1:1:3:1:1 的定位图形并做垂直复核与聚类 → 用三个定位中心建立仿射基,按模块采样出矩阵。仿射基顺带容忍轻微旋转与缩放。 - 编码侧:读数据位时**复用编码器的 `dataModules(version)` 遍历顺序**,两个方向不可能对不上;然后对 4 个纠错级别 × 8 个掩码共 32 种组合逐一反交织解出字节流,取第一个能通过 D1 校验的结果。 - **不做 Reed-Solomon 纠错**是有意的:干净图片不会出错,而误读由组合码自带的 CRC32 兜底——坏图只会得到「识别失败」,不会得到一个能装出去的错码。 - 入参是 `{ width, height, gray }`,`gray` 是 base64 的每像素 1 字节灰度;像素上限 4M,请求体上限 8 MB。图片灰度化与缩放(长边 ≤ 1400)在浏览器里完成,**图片本身不上传任何地方**。 - 返回 `{ ok, code, version, level, mask, entries }`;失败返回 `qr-not-found` / `qr-unreadable` / `qr-image-too-small` / `qr-image-invalid` / `qr-not-a-share-code`(后者附带认出的原文,便于用户判断)。 客户端把「扫码导入」做成一个 `Menu`(官方 primitives):菜单里是「选择图片…」,面板本身接受拖放;识别成功后自动写入文本框**并立即解析**,不需要用户再点一次解析。 `previewEntries` 为每一行分配稳定的 `index`,并显示来源、安装 spec、校验结论和动作:`skip`、`set-enabled`、`install` 或 `upgrade`。被拒绝的条目保留在预览中并标记原因,不会混入可安装项。 单行应用的协议约定: - `/import` 的 `only` 只接受整数索引数组;非法类型返回 `only-invalid`,选择结果为空返回 `selection-empty`,两种情况都不触碰 profile; - `selectPreview(preview, only)` 负责过滤,未传 `only` 时等同于全量应用,因此「全部安装」与单行安装走同一条代码路径; - 解析结果带 `selection: true` 作为宿主半的能力标志。客户端只有在看到该标志时才启用单行按钮,避免客户端已更新而宿主半仍旧时,把「安装一个」误当成「安装全部」执行。 `applyPreview` 串行调用: - 安装调用 `installBundle(spec, { enabled, requestId, approvedBuilds })`; - builtin 状态变化调用 `setBundleEnabled`; - 返回 `pendingBuilds` 且没有批准列表时立即停止,要求用户明确批准; - 失败或取消时停止,不静默继续; - 仅移除本次新安装的 bundle,并恢复本次改变过的 builtin enabled 状态; - 升级不自动调用破坏性的 `removeBundle`; - 报告 `restart-required` 等宿主结果,不自动重启 DSH。 ## 7. Web Client UI `src/client.js` 注册命名空间 `dshPluginShare`,提供中文和英文文本,并向 `settings.plugins.tab` 注入 `plugin-share` 标签页。 外观严格遵守 DSH 现有视觉语言,不自己造控件: - 控件一律使用官方 `@deepseek-ai/dsh-client-ui-primitives` 的 `Button`(`variant` = `primary` / `outline` / `ghost`,`size` = `md` / `sm`)和 `Tag`(`tone` = `outline` / `success` / `danger`),不手写按钮样式; - 布局样式放在本插件自己的 CSS 里,用 `--dsw-*` 主题 token(`--dsw-alias-bg-layer-1`、`--dsw-alias-border-l1/l2`、`--dsw-alias-label-*`、`--dsw-alias-state-*`),并以单个去重 `