Ckarefulon/dsh-tailscale-remote ↗★ 1

dsh-tailscale-remote

Publishes the DSH Web GUI on your tailnet whenever DSH runs, so a phone anywhere can connect, and removes the route on exit. Phone pages default to a lite plugin set (about 1.1 MB gzip instead of about 5.3 MB) that the desktop app never sees, toggled by an icon beside Settings; the desktop app itself is untouched (this plugin registers nothing there) and startup cost is zero because the WebSocket compression patch is armed lazily on the first handshake. The phone link lives in Settings -> General -> Phone access with one-click copy and key rotation: rotating revokes the old link and every session signed with it. The same link is in the loginUrl field of ~/.dsh/tailscale-remote.json. 适合需要从手机等远程设备安全连接并控制DSH Web界面的用户。

パッケージ
dsh-tailscale-remote
互換性
未検証
バージョン
0.5.3
ライセンス
MIT
最終更新
2026/10/20

インストール

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:Ckarefulon/dsh-tailscale-remote

ドキュメント

README 全文を読む ↗

配置

字段默认说明
enabledtrue关掉则插件完全不动作。
httpsPort7259tailnet 上的 HTTPS 端口。
connectOnStarttruetailscale 没在跑时是否把它拉起来。
serveOnStarttrue是否建立 Serve 路由。
teardownOnExittrue退出时是否回滚。
trustedHoststrue是否把该 authority 加进 /api 信任列表。
loginRoutetrue是否注册 /remote/ 这个稳定登录入口(设置页那一行的「更换密钥」也依赖它)。
revokeSessionsOnRotatetrue换密钥时是否同时吊销所有已登录的会话。默认开:密钥泄露时对方可能已用旧密钥换到一张 10 年 cookie,只换登录密钥拦不住他。关掉则只换链接、所有设备继续登录。
cookieMaxAgeDays3650登录 cookie 有效期(天)。0 = 不插手 DSH 的 30 天默认值。
binaryPath自动探测tailscale CLI 可执行文件路径。
guiPath自动探测Tailscale 桌面客户端路径(Windows 默认 tailscale-ipn.exe)。
readyTimeoutMs8000等 tailscale up 生效的预算。
guiReadyTimeoutMs25000启动桌面客户端后等 backend 就绪的预算。
stateFile$DSH_HOME/tailscale-remote.json状态文件位置。
litetrue精简模式默认值:手机那个页面只带基础插件(首屏插件从约 5.3 MB gzip 降到约 1.1 MB)。桌面端不受影响。侧边栏底部的图标点一下可以随时改,改了就以那个持久选择为准。
liteSeeds内置白名单精简模式保留哪些插件(闭包种子)。只在 DSH 升级改包名、你想自己补几个包时才需要。
liteHide内置列表精简页面上要藏掉的界面锚点(CSS 选择器)。默认藏掉右侧栏与轨迹页签。设置入口永远不藏(手机上进设置页只有那一个地方)。
websocketCompressiontrue给 DSH 那条 WebSocket(/api/remote.mux,会话历史走它)开 permessage-deflate。惰性挂钩:启动期零开销,第一次握手时才 patch。

精简模式(手机流量省 79%)

DSH 的 Web 首屏会把插件图里每一个客户端包 preload 下来,本机实测约 5.3 MB(gzip)。 其中大半手机根本用不到:设置里的账号插画单个包就 2.4 MB,还有插件管理器、模型设置、 各种第三方面板。手机走 Tailscale 时带宽窄,这些字节是纯粹的负担。

插件的做法是不动 DSH 一行代码,只在渲染出来的 index.html 上做手术:

  1. 用一份白名单在活的插件图上求闭包,得到精简图(实测 30/86 个包);
  2. 把 globalThis["__DSH_BOOT__"] 换成精简图 —— 客户端于是只认这些包;
  3. 把整图那些首屏 preload 换成精简版,由插件自己的路由服务,并用 brotli 压 (比 webserver 默认的 gzip level 1 再省约 32%);
  4. 在 head 里补一段 ``,把右侧栏、轨迹页签藏掉,顺手把 「工作区」标题行里搜索按钮前面那段硬编码空白归零。

桌面端一个字节都不会变。 Electron 不走这条路:它直接读磁盘上的 index.html, 再经 IPC 把注入表交给客户端;而 tapIndex 只参与 webServer.renderIndex(), 也就是 HTTP 那条回退路径。所以「不动桌面 DSH」是结构保证,不是小心翼翼。 另外客户端半边在桌面窗口里一个插槽都不注册(判据是 preload 注入的 dshDesktopBoot,只有桌面窗口才有),所以桌面上的 DSH 与没装本插件时完全一致。

