@linxin666/dsh-remote-web-ui 0.1.20 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +41 -19
  3. package/README.zh.md +17 -12
  4. package/assets/mobile-icon-192.png +0 -0
  5. package/assets/mobile-icon-512.png +0 -0
  6. package/assets/mobile-service-worker.js +64 -0
  7. package/lib/client.js +37 -10
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +290 -53
  10. package/lib/mobile.js +278 -17
  11. package/lib/mobile.js.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.d.ts +6 -0
  13. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PluginSettingsCard.js +5 -3
  15. package/lib/types/client/RemoteEntry.js +2 -2
  16. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  17. package/lib/types/client/RemoteSettingsCard.js +1 -1
  18. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  19. package/lib/types/client/UpdateEntry.js +36 -3
  20. package/lib/types/client/deep-link.js +1 -1
  21. package/lib/types/client/locales.d.ts +2 -0
  22. package/lib/types/client/locales.d.ts.map +1 -1
  23. package/lib/types/client/locales.js +2 -0
  24. package/lib/types/gate.d.ts +9 -0
  25. package/lib/types/gate.d.ts.map +1 -1
  26. package/lib/types/gate.js +15 -4
  27. package/lib/types/index.d.ts +25 -0
  28. package/lib/types/index.d.ts.map +1 -1
  29. package/lib/types/index.js +30 -13
  30. package/lib/types/mobile/PairRequiredView.d.ts +7 -0
  31. package/lib/types/mobile/PairRequiredView.d.ts.map +1 -0
  32. package/lib/types/mobile/PairRequiredView.js +28 -0
  33. package/lib/types/mobile/PairRequiredView.test.d.ts +2 -0
  34. package/lib/types/mobile/PairRequiredView.test.d.ts.map +1 -0
  35. package/lib/types/mobile/PairRequiredView.test.js +24 -0
  36. package/lib/types/mobile/index.js +14 -1
  37. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  38. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  39. package/lib/types/mobile/mobile-styles.js +48 -0
  40. package/lib/types/mobile/pairing.d.ts +31 -0
  41. package/lib/types/mobile/pairing.d.ts.map +1 -0
  42. package/lib/types/mobile/pairing.js +62 -0
  43. package/lib/types/mobile/views/App.d.ts +12 -5
  44. package/lib/types/mobile/views/App.d.ts.map +1 -1
  45. package/lib/types/mobile/views/App.js +34 -7
  46. package/lib/types/mobile/views/App.test.d.ts +2 -0
  47. package/lib/types/mobile/views/App.test.d.ts.map +1 -0
  48. package/lib/types/mobile/views/App.test.js +52 -0
  49. package/lib/types/mobile-pwa.d.ts +6 -0
  50. package/lib/types/mobile-pwa.d.ts.map +1 -0
  51. package/lib/types/mobile-pwa.js +17 -0
  52. package/lib/types/mobile-routes.d.ts +5 -8
  53. package/lib/types/mobile-routes.d.ts.map +1 -1
  54. package/lib/types/mobile-routes.js +115 -30
  55. package/lib/types/pairing-access.d.ts +28 -0
  56. package/lib/types/pairing-access.d.ts.map +1 -0
  57. package/lib/types/pairing-access.js +26 -0
  58. package/lib/types/pairing.d.ts +27 -0
  59. package/lib/types/pairing.d.ts.map +1 -1
  60. package/lib/types/pairing.js +68 -0
  61. package/lib/types/pairing.persist.test.d.ts +2 -0
  62. package/lib/types/pairing.persist.test.d.ts.map +1 -0
  63. package/lib/types/pairing.persist.test.js +128 -0
  64. package/lib/types/routes.js +1 -1
  65. package/lib/types/update.d.ts +3 -3
  66. package/lib/types/update.d.ts.map +1 -1
  67. package/lib/types/update.js +34 -11
  68. package/package.json +12 -12
  69. package/src/client/PluginSettingsCard.tsx +10 -0
  70. package/src/client/RemoteEntry.tsx +2 -2
  71. package/src/client/RemoteSettingsCard.tsx +1 -0
  72. package/src/client/UpdateEntry.tsx +36 -4
  73. package/src/client/deep-link.ts +1 -1
  74. package/src/client/locales.ts +2 -0
  75. package/src/client/remote.module.css +26 -0
  76. package/src/gate.ts +15 -3
  77. package/src/index.ts +46 -15
  78. package/src/mobile/PairRequiredView.test.tsx +28 -0
  79. package/src/mobile/PairRequiredView.tsx +58 -0
  80. package/src/mobile/index.tsx +15 -1
  81. package/src/mobile/mobile-styles.ts +48 -0
  82. package/src/mobile/pairing.ts +77 -0
  83. package/src/mobile/views/App.test.tsx +62 -0
  84. package/src/mobile/views/App.tsx +53 -6
  85. package/src/mobile-pwa.ts +19 -0
  86. package/src/mobile-routes.ts +123 -30
  87. package/src/pairing-access.ts +36 -0
  88. package/src/pairing.persist.test.ts +145 -0
  89. package/src/pairing.ts +70 -1
  90. package/src/routes.ts +1 -1
  91. package/src/update.ts +40 -12
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # node scripts/verify-docs.mjs --write <dir>
5
- README.md: 3a4ac70503ef6629ac456e3e86e0e429b90fd4b2
6
- README.zh.md: e16a9bd530de74cff366fc23ae143254a198989b
5
+ README.md: ed09a3bb6b09641147fecd3a465a6766f22fb298
6
+ README.zh.md: d95d6fefe6b583c90169cd201464a62ac52381e0
package/README.md CHANGED
@@ -2,29 +2,31 @@
2
2
 
3
3
 
4
4
  English | [中文](README.zh.md)
5
- > 移动端远程控制 + 一键远程更新:扫码配对后用手机远程使用当前 dsh web 工作区;
6
- > 点击侧边栏更新按钮自动检查并更新 dsh-web-ui 全家桶。
5
+ > Mobile remote control + one-click updates: pair a phone to use the current dsh web workspace remotely; the sidebar checks for a newer dsh-web-ui release after it loads and marks the update button when one is available; clicking it updates the family.
7
6
 
8
7
  This repository is an external plugin package for DeepSeek Harness (DSH):
9
8
  scan-to-pair mobile remote control for the dsh web GUI, plus a one-click
10
9
  self-update for the dsh-web-ui family. It is a single dual-face package — the
11
10
  host half owns pairing tokens, device sessions, the `/api/pair` route family,
12
11
  and the `/api/update` surface; the browser half renders the sidebar-foot
13
- entries (the download trigger and the phone icon beside the settings button),
12
+ entries (the download trigger and an icon-only mobile-remote control beside the settings button),
14
13
  the pairing panel with a QR code, live device status, and stop/refresh/copy
15
14
  actions, and the update panel that probes and runs the update.
16
15
 
17
16
  ## What it does
18
17
 
19
- - **Entry**: a phone icon in the sidebar foot, next to the settings button.
18
+ - **Entry**: a phone icon beside the settings button in both the expanded sidebar and narrow rail; its tooltip and accessible label say "Mobile remote control".
20
19
  - **Panel**: "移动端远程控制" title, "扫码或在手机上打开链接,即可远程控制当前工作区"
21
20
  subtitle, a "手机扫码连接" card with the status area ("等待手机连接" + status
22
21
  badge), a large QR code, the "无法扫码?可以在手机上打开链接" hint, and three
