dsh-plugin-remote-access
给 DeepSeek Harness(DSH)桌面版用的插件:把本机 DSH 的网页界面只开放给你指定的网段
(默认 Tailscale 的 100.64.0.0/10),于是手机或另一台电脑也能用上它。
- 不绑
0.0.0.0:只监听「允许网段」里属于本机的地址,网段外的对端一律 403。
- 不写死地址:网段、监听地址、端口、上游、落地文件全是配置项。
- 两种进入方式:复制带令牌的网址;或者设一个密码,手机开裸地址输一次(之后 30 天免密)。
- 手机界面能看:「远程UI布局」可选手机 / 电脑 / 自动;手机布局只作用于远程页面,本机界面一个像素都不动。
- 手机上的官方界面偏好能留下来:「UI 设置」把「通用设置」里的外观 / 字号 / 工作步骤展示 / 显示代码工作视图 /
性能与用量存进本插件的配置(默认「出厂值」=不干预官方默认),远程页面每次打开都重新套一遍 ——
官方那几项在手机上是「改完刷新即还原」的。
- 在设置页里改:保存即热重载(原地关掉旧监听、按新参数重开),不用重启 DSH。
- 失败可见:无论成功失败都会写一份自诊断文件,看不到 DSH 日志时也能定位。
安装
三种方式,选一种即可;装完重启一次 DSH。
发布记录:GitHub Releases
(每个版本的说明与 APK)· npm
(插件包,latest 就是当前版本)· 更新日志。
① 插件页(推荐) —— 打开 DSH →「设置 → 插件 → 添加插件」,填下面任一:
| 填什么 | 从哪里装 |
|---|
dsh-plugin-remote-access | 从 npm(1.2.0 起已发布,装起来最快) |
github:Mzy123l/dsh-plugin-remote-access | 直接从 GitHub(要与仓库同步的最新代码时) |
| 本机某个目录的绝对路径 | 自己 clone / 下载下来时 |
⚠️ npm 上那两个旧名字 —— dsh-remote-access-cidr(我们自己发的,停在 1.0.0)和
dsh-remote-access(别人的包)—— 都别再用来安装。当前名字是 dsh-plugin-remote-access:
https://www.npmjs.com/package/dsh-plugin-remote-access。
(页面上那个 0.0.0-stage 是 npm 给新包名自动建的占位版本,latest 才是当前版本,不用管它。)
📛 改名(1.2.0):dsh-remote-access-cidr → dsh-plugin-remote-access。
bundle 的插件行是按包名装载的,所以旧来源装的那份不会自己跟过来:到「设置 → 插件」里把旧条目
移除,再按新名字添加一次。移除有可能一并清掉 profile 里 cordis.patch.yml 中 id: remote-access
那一行的 config:(网段 / 密码 / UI 设置都在里面)—— 想留参数,先把它抄下来,装好再粘回去。
② 命令行(等价于插件页那一步;DSH 正在运行时 profile 写锁会占住):
dsh plugin --profile desktop add dsh-plugin-remote-access
# 想跟仓库最新代码:dsh plugin --profile desktop add github:Mzy123l/dsh-plugin-remote-access
# CLI 若不认这两种写法,就填本机目录的绝对路径
③ GitHub 一键取代码 —— 想把源码放到本机再装:
irm https://raw.githubusercontent.com/Mzy123l/dsh-plugin-remote-access/main/install.ps1 | iex
脚本只负责把代码取到一个固定目录(默认 %LOCALAPPDATA%\dsh-plugins\dsh-plugin-remote-access),
装的动作仍然交给 DSH 自己(插件页填那个目录)。需要代理时:
$s = irm https://raw.githubusercontent.com/Mzy123l/dsh-plugin-remote-access/main/install.ps1
& ([scriptblock]::Create($s)) -Proxy http://127.0.0.1:7890
为什么不一步装完:DSH 的插件安装(写 profile 的 package.json / cordis.patch.yml、跑包管理器)
只能由 DSH 自己做(插件页,或让 agent 用 plugin_manager 工具),手工改 profile 容易把它弄坏。
之后改参数都是热重载。改代码分两种:client.js(浏览器半区)刷新页面就会重新拉取;
index.js(宿主半区,含新增配置字段)要重启一次 DSH 才会加载。
安卓 App(APK,可选)
不想在手机浏览器里手打地址的话,可以用配套的外壳 App:
下载最新 APK
(全部 Release · 源码在 android/ · 说明见 android/README.md)
链接指向 Releases 而不是某个固定文件名:APK 名形如 dsh-plugin-remote-access-<版本>.apk,
每抬一次版本都会换名字,钉死某个文件名迟早会落后。要装具体某一版,进 Release 页挑。
APK 与插件不总是同一个号:APK 只在「动了外壳代码」的那一版重发(两边的号仍然对齐,
只是纯网页半区的版本不重打 APK)。当前插件与 APK 都是 1.3.8
(v1.3.8 Release)。
(这一版只动网页半区与宿主半区,外壳代码没动,不用重装 App)。
App 名字就叫 DSH,装完图标是那个蓝色看板娘(自适应图标,圆形/方形启动器都正常)。
- 首屏就是地址页:只填
IP或域名:端口(不用写协议,例 100.64.0.3:19388),HTTPS 一个勾决定;
- 连不上 / 填错 / 电脑没开时自动退回地址页,网页上左侧那枚小胶囊里永远有「设置」——断网也改得回来;
- 返回键整个归外壳管:先关网页里的抽屉 / 右栏 / 设置面板,再网页后退,最后退到后台(不退出 App ——
退到后台才有下面那条「模型提问时提醒你」;真要退出,从最近任务里划掉即可。
细节见
android/README.md);
- 模型提问 / 一轮结束都从屏幕顶部弹出来(横幅 / heads-up,像收到一条微信):App 退到后台后由一条
前台服务盯着,只在「电脑上的 DSH 窗口不可见(收进托盘 / 最小化 / 页面已关)」且你不在看 App 时才弹;
见下面「手机通知(提问 / 一轮结束)」;
- 网页仍是电脑上那份 DSH:手机布局、
远程UI布局、UI 设置 全由本插件的配置决定;
- 只申请
INTERNET / ACCESS_NETWORK_STATE / POST_NOTIFICATIONS / FOREGROUND_SERVICE(_SPECIAL_USE),
自签(CN=DSH Remote),同签名的后续版本可直接覆盖安装。
装的时候要允许「安装未知应用」。电脑那端仍然是本插件在监听(默认 19388),App 只是把网页装进壳里。
手机通知(提问 / 一轮结束)
两类通知,各有一个开关、各走一条通道,互不影响(都在「设置 → 远程访问 → 手机通知」里):
| 通知 | 什么时候响 | 开关 | 通道 |
|---|
| 模型提问 | 模型问你「要用哪个方案?」这类选择(ask_user_question,计划审阅也在内),且没人回答 | notifyOnChoice | 「模型提问」 |
| 一轮结束 | 这一轮跑完了,或者异常结束(模型输出格式错 / 余额、额度不足 / 网络连不上 / 被限流 / 凭据失效 / 上下文超长 / 服务端错误…) | notifyOnFinish | 「任务结束」 |
两类共用的触发条件与原来一致:电脑上的 DSH 窗口不可见(收进托盘 / 最小化 / 页面已关,
由电脑上那一页自己回报,25 秒心跳)且你没在看手机 —— 两个条件都满足才弹,屏幕上从顶部弹出横幅
(heads-up,像收到一条微信:会响、会震、几秒后收进状态栏),点通知回到 DSH。
- 「跑完了」那条的正文只写结论与用时;异常那条写分类 + 原因是哪个错误(把 DSH 自己的错误码
翻成人话,例如
余额 / 额度不足:insufficient balance),绝不带模型的回答内容
(可能很长,也不该出现在锁屏上)。
- 子任务(subagent)跑完不打扰你:只有「这一轮」的主流程结束才算数。
- 结束的判定走 DSH 自己的事件(
agent/status 的 running → idle),并带 2 秒防抖 ——
循环里的中间态(维护 / 压缩)不会被误报成「跑完了」。
- 刚跑完那一条有 5 分钟的新鲜窗口:你「在页面上看着它跑完、然后切出去」,照样能收到。
要弹出横幅,Android 那边有四件事必须同时成立,这一版都落实了(细节见
android/README.md):通道是 IMPORTANCE_HIGH、
通知本身是高优先级、每次都发一条新通知(更新旧通知不保证再弹),
而常驻的「后台待命」那条故意留在低重要性(它不弹、不响,只是前台服务的本职)。
只在状态栏看到一条不弹的通知? 那多半是常驻的「后台待命」那条 —— 它是前台服务的本职,
不弹是对的。真正会弹的是「模型提问」/「任务结束」那两条。
通知发了、就是不弹横幅(国产 ROM 必看)
1.3.5 起,那条通知上带一个「去开启横幅」按钮,点它直接跳到「模型提问」通道的设置页 ——
厂商那一层的开关就在那一页里。**小米 / Redmi(MIUI、HyperOS)**上要打开的是这几个(按重要性排):
| 开关 | 在哪 | 为什么需要 |
|---|
| 后台弹出界面 | 设置 → 应用设置 → 应用管理 → DSH → 权限管理 → 后台弹出界面(有的版本在「其他权限」里) | 最关键的一个:App 在后台时,MIUI 靠它决定「准不准许这个界面弹出来」,横幅也归它管。不开就是「通知栏里有、屏幕上不弹」 |
| 通知类别「模型提问」的横幅 / 悬浮通知 | 设置 → 应用设置 → 应用管理 → DSH → 通知管理 → 通知类别 → 模型提问 | 这一条就是横幅的开关;关掉它,通道再重要也不弹 |
| 允许通知(总开关) | 同上那一页最上面 | Android 13+ 的 POST_NOTIFICATIONS 被拒时也在这里重新打开 |
| 锁屏通知 | 设置 → 通知与控制中心 → 锁屏通知 → 允许 DSH | 屏幕锁着时想让横幅亮屏,需要它 |
| 省电策略 / 自启动 | 设置 → 应用设置 → 应用管理 → DSH → 省电策略 → 无限制;自启动 → 允许 | 不开的话后台那条前台服务会被系统收掉,链路整个断 |
其余厂商(华为 / 荣耀、OPPO / vivo / 一加 / realme)位置不同,但要找的都是同一件事:
「允许这个应用在后台弹窗 / 显示横幅」。App 自己能做的只有两件:把通道重要性设成最高、
给一个直达设置页的按钮;剩下那些开关系统不交给应用。
怎么确认手机这一端是通的:把 App 退到后台,到「设置 → 远程访问」底部看那行自检 ——
它现在会写 手机端 App 1.3.5:通知已开、横幅通道=高 ✓,或者直接告诉你差哪一项
(手机端 App 1.3.5:通知已开、横幅通道=2(横幅弹不出来 → 去系统设置里打开 DSH 的通知/横幅))。
这行字是手机自己每次轮询时报给电脑的,所以不用猜。
剩下两种代码管不了:开着勿扰模式;以及 App 就在前台(那时问题卡片本来就在眼前,不重复打扰)。
分工是这样的(三件事各归各的,谁也不猜谁):
| 谁 | 干什么 |
|---|
宿主插件(index.js) | 挂 user-questions/request 记下「谁在等回答」,并在本机页面上开一条路由收「窗口可见吗」 |
本机页面的 client.js | 窗口隐藏 / 显示时把 document.visibilityState 报给宿主(只有本机页面报,手机那端不参与) |
安卓外壳(WatchService) | App 退到后台时起一条前台服务,长轮询 /__remote_access__/notify,该响才响,答完就撤 |
几条刻意的设计:
- 判定在宿主,打扰在手机。宿主只回答两个事实(有没有待答选择、电脑窗口可不可见),
弹不弹由 App 决定 —— 它才知道自己在不在前台。所以「你正看着手机」时绝不会响。
- 窗口可见性由页面自己报:窗口收进托盘 / 最小化只有渲染进程知道,宿主看不到 Electron 的窗口状态。
报告带 25 秒心跳,宿主 150 秒收不到就按「不可见」处理 —— 失效方向是多提醒一次,
不是「该提醒时没提醒」。
- 两条开关(设置页里能改):
- 「提问时通知手机」
notifyOnChoice —— 关掉即不再上报待答状态,App 也就不再提醒;
- 「仅窗口不可见时通知」
notifyOnlyWhenWindowHidden —— 关掉则只要 App 在后台就提醒
(电脑窗口开着时也会响,适合把 DSH 窗口常年放着的用法)。
- 权限:Android 13 起通知要用户点头,外壳在第一次退到后台时问一次;点了「不允许」就不再问
(想开去系统设置里打开「DSH → 通知」)。App 在前台时那条常驻通知也会消失,
它只在「App 在后台待命」时存在。
- 没收到通知时先看哪两处:
- 手机「设置 → 远程访问」底部那行自检 —— 它写着本页跑的是哪一版 client.js、
以及电脑上的通知接口在不在(
... 电脑上的插件已支持提问通知 ✓ / ... 电脑上的插件还没有通知接口 (1.3.1 起才有)→ 更新插件并重启一次 DSH);
- 手机状态栏那条常驻通知的文案 —— 它按失败原因说话:
电脑上的插件版本太旧… /
登录凭据已过期:打开 App 重新输一次访问密码 / 连不上电脑上的 DSH,正在重试…。
这两处合起来能分清「代码旧 / 宿主旧 / 开关关了 / 真的连不上」,不用再猜。
两处都对、问题也确实在等,但就是不弹横幅时,去系统设置里看「DSH → 通知」:
「模型提问」通道别关成静音、别关掉「横幅 / 悬浮通知」开关,也别开着勿扰。
- 不依赖网页活着:长轮询是前台服务自己发的(带着 WebView 里那张放行 cookie),
所以网页被系统掐掉也不影响提醒;反过来,电脑关着时它会退避重试,连续 5 分钟连不上就自己收工。
- 这一组是新增的 Host 配置字段,而宿主的配置 schema 在 DSH 启动时读入:第一次升级到这一版要重启一次 DSH。
使用
方式一:设访问密码(推荐,手机不用复制长串)
- 「设置 → 远程访问 → 访问密码」填 4–12 位数字或字母,保存(写别的字符会被就地拦下并说明规则)。
- 手机打开
http://<地址>:<端口>/ —— 地址和端口见状态文件里的 手机访问: 那一行。
- 输一次密码即可进入,之后 30 天免密(cookie)。
- 密码输错一次,该地址就自动写进「排除的网段」(形如
100.x.y.z/32)、之后一律 403;
解封 = 到「设置 → 远程访问 → 排除的网段」里把那一项删掉。
方式二:用带令牌的网址(不设密码)
打开状态文件里的 远程访问网址: http://…/?token=… 一次,DSH 会把 token 换成自己的签名
cookie(默认 30 天);之后直接访问 http://<地址>:<端口>/ 即可。没票就是 401。
每次启动 DSH 都会换一张 token(用另一个 Windows 账户跑 DSH,也有它自己的一张),所以旧网址、
别的账户/上一次运行留下的网址里那个 ?token=… 已经作废。拿它访问不会再把 DSH 那句英文甩给你了:
从当前版本起会看到插件自己的中文说明页(讲清「票作废」还是「没带票」,以及去哪儿拿当前那条)。
省事的做法:设了 accessCode 就直接打开裸地址
(http://<地址>:<端口>/)输一次密码;没设就复制状态文件里当前那条带票网址。
旧 cookie 同理不通用(DSH 的 cookie 绑 hostname:port)。
状态文件
<DSH_HOME>\remote-access-url.txt(默认在 %USERPROFILE%\.dsh,权限 0600)里写着:带票网址、
手机访问地址、是否开启了解锁密码、已拉黑的地址与「待补写」的地址、放行密钥、当前生效的配置、
上游自检结果。打不开时先看它。
⚠️ 文件里有访问令牌,等于本机操作权限,不要外传(访问密码在里面打码成 ***)。
配置
设置页里能改的
「设置 → 远程访问」里是 10 个常用项(启用 / 允许的网段 / 排除的网段 / 端口 / 并发上限 / 日志级别 /
访问密码 / 远程UI布局 / 提问时通知手机 / 仅窗口不可见时通知),外加一层可折叠的下一级菜单
「UI 设置」(5 项界面偏好,见下)。
保存会写进 profile 的 cordis.patch.yml,插件随即热重载(端口会变,新网址以状态文件为准)。
访问密码的规则
访问密码只收 4–12 位数字或字母(大小写敏感)。写别的(空格、符号、太短太长)不会生效,
而且一定看得见:设置页点保存时就地拦下并说明规则,cordis.patch.yml 里手写的非法值会在
状态文件的诊断里留一条 「访问密码」没生效…。这条提示是故意做的 —— 静默失效会让人以为密码设好了,
然后在手机上撞见 DSH 那句英文 401(这曾经是最难查的一种「坏了」)。
改密码不会把已经进来的设备踢下线:放行 cookie 的密钥与 accessCode 无关,单独存在
<DSH_HOME>\remote-access-secret(0600),DSH 重启后也照样有效。想让所有设备重新输一次密码,
删掉那个文件即可。
手机上的同一页也能改
DSH 的客户端策略让 configForms 在非回环页面不可写,但 Remote 通道本身是通的,
所以那一页直接调 ctx.remote.settings.describe() / mutate(),写盘跑在 Host 网关自己的上下文里,
落盘位置与桌面完全相同,而且等写盘完成才回执。
「UI 设置」这一组是什么、为什么要有副本
设置页里「远程访问」下面有一层可折叠的下一级菜单「UI 设置」,放的是官方「通用设置」里的五项界面偏好
(外观、字号大小、工作步骤展示、显示代码工作视图、性能与用量)。
官方这几项在非回环页面上只能「本页有效、刷新即还原」:ui-settings 把设置通道的持久化降级成内存态
(persistence = ctx.remote.$host.isLoopback ? 'host' : 'memory'),读写都到不了宿主 —— 手机端在官方设置里
改完,一刷新就回到默认。本插件因此存了同一批值,并在每次打开远程页面时重新套一遍(桌面端改完、
手机切回来也会重读):
- 用的是官方自己的「本页」写入入口(
theme.setTheme / theme.setFontSize /
configForms.developerTools.setEnabled / 官方那两行设置项的 setTranscriptView、setPerformanceUsage),
与你在设置页手点那几行是同一条路径;
- 宿主的官方配置一个字都不写;本机(回环)页面一个像素都不动(与
remoteLayout 同一条硬约束);
default(出厂值)不是「什么都不做」,而是显式套回官方默认值 —— 这样从「完全展开」改回「出厂值」
也能真的还原。官方默认值取自官方源码:外观 system、字号 14、工作步骤展示 Web 端 detailed
(桌面客户端 standard)、显示代码工作视图 true、性能与用量 detailed;
- 因此本插件的配置优先:在官方「通用设置」里改这几项只对当前页面有效,下一次重读配置时会被本插件的值
覆盖。想用官方的默认行为,就把这几项留在「出厂值」;
- 这一组是新增的 Host 配置字段,而宿主的配置 schema 是在 DSH 启动时读进来的:第一次升级到这一版需要重启
一次 DSH(之后改值、保存都不必重启;浏览器半区刷新页面即可生效)。
其余参数
其余参数只在 cordis.patch.yml 里配(也不建议常用):
| 键 | 默认 | 说明 | 设置页 |
|---|
enabled | true | 关掉它只需设 false,不必卸载 | ✅ |
allowCidrs | ['100.64.0.0/10'] | 允许来访的网段;也是 listen: auto 挑选本机监听地址的依据 | ✅ |
denyCidrs | [] | 白名单里的例外黑名单;输错访问密码的地址会自动加到这里,删掉该项即解封 | ✅ |
port | 0 | 监听端口,0 = 系统随机 | ✅ |
maxConnections | 64 | 并发连接上限(浏览器会开好几条 keep-alive,别设太小) | ✅ |
logLevel | 'info' | silent / info / debug | ✅ |
accessCode | '' | 解锁密码(4–12 位数字或字母)。设了就不用复制 token:手机开裸地址输一次即可;输错一次即把该地址写进「排除的网段」 | ✅ |
remoteLayout | 'auto' | 远程页面的界面布局:auto(按视口/指针自动判断)/ phone(强制手机布局,横竖屏自适应)/ desktop(与桌面一致)。只作用于远程页面 | ✅ |
phoneEnterNewline | true | 手机端回车 = 换行,只有点「发送」按钮才发送(Ctrl/Cmd+Enter 仍是发送)。关掉即恢复官方语义。只作用于远程页面 | ✅ |
notifyOnChoice | true | 模型提问(ask_user_question)时让安卓 App 弹状态栏通知;关掉即不再上报待答状态 | ✅ |
notifyOnFinish | true | 一轮结束时通知手机:正常跑完与异常结束都算,异常会带上分类(模型输出格式错 / 余额额度不足 / 网络连不上 / 被限流 / 凭据失效 / 上下文超长 / 服务端错误…)。与「提问」的开关、通道都独立 | ✅ |
notifyOnlyWhenWindowHidden | true | 只在「电脑上的 DSH 窗口不可见」时才让 App 提醒;关掉则 App 一退到后台就提醒 | ✅ |
remoteTheme | 'default' | 「UI 设置」一组里的外观:(出厂值)/ (深色)/ (浅色)/ (跟随系统)。,且不写宿主设置文档 |
手机上的「新建工作区」选目录
DSH 默认用 directory-picker-auto:它判定「回环绑定 + 非 SSH + 有显示会话」时会挑原生后端,
而原生后端是在宿主屏幕上弹系统对话框 —— 手机那边点下去只会一直等(表现就是「打不开文件管理器」)。
本插件的 bundle patch 因此把选目录器固定成应用内浏览:关掉 auto,改挂
dsh-host-directory-picker-browse(列目录 / 建目录的后端)+ dsh-client-ui-directory-picker-browse
(「选择工作区目录」对话框)。手机上点新建工作区就会弹出这个应用内对话框,桌面上也一样。
- 桌面端代价:系统原生对话框换成应用内对话框(功能等价)。
- 安全说明:已授权的远程端因此可以列目录、建目录。它本来就能通过 DSH 跑命令,所以这不是新的权限等级;
不想让远程端看到文件系统,就把
cordis.patch.yml 里 disabled 那段和两行 -browse 删掉
(代价:手机端不再有选目录能力)。
为什么只看到「主目录」,怎么去别的盘
对话框的面包屑在主目录以内会折叠:起点显示成「主目录」,C:\、Users 这些上层被有意隐掉,
所以「找不到上一级」是设计而不是坏了。跳出去很容易:
点面包屑右端的铅笔按钮(「编辑路径」),它会变成输入框并预填当前路径,改成你要的路径回车即可:
| 输入 | 效果 |
|---|
C:\ | C 盘顶层,所有顶层文件夹都出来了 |
D:\projects | 直接跳到目标目录 |
\\server\share | UNC 路径 |
宿主只接受完全限定路径(Windows 上必须是 C:\… 或完整的 \\server\share…;\foo、相对路径会被拒绝)。
跳到主目录以外之后,面包屑就会显示真实完整路径。目标文件夹不存在时,先用对话框里的「新建文件夹」,
或直接输入一个已存在的目录再点「打开」。
手机布局(远程UI布局)
DSH 的界面是为桌面三栏设计的。在 390px 宽的手机上,它的表现是:
- 侧栏一旦展开就吃掉 280px,正文只剩 110px;
- 设置页的左右两栏里,右侧只剩 101px —— 中文会一个字一行竖排下来;
- 输入区工具条把模型名截成
DeepSeek-V4…。
「远程UI布局」就是针对这些做的,选 手机 或 自动 后,只有远程页面会变成:
| 位置 | 手机布局 |
|---|
| 侧栏 | 收起时保持 56px 图标栏;展开时改成抽屉浮在正文上(min(86vw, 320px)),正文保持满宽,点遮罩即收起;点列表里的一条会话也立刻收起(见下) |
| 设置页 | 改成上下两段:导航变成顶部横向滚动的胶囊条,内容占满整宽并自行滚动 |
| 输入区 | 工具条允许换行、左右内边距收窄,模型名不再被截断 |
| 触控 | 设置项在手机上单列排布、输入框 16px(iOS 聚焦不放大整页)、按钮 ≥40px |
| 安全区 | 刘海/圆角/手势条按 env(safe-area-inset-*) 留白;横屏高度紧张时压薄设置页导航 |
点左侧列表里的会话:一步到位
手机上「左侧工作区列表」就是抽屉。点一条会话时,插件会做两件事(只在手机布局下):
- 立刻把抽屉收掉 —— 替用户点它自己的「收起侧边栏」,与点遮罩、手势、返回键走同一条路,
于是「选会话」是一步而不是「点会话 + 再点遮罩」两步;
- 不让光标落进输入框 —— DSH 在会话切换时会把焦点塞进输入框(电脑上这是好意:切完就能直接打字),
手机上一执行就弹出软键盘、把刚选中的会话盖掉一半。插件不改它那份逻辑,只在这一下之后
400ms 的窗口里让输入框不可聚焦(
inert),并兜底把抢到的焦点交回去;
窗口一过立刻恢复,用户自己点输入框照常能打字。
认的是「会话行」本身(DSH 自己写在行上的 data-row-key="session:…",且必须落在侧栏里),
所以行内那几个按钮(…菜单 / 置顶 / 归档,DSH 自己就 stopPropagation)与工作区分组行
(workspace:…,点它只是展开收起)都不会被误伤。
三条实现上的约定:
- 本机不动。判据是
ctx.remote.$host.isLoopback(页面生命周期内固定),不是窗口宽度 ——
本机窗口拖到 390px 也不会变成手机布局。所有样式都挂在 html[data-ra-layout="phone"] 之下,
本机页面拿不到这个属性,也就永远匹配不上任何一条规则。
- 不改别人的渲染树。只加
data-ra-* 标记和一张 <style>,靠 CSS 覆盖;
唯一挂在框架之外的是抽屉遮罩(<body> 下、有固定 id)。定位用的是 data-slot 这类稳定契约,
再用 [class*="_xxx"] 兜住 DSH 会变的 CSS-module 哈希前缀。
- 改完立刻生效。这一项走 Remote 通道热更,手机上改完不用刷新(
settings/document-updated 会重算)。
已知限制:手机宽度下 DSH 本身不给右侧栏分配轨道(available < 300 时右栏宽度算成 0),
所以文件/终端那类右侧面板在手机上仍然打不开 —— 这属于 DSH 布局自身的策略,
本插件只改样式、不动别人的布局计算,因此没有去绕它。
手机端回车 = 换行(不发送)
手机上的回车键太小、太容易误触,所以本插件把它改成换行:只有点「发送」按钮才真正发送。
- 生效范围:手机布局 或 触摸(粗指针)设备上的主输入框;两处都算,所以「宽屏平板」与
「手机选了电脑布局」都不会漏。本机(电脑)页面完全不受影响。
- 一律放行、绝不插手的四种情况:
Shift/Ctrl/Cmd/Alt + Enter(Shift+Enter 本来就是换行,
Ctrl/Cmd+Enter 仍然是发送)、输入法合成中或刚合成完 60ms 内(中文输入法用回车确认候选词,
吃掉它等于打不了字)、不在主输入框里(设置页的输入框、排队消息的行内编辑器都不碰)、
以及 手机端回车=换行 被关掉时。
- 实现方式是在捕获阶段把这一个按键吃掉(官方那条「回车发送」根本收不到),再自己往编辑器里插一个
换行;三条路依次试(合成
beforeinput/insertLineBreak → execCommand('insertLineBreak') →
合成一个 Shift+Enter),三条都不成时也什么都不做,绝不退回「发送」。
- 不想要这个行为:设置 →「远程访问」→ 关掉「手机端回车=换行」即可,回车立刻恢复官方语义。
手机布局 + 安卓外壳:返回键
用配套的安卓 App(android/)时,系统返回键由外壳接管,顺序是
先关网页里的浮层 → 再网页后退 → 最后退到后台(不再退出 App —— 退到后台之后,
模型在电脑上提问时手机才会在状态栏提醒你;真要退出就从最近任务里划掉)。
网页这一半只回答两个问题
(window.__dshRaBack 的 count() / closeTop()),而且只在手机布局下算数:
电脑布局里侧栏就是普通一列,没什么可「关掉」的;本机页面连这个入口都不装。
关的方式仍然是替用户点它自己的入口 —— 官方浮层(设置面板、下拉菜单…)派发一次 Escape,
抽屉点「收起侧边栏」,左上角面板列表里的「插件 / 自动化任务」这类全局面板走官方
layout.selectPanel(null) 回会话;与手势、遮罩、手点图标走的是同一条路,我们不碰任何状态。
细节与取舍见 android/README.md。
手机端缓存(大资源不再每次重传)
手机上每进一次页面都要把十几 MB 重下一遍 —— 这不是 App 的锅(APK 用的是 LOAD_DEFAULT,
没有禁用缓存),而是上游根本没发缓存头:
| 资源 | 上游实测 | 后果 |
|---|
/assets/*(Vite 哈希产物) | 只有 content-type,没有 cache-control / etag / last-modified | 手机端 1.42 MB 的 bundle 每次重下 |
插件聚合包 /plugins/??…&rev=… | DSH 自己发 immutable | 本来就该长期复用 |
| 壁纸这类文件(预览图 / 上传 / 转码产物) | 只有 content-type / accept-ranges / content-length,没有任何缓存口径,也没有 ETag | 几十 MB 的图/视频每次整份重传 |
做法:缓存本体留在手机的 WebView 里(Chromium 自己按 LRU 管容量,我们不去管它会不会满),
宿主只做两件事,而且正文一个字节都不落宿主盘:
-
把响应头喂对(通用判据,不认任何插件名、不认路径前缀):
| 这类资源 | 判据 | 补什么 |
|---|
| 内容寻址 | URL 里真有内容哈希(?rev=…、index-5SrrfWpU.js 这种哈希文件名、64 位 hex 的附件 id) | public, max-age=31536000, immutable —— 一年不再问 |
| 上游已有校验器 | 上游给了 ETag / Last-Modified(壁纸的 scene.pkg 就是) | 只补新鲜期(private, max-age=…),校验器原样保留,条件请求仍由上游回答 |
| 上游什么都没给 | 静态类型白名单 + 200 + 没有 no-store/no-cache/set-cookie | 补新鲜期 + 我们自铸的弱校验器(见下) |
no-store / no-cache / private / HTML / JSON 接口 / 带 Range 的请求:一律不碰(不做通用 HTTP 缓存)。
-
替手机判定条件请求:新鲜期(默认 10 分钟,配 stale-while-revalidate)内手机一次请求都不发;
过期后先出旧图、后台带着我们那个校验器回来问一次 —— 这时宿主只读上游响应头 + 首/尾各 64 KB
就能答:
- 对得上 → 304(零字节),连上游那份正文都不读完;
- 对不上(比如换了壁纸)→ 把新的一份发过去,并给一个新的校验器。
为什么用「长度 + 类型 + 首/尾片段」而不是整份哈希:壁纸那种几十 MB 的文件,整份哈希既费 CPU 又要
么落盘要么把整份读一遍;首尾各 64 KB 已经能挡住「换了图但字节数一模一样」这种最阴的情况
(想关掉它就把 cacheProbe 设 false,退回只按长度+类型核对)。
为什么坚持不落盘、也不读宿主的文件系统:资源可能在任何盘、任何路径上(非默认目录、
多个 Windows 用户各自的空间、用软/硬链接搭出来的共用目录)。判定只看「URL(剔除 token)」与上游给的
响应头,上面这些形态对我们完全透明 —— 也不会出现「A 用户的缓存被 B 用户读到」。手机上的副本被系统
清掉时,代价只是重新下载一次,不会出现「缓存坏了所以打不开」。
流量账(排查「退出重进还是大流量」用):展开「缓存」那一组,展开后是三块正经的报告 ——
生效策略(内容寻址 / 新鲜期 / 过期核对 / 两个开关的实际状态 / 正文落不落盘)、
本次会话计数(304 / 小资源缓冲 / 片段探针 / 接管次数)、
本轮流量账(按 URL 排字节的表格:送出、次数、URL,每一行还写明为什么没带缓存口径,
如 上游写了 no-store / 带 Range 的分段请求(bytes=0-…) / 类型不在白名单(…)),
下面配「刷新」与「清零本轮统计」两个按钮,以及一行用法说明。
用法:先点「清零本轮统计」→ 完全退出 App(从最近任务划掉)→ 重新打开 → 回来看这三块,
那一轮的钱花在哪儿、为什么省不下来,一目了然。
为什么必须有这本账:组合包(/plugins/??…&rev=…)的 immutable 是 DSH 自己发的,按「尊重上游」
的原则我们一个字都不改 —— 于是「我们加了什么头」的计数看不见它们(十几 MB 的大头恰好在那儿)。
字节账不看我们插没插手,谁在重传就记谁。同一份数据也在状态文件里(流量账 那几行),
接口是 GET /__remote_access__/cache(?reset=1 清零)。
有两类东西是任何 HTTP 缓存都救不了的,看到大数字别惊讶:
· 聊天里的图片/附件走的是 RPC(readAttachment),字节在会话通道里(WS /api/remote.mux),
客户端只把它缓存在页面内存里,页面一重载就重取 —— 所以「从最近任务划掉 App」=整页重载=图片重来;
只是退到后台再回来(不划掉)不会重载,也就不重取;
· 实时轮询(动态壁纸的音频频谱那种,实测约 8 次/秒)本身就是「每次都要最新值」,缓存它只会把动画弄坏。
这两类在流量账里分别以 WS <路径> 与 GET …(原因写着白名单之外)出现。
两个「逆着上游意愿」的开关,专门治实测里最大那两笔(默认都关,自己权衡):
· cacheOverrideNoStore:上游给实时抓帧写 no-store,可那一张就是 4 MB、每次进页面都重来;
· cacheFullRange:媒体元素取音轨发 Range: bytes=0-,上游回覆盖整份的 206,而浏览器永远不缓存 206,
实测一次进页面要重下 3.8 MB × 2。
两个都只有在「字节确实就是完整一份」时才动手,实时小端点与中间分段一律不碰。
怎么看它到底生效了没有:手机 →「设置 → 远程访问 → 缓存」(默认收起),展开才读一次宿主的
/__remote_access__/cache,那一行会写明实际口径与本次计数,例如
内容寻址 immutable · 无校验器 10 分钟(过期先出旧的、后台只做一次核对)· 指纹探针开 · 本次 304 4 次 / 小资源 2 / 探针 5。
304 那一项就是「省下来的重传次数」。状态文件里也有同样一份口径与计数。
想彻底清掉手机上的副本:用手机浏览器/App 的清缓存功能即可 —— 我们不在手机侧留任何自己的数据库,
也不在宿主侧留正文,所以没有「清了缓存反而对不上」的中间态。
动态壁纸为什么常常是静态的
装了「壁纸引擎」这类插件时,手机端经常只看到一张静态首帧。根因在壁纸插件那边,不在代理:
它把动态载荷(scene.pkg、网页壁纸入口)的地址写死成自己另起的那个媒体服务
http://127.0.0.1:<随机端口>(只绑回环)。手机打开这个绝对地址,访问的是手机自己的 127.0.0.1,
必然连不上 → 渲染页等不到载荷 → 首帧预算(15s 起、封顶 90s)到期 → 判定实时渲染失败,
并把这次失败写进宿主共享的设置文件,于是手机和桌面一起退回静态帧,直到手动重开「场景实时渲染」。
静态预览图走的是相对路径、经代理可达,所以「有图但不动」正是这个组合的表现。
本插件这边确实有一个放大器,已修:默认 timeoutMs=0 时没有显式声明超时,
于是继承了 Node ≥19 默认 agent 的 5 秒空闲超时 —— 上游 5 秒没吐字节就把正在传输的响应掐断,
而且日志里打印的 cfg.timeoutMs 还是 0,看起来像「没设超时却超时了」。现在:总是显式声明超时,
且 timeoutMs 只作用于「等响应头」,头一到就交棒;上游半路断掉时下游会被一起收掉,不会让手机一直挂着。
彻底治好动态壁纸需要壁纸插件改用相对路径(或让它感知反代)。在它能改之前,两个可用做法:
把壁纸插件的「适配」目标设成浏览器(回到相对路径),或者接受手机端只有静态壁纸。
安全边界
- 只监听
allowCidrs 里的本机地址,永远不会绑 0.0.0.0。
- 每个连进来的对端地址也必须在
allowCidrs 内,否则 403;被拉黑(在 denyCidrs / 拉黑暂存里)的一律 403。
- 没设
accessCode 时:DSH 自身的令牌 / cookie 仍然生效(没票 401),本插件不降低 DSH 的任何鉴权。
- 设了
accessCode 就别再把它当「双因子」:那时「网段 + 数字密码」就是全部凭据 —— 解锁成功后由本插件
在服务端替请求补上 DSH 的令牌(浏览器始终看不到 token),token 在这条路径上不再是第二道门。
想回到「必须带票」的严格模式,把 accessCode 清空即可。
- 一次密码错误立即拉黑该地址(写进「排除的网段」
denyCidrs;万一写盘失败,它仍会被立刻拦住、
暂存到 banFile 并在状态文件里写明),这是故意的:可暴力猜的数字密码需要一道硬刹车。
- 建议再在 Tailscale ACL 里限定设备;
tailscale funnel(公网入口)不要开。
- 本插件把来自网段的请求改写
Host/Origin 后转给回环上的 DSH,因此它就是这个边界的守门人:
allowCidrs 写宽了,等于把本机命令执行权限放开。
常见问题
显示 dsh web authentication required(DSH 自己那句英文) — 说明这次访问用的是已经作废的票:
每次启动 DSH 都会换一张 token,别的账户/上一次运行留下的那条 ?token=… 网址都不能再用了。
换个做法:设了 accessCode 就直接打开裸地址 http://<地址>:<端口>/ 输一次密码;
没设就复制状态文件里当前那条带票网址。
如果你之前明明能进、现在只看到这句:那是浏览器里那张 DSH cookie 失效了。
从 b0ec52a 起,插件遇到这种 401 会自动带当前票重试一次,DSH 顺手发一张新 cookie,你不用再手动清。
从当前版本起,这句英文不会再出现在「页面导航」上了。 地址栏打不开时看到的是插件自己的中文页,
它按情况说清两种可能:票作废了(每次启动 DSH 都会换一张)还是根本没带票/没带密码,
并告诉你下一步去哪儿拿当前那条网址(本机「设置 → 远程访问」或状态文件)。
两个刻意的设计:页面绝不回显当前 token(它发给的正是尚未通过校验的对端),
而且只接管导航请求 —— RPC/子资源的 401 仍原样透传,机器可读的错误语义不变。
设了 accessCode 但凭据过期时,回的是解锁页(重新输一次密码即可),也不是那句英文。
输错密码被挡在外面 — 该 IP 已被拉黑。到「设置 → 远程访问 → 排除的网段」里删掉对应那一项
(形如 100.x.y.z/32)即可。如果状态文件里「待补写」不为空,说明当时写盘失败、它还没进配置,
那就删掉 <DSH_HOME>\remote-access-bans.txt 里的那一行。
前几天还好,现在又要重新验证 — 换过端口或重启过 DSH。DSH 的 cookie 绑定 hostname:port,所以旧的不通用;
按「使用」里的步骤重来一次。
设置页点了保存没反应 / 报失败 — 跑 node tools/check-config-schema.mjs,它会检查写盘的两个硬前提
(Config 是原生 Schemastery schema 且字段都标了 .volatile();package.json 把
@deepseek-ai/schemastery 声明成了 peerDependency)。改完 package.json 要重启一次 DSH 才生效。
手机上在「通用设置」里改完,刷新就变回去了 — 那几项(外观 / 字号大小 / 工作步骤展示 / 显示代码工作视图 /
性能与用量)在非回环页面上是「本页有效、刷新即还原」的。要让它留住,就用本插件的
「设置 → 远程访问 → UI 设置」改:这份值存在宿主配置里,每次打开远程页面重新套一遍
(默认「出厂值」=不干预官方默认,行为与官方一致)。
升级后保存「UI 设置」报 Config field "…" is not volatile — 宿主还不认识这个新字段
(宿主的配置 schema 在 DSH 启动时读入,改 index.js 不会热生效)。重启一次 DSH 即可,
之后改值不必再重启。
手机在选目录里只能看到主目录 — 见上面「为什么只看到主目录」。
手机上界面挤成一条、中文一个字一行 — 那是「远程UI布局」还留在 电脑(或 auto 没判定成手机)。
改成 手机 或 自动 即可。注意这一项是存服务端配置的:在手机上改完立刻生效,不用重装也不用刷新。
视频/大文件/动态壁纸传到一半卡住 — 老版本会在「上游 5 秒没吐字节」时掐断响应
(Node ≥19 的默认 agent 自带 5 秒空闲超时,而 timeoutMs=0 恰好没覆盖它)。已修:
现在总是显式声明超时,且 timeoutMs 只管「等响应头」,头一到就交棒。
若壁纸仍然只有静态画面,那是壁纸插件自己把载荷地址写死成 http://127.0.0.1:<端口> 造成的
(手机打开这个地址等于访问手机自己的回环),见下面「动态壁纸为什么常常是静态的」。
手机每次打开都要重下一遍大文件(壁纸最明显) — 见上面「手机端缓存」。先确认两件事:
① 手机 →「设置 → 远程访问 → 缓存」展开,那一行有没有报出 304 N 次(有就是在省重传);
② 若那一行说「电脑上的插件还没有缓存接口」或展开是空的,就是宿主那半区还是旧版 ——
去电脑上更新插件并重启一次 DSH(index.js 的改动要重启才生效)。壁纸插件自己写 no-store
的那些端点(媒体映射这类 JSON)我们故意不缓存,所以它们每次都还是新取的,这是对的。
手机上按回车不发送了 — 这是 1.3.8 起的行为(回车=换行,点「发送」才发送)。
想恢复官方的「回车即发送」:设置 →「远程访问」→ 关掉「手机端回车=换行」。
开发与测试
node --check index.js # 语法
node tools/test-remote-access.mjs # Host 功能测试(起一个假上游,不需要 DSH)
node tools/test-client.mjs # 客户端冒烟测试(极简 React / 宿主桩子真跑一遍设置页,不需要浏览器)
node tools/check-config-schema.mjs # 用安装包里的真 Schemastery 校验 Config(找不到安装包则 SKIP)
node tools/e2e-cache-local.mjs # 手动:起一个「什么都不发缓存头」的假上游 + 本插件代理,用真浏览器/curl 验缓存与 304
e2e-cache-local.mjs 会在临时端口上跑起来并打印带票网址(新鲜期压到 1 分钟,几分钟内就能看到
「过期 → 条件请求 → 304 零字节」)。它只是给人手验的,不参与自动测试。
test-remote-access.mjs 覆盖转发、Host/Origin 改写、x-forwarded-for、WebSocket 101 透传、
网段外 403、解锁页与「错一次自动写进排除的网段」(含写盘失败退回暂存)、改密码不踢人、热重载换端口,
以及本轮补上的:访问密码规则(字母数字、非法值必须可见)、没有凭据时的中文 401 页
(且绝不回显 token、非导航请求仍原样透传)、timeoutMs=0 不再继承 agent 的 5 秒空闲超时、
timeoutMs 只作用于等响应头、上游半路断掉时下游不悬挂;
还有「模型提问时通知手机」那一整条链:待答中心的去重 / 清除 / 报告过期(假时钟,
不靠 sleep 150 秒)、/__remote_access__/notify 的 401 与 405、模型提问 → 端点带出正文与选项、
长轮询挂住且状态一变立刻回、答完即清空、窗口可见性路由(204 / 415 / 400 / 405)与开关透传。
1.3.8 起还覆盖一轮结束的通知:agent/status 的 running→idle 记一条「跑完了」、
子 agent 跑完不打扰(id === session.id 才算主流程)、idle 的 2 秒防抖(中间态不误报)、
异常结束按 DSH 错误码逐条分类(模型输出格式错 / 余额额度 / 网络 / 限流 / 凭据 / 上下文 / 服务端,
认不出来老实说「出错」并带原文)、结束会推 rev(长轮询立刻回)、状态文件写明最近一次结论、
以及 notifyOnFinish=false 时端点如实回答 enabledFinish=false(而 enabled 不受影响)。
1.3.8 起还覆盖手机端缓存这一整条:内容寻址(?rev= / 哈希文件名 / 64 位 hex)才发 immutable
(普通文件名不许被钉一年)、上游已有 immutable 时原样透传不加第二个头、上游什么都不给的大图补上
private + max-age + stale-while-revalidate 并铸弱校验器、条件请求命中回 304 且零字节
(并核对「只打了 1–3 次上游」证明走的是片段探针)、换了图但长度一模一样时不会误判成 304
(首/尾片段指纹)、小图片走缓冲整份的精确校验器、上游有 ETag 时只补新鲜期不改校验器、
no-store / JSON 接口 / 小块二进制一个字都不加、带 Range 的请求原样透传、
cacheEnabled=false 时彻底不插手,以及自检端点(401 / 405 / 口径与计数)。
那条 5 秒超时的用例故意要跑 5.3 秒:只有真的等过 5 秒才能证明没继承那个默认值
(Node 18 上它恒过,因为那个默认值不存在)。
test-client.mjs 覆盖设置页注册与渲染、回环与手机两条读写通道、保存与失败提示
(Remote 通道返回的是 { ok, value } 信封,这是最容易踩的一处),以及手机布局最关键的那条不变式:
回环页面即使窗口只有 390px、配置又写着 phone,也不设 data-ra-layout、不注入任何样式;
远程页面则按 phone/desktop/auto(窄视口、触摸设备)分别给出正确判定;以及「UI 设置」那一组:
按配置调用官方五个入口、default 显式套回官方默认值、本机页面一个入口都不碰、官方组件缺席时安静跳过、
菜单默认收起且展开后是那五档;还有给安卓外壳的返回键入口(window.__dshRaBack):
手机布局下抽屉 / 官方浮层 / 全局面板分别算几层、closeTop() 关的是最上面那层且走它自己的入口
(全局面板走官方 layout.selectPanel(null))、电脑布局恒为 0、本机页面不装入口、控制器清理后入口收回、
以及 layout 服务缺席(更老的 DSH)时不报错;还有手势归属:body 上挂着壁纸 data-we-* 锚点时
首页照样能滑出面板,而壁纸卡片(data-we-id)与吉祥物(touch-action: none)仍然不让开;
以及给通知用的窗口可见性上报:本机页面进页面就报一次、带 25 秒心跳、窗口藏起来报 hidden、
清理后彻底安静、远程(手机)页面一个请求都不发、没有 fetch 的老 WebView 安静跳过;
以及 1.3.2 的会话行行为:点会话行收抽屉、输入框被按住(inert)与 400ms 后恢复、
窗口内的 focusin 兜底 blur、行内按钮 / 工作区分组行 / 抽屉没开 / 电脑布局 / 本机页面都不误伤、
停用后监听收回且输入框放开;还有 1.3.3 的自检页脚(版本号与 package.json 一致、
宿主没有通知接口时说清「更新插件」、宿主支持时报「已支持提问通知」)。
1.3.8 起还覆盖手机端回车 = 换行:手机布局与触摸设备分别都要接管、裸回车被吃掉
(preventDefault + stopPropagation)且换行真的插进去了(走 beforeinput/insertLineBreak)、
Shift/Ctrl/Cmd/Alt + 回车一律放行、输入法合成中与刚合成完 60ms 内放行(窗口过了才恢复)、
不在主输入框里 / 没有 target / 别的按键都不碰、配置关掉后回车仍是发送、电脑布局 + 非触摸不接管、
本机页面连监听都不装、停用后监听收回;以及设置页「缓存」那一层子菜单:默认收起、
展开才显示三个字段、自检那行读出实际口径与 304 计数、收起状态零请求、
宿主没有缓存接口时说清「更新插件并重启」。
check-config-schema.mjs 覆盖设置页写盘的两道硬前提,并顺带对表 client.js 的表单字段与 Config。
改手机布局时必须真机看一眼:这里用的调试回路是「无头浏览器 + CDP 仿真视口」
(Emulation.setDeviceMetricsOverride 到 390×844),因为 DSH 的布局计算读的是 window.innerWidth,
光把容器改窄看不到真实效果。
目录
| 路径 | 说明 |
|---|
index.js | 插件 Host 半区(零依赖,只用 node: 内置模块):反向代理、访问门、401 说明页、热重载;手机端缓存策略(响应头治理 + 用首/尾片段探针替手机判定 304,正文一律不落盘)与 /__remote_access__/cache 自检端点;外加给安卓外壳的待答选择状态(/__remote_access__/notify 长轮询)与窗口可见性上报路由 |
client.js | 浏览器半区:设置页参数表单(含「UI 设置」「缓存」两层子菜单)+ 只作用于远程页面的手机布局引擎 + 手机端「回车=换行」+ 把官方那五项界面偏好套到远程页面 + 给安卓外壳的返回键入口(手机布局下的浮层层数 / 关掉最上面那层)+ 本机页面的窗口可见性上报 |
docs/release-notes/ | 每个版本的 GitHub Release 正文(dshmarket 的「更新说明」就是从 Release 正文读的,发布时直接贴) |
cordis.patch.yml | bundle 的 patch:插入插件行、固定选目录器 |
package.json | 清单(dsh.bundle.patch / exports / icon / meta) |
locale/{zh,en}.json | 插件页显示用的标题与说明 |
icon.svg | 插件页图标 |
tools/ | 测试(Host / 客户端 / Config 校验)与从 DSH 安装包里取文件的工具 |
android/ | 安卓外壳 App 的源码(首屏地址页 + WebView + 后台哨兵 WatchService;Java、零第三方依赖,可离线构建) |
install.ps1 | GitHub 一键取代码(放到本机固定目录,安装仍由 DSH 自己做) |
LICENSE | MIT |
更新日志
1.3.8
- 手机端「回车 = 换行」,点「发送」才发送。手机上的回车键本来就小,官方语义下它直接发送,
想写两行只能去凑
Shift+Enter。现在:手机布局或触摸设备上的主输入框里,裸回车插一个换行,
只有点发送按钮才真正发送;Ctrl/Cmd+Enter 仍然是发送,Shift/Alt+Enter 保持原样;
输入法合成中(含刚合成完 60ms 内)一律放行 —— 中文输入法用回车确认候选词,吃掉它等于打不了字。
本机(电脑)页面完全不受影响;不想要就去掉「手机端回车=换行」这一项。
- 手机端资源缓存:大资源不再每次重传。查清了「每次进去都要重下十几 MB」的根因在上游:
/assets/* 那两个 Vite bundle(实测 1.42 MB)与壁纸那类文件族(几十 MB 的图/视频)
上游一个缓存头都不发(没有 cache-control / etag / last-modified),浏览器没有 freshness、
也没有校验器,只能整份重下。这一版由代理把响应头喂对,并把「条件请求怎么判」接过来:
- 内容寻址(
?rev=…、哈希文件名、64 位 hex 的附件 id)→ immutable,一年不再问;
判定改成通用规则(URL 里真有内容哈希),不再写死 /plugins/、/assets/ 两个前缀 ——
顺带修掉「非哈希的插件文件被钉一年」这个隐患;
- 上游自己给了
ETag/Last-Modified → 只补新鲜期,校验器原样保留、条件请求仍由上游回答;
- 上游什么都不给的大静态文件 → 补
private, max-age=10 分钟, stale-while-revalidate
与自铸的弱校验器:期内手机一次请求都不发;过期后先出旧图、后台只做一次头部级核对,
对得上就回 304(零字节)。
- 校验器用「长度 + 类型 + 首/尾各 64 KB 片段」(
cacheProbe),不整份哈希、不落盘,
因此「换了图但字节数一模一样」也不会被误判成没变;小文件(≤256 KB)则缓冲整份算精确哈希。
- 宿主的正文零落盘,也不读宿主的文件系统:资源在哪个盘、哪个 Windows 用户的空间、
是不是软/硬链接搭出来的共用目录,对这套逻辑都透明(只看 URL 与响应头),也就不会出现
「A 用户的缓存被 B 用户读到」。缓存容量交给手机自己的 Chromium LRU,我们不做第二份账。
- 不碰的范围(默认):
no-store / no-cache / HTML 导航 / JSON 接口 / 小块二进制 / 带 Range 的请求。
- 流量账:「缓存」那一组里按 URL 列出本轮字节数(送出 / 无缓存口径 / 每个 WebSocket 通道各推多少),
每行附一句「为什么没带缓存口径」(
上游写了 no-store / 带 Range 的分段请求 / 类型不在白名单(…));
配「清零流量账」按钮,用来量「退出重进」那一轮。接口 GET /__remote_access__/cache(?reset=1 清零)。
- 两个默认关、专门治实测里最大两笔的开关:
cacheOverrideNoStore(实时抓帧实测 4.03 MB / 次,
上游写了 no-store;判大小用探针首段,分块传输没有长度头也算数,实时小端点不碰)、
cacheFullRange(媒体元素 Range: bytes=0- 拿到的是覆盖整份的 206,而浏览器永远不缓存 206,
实测音轨 3.8 MB × 2;只把「从 0 到结尾」这一种改写成整份 200,字节一模一样,中间分段不动)。
- 自检:手机 →「设置 → 远程访问 → 缓存」(默认收起)展开一行,直接写出口径、
304 次数与本轮流量;
状态文件里也有同样一份。
- 说清两类缓存救不了的:聊天图片/附件走 RPC(
WS /api/remote.mux,客户端只缓存在页面内存,
所以「从最近任务划掉 App」=整页重载=重取;只退后台再回来则不会)、以及实时轮询(音频频谱约 8 次/秒)。
1.3.6
- 修:「去开启横幅」按钮不再常驻。1.3.5 把它无条件挂在每条通知上 —— 横幅明明已经开了还一直显示,
用户自然会问「这不是已经开了吗」。现在只在真的检测出问题时才出现:通知权限没开,
或者「模型提问」通道的重要性被调低(小米的「悬浮通知 / 横幅通知」开关就映射成通道重要性)。
一切正常时那条通知上不再挂任何按钮。
- 顺手:设置页页脚里手机端那句不打勾了(
手机端 App 1.3.6:通知已开、横幅通道=高)。
「通道=高」只是必要条件,厂商那一层(小米的「后台弹出界面」这类)系统不交给应用判断,
打勾反而会让人以为万事俱备。宿主那句 电脑上的插件已支持提问通知 ✓ 保留 —— 那句是能验的。
- 真机确认:
1.3.5 上横幅已经能弹(小米上把「后台弹出界面 / 横幅通知」打开之后)。
1.3.5
- 修「通知发出去了,屏幕上就是不弹横幅」(真机反馈:小米 / Redmi,通知栏里两条都在,就是不弹)。
查清了这不是链路问题 —— 通知已经发出来了,是厂商那一层没放行。这一版能做的是把路指明白:
- 通知上多一个「去开启横幅」按钮:直接跳到「模型提问」通道的设置页(厂商的横幅开关就在那页),
常驻那条在检测到问题时也带上它;
- 手机把自己的通知环境报给电脑(App 版本 / 通知开没开 / 通道重要性),
设置页页脚于是能直接写:
手机端 App 1.3.5:通知已开、横幅通道=高 ✓,或者
横幅通道=2(横幅弹不出来 → 去系统设置里打开 DSH 的通知/横幅);状态文件里也留一份。
这条链路让「手机上到底装的是哪一版、通知权限开没开、横幅通道是高是低」不用再靠猜。
- README 补了**小米 / Redmi(MIUI、HyperOS)**那张对照表:最关键的是「后台弹出界面」权限
(App 在后台时 MIUI 靠它决定准不准弹),其次是通知类别里的「横幅 / 悬浮通知」开关。
- 说明:
index.js 也动了(解析手机那份自报)——这次要重启一次 DSH;
但横幅本身只跟 APK 与手机设置有关,不重启 DSH 也能弹。
1.3.4
- 「模型提问」改成从屏幕顶部弹出来(横幅 / heads-up),不再只是躺进状态栏(APK
1.3.4)。
这就是「像微信消息那样」的效果。Android 上要弹出横幅得同时满足几件事,这一版一件件落实:
- 通道重要性
IMPORTANCE_HIGH(「模型提问」通道)—— 横幅的总开关,1.3.1 起就是 HIGH;
这一版把震动、提示音、角标也显式写上;
- 通知本身
PRIORITY_MAX + DEFAULT_ALL + VISIBILITY_PUBLIC + 分类 CATEGORY_MESSAGE + setTicker;
- 每条问题都发一条真正的新通知:以前所有问题共用一个通知 id,而更新同 id 的旧通知时系统
不保证再弹一次横幅 —— 「上一条还没消、下一条问题又来了」就不会弹。现在两个 id 轮流用,
每次都算新通知,旧的那条顺手撤掉;
- 常驻那条(「后台待命」/失败诊断)故意留在
IMPORTANCE_LOW:不弹、不响、不震 ——
前台服务按规定必须挂着一条通知,那是它的本职,不该被误当成「模型在问你」。
只在状态栏看到一条不弹的通知时,多半就是它(外加电脑上的插件还没更新)。
- 「弹不出来」还剩三种代码管不了的原因:系统设置里「DSH → 通知 → 模型提问」被关成静音、
或关了「横幅 / 悬浮通知」开关(国产 ROM 常见),或开着勿扰模式;另外 App 在前台时不弹
(那时问题卡片就在眼前)。这一节写在下面「模型提问时通知手机」里。
- 顺带:1.3.3 的失败分类文案与设置页那行自检都在这一版里;插件与 APK 都是
1.3.4。
1.3.3
- 修:手机端把「电脑上的插件太旧」说成了「连不上电脑上的 DSH」(APK
1.3.3)。
1.3.1 加的常驻通知以前对任何失败都只说「连不上电脑上的 DSH,正在重试…」—— 而真机上最常见的那种
失败其实是电脑上装的插件还是旧版(没有 /__remote_access__/notify,端点回 404),
那条文案会把人往网络问题上带。现在按结果分类:404 → 「电脑上的插件版本太旧(没有通知接口),
去电脑上更新它」、401/403 → 「登录凭据已过期:打开 App 重新输一次访问密码」、
连不上 / 5xx → 才是「连不上电脑上的 DSH」。前两类是「等你处理」,所以不再 5 分钟收工,
改成每分钟重试一次 —— 在电脑上更新完插件,手机不用再动就会自己接上。
- 新增自检:设置页底部一行。手机上的「设置 → 远程访问」底部现在写着
本页客户端 1.3.3 · 电脑上的插件已支持提问通知 ✓(或它为什么不是这样):
前面是这一页实际跑的那份 client.js,后面那句是现场问一次电脑上的通知接口得到的。
三种「怎么没生效」在这一行里能直接分开 —— 代码旧了(手机刷新一下页面)、
宿主旧了(去更新插件并重启一次 DSH)、开关关了(在设置里打开)。
这一版加它,正是因为 1.3.2 那次「明明发布了却没生效」,查出来是手机上那一页还在跑旧的 client.js。
- 测试:客户端 81 条(新增
CLIENT_VERSION 与 package.json 的一致性、页脚自检的几种回答)。
- 说明:1.3.2 的「点会话行收抽屉 / 不聚焦输入框」是纯网页半区,已经包含在这一版里
(这次在真机上按手机布局验过:点会话行后抽屉收起、光标不落进输入框)。
这一版同时发 APK
1.3.3,因为上面那条「说错话」的通知文案在 APK 里 —— 不换 APK 的话手机上
仍然是含糊的「连不上」。
1.3.2
- 手机布局:点左侧列表里的会话,一步到位(纯网页半区,APK 不用换)。以前点一条会话,
DSH 切过去了、抽屉却还盖在正文上,得再点一次遮罩才看得见 —— 现在点会话行会立刻收起抽屉
(替用户点它自己的「收起侧边栏」,与遮罩 / 手势 / 返回键同一条路)。
- 同时不把光标塞进输入框:DSH 在会话切换时会 focus 输入框(
dsh-client-ui-conversation
里那个 useEffect(…, [sessionId])),电脑上是好意、手机上一执行就弹软键盘。插件不改它那份逻辑,
只在点会话之后 400ms 的窗口里把输入框设为不可聚焦(inert)、并兜底把抢到的焦点 blur 掉;
窗口一过立刻恢复,用户自己点输入框照常能打字。只认侧栏里的会话行
(data-row-key="session:…"),行内按钮与工作区分组行都不受影响。
- 测试:客户端补 12 条(点会话行收抽屉、输入框被按住与恢复、行内按钮 / 工作区行 / 抽屉没开 /
电脑布局 / 本机页面都不误伤、停用后监听收回且输入框放开)。
- 这一版只动网页半区:手机上刷新页面即生效,APK 仍是
1.3.1
(下载),
因此这次也没有新建 Release。
1.3.1
- 安卓外壳:返回键不再退出 App,而是退到后台(APK
1.3.1)。最后一档从「退出 App」改成
moveTaskToBack —— 退到后台才有下面那条通知;真要退出从最近任务里划掉即可。
地址页、错误页上按返回的确定性行为不变(回网页 / 回地址页)。
- 安卓外壳:模型提问时弹状态栏通知(新,需 APK
1.3.1 + 插件 1.3.1)。三条条件同时成立才响:
模型在等你选择 + App 不在前台 + 电脑上的 DSH 窗口不可见;问题答完通知自动消失,点它回到 App。
实现分三处,谁也不猜谁:宿主挂 user-questions/request 记待答、/__remote_access__/notify
提供带鉴权的长轮询;本机页面的 client.js 上报 document.visibilityState(带 25 秒心跳,
宿主 150 秒收不到就按「不可见」处理 —— 失效方向是多提醒一次);外壳在退到后台时起一条
specialUse 前台服务(WatchService)自己发长轮询,因此不依赖 WebView 还活着。
两个新开关:「提问时通知手机」(notifyOnChoice)与「仅窗口不可见时通知」
(notifyOnlyWhenWindowHidden)。Android 13+ 的通知权限在第一次退到后台时问一次;
电脑关着时前台服务退避重试,连续 5 分钟连不上就自己收工。
- 新增权限:
POST_NOTIFICATIONS / FOREGROUND_SERVICE(_SPECIAL_USE)(都是这条通知链路必需)。
APK 仍是同一把自签钥匙(CN=DSH Remote),可直接覆盖安装(versionCode 8 → 9)。
- 状态文件多两行诊断:
待答选择 与 电脑窗口(后者看得出「谁在什么时候回报过可见性」)。
- 文档:Release tag 实际是
v<版本号>(v1.3.1 等),早先几处写成 v<版本号>-android 与事实不符,已改;
包名/仓库改名后已发过的 tag 不动(v1.3.0 / v1.1.1 / v1.1.0 / v1.0.1 照旧能下)。
- 测试:Host 侧补 28 条(待答中心、通知端点、长轮询、窗口可见性路由、开关透传、
「必须挂最外层」这条 prepend 约定),客户端补 6 条(只在本机页面上报、心跳、清理、老 WebView 安静跳过)。
1.3.0
- 安卓外壳:返回键整个接管(APK
1.3.0)。以前它只在「网页能后退」时才动,其余情况一律退出:
抽屉开着、设置面板开着、错误页上按返回,都会直接把 App 关掉。现在的顺序是
先关网页里的浮层(抽屉 / 右栏 / 设置面板)→ 再网页后退 → 最后退出;
地址页上按返回则回到网页(从网页点「设置」进来时),错误页上按返回回到地址页改地址。
两条系统路径都接上了:Android 13+ 的 OnBackInvokedCallback 与旧版的 onBackPressed。
网页那半边只需多答一句「还有几层能关的」(window.__dshRaBack,只作用于手机布局,
本机页面与电脑布局都不参与)—— 普通浏览器里打开时它等于不存在。
⚠️ 两端要配套:APK 与插件都从 1.3.0 起(仓库改名后两边共用一个版本号,
安装包叫 dsh-plugin-remote-access-1.3.0.apk)。只换 APK、插件还是老版本时,
外壳问不到浮层,表现依旧是「抽屉开着按返回直接退出」(这一条是被真机踩出来的)。
- 返回键也管左上角那两个全局面板:「插件」与「自动化任务」(以及任何注册进
main 槽位的全局面板)选中后正文就是它,返回键现在会先回会话 —— 状态读官方的
ctx.layout.panelInfo、切换走 layout.selectPanel(null),不猜 DOM、不加必需服务
(用 ctx.get 可选取用,老 DSH 取不到就少这一层)。这一层是纯网页半区,不用重打 APK。
- 修:新会话首页左右滑不出面板(聊天页却正常)。手势判定里把「带
data-we* 属性的元素」
一律当成横向归属方,而壁纸引擎那批锚点(data-we-glass-page / -wallpaper / -parallax /
-adapter…)是插件一启用就恒挂在 <body> 上的 CSS 门控锚点;首页 DOM 浅、起手点往上
几层就到 body,整页因此被吃掉,聊天页嵌套更深、14 层内走不到 body 才侥幸正常。
现在根节点(body / html)一律跳过,只认真正挂在元素上的交互标记;吉祥物照样让开
(它自带 touch-action: none),壁纸卡片这类带标记的元素也照样让开。
- 顺手修掉
android/README.md 里过时的「顶栏」描述(那两枚图标早就是左侧的悬浮胶囊了),
并让地址页左上角那个返回图标与返回键改看主框架有没有真的加载成功
(以前连接一发起就算「连过」,于是连错地址时返回会退回一张白页)。
- 文档:主 README 的 APK 下载入口改成指向 Releases 页,不再把版本号钉死在文件名里
(APK 每抬一次版本就换名字,钉死迟早落后),并补上本节这条「两端要配套」。
- 版本号对齐:APK 的
versionName 由 1.1.2 改成 1.3.0(与插件同号,versionCode 仍是 8),
安装包与 Release tag 跟着仓库改名:dsh-plugin-remote-access-1.3.0.apk / v1.3.0-android
(改名前的 dsh-remote-1.1.1.apk / v1.1.1-android 保持原样,老链接照旧能下)。
1.2.0
- 改名:包名
dsh-remote-access-cidr → dsh-plugin-remote-access,仓库同为
Mzy123l/dsh-plugin-remote-access。
插件行的 id(remote-access)与设置命名空间没变,但 bundle 是按包名装载的,
所以已装的旧来源不会自动跟过来 —— 按上面「安装」里的迁移说明重装一次即可。
- 发布到 npm:新名字下已发布 dsh-plugin-remote-access@1.2.0
(旧的
dsh-remote-access-cidr 停在 1.0.0,不再更新)。装起来最快的写法从此变成直接填包名。
- 文档:README /
install.ps1 / 代码注释里的旧名字与旧链接全部换成新仓库名,APK 链接指向当前 release。
1.1.1 – 1.1.2
- 安卓外壳:地址页左上角加返回(从网页点设置进来时回得去);设置 / 刷新两枚小图标挪到左侧图标栏下方;
APK 版本号统一为 1.1.1。
- 手机端手势:起手落在画布 / 可横滚容器 / 可拖拽 / 壁纸节点上时不接管横滑
(拖动吉祥物、横拖表格代码块不再滑出面板);抽屉开着时向右滑也能收起。
- 代理:给内容寻址的静态产物(
/plugins/??rev=… 与 /assets/)钉一年 immutable 缓存,
治「每次进去都要重下十几 MB」。
1.1.0
- 「UI 设置」(新):设置页里多一层可折叠的下一级菜单,把官方「通用设置」里的
外观 / 字号大小 / 工作步骤展示 / 显示代码工作视图 / 性能与用量 存进本插件配置
(
remoteTheme / remoteFontSize / remoteTranscriptView / remoteDeveloperTools / remotePerformanceUsage)。
这几项在非回环页面上本来是「改完刷新即还原」,现在每次打开远程页面都会重新套用;
default = 出厂值(显式套回官方默认值),且只作用于远程页面、不写宿主设置文档。
- 安卓 App(新):
android/ 是 DSH 的安卓外壳(首屏填 IP:端口,断网也能改地址),
Release 里有可直接安装的 APK(应用名 DSH)。
install.ps1:下载后校验包名与必需文件、覆盖前自动备份旧版本、-Install 直接交给 dsh CLI
(DSH 正在跑时自动跳过),还会认出 profile 里钉死的 github: 来源并提示怎么换。
- 手机端:抽屉真正不透明(垫底用壁纸引擎自己的「可读性底色」)、右栏不再压到正文上、边缘滑动手势。
- 修:上个版本引入的「下游断开后上游才响应」会把 DSH 宿主整个带崩(
ERR_STREAM_UNABLE_TO_PIPE)。
- 外观下拉的选项顺序照官方来(浅色在深色之前)。
1.0.0
- 首次发布:按网段开放 DSH 网页界面;带票网址 / 数字密码两种进入方式;
远程UI布局(手机 / 电脑 / 自动);
状态文件与自诊断;手机上的「新建工作区」用应用内选目录。
许可
MIT © 2026 Mzy123l