dsh-plugin-mobile-bridge
给 DeepSeek Harness 用的手机端桥接插件:
在手机上导入图片、下发任务、实时看输出,从同一个 WiFi、USB 共享、或者外网隧道都行。
在电脑上跑 DSH,在手机上用。不用装 App,打开浏览器就是控制台。
它能做什么
- 手机直接发图(截图、拍的照片)给 DSH,不用再经过微信/QQ 中转;动图、HEIC/BMP 等格式、视频也能发(图片先在手机上压到长边 1600,大文件分片上传)
- 「文件」键:任意文件都能传(PDF / 文档 / 压缩包 / 代码 / 音频…),不限制类型 —— 原样传到电脑,agent 拿到的是路径(模型看不了这些格式,但 agent 能用工具读它、解压、转码、跑脚本)。一次最多 3 个(宿主的上限;手机上会拦住第 4 个并说清原因,不会让它悄悄消失)
- 电脑给的素材在手机上直接看、直接存(agent 交付的「交付物」也会列出来:图片给缩略图):对话里出现图片/视频路径(脚本截的图、
image_generate 出的图、导出的视频…)会自动变成缩略图,点开全屏,长按就能"存储到照片";附件也走真 URL(data: 在 iOS 上长按没有"存储到照片")
- 自定义背景(手机上自己换):点工具栏「背景」→ 选一张图片 / 动图(GIF)/ 视频 → 传到电脑、当场铺满整个手机界面,不用碰电脑也不用重载插件;「去掉背景」一键还原。4.7.0 起面板里还多一个自带的
🌀 赛博朋克:纯 CSS 画的(霓虹地平线 + 透视网格 + 扫描线),不用传图、不占流量、任何分辨率都清晰;4.8.0 起它默认就在动,4.8.1 把动态感做足(六层:光晕漂 / 网格快速流 / 斜落的霓虹雨 / 地平线呼吸 / 扫描线 / 偶发故障闪,而且压暗层让开、气泡更透 —— 隔着气泡就看得见在动),配色跟着换成霓虹青 + 品红,面板上还有 🐢 让它静下来 的刹车(会把所有层一起停;系统开了"减弱动态效果"也会自动静) —— 它和图片背景二选一(设一个会摘掉另一个)。整套界面是毛玻璃风格(对话气泡透明、面板半透明 + blur,冷色冰蓝调子),跟你挑的背景同一个调子
- 新建任务、下发指令、取消运行,实时看思考过程和工具调用
- 它跑着的时候能插话:运行中右边的按钮变成「插话」,说的话排进队列、这一轮结束就接着处理,不用干等它跑完才敢打字(桌面端的
steer = 打断当前回合,手机上默认不用它:宁可等一轮,也不要把你正在跑的事打断)
- 发出去的话立刻看得见、还能撤回:排队中的消息先在对话里显示成一条虚线气泡(带「排队中」状态),点「撤回」真的把它从队列里拿掉(DSH
updateQueue 的 remove);已经开始处理的会如实告诉你撤不回来,绝不假装成功
- 第一次连上会讲一遍怎么用(点「开始使用」收起);顶栏有「说明」键随时能再看。这篇说明按设备记"看过没有"(不是按 IP)—— 所以换一台手机、同学连进来都会各讲一遍,不会被人"代表"掉
- 按会话回看历史;每条消息气泡外侧带一个看得见的「复制」(点一下复制这条的正文 —— agent 的回复只复制正文、不带思考过程;长按气泡也能复制,但那个入口藏得深,没人会去猜)
- 会话能置顶、能标常用(2.5.0 新增):点顶部那一行打开列表 —— 📌 置顶(钉到最上面,重启手机还在)、⭐ 常用(自己打星)、以及手机按"你用得多的"自动排的前几个;点行切换、点 📌/⭐ 只改标记。这三份数据只存在你这台手机里,不碰电脑上的会话数据
- 顶端就写着当前模型、列表能删会话(2.6.0 新增):最上面一行是当前模型(读宿主的真值,电脑端换过这里也跟着变,点开就能换);会话列表默认不摆 agent 子会话和自检会话(想看在底部勾「显示全部会话」),每行有 🗑 删除 —— 要输 PIN 才删得掉,正在跑的会话得再确认一次,删掉的记录在电脑端可以取消归档找回来
- 余额查询(配合余额插件的同一份凭据)
- 地址自动排序:USB 共享 / 蓝牙 PAN / 个人热点 / 局域网,哪个能通就把哪个排前面
- 外网可达:推荐 Tailscale Funnel(一条固定的
https://<机器名>.<tailnet>.ts.net/ 地址、开机自恢复、手机不用装任何 App);也附带 Cloudflare 快速隧道脚本做临时方案
- 手机上直接生图(1.0 新增):「生图」键 → 写一句英文提示词 → 它不是直连生图后端,而是把这条指令交给当前会话里的
image_generate 工具去出图 —— 图因此落在会话里,能像别的素材一样在对话中看、点开、长按存相册。第一次点会把那句话先放进输入框让你过目,之后照发。⚠️ 这需要电脑上装了 image_generate 工具(单独的插件,不在本仓库里),否则 agent 只会回一句"没有这个工具 / 没有生图模型" —— 那不是手机桥坏了,见下面《手机上直接生图》一节。
- 手机上看 / 改权限档位(1.0 新增):权限面板 + 状态角标。读不需要额外证明;收紧到只读也不拦(往安全方向走不设障);但放开档位必须重新输 PIN —— 免得"捡到一台已解锁手机的人"直接把整台机器放开。3.5.0 起,新建任务那张纸里也能先选一档(建好的那一刻就按它跑;选「跟随默认」=什么都不改、不额外要 PIN)
- 任何 App 的「分享」都能进电脑(3.6.0 新增):用 iOS 快捷指令做一个"分享表"动作 POST 到桥的一条短链,之后 Safari / 相册 / 备忘录里点「分享」→ 选它,内容就进了电脑上最近那个会话。页面上一个键都没加;默认关 + 每次带 PIN(见下面《分享到桥》一节)
- 分享键还能给「网页版」(4.2.0):一份单文件、自带排版的
.html 安装页(GET /api/install?format=html ✓),同学直接浏览器打开就能读 ✓、不需要你电脑在线 ✓;手机上不能直接分享文件时会如实说并退到下载 ✓
- 📋 那张纸上还多了两样(3.8.0,同样没加新键):「最近放进去的」 —— 你放进电脑剪贴板的文字在手机本地留最近 20 条,点一条只放进输入框(不替你按发送);⚠️ 从电脑读回来的不进历史(那可能是密码)。🔤 取字 —— 截一张电脑屏幕交给电脑上的 agent 认字、存成一份可搜索的笔记(桥自己不认字)
- ⏰ 定时开机(4.13.0 新增):在「📊 状态盘」上写一个时刻,到点在睡眠里把机器叫醒 ✓。⚠️ 两条路必须分开说:睡眠 (S3) 系统还活着 → 计划任务的「唤醒定时器」能叫醒它 ✓;完全关机 (S5) 软件做不到 ✗(那时系统没在运行,任何软件都不存在 ✓)—— 那只能去主板 BIOS 里设「RTC 定时开机」✓,而"BIOS 里设没设"Windows 查不到 ✗,所以插件不摆那个键 ✗。默认关,跟
powerControl 那个开关走(见下面《定时开机》一节)
- 🔌 蓝牙能「断开某一台」(4.14.0 新增):设备列表里已连接的那几行右边多一个「断开」✓。⚠️ 两条实话:断没断以电脑读回来的连接状态为准 ✗(实测音频/遥控两种服务返回"成功"却没断 ✓,真正断掉耳机的是免提那一种 ✓);有的设备就是断不掉 ✗(本机实测 iPhone 试了 14 种服务都不掉)→ 那就照实说"没断成" ✓ 并给两条替代 ✓。不会取消配对 ✓(只断连接);"连接"仍然只能去系统设置里点 ✗(Windows 没有可靠接口,实测 rc=87 ✓)
版本变更记录见 CHANGELOG.md。
想分享给同学?照这三步走
-
同学先得有 DSH(这个插件是扩展,不是独立程序;Node 22+)。
-
一条命令装插件,然后重启 DSH:
dsh plugin --profile web add github:jxboop/dsh-plugin-mobile-bridge
重启后日志里会打印他自己那台电脑的手机网址:
[mobile-bridge] listening on 0.0.0.0:3081, PIN 123456
[mobile-bridge] phone url: http://192.168.1.20:3081/ab12cd34ef56ab78/ (WLAN)
-
手机浏览器打开那条网址,输 PIN 就能用。桌面 DSH 左下角的徽标上也写着完整网址 + PIN。
两点务必说清楚:
- PIN 和网址里的随机段是每台电脑各自生成的(
~/.dsh/mobile-bridge.json)——别把自己的网址/PIN 发给别人,那等于把你电脑的后门给对方。
- 同一个 WiFi(宿舍/家里)一般直接可用;校园网常常禁止客户端互访,那就按下面「外网访问」走 Tailscale Funnel —— 地址固定、手机不用装 App,也还是只认那台电脑自己的 PIN。
装成「App」放在手机桌面上(推荐)
不需要 App Store、不需要越狱、不用装任何第三方东西 —— 用手机浏览器自带的
「添加到主屏幕」,就是一个带图标、全屏、能离线打开的 App:
iPhone(Safari):打开手机桥的网址 → 底部「分享」按钮 → 添加到主屏幕 → 添加。
Android(Chrome):打开网址 → 右上角菜单 → 安装应用 / 添加到主屏幕。
装完从桌面图标点开:没有地址栏、没有浏览器按钮,启动有加载进度条,界面是毛玻璃那套;
后台断网也能打开外壳(页面走 network-first,永远拿最新的)。
图标是 tools/make-icons.mjs 生成的真 PNG(iOS 不认 SVG 图标),改了图标设计重跑一次即可:
node tools/make-icons.mjs
说明:原生 App 做不了(iOS 打包签名要 Mac + 开发者账号 + 上架审核),
而 PWA 在体验上已经等价:独立图标、全屏、启动图、离线外壳、无浏览器界面。
安装
前置条件:你得先有一个能正常运行的 DSH —— 这是个扩展插件,不是独立程序。需要 Node 22 以上(用的都是内置模块,没有构建步骤)。
1. 装插件
两个会直接卡住你的坑(同学那边实测踩到,都写在这里):
- 这条命令需要 Git。 pnpm 对
github: / git+https: 一律走 git 解析,不会自动改走
tarball;没装 Git 就是 ERR_PNPM_GIT_RESOLVE_FAILED。装一次
Git for Windows 再重跑;不想装 Git 就用下面的 tar.gz 地址。
- 必须是 1.8.2 及以上。 1.8.0 / 1.8.1 的包里缺
lib/addresses.js(files 漏列),
装上去插件一加载就报找不到模块 —— 症状是手机页面打不开、一直转圈。
判断办法:看 <profile>\node_modules\dsh-plugin-mobile-bridge\lib 里有没有 addresses.js。
# 从 GitHub 安装(`link:` 只接受本地路径,网址要用 git+https 或 github: 简写)
dsh plugin --profile web add git+https://github.com/jxboop/dsh-plugin-mobile-bridge.git
# 等价写法
dsh plugin --profile web add github:jxboop/dsh-plugin-mobile-bridge
# 没有 Git?用 codeload 的 tar.gz 地址(这条路不需要 git)
dsh plugin --profile web add "https://codeload.github.com/jxboop/dsh-plugin-mobile-bridge/tar.gz/refs/heads/main"
# 从本地目录(改代码时用)
dsh plugin --profile web add link:/path/to/dsh-plugin-mobile-bridge
tar.gz 地址不会自己更新到最新提交:它的版本串是"分支"而不是提交号,add 时 pnpm
会跳过已装的那一份。要更新得先 remove 再 add:
dsh plugin --profile web remove dsh-plugin-mobile-bridge
dsh plugin --profile web add "https://codeload.github.com/jxboop/dsh-plugin-mobile-bridge/tar.gz/refs/heads/main"
(用 github: 那条则可以直接重跑 add 更新 —— pnpm 会重新解析到最新提交。)
装完重启 DSH,日志里会出现:
[mobile-bridge] listening on 0.0.0.0:3081, PIN 123456
[mobile-bridge] phone url: http://192.168.1.20:3081/ab12cd34ef56ab78/ (WLAN)
更新到最新版
把上面那条安装命令原样再跑一遍就行,不需要先卸载:
dsh plugin --profile web add github:jxboop/dsh-plugin-mobile-bridge
然后重启 DSH。(dsh plugin 是 pnpm 的转发器;对分支形式的 git 依赖,add 会重新解析到最新提交。实测 0.2.2 → 0.3.0 可直接升级。)想确认装到了哪一版:看 <profile>/node_modules/dsh-plugin-mobile-bridge/package.json 的 version。
版本号从 0.9.4 起才是准的。0.7.0~0.9.3 那一段只在提交标题里写了版本,package.json
一直停在 0.6.7 —— 所以那几版看不出新旧,照"重跑一遍安装命令 + 重启"来判断即可。
版本怎么排(2026-10-09 起):加功能 = 进一位小数(1.3.17 之后的下一个功能是 1.4.0,
再下一个 1.5.0…);修 bug / 只改文案 = 补丁位(1.4.1、1.4.2…)。一次提交里两者都有时按功能算。
每个版本都带 package.json 版本、CHANGELOG.md 一段、git tag v<版本> 和 Release。
2. 首次启动会自动生成配置
~/.dsh/mobile-bridge.json(Windows:%USERPROFILE%\.dsh\mobile-bridge.json):
{
"version": 1,
"port": 3081,
"pin": "123456",
"pathSecret": "ab12cd34ef56ab78",
"tokenTtlHours": 12,
"bindTokenToIp": false,
"elevationMinutes": 60
}
pin 和 pathSecret 都是自动生成的,别外传。 在 DSH 界面左下角的徽标上能直接看到完整网址和 PIN。
| 字段 | 默认 | 说明 |
|---|
port | 3081 | 监听端口 |
pin | 随机 6 位 | 登录密码 |
pathSecret | 随机 16 位十六进制 | 所有路由都藏在这个随机段后面,没有它一律 404 |
tokenTtlHours | 12 | 登录令牌有效期(上限 14 天) |
bindTokenToIp | false | 是否严格绑定令牌的来源 IP。默认 false:地址变化只记录告警(在 /api/bootstrap 的 foreignUses 里可见),不吊销登录。设 true 则换地址即吊销 —— 移动网络下不可用(运营商 NAT、IPv6 隐私扩展会不停换地址,手机会被反复踢下线),只适合固定网络 |
elevationMinutes | 60 | 输一次 PIN 后,多久内可以"指挥 DSH 干活" |
crashProbe | false | 排查用,平时别开。 设为 true 会把宿主的 uncaughtException / exit 事件写进 ~/.dsh/dsh-crash.log(含每 30 秒一条内存心跳)。它跑在宿主进程里、会注册全局处理器并持续写盘,所以默认关闭。 |
answerOnPhone | false | 让手机回答 agent 的提问(ask_user_question)和工具审批。默认关:开着时"正在看这个会话的手机"会抢在桌面前面拿到提问,桌面要等 120 秒才轮到。没有手机在看这个会话时,无论开关如何都是桌面先拿到。 |
pcControl | 关 | 手机上控制电脑(4.5.0 / 4.5.1):静音 / 音量 / 亮度 / 开-关蓝牙 / 看电量与联网 / 看蓝牙设备 / 断开某一台蓝牙设备(4.14.0)。默认关,改完立刻生效、不用重启。开着时"让电脑动手"的动作仍要 PIN 提权。⚠️ 亮度只能调笔记本内置屏(台式机 / 外接屏会如实说调不了);蓝牙设备列表走系统原生接口(约 0.8 秒、带连接状态);「连接某台设备」底层接口实测一律被拒(rc=87),所以列表里点一台就是打开系统蓝牙设置;「断开」以回读到的连接状态为准(详见下面《蓝牙:能「断开」,连不回来》一节)。 |
powerControl | 关 | 手机上让这台机器动手:关机 / 重启 / 解锁,以及 4.13.0 起的「定时开机」(到点在睡眠里唤醒 ✓)。默认关,改完立刻生效、不用重启;每次动作仍要 PIN 提权。⚠️ 完全关机后开机软件做不到 ✗(详见下面《定时开机》一节)。 |
为什么过一会儿又让我输 PIN?
这是设计如此,不是掉线。手机上有两种状态:
- 已登录(有 cookie):能看会话列表和聊天记录。令牌默认 12 小时有效,所以重启电脑、重启 DSH 都不会把你踢出去。
- 已提权(最近输过一次 PIN):才能让这台电脑干活 —— 下发任务、取消、新建会话。
提权默认保持一个小时(elevationMinutes —— 2026-10-14 用户要求:从 15 分钟改成 60 分钟),过期后第一次动手会重新弹出 PIN 门;宿主重启后恢复的会话同样不带提权。觉得还不够就把它调大,上限 240 分钟:
{ "elevationMinutes": 240 }
如果你看到"需要重新输入 PIN"却找不到输入框,那是 0.2.0 及更早版本的 bug(页面只认 401、不认服务器用来表达提权的 403 + needPin,于是提示了却没有门)。0.2.1 起已修复:过期时页面会直接把 PIN 门打开。
3. 放行防火墙(Windows)
首次启动会弹 Windows 防火墙提示。**只勾"专用网络"**即可(手机和电脑在同一 WiFi 时够用)。若要走外网隧道,勾"公用网络"。
外网访问(可选)
局域网之外也能连。三条路,按这个顺序试:手机也装 Tailscale(最稳)→ Tailscale Funnel(手机不用装东西)→ Cloudflare 快速隧道(临时)。
方案 A:手机也装 Tailscale(最稳,推荐先试)
电脑和手机都装 Tailscale、用同一个账号登录,然后手机浏览器打开:
http://<电脑的 tailnet IP>:3081/<pathSecret>/
电脑的 tailnet IP 怎么拿:托盘图标点开就能看到(形如 100.122.188.30),或者命令行 tailscale ip -4。
<pathSecret> 是网址密钥段 —— DSH 窗口的日志里、或桌面 DSH 左下角徽标上都有完整网址。
为什么它最稳:流量走 tailnet(设备间端到端加密),完全不经过 Funnel 的公网入口。
2026-10-08 实测过一次:Funnel 的公网入口有两个 IP,其中一个偶发回不到机器
(外部 6 个国家的节点复测,一半报 Broken pipe)—— 手机就会间歇性"网络不通",
而同一时刻 tailnet 直连是好的。
代价:手机要多装一个 App(iPhone 需要非中国区 Apple ID),并且 Tailscale 要在后台常驻。
方案 B:Tailscale Funnel(地址永不变、开机自恢复、手机不用装东西)
为什么推荐:Cloudflare 免费隧道的域名每次重启都变,旧的永久失效;Funnel 给的是一条 https://<机器名>.<tailnet>.ts.net/ 的固定公网地址 —— Tailscale 服务随开机自启、配置存在 daemon 状态里,重启电脑后什么都不用做,网址照旧。而且它是标准 HTTPS,手机上不用装任何客户端(这点对 iPhone 尤其重要:Tailscale 的 iOS app 没有上架中国区 App Store)。
# 1. 装 Tailscale 并登录:https://tailscale.com/download
# 2. 首次开 Funnel 需要去控制台同意一次(只此一次),命令会把链接打出来:
tailscale funnel 3081
# Funnel is not enabled on your tailnet.
# To enable, visit: https://login.tailscale.com/f/funnel?node=xxxxx
# 3. 同意之后再执行一次,就常驻后台了:
tailscale funnel --bg 3081
# Available on the internet:
# https://<机器名>.<tailnet>.ts.net/
# |-- proxy http://127.0.0.1:3081
# 4. 手机访问(结尾必须带密钥段):
# https://<机器名>.<tailnet>.ts.net/<pathSecret>/
不用时关掉:tailscale funnel --https=443 off
⚠️ 选了一条路就别同时跑 tools/tunnel-start.ps1 —— 它会把配置里的 publicUrl 改回那个每次都变的 cloudflared 域名。
安全上如实说:Funnel 和免费隧道一样是公网地址,保护来自随机密钥段 + PIN 这两层;区别只是域名固定、不会更容易被猜到。要"只有自己的设备能进",就用方案 A(设备之间端到端加密、不经公网入口)。
Funnel 不稳时怎么办:今天实测到的症状是"手机说网络不通",但电脑这边一切正常(tools/tunnel-status.ps1 也是 OK)。
先 tailscale down 再 tailscale up(不需要管理员)常常能救回来;实在不行重启电脑,或直接用方案 A。
方案 C:Cloudflare 免费快速隧道(临时用)
tools/ 下有一套脚本:不需要公网 IP、不需要改路由器、不需要注册账号。代价是域名每次重启都变,旧的永久失效 —— 只适合临时开一下。
# 1. 下载 cloudflared.exe 放到 tools/ 目录
# https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe
# 2. 启动隧道(会打印完整网址并复制到剪贴板)
powershell -NoProfile -ExecutionPolicy Bypass -File tools\tunnel-start.ps1
# 3. 不用时关掉
powershell -NoProfile -ExecutionPolicy Bypass -File tools\tunnel-stop.ps1
其它工具:
| 脚本 | 用途 |
|---|
tools/tunnel-status.ps1 | 体检:隧道现在是死是活(进程、端口、外网实测),手机上出现错误页时先跑它 |
tools/show-phone-url.ps1 | 一个可复制的小窗口,显示外网/局域网网址 + PIN(带刷新按钮) |
tools/revoke-phone.ps1 | 应急:一键吊销所有手机登录(两步确认,防误触) |
在手机上回答 agent 的提问
agent 卡在 ask_user_question 上等你选,或者某个工具调用需要你点头时,手机上会直接出现一张卡片:选项按钮、可多选、也能自己写一段话;审批则是「允许一次 / 拒绝」两个键。答完 agent 立刻继续,不用你跑回电脑前面。
默认关闭,打开方式:
{ "answerOnPhone": true }
打开后改配置需重启 DSH(或重载插件)生效。
它怎么决定谁来答:
| 情况 | 谁作答 |
|---|
| 有手机正在看着这个会话 | 手机(桌面等 120 秒才轮到,防手机没答导致卡死) |
| 没有手机在看这个会话 | 桌面,和我们没插手时一模一样 |
| 手机答了,别的手机也开着 | 先答的算数,其余卡片自动撤掉 |
| 提问发生时手机不在场(在别的会话 / 页面被系统挂起 / 正在重连) | 桌面先答;手机上事后会出现一条能点的横幅,切过去还能补答(4.14.1) |
4.14.1「回来还能看见、还能答」(用户 2026-10-16 报「我看不见你的问题手机端」✓):
- 上面那张表里第四条就是当时那两次的情形 —— 卡片只在"提问发生那一刻有手机盯着这个会话"时才推给手机,
手机不在场就一个字都到不了 ✗(而桌面照常,这是设计 ✓,不是坏了 ✗);
- 现在宿主会把超时之后仍然收得住的提问(
userQuestions.continued)也告诉手机(GET /api/waiting):
手机显示「另一条会话在等你回答」✓,点进去直接看到那张卡 ✓ 并且答得出去 ✓(走宿主自己的 callId ✓,
不是桥上那个 120 秒就失效的临时 id ✗);页面从后台回到前台时也会主动补问一次 ✓;
- ⚠️ 仍然没做:提问的那一瞬间手机不在场,卡片不会"立刻"出现在手机上 ✗ —— 它只能事后看到并补答。
要"立刻"就得让桥在没人在看时也把提问扣给手机,代价是桌面那条会晚 120 秒 ✗,所以没做。
- "读不到"和"没有"是两件事 ✓:宿主没有那两个服务时,服务端回
available:false ✓、页面上写「读不到」✗ ——
绝不说成"没有提问" ✗。
- 出了这种事不再只能靠猜 ✓:桥会把每一次「为什么没交给手机」记进日志 ✓
(例:
ask(question) -> 桌面:现在没有手机在看 session-xxx(此刻有人在看:session-yyy) ✓),
以及手机开始看 / 不看哪个会话 ✓。
几个已经处理好的细节:
- 答案要提权:和下发任务一样,光有 cookie 不够,得最近输过 PIN —— 否则偷走 cookie 的人能替你在批准框上点"允许"。
- 同一个提问不能答两次:用过的 id 立刻失效。
- 手机刷新不会丢卡片:新开的流会把还没答的提问补推一次。
- 问题文本来自模型,一律当纯文本渲染(
textContent,不是 innerHTML),模型塞不进脚本。
- 贴到别的页签也看得见:卡片挂在主区域外面,切到「余额」不会漏掉提问。
- 提问作废了就撤卡:超时/已在别处回答后,卡片自己消失(而不是点一次报一次错)。
- 你正开着面板时也看得见(2.5.1):卡片一到会自动收起当前打开的工具面板(会话列表 / 录音 / 生图 / 背景 / 权限 / 删除纸),振动一下并提示「agent 要你点头」——
面板只是收起,里面写好的内容不会丢;卡片的层级也不再低于任何全屏面板。
(这条是用户报「审批没弹出来」查出来的真因:卡片本来是流内元素,被那些
z-index:50 的全屏纸整个盖住,
而宿主只等 120 秒就退回桌面 —— 桌面上两分钟后才冒出来,看起来就像"手机上没提示"。)
已知限制:加载页和断线整屏(那两屏比卡片高)时不会显示卡片 —— 那属于"还没连上/断开了",
连上以后卡片自然回来。
断了会告诉你,不用你自己发现
手机和电脑之间的连接有三种坏法,每一种都会在页面顶部弹出一条说明,而不是像以前那样只把一个 9 像素的圆点变红:
| 情况 | 页面会说什么 |
|---|
| 连接被切断(隧道没了 / 网络断了) | 「和电脑断开了」+ 可能的原因 |
| 连接还在、但对面不再说话(电脑睡眠最典型) | 「好像和电脑失去联系了」+ 最后收到消息是多久之前 |
| 提问过期、或已在别处回答 | 卡片自己撤掉,并说明原因 |
而且它会给出两条能点的出路:
- 改用局域网地址 —— 同一个 WiFi 下,这条链路完全不依赖隧道。地址由宿主在
bootstrap 里给(含密钥段的完整 URL)。注意换地址等于换站点,cookie 带不过去,要重输一次 PIN。
- 重试连接 —— 不换地址,直接重连。
桌面那一侧也不再装死:插件每 120 秒从外网探一次自己的隧道,连续两次探不通就把徽标上的外网地址标红成「⚠️ 隧道已断,手机连不上」,并往 ~/.dsh/mobile-bridge.log 写一行。恢复时也会写一行。
手机上是 Cloudflare 错误页?(Error 1033 / 1016)
这两个错都不是插件的问题,而是隧道没了。
| 错误 | 含义 |
|---|
| 1033 Cloudflare Tunnel error | 这个主机名曾经是一条隧道,但现在没有任何隧道连着它 |
| 1016 Origin DNS error | 这个主机名已经完全解析不到了(隧道早就没了) |
最常见的两个原因:
- 免费快速隧道的网址,每次 cloudflared 重启都会换一个。 旧网址永久失效,救不回来 —— 不是"过一会儿就好",是那个域名再也不属于你了。手机上收藏的旧网址永远打不开。
- 跑隧道的电脑睡眠 / 休眠 / 合盖了,或者换了网络、被安全软件或加速器掐掉了连接 —— 隧道随之中断。
怎么办:
# 先体检:一眼看出是进程没了、端口没起、还是网址失效
powershell -NoProfile -ExecutionPolicy Bypass -File tools\tunnel-status.ps1
# 网址失效就重拿一个(先停再起,确保拿到全新的)
powershell -NoProfile -ExecutionPolicy Bypass -File tools\tunnel-stop.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File tools\tunnel-start.ps1
tunnel-start.ps1 会真的从外网打开一次再告诉你成功了 —— 如果它说"没能通过外网验证",那就是现在把网址给手机也没用,直接照它列的原因排查,别浪费时间。
想要一个永不变的网址,只有两条路:Cloudflare 命名隧道(要有自己的域名 + 账号),或者用 Tailscale / ZeroTier 这类组网工具(推荐:地址固定、端到端加密、且完全不暴露到公网)。
⚠️ 免费快速隧道的网址每次重启都会变,而且是一条公开网址 —— 拿到的人都能看到 PIN 登录页。手机桥有 PIN 认证和限速兜着,但别把网址发到群里。
安全模型
这个桥可以经隧道暴露到公网,所以"安全"必须是能验证的,不是一句承诺。
已实施的防护
| 防护 | 说明 |
|---|
| 随机路径前缀 | 所有路由藏在 /<pathSecret>/ 后面。无密钥路径一律 404 |
| 限速的钥匙不可伪造 | 只在 socket 对端是 loopback(即自家 cloudflared)时才读转发头;cf-connecting-ip 优先,x-forwarded-for 取最后一段(自家代理追加的),绝不取第一段(那是调用方可控的) |
| 两级登录限速 | 每地址 12 次/10 分钟 + 所有人合计 60 次/10 分钟 |
| 令牌绑定来源 IP | 偷走的 cookie 换一台机器就是废纸 |
| 写操作需要再验 PIN | cookie 只能读;下发指令/取消/新建会话需要一小时内输过一次 PIN |
Cookie Secure 条件化 | HTTPS 下带 Secure,明文 HTTP 下不带(否则局域网登录会静默失效) |
| 令牌 12 小时过期 | 上限 14 天,/api/logout 立即吊销 |
| SSE 连接上限 | MAX_LIVE_STREAMS = 6,超限 503 |
| 网络层超时与连接上限 | headersTimeout 20s(挡 slowloris)、requestTimeout 180s、maxConnections 64 |
| 统一安全响应头 | nosniff、X-Frame-Options: DENY、cache-control: no-store、Referrer-Policy: no-referrer(防止密钥路径经 Referer 泄漏) |
| 不回显内部错误 | 细节只进日志,不回给调用方 |
| 探测留痕但密钥不落盘 | 无密钥请求记为 GET <no-secret> <- ip;日志里永远不出现密钥路径 |
| 登录审计 | 成功/失败的每一次登录都落一行(~/.dsh/mobile-bridge.logins.jsonl,超 256KB 时成功的那几行优先留 —— 否则约 1300 次错 PIN 就能把真记录挤光、面板假报"没有陌生设备")。失败尝试不算"已知设备" —— 否则攻击者先试错一次就把自己洗白;"新地址第一次成功登录"会在桌面角标上报警,并写明"走外网时这是隧道那端的地址" |
| 看审计要现输 PIN | GET /api/logins 走 x-dsh-pin 头(绝不进 URL):这份账里有这台电脑的网络史,和"看剪贴板 / 截图"同一级。current("这台手机")按令牌判,不按 IP(IP 会被 warn-only 路径改写) |
| 踢设备要现输 PIN | 手机端「踢掉其它设备」= 收回权限,仍需证明是本人(和"删除会话"同级);默认保留当前这台。桌面那个 /revoke 照旧(loopback),密钥泄漏也踢不掉你 |
怎么验证(请自己跑)
node test/security.mjs
51 项断言:限速钥匙的单元断言、密钥路径门禁、每个接口的未授权拒绝、限速生效、cookie 标志、令牌有效期、注销吊销、响应头、SSE 上限、附件不泄漏、日志脱敏。退出码非 0 即有失败。
其中只读的那半(密钥门禁、未授权拒绝、响应头、日志脱敏)打的是你正在用的那台桥;破坏性的那一半(连猜 15 次 PIN、真登录拿 cookie、打满 SSE 上限)会在临时目录里另起一台一次性实例,跑完即拆 —— 因为登录限速有一份全局额度,是和手机共用的,在真身上打就是拿手机当靶子。早期版本正是这么把用户锁在门外的。
要连真实 Cloudflare 边缘一起验(会消耗本机出口 IP 的登录额度,10 分钟后自愈):
$env:BRIDGE_TUNNEL_URL='https://<你的隧道域名>'
node test/security.mjs
其它测试。先装一次测试依赖(jsdom,只有 UI 测试用它;插件本身零运行时依赖):
npm install
| 文件 | 内容 | 需要 |
|---|
test/crashprobe.mjs | 崩溃探针开关:默认不碰宿主全局状态、开启后能在配置重写中存活(17 项) | — |
test/harness.mjs | 桥的端到端行为:PIN 门、cookie、prompt 组装、SSE 扇出、取消、附件、新建会话、余额缓存、重启保活(含"恢复的会话可读但不可写 = 403+needPin")、注销、桌面角标注入与 start() 不抛错、普通请求取对话 /api/transcript(巨型历史裁剪 / 一堆短字符串拼成的 stream 也要丢 / 缺 sessionId 400 / 客户端半路掐断后桥还活着)、子会话的错误帧带 unsupported 且从列表里消失,以及手机作答全链路(提问推到流上 / POST api/answer 收答案 / 防重放 / 审批 outcome 校验 / 取消不惊动桌面 / 刷新补推)(492 项) | — |
test/ui.mjs | 手机页面的 DOM 与交互:含"没有请求跑出密钥段"、"提权过期时必须把 PIN 门打开"、运行中同时给出「插话」与「停止」并确认插话已收下、换会话后「在跑」状态跟着重置、首次说明页(自动弹出 / 讲清四件事 / 点了就记住 / 真有提问时让位)、子会话不许谎报断线、也不再重连、迟到的旧会话回包不许贴到新会话上、localStorage 写不进去(无痕模式)也不许把页面搞死、Ctrl+Enter 连按两下只发一次、提问与审批卡片(渲染、作答编码、防 XSS、重推保草稿、单选与自定义互斥、作废撤卡、跨页签可见),以及断线提示条(弹条、说清原因、给局域网兜底、心跳到达后收起)(685 项) | jsdom |
test/addresses.mjs | 地址排序与分类(16 项) | — |
test/shot.mjs | 真浏览器截图 + 布局/滚动体检(scroll: 那行会报能不能滚到顶底、滚动中位重取后位置还在不在、有没有块在估算高度) | Windows + Edge |
test/switch-bench.mjs | 真浏览器量"切会话":切到没打开过的会话多久有内容、切回来是不是那一刻就有、这次切换下载了多少字节 | Windows + Edge |
test/reopen-bench.mjs | 真浏览器量"从别的窗口点回来":把网络延迟拉到 2 秒再重载,报"对话出现"与"界面显示"两个时刻 | Windows + Edge |
test/scroll-live.mjs | 真浏览器复现"回弹":开一个临时会话让它真的流式输出一千多行,一边往上滚一边采样 15 秒(回弹次数 / scrollHeight 变动 / 最长掉帧) | Windows + Edge |
test/phone-diag.mjs | 真浏览器诊断:抓页面异常、控制台、网络状态码 | Windows + Edge |
test/boot-probe.mjs | 加载页进度条探针:网络节流下每 250ms 采样一次,回答"百分比会不会冻住"(这类 bug 本机复现不了,只有慢网络才看得见) | Windows + Edge |
node test/package.mjs && node test/harness.mjs && node test/qr.mjs && node test/ui.mjs
node test/security.mjs && node test/crashprobe.mjs && node test/addresses.mjs && node test/offline.mjs
node test/platform.mjs
九个套件共 1764 项(package 163 · harness 612 · qr 12 · ui 846 · security 54 · crashprobe 17 ·
addresses 16 · offline 17 · platform 27)。另有 test/voice.mjs 33 条语音草稿 —— 语音那一版
按用户要求先跳过,文件留在仓库里、能单独跑,但不并进这项门槛(tools/check-counts.mjs 只管上面九个)。
改完必跑全套 ✓ —— 只跑某一个会漏掉内容规矩与界面回归(2.7.2 就是这么漏了两版的 ✓)。
桌宠:小鲸(4.15.0 起)
手机界面上浮着一只 小鲸 / DeepSeek酱(用户自己的角色设定图 ✓):
- 按住就拖走 ✓;右下角 ⤡ 拖一下改大小 ✓(两只手指捏也行 ✓);
- 点一下有反应 ✓(随机换一只 + 说一句 + 弹一下 ✓);长按出小工具条(换立绘 / 大小 / 藏起来 ✓);
- 表情长在她自己脸上 ✓(4.15.1:换的是整只立绘 ✓ —— 早先那个"头顶一个圆形脸框"已删干净 ✗,
连 CSS 与注释都不留 ✓,
test/package.mjs 钉着「petFace 不许再出现」✓);
- 会自己动 ✓:空闲时每 7 秒在
moods.idleCycle(中性 → 吃饭 → 开心 ✓)里整只轮换 ✓;
有事情就换对应的 —— 发成功 → 开心 ✓、报错 → 难受 ✓、在跑 → 中性 + 「在跑了」✓;
- ⚠️ 代价如实 ✗:那 4 张立绘是分开生成的 ✓(衣服小细节彼此有差异 ✓),而且没有逐帧动画 ✗
(不眨眼 ✓)—— 要"完全一致的多表情"得手画一套帧 ✓,还没做 ✓;
- 位置与大小存这台手机 ✓(百分比 + 倍率 ✓ —— 横竖屏切换不会跑到屏幕外 ✗);
- 藏起来之后有路回来 ✓:「更多」→ 🐳 桌宠(点一下藏 / 再点一下放出来 ✓)。
素材怎么放(⚠️ 用户自己的东西只放本机 ✗,不进插件、不进 Release ✓ —— 和「夜行星」同一条规矩 ✓):
~/.dsh/mobile-bridge-pets/<名字>/
pet.json ← 清单:body / faces / faceLabels / palette / moods / lines
body.png ← 透明底立绘(插件不提供 ✗)
faces/*.png ← 表情(圆形脸用 ✓)
- 配置里可以点名
{ "petPreset": "<名字>" } ✓;不配就取目录里第一只有清单的 ✓;
- 没有素材也不会空着 ✓:插件自带一只代码画的(内联 SVG ✓),所以别人装了也能看见一只 ✓;
- 服务端两条只读接口:
GET /api/pet ✓(清单 ✓)与 GET /api/pet/asset?name=… ✓(素材 ✓)——
都要登录 ✓,而且取素材走白名单 + 逐段洗 + 落目录内三重校验 ✓(文件名只来自 pet.json ✓,
不拿 URL 拼路径 ✗)。
- ⚠️ 电脑端(Harness Web UI)那只还没上 ✗ —— 下一版补 ✓。
蓝牙:能「断开」,连不回来(4.14.0 起)
用户 2026-10-16 原话:「还有蓝牙功能的断开设备连接,现在只有连接设备」✓ —— 这一版补上了「断开」那一半 ✓。
| 动作 | 能不能做 | 说明 |
|---|
| 看设备列表 + 真实连接状态 | ✅ | 系统原生接口(约 0.8 秒) |
| 断开某一台(4.14.0) | ✅ 能(部分设备) | 见下面两条 ⚠️ |
| 连上某一台 | ❌ 不能 | Windows 没有可靠接口(BluetoothSetServiceState(ENABLE) 实测一律 rc=87);面板里点一台 = 打开系统蓝牙设置 ✓ |
| 取消配对(忘记设备) | ❌ 故意不做 | 那是 BluetoothRemoveDevice:要重配一遍,代价太大;本插件不动配对关系 ✓ |
⚠️ 两条必须知道的实话(都是 2026-10-16 在真机上验出来的 ✓):
- "断没断"以电脑读回来的连接状态为准 ✗ ✗ —— 不是"命令返回成功" ✓。
实测:耳机的 A2DP / AVRCP 两种服务关下去,系统返回成功(rc=0)却没断 ✗;
真正让它断开的是免提(HFP)那一种 ✓。所以插件是逐个服务关、每关一次回读状态 ✓,断了才说断了 ✓。
- 有的设备就是断不掉 ✗ —— 同一台机器上的 iPhone 试了 14 种服务都不掉 ✓
(其中 4 种还回 rc=0 ✗)。这种情况面板会照实说"没能断开" ✗,并给两条真能用的替代 ✓:
点「打开蓝牙设置」在电脑上手动关 ✓;或者「蓝牙关掉再打开」——
⚠️ 后者会把所有连接一起断掉(耳机、手机热点都会断 ✓),代价写在面板上 ✓。
断开成功之后那一行会变成"未连接" ✓(那一步是重新读一次设备列表得到的 ✓ ——
不是页面自己改的 ✗);要连回来仍然只能去系统蓝牙设置里点一下 ✓。
定时开机(4.13.0 起):睡眠能醒,关机只能靠 BIOS
用户 2026-10-16 原话:「既然有关机那么可以做一个对应的定时开机功能」✓。
这个名字最容易让人误会,所以先把两句话放前面(手机面板上写的也是这两句):
| 机器当时的状态 | 到点能不能自己开 |
|---|
| 睡眠 (S3) | ✅ 能 —— 系统还活着,计划任务带「唤醒定时器」(WakeToRun)就有权把它叫醒 |
| 休眠 (S4) | ✅ 能(这台机器上休眠没启用 —— 面板会照实说,这是另一件事) |
| 完全关机 (S5) | ❌ 不能 —— 系统没在运行,任何软件都不存在 |
⇒ 完全关机之后到点开机,只能靠主板 BIOS/UEFI 的 RTC 定时开机(各家叫法不同:RTC Alarm Power On /
Resume by RTC Alarm / Wake on RTC / Power On By RTC),设好之后不需要任何软件。
而"BIOS 里设没设",Windows 这边没有任何接口能读到 ✗ —— 面板会明说查不到,不给假安心;
任何人拿这个动作来问(接口是公开的),服务端也会如实拒绝并把"为什么做不到"说出来 ✓。
这也是插件不摆那个键的原因 ✗(摆一个按了没反应的键 = 骗人)。
怎么用(在「📊 状态盘」上,紧挨着「电脑端一键动作」):
- 配置里要有
"powerControl": true(见上表 —— 定时开机和"关机 / 重启"共用一个开关:都是"让这台机器自己动";默认关,改完立刻生效);
- 写一个时刻:
07:30(今天已经过了就定明天)/ 10-17 07:30(今年过了就定明年)/ 2026-10-17 07:30(必须是将来,过去的一律拒绝)→ 点「设它」;
- 面板会当场读回来给你看:那个任务的时刻、有没有「唤醒」标志、以及这台机器的唤醒定时器策略
(交流 / 电池两档)。不是「启用」时,面板会把改成启用的那条命令摆出来(要管理员 ——
插件不偷偷提权 ✗);电池那档是「禁用」时,还会提醒你插着电才稳。
只响一次 ✓(同一时刻只留一个 ✓,再设一个就换掉它);「取消这个定时唤醒」删完会回读确认才说删掉了 ✓。
powercfg /waketimers("系统里还排着哪些唤醒定时器")要管理员才能读 —— 读不到时面板说读不到 ✗,
不当成"没有" ✗(这两件事完全不一样)。
分享到桥:任何 App → 分享 → 电脑会话(3.6.0 起)
在手机上看见一段话、一个链接、一张图,想直接丢给电脑上的会话 —— 不用切到桥的界面,**在原来的 App 里点「分享」**就行。
为什么做成"快捷指令 + 一条短链":手机上这个网页已经很大了(10.8k 行),
再加按钮就要跟"面板重渲染"那套脾气搏斗(3.5.0 为了一个「解锁」按钮折腾了五轮 ✗)。
短链那条路完全不碰页面 —— 更稳,也更快。
怎么开(默认关 ✓)
-
在这台电脑的 ~/.dsh/mobile-bridge.json 里加一行:
{ "shareIntake": true }
(改完立刻生效,不用重启 ✓;不想要了删掉/改回 false 就立刻关 ✓。)
-
手机上打开桥 → 工具键那排的 🩺 一键自检 → 找到**「分享到桥」那一行 → 抄下它给出的短链**
(那条链里带着这段密钥,别发给别人 ✓)。
-
在 iPhone 的快捷指令里新建一个快捷指令:
-
打开「在分享表单中显示」;
-
「接收」选 文本、网址(想分享图片再加 图像);
-
加一个「获取 URL 内容」:方法 POST,URL 填第 2 步抄来的短链,
请求体选 JSON,内容形如:
{ "pin": "你的六位 PIN", "text": "分享进来的文本" }
(有网址就再加 "url": "分享进来的网址";有图片可先「Base64 编码」再放进 "images": [{"mediaType": "image/png", "data": "…"}]。)
-
保存 ✓ —— 之后在任何 App 点「分享」,列表里就有它。
它到底做了什么(以及没做什么)
- 它把内容当成你发的一条消息送进电脑上最近那个会话(跳过空会话 / agent 子会话 / 自检会话);
消息最前面会带一句【分享到桥】。也可以带
"sessionId": "…" 指定会话 —— 指定的会话找不到就如实报 404,
不会偷偷换个会话发。
- 每次都要带 PIN:它等于替你指挥这台电脑(和你在手机上点「发送」同级),而短链没有登录 cookie,
所以只能要"随这次请求的 PIN"。错一次就记一笔账(审计留一行、桌面角标会显示有人正在试);
同一 IP 10 分钟内错满 12 次才会被挡 10 分钟(和登录那条路同一个闸)。
- 关着的时候(默认)→
403 + 一句"怎么开";一个字都不会进会话。
- 收不下的会明说:文本超 8000 字只取前 8000(内容里与回包里都写明)、一次最多 3 张图
(多出来的张数也会说明)—— 不静默丢。
- 分享文件(PDF / 压缩包)这一版没做:大文件要走分片上传,在快捷指令里拼分片不划算 ——
用手机上的「文件」键传,那条路是现成的。
接口形状(想自己写脚本也一样):
curl -X POST "https://<你的地址>/<密钥段>/api/intake" \
-H 'content-type: application/json' \
-d '{"pin":"935881","text":"看到的这段话","url":"https://example.com/a"}'
# → {"ok":true,"sessionId":"…","title":"…","requestId":"…","accepted":true,"images":0}
一键 OCR / 扫码(3.7.0 起)
同一条短链,多带一个白名单里的 task 就行 —— 桥会把一句固定指令附在内容最前面:
# 拍张照 → 让它认字 → 整理成一份可搜索的 Markdown 笔记
curl -X POST "https://<你的地址>/<密钥段>/api/intake" -H 'content-type: application/json' \
-d '{"pin":"935881","task":"ocr","images":[{"mediaType":"image/png","data":"<base64>"}]}'
# 拍二维码 / 条形码 / 书号 → 解出来 + 查一下它是什么
curl -X POST "https://<你的地址>/<密钥段>/api/intake" -H 'content-type: application/json' \
-d '{"pin":"935881","task":"scan","images":[{"mediaType":"image/png","data":"<base64>"}]}'
task 是白名单(ocr / scan,GET /api/intake 会列出来):task 这个字段不接受自由指令 ——
这条链虽然是凭证(密钥段 + PIN),但它形状是公开的,让调用方自己写指令等于白送一个"远程指挥 agent"的入口。
名单外的值(含空值与非字符串)→ 400 + 列出能用哪些(不静默忽略)。
⚠️ 说清楚:text 与图片名仍然是调用方自己的内容(它们本来就是"分享进来的东西"),
桥不改写它们、也不拿它们当系统指令用。
- 认出来的字落在会话里 —— 所以挂了会话检索(宿主
dsh-session-query)的部署上,
手机上现成的「🔍 搜一搜」(搜这台电脑上的会话内容、不上云)直接就能搜到 ✓;
没挂的话搜一搜会如实说搜不了(那是宿主的原话,不是桥坏了)—— 文字照样在会话与那份 Markdown 笔记里,
在电脑上搜也一样。
- 桥自己不做识别、也不做解码:它只把图和指令交给电脑上的 agent(它本来就会看图);
扫码那条指令里也写着"桥不做解码",让 agent 自己找工具或写一小段脚本。零新依赖。
- 内容顺序是固定的:
【分享到桥】 → 任务指令 → 你的 text → 图片。收不下的(文本超 8000 字 / 图超 3 张)
照旧明说(truncated / dropped)。
开放接口:把事件投给你自己的程序(3.4.0 起)
想让别的程序"知道电脑上发生了什么"(跑完了一轮、出错了、会话有新活动),
用 webhook 把事件 POST 到你自己写的地址上就行。
怎么开(默认关 ✓)
在电脑上的 ~/.dsh/mobile-bridge.json 里加三行(配置是现读的 ✓ —— 改完不用重启 ✓;
重启 / 开机之后也会自动按配置订上 ✓ —— 复查第 83 轮修掉了"重启后订阅会掉、还得手工再调一次"那个洞 ✗):
{
"webhookUrl": "https://你的服务器/hook",
"webhookSecret": "一串你自己的随机字(用来签名)",
"webhookEvents": ["api-session/status", "api-session/error", "api-session/activity"]
}
webhookEvents 不写就默认订上面这三个 ✓;
- 改完想立刻生效:调一次
POST /api/webhook/subscribe ✓(登录即可 ✓ —— 这个动作不改任何配置,
只是按现配置重新订阅 ✓;重启 / 开机本来就会自动订 ✓,它是"不想重启"时的入口 ✓);
- 想先确认"发不发得出去":
POST /api/webhook/test(要现输 PIN ✓)会真发一条测试投递 ✓。
你会收到什么
每条事件一个 POST,头里带三样东西 ✓:
| 头 | 意思 |
|---|
x-dsh-event | 事件名(如 api-session/status) |
x-dsh-timestamp | 毫秒时间戳 |
x-dsh-signature | sha256=<hex> —— 用 webhookSecret 对正文做 HMAC-SHA256(与 GitHub 的 X-Hub-Signature-256 同形) |
正文只有元信息 ✓(这是有意的 ✓ —— 会话内容太敏感 ✗):
{"event":"api-session/status","at":1791800198081,"sessionId":"…","running":true}
三个事件分别是什么
| 事件名 | 什么时候发 | 正文里的字段 |
|---|
api-session/status | 某个会话开始跑 / 跑完了(宿主级状态,比手机上的"这一轮"更准) | sessionId、running |
api-session/error | 某个 agent 出错了(且不在某轮的可记录位置上) | sessionId、message(宿主的错误原文,截断到 300 字 —— 可能夹带路径等上下文 ✓) |
api-session/activity | 有一条用户消息推进了会话的活动时间 | sessionId、updatedAt(活动发生的时间 ✓) |
at 一律是投递时刻 ✓(复查第 83 轮 R13:以前 activity 的发生时间被映射成 at ✗,
于是对方拿到的是"我们发出去的时间"、还看不出来 ✗ —— 现在发生时间单独叫 updatedAt ✓)。
一个最小接收端(Node,验签名)
import { createServer } from 'node:http'
import { createHmac } from 'node:crypto'
const SECRET = '你在配置里写的那串'
createServer((req, res) => {
let body = ''
req.on('data', (c) => { body += c })
req.on('end', () => {
const expected = 'sha256=' + createHmac('sha256', SECRET).update(body, 'utf8').digest('hex')
if (req.headers['x-dsh-signature'] !== expected) { res.statusCode = 401; return res.end('bad signature') }
console.log(req.headers['x-dsh-event'], JSON.parse(body))
// 回 200 + 一句正文最省事 ✓;回 **204**(成功且没有正文)也完全可以 ✓ ——
// 桥对 204 **不会**告警 ✓(复查第 83 轮 R9:以前会把规范意义上的成功记成"正文为空、说不准"✗)。
res.statusCode = 200
res.end('ok')
})
}).listen(8787, () => console.log('listening'))
安全边界(都按项目的铁律 ✓)
- 默认关 ✓:没配
webhookUrl 就一条都不订(审计日志里会明确写
「没配 webhookUrl —— 默认关 ✓,一个都没订 ✗」✓;订阅接口也会回同一句真话 ✓,
不会回"已订阅 ✓"那种假成功 ✗ —— 复查第 83 轮 R3);
- 出站地址白名单 ✓:只允许
https:// 或本机 http://127.0.0.1|localhost;
拒 file:/ftp:/data:、拒内网段(10./172.16-31./192.168.)、拒云元数据地址(169.254.169.254)、
拒带用户名/密码的 URL(https://user:pass@… ✓ —— fetch 本来也不会用它 ✓,复查 R7);
- ⚠️ 白名单是"字面串"判断 ✓ —— 挡不住「解析后才内指」的域名(
*.nip.io、localtest.me ✓)。
要挡必须在投递时用 dns.lookup 校验全部结果 ✓,这一条还没做 ✗(套件里有一条哨兵断言
把"它现在仍被放行"钉着 ✓ —— 将来做了校验,那条会变红提醒改成"必须拒" ✓)。
同理:POST /api/webhook/test 拿 PIN 可以往任意 https 主机发一次固定正文的 POST 并拿到状态码 ✓,
在缺口没补之前,它相当于一个受 PIN 保护的 SSRF 探针 ✓("能拿到 PIN = 已完全信任"是当前威胁模型 ✓,
但这不等于零风险 ✓ —— 如实写在这儿 ✓);
- 签名 ✓:对方能验真,伪造不了;
- 投递失败会写审计日志 ✓(
~/.dsh/mobile-bridge.log),不静默丢;
「2xx 但正文为空」这种"说不准收没收到"也会记一行 ✓(204 例外 ✓ 那是规范意义上的成功 ✓);
POST /api/webhook/test 与"读剪贴板/截屏"同级:要现输 PIN ✓。
还没做的(如实 ✗)
- 手机侧还没有配置入口 ✗(现在只能改电脑上的文件 + 调一次订阅接口);
- 只订了
emit 类事件 ✗ —— user-questions/request("agent 要你点头")与 approval/request 是
waterfall 类 ✓,订阅者必须调 next() 并给出决定 ✓;乱订会干扰宿主自己的流程 ✗,所以这两个
要按 waterfall 的规矩单独写并单独测;
- 投递不重试 ✗(一次不成功只记一行 ✓)—— 要重试得有退避与上限,那是后面的事;
- 正文不带会话内容 ✗(有意 ✓);想带内容需要另开一个开关并写明后果 ✓(还没做)。
个人知识库(3.3.0 起)
-
搜一搜:工具键那排的 🔍 → 输入关键词 → 结果来自宿主自己的 sessionController.search ✓
(不会唤醒 agent ✓、不建索引 ✓)。点一条结果直接切到那个会话 ✓。
⚠️ 先在电脑上挂 @deepseek-ai/dsh-session-query(+ -sqlite) ✓ —— 这台部署默认没挂 ✓,
没挂时界面会显示宿主的原话(搜不了:…)✓,不会假装搜过了没结果 ✗。
-
这周学了什么:同一张纸的 📅 → 最近 7 天有活动的会话(days 可传 1–30 ✓,夹取 ✓)。
-
⚠️ 标题那段更正:这一版最初写的是"这台宿主的列表不带标题" ✗ —— 那是我们读错了字段 ✓
(标题在 projections.values.title ✓,桥自己的 summarize() 一直读对 ✓;宿主 /api/boot 83 条全非空 ✓)。
已修 ✓:titleOfSummary() 共用 ✓ —— 所以现在列表里显示的是真标题 ✓,
短编号只是极少数兜底 ✓。把我们的 bug 说成宿主的能力缺失,是这一版最该记住的教训 ✓。
不留空白行 ✗。子会话也会混进来 ✓(筛不筛留到设置那一版 ✓)。
-
两条路都是只读 ✓、不出这台电脑 ✓;读不到就说读不到 ✓(503 + 原话 ✓),
不显示空列表假装搜过 ✗。
手机上把步骤拼起来跑(3.2.0 起)
「📅 定时」纸里点开「🧩 工作流编排」:一行一步 ✓,写几行就是几步(最多 12 步 ✓)。
- 点「▶ 跑一遍」:一步步来 ✓ —— 前一步跑完(这一轮结束)才发下一步 ✗(不会三条一起塞队列 ✓);
纸上写着“第 N / M 步”✓,随时能按「■ 停下」✓(停下之后再来的“这轮跑完”也不会继续发 ✗)。
- ⚠️ 步骤之间不传数据 ✓:agent 就在同一个会话里 ✓,上一步的产物本来就在它眼前 ✓ ——
这也是“转写 → 出提纲 → 存笔记”这种链天然成立的原因 ✓。
- 点「📅 定时跑」:每天到点把这条编排投给这个会话 ✓。⚠️ 那是另一种形态 ✗:无人值守时没人盯着
“这轮跑完”✓ → 所以整串压成一条提示词 ✓、交代“按顺序一步一步做完” ✓,让 agent 自己按顺序做 ✓。
- 点「🔁 一轮跑完自动跑」:一轮跑完就自动跑这条编排 ✓(按钮上写着是哪条 ✓,再点一次取消 ✓)。
⚠️ 需要页面开着 ✗(冻结/关掉就收不到 ✓);有三道防套娃闸 ✓:正在跑不起新的 ✓、
跑完冷却 90 秒 ✓、每小时最多 3 次 ✓(到顶会说出来 ✓,不静默 ✗)。
- 「导出(复制)」给你一份 JSON ✓(发给别人或存起来 ✓);「导入(粘贴)」是追加 ✓,不覆盖 ✗。
编排存在手机本地 ✓(dshm.workflows ✓),也进备份 ✓。
自己写一个手机面板(3.1.0 起)
一个目录 + 一个 panel.json,就能在手机上多一个面板(默认关,见下面的信任边界):
~/.dsh/mobile-panels/my-notes/panel.json ← { "title": "我的面板", "icon": "🧩", "kind": "tool", "entry": "index.html" }
~/.dsh/mobile-panels/my-notes/index.html ← 你的页面
然后在 ~/.dsh/mobile-bridge.json 里加 "panels": true ✓,手机刷新一次 ✓。
- ⚠️ 目录名(id)有规矩 ✗:只能小写字母/数字/短横线 ✓、字母或数字开头 ✓、最多 32 字 ✓
(
my-notes ✓、我的面板 ✗)。写错了手机上不会出现 ✓,但 /api/panels 会带 skippedReasons
说清是哪一条、为什么 ✗(不再只给一个数字 ✓)。
kind:tool / sheet / screen(现在都表现为"工具键 + 整屏"✓,字段留着给以后分化 ✓)。
- 页面里写相对路径就是在调桥的接口 ✓(同源 ✓)—— 例如
fetch('api/panels') ✓(那是清单 ✓;
面板自己的资源在 /panel/<id>/<文件> ✓)。
⚠️ 别把密钥段写进页面 ✗(换地址就废 ✓,而且会进 git ✓)。
- 同 id 时
~/.dsh 里那份优先 ✓(能盖掉自带示例 ✓);桥自带 📝 便签 / ⏳ 计时 两个示例 ✓。
- ⚠️ 信任边界(必须知道):面板与手机页同源 ⇒ 它拿得到手机页的 cookie ⇒
装一个面板 = 把手机端的一切交给它 ✓。所以默认关 ✓;而"谁能装"的边界是
"谁能写这台电脑的
~/.dsh" ✓ —— 那本来就是完全信任 ✓。
- ⚠️ 那个目录整个"会被托管" ✗:只要扩展名在 html/js/css/json/svg/图片 里 ✓,
任何已登录的会话都能按路径取 ✓ —— 所以别把密钥或私有配置放进
mobile-panels/ ✓。
- 桥这边只管发文件 ✓:大小上限 512 KB ✓;符号链接一律拒 ✓(
lstat + realpath 双检 ✓ ——
否则 leak.json -> ~/.dsh/mobile-bridge.json 会把 PIN 与路径密钥整套读走 ✗);
词法穿越守卫 ✓(/panel/x/../../mobile-bridge.json 读不到 ✓)。
发版前顺手跑一次 node tools/check-counts.mjs ✓:它会真跑一遍九个套件、把实测项数
和 README / CHANGELOG 最新一节里写的数字逐项比对 ✓ —— 手写项数加错过一次(2.10.0 ✗),
而"套件全绿"的断言管不到文档里的数字 ✓。
仍未消除的风险(如实列出)
- Cloudflare 能看到明文:HTTPS 在其边缘终结,prompt、会话内容、附件对它都是明文
- 会话记录无独立保护:拿到有效 cookie 即可读取全部历史
- 登录审计里的 User-Agent 是客户端自己写的:它只作为线索存进本机文件(前 120 字),绝不当证据 —— 真正的凭据始终是 PIN
- 公网攻击面是一个手写的 Node HTTP 服务:没有框架兜底
- 免费快速隧道:无可用性保证,网址每次重启都会变
- 不用时请关闭:
tools/tunnel-stop.ps1。不暴露就没有攻击面,这是最有效的一条
语音转文字:手机发的录音,模型直接"读"得到
模型读不了音频。桥会把手机发来的音频(语音备忘录、录音)转成文字交给模型;
视频里的语音也一起转(所以"发视频"= 关键帧 + 语音文字,讲解类视频全都能懂)。
用智谱 GLM-ASR,0.06 元/分钟(一分钟六分钱)。如果你已经装过本仓库的
生图插件,那份智谱 key 会被自动复用 ——
不用再配任何东西。接口限制单次 ≤30 秒,所以长音频按 25 秒切片转写,段与段之间把上一段
结果作为上下文传下去,句子不会在切口处断;超过 asrMaxMinutes(默认 10)分钟的录音不转,
并明确写出原因(省钱,也让模型知道为什么没有转写)。
~/.dsh/mobile-bridge.json 里的可选项:
{
"asrKey": "(留空 = 自动复用生图插件的智谱 key)",
"asrBaseUrl": "https://open.bigmodel.cn/api/paas/v4",
"asrModel": "glm-asr-2512",
"asrMaxMinutes": 10
}
没配 key、或没装 ffmpeg(切片要用它)时,音频会像以前一样只给路径,不影响发送。
实测:66 秒的 iPhone 讲课视频 → 239 字中文、2.2 秒、约 7 分钱。
一节课的录音(45~90 分钟)能不能识别?能,但要知道三件事
- 上传是最慢的一段:手机上行实测只有 20~70 KB/s。一节课的压缩录音约 20~40 MB,
也就是 5~30 分钟的上传时间(走分片上传,中断了能续,不会白传)。
想快:录音 App 里选「压缩」音质;或者干脆把文件拷到电脑上再让 agent 处理(见下)。
- 转写要花钱:智谱 GLM-ASR 0.06 元/分钟,一节课(45 分钟)约 2.7 元。
转写完成时消息里会写明这段花了多少(
≈ x.xx 元),不用自己算。
- 上限已经放到 3 小时(
asrMaxMinutes,默认 180)。以前是 10 分钟 —— 会把一整节课
直接挡在门外。转写本身按 25 秒切片逐段跑,45 分钟大约 2~4 分钟转完。
手机里的录音直接发过来就行;音频单文件上限放宽到 80 MB(其他文件仍是 28 MB)。
视频:手机发的视频,模型直接"看"得懂
模型本身看不了视频。桥会在电脑上自动抽 6 帧(均匀分布、每帧缩到 768 宽、JPEG),
连同一句"这是第几秒"的说明一起交给模型 —— 于是"这段视频里讲了什么"能直接答。
实测:一支 66 秒的 iPhone HEVC .MOV,抽帧 0.6 秒、6 帧合计 约 100 KB(比原片小三个数量级,
手机上行也扛得住)。原片照样留在 ~/.dsh/mobile-uploads/,路径也一并给模型,
要看细节/听声音时它自己用 ffmpeg 处理。
抽帧(以及语音转写的切片)靠 ffmpeg。插件本身不打包它(100 MB 的二进制不该塞进插件),
按下面的顺序找:
- 配置里的
ffmpegPath(~/.dsh/mobile-bridge.json,可选);
~/.dsh/bin/ffmpeg.exe;
PATH 里的 ffmpeg。
装一个(Windows,不用管理员)。两条路都行,建议下官方的 essentials 包(同时给你
ffprobe,读时长更准更快:实测 65 ms,而不是解析 ffmpeg 的日志):
# 路 A(推荐,约 110 MB,含 ffprobe;国内可能慢,几十 KB/s 也能下完)
New-Item -ItemType Directory -Force "$env:USERPROFILE\.dsh\bin" | Out-Null
$zip = "$env:TEMP\ffmpeg.zip"
Invoke-WebRequest 'https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip' -OutFile $zip
Expand-Archive $zip "$env:TEMP\ffmpeg-x" -Force
Copy-Item (Get-ChildItem "$env:TEMP\ffmpeg-x" -Recurse -Filter ffmpeg.exe)[0].FullName "$env:USERPROFILE\.dsh\bin\" -Force
Copy-Item (Get-ChildItem "$env:TEMP\ffmpeg-x" -Recurse -Filter ffprobe.exe)[0].FullName "$env:USERPROFILE\.dsh\bin\" -Force
# 路 B(国内镜像,几秒钟,只有 ffmpeg 没有 ffprobe;够用,时长走 ffmpeg 日志兜底)
New-Item -ItemType Directory -Force "$env:USERPROFILE\.dsh\bin" | Out-Null
Invoke-WebRequest 'https://registry.npmmirror.com/-/binary/ffmpeg-static/b6.0/ffmpeg-win32-x64' `
-OutFile "$env:USERPROFILE\.dsh\bin\ffmpeg.exe"
& "$env:USERPROFILE\.dsh\bin\ffmpeg.exe" -version
没装也能用:视频会像以前那样只把路径交给模型(它自己想办法),不影响发送。
HEIC 素材(iPhone 照片)
iPhone 拍的照片是 HEIC,而且扩展名经常骗人(名字叫 ..jpeg,内容却是 ftypheic)。
浏览器不一定解得了,插件里的背景/素材预览就会显示不出来。桥会按内容认出它是 HEIC
(而不是当视频),但要在任何浏览器里都能显示,最好先转成 JPEG:
powershell -File tools\heic-to-jpg.ps1 -In <HEIC 文件> [-Out 输出.jpg] [-MaxEdge 1600]
它借 Windows 自带的 HEIF 解码器(Microsoft.HEIFImageExtension)转码,只读输入、
写输出。转出来的 1600px JPEG 任何浏览器都能显示,也能直接当手机界面背景。
已知坑(作者踩过的,写在代码注释里)
-
res.write() 写到已断开的响应上不会同步抛错,而是异步 emit 'error' —— 无人监听时 Node 直接抛出,整个宿主进程陪葬。所以每一处响应都要挂 res.on('error')。
-
不要用局部变量遮蔽同名函数。曾经有一个 const record = ... 遮蔽了日志函数 record(),只在"令牌异地使用"这条路径上抛 TypeError,把整个 DSH 打死,排查了很久。后来又踩了一次:手机作答的等待记录也叫了 record,于是"提问推给手机"那一行直接把宿主打死。变量名起之前,先看一眼作用域里有没有同名函数。
-
注释里写了"要保住的东西",就得在每一条写入路径上兑现。 提问卡片的草稿注释着"存在 state 上就不会丢",但"宿主重推同一个 id"那条路径直接整体覆盖,把用户勾到一半的选择清光 —— 注释不能当实现。
-
server.close() 不会断开已建立的 keep-alive 连接。 插件重载时旧实例的会话已清空,却还在服务复用连接,手机就会拿到莫名的 401(表现为"刚重载又要我输 PIN")。要显式 closeIdleConnections() / closeAllConnections()。
-
waterfall 是顺序的,注册顺序决定谁作数。 手机应答者必须 prepend 在官方 api-remotes 之前,否则永远不会被调用(官方那一环会一直等到浏览器作答)。
-
超时和取消要分开。 超时该 next() 交还桌面;取消(agent 已放弃)绝不能 next(),否则桌面上会弹出一个已经作废的批准框。
-
退出前要同步落盘。异常处理器里用异步 appendFile 再 process.exit(),写入不会完成 —— 探针会把证据弄丢。
-
pnpm/npx 建的联接,Target 可能是相对路径,重建时要相对原联接所在目录解析。
-
robocopy 默认跟进目录联接,会把链接指向的整棵树也复制过来。
-
str.Replace() 替换的是【全部】匹配,不是第一处。 曾用带锚点的替换插入 /api/revoke 路由,而那个锚点在文件里出现了两次:一处正确,另一处落在无密钥的 404 分支里、位于 const route 之前 —— 于是手机浏览器自动请求 /favicon.ico 时抛 ReferenceError: Cannot access 'route' before initialization,整个宿主进程直接退出。表现是"手机一连上就崩"。插入唯一代码块必须用强制唯一匹配的编辑方式,或先断言出现次数。
-
配置重写要带上所有字段。 loadConfig() 只写它列出的字段;新增的排查开关漏掉,就会被下一次"配置有变化"的重写悄悄抹掉(crashProbe 踩过)。
-
测试会因为路径形态漂移而静默烂掉。 页面改成相对地址(api/...)后,UI 测试的 mock 仍在按 startsWith('/api/...') 匹配,于是每个请求都 404 —— 测试不是失败,是整个不工作了。同一类漂移还让 harness.mjs 一次废掉 37 条断言(它的 base 里没有密钥段,于是 PIN 门、cookie、SSE、取消、余额缓存全部 404),让 shot.mjs 永远拿不到截图。规则:测试里凡是拼 URL,都必须从配置里读密钥段,绝不硬编码绝对路径;mock 要按浏览器语义把相对地址解析到带密钥段的页面地址上,并断言"没有请求跑出密钥段"。
诊断纪律:先复现,再治本(治表要被当成失败)
这个项目里最贵的几次返工,都是没复现就动手造成的。写成规矩,省得下次又犯:
- 先复现,再改代码。 用户报的现象必须在真环境里重现出来(真浏览器 + 真桥,
必要时真流式输出),或者从日志/崩溃遗言里拿到确凿证据。工具表里那几个
*-bench.mjs / scroll-live.mjs / shot.mjs / phone-diag.mjs 就是干这个的;
需要新的复现手段,就先写工具。
- 复现不了就明说,不许靠猜。 说清"我拿到什么证据、还差什么",然后去加测量点
(例如
window.__dshBoot() 这种读数),而不是先塞一个"看起来能缓解"的改动。
- 区分"症状"和"根因",改完要能解释为什么它不会再犯。 判据:这条改动如果拿掉,
问题会不会回来?会 —— 那它治的是根因。不会 —— 那它只是挡了一层。
- 不许用"性能"当估算的借口。 典型反面教材:
content-visibility 让滚动容器里的
块按估算高度参与 scrollHeight,用户往上一滚内容就被顶回去 —— 那是拿正确性
换性能,而实测它连性能都没换来(去掉之后流式期间最长掉帧 108ms → 61ms)。
要性能就先量,量不出收益就别加。
- 同一个 bug 第二次回来时,先怀疑上一次的修法是治表。 "回弹"就是这样:第一次改
阈值和键盘(治表),第二次才挖到"边滚边改 DOM 会被 iOS 复位滚动位置"(根因)。
- 修完把根因写进这份 README 的坑表(现象 → 证据 → 根因 → 为什么不会再犯),
并在测试里钉住根因,而不是钉住那个数字。例:现在
ui.mjs 断言的是
"页面里不用 content-visibility"(根因),而不是"阈值等于 24px"。
开始页上的版本说明(1.8.0 起)
开始页两处都写着版本,报障时不用再问"你装的是哪一版":
- PIN 登录页底部:
手机桥 v1.8.0;
- 「开始使用」说明页:版本号 + 最近三版的更新要点(只挑"加 / 改 / 修"这类用户能感知的,
不带"测试""实现细节")。
两个数字都是现读的,不是手抄:
| 显示什么 | 从哪儿读 |
|---|
| 版本号 | package.json 的 version |
| 更新要点 | CHANGELOG.md 头部最上面三节的小标题 |
手抄一份的下场是迟早对不上,而版本号一旦开始撒谎,排查就没有依据了。读不到就整块收起来
(不会变成报错)。更新清单以 <script type="application/json"> 注入,客户端用 textContent
建节点 —— CHANGELOG 是我们自己的文本,但"把文本当 HTML 塞进页面"这种事一次都不做。
顺带修掉的一个真坑:页面里的注入标记出现不止一次(版本标记在登录页和说明页各一处),
而 String.replace(字符串, …) 只换第一处 —— 结果是说明页上摆着一串
__DSH_BRIDGE_VERSION__ 给用户看。现在全部用 replaceAll,harness 里也钉了一条
"注入标记一处都没漏"。
手机打开电脑上的文档(2.1.0 起)
会话里出现的文档——agent 在电脑上写的文稿、导出的 PDF、脚本、数据表——在手机上多一个「打开 ›」:
| 类型 | 手机上怎么呈现 |
|---|
| Markdown / 文本 / 日志 / YAML / 代码 / CSV / JSON | 直接在页面里渲染(Markdown 会做标题/加粗/列表格式化) |
| PDF | 用 iframe 打开,手机浏览器自己翻页 |
| 图片 / 音频 / 视频 | 直接看 / 听(原有能力) |
| Word / Excel / PPT / 压缩包 | 浏览器渲染不了 → 页面会明说,并指向右上角「下载」(用手机上的 WPS/Office 打开) |
范围是最小方案:只服务会话里给你的文件,不做目录浏览 ✓。
安全上有两条硬的:
- 文档走
/api/file,路径必须在桥认识的目录里(工作区 / 会话目录 / ~/.dsh / 临时目录)✓;
- 密钥类文件一律 403:
mobile-bridge.json(PIN 明文)、.credentials.yaml(模型 key)、
image-gen.json(生图 key)、.env*、*.key/*.pem/*.pfx/*.p12/*.keystore 都不通过手机发送 ✓
(加文档类型时发现的洞:.json 一度让它们变成"点一下就下载")。
- 文档另有 25 MB 上限(手机上打开几十 MB 的文件只会卡死)。
顶端显示当前模型(2.6.0 起)
网页最上面(标题栏正下方)有一行 「当前模型」:
- 值来自宿主(每一行会话带的
modelSelection 投影),所以在电脑上换过模型,手机这里也跟着变 ✓
—— 2.4.2 那个"手机永远显示默认"的已知限制在这一版修掉了。宿主在流里推的
request/header(下一次请求真用哪个)和 model/selection(刚选了还没发)都会立刻反映过来,
不用重开页面。
- 它同时是切换器:点开就能换,落在当前会话上(和「新建」面板里那份目录是同一份)。
- 宿主报出来的模型不在目录里时(provider 被停用、目录没拉到)也照样显示那一个,
不会静默跳到第一项 —— 这个功能的全部意义就是"看到正在用的到底是哪个"。
删除会话(2.6.0 起)
会话列表里每一行右边多了一个 🗑(在 📌 / ⭐ 后面)。点它打开一张确认纸:
- 必须现输 PIN(就是登录手机桥那个 6 位 PIN)—— 每次都要,不复用"最近提权过"。
这是手机上唯一不可从手机撤销的操作,所以门槛单独抬一级:手上有一部已解锁手机的人,
不能直接把你几十个会话从列表里清空。PIN 错了就地报错(不会把你踢回登录页),
而且同样进登录限速 —— 它不是可以无限试的接口。
- 正在跑的会话不会被悄悄删掉:宿主先拒绝,页面把按钮换成「停掉它并删除」,
你再点一次才真的连正在跑的一起停。
- 删完列表立刻刷新;如果删的正好是你正在看的那个,页面会自动让到另一个会话。
它到底是"删"还是"归档"?
是归档(宿主原生的 archiveSession,桌面端会话菜单里那一项就是它):
- 会话不再出现在手机和电脑的会话列表里;内容仍留在电脑上,在电脑端可以取消归档找回来;
- 桥不会自己去删会话文件。手删会话日志(JSONL)会绕过宿主的索引、附件目录和派生会话的
血缘 —— 那是真会弄坏东西的做法,所以这条线不碰。
列表默认不显示哪两类会话
用户报过"agent 子对话和测试对话占位置",所以这两类默认不摆出来:
| 类别 | 判据 | 为什么 |
|---|
| agent 子会话 | origin: 'subagent' 或带 parentSessionId | 手机本来就打不开它们(宿主只认父会话地址) |
| 自检 / 开发会话 | cwd 在桥自己的目录里 | 那是开发测试跑出来的("输出数字1到1200"这类),不是你的工作区 |
两条都只是手机这一侧的显示规则:宿主的会话数据一个字不改,桌面上照旧全都能看到。
列表底下会写明"已藏起 N 个",并有一个 「显示全部会话」 开关(记在手机本地)能看回来 ——
不静默过滤、也不会让人以为会话丢了。
从 2.x 升到 3.0 要做什么
什么都不用做 ✓ —— 直接升级、手机刷新一次就行 ✓(所有新功能都默认"不动你现在的习惯" ✓)。
想让新东西生效,只有两三处可选动作:
| 想用 | 要做什么 |
|---|
| 有事通知我 | 用 https 的网址打开手机页(https://…ts.net ✓)→ 在「🩺 一键自检」里勾上「有事通知我」✓ |
| 说出来 → 文字 | 同上,必须是 https(浏览器只在安全地址给麦克风 ✗)→ 「🎤 录音」纸里点「说一句」✓;电脑上第一次用要先点「准备识别器」✓ |
| 念出来 | 不用配置 ✓(用手机自带的声音 ✓);点回答旁边的 🔊 ✓ |
| 新设备要你点头 | 在 ~/.dsh/mobile-bridge.json 里加 "deviceApproval": true ✓,然后到电脑上打开 /approve 放行 ✓(⚠️ 默认关 ✓;一台可信设备都没有时自动放行 ✓,不会把你锁在外面 ✓)。放行是按设备记的 ✓(3.4.1 起 ✓):同一台手机换网络 / 换地址 / 登录过期后重登,都直接进 ✓;只有没见过的设备才要你点头 ✓ |
| 弱网模式 | 不用配置 ✓;在「🩺」里勾上,或等它在你发得慢时主动问你要不要开 ✓ |
⚠️ 两个环境前提(不是配置能绕过的 ✗):通知和麦克风都要求浏览器认为你在"安全地址"上 ✓
(https 或 localhost ✓)——用 http://100.x.x.x:3081 这种局域网地址时,浏览器直接不给,
插件会如实告诉你,并保留"标签页标题提醒"这个兜底 ✓。
把手机当第一屏(3.0.0 起)
- 离线优先:写到一半的话自动存住 ✓(点一下输入框就回来 ✓);整屏离线时能直接读到上次的对话 ✓、
能接着写 ✓、并且一眼看到"有几条还在等着发" ✓。
- 有事一定找得到你:要你拍板 / 一轮跑完 → 页面在后台就弹通知 ✓(正看着时不打扰 ✓;
几分钟的一轮才叫 ✓);点通知回到页面 ✓。安全地址之外用标签页标题兜底 ✓。
- 说 → 文字 → 你看一眼再发:转出的文字只进输入框 ✓,插件绝不替你发出去 ✓。
- 念出来:助手回答旁的 🔊(代码不念 ✓、超 1200 字只念一段并说明 ✓、再点一下停 ✓)。
- 新设备要你点头(可选,默认关):电脑上
/approve 放行 ✓;防锁死三条见上表 ✓。
- 连不上自己撑住:带倒计时的重试 ✓、切到当前地址 ✓、改用局域网地址 ✓(会先判能不能用 ✓)、
离线读缓存 ✓、排查清单 ✓。
从 2.9 到 2.10(键盘手感 / 长回答 / 弱网 / 看图)
……见下面各节 ✓(README 这一版按"第一屏"重排 ✓,旧的说明都还在 ✓)。
看图 / 相册滑动(2.11.0 起)
点开一张图(或视频)进全屏:
- 左右滑(或按 ‹ ›;桌面上按方向键)在同一组之间翻,计数写着
3 / 7。
"一组" = 点的那张所在的气泡/交付块;只有一张时退回整个对话(最多 40 张)。
- 📌 钉住 = 并排对比(挑封面):钉住的那张留在左边不动,另一张随手指翻。
钉住当下不会把同一张复制成两份。
- 选这张 = 把这个素材的电脑路径写进输入框(
data-path;没有路径就退而写链接),
发出去就等于告诉 agent 用哪张。
- ⚠️ 全屏看图器开着时,页面级左右翻页会让开 —— 不然在看图器里滑一下,底下的页面也跟着换。
弱网模式(2.11.0 起)
开关在「🩺 一键自检」纸里(不往工具键那排再加键)。开了之后:
- 文字立刻发;图片/文件进"留着"的箱子,有网时自动补发(复用离线发件箱那条路:
requestId 幂等 + 20 秒重试)。代价如实说:附件是单独再发一条,agent 晚一轮看到。
- ⚠️ 存不下就绝不"留着":附件是 data URL,localStorage 只有 ~5MB ——
超过 1.2MB 或写入失败时**退回"一起发"**并说清原因(刷新后附件消失是最不能犯的错)。
- 发送中屏幕上写「📡 正在发送…」,发完自己收走(只收自己写的那句,不吞别人的提示)。
- 一条发得超过 6 秒 → 主动问一次要不要开(一个会话只问一次)。
读长回答 & 键盘手感(2.10.0 起)
工具键那一排里的 ⌨ 是这一批的两个入口(它俩都只记在这台手机上,也进备份):
打长句子顺手
- ↑ 取上一条已发消息(↓ 往回翻;只记最近 20 条、单条 >4000 字的不进历史):只在"不会抢活"的时候接管 ——
光标不在第一行时,↑/↓ 还是正常的"看上一行"。一打字就退出翻历史状态(所以取回历史后再改字不会被抹掉)。
⚠️ 历史是这台手机共用一份、不分会话(像终端的 history 一样)。
- Enter 可直接发送:默认不勾(Enter 就是换行,老习惯不动);勾上后 Shift+Enter 仍换行、
Ctrl/⌘+Enter 一直是发送。
- 长按输入框再左右拖 → 移光标:默认关(长按本来会触发系统的"选词 / 放大镜",硬抢只会两败俱伤)。
长回答不刷屏
- 超过 22 行的回答只露开头 8 行,其余点「展开全文」;超过 18 行的代码块同样默认折。
折叠用的是原生
<details> —— 展开/收起不重排、不跳滚动。
- 代码高亮(零依赖手写:关键字 / 字符串 / 注释 / 数字),铁律是只加标签、不改内容 ——
对代码块有一条往返断言(渲染出来的文字必须与原文一字不差,样本含转义反斜杠 / 反引号 /
URL 里的
# / 十六进制 / 中文 / emoji)+ 一个注入探针。⚠️ 两处口径边界如实写在这儿:
· 行内格式(**粗**、`代码`)不是往返 —— 它本来就是 Markdown 渲染,标记会被吃掉;
· \r\n 会归一成 \n(和 textarea 一致);单条 >8000 字符的代码块不做高亮(只转义,保内容)。
- Markdown 表格画成真表格,外面那层声明了
overflow-x: auto(⚠️ jsdom 没有布局引擎,
"真的滑得动"这条只能真机看 —— 测试里也这么写的)。
- 单条 >24KB 会降级成纯文本(流式输出每 80ms 重画一次,长文里做排版会把帧率吃光)。
- 想全部铺开:⌨ 里把「长回答默认折叠」关掉,已有对话立刻重画。
收起看(2.9.0 / 2.9.1 起)
手机上竖着的地方最贵,所以上下两条栏都能收 ✓:
- 底部工具键:输入框左边的
⋯ —— 收起的只有那 13 个工具键;输入框、发送、页签栏、
附件缩略图一个都不藏。开关自己一直看得见(放进被收起的那排 = 再也找不到)。
- 顶栏:最左边的
⌃ —— 收起后只剩状态点与开关(一条细缝);标题、「说明 / 刷新 / 退出」、
「当前模型」那行一起让位。「已跑 X:XX / 本轮 token」不跟着收(它只在跑任务时出现,
收掉等于把功能藏了)。
- 两个状态都记在手机本地(
dshm.tools / dshm.top),刷新、切回 App 都记得,也进备份白名单。
- ⚠️ 实现上有一条别踩的坑:
#tools 里还嵌着「只看不发」「离线发件箱」两条横幅和三张纸 ——
收 #tools 会把它们一起 display:none 掉(祖先隐藏,position:fixed 也救不了)。
所以收起的是新加的那层 #toolKeys(只包工具键)。
备份 / 换手机(2.9.0 起)
🩺 那页里的「💾 备份 / 换手机」:把这台手机上的设置导成一个 JSON 文件,新手机 / 重装 PWA /
清了站点数据之后导回来就还在(置顶、常用、用得多的顺序、只看不发、收起工具键、上次停在哪个会话)。
- 只备份"这台手机上的"东西:电脑那边的配置(PIN / 端口 / 隧道)不用备份 —— 换手机不动它。
- 两条故意不备份:网址里的密钥段(
dshm.canonical / dshm.addresses —— 备份文件是要发给自己、
存网盘的,把访问密钥抄进去等于给文件配把钥匙),以及离线发件箱(恢复一份几天前的备份不该
替你把过时的消息发出去)。
- 导入按外部输入对待:只认白名单键、只收字符串、有大小上限;坏文件当场说"没恢复",
白名单外的键跳过并报数。恢复后置顶 / 常用 / 只看不发立刻生效,"上次的会话与模型"刷新一次才生效
(这一点写在界面上,不假装全都立刻好了)。
自己会看病(2.8.0 起)
手机上一个 🩺 键 + 一个 🛡 键,配桌面角标上的报警:
- 🩺 一键自检:七条只读检查(桥在不在听 / 桌面端在不在听 / 开机自启装没装 /
有没有会话在跑 / 外网隧道通不通 / 上传目录能不能写 / ffmpeg 在不在),每条都写"怎么办"。
它只看不动 —— 不会替你启动任何东西(那是
tools/boot-up.ps1 的活)。不含"publish 目录干净"
这类维护者检查项:插件不该知道你的发布目录在哪。
- 🛡 安全审计:谁在什么时候从哪个地址登录过、成没成,一行一行列出来(还有"当前还在登录的
设备有几台")。看这份账要现输一次 PIN —— 里面是这台电脑的网络记录(和"看剪贴板 / 截图"同一级)。
新地址第一次成功登录会在桌面角标上报警 —— 提示里写明"手机换 WiFi / 切流量时地址本来就会变,
走外网时那是隧道那端的地址",免得把自己吓一跳。发现不对,页面上「踢掉其它设备」用刚输的
PIN 就能把别的全部下线(不会把你自己这台踢掉)。
- 📦 诊断包(在 🩺 那一页最下面):下载一份文本 —— 自检结果 + 最近日志 + 登录账 + 审批账 +
崩溃日志。PIN 与网址里的密钥段会被替换成占位符,每份日志只截最近 48KB。
出问题时先下这一份发出去,比描述一百句都准。
- 实时耗时 / token 跳动:跑任务时顶部显示
⏱ 已跑 1:05 · 本轮 ↑9.2k ↓340 · 本会话累计 ↑12.3M ↓106k。
计时起点取自 turn/start 事件自己的时间(刷新页面把这一轮重放回来时,计时接着走,
不是从打开那一刻重算);token 来自宿主的 usage(assistant/message 里的每轮用量)与
tokenUsage 投影(会话累计)。读不到就不显示那一段 —— 宿主没报就只显示时间,绝不编数字。
- 发送失败的「重试」:条幅上有一个能点的键,点一下把刚才那条重发(输入框与附件都还在)。
断网那种失败另走「离线发件箱」(2.7.0 起),两条路不重叠。
会话置顶与常用(2.5.0 起)
任务页顶部原来是一个原生下拉选会话 —— 放不下 📌 / ⭐ 按钮,也没法分组排序。
2.5.0 换成自绘的分组列表:顶部一行只显示"现在在哪个会话"(📌/⭐ 会一起标在标题前),
点开就是三组:
| 分组 | 谁在这里 | 怎么排 |
|---|
| 📌 置顶 | 你钉过的会话 | 按钉的顺序,先钉的在前;重启手机也还在 |
| ⭐ 常用 | 你打星的 + 用得最多的前 3 个(自动) | 次数降序 → 平手按最近活动 |
| 🕘 最近 | 其余全部 | 最近活动降序 |
- 每行右侧两个小按钮:📌 = 置顶开关、⭐ = 打星开关(点一下标上、再点一下取消,
按钮本身会亮起来);点行本身 = 切到这个会话。
- 「常用 A(按使用频率)」就是上面那条自动排序:切过去 +1、发一条消息再 +1;
「常用 B(手动打星)」是你自己说了算 —— 一次没用过也能进「常用」。
- 「打不开的会话」(agent 自己派生的子会话)在那一行里写明 ⚠️ 打不开,照样能点进去看原因。
- 三份数据只存在这台手机的浏览器里(
dshm.pins / dshm.stars / dshm.usage):
不写宿主的会话库、不给宿主加字段 —— 所以电脑端完全不受影响,换台手机也不会互相干扰。
- 触摸上有一条硬规矩:从 📌/⭐ 上起手时拦掉
touchstart 的冒泡 —— 否则按着那个小按钮
横向拖一下就会被当成"翻页"。列表空白处照样能左右滑换页(滑动换页一个字没改)。
左右滑动换页(1.7.0 起,2.0.0 补齐面板)
底下一栏是三个页面:任务 → 新建 → 余额。除了点那三个键,左右滑动也能换页:
往左滑 = 下一页,往右滑 = 上一页(用户举的例子:在「新建」上往左滑就是「余额」)。
「新建」其实是一个盖在当前页上的面板(当初它是页签时,主按钮正好在页签栏上方,
拇指瞄页签会误建会话并立刻产生一次计费回合),但滑动按用户心里的三页顺序走,
不按 DOM 里那两个 view 走。
录音 / 生图 / 背景 / 权限这几个面板上也照样能滑(2.0.0 起,用户报的
「左右滑动时录音界面不行」):划走时那个面板自己关掉、直接翻到下一页。
1.7.0 时这四个"工具抽屉"开着会整个挡掉滑动 —— 用户的感受是"滑动时灵时不灵",
而"时灵时不灵"比"不支持"更难理解。
三条让路的规则(错了就会变成"东西自己动了"这类最难解释的故障):
| 情况 | 行为 |
|---|
| 竖着为主的滑动(横向位移不到纵向位移的 1.2 倍) | 当滚动,绝不翻页 —— 用户在看历史时页面不能自己跳走 |
| 从输入框 / 下拉 / 播放器上起手,或从横向能滚的元素(代码块)上起手 | 不翻页(那是在选文字 / 调进度 / 看内容) |
| 说明页、提问卡片、离线整屏开着时 | 背后不换页(这三处必须先处理掉;离线整屏 2.0.0 起也不再被穿透) |
| 正在录音时 | 不翻页,并弹一句"先按「停止」"(面板上就是停止键,划走了够不着它) |
| 生图里写了提示词时 | 先 confirm 一句再走(和「新建」同一套) |
另外两条:幅度不到 60px 当点击;左右到头时只提示一句("已经是最左边了(任务)")。
离开「新建」前如果那一页写了东西,会先 confirm 一句再走。翻页时进场动画只是"确实翻了"的
即时反馈,prefers-reduced-motion 的人看不到它。
提示走的是 #toast,它的 z-index 必须高于所有会盖住它的东西(面板 80 / 加载页 90 /
离线整屏 95 → toast 120)。1.7.0 时它是 60,于是这些提示在面板上根本看不见 ——
看不见的提示等于没提示,用户就会以为"在这儿滑动没反应"。
上滑出现「回到底部」的箭头(2.0.0 起)
往上翻着看历史时,屏幕下方会出现一个圆形箭头「↓」:
- 手指还按着往上滑的过程中就出现(不用等松手);
- 点它 → 立刻回到底部、恢复"跟着新消息走",箭头自己收起;
- 已经贴到底部时不出现(不做常驻的无用按钮)。
旧版(1.7.0~1.8.x)这个入口是"12px 灰字小胶囊 + 松手 160ms 后才出现",
用户报的原话是"上滑就会出现的会到底部的箭头" —— 也就是他压根没看见旧的那个。
网页里直接录音(1.5.0 起)
最底下那一栏(任务 / 新建 / 🎤 录音 / 余额)里的「录音」:点开就是一个录音面板(大计时器 + 电平条),录完可以先听一遍,
满意再发。发出去之后会和别的音频一样自动转成文字交给模型。
- 为什么要在网页里录:手机上"用系统录音 App 录 → 找到那个文件 → 分享给某个应用"
这条路很难走;而且系统录音默认音质一节课 20
40 MB,在 2070 KB/s 的上行下要传半小时。
网页里录的是 opus 约 24 kbps —— 一节课约 10 MB,三分之一的体积。
- 两个前提:① 浏览器只在安全地址下给麦克风 —— 用
https://…ts.net(Funnel)
可以;用 http://100.x.x.x:3081(Tailscale 局域网 IP)浏览器会直接拒绝;
② 录的时候别锁屏、别切到别的 App(iOS 会掐掉网页录音)。
- 录音走的是和「文件」完全一样的上传路径(分片、可续),所以长录音也发得出去;
音频单文件上限 80 MB。
- 体积:1 秒约 3 KB。45 分钟的课:
| 网页录音(opus 24k) | 系统录音 App |
|---|
| 体积 | 约 8 MB | 约 20~40 MB |
| 在你那条上行上传 | 约 3~7 分钟 | 约 8~30 分钟 |
手机上生视频(1.4.0 起,用同一个「生图」键)
「生图」面板顶部有 🖼 图片 / 🎬 视频 两个模式。切到视频,写一句描述发出去,
agent 就会调电脑上的 video_generate 出片:
- 走智谱 CogVideoX,
cogvideox-flash 免费(文生视频 / 图生视频,最高 4K),
用的是生图那份智谱 key —— 电脑上装过本仓库配套的生图插件、填过 key,就直接能用;
- 出片是分钟级的(免费模型实测 56~63 秒一段,高峰期排队更久);
- 出来的 mp4 落在
~/.dsh/生成视频/,手机上直接显示成能播放的视频,长按可存相册
(手机端本来就会把文本里的 .mp4 路径渲染成播放器);
- 要更清晰、要带音频,可以让 agent 用
cogvideox-3(按量计费)。
手机桥这边不直连视频后端(和生图同一个取舍):产物必须落进当前会话,才看得见、存得下。
连不上电脑时,看到的是一屏说明(1.6.0 起)
用户原话:「连接不上时不是黑屏就是白屏」。两种成因都真实存在:
- 黑屏:设了背景图/视频,而它取不到 → 深色底上什么都没有;
- 白屏:连不上、又没有内容可渲染 → 只剩一个空壳。
现在这两种情况都会换成一整屏说明(不透明、压在最上层、失败时顺便把背景收掉):
| 屏幕上告诉你的 | 你能做的 |
|---|
| 哪一类故障:手机自己没网 / 从没连上过 / 用着用着断了 —— 三句不同的话 | 照着第一句排查(多半是电脑睡了) |
| 10 秒后自动重试(倒计时就写在屏幕上) | 也可以点「立即重试」 |
| 先看上次的对话(离线可读) | 手机本地缓存的那份能读,等连上再发消息 |
| 切到当前地址 / 改用局域网地址 | 换过隧道地址之后,旧地址是要手动切走的 |
一份排查清单:电脑睡没睡 / DSH 还在跑吗 / Tailscale 两端 / 是不是旧地址 / tools\tunnel-status.ps1 | 不用自己猜 |
两个细节:重试是"原地重跑一次 boot",不是整页刷新 —— 你正在读的东西不会被刷掉;
401(PIN 过期)不会弹这一屏,那时候该出现的是登录页,弹"连不上电脑"只会把人引去查网络。
兼容性
- 需要 DSH(peerDependency:
@deepseek-ai/cordis)
- 纯 ESM,无构建步骤,无运行时依赖(只用 Node 内置模块)
- 只在 Windows 上实测过;监听、认证、页面都是平台无关的
量"进插件"要多久
node tools/measure-boot.mjs <手机桥网址> <PIN> # 老用户(本地记着上次那个会话)
node tools/measure-boot.mjs <手机桥网址> <PIN> --cold # 首装(没有本地标记,会多拉一次对话)
真浏览器跑一遍,报"界面可见 ms / 对话有内容 ms"、这次打开的请求数,还有一条请求时间线(哪个请求在什么时刻发出去)—— 慢的时候一眼能看出时间被哪个请求吃掉了,以及接口之间是串行还是并行。
本机 127.0.0.1 实测(工具会先按"老用户"给页面塞回 dshm.session,量的才是每次进来的真实路径):
界面可见 327 ms、对话有内容 500 ms,只发 1 次 /api/boot、0 次 /api/transcript。
手机上还要加上隧道往返,但数据量已经靠 gzip 压到 1/4~1/5(首屏 122 KB → 41.7 KB,
对话快照 202 KB → 42 KB),第二次打开还走 service worker 的缓存壳。
⚠️ 隧道慢的时候(tailscale netcheck 显示最近 DERP 在旧金山就是信号)别硬扛:
手机和电脑在同一个 WiFi / 热点下时,直接用 bootstrap 里报的局域网地址
(http://<WLAN IP>:3081/<密钥>/)比走 Funnel 快一个数量级。
手机上直接生图(要额外装一个插件)
「生图」键本身只是个入口:它把「请用 image_generate 生成一张图片…」这句话交给当前会话,
真正出图的是电脑上那个 image_generate 工具 —— 它是一个单独的 DSH 插件,不在本仓库里。
- 装了(会话里能看到
image_generate 工具)→ 点「生图」就出图,图落在会话里,可点开、长按存相册;
- 没装 → agent 只会回一句"没有这个工具 / 没有生图模型"。这不是手机桥坏了。
装法(一条命令):
dsh plugin --profile web add github:jxboop/dsh-plugin-image-gen
仓库:https://github.com/jxboop/dsh-plugin-image-gen(自带构建产物,装上即可用)。
装完重启 DSH,再刷新手机页面。验证:在电脑上开一个新会话,直接说"用 image_generate 生成一张图",
能出图就说明插件装好了。
免费生图后端(pollinations / HF Space)不太稳:会被限流(约 15 秒一条)、配额用尽时返回 402,
高峰期可能连续失败。真想稳定出图,去 https://api.together.xyz 免费注册拿 key,
填进插件配置的 together.key 并把 providers 设为 ["together"]。
换手机界面的背景
在手机上点工具栏的「背景」,选一张图片 / 动图(GIF)/ 视频就完事:
- 点「背景」→「选图片 / 动图 / 视频」;
- 选好的文件会传到电脑、写进配置,当场铺满界面(不用刷新,也不用重载插件);
- 想换就再选一张;想关掉点「去掉背景」。
几点说明:
- 背景是给这台电脑配的:连上来的手机看到的是同一张。
- 文件存在电脑的
~/.dsh/mobile-uploads/(只允许这个目录,.. 逃不出去),不占手机空间;
手机第一次打开下一次、之后走手机本地缓存(背景地址带 ?v=<大小>-<修改时间>,换背景必然换缓存)。
- 支持
.gif/.webp/.png/.jpg 和 .mp4/.mov/.webm;扩展名会骗人,服务端按内容认类型。
- 手工改
~/.dsh/mobile-bridge.json 的 backgroundFile 仍然有效(老办法),但现在没必要了。
跑测试(改了代码之后必跑)
cd <这个插件目录>
node test/package.mjs # 打包清单、文本编码自检(乱码/BOM)
node test/harness.mjs # 宿主接口与闸门(最多)
node test/qr.mjs # 二维码编码器(含**往返解码**)
node test/ui.mjs # 手机页面(jsdom)
node test/security.mjs
node test/crashprobe.mjs
node test/addresses.mjs
node test/offline.mjs
node test/platform.mjs # 解锁那三种结论的纯函数(跨平台命令形态)
node tools/check-counts.mjs # 跑上面九个 + 核对文档里的项数
-
test/voice.mjs(33 条,语音草稿)不在上面这串里 —— 语音那一版按用户要求先跳过;
它留在仓库里、可以单独 node test/voice.mjs 跑 ✓,但不进门槛 ✗(别把"没验过"说成"验过了"✓)。
-
有两条需要仅测试依赖(jsdom 给界面测试 ✓、jsqr 给二维码往返解码 ✓)——
它们放在 ..\_verify 里,和插件运行时无关:插件本身零运行时依赖 ✓。
-
缺 jsqr 时 test/qr.mjs 会显眼地 SKIP 那条往返解码 ✓(打印一行 SKIP + 写明怎么装 ✓),
结果是「11 通过 + 1 跳过」、exit 0 ✓ —— 既是"不是失败",也不算验过 ✓。
发版前请在装了 jsqr 的地方跑一次,确认往返解码那行是 PASS ✓(不然等于没验编码器 ✗):
npm i --no-save jsqr。
-
发布目录(publish/)里没有那个 _verify ✓,所以在那里跑会走 SKIP ✓ ——
要真验二维码就回源码目录跑 ✓。
License
MIT