dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
只把本机 DSH 网页界面开放给指定的一个网段(默认 Tailscale 100.64.0.0/10),不绑 0.0.0.0:网段外的对端一律拒绝,进入方式可选带票网址或 4–12 位数字/字母访问密码;远程页面可强制手机布局而不动本机界面,官方「通用设置」里的外观 / 字号大小 / 工作步骤展示 / 显示代码工作视图 / 性能与用量可存进本插件配置,并在每次打开远程页面时重新套用。
给 DeepSeek Harness(DSH)桌面版用的插件:把本机 DSH 的网页界面只开放给你指定的网段
(默认 Tailscale 的 100.64.0.0/10),于是手机或另一台电脑也能用上它。
0.0.0.0:只监听「允许网段」里属于本机的地址,网段外的对端一律 403。engines.dsh = ^0.2.1-rc.1;上游地址自动读 webServer.getOrigin(),
跟随桌面端系统分配端口与原生 HTTPS;Host/Origin 改写在两种协议下都正确。$DSH_HOME/remote-access-config.json。
插件不读也不写共享 profile 的 cordis.patch.yml;设置页走本插件自己的
/__remote_access__/config 端点读写这份 JSON。多个用户共用一份 profile 时端口各用各的,
被占用还会自动换一个空闲端口并记住。三种方式,选一种即可;装完重启一次 DSH。
版本要求:本版(1.4.0 起)只适配 DSH 0.2.1+(
engines.dsh = ^0.2.1-rc.1, 覆盖0.2.1-rc.1/0.2.1-rc.2与后续 0.2.x)。DSH 0.2.0 及更早版本请用1.3.8。 dshmarket 会按这个字段显示「支持/不兼容」,安装前也能看到。
发布记录: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-cidr那一行的config:(网段 / 密码 / UI 设置都在里面)—— 想留参数,先把它抄下来,装好再粘回去。
⚠️ 1.4.1 的 id 迁移(重要):DSH 0.2.1 自己新增了一个 loader 行
id: remote-access(@deepseek-ai/dsh-api-remote-access)。本插件 1.4.0 的行 id 也是remote-access,两者重名会让官方的remoteAccess服务不挂载,桌面壳启动时报desktop welcome: Web request failed。1.4.1 起插件行与设置命名空间改为remote-access-cidr。升级后把旧cordis.patch.yml里id: remote-access的整段config:挪到id: remote-access-cidr下面(或者到设置页重新填一遍参数)。
② 命令行(等价于插件页那一步;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
(全部 Release · 源码在 android/ · 说明见 android/README.md)
链接指向 Releases 而不是某个固定文件名:APK 名形如
dsh-plugin-remote-access-<版本>.apk, 每抬一次版本都会换名字,钉死某个文件名迟早会落后。要装具体某一版,进 Release 页挑。
APK 与插件不总是同一个号:APK 只在「动了外壳代码」的那一版重发。 当前插件是 1.4.4,APK 仍是 1.4.1(
versionCode16;这一版只有宿主/网页半区改动, 外壳代码没动,不用重装 APK)。
App 名字就叫 DSH,装完图标是那个蓝色看板娘(自适应图标,圆形/方形启动器都正常)。
IP或域名:端口(不用写协议,例 100.64.0.3:19388),HTTPS 一个勾决定;android/README.md);远程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。
余额 / 额度不足:insufficient balance),绝不带模型的回答内容
(可能很长,也不该出现在锁屏上)。agent/status 的 running → idle),并带 2 秒防抖 ——
循环里的中间态(维护 / 压缩)不会被误报成「跑完了」。要弹出横幅,Android 那边有四件事必须同时成立,这一版都落实了(细节见
android/README.md):通道是 IMPORTANCE_HIGH、
通知本身是高优先级、每次都发一条新通知(更新旧通知不保证再弹),
而常驻的「后台待命」那条故意留在低重要性(它不弹、不响,只是前台服务的本职)。
只在状态栏看到一条不弹的通知? 那多半是常驻的「后台待命」那条 —— 它是前台服务的本职, 不弹是对的。真正会弹的是「模型提问」/「任务结束」那两条。
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,该响才响,答完就撤 |
几条刻意的设计:
notifyOnChoice —— 关掉即不再上报待答状态,App 也就不再提醒;notifyOnlyWhenWindowHidden —— 关掉则只要 App 在后台就提醒
(电脑窗口开着时也会响,适合把 DSH 窗口常年放着的用法)。... 电脑上的插件已支持提问通知 ✓ / ... 电脑上的插件还没有通知接口 (1.3.1 起才有)→ 更新插件并重启一次 DSH);电脑上的插件版本太旧… /
登录凭据已过期:打开 App 重新输一次访问密码 / 连不上电脑上的 DSH,正在重试…。
这两处合起来能分清「代码旧 / 宿主旧 / 开关关了 / 真的连不上」,不用再猜。
两处都对、问题也确实在等,但就是不弹横幅时,去系统设置里看「DSH → 通知」:
「模型提问」通道别关成静音、别关掉「横幅 / 悬浮通知」开关,也别开着勿扰。http://<地址>:<端口>/ —— 地址和端口见状态文件里的 手机访问: 那一行。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)里写着:带票网址、
手机访问地址、是否开启了解锁密码、已拉黑的地址、放行密钥、本用户配置路径、
当前生效的配置、上游自检结果。打不开时先看它。
⚠️ 文件里有访问令牌,等于本机操作权限,不要外传(访问密码在里面打码成
***)。
配置只存在 <DSH_HOME>\remote-access-config.json(默认 %USERPROFILE%\.dsh,权限 0600)。
插件不读也不写共享 profile 的 cordis.patch.yml,也不注册 DSH 的配置表单 ——
设置页通过本插件自己的 /__remote_access__/config 端点读写这份 JSON。
为什么这么改:把 .dsh\profiles\desktop 做成 junction / 符号链接指到同一个目录,是多个
Windows 用户共用一份 DSH 的常见做法 —— 那样 cordis.patch.yml 里的 port、密码等也是共享的,
两个人同时开 DSH,第二个实例必然 listen EADDRINUSE: address already in use。现在每个用户只认自己的文件。
规则:
100.64.0.0/10,upstream: auto);cordis.patch.yml 读任何参数;你在那里写的 config: 会被忽略。文件是可手改的 JSON:config 就是全部生效值。
{
"version": 1,
"note": "本文件是当前 Windows 用户自己的远程访问配置(唯一配置源)……",
"updatedAt": "2026-10-19T00:00:00.000Z",
"config": { "port": 54321, "remoteTheme": "dark", "……": "……" }
}
从 1.4.x 升级:1.4.x 的参数在共享 profile 的
cordis.patch.yml里,这一版不再读取它。 升级后请到「设置 → 远程访问」里把参数重新填一遍(或者直接编辑上面这个 JSON)。 如果你已经在用 1.4.2–1.4.4,本用户 JSON 已经存在,里面就是上次的生效值,通常不用改。
「设置 → 远程访问」里是 10 个常用项(启用 / 允许的网段 / 排除的网段 / 端口 / 并发上限 / 日志级别 / 访问密码 / 远程UI布局 / 提问时通知手机 / 仅窗口不可见时通知),外加一层可折叠的下一级菜单 「UI 设置」(5 项界面偏好,见下)。 保存只写本用户 JSON,插件随即重挂监听(端口会变,新网址以状态文件为准)。
访问密码只收 4–12 位数字或字母(大小写敏感)。写别的(空格、符号、太短太长)不会生效,
而且一定看得见:设置页点保存时就地拦下并说明规则,直接改 JSON 写进去的非法值会在
状态文件的诊断里留一条 「访问密码」没生效…。这条提示是故意做的 —— 静默失效会让人以为密码设好了,
然后在手机上撞见 DSH 那句英文 401(这曾经是最难查的一种「坏了」)。
改密码不会把已经进来的设备踢下线:放行 cookie 的密钥与 accessCode 无关,单独存在
<DSH_HOME>\remote-access-secret(0600),DSH 重启后也照样有效。想让所有设备重新输一次密码,
删掉那个文件即可。
设置页在本机与手机上是同一套:都走本插件自己的 /__remote_access__/config 端点,
只读写宿主那台机器上的本用户 remote-access-config.json(手机请求先过访问门)。
写盘完成后才回执;重挂监听排在回执之后(不会把这次保存的连接掐断)。
设置页里「远程访问」下面有一层可折叠的下一级菜单「UI 设置」,放的是官方「通用设置」里的五项界面偏好 (外观、字号大小、工作步骤展示、显示代码工作视图、性能与用量)。
外观 / 字号:只投影到本页 DOM —— root 的 color-scheme、data-ds-theme-source、body 的
data-ds-dark-theme 与三种字号的 CSS 变量;不调用 theme.setTheme / theme.setFontSize,
宿主的官方设置文档一个字都不写。电脑端改主题时会广播 theme/change,手机页在官方 presenter
之后重新投影自己的外观,所以「电脑切深色、手机仍按自己的设置」。
工作步骤展示 / 显示代码工作视图 / 性能与用量:官方只提供「写宿主」的公开入口,
本插件不写任何别的配置,所以它们跟随电脑设置;设置页状态行会写明。
remoteLayout 同一条硬约束;default(出厂值)不是「什么都不做」:外观套回 system、字号套回官方当前值;其余参数直接写在本用户 JSON 的 config 里(也不建议常用):
| 键 | 默认 | 说明 | 设置页 |
|---|---|---|---|
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 设置」一组里的外观:default(出厂值)/ dark(深色)/ light(浅色)/ system(跟随系统)。只作用于远程页面,且不写宿主设置文档 | ✅ |
remoteFontSize | 0 | 正文字号(10–22 px):0 = 出厂值(14)。只投影到远程页面 | ✅ |
remoteTranscriptView | 'default' | 工作步骤展示:default / compact / standard / detailed / verbose。DSH 0.2.1 起跟随电脑设置(官方只提供写宿主入口) | ✅ |
remoteDeveloperTools | 'default' | 显示代码工作视图:default(开启)/ on / off。DSH 0.2.1 起跟随电脑设置 | ✅ |
remotePerformanceUsage | 'default' | 性能与用量:default / compact / detailed。DSH 0.2.1 起跟随电脑设置 | ✅ |
cacheEnabled | true | 手机端资源缓存总开关:给上游没给缓存头的静态资源(图片 / 视频 / 字体 / 打包产物…)补新鲜期与弱校验器。API、HTML、no-store 一律不碰 | ✅ |
cacheTtlMinutes | 10 | 上面那类资源的手机端新鲜期(1–1440 分钟):期内手机一次请求都不发;过期后先出旧的、后台只做一次「头部级」核对(零字节) | ✅ |
cacheProbe | true | 过期核对时额外取首/尾各 64 KB 做指纹(治「换了图但字节数一模一样」)。不整份哈希、不落盘 | ✅ |
cacheOverrideNoStore | false | 默认关:连上游标了 no-store 的大静态媒体(图片 / 音频 / 视频)也缓存。有些插件对「实时抓帧 / 预览图」写 no-store,一取就是几 MB、每次进页面都重来;打开后最多会看到「新鲜期」这么久之前的旧帧(配 SWR:先出旧的、后台悄悄换新)。判大小用探针读到的首段 —— 没有 content-length 的分块响应同样算数(真实抓帧就是这种);读完不足 64 KB 的实时小端点不会被顺手缓存 | ✅ |
cacheFullRange | false | 默认关:把「从 0 取到结尾」的整段 Range 请求当成整份来缓存(上游回的 206 覆盖整份,而浏览器永远不缓存 206 —— 实测壁纸音轨每次进页面重下 3.8 MB × 2)。只有「请求就是 bytes=0-」且「上游回的确实覆盖 0..total-1」时才改写成 200,字节一模一样;拖进度那种中间分段一律原样透传 | ✅ |
listen | ['auto'] | auto = 只监听上面网段里的本机地址;也可写 ['100.x.y.z'] | — |
upstream | 'auto' | DSH 界面地址;auto 读 ctx.webServer.getOrigin()(协议 + 真实端口,兼容 0.2.1 的系统分配端口与 HTTPS)。也可显式写 http://127.0.0.1:3080 / https://... | — |
rewriteHost | true | 把 Host/Origin 改写成上游 authority | — |
forwardClientHeaders | true | 转发 x-forwarded-for / x-forwarded-proto | — |
timeoutMs | 0 | 等上游响应头的超时(毫秒),0 = 不超时。响应头一到就交棒,正文/流式传输不会被它掐断 | — |
allowWebSocket | true | 透传 WebSocket(界面实时推送靠它) | — |
urlFile | '' → <DSH_HOME>/remote-access-url.txt | 状态文件写哪;off = 不写 | — |
printUrl | true | 同时把带票网址打到 DSH 日志 | — |
DSH 默认用 directory-picker-auto:它判定「回环绑定 + 非 SSH + 有显示会话」时会挑原生后端,
而原生后端是在宿主屏幕上弹系统对话框 —— 手机那边点下去只会一直等(表现就是「打不开文件管理器」)。
本插件的 bundle patch 因此把选目录器固定成应用内浏览:关掉 auto,改挂
dsh-host-directory-picker-browse(列目录 / 建目录的后端)+ dsh-client-ui-directory-picker-browse
(「选择工作区目录」对话框)。手机上点新建工作区就会弹出这个应用内对话框,桌面上也一样。
cordis.patch.yml 里 disabled 那段和两行 -browse 删掉
(代价:手机端不再有选目录能力)。对话框的面包屑在主目录以内会折叠:起点显示成「主目录」,C:\、Users 这些上层被有意隐掉,
所以「找不到上一级」是设计而不是坏了。跳出去很容易:
点面包屑右端的铅笔按钮(「编辑路径」),它会变成输入框并预填当前路径,改成你要的路径回车即可:
| 输入 | 效果 |
|---|---|
C:\ | C 盘顶层,所有顶层文件夹都出来了 |
D:\projects | 直接跳到目标目录 |
\\server\share | UNC 路径 |
宿主只接受完全限定路径(Windows 上必须是 C:\… 或完整的 \\server\share…;\foo、相对路径会被拒绝)。
跳到主目录以外之后,面包屑就会显示真实完整路径。目标文件夹不存在时,先用对话框里的「新建文件夹」,
或直接输入一个已存在的目录再点「打开」。
DSH 的界面是为桌面三栏设计的。在 390px 宽的手机上,它的表现是:
DeepSeek-V4…。「远程UI布局」就是针对这些做的,选 手机 或 自动 后,只有远程页面会变成:
| 位置 | 手机布局 |
|---|---|
| 侧栏 | 收起时保持 56px 图标栏;展开时改成抽屉浮在正文上(min(86vw, 320px)),正文保持满宽,点遮罩即收起;点列表里的一条会话也立刻收起(见下) |
| 设置页 | 改成上下两段:导航变成顶部横向滚动的胶囊条,内容占满整宽并自行滚动 |
| 输入区 | 工具条允许换行、左右内边距收窄,模型名不再被截断 |
| 触控 | 设置项在手机上单列排布、输入框 16px(iOS 聚焦不放大整页)、按钮 ≥40px |
| 安全区 | 刘海/圆角/手势条按 env(safe-area-inset-*) 留白;横屏高度紧张时压薄设置页导航 |
手机上「左侧工作区列表」就是抽屉。点一条会话时,插件会做两件事(只在手机布局下):
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 哈希前缀。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
就能答:
为什么用「长度 + 类型 + 首/尾片段」而不是整份哈希:壁纸那种几十 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;写盘失败时它仍会被立刻拦住,
并在状态文件里写明),这是故意的:可暴力猜的数字密码需要一道硬刹车。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)即可;也可以直接编辑本用户 JSON 的 denyCidrs。
插件显示已启用,手机却连不上 / 状态文件里是 listen EADDRINUSE(1.5.0 起每个用户只认自己的配置)—
这个端口被另一个 DSH 实例占着了。1.4.x 时多个 Windows 用户共用 profile 会共享 cordis.patch.yml
里的 port;1.5.0 起配置只在本用户 JSON 里,端口被占用时插件会自己换一个空闲端口、
写回本文件,并把实际网址写进状态文件 —— 手机照状态文件里那条打开即可。
想让某个用户固定用某个端口:在「设置 → 远程访问 → 端口」里填(只影响本用户),
或者直接改本用户配置里的 config.port。
前几天还好,现在又要重新验证 — 换过端口或重启过 DSH。DSH 的 cookie 绑定 hostname:port,所以旧的不通用;
按「使用」里的步骤重来一次。
设置页点了保存没反应 / 报失败