dsh-open-workspace-any-app
在 DeepSeek Harness 的会话头部加一个「用其它应用打开」按钮:把当前工作区目录交给任意应用 —— Zap、Qoder IDE、IntelliJ IDEA、Obsidian、HBuilderX,或任何你能写出启动方式的东西。
它和内置的 open-in-app 控件并排共存,互不遮挡。
交互
按钮上就是这个应用的图标。 不用展开菜单、不用看提示文字,一眼知道点下去会开哪个应用。
记住上一次的选择。 你上次用谁打开,下次按钮就是谁(跨会话、跨浏览器重启)。选中的那项在菜单里带勾。
在界面里增删应用。 菜单底部「管理应用…」打开一个对话框:
- 已经装好但还没进菜单的应用会列在「发现」区里,点一下即加入 —— 不需要知道
.app路径。 - 每一项都有 移除 按钮 —— 包括
cordis.patch.yml里声明的应用。 - 移除的应用进入「已移除」区,可以一键放回。
- 已经装好但还没进菜单的应用会列在「发现」区里,点一下即加入 —— 不需要知道
对话框是固定视窗,不随列表变高。 卡片最高 520px 且不超过窗口,列表在卡片内滚动 —— 一台机器装着几百个应用,「发现」列表默认只显示前 6 条,其余靠搜索。
顶部有搜索框。 三段列表(菜单中 / 已移除 / 发现)一起被过滤:输入关键字后不再截断,匹配到的全部列出;清空就回到 6 条。
增删都立刻生效,不需要重启 DSH。
Open the workspace directory in any app you declare, beside the built-in open-in-app control.
为什么需要这个插件
DSH 自带的 @deepseek-ai/dsh-host-open-in-app 解析的是一张构建期固定的目录表:
OPEN_IN_APP_CATALOG是模块级常量,没有出现在包的exports里,外部import不到;- 解析器读取主机事实要经过模块私有的
internals缝(lib/types/internals.js里是{ catalog: {} },生产环境刻意留空),插件无法注入; - 它的
Config只接受三个超时字段,没有应用列表字段。
所以内置表里没写的应用(Zap、Qoder IDE、HBuilderX、Obsidian)既不能靠配置追加,也不能被插件从外部扩展。
插件能做的是:在旁边挂自己的路由、在会话头部注册自己的格子。这正是本包的做法。
| 本插件 | 改内置白名单 | |
|---|---|---|
| 你的编辑器能出现 | ✅ 独立按钮,列出你声明的全部应用 | ✅ 融进内置下拉 |
| 改动官方安装包 | ❌ 不动 | ✅ 要改 asar |
| 破坏代码签名 | ❌ 不破坏 | ✅ 破坏 |
| DSH 升级后 | ✅ 自动保留 | ❌ 每次重做 |
| 与内置菜单的关系 | 两个控件并列 | 一个控件 |
安装
在 DSH 的插件页安装本包,或让 Agent 通过 plugin_manager 安装。装完重启 DSH(新装的 JS 模块需要重新加载一代)。
安装后:
package.json的dsh.bundle.patch指向cordis.patch.yml,Profile 会把它作为一层 patch 应用;- patch 里的
insert把dsh-open-workspace-any-app这一行加进 Profile; package.json的dsh.client声明让浏览器端发现exports["./client"]这个客户端产物。
配置
应用列表是数据,写在 cordis.patch.yml 的 config.apps 下,或你在 ~/.dsh/profiles/desktop/cordis.patch.yml 里覆盖。
- id: open-workspace-any-app
name: dsh-open-workspace-any-app
config:
launchWatchMs: 1000
apps:
- id: zap # 稳定 id:启动请求和图标 URL 都用它寻址
label: Zap # 菜单显示名;省略则用 id
locators: # 按顺序尝试,第一个在此机器上验证通过的胜出
- kind: app # app = 按绝对路径的应用程序包
paths:
- /Applications/Zap.app
- ~/Applications/Zap.app
定位器(locator)
| kind | 含义 | 默认启动方式 |
|---|---|---|
app |
绝对路径下的应用程序包(macOS .app、Linux 应用目录) |
open:macOS 用 open -a <bundle> <dir>,按身份交给 LaunchServices |
file |
绝对路径下的可执行文件 | argv:直接运行,目录进 argv |
cli |
通过 PATH 解析的命令(ctx.subprocess.resolveExecutable) |
argv |
app-paths |
Windows 注册表 App Paths(先查 HKCU,再查 HKLM) |
argv |
第一个验证通过的定位器胜出——所以「优先用 CLI,找不到再退回应用包」这样写就行:
- id: idea
label: IntelliJ IDEA
iconBundle: /Applications/IntelliJ IDEA.app
locators:
- kind: cli
command: idea # 优先:启动脚本,天然接受目录
- kind: app
paths: [/Applications/IntelliJ IDEA.app]
目录怎么传给应用
launch 省略时按定位器决定:app → open(按身份打开),其余 → argv(可执行文件)。
argv 形式下:
- 声明的参数里没有
{path}→ 目录追加到末尾(编辑器 CLI 想要的形状):idea <dir>; - 某个参数含
{path}→ 在该处替换:--cd={path}→--cd=/work/project。
列表顺序 = 菜单顺序。label 省略时用 id。
platforms 覆盖
platforms 里的某个平台整体替换 locators(而不是合并)——和内置目录表把「每平台 spec」当作该平台的完整答案一致:
- id: editor
label: Editor
locators:
- kind: app
paths: [/Applications/Mac.app]
platforms:
linux:
- kind: cli
command: editor
图标
iconBundle:包路径,取其.icns转成 128px PNG(plutil+sips,macOS 自带);iconPath:直接提供的图片文件,按扩展名给 media type 原样送出;app定位器在 macOS 上默认用自己的包当图标来源。
图标是装饰:取不到就 404,浏览器画一个通用字形,不会让菜单项消失。
安全
每个路由都先向组合的 connection 服务要一次拒绝判定(requestRejection),和内置控件一致:
- Host/Origin 栅栏挡住 DNS rebinding 与跨站调用;
- 浏览器认证(登录 token cookie)在任何目录、图标、启动可达之前就拦下调用方。
在此之上,启动路由在线缆层再校验一次请求体:application/json、64 KiB 上限、app/path 必须是字符串、id 必须已解析、路径必须是绝对路径且指向真实存在的目录。所以不可信的调用方无法把这个路由变成「运行任意路径」。
connection 是必需注入:缺了它就是一个没有栅栏的「启动本地应用」端点。(缺少时路由一律 503,而不是无栅栏放行。)
启动的子进程拿到抹掉凭据的环境:*KEY*/*PASSWORD*/*SECRET*/*TOKEN*、DSH_*、NODE_OPTIONS、NODE_PATH 都不会传下去。
行为细节
- 一次解析,长期复用:目录在第一次需要它的请求时解析一次,之后的菜单打开、页面刷新都不再重新探测。
- 装了什么才显示什么:某台机器上解析不出来的条目,直接不出现在菜单里——一份配置可以服务多台机器。
- 单个条目坏掉不拖垮菜单:某个定位器抛错(路径不可读、seam 异常)只让那一条按「未安装」处理。
- 解析有预算:
resolveBudgetMs(默认 15s)到点就用已完成的部分作答,并在响应里带truncated: true,而不是无限等下去。 - SSH 会话不探测:远端没有本地 GUI 可开,目录直接为空。
- 启动失败只在观察窗内判定:子进程在
launchWatchMs(默认 1s)后仍在运行 → 计为启动成功并unref(编辑器整个窗口生命周期都在前台);窗口内非零退出 → 502。
配置项
| 字段 | 默认 | 含义 |
|---|---|---|
apps |
[] |
声明的应用,按菜单顺序 |
probeTimeoutMs |
10000 |
单个定位器解析命令的超时 |
iconTimeoutMs |
10000 |
图标提取命令的超时 |
launchWatchMs |
1000 |
每次启动的早期失败观察窗 |
resolveBudgetMs |
15000 |
单次解析总预算 |
storeDir |
<DSH home>/open-workspace-any-app |
界面添加的应用存放目录(测试/隔离用) |
重复的 id 会在组合期直接报错(而不是让后一个静默不可达)。
无头验证
npm test # 等价于 node --test 'tests/*.test.js'
npm run verify:geometry # 可选:用无头 Chrome 量对话框的真实布局
覆盖:定位器链与平台覆盖、~/${VAR} 展开、启动 argv 构造(追加 vs {path} 替换)、三种 open/argv 启动路径、解析预算截断、三条路由的注册路径与全部拒绝分支、栅栏缺失/拒绝/放行、重复 id 报错、客户端产物的注册契约与双语字典平衡,以及真实 HTTP 请求驱动的真实进程启动(/usr/bin/true 成功、/usr/bin/false 得 502、不存在的目录得 404)。
对话框另有断言:长扫描在空搜索时只画 6 行并提示剩余数量、短列表不提示、查询同时过滤三段并解除截断、查无结果与扫描失败各自的文案、空列表不画搜索框、卡片带着 max-height 上限与可滚动正文。客户端测试用的 React 替身遵守依赖数组(否则扫描 effect 每次渲染都重跑、搜索框刚输入就被清空,那是浏览器里不会出现的状态),client.render.test.js 则用真 React + react-dom/server 渲染,确保元素树与 hook 元数没漂。
几何是另一回事,上面两条都管不到它:一张画了 200 行、max-height: none 的卡片,元素树每个属性都对,只是布局不对;node --test 没有布局引擎。所以 npm run verify:geometry 用无头 Chrome 量真实布局 —— 它从已安装的 DSH 里取出真实设计令牌、真实的 Modal/Input 样式表,再执行 lib/client.js 拿到它真正注入的那张样式表,量 4 种视口 × 9 条断言(卡片有上限、卡片在窗口内、按钮与搜索框在屏上、正文可滚动、搜索框吸顶、正文可收缩、滚动条归正文、搜索框撑满)。卡片高度从 7611px 变成 520px。
这个脚本是可选的(要 Chrome + 装好的 DSH),不进 npm test。它的断言做过反向验证:把 max-height 与 overflow-y 去掉,14 条断言立刻失败。
两个坑记在这里:--dump-dom 早于异步帧完成,量尺寸要同步 + 强制回流,别用 requestAnimationFrame 链(否则会把正常工作的 sticky 判成坏的);读 asar 的头部长度要对齐到 4 字节边界(16 + align4(headerSize)),差 2 字节就会让每个样式表开头多一个字符,浏览器随即丢掉第一条规则,量出来的 max-height 就成了 none。
为什么依赖面这么窄
本包只 import 一个主机依赖 @deepseek-ai/schemastery(用于 Config schema),其余主机能力都通过 cordis 上下文取:webServer、connection、subprocess。
这不是洁癖。插件从它被安装进的 Profile 解析 @deepseek-ai/*,而那个 Profile 未必是运行中的应用启动时用的那棵树——两者可以是不同版本。多一个跨包 import,就多一次版本错位让插件在 import 阶段直接失败的机会。所以本包把本来会 import 的两个能力各用几行自己实现了:
scrubbedParentEnv()—— 启动子进程的环境清洗;launchedThroughSsh()—— SSH 会话判定。
交互实现
记住选择
用平台自己的快照 store 持久化,写到 localStorage 的 dsh.open-workspace-any-app.choice:
store.createSnapshotStore('', { persist: { name: 'dsh.open-workspace-any-app.choice' } })
这是内置控件同款机制(它用 dsh.open-in-app.choice)。存储不可用时(隐私模式、配额)自动退化成内存态,不会抛错。
只有主机确认接受的启动才会被记住 —— 把一个失败的启动写进存储,会让下次打开时按钮指向一个坏掉的应用。
被记住的应用如果已经不在菜单里了(比如你把它移除了),按钮回退到第一个可用应用,而不是变空。
内置控件在这里有个坑:它把持久化的 id 拿
APP_LABEL_KEY硬编码字典过滤,未知 id 会被静默丢弃。本插件不过滤,所以自己加的应用不会被吞掉。
增删应用
添加走对话框里的扫描列表:GET /discover 扫描 /Applications、/System/Applications、~/Applications,会排除掉已经解析成功的应用,所以「可加的」和「菜单里已有的」不重叠。「发现」区里每一行自带一个添加按钮,点一下即加入 —— 不需要知道 .app 路径,也没有平台差异。
删除是这个版本修掉的核心问题。它记录为「隐藏」,而不是去改配置:
- 早期版本只允许删除界面添加的应用,结果是最可能在屏幕上的那些(profile 自带的 5 个声明应用)一个都删不掉。菜单项能不能删,是关于菜单的问题,不是关于这个条目写在哪儿的问题。
- 所以删除统一记为 id 列表
hidden。手写的配置从不被改写(插件改写操作员的文档,下次手工编辑就得和机器写的行做合并),而且这样删除是可逆的 —— 对话框能把它们放回。
存储文件 <DSH home>/open-workspace-any-app/apps.json:
{
"version": 2,
"apps": [{ "id": "obsidian", "label": "Obsidian", "path": "/Applications/Obsidian.app" }],
"hidden": ["qoder"]
}
apps 是界面添加的,hidden 是要从菜单隐藏的 id(来自任一来源)。版本 1 只有 apps,读到时升级为「没隐藏任何东西」,而不是丢弃。
两份来源在读取时合并(mergeCatalog):手写条目在前且保持顺序;界面条目的 id 或包路径若已被手写条目占用则丢弃 —— 手写的那行优先,绝不静默遮蔽。
移除一个来源里已经没有的 id 是无害的,也不会出现在「可恢复」里(没有东西可恢复)。
图标
iconBundle:包路径,取其.icns转成 128px PNG(plutil+sips,macOS 自带);iconPath:直接提供的图片文件,按扩展名给 media type 原样送出;app定位器在 macOS 上默认用自己的包当图标来源。
图标是装饰:取不到就 404,浏览器画一个通用字形,不会让菜单项消失。
安全
每个路由都先向组合的 connection 服务要一次拒绝判定(requestRejection),和内置控件一致:
- Host/Origin 栅栏挡住 DNS rebinding 与跨站调用;
- 浏览器认证(登录 token cookie)在任何目录、图标、启动可达之前就拦下调用方。
在此之上,启动路由在线缆层再校验一次请求体:application/json、64 KiB 上限、app/path 必须是字符串、id 必须已解析、路径必须是绝对路径且指向真实存在的目录。所以不可信的调用方无法把这个路由变成「运行任意路径」。
connection 是必需注入:缺了它就是一个没有栅栏的「启动本地应用」端点。(缺少时路由一律 503,而不是无栅栏放行。)
启动的子进程拿到抹掉凭据的环境:*KEY*/*PASSWORD*/*SECRET*/*TOKEN*、DSH_*、NODE_OPTIONS、NODE_PATH 都不会传下去。
行为细节
- 一次解析,长期复用:目录在第一次需要它的请求时解析一次,之后的菜单打开、页面刷新都不再重新探测。
- 装了什么才显示什么:某台机器上解析不出来的条目,直接不出现在菜单里——一份配置可以服务多台机器。
- 单个条目坏掉不拖垮菜单:某个定位器抛错(路径不可读、seam 异常)只让那一条按「未安装」处理。
- 解析有预算:
resolveBudgetMs(默认 15s)到点就用已完成的部分作答,并在响应里带truncated: true,而不是无限等下去。 - SSH 会话不探测:远端没有本地 GUI 可开,目录直接为空。
- 启动失败只在观察窗内判定:子进程在
launchWatchMs(默认 1s)后仍在运行 → 计为启动成功并unref(编辑器整个窗口生命周期都在前台);窗口内非零退出 → 502。
配置项
| 字段 | 默认 | 含义 |
|---|---|---|
apps |
[] |
声明的应用,按菜单顺序 |
probeTimeoutMs |
10000 |
单个定位器解析命令的超时 |
iconTimeoutMs |
10000 |
图标提取命令的超时 |
launchWatchMs |
1000 |
每次启动的早期失败观察窗 |
resolveBudgetMs |
15000 |
单次解析总预算 |
storeDir |
<DSH home>/open-workspace-any-app |
界面添加的应用存放目录(测试/隔离用) |
重复的 id 会在组合期直接报错(而不是让后一个静默不可达)。
无头验证
npm test # 等价于 node --test 'tests/*.test.js'
npm run verify:geometry # 可选:用无头 Chrome 量对话框的真实布局
覆盖:定位器链与平台覆盖、~/${VAR} 展开、启动 argv 构造(追加 vs {path} 替换)、三种 open/argv 启动路径、解析预算截断、三条路由的注册路径与全部拒绝分支、栅栏缺失/拒绝/放行、重复 id 报错、客户端产物的注册契约与双语字典平衡,以及真实 HTTP 请求驱动的真实进程启动(/usr/bin/true 成功、/usr/bin/false 得 502、不存在的目录得 404)。
对话框另有断言:长扫描在空搜索时只画 6 行并提示剩余数量、短列表不提示、查询同时过滤三段并解除截断、查无结果与扫描失败各自的文案、空列表不画搜索框、卡片带着 max-height 上限与可滚动正文。客户端测试用的 React 替身遵守依赖数组(否则扫描 effect 每次渲染都重跑、搜索框刚输入就被清空,那是浏览器里不会出现的状态),client.render.test.js 则用真 React + react-dom/server 渲染,确保元素树与 hook 元数没漂。
几何是另一回事,上面两条都管不到它:一张画了 200 行、max-height: none 的卡片,元素树每个属性都对,只是布局不对;node --test 没有布局引擎。所以 npm run verify:geometry 用无头 Chrome 量真实布局 —— 它从已安装的 DSH 里取出真实设计令牌、真实的 Modal/Input 样式表,再执行 lib/client.js 拿到它真正注入的那张样式表,量 4 种视口 × 9 条断言(卡片有上限、卡片在窗口内、按钮与搜索框在屏上、正文可滚动、搜索框吸顶、正文可收缩、滚动条归正文、搜索框撑满)。卡片高度从 7611px 变成 520px。
这个脚本是可选的(要 Chrome + 装好的 DSH),不进 npm test。它的断言做过反向验证:把 max-height 与 overflow-y 去掉,14 条断言立刻失败。
两个坑记在这里:--dump-dom 早于异步帧完成,量尺寸要同步 + 强制回流,别用 requestAnimationFrame 链(否则会把正常工作的 sticky 判成坏的);读 asar 的头部长度要对齐到 4 字节边界(16 + align4(headerSize)),差 2 字节就会让每个样式表开头多一个字符,浏览器随即丢掉第一条规则,量出来的 max-height 就成了 none。
为什么依赖面这么窄
本包只 import 一个主机依赖 @deepseek-ai/schemastery(用于 Config schema),其余主机能力都通过 cordis 上下文取:webServer、connection、subprocess。
这不是洁癖。插件从它被安装进的 Profile 解析 @deepseek-ai/*,而那个 Profile 未必是运行中的应用启动时用的那棵树——两者可以是不同版本。多一个跨包 import,就多一次版本错位让插件在 import 阶段直接失败的机会。所以本包把本来会 import 的两个能力各用几行自己实现了:
scrubbedParentEnv()—— 启动子进程的环境清洗;launchedThroughSsh()—— SSH 会话判定。
交互实现
记住选择
用平台自己的快照 store 持久化,写到 localStorage 的 dsh.open-workspace-any-app.choice:
store.createSnapshotStore('', { persist: { name: 'dsh.open-workspace-any-app.choice' } })
这是内置控件同款机制(它用 dsh.open-in-app.choice)。存储不可用时(隐私模式、配额)自动退化成内存态,不会抛错。
只有主机确认接受的启动才会被记住 —— 把一个失败的启动写进存储,会让下次打开时按钮指向一个坏掉的应用。
被记住的应用如果已经不在菜单里了(比如你把它移除了),按钮回退到第一个可用应用,而不是变空。
内置控件在这里有个坑:它把持久化的 id 拿
APP_LABEL_KEY硬编码字典过滤,未知 id 会被静默丢弃。本插件不过滤,所以自己加的应用不会被吞掉。
新增应用
对话框通过 GET /plugins/dsh-open-workspace-any-app/discover 拿到扫描结果(/Applications、/System/Applications、~/Applications),排除掉已经解析成功的应用,所以「可加的」和「菜单里已有的」不会重叠。
添加写入 <DSH home>/open-workspace-any-app/apps.json,不写 cordis.patch.yml:
- 那是操作员手写的文档。让一次界面点击获得改写它的权限,下次手工编辑就得和机器写的行做合并;
- 而且「我在这台机器上装了哪些应用」是关于机器的事实,不是关于某个 profile 组合的事实。
两份来源在读取时合并(mergeCatalog):手写条目在前且保持顺序;界面添加的条目里,若 id 或包路径已被手写条目占用则丢弃 —— 手写的那行优先,绝不静默遮蔽。
添加后会立即重建目录,无需重启。移除只能移除界面添加的条目;手写条目拒绝移除(404),因为那属于 cordis.patch.yml。
对话框为什么是固定视窗
问题:这个对话框列的是「本机装了什么」,而一台机器有几百个 bundle。早期版本把它们全部渲染进一张按内容撑高的卡片里 —— 卡片于是比窗口还高,不仅列表项滚不到,连卡片底部的按钮也一起跑到屏幕外,而且没有任何滚动条。实测(1280×633 视口、200 个候选):卡片高 7611px,max-height 为 none,页脚与搜索框都在视口外。
修法,三件事一起才成立:
- 卡片有上限:
.dsh-open-any-app-dialog { max-height: min(520px, 100%) }。100%相对的是Modal图元的 padding box(它给了一个撑满视口的 flex 容器、四周 24px 内边距),所以卡片永远不会高过窗口。这与@deepseek-ai/dsh-client-ui-plugin-manager的安装对话框是同一种写法(max-height: min(800px, 100%))。 - 正文要能收缩:只在卡片上设上限不够 —— flex 子项默认
min-height: auto,内容会把卡片顶破上限。所以正文那层是min-height: 0,再加overflow-y: auto,溢出才变成滚动条而不是被裁掉。 - 列表要截断:
GET /discover最多返回 200 条,全画出来没有意义,「发现」段在搜索框为空时只画前 6 条,并用一行小字说明还剩多少、可以输入关键字。输入任何查询就解除截断 —— 此时你已经说了要找什么。
实测同一场景:卡片 520px,完整落在窗口内,页脚与搜索框都在视口里,正文可滚动(scrollHeight 579 > clientHeight 440),每一行都够得着。
搜索框吸顶(position: sticky)在正文顶部,所以列表往下滚时它不会跑掉。它过滤的是三段一起:一个人找应用时知道的是名字,不是它恰好落在哪一段。查询匹配不到任何东西时,文案是「没有匹配……的应用」而不是「没有发现新应用」—— 后者会把一次失败的搜索说成一台没有装东西的机器。
失败的扫描也单独说:GET /discover 报错时显示「无法读取本机应用」,不再冒充「没有发现新应用」。
样式表由 bundle 注入一张 <style> 标签(内联 style 写不了 :hover、滚动条和 placeholder)。注入点在 factory 作用域,不是 apply() —— 这一点是功能性的:加载器在 factory 返回后立刻用 claimStyles 认领本插件的 style 标签(给未标记的标签盖上包名),重载时只删它认识的标签。在 apply() 里创建就晚了一步,标签永远无主,每次重载都会留下一份陈旧副本。所以标签创建时就自报家门(data-plugin + data-plugin-css),和编译产物的 CSS-module bundle 走的是同一条路。
图标
按钮面用 <img> 指向本插件自己的图标路由(/plugins/dsh-open-workspace-any-app/icon/<id>),不是内置控件那个。解析不到图标时回退成通用字形,不会出现破图。
已知限制
- 应用必须能通过绝对路径或
PATH找到。装在非标准位置、或包名与声明不符的应用,请直接把真实路径写进paths。 - 图标提取依赖 macOS 的
plutil/sips;其他平台请用iconPath提供图片。 - 界面新增的应用立刻生效,不需要重启。但改
cordis.patch.yml里手写的条目需要重启:主机端模块按 URL 缓存,DSH 的 HMR 默认忽略**/node_modules(插件正是从 profile 的node_modules软链进来的),所以配置层能热重载、插件代码本身不能。 - 界面添加只支持 macOS 应用包(
.app目录):扫描列表枚举的是 bundle。kind: cli、{path}参数这类高级写法仍然要写进cordis.patch.yml。 - 同时只能有一个控件占用
conversation.session.header.utilities里id: 'open-any-app'这个格子;重复注册同 id 会被槽位拒绝。 - Windows 的
app-paths定位器需要reg.exe;非 Windows 平台上该定位器直接判为不存在。
许可
MIT
No comments yet. Be the first to write one.