dsh-mobile-plugin
AI-HUB 手机端的对端插件,同时充当无头 Cordis Host:它自带 Host 进程入口,提供 relay v2
隧道客户端、局域网代理,以及受限的 /api/mobile/v1/* 移动 API。本包不依赖任何桌面外壳或
Electron 运行时,node host.js 即可独立运行。
POST /api/mobile/v1/session/new 的 body 支持 workspaceId 或 cwd(二选一,前者优先):传 workspaceId 时宿主会把新会话 attach 到该项目账上,宿主项目列表下立即可见;仅传 cwd 时会话归入宿主「未分组」。
POST /api/mobile/v1/workspace/move-session 透传宿主 workspace.insertSessionBefore,移动端拖动会话排序会同步写入宿主工作区。
设备级实时兼容出口:GET /api/mobile/v1/events/stream(Bearer token)以 SSE 转发宿主 events.mux 的所有设备事件。每帧包装为 { type: "device/event", version: 1, eventId, sessionId?, rpcId?, payload };payload 保留原 mux 负载,旧的 /session/stream 与 relay 协议不变。session/event 的 eventId 为 ${sessionId}:${event.seq},其他帧使用连接内序号。工作区变更是否可见取决于宿主 mux 是否发布对应帧;当前插件没有额外伪造工作区事件。
仓库与发布
本仓库是插件的公开发布仓库,仓库根就是包根:别人从这里读代码,也从这里核对这份代码的说法。
隐私政策(App Store / Google Play 上架用的那份,描述手机 App 会经手哪些数据):PRIVACY.md。 技术支持 / 常见问题(上架时填的「技术支持网址」):SUPPORT.md。
开发和 App 客户端、中转服务器放在一起进行(一个私有 monorepo),所以这里的提交历史从拆分那天开始, 不是项目历史 —— 每次发版,把同一次改动一起推到这里。
- 仓库地址要指回本仓库:
package.json的repository字段是 npm 包与插件列表条目之间的唯一关联 (列表侧从注册表自动采集,条目里不许手写npm:)。改了它要等下一次npm publish才会体现在注册表上。 - 要被插件列表(awesome-dsh-plugin)收录:往那个仓库提一个数据文件
data/plugins/<owner>__<repo>.yml就行,一个文件即全部投稿;description.en必填,分类用remote。 硬门槛是package.json里声明dsh.bundle(只声明dsh.client会被 CI 拒)、仓库创建满 1 天、 以及本仓库打了dsh-plugintopic。那条「满 1 天」的检查会自己转绿(列表侧每 6 小时重跑一次), 当天看到红不用重提、也不用关了重开。 - 截图:市场详情页要图,就在
package.json旁边放一份screenshots.json,列 1-8 张本仓库内的图片路径 (相对路径不能跳出仓库;绝对地址只收 GitHub 托管的 https)。不声明也行,市场会自己从本 README 里找图。 - 描述会被对着代码核:条目里写的每个数字和 API 名字都要在代码里找得到。夸大是唯一会被打回的原因 —— 分类选得不准不会,维护者会直接改。
无头部署
部署到 Linux 服务器(无桌面环境)时的要点如下。
目录布局不能漏掉凭据库。 设备凭据库 dsh-mobile-credentials 就在本包目录内部(发布时它仍是独立的一个包,装进来后嵌在插件目录里),代码用包内相对路径引用它:
lib/impl.js:26 require('../dsh-mobile-credentials/lib/credentials.js')
lib/mobile/auth.js:15 require('../../dsh-mobile-credentials/lib/credentials.js')
/opt/dsh-mobile/
├── dsh-mobile-plugin/
│ └── dsh-mobile-credentials/ # 嵌在插件目录内,漏掉就启动即崩
└── dsh-mobile-server/
用 pnpm 装,不要加
--legacy-peer-deps。本包的
pnpm-lock.yaml是入库的依赖锁,而 pnpm 的autoInstallPeers会把 peer 冲突 一起解掉:cordis-plugin-hmr@1.0.19要@deepseek-ai/cordis@~4.0.4,锁里解析到的 正是 4.0.4。这份文档以前教的是
npm install --legacy-peer-deps。那个参数只是让 npm 跳过 peer 冲突检查,不是解决冲突;仓库里又没有package-lock.json,也就是说那次安装什么都 没锁住——每次干净安装解析出的树都可能不同。
cd dsh-mobile-plugin
pnpm install --frozen-lockfile # 锁文件与 package.json 不同步时直接失败,而不是悄悄升级
锁与
package.json的一致性由dsh-mobile-plugin/test/lockfile-sync.test.cjs守住。 真要让 cordis 对齐到别的版本,改package.json后跑一次pnpm install把锁更新进来。
启动与数据根:
DSH_HOME=/opt/dsh-mobile/.dsh DSH_MOBILE_PROFILE=mobile node host.js
# → dsh-mobile-plugin: mobile host ready (profile mobile)
DSH_HOME 决定数据根(profile、relay 配置、会话),不设则用 ~/.dsh;DSH_MOBILE_PROFILE
决定 profile 名(默认 mobile),文件生成在 $DSH_HOME/profiles/<name>/。
Web 端口固定 3080 且只绑 127.0.0.1(webserver 行的端口表达式是 ctx.webStartup.port ?? 3080,
而 host.js 传空命令行参数,因此 --port 无效)。无头模式的 web-runtime 行关闭了
openBrowser/printUrl,所以启动日志不会打印端口,这不是故障。
安装与更新
本包有两条完全不同的安装路径,别混用。
服务器 / 无头部署:启动器 rsync + 插件本体走 npm。(2026-10-11 改)那一台是两半,别混着看:
- 启动器:
host.js、两层 patch、config/、内嵌的凭据包。把仓库目录 rsync 到/opt/dsh-mobile/, 然后systemctl restart dsh-mobile-host—— 它只负责把宿主那棵树拼起来。 - 插件本体:装在 profile 里,用 pnpm 从 npm 装:
cd <DSH_HOME>/profiles/<profile> && pnpm add dsh-mobile-plugin@<版本>。宿主按绝对路径挂那份的lib/index.js(稳定外壳),所以这台面板里也有「检查 / 立即更新」,换代不用重启宿主。 profile 的autoInstallPeers: false是刻意的:peer 依赖(@deepseek-ai/*)由宿主自己的依赖树供, 两边必须解析到同一份 realpath,否则会出现两份 cordis(见lib/shell/manager.js的文件头)。 - 挂哪一份由
host.js现场判断(profile里有没有这个包,不看环境变量):装了挂那份外壳; 没装就退回源码树直挂(没有外壳,面板里更新不了,只能同步源码 + 重启宿主)。两种现场都会在 启动日志里打一行「挂载方式 = …」,npm run test:plugin:server会核对它。
桌面端:从 npm 装,更新交给 DSH 的插件市场。(2026-10 改的:此前是本包自带的 OTA + 「稳定外壳 + 可热替换的代」,那套已经删掉。)
- 包名
dsh-mobile-plugin,凭据库dsh-mobile-credentials内联在本包里(files收它), 所以只发一个包、只有一个版本号要管。 - 装/更新都在 DSH 里做:设置 → 插件(插件市场)里安装或更新本插件,底层就是 profile 里
跑一次 pnpm。装了之后「设置 → 移动端 → 打开配置页面」那一页的更新卡片会直接调市场的公共
更新接口(
/dsh-market/api/v1/*),能在页面上检查更新、开始更新、看进度,并在需要时请 市场代劳重启 DSH。 - 更新后不需要重启 DSH(2026-10-10 起):插件入口是一个稳定外壳,市场在 profile 里装完新版,
外壳会就地换到新代码(先把旧实现卸干净、再装新实现),面板那句「重启 DSH 才生效」不再是必须的。
代价与边界照实说:① 装了外壳的那一版本身仍需重启一次(旧版本入口就是实现本身,没有外壳可换),
从再下一版起才真正免重启;② 外壳自己(
lib/index.js+lib/shell/*)在进程启动时就被 Node 缓存了,改它要等下次启动 —— 面板会用shellChanged把这件事说出来,不让你猜;③ 客户端那半个文件 (设置菜单里的入口)仍然是渲染器直读安装目录的,如果它改了需要刷新浏览器页面(所以这一半刻意保持 不变,更新界面都放在宿主吐的面板里)。 - 自动更新(面板 → 插件更新卡片里那个开关,默认关):打开后每 10 分钟问一次 DSH 插件市场, 有新版就自动装好并立即切换,全程不需要重启。手动点「更新」走的是同一条路。
- 有一个前提要认:市场只从注册表更新。如果这台机器上的插件是本地目录 /
file:tgz 装的 (市场把它报成linked),自动更新会如实跳过并说明原因 —— 要享受这条链,得先在插件市场里按包名 重装一次,切到 npm 来源。 - 如果「设置 → 移动端 → 打开配置页面」打不开、页面吐一段
panel_missingJSON:那段文案会 写清是哪一种读不到 —— 「装完没重启」(旧实例还挂在这次安装删掉的那个目录上,最常见)、 「装了老版本」(0.1.47 及更早还没有这一页)还是「真没构建」(源码树里才会遇到)。三种解法 不同,别照着「没重启」的现场去跑 React 构建。同一句话也会进宿主 stderr。 - 从前那套的教训记在这里:热更新只换宿主侧的实现代,换不掉设置菜单那一半(渲染器直接从 安装目录读它),于是出现过「更新报成功、界面一动不动」,重启 DSH、重启 sidecar 都没用。 现在只有一条更新路径,这类「哪一半是旧的」不再存在。
发版流程(自己发一版更新)
- 抬版本号:
node scripts/bump-version.mjs(三段各0..999)。 - 改过
React/就先重建面板:cd React && pnpm run build。产物lib/panel/index.html是提交物,要和源码一起提交 —— 忘了重建,发出去的就是旧页面,而仓库里没有任何东西能 替你发现(守卫只比字节形状,不比新旧)。 - 自检:
npm test(其中panel-artifact管产物形状与「读不到时报什么」,publish-payload管发布清单收得住每个会被加载的文件);npm pack --dry-run扫一眼发了什么。 - 发布:
npm publish。 - 核对:
npm view dsh-mobile-plugin version。 - 到机器上更新:DSH → 设置 → 插件 里更新,然后完整重启 DSH。
发布这一步现在要你现场过二次验证(2026-10 起,别以为是配错了):
npm publish会打印一条https://www.npmjs.com/auth/cli/…链接(或直接开浏览器),用账号 绑定的安全密钥确认一下,它自己接着把包发出去。- 所以这一步必须能打开 www.npmjs.com:机房/代理出口 IP 会被它的反机器人拦成 403 (页面写「访问暂时受限」并带上你的 IP)。换个节点或直连再发。
- npm 已经关掉了「命令行建号」和「验证器 App(TOTP)」两条路:建号只能在网页上做,2FA 只能挂 安全密钥。这两件事上试命令是白费时间,认它就行。
- 别指望用「绕过 2FA」的令牌跳掉上面这一下(2026-10-11 实测):换上这把令牌之后
npm whoami能过,但写操作照样要求交互式验证(拿npm dist-tag add试的),而 npm 2026-07-31 的公告说这类令牌 2027-01 起连直接发布也会失去。要自动化发布得迁到 trusted publishing(OIDC)或 staged publishing,本项目的发布是人在开发机上发,这两条都不顺手。 - 想少点一次确认,可以建一把「允许绕过 2FA」的细粒度令牌少输几次验证:
npm token create --bypass-2fa --packages dsh-mobile-plugin --packages-and-scopes-permission read-write --expires 90(最长 90 天)。代价:这类令牌 2027-01 起不能再直接发布,到期或到点都得回到上面 那条交互式路径。 - 还有个死结记在这里:令牌不能指向还不存在的包(会报
You do not have access to the requested package(s)),所以第一版只能用浏览器验证发, 令牌是「包已经存在之后」才有的便利。
插件没有 GUI 外壳,配置入口是插件注册在 webServer 上的仅限本机访问(localOnly 守卫)
的 HTTP 端点。host.js 启动后,其 webServer.port 就是下面 URL 里的 <port>
(无头部署下固定 3080):
curl http://127.0.0.1:<port>/api/relay/status # 实例 ID、连接地址、开关状态
curl http://127.0.0.1:<port>/api/relay/pairing # 配对码 + 可直接扫码的 SVG 二维码
POST /api/relay/togglebody{"enabled":true|false}—— 开关远程控制POST /api/relay/regenerate—— 轮换配对码POST /api/relay/config—— 改写relayUrl/lanEnabled/lanPort/name/pairingPolicy(无 GUI 外壳时唯一的配置入口)POST /api/relay/reconnect—— 重新握手隧道与局域网代理
持久化配置在 mobile-remote-control 设置命名空间:enabled(默认 false)、relayUrl
(默认空,即只用局域网)、lanEnabled(默认 true)、lanPort(默认 18787)、name。
插件启动后不向控制台打印配对信息,只能经上述端点获取。
自动归档
插件默认启用 Host 侧自动归档:激活时扫描一次,此后每 12 小时扫描一次;项目内会话数超过 10 且最后更新时间超过 24 小时的非运行、非空白会话会被归档。归档只隐藏会话,不删除日志。
会话清理工具
插件注册模型工具 session_cleanup,可直接通过对话执行归档或永久删除。action: "archive" 按项目路径、闲置分钟数或会话 ID 筛选并隐藏会话;action: "delete" 只处理已经归档的会话,并且必须显式传入 confirm: true,删除会话日志和本地投影缓存。删除不可恢复,运行中、空白会话默认不会处理。
远程控制元数据
配对请求(relay 的 pairRequest 或局域网 POST /__lan_pair)可携带 device 对象,字段会原样转发并兼容旧版仅传 code 的请求。当前 App 发送 id、deviceId、name、platform、osVersion、appVersion,局域网代理与中转会补充服务端观察到的手机 ip。
配对授权策略
配对码校验始终由传输层完成(中转与局域网代理都用恒定时间比较,且中转对每实例每 IP 做 5 次失败锁定 60 秒);校验通过后,宿主端由 pairingPolicy 决定是否准入,两条链路都汇总到同一个 POST /api/relay/internal/approval:
| 取值 | 行为 |
|---|---|
code(默认) |
凭配对码放行 |
deny |
拒绝一切新配对 |
原来的桌面端弹窗确认随桌面端一并删除,宿主已无 UI 承接逐次确认,因此改为该策略;deny 与删除前的 fail-closed 行为完全等价。策略求值在 lib/relay/pairing-policy.js,不经过 sidecar worker,故未列入 worker 的 configure 白名单。走 deny 时每次决策都会写一行审计日志到 stderr。
/api/mobile/v1/status 和 /api/relay/status 返回宿主 os、osVersion、arch、appVersion;移动 token 的签发与校验仍严格绑定宿主 instanceId。
No comments yet. Be the first to write one.