23
22
  buttons: 停止 / 刷新二维码 / 复制链接.
24
23
  - **Phone side**: scanning the QR binds the phone with a one-time,
25
24
  time-limited token and lands it on the **standalone mobile surface at
26
- `/m`** — a thin client purpose-built for a small screen (see
25
+ `/m/`** — a thin client purpose-built for a small screen (see
27
26
  [Screenshots](#screenshots)), not the desktop UI squeezed into a phone.
27
+ The page is installable as a PWA. Each installed app uses its own
28
+ paired-device cookie; a storage-isolated mobile web-app context pairs by
29
+ opening a fresh QR link there or pasting a fresh desktop-issued link.
28
30
  The link carries a `workspace` parameter so the phone lands in the same
29
31
  workspace the desktop was looking at.
30
32
  - **Security**: one active one-time token (a refresh invalidates the old
@@ -36,9 +38,14 @@ actions, and the update panel that probes and runs the update.
36
38
  - **Live status**: the desktop panel mirrors the pairing state in real time
37
39
  (waiting → connected → disconnected) over an SSE stream.
38
40
  - **Remote update**: the download trigger in the sidebar foot (left of the
39
- phone icon) opens the update panel, which probes the npm registry for the
40
- installed `@linxin666/dsh-*` family releases. When a newer release exists
41
- the panel runs the update automatically (`pnpm update --latest` inside the
41
+ phone icon) performs a silent status probe after the sidebar loads. When a
42
+ newer registry release is available, the trigger shows a dot and the "New
43
+ version available. Check for updates" tooltip. Clicking the trigger opens the
44
+ update panel, which probes the npm registry for the installed
45
+ `@linxin666/dsh-*` family releases. Without the aggregate package, checks and
46
+ updates cover every registry-managed direct `@linxin666/*` dependency in the
47
+ profile; local link/file development dependencies are skipped. When a newer
48
+ release exists, the panel runs the update automatically (`pnpm update --latest` inside the
42
49
  owning dsh profile; when pnpm is missing it falls back to `corepack pnpm`
43
50
  and then `npx --yes pnpm`, and on Windows the command runs through
44
51
  `cmd.exe` so npm-installed `.cmd` shims resolve; the loopback-only
@@ -47,9 +54,8 @@ actions, and the update panel that probes and runs the update.
47
54
  versions are re-checked against the registry: a green exit that left every
48
55
  version in place (e.g. the pnpm `minimumReleaseAge` gate silently skipping
49
56
  same-day releases) is reported as a stale update with configuration
50
- guidance instead of a false success. Local
51
- link installs (development mode) are detected and report the npm state
52
- without updating.
57
+ guidance instead of a false success. When the anchor itself is a local link
58
+ install (development mode), it reports the npm state without updating.
53
59
 
54
60
  ## Screenshots
55
61
 
@@ -89,6 +95,7 @@ sun/moon toggle in every header flips to the dark palette at any time.
89
95
  runtime download covers installers that skip postinstall scripts). No
90
96
  user-side tooling, account, or domain is needed — a Cloudflare quick
91
97
  tunnel is free and anonymous.
98
+ - Installing `/m/` as a PWA requires a secure context: `localhost` and `127.0.0.1` work for local use, while phone installation requires HTTPS. Plain LAN HTTP keeps the online mobile remote usable but cannot register its Service Worker.
92
99
 
93
100
  ## Install
94
101
 
@@ -121,7 +128,7 @@ mounts both halves.
121
128
  1. `dsh web --host 0.0.0.0` (the printed LAN URL confirms reachability).
122
129
  2. Click the phone icon → the panel mints a fresh one-time QR.
123
130
  3. Scan with the phone (or open the copied link): the phone binds and
124
- lands on the **standalone mobile surface at `/m`** — no desktop UI on a
131
+ lands on the **standalone mobile surface at `/m/`** — no desktop UI on a
125
132
  small screen. The surface is deliberately thin:
126
133
  - workspaces straight away (a 新建会话 button lives on each workspace's
127
134
  session list: it creates a blank session attached to that workspace via
@@ -150,9 +157,10 @@ mounts both halves.
150
157
  sheet with persistent toggles for 工具调用 (tool-call disclosures) and
151
158
  系统提示词 (injected system messages), and a 上下文 usage chip that
152
159
  shows the latest assistant answer's context-fill percentage.
153
- 4. The desktop badge flips to 已连接 in real time; it falls back to
160
+ 4. On a secure origin, install the `/m/` page from the browser. The installed app retains the same mobile remote capabilities; when its browser context has no paired-device cookie, paste a fresh desktop-issued pairing link into its pairing screen.
161
+ 5. The desktop badge flips to 已连接 in real time; it falls back to
154
162
  offline/断开 when the phone leaves.
155
- 5. 刷新二维码 invalidates the old link and issues a new one. 停止 revokes
163
+ 6. 刷新二维码 invalidates the old link and issues a new one. 停止 revokes
156
164
  mobile access: paired devices 403 on their next request, including their
157
165
  live stream.
158
166
 
@@ -169,7 +177,7 @@ to the advisory `session.models` / `session.selectModel` pair, creation to
169
177
  directory of its own), and the permission picker only ever sends the
170
178
  mode-agnostic `/permission` command
171
179
  through the already-allowlisted `session.prompt`); the live stream arrives
172
- over Server-Sent Events on `/m/api/events.mux`.
180
+ over Server-Sent Events on `/m/api/events.mux`. The canonical `/m/` page owns a same-scope manifest and Service Worker; its cache is limited to the static shell and an offline page, never mobile API responses, session data, or commands.
173
181
 
174
182
  ### Behavior notes
175
183
 
@@ -180,11 +188,16 @@ over Server-Sent Events on `/m/api/events.mux`.
180
188
  through its own `/m/api` preferences method when a chat opens. On
181
189
  browsers that support `field-sizing: content`, the input grows with the
182
190
  draft up to its 120px cap in either mode.
191
+ - The `/m/` worker uses network-first static-shell fallback and waits for current pages to close before an updated worker activates. It bypasses `/m/api`, `/api`, SSE, and every write request.
183
192
  - Installing this plugin gates non-loopback `/api` access behind pairing
184
193
  (see `requirePairingForLan` in `src/index.ts`). A desktop browser opened
185
194
  via the LAN URL must pair like any remote device; loopback (127.0.0.1)
186
195
  is unaffected. Set `requirePairingForLan: false` in the profile patch to
187
196
  restore the open-LAN behavior while keeping tokens/status/revocation.
197
+ - Sibling host routes outside `/api` (the right-panel `/aionui-panel/*`
198
+ family) can consult this plugin's `remoteWebUiPairing` service: a live
199
+ paired-device cookie is an allow path, `stop()` still cuts them off, and
200
+ the service is absent when this plugin is not installed.
188
201
  - The QR link is built from the machine's non-internal IPv4 literals; a
189
202
  multi-homed host (Wi-Fi + wired, or a proxy/VPN virtual adapter) shows a
190
203
  radio picker so you can advertise the network the phone can actually
@@ -367,15 +380,24 @@ panel still opens at `http://127.0.0.1`.
367
380
 
368
381
  - **Revocation is per-request**: a paired phone whose request is already in
369
382
  flight when 停止 lands completes that request; the next one 403s.
370
- - **Device sessions are in-memory**: pairing state (token + devices) resets
371
- with the `dsh web` process.
383
+ - **Device sessions are in-memory by default**: pairing state (token + devices)
384
+ resets with the `dsh web` process. Setting the `devicesFile` config option
385
+ (an absolute JSON path) persists sessions across restarts — the phone-side
386
+ cookie already lives 365 days, so with persistence a `dsh web` restart no
387
+ longer requires re-scanning the QR. An explicit 停止 (stop) still revokes
388
+ immediately and the revocation is persisted. Device ids are session
389
+ credentials (the gate authorizes requests by the device id in the cookie),
390
+ so the file is written 0600 via a temp file + atomic rename; keep it inside
391
+ a 0700 directory such as `$DSH_HOME`. Changing `cookieName` invalidates
392
+ existing devices (expected).
372
393
  - **No per-device management UI**: the panel shows aggregate status
373
394
  (waiting / connected N / offline); individual device revocation is
374
395
  deferred.
375
396
  - **Quick-tunnel hostnames change per run**: a `trycloudflare.com` URL is
376
397
  random on every `cloudflared` start, so `--trusted-host` and
377
398
  `publicBaseUrl` must be updated together whenever the tunnel restarts.
378
- A named tunnel (fixed hostname) avoids the churn.
399
+ A named tunnel (fixed hostname) avoids the churn and is required for a durable installed-PWA address.
400
+ - **PWA is online-first**: only the static shell and offline page are cached. The running DSH host remains required for all mobile remote capabilities; no session, API response, or command is available offline.
379
401
  - **Dev HMR**: `dsh web --dev` polls every roster bundle by path, so
380
402
  rebuilding this package (its own `tsdown --watch`) hot-reloads the client
381
403
  bundle; no harness-side watcher is involved.
@@ -387,4 +409,4 @@ QR as a dependency-free SVG component — no canvas, no server-side image
387
409
  generation. It is inlined into the client bundle at build time (like the
388
410
  official skin/turtle-ui plugins inline their non-shared deps), so profile
389
411
  installations need no extra runtime dependency beyond the dsh peer closure.
390
- `schemastery` is the DSH-standard config schema validator.
412
+ `schemastery` is the DSH-standard config schema validator.
package/README.zh.md CHANGED
@@ -2,18 +2,18 @@
2
2
 
3
3
  [English](README.md) | 中文
4
4
 
5
- > 移动端远程控制 + 一键远程更新:扫码配对后用手机远程使用当前 dsh web 工作区;点击侧边栏更新按钮自动检查并更新 dsh-web-ui 全家桶。
5
+ > 移动端远程控制 + 一键远程更新:扫码配对后用手机远程使用当前 dsh web 工作区;侧边栏加载后静默检查 dsh-web-ui 全家桶新版本,发现时标记更新按钮;点击按钮后自动更新全家桶。
6
6
 
7
- 本仓库是 DeepSeek Harness(DSH)的外部插件包:为 dsh web GUI 提供扫码配对式移动端远程控制,外加 dsh-web-ui 全家桶的一键自更新。它是单一双半区包——host 半区持有配对令牌、设备会话、`/api/pair` 路由族与 `/api/update` 面板;浏览器半区渲染侧边栏底部入口(下载触发按钮与设置按钮旁的手机图标)、带二维码的配对面板、实时设备状态,以及停止/刷新/复制操作,还渲染探测并执行更新的更新面板。
7
+ 本仓库是 DeepSeek Harness(DSH)的外部插件包:为 dsh web GUI 提供扫码配对式移动端远程控制,外加 dsh-web-ui 全家桶的一键自更新。它是单一双半区包——host 半区持有配对令牌、设备会话、`/api/pair` 路由族与 `/api/update` 面板;浏览器半区渲染侧边栏底部入口(下载触发按钮与设置按钮旁的手机图标入口)、带二维码的配对面板、实时设备状态,以及停止/刷新/复制操作,还渲染探测并执行更新的更新面板。
8
8
 
9
9
  ## 功能
10
10
 
11
- - **入口**:侧边栏底部靠设置按钮旁的手机图标。
11
+ - **入口**:设置按钮旁始终显示手机图标;悬停时显示“移动端远程控制”提示。
12
12
  - **面板**:「移动端远程控制」标题、「扫码或在手机上打开链接,即可远程控制当前工作区」副标题、「手机扫码连接」卡片(含状态区「等待手机连接」+ 状态徽标)、大号二维码、「无法扫码?可以在手机上打开链接」提示,以及三个按钮:停止 / 刷新二维码 / 复制链接。
13
- - **手机侧**:扫码将手机与一次性、限时令牌绑定,并落地到 **`/m` 独立移动端界面**——一款专为小屏设计的轻客户端(见[截图](#截图)),而不是把桌面 UI 塞进手机。链接携带 `workspace` 参数,手机落地到桌面正在查看的同一工作区。
13
+ - **手机侧**:扫码将手机与一次性、限时令牌绑定,并落地到 **`/m/` 独立移动端界面**——一款专为小屏设计的轻客户端(见[截图](#截图)),而不是把桌面 UI 塞进手机。该页面可安装为 PWA;每个已安装应用使用自己的已配对设备 cookie,存储隔离的移动端 Web App 需要在该上下文打开新的二维码链接,或粘贴桌面端新复制的链接完成配对。链接携带 `workspace` 参数,手机落地到桌面正在查看的同一工作区。
14
14
  - **安全**:一个有效的一次性令牌(刷新会使旧链接失效;已接受的令牌不可复用;令牌会过期)。停止会撤销每一台已配对设备与当前令牌——已配对设备在下一次请求时被切断。当插件 `requirePairingForLan` 门开启(默认)时,每个非 loopback 的 `/api` 请求必须携带有效的已配对设备 cookie,因此二维码是进入暴露在局域网上的 dsh web 的唯一途径。
15
15
  - **实时状态**:桌面面板经 SSE 流实时镜像配对状态(等待 → 已连接 → 已断开)。
16
- - **远程更新**:侧边栏底部的下载触发按钮(手机图标左侧)打开更新面板,它探测 npm registry 上已安装的 `@linxin666/dsh-*` 全家桶版本。当存在较新版本时,面板自动执行更新(在所属 dsh profile 内 `pnpm update --latest`;pnpm 缺失时依次回退 `corepack pnpm`、`npx --yes pnpm`,Windows 上经 `cmd.exe` 执行以解析 npm 安装的 `.cmd` shim;由仅 loopback 的 `/api/update/status` + `/api/update/run` 端点驱动)并请求重启 dsh web 以生效。pnpm 绿色退出后还会对照 registry 复核已装版本:绿色退出但版本纹丝不动(例如 pnpm 的 `minimumReleaseAge` 门禁静默跳过同日发布的新版本)会报告为「未更新成功」并附配置指引,而不是误报成功。本地 link 安装(开发模式)会被探测到,只报告 npm 状态而不更新。
16
+ - **远程更新**:侧边栏加载后,底部的下载触发按钮(手机图标左侧)会静默探测 npm registry 上已安装的 `@linxin666/dsh-*` 全家桶版本;发现可自动更新的新版本时,按钮显示圆点并提供“发现新版本,检查更新”提示。点击按钮打开更新面板;未安装聚合包时,检查和更新覆盖 profile 中 registry 管理的全部 `@linxin666/*` 直接依赖,本地 link / file 开发依赖会被跳过。当存在较新版本时,面板自动执行更新(在所属 dsh profile 内 `pnpm update --latest`;pnpm 缺失时依次回退 `corepack pnpm`、`npx --yes pnpm`,Windows 上经 `cmd.exe` 执行以解析 npm 安装的 `.cmd` shim;由仅 loopback 的 `/api/update/status` + `/api/update/run` 端点驱动)并请求重启 dsh web 以生效。pnpm 绿色退出后还会对照 registry 复核已装版本:绿色退出但版本纹丝不动(例如 pnpm 的 `minimumReleaseAge` 门禁静默跳过同日发布的新版本)会报告为「未更新成功」并附配置指引,而不是误报成功。锚点自身是本地 link 安装(开发模式)时,只报告 npm 状态而不更新。
17
17
 
18
18
  ## 截图
19
19
 
@@ -29,6 +29,7 @@
29
29
  - 其 `dsh` CLI 支持 profile(`dsh --profile`、`dsh plugin`)的 DSH 安装——本包所依托的 profile/bundle 机制。
30
30
  - 局域网使用必须手机可到达服务器:用 `dsh web --host 0.0.0.0` 启动。默认 `127.0.0.1` 绑定时,面板会显示明确说明而不是死二维码——除非配置了公网 base URL(见下文「通过互联网远程访问」),那会让二维码在无需重新绑定即可从任意位置访问。面板的 mint/stop 端点设计上仅限 loopback:在局域网 URL 打开的桌面浏览器只会看到「配对面板仅限本机使用」横幅——请在 `http://127.0.0.1` 打开面板,让手机使用配对链接。
31
31
  - 一键公网隧道(`autoTunnel`)需要 `cloudflared` 平台二进制随包分发(其 postinstall 会下载它;运行时下载覆盖跳过 postinstall 脚本的安装器)。无需用户侧工具、账号或域名——Cloudflare quick tunnel 免费且匿名。
32
+ - 把 `/m/` 安装为 PWA 需要安全上下文:`localhost` 和 `127.0.0.1` 可用于本机,手机安装需要 HTTPS。普通局域网 HTTP 仍可在线使用移动端远程控制,但无法注册其 Service Worker。
32
33
 
33
34
  ## 安装
34
35
 
@@ -54,7 +55,7 @@ dsh plugin --profile web add link:$(pwd)/packages/dsh-remote-web-ui
54
55
 
55
56
  1. `dsh web --host 0.0.0.0`(打印的局域网 URL 确认可达性)。
56
57
  2. 点击手机图标 → 面板铸一枚新的二维码。
57
- 3. 用手机扫码(或打开复制的链接):手机绑定并落到 **`/m` 独立移动端界面**——不在小屏显示桌面 UI。该界面刻意精简:
58
+ 3. 用手机扫码(或打开复制的链接):手机绑定并落到 **`/m/` 独立移动端界面**——不在小屏显示桌面 UI。该界面刻意精简:
58
59
  - 直接进入工作区(每个工作区的会话列表上有 新建会话 按钮:它经 host 的 `session.create` 创建附加到该工作区的空白会话,并立即打开新聊天),
59
60
  - 一个工作区的会话**增量**加载(每页 20 行,"加载更多会话"继续;绝不同时加载整份列表),
60
61
  - 打开会话**按需**抓取聊天内容(历史分页,"加载更早的消息"继续往回翻),
@@ -62,15 +63,18 @@ dsh plugin --profile web add link:$(pwd)/packages/dsh-remote-web-ui
62
63
  - **亮色优先主题**:界面默认亮色调色板;每个页头内的日/月切换翻到暗色调色板,选择跨访问持久(localStorage),
63
64
  - 消息按桌面折叠纪律渲染:推理隐藏在被折叠的 深度思考 揭示下面,工具调用隐藏在被折叠的 工具 行下面(点击查看每个调用的参数),超长回答藏在显式 展开全文 切换下面,每行带时间,并且 assistant 回复按 GFM Markdown 渲染(标题 / 加粗 / 斜体 / 行内码 / 代码块 / 列表 / 表格 / 引用 / 链接 / 图片;零依赖自写渲染器,先转义再白名单协议,移动端 bundle 体积几乎不变;KaTeX 公式暂不支持,后续单独评估),用户消息保持纯文本,
64
65
  - 输入栏工具条带 **模型** 选择器(provider 分组目录 + 每模型 思考强度 effort 区)与 **权限** 选择器(权限预设;完全权限 需要显式确认步骤)。两者都走 host 自己的 `session.models` / `session.selectModel` RPC 与 `/permission` 命令——手机改的与桌面改的是同一个会话设置——外加 **显示** 弹层(含 工具调用 与 系统提示词 两个持久开关)和一个 上下文 用量 chip(显示最近一次助手回答的上下文占用百分比)。
65
- 4. 桌面徽标实时翻到 已连接;手机离开时回落到离线/断开。
66
- 5. 刷新二维码 使旧链接失效并铸一枚新的。停止 撤销移动端访问:已配对设备下一次请求 403,包括其实时流。
66
+ 4. 在安全 origin 上从浏览器安装 `/m/` 页面。已安装应用保留相同的移动端远程控制能力;其浏览器上下文没有已配对设备 cookie 时,把桌面端新复制的配对链接粘贴进配对页面。
67
+ 5. 桌面徽标实时翻到 已连接;手机离开时回落到离线/断开。
68
+ 6. 刷新二维码 使旧链接失效并铸一枚新的。停止 撤销移动端访问:已配对设备下一次请求 403,包括其实时流。
67
69
 
68
- 该移动端界面完全自包含在本插件内:`/m` 页面及其数据通道(`/m/api`)由插件自己的路由伺服,**无需任何 harness 源码改动**——手机的 RPC 调用走插件的 `/m/api` 代理(它委托给 host 的 ApiProxy 服务并自己分页 `session.list`),因此被隧道化的 Host 永远不必进入连接插件的信任围栏。手机受其已配对设备 cookie 与显式方法白名单门控(settings/credentials/host-action 域手机永远不可达;模型读写限制于建议性的 `session.models` / `session.selectModel` 对,创建限制于 `session.create`(仅工作区 id——手机绝不自命名工作目录),权限选择器只通过已放行的 `session.prompt` 发送模式无关的 `/permission` 命令);实时流在 `/m/api/events.mux` 上经 Server-Sent Events 送达。
70
+ 该移动端界面完全自包含在本插件内:`/m/` 页面及其数据通道(`/m/api`)由插件自己的路由伺服,**无需任何 harness 源码改动**——手机的 RPC 调用走插件的 `/m/api` 代理(它委托给 host 的 ApiProxy 服务并自己分页 `session.list`),因此被隧道化的 Host 永远不必进入连接插件的信任围栏。手机受其已配对设备 cookie 与显式方法白名单门控(settings/credentials/host-action 域手机永远不可达;模型读写限制于建议性的 `session.models` / `session.selectModel` 对,创建限制于 `session.create`(仅工作区 id——手机绝不自命名工作目录),权限选择器只通过已放行的 `session.prompt` 发送模式无关的 `/permission` 命令);实时流在 `/m/api/events.mux` 上经 Server-Sent Events 送达。规范的 `/m/` 页面拥有同 scope 的 manifest 与 Service Worker;其缓存仅包含静态壳和离线页,绝不包含移动端 API 响应、会话数据或命令。
69
71
 
70
72
  ### 行为说明
71
73
 
72
74
  - 移动端输入框默认 Enter 发送(Shift+Enter 换行)。在插件设置卡片(或 profile patch)把 `mobileEnterToSend` 设为 false 后,普通 Enter 改为插入换行,只有「发送」按钮会发送;手机打开聊天时经自己的 `/m/api` 偏好方法读取该开关。在支持 `field-sizing: content` 的浏览器上,输入框随草稿自动增高,最高 120px 封顶(两种模式一致)。
75
+ - `/m/` Worker 对静态壳使用 network-first 回退,并等待当前页面关闭后才激活更新。它旁路 `/m/api`、`/api`、SSE 与所有写请求。
73
76
  - 安装本插件会门控非 loopback 的 `/api` 访问于配对之后(见 `src/index.ts` 的 `requirePairingForLan`)。经局域网 URL 打开的桌面浏览器必须像任何远程设备一样配对;loopback(127.0.0.1)不受影响。把 profile patch 里 `requirePairingForLan` 设为 false 可恢复开放局域网行为,同时保留令牌/状态/撤销。
77
+ - `/api` 之外的兄弟 host 路由(右侧面板的 `/aionui-panel/*`)可查询本插件的 `remoteWebUiPairing` 服务:有效的已配对设备 cookie 是放行路径,`stop()` 仍会切断它们;未安装本插件时该服务不存在。
74
78
  - 二维码链接基于机器的非内部 IPv4 字面量构建;多宿主主机(Wi-Fi + 有线,或代理/VPN 虚拟适配器)会显示单选器供你发布手机实际可达的网络。第一个字面量是默认值。设 `publicBaseUrl` 后,单选器在顶部额外加一项 公网地址——默认二维码改用公网 base,选中局域网字面量会重新铸一枚网内链接。
75
79
  - 配置的 `publicBaseUrl` 本身满足可达绑定需求:`dsh web` 绑定 `127.0.0.1`(不带 `--host 0.0.0.0`)仍能经隧道铸出可用的公网二维码链接。
76
80
 
@@ -161,11 +165,12 @@ pnpm run build
161
165
  ## 已知限制与待办
162
166
 
163
167
  - **撤销是逐请求的**:已配对手机请求已在 停止 落地时在途,完成该请求;下一个 403。
164
- - **设备会话在内存中**:配对状态(token + devices)随 `dsh web` 进程重置。
168
+ - **设备会话默认在内存中**:配对状态(token + devices)随 `dsh web` 进程重置;设置 `devicesFile` 配置项(绝对路径 JSON)后可跨重启持久化——手机端 cookie 本有 365 天有效期,持久化后重启 `dsh web` 无需重新扫码(点「停止」主动撤销仍会立即生效并同步落盘)。设备 id 即会话凭证(网关凭 cookie 中的设备 id 放行请求),文件以 0600 权限经临时文件原子写入,建议把文件放置在 0700 目录(如 `$DSH_HOME`)内;变更 `cookieName` 会使旧设备失效(预期行为)。
165
169
  - **无逐设备管理 UI**:面板显示聚合状态(waiting / connected N / offline);单设备撤销延后。
166
- - **Quick-tunnel hostname 每次运行变化**:`trycloudflare.com` URL 每次 `cloudflared` 启动随机,所以隧道重启时 `--trusted-host` 与 `publicBaseUrl` 必须一起更新。named tunnel(固定 hostname)避免这种抖动。
170
+ - **Quick-tunnel hostname 每次运行变化**:`trycloudflare.com` URL 每次 `cloudflared` 启动随机,所以隧道重启时 `--trusted-host` 与 `publicBaseUrl` 必须一起更新。named tunnel(固定 hostname)避免这种抖动,也是持久安装 PWA 地址所必需的。
171
+ - **PWA 为在线优先**:只缓存静态壳和离线页。全部移动端远程控制能力仍要求运行中的 DSH host;离线时不提供会话、API 响应或命令。
167
172
  - **开发 HMR**:`dsh web --dev` 按路径轮询每个 roster bundle,因此重建本包(其自己的 `tsdown --watch`)会热重载 client bundle;无 harness 侧 watcher。
168
173
 
169
174
  ## 依赖理由
170
175
 
171
- `qrcode.react`(MIT,活跃维护,React 16–19 支持)将二维码渲染为无依赖的 SVG 组件——无 canvas、无服务端图片生成。它在构建时内联进 client bundle(与官方 skin/turtle-ui 插件内联其非共享依赖相同),profile 安装无需超出 dsh peer closure 之外的额外运行时依赖。`schemastery` 是 DSH 标准配置 schema 校验器。
176
+ `qrcode.react`(MIT,活跃维护,React 16–19 支持)将二维码渲染为无依赖的 SVG 组件——无 canvas、无服务端图片生成。它在构建时内联进 client bundle(与官方 skin/turtle-ui 插件内联其非共享依赖相同),profile 安装无需超出 dsh peer closure 之外的额外运行时依赖。`schemastery` 是 DSH 标准配置 schema 校验器。
Binary file
Binary file
@@ -0,0 +1,64 @@
1
+ /* PWA worker for the standalone /m mobile shell. */
2
+ const CACHE_NAME = 'dsh-remote-mobile-shell-v1'
3
+ const OFFLINE_URL = '/m/offline.html'
4
+ const SHELL_PATHS = new Set([
5
+ '/m/',
6
+ '/m/mobile.js',
7
+ '/m/manifest.webmanifest',
8
+ '/m/apple-touch-icon.png',
9
+ '/m/icon-192.png',
10
+ '/m/icon-512.png',
11
+ OFFLINE_URL,
12
+ ])
13
+
14
+ self.addEventListener('install', event => {
15
+ event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.add(OFFLINE_URL)))
16
+ })
17
+
18
+ self.addEventListener('activate', event => {
19
+ event.waitUntil(caches.keys().then(keys => Promise.all(
20
+ keys
21
+ .filter(key => key.startsWith('dsh-remote-mobile-shell-') && key !== CACHE_NAME)
22
+ .map(key => caches.delete(key)),
23
+ )))
24
+ })
25
+
26
+ self.addEventListener('fetch', event => {
27
+ const request = event.request
28
+ if (request.method !== 'GET') return
29
+
30
+ const url = new URL(request.url)
31
+ if (url.origin !== self.location.origin) return
32
+ if (url.pathname === '/api' || url.pathname.startsWith('/api/') || url.pathname === '/m/api' || url.pathname.startsWith('/m/api/')) return
33
+
34
+ const isMobileNavigation = request.mode === 'navigate' && (url.pathname === '/m/' || url.pathname === '/m')
35
+ if (isMobileNavigation) {
36
+ event.respondWith(networkFirst(request, OFFLINE_URL, false))
37
+ return
38
+ }
39
+
40
+ if (SHELL_PATHS.has(url.pathname)) event.respondWith(networkFirst(request, url.pathname))
41
+ })
42
+
43
+ async function networkFirst(request, fallbackPath, allowCachedResponse = true) {
44
+ try {
45
+ const response = await fetch(request)
46
+ if (response.status >= 500) throw new Error('mobile shell unavailable')
47
+ if (response.ok && new URL(request.url).search === '') {
48
+ const cache = await caches.open(CACHE_NAME)
49
+ await cache.put(request, response.clone())
50
+ }
51
+ return response
52
+ } catch {
53
+ const cache = await caches.open(CACHE_NAME)
54
+ if (allowCachedResponse) {
55
+ const cached = await cache.match(request)
56
+ if (cached !== undefined) return cached
57
+ }
58
+
59
+ const fallback = await cache.match(fallbackPath)
60
+ if (fallback !== undefined) return fallback
61
+
62
+ return new Response('', { status: 503, statusText: 'Service Unavailable' })
63
+ }
64
+ }
package/lib/client.js CHANGED
@@ -1343,7 +1343,7 @@ window.__ModuleLoader__.load({
1343
1343
  }
1344
1344
  //#endregion
1345
1345
  //#region \0dsh-css:packages/dsh-remote-web-ui/src/client/remote.module.css.mjs
1346
- const css$1 = ".fThDlq_overlay{z-index:1000;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.fThDlq_mask{background:var(--dsw-alias-bg-mask-1);backdrop-filter:var(--dsw-mask-blur);position:absolute;inset:0}.fThDlq_trigger{width:36px;height:36px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:50%;flex:none;justify-content:center;align-items:center;padding:0;transition:background-color .12s,color .12s,box-shadow .12s;display:inline-flex}.fThDlq_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.fThDlq_trigger:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}.fThDlq_trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);outline:none}.fThDlq_trigger:disabled{opacity:.5;cursor:default}.fThDlq_panel{z-index:1;box-sizing:border-box;background:var(--dsw-alias-bg-layer-2);width:560px;max-width:calc(100vw - 48px);max-height:calc(100vh - 48px);box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);border-radius:24px;flex-direction:column;gap:14px;padding:24px;font-size:14px;line-height:22px;display:flex;position:relative;overflow:auto}.fThDlq_header{align-items:flex-start;gap:12px;display:flex}.fThDlq_heading{flex:1;min-width:0}.fThDlq_title{margin:0;font-size:18px;font-weight:600;line-height:26px}.fThDlq_subtitle{color:var(--dsw-alias-label-secondary);margin:4px 0 0;font-size:13px}.fThDlq_close{width:28px;height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:50%;flex:none;justify-content:center;align-items:center;padding:0;transition:background-color .12s,color .12s,box-shadow .12s;display:inline-flex}.fThDlq_close:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_close:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}.fThDlq_close:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);outline:none}.fThDlq_close:disabled{opacity:.5;cursor:default}.fThDlq_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:16px;flex-direction:column;align-items:center;gap:12px;padding:16px;display:flex}.fThDlq_cardHeader{justify-content:space-between;align-items:center;gap:12px;width:100%;display:flex}.fThDlq_cardTitle{font-weight:500}.fThDlq_badge{white-space:nowrap;border-radius:999px;flex:none;align-items:center;gap:6px;min-width:0;padding:2px 10px;font-size:12px;line-height:18px;display:inline-flex}.fThDlq_badge:before{content:\"\";background:currentColor;border-radius:50%;width:8px;height:8px}.fThDlq_badge-waiting{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badge-connected{color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badge-disconnected{color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badge-stopped{color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badgePublic{color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badges{flex:none;align-items:center;gap:6px;display:inline-flex}.fThDlq_qrWrap{background:var(--dsw-alias-bg-base);border-radius:12px;justify-content:center;align-items:center;padding:12px;display:flex}.fThDlq_qr{display:block}.fThDlq_expired{color:var(--dsw-alias-state-error-primary);margin:0;font-size:13px}.fThDlq_expiry{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px}.fThDlq_hint{color:var(--dsw-alias-label-secondary);margin:0;font-size:13px}.fThDlq_link{text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-caption);font-family:var(--dsw-font-mono,ui-monospace, monospace);margin:0;font-size:12px;overflow:hidden}.fThDlq_stoppedHint{color:var(--dsw-alias-state-error-primary);margin:0;font-size:13px}.fThDlq_tunnelNote{color:var(--dsw-alias-label-secondary);margin:0;font-size:13px}.fThDlq_tunnelFailed{color:var(--dsw-alias-state-error-primary);margin:0;font-size:13px}.fThDlq_actions{gap:8px;display:flex}.fThDlq_action{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-button-elevated-fill);height:34px;color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;border-radius:10px;justify-content:center;align-items:center;gap:6px;padding:0 14px;font-size:13px;transition:background-color .12s,border-color .12s,box-shadow .12s;display:inline-flex}.fThDlq_action:hover:not(:disabled){background:var(--dsw-alias-button-floating-hover)}.fThDlq_action:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}.fThDlq_action:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);outline:none}.fThDlq_action:disabled{opacity:.5;cursor:default}.fThDlq_banner{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:16px;padding:16px}.fThDlq_bannerTitle{color:var(--dsw-alias-state-warn-primary);margin:0;font-weight:500}.fThDlq_bannerHint{color:var(--dsw-alias-label-secondary);margin:6px 0 0;font-size:13px}.fThDlq_notice{z-index:2000;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);max-width:min(420px,100vw - 32px);box-shadow:var(--dsw-shadow-lv3);border-radius:12px;padding:12px 16px;position:fixed;top:16px;left:50%;transform:translate(-50%)}.fThDlq_noticeTitle{color:var(--dsw-alias-state-error-primary);margin:0;font-weight:500}.fThDlq_noticeDetail{color:var(--dsw-alias-label-secondary);margin:4px 0 0;font-size:13px}.fThDlq_addresses{border:none;margin:12px 0 0;padding:0}.fThDlq_addresses legend{color:var(--dsw-alias-label-secondary);padding:0;font-size:13px}.fThDlq_address{color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums;cursor:pointer;border-radius:6px;align-items:center;gap:8px;margin-top:6px;padding:4px 6px;font-size:13px;transition:background-color .12s;display:flex}.fThDlq_address:hover{background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_address input:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);border-radius:50%;outline:none}.fThDlq_addressValue{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-secondary);flex:1;font-size:12px;overflow:hidden}.fThDlq_addressHint{color:var(--dsw-alias-label-tertiary);margin:6px 0 0;font-size:12px}@media (prefers-reduced-motion:reduce){.fThDlq_trigger,.fThDlq_close,.fThDlq_action,.fThDlq_address{transition:none}}.fThDlq_entryRow{flex:none;align-items:center;gap:6px;display:flex}.fThDlq_entryRow[data-rail=rail]{flex-direction:column-reverse;gap:4px}.fThDlq_updateStatus{margin:0;font-weight:600}.fThDlq_updateDetail{color:var(--dsw-alias-label-secondary);margin:6px 0 0;font-size:13px}.fThDlq_updateError{color:var(--dsw-alias-text-danger,var(--dsw-alias-label-primary));margin:0;font-weight:600}.fThDlq_updateOutput{background:var(--dsw-alias-bg-layer-1);max-height:180px;color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-all;border-radius:10px;margin:10px 0 0;padding:10px 12px;font-size:12px;line-height:18px;overflow:auto}.fThDlq_updateList{flex-direction:column;gap:6px;margin:12px 0 0;padding:0;list-style:none;display:flex}.fThDlq_updateListItem{justify-content:space-between;align-items:baseline;gap:12px;font-size:13px;display:flex}.fThDlq_updateListName{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-primary);overflow:hidden}.fThDlq_updateListVersions{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;flex:none}.fThDlq_updateActions{justify-content:flex-end;margin-top:16px;display:flex}.fThDlq_updateRetry{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);cursor:pointer;border:none;border-radius:10px;align-items:center;gap:6px;padding:8px 14px;font-size:13px;transition:background-color .12s,color .12s;display:inline-flex}.fThDlq_updateRetry:hover{background:var(--dsw-alias-interactive-bg-active)}@media (prefers-reduced-motion:reduce){.fThDlq_updateRetry{transition:none}}";
1346
+ const css$1 = ".fThDlq_overlay{z-index:1000;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.fThDlq_mask{background:var(--dsw-alias-bg-mask-1);backdrop-filter:var(--dsw-mask-blur);position:absolute;inset:0}.fThDlq_trigger{width:36px;height:36px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:50%;flex:none;justify-content:center;align-items:center;padding:0;transition:background-color .12s,color .12s,box-shadow .12s;display:inline-flex;position:relative}.fThDlq_trigger[data-update-available]:after{box-sizing:border-box;border:2px solid var(--dsw-alias-bg-layer-1);background:var(--dsw-alias-brand-primary);content:\"\";pointer-events:none;border-radius:50%;width:8px;height:8px;position:absolute;top:4px;right:4px}.fThDlq_trigger[data-wide=wide]{border-radius:8px;flex:auto;justify-content:flex-start;gap:8px;width:auto;min-width:0;padding:0 10px}.fThDlq_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.fThDlq_trigger:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}.fThDlq_trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);outline:none}.fThDlq_trigger:disabled{opacity:.5;cursor:default}.fThDlq_panel{z-index:1;box-sizing:border-box;background:var(--dsw-alias-bg-layer-2);width:560px;max-width:calc(100vw - 48px);max-height:calc(100vh - 48px);box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);border-radius:24px;flex-direction:column;gap:14px;padding:24px;font-size:14px;line-height:22px;display:flex;position:relative;overflow:auto}.fThDlq_header{align-items:flex-start;gap:12px;display:flex}.fThDlq_heading{flex:1;min-width:0}.fThDlq_title{margin:0;font-size:18px;font-weight:600;line-height:26px}.fThDlq_subtitle{color:var(--dsw-alias-label-secondary);margin:4px 0 0;font-size:13px}.fThDlq_close{width:28px;height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:50%;flex:none;justify-content:center;align-items:center;padding:0;transition:background-color .12s,color .12s,box-shadow .12s;display:inline-flex}.fThDlq_close:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_close:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}.fThDlq_close:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);outline:none}.fThDlq_close:disabled{opacity:.5;cursor:default}.fThDlq_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:16px;flex-direction:column;align-items:center;gap:12px;padding:16px;display:flex}.fThDlq_cardHeader{justify-content:space-between;align-items:center;gap:12px;width:100%;display:flex}.fThDlq_cardTitle{font-weight:500}.fThDlq_badge{white-space:nowrap;border-radius:999px;flex:none;align-items:center;gap:6px;min-width:0;padding:2px 10px;font-size:12px;line-height:18px;display:inline-flex}.fThDlq_badge:before{content:\"\";background:currentColor;border-radius:50%;width:8px;height:8px}.fThDlq_badge-waiting{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badge-connected{color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badge-disconnected{color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badge-stopped{color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badgePublic{color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badges{flex:none;align-items:center;gap:6px;display:inline-flex}.fThDlq_qrWrap{background:var(--dsw-alias-bg-base);border-radius:12px;justify-content:center;align-items:center;padding:12px;display:flex}.fThDlq_qr{display:block}.fThDlq_expired{color:var(--dsw-alias-state-error-primary);margin:0;font-size:13px}.fThDlq_expiry{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px}.fThDlq_hint{color:var(--dsw-alias-label-secondary);margin:0;font-size:13px}.fThDlq_link{text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-caption);font-family:var(--dsw-font-mono,ui-monospace, monospace);margin:0;font-size:12px;overflow:hidden}.fThDlq_stoppedHint{color:var(--dsw-alias-state-error-primary);margin:0;font-size:13px}.fThDlq_tunnelNote{color:var(--dsw-alias-label-secondary);margin:0;font-size:13px}.fThDlq_tunnelFailed{color:var(--dsw-alias-state-error-primary);margin:0;font-size:13px}.fThDlq_actions{gap:8px;display:flex}.fThDlq_action{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-button-elevated-fill);height:34px;color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;border-radius:10px;justify-content:center;align-items:center;gap:6px;padding:0 14px;font-size:13px;transition:background-color .12s,border-color .12s,box-shadow .12s;display:inline-flex}.fThDlq_action:hover:not(:disabled){background:var(--dsw-alias-button-floating-hover)}.fThDlq_action:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}.fThDlq_action:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);outline:none}.fThDlq_action:disabled{opacity:.5;cursor:default}.fThDlq_banner{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:16px;padding:16px}.fThDlq_bannerTitle{color:var(--dsw-alias-state-warn-primary);margin:0;font-weight:500}.fThDlq_bannerHint{color:var(--dsw-alias-label-secondary);margin:6px 0 0;font-size:13px}.fThDlq_notice{z-index:2000;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);max-width:min(420px,100vw - 32px);box-shadow:var(--dsw-shadow-lv3);border-radius:12px;padding:12px 16px;position:fixed;top:16px;left:50%;transform:translate(-50%)}.fThDlq_noticeTitle{color:var(--dsw-alias-state-error-primary);margin:0;font-weight:500}.fThDlq_noticeDetail{color:var(--dsw-alias-label-secondary);margin:4px 0 0;font-size:13px}.fThDlq_addresses{border:none;margin:12px 0 0;padding:0}.fThDlq_addresses legend{color:var(--dsw-alias-label-secondary);padding:0;font-size:13px}.fThDlq_address{color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums;cursor:pointer;border-radius:6px;align-items:center;gap:8px;margin-top:6px;padding:4px 6px;font-size:13px;transition:background-color .12s;display:flex}.fThDlq_address:hover{background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_address input:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);border-radius:50%;outline:none}.fThDlq_addressValue{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-secondary);flex:1;font-size:12px;overflow:hidden}.fThDlq_addressHint{color:var(--dsw-alias-label-tertiary);margin:6px 0 0;font-size:12px}@media (prefers-reduced-motion:reduce){.fThDlq_trigger,.fThDlq_close,.fThDlq_action,.fThDlq_address{transition:none}}.fThDlq_entryRow{flex:none;align-items:center;gap:6px;min-width:0;display:flex}.fThDlq_entryRow[data-rail=rail]{flex-direction:column-reverse;gap:4px}.fThDlq_updateStatus{margin:0;font-weight:600}.fThDlq_updateDetail{color:var(--dsw-alias-label-secondary);margin:6px 0 0;font-size:13px}.fThDlq_updateError{color:var(--dsw-alias-text-danger,var(--dsw-alias-label-primary));margin:0;font-weight:600}.fThDlq_updateOutput{background:var(--dsw-alias-bg-layer-1);max-height:180px;color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-all;border-radius:10px;margin:10px 0 0;padding:10px 12px;font-size:12px;line-height:18px;overflow:auto}.fThDlq_updateList{flex-direction:column;gap:6px;margin:12px 0 0;padding:0;list-style:none;display:flex}.fThDlq_updateListItem{justify-content:space-between;align-items:baseline;gap:12px;font-size:13px;display:flex}.fThDlq_updateListName{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-primary);overflow:hidden}.fThDlq_updateListVersions{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;flex:none}.fThDlq_updateActions{justify-content:flex-end;margin-top:16px;display:flex}.fThDlq_updateRetry{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);cursor:pointer;border:none;border-radius:10px;align-items:center;gap:6px;padding:8px 14px;font-size:13px;transition:background-color .12s,color .12s;display:inline-flex}.fThDlq_updateRetry:hover{background:var(--dsw-alias-interactive-bg-active)}@media (prefers-reduced-motion:reduce){.fThDlq_updateRetry{transition:none}}";
1347
1347
  const tagId$1 = "@linxin666/dsh-remote-web-ui/remote.module.css";
1348
1348
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$1) + "]") === null) {
1349
1349
  const tag = document.createElement("style");
@@ -1899,13 +1899,27 @@ window.__ModuleLoader__.load({
1899
1899
  function UpdateEntry({ wide, t }) {
1900
1900
  const [open, setOpen] = (0, react.useState)(false);
1901
1901
  const [view, setView] = (0, react.useState)({ kind: "checking" });
1902
+ const [updateAvailable, setUpdateAvailable] = (0, react.useState)(false);
1902
1903
  const runToken = (0, react.useRef)(0);
1904
+ const availabilityToken = (0, react.useRef)(0);
1905
+ const mounted = (0, react.useRef)(false);
1906
+ const probeAvailability = (0, react.useCallback)(async () => {
1907
+ const token = ++availabilityToken.current;
1908
+ try {
1909
+ const status = await fetchUpdateStatus();
1910
+ if (token === availabilityToken.current) setUpdateAvailable(status.mode === "npm" && status.outdated);
1911
+ } catch {
1912
+ if (token === availabilityToken.current) setUpdateAvailable(false);
1913
+ }
1914
+ }, []);
1903
1915
  const check = (0, react.useCallback)(async () => {
1916
+ const availabilityCheck = ++availabilityToken.current;
1904
1917
  setView({ kind: "checking" });
1905
1918
  let status;
1906
1919
  try {
1907
1920
  status = await fetchUpdateStatus();
1908
1921
  } catch (error) {
1922
+ if (availabilityCheck === availabilityToken.current) setUpdateAvailable(false);
1909
1923
  if (error instanceof UpdateStatusError && error.status === 404) {
1910
1924
  setView({
1911
1925
  kind: "error",
@@ -1921,6 +1935,7 @@ window.__ModuleLoader__.load({
1921
1935
  });
1922
1936
  return;
1923
1937
  }
1938
+ if (availabilityCheck === availabilityToken.current) setUpdateAvailable(status.mode === "npm" && status.outdated);
1924
1939
  if (status.error === "registry-unreachable") {
1925
1940
  setView({
1926
1941
  kind: "result",
@@ -1940,6 +1955,7 @@ window.__ModuleLoader__.load({
1940
1955
  const token = ++runToken.current;
1941
1956
  try {
1942
1957
  const result = await runUpdate();
1958
+ if (result.ok && mounted.current) setUpdateAvailable(false);
1943
1959
  if (token !== runToken.current) return;
1944
1960
  setView({
1945
1961
  kind: "done",
@@ -1962,15 +1978,23 @@ window.__ModuleLoader__.load({
1962
1978
  runToken.current++;
1963
1979
  setOpen(false);
1964
1980
  }, []);
1965
- (0, react.useEffect)(() => () => {
1966
- runToken.current++;
1967
- }, []);
1981
+ (0, react.useEffect)(() => {
1982
+ mounted.current = true;
1983
+ probeAvailability();
1984
+ return () => {
1985
+ mounted.current = false;
1986
+ runToken.current++;
1987
+ availabilityToken.current++;
1988
+ };
1989
+ }, [probeAvailability]);
1990
+ const updateLabel = updateAvailable ? t("update.availableLabel") : t("update.label");
1968
1991
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1969
1992
  type: "button",
1970
1993
  className: remote_module_css_default.trigger,
1971
1994
  "data-wide": wide ? void 0 : "rail",
1972
- "aria-label": t("update.label"),
1973
- title: t("update.label"),
1995
+ "data-update-available": updateAvailable ? "true" : void 0,
1996
+ "aria-label": updateLabel,
1997
+ title: updateLabel,
1974
1998
  onClick: openPanel,
1975
1999
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconDownloadOutline16, { size: wide ? 16 : 18 })
1976
2000
  }), open && (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -2171,12 +2195,12 @@ window.__ModuleLoader__.load({
2171
2195
  })]
2172
2196
  }), document.body)] });
2173
2197
  }
2174
- /** The trigger: an icon button matching the settings rail/row geometry. */
2198
+ /** The trigger: an icon-only control with a persistent accessible label. */
2175
2199
  function TooltipAnchor({ wide, label, onClick }) {
2176
2200
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2177
2201
  type: "button",
2178
2202
  className: remote_module_css_default.trigger,
2179
- "data-wide": wide ? void 0 : "rail",
2203
+ "data-wide": wide ? "wide" : "rail",
2180
2204
  "aria-label": label,
2181
2205
  title: label,
2182
2206
  onClick,
@@ -2379,7 +2403,7 @@ window.__ModuleLoader__.load({
2379
2403
  children: props.t("settings.readOnly")
2380
2404
  }) : null,
2381
2405
  props.children,
2382
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2406
+ props.hideFooter === true ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2383
2407
  className: settings_card_module_css_default.footer,
2384
2408
  children: [
2385
2409
  state.failed ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
@@ -2868,6 +2892,7 @@ window.__ModuleLoader__.load({
2868
2892
  t,
2869
2893
  titleKey: "settings.title",
2870
2894
  descriptionKey: "settings.description",
2895
+ defaultOpen: false,
2871
2896
  state,
2872
2897
  onSave: props.save,
2873
2898
  onDiscard: props.discard,
@@ -3083,6 +3108,7 @@ window.__ModuleLoader__.load({
3083
3108
  "settings.saveFailed": "部署未接受这些值,已保留供你修改。",
3084
3109
  "settings.invalidNumber": "请输入数字,留空则使用默认值。",
3085
3110
  "update.label": "检查更新",
3111
+ "update.availableLabel": "发现新版本,检查更新",
3086
3112
  "update.title": "检查更新",
3087
3113
  "update.checking": "正在检查远程仓库中的新版本…",
3088
3114
  "update.upToDate": "当前已是最新版本",
@@ -3187,6 +3213,7 @@ window.__ModuleLoader__.load({
3187
3213
  "settings.saveFailed": "The deployment did not accept these values; they were left for you to correct.",
3188
3214
  "settings.invalidNumber": "Enter a number, or leave blank to use the default.",
3189
3215
  "update.label": "Check for updates",
3216
+ "update.availableLabel": "New version available. Check for updates",
3190
3217
  "update.title": "Check for updates",
3191
3218
  "update.checking": "Checking the remote repository for newer releases…",
3192
3219
  "update.upToDate": "Everything is up to date",
@@ -3272,7 +3299,7 @@ window.__ModuleLoader__.load({
3272
3299
  url.searchParams.delete("pair");
3273
3300
  page.replaceState(`${url.pathname}${url.search}${url.hash}`);
3274
3301
  if (ok) if (isMobileSurface()) {
3275
- url.pathname = "/m";
3302
+ url.pathname = "/m/";
3276
3303
  page.navigate(`${url.pathname}${url.search}${url.hash}`);
3277
3304
  } else page.reload();
3278
3305
  }