给 DeepSeek Harness 写插件:按扩展名接管 Windows 右键打开

5 阅读6分钟

本文基于 DSH 0.2.1-rc.5,插件接口后续可能变化。

用 DSH 桌面版看文档的人,大概率遇到过这种事:

右键一个 .md,想用 Typora 打开,系统弹记事本;去 Windows 设置里把默认应用改好,过几天又被别的软件抢回去。

Windows 的文件关联是全局的,而我只想改 DSH 里的右键行为。于是我写了一个 DSH 插件 dsh-open-with:它在插件里维护一套「扩展名 → 程序」的映射,不修改系统注册表,也不抢 Windows 默认关联。

这篇文章记录一下插件的结构、Host 端实现、Windows 关联查询的坑,以及设置页两次返工的过程。

一、先看 DSH 插件的最小结构

DSH 插件本质上是一个带 dsh 字段的 npm 包,通过 bundle patch 挂进 profile:

{
  "name": "dsh-open-with",
  "version": "1.0.0",
  "type": "module",
  "main": "lib/index.js",
  "dsh": {
    "client": { "platform": "web" },
    "bundle": { "patch": "./cordis.patch.yml" }
  }
}

cordis.patch.yml 负责把插件插入 DSH 的 bundle 列表:

- insert:
    - id: open-with
      name: dsh-open-with

装进 DSH 桌面版 profile 后,需要完全退出再启动,刷新页面不会重新加载 bundle。

插件分两半:

  • lib/index.js:Host 端,负责配置读写、打开文件、查询 Windows 关联、弹文件选择框;
  • lib/client.js:客户端,负责右键菜单和设置页。

客户端通过两个 slot 挂载:

ctx.slots.inject("shell.overlay", () =>
  ctx.slots.register({
    name: "shell.overlay",
    id: "open-with",
    order: 70,
    locale: NS
  }, Overlay)
);

ctx.slots.inject("plugins.bundle.config", () =>
  ctx.slots.register({
    name: "plugins.bundle.config",
    key: "dsh-open-with",
    order: 0,
    locale: NS
  }, ConfigPage)
);

shell.overlay 用来画右键菜单,plugins.bundle.config 是插件详情页里的设置区域。

二、Host 端:配置、路由和打开文件

配置存在 $DSH_HOME/open-with.json,结构很简单:

{
  "version": 1,
  "apps": {
    "md": {
      "name": "Typora",
      "path": "C:\\Users\\you\\AppData\\Local\\Programs\\Typora\\Typora.exe"
    }
  }
}

写入用临时文件 + rename,避免写到一半断电留下半截 JSON:

const tmp = `${configFile}.tmp`;
await writeFile(tmp, JSON.stringify(state, null, 2), "utf8");
await rename(tmp, configFile);

路由通过 ctx.webServer.register 注册,统一先过 DSH 的连接鉴权:

export const inject = ["webServer", "connection"];

export function apply(ctx) {
  ctx.webServer.register({
    kind: "exact",
    path: "/open-with/handlers",
    handler: async (req, res) => {
      if (ctx.connection.requestRejection(req)) return;

      const ext = new URL(req.url, "http://localhost")
        .searchParams.get("ext") ?? "";

      const apps = await handlersFor(ext);
      res.writeHead(200, { "content-type": "application/json" });
      res.end(JSON.stringify({ apps }));
    }
  });
}

打开文件时,Host 直接 spawn 配置里的 exe,把文件路径作为第一个参数,不经过 Windows 默认关联解析。启动前做两件事:

  • 校验文件路径在 DSH 文件系统映射范围内;
  • 清理子进程环境里的敏感变量。

/open-with/open 只接受配置里存在的 exe,不接受客户端传任意命令。

三、关键坑:Windows 的“通用回退列表”

应用推荐来自 Windows Shell 的 SHAssocEnumHandlers,通过 @deepseek-ai/dsh-native-command 的 nativeFileApplications 调用:

import { nativeFileApplications } from "@deepseek-ai/dsh-native-command";

const apps = await nativeFileApplications("md");

问题在于:如果扩展名没有登记任何处理器,Windows 不会返回空数组,而是返回一个约 18 个应用的通用列表,里面混着网盘、压缩软件、播放器。用户看到“用坚果云打开 .zzz”会觉得插件在乱猜。

我先查了注册表,确认这个扩展名到底有没有关联类:

// 伪代码
async function hasRegisteredClass(ext) {
  const { code } = await run("reg.exe", ["query", `HKCR\\.${ext}`]);
  return code === 0;
}