实测(真浏览器 + 真宿主):精简页面的插件请求只有两个 —— 一个 bootstrap 小包加一个 合并请求,首屏不再有那条约 5 MB 的 /plugins/??...;页面照常启动、连接正常、 工作区与会话列表、输入框全部可用,控制台零报错。

怎么切换:精简页面上,侧边栏底部「设置」图标旁边多了一个图标按钮 (16×16 的双向箭头,与 DSH 自己的图标同规格)。点一下在「精简 / 完整」之间切换:

  • 关掉后刷新手机上的页面,就会发完整版(所有插件)。
  • 站点本身立刻记录这个选择,但页面是在下一次渲染 index 时变的 —— 所以按钮的 title / aria-label 会写清「刷新后生效」,否则看起来像按钮坏了。
  • 完整模式页面里不显示这个图标(那时完整模式页面跟手机上没区别,用不着它)。
  • 桌面窗口里根本没有这个图标(连插槽都不注册)。
  • 设置图标本身始终保留:sidebar.settings 是 single 插槽、由 ui-settings 注册在 侧边栏底部,是手机上进设置页的唯一入口,藏了就没法改模型/账号/插件了。

为什么不能简单地「把不要的包从图里删掉就算完」:exports.inject 是服务级 门禁。像 ui-sidebar-right 提供的 sidebarRight 被 ui-chat 硬依赖,删了它 ui-chat 的 fiber 会停在 pending,而 shell 的 boot 审计把 pending 算失败, 整页白屏。所以这类包留着、只藏界面;真正能删的是那些没人依赖其服务的。

安全阀:如果白名单在活图上失配(DSH 升级拆包/改名),插件原样发完整页面并记一条日志 —— 宁可多发几 MB,也绝不发一个进不去设置页的残缺页面给手机(那里用户没有任何自救手段)。

手机中途连接为什么快了很多

「刷新页面」只是第一段。手机真正难熬的是中途操作:载入历史会话、发命令、收流式回复。 这两件事走的是完全不同的两条路,插件各修了一处:

① 拉会话历史 / 收事件 = 一条 WebSocket,而 DSH 默认完全不压它。 会话历史不走 HTTP,走 /api/remote.mux;DSH 用 new WebSocketServer({ noServer: true }) 建它,而 ws 的 perMessageDeflate 默认是 false。实测握手响应里没有 sec-websocket-extensions —— 那些高度重复的 JSON 事件是明文过的(一个界面 12 KB 的会话, 背后收进了 22.5 MB 下行文本)。

插件在 ws.WebSocketServer.prototype.handleUpgrade 上补一层:把 this.options.perMessageDeflate 打开。位置很关键 —— ws 在 handleUpgrade 内部同步用 this.options 构造 socket, 所以在这里改一定生效,没有竞态。打的又是 DSH 自己那一个 ws 模块实例(从 process.resourcesPath 下的 app.asar 解析),所以对已经建好的 mux 也生效,不用重启、不碰 DSH 一行代码。

② 启动开销为零。 require('ws') 要从 asar 里读并编译,实测 159 ms —— 花在启动路径上 就是「插件拖慢了启动」。所以这个 patch 是惰性的:启动期只挂一个一次性钩子 (server.prependListener('upgrade'),只观察不消费事件),等第一次 WS 握手路过时再 patch。 那 159 ms 于是落在手机那条连接上,而不是你的启动路径上(实测装配耗时 0.04 ms)。

③ 首屏合并请求自己压。 webserver 的 gzip 是 level 1(约 1.30 MB),同一份字节用 brotli q5 约 0.89 MB、gzip level 6 约 1.06 MB。插件自己压并缓存结果(只算一次), 按 Accept-Encoding 协商 br → gzip → 原文,并带 Vary: Accept-Encoding。

关于「永久不过期」

DSH 把 cookie 有效期上限写死在 connection 的 cookieMaxAgeDays 里,并且校验时要求 expiresAt - issuedAt <= maxAgeMilliseconds —— 所以自己造一个超长 cookie 是无效的, 必须改这个上限。本插件启动时会直接改 connection.browserAuth.maxAgeMilliseconds, 默认给 10 年(3650 天),够用且不会溢出 JS 安全整数范围。

想更长就把它调大(比如 36500 = 100 年);0 表示完全不动 DSH 的默认 30 天。