async function handlersFor(ext) {
  if (!(await hasRegisteredClass(ext))) return [];
  return nativeFileApplications(ext);
}

效果对比:

扩展名修复前修复后
md20 个应用20 个应用
py21 个应用21 个应用
zzz18 个无关应用0 个
odp18 个无关应用0 个

没有登记时,插件显示“没有找到可打开 .xxx 的应用”,让用户手动填 exe 路径,而不是推荐一堆无关程序。

查询结果按扩展名缓存 5 分钟,避免每次打开下拉都调一次 Shell API。

四、客户端:右键菜单和设置页

右键菜单挂在文件卡片上,主要提供这些动作:

  • 用 X 打开:读插件配置,没有配置时回退系统默认;
  • 打开方式…:插件应用 + 系统关联应用;
  • 在文件夹中显示;
  • 复制路径;
  • 在侧栏预览;
  • 打开方式设置…。

设置页是这次返工最多的地方。

第一版:表格塞进插件详情页

第一版把「扩展名、应用、路径」做成表格,直接放在插件详情页里。功能没问题,但在 31.5 寸 4K 屏上很难看:

  • 插件详情页是阅读宽度,正文列约 650px;
  • 表格四列挤在 650px 里,路径被截断;
  • 屏幕两侧大量空白,表格却不够用;
  • 扩展名下拉只有 300px,类型筛选按钮换行。

第二版:摘要 + 宽抽屉

后来改成:

  • 插件页只显示摘要:已配置数量、前几个映射、打开设置;
  • 点「打开设置」或右键「打开方式设置…」打开一个宽抽屉;
  • 抽屉宽度 min(1320px, 100vw - 280px),不再受插件详情页 max-width 限制;
  • 抽屉里是完整表格,Esc、✕、点遮罩都能关。

这样不需要覆盖 DSH 父容器的宽度,也不会因为 DSH 升级改布局而错位。

配图 1:右键菜单。 配图 2:宽抽屉设置页。

字号放大与遮挡

抽屉里加了 Aa 缩放:100% / 110% / 120% / 130%,默认 120%。

一开始只放大字体,结果列宽、按钮、下拉面板没跟着变,反而更挤。后来用整体 zoom,让文字、列宽、行高、按钮、浮层同比缩放,才保持一致。

另一个问题是批量新增:原来是一个浮在表格右上的弹层,打开后会遮住路径列。改成标题下方全宽内联面板,表格整体下移,面板内部滚动,底部“取消/添加”固定可见。

配图 3:批量新增 3D 格式的内联面板。

自动保存

设置页没有「保存」按钮:

  • 下拉选择、删除立即保存;
  • 路径输入停止 600ms 后保存;
  • 只保存“扩展名 + 绝对路径”完整的行;
  • 缺应用/路径的行是草稿,不写入配置,标题行显示「N 行待完成」。

这样既不会频繁写盘,也不会把半成品写进配置。

五、两个 Windows 坑

1. 中文文件名和 PowerShell 5.1

安装脚本里要复制 安装与使用说明.pdf 这样的中文文件名。脚本如果保存成 UTF-8 无 BOM,Windows PowerShell 5.1 会按 ANSI 读取,中文文件名直接乱码,Test-Path 找不到文件。

解决:install.ps1 保存为 UTF-8 with BOM。PowerShell 7 也兼容。

2. 子进程与测试沙箱

测试时遇到 spawn EPERM,排查后发现不是插件逻辑问题,而是测试用的 DSH 服务跑在受限沙箱里,子进程管道被拦住。换成正常的桌面环境运行就正常了。

如果以后做类似插件,测试环境要跟真实桌面环境保持一致,否则这类错误很容易误判。

六、安装与分发

插件只支持 Windows 10/11 + DSH 桌面版,不需要管理员权限。

安装:

powershell -ExecutionPolicy Bypass -File .\install.ps1

完全退出并重新打开 DSH 后,插件列表里会出现「文件打开方式 v1.0.0」。

当前 DSH 没有官方插件市场,所以主要通过本地 bundle + 安装脚本分发;DSH 也有实验性的自定义插件市场索引,后面可以再接一个 market-index.json。

卸载:

powershell -ExecutionPolicy Bypass -File .\install.ps1 -Uninstall

配置文件 open-with.json 不会被删除,需要的话手动清理。

七、项目地址

  • Gitee:https://gitee.com/leecmd786567/dsh-open-with
  • 许可证:MIT
  • 版本:V1.0.0

代码不多,欢迎提 Issue 或者直接看实现。如果也在用 DSH,遇到过 Windows 默认关联被改的问题,这个插件应该能省点事。