@linxin666/dsh-remote-web-ui 0.3.5 → 0.3.9

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 (238) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +77 -468
  3. package/README.zh.md +83 -113
  4. package/lib/client.js +955 -203
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1136 -1466
  7. package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
  8. package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
  9. package/lib/types/client/FooterRemoteEntry.js +1 -1
  10. package/lib/types/client/RemoteEntry.d.ts +7 -4
  11. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  12. package/lib/types/client/RemoteEntry.js +7 -10
  13. package/lib/types/client/RemotePanel.d.ts +2 -2
  14. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  15. package/lib/types/client/RemotePanel.js +2 -2
  16. package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
  17. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  18. package/lib/types/client/RemoteSettingsCard.js +56 -3
  19. package/lib/types/client/deep-link.d.ts +10 -13
  20. package/lib/types/client/deep-link.d.ts.map +1 -1
  21. package/lib/types/client/deep-link.js +14 -74
  22. package/lib/types/client/index.d.ts +2 -6
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +73 -39
  25. package/lib/types/client/locales.d.ts +26 -12
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +36 -22
  28. package/lib/types/client/mobile-adapt.d.ts +40 -0
  29. package/lib/types/client/mobile-adapt.d.ts.map +1 -0
  30. package/lib/types/client/mobile-adapt.js +967 -0
  31. package/lib/types/client/pair-api.d.ts +24 -6
  32. package/lib/types/client/pair-api.d.ts.map +1 -1
  33. package/lib/types/client/pair-api.js +9 -14
  34. package/lib/types/client/remote-channel.d.ts +3 -0
  35. package/lib/types/client/remote-channel.d.ts.map +1 -1
  36. package/lib/types/client/remote-channel.js +35 -3
  37. package/lib/types/client/settings-form.d.ts +35 -43
  38. package/lib/types/client/settings-form.d.ts.map +1 -1
  39. package/lib/types/client/settings-form.js +60 -53
  40. package/lib/types/firewall.d.ts +75 -0
  41. package/lib/types/firewall.d.ts.map +1 -0
  42. package/lib/types/firewall.js +184 -0
  43. package/lib/types/gate.d.ts +11 -5
  44. package/lib/types/gate.d.ts.map +1 -1
  45. package/lib/types/gate.js +11 -5
  46. package/lib/types/index.d.ts +39 -10
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +206 -58
  49. package/lib/types/inner-auth.d.ts +32 -0
  50. package/lib/types/inner-auth.d.ts.map +1 -0
  51. package/lib/types/inner-auth.js +72 -0
  52. package/lib/types/lan-bind-plan.d.ts +55 -0
  53. package/lib/types/lan-bind-plan.d.ts.map +1 -0
  54. package/lib/types/lan-bind-plan.js +60 -0
  55. package/lib/types/lan-bind.d.ts +69 -0
  56. package/lib/types/lan-bind.d.ts.map +1 -0
  57. package/lib/types/lan-bind.js +128 -0
  58. package/lib/types/loopback-proxy.d.ts +9 -2
  59. package/lib/types/loopback-proxy.d.ts.map +1 -1
  60. package/lib/types/loopback-proxy.js +24 -2
  61. package/lib/types/pairing.d.ts +15 -3
  62. package/lib/types/pairing.d.ts.map +1 -1
  63. package/lib/types/pairing.js +20 -6
  64. package/lib/types/remote-api.d.ts +30 -13
  65. package/lib/types/remote-api.d.ts.map +1 -1
  66. package/lib/types/remote-api.js +55 -42
  67. package/lib/types/remote-channel-boot.d.ts +21 -2
  68. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  69. package/lib/types/remote-channel-boot.js +65 -3
  70. package/lib/types/remote-channel-rules.d.ts +8 -0
  71. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  72. package/lib/types/remote-channel-rules.js +9 -2
  73. package/lib/types/remote-methods.d.ts +45 -11
  74. package/lib/types/remote-methods.d.ts.map +1 -1
  75. package/lib/types/remote-methods.js +56 -27
  76. package/lib/types/remote-presence-pet.d.ts +51 -0
  77. package/lib/types/remote-presence-pet.d.ts.map +1 -0
  78. package/lib/types/remote-presence-pet.js +101 -0
  79. package/lib/types/routes.d.ts +76 -4
  80. package/lib/types/routes.d.ts.map +1 -1
  81. package/lib/types/routes.js +336 -19
  82. package/lib/types/tunnel.d.ts.map +1 -1
  83. package/lib/types/tunnel.js +7 -1
  84. package/package.json +20 -18
  85. package/src/client/FooterRemoteEntry.tsx +9 -10
  86. package/src/client/RemoteEntry.tsx +13 -14
  87. package/src/client/RemotePanel.tsx +5 -16
  88. package/src/client/RemoteSettingsCard.tsx +72 -13
  89. package/src/client/deep-link.ts +14 -75
  90. package/src/client/index.ts +77 -39
  91. package/src/client/locales.ts +36 -22
  92. package/src/client/mobile-adapt.ts +913 -0
  93. package/src/client/pair-api.ts +26 -15
  94. package/src/client/remote-channel.ts +31 -3
  95. package/src/client/settings-form.ts +78 -92
  96. package/src/dsh-home.ts +1 -1
  97. package/src/firewall.ts +230 -0
  98. package/src/gate.ts +11 -5
  99. package/src/index.ts +235 -65
  100. package/src/inner-auth.ts +85 -0
  101. package/src/lan-bind-plan.ts +81 -0
  102. package/src/lan-bind.ts +145 -0
  103. package/src/loopback-proxy.ts +34 -0
  104. package/src/pairing.persist.test.ts +1 -1
  105. package/src/pairing.ts +20 -6
  106. package/src/remote-api.ts +67 -45
  107. package/src/remote-channel-boot.ts +70 -3
  108. package/src/remote-channel-rules.ts +17 -2
  109. package/src/remote-methods.ts +58 -27
  110. package/src/remote-presence-pet.ts +129 -0
  111. package/src/routes.ts +358 -19
  112. package/src/tunnel.ts +7 -1
  113. package/assets/apple-touch-icon.png +0 -0
  114. package/assets/mobile-icon-192.png +0 -0
  115. package/assets/mobile-icon-512.png +0 -0
  116. package/assets/mobile-service-worker.js +0 -63
  117. package/lib/mobile.js +0 -17621
  118. package/lib/mobile.js.map +0 -1
  119. package/lib/types/mobile/PairRequiredView.d.ts +0 -7
  120. package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
  121. package/lib/types/mobile/PairRequiredView.js +0 -28
  122. package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
  123. package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
  124. package/lib/types/mobile/PairRequiredView.test.js +0 -24
  125. package/lib/types/mobile/api.d.ts +0 -145
  126. package/lib/types/mobile/api.d.ts.map +0 -1
  127. package/lib/types/mobile/api.js +0 -103
  128. package/lib/types/mobile/index.d.ts +0 -10
  129. package/lib/types/mobile/index.d.ts.map +0 -1
  130. package/lib/types/mobile/index.js +0 -38
  131. package/lib/types/mobile/markdown.d.ts +0 -34
  132. package/lib/types/mobile/markdown.d.ts.map +0 -1
  133. package/lib/types/mobile/markdown.js +0 -285
  134. package/lib/types/mobile/markdown.test.d.ts +0 -2
  135. package/lib/types/mobile/markdown.test.d.ts.map +0 -1
  136. package/lib/types/mobile/markdown.test.js +0 -79
  137. package/lib/types/mobile/messages.d.ts +0 -141
  138. package/lib/types/mobile/messages.d.ts.map +0 -1
  139. package/lib/types/mobile/messages.js +0 -553
  140. package/lib/types/mobile/messages.test.d.ts +0 -2
  141. package/lib/types/mobile/messages.test.d.ts.map +0 -1
  142. package/lib/types/mobile/messages.test.js +0 -356
  143. package/lib/types/mobile/mobile-styles.d.ts +0 -6
  144. package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
  145. package/lib/types/mobile/mobile-styles.js +0 -1438
  146. package/lib/types/mobile/mobile-theme.d.ts +0 -22
  147. package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
  148. package/lib/types/mobile/mobile-theme.js +0 -70
  149. package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
  150. package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
  151. package/lib/types/mobile/mobile-theme.test.js +0 -71
  152. package/lib/types/mobile/mux.d.ts +0 -128
  153. package/lib/types/mobile/mux.d.ts.map +0 -1
  154. package/lib/types/mobile/mux.js +0 -305
  155. package/lib/types/mobile/mux.test.d.ts +0 -2
  156. package/lib/types/mobile/mux.test.d.ts.map +0 -1
  157. package/lib/types/mobile/mux.test.js +0 -267
  158. package/lib/types/mobile/pairing.d.ts +0 -31
  159. package/lib/types/mobile/pairing.d.ts.map +0 -1
  160. package/lib/types/mobile/pairing.js +0 -62
  161. package/lib/types/mobile/rpc.d.ts +0 -38
  162. package/lib/types/mobile/rpc.d.ts.map +0 -1
  163. package/lib/types/mobile/rpc.js +0 -83
  164. package/lib/types/mobile/theme-toggle.d.ts +0 -12
  165. package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
  166. package/lib/types/mobile/theme-toggle.js +0 -26
  167. package/lib/types/mobile/views/App.d.ts +0 -57
  168. package/lib/types/mobile/views/App.d.ts.map +0 -1
  169. package/lib/types/mobile/views/App.js +0 -150
  170. package/lib/types/mobile/views/App.test.d.ts +0 -2
  171. package/lib/types/mobile/views/App.test.d.ts.map +0 -1
  172. package/lib/types/mobile/views/App.test.js +0 -52
  173. package/lib/types/mobile/views/ChatView.d.ts +0 -56
  174. package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
  175. package/lib/types/mobile/views/ChatView.js +0 -773
  176. package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
  177. package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
  178. package/lib/types/mobile/views/ChatView.stream.test.js +0 -231
  179. package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
  180. package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
  181. package/lib/types/mobile/views/ChatView.test.js +0 -718
  182. package/lib/types/mobile/views/SessionListView.d.ts +0 -27
  183. package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
  184. package/lib/types/mobile/views/SessionListView.js +0 -139
  185. package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
  186. package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
  187. package/lib/types/mobile/views/SessionListView.test.js +0 -167
  188. package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
  189. package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
  190. package/lib/types/mobile/views/WorkspaceView.js +0 -93
  191. package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
  192. package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
  193. package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
  194. package/lib/types/mobile-api.d.ts +0 -65
  195. package/lib/types/mobile-api.d.ts.map +0 -1
  196. package/lib/types/mobile-api.js +0 -404
  197. package/lib/types/mobile-pending.d.ts +0 -38
  198. package/lib/types/mobile-pending.d.ts.map +0 -1
  199. package/lib/types/mobile-pending.js +0 -59
  200. package/lib/types/mobile-pwa.d.ts +0 -6
  201. package/lib/types/mobile-pwa.d.ts.map +0 -1
  202. package/lib/types/mobile-pwa.js +0 -17
  203. package/lib/types/mobile-routes.d.ts +0 -13
  204. package/lib/types/mobile-routes.d.ts.map +0 -1
  205. package/lib/types/mobile-routes.js +0 -217
  206. package/lib/types/paired-model-catalog.d.ts +0 -17
  207. package/lib/types/paired-model-catalog.d.ts.map +0 -1
  208. package/lib/types/paired-model-catalog.js +0 -311
  209. package/src/mobile/PairRequiredView.test.tsx +0 -28
  210. package/src/mobile/PairRequiredView.tsx +0 -58
  211. package/src/mobile/api.ts +0 -229
  212. package/src/mobile/index.tsx +0 -41
  213. package/src/mobile/markdown.test.ts +0 -91
  214. package/src/mobile/markdown.ts +0 -291
  215. package/src/mobile/messages.test.ts +0 -402
  216. package/src/mobile/messages.ts +0 -677
  217. package/src/mobile/mobile-styles.ts +0 -1439
  218. package/src/mobile/mobile-theme.test.ts +0 -84
  219. package/src/mobile/mobile-theme.ts +0 -76
  220. package/src/mobile/mux.test.ts +0 -314
  221. package/src/mobile/mux.ts +0 -336
  222. package/src/mobile/pairing.ts +0 -77
  223. package/src/mobile/rpc.ts +0 -90
  224. package/src/mobile/theme-toggle.tsx +0 -47
  225. package/src/mobile/views/App.test.tsx +0 -62
  226. package/src/mobile/views/App.tsx +0 -213
  227. package/src/mobile/views/ChatView.stream.test.tsx +0 -263
  228. package/src/mobile/views/ChatView.test.tsx +0 -816
  229. package/src/mobile/views/ChatView.tsx +0 -1295
  230. package/src/mobile/views/SessionListView.test.tsx +0 -207
  231. package/src/mobile/views/SessionListView.tsx +0 -244
  232. package/src/mobile/views/WorkspaceView.test.tsx +0 -227
  233. package/src/mobile/views/WorkspaceView.tsx +0 -245
  234. package/src/mobile-api.ts +0 -418
  235. package/src/mobile-pending.ts +0 -88
  236. package/src/mobile-pwa.ts +0 -19
  237. package/src/mobile-routes.ts +0 -231
  238. package/src/paired-model-catalog.ts +0 -272
package/README.md CHANGED
@@ -1,175 +1,39 @@
1
1
  # DSH Remote Web UI
2
2
 
3
-
4
3
  English | [中文](README.zh.md)
5
- > Remote access for phones and computers + one-click updates: pair a phone to use the current dsh web workspace remotely, or pair a computer browser through the same token to run the full Web GUI from another device; the sidebar checks for a newer dsh-web release after it loads and marks the update button when one is available; clicking it updates the family.
6
-
7
- This repository is an external plugin package for DeepSeek Harness (DSH):
8
- pairing-based remote access for the dsh web GUI on phones and computers, plus
9
- a one-click self-update for the dsh-web family. It is a single
10
- dual-face package — the host half owns pairing tokens, device sessions, the
11
- `/api/pair` route family, the gated `/remote` desktop channel, and the
12
- `/api/update` surface; the browser half renders the sidebar-foot entries (the
13
- download trigger and an icon-only remote-access entry beside the settings
14
- button), the pairing panel with a QR code, live device status, an
15
- authorized-device list, and stop/refresh/copy actions, and the update panel
16
- that probes and runs the update.
4
+ > Remote access for the dsh web GUI that shares **one interface**: pair a phone or another computer from a QR beside the settings button, and both run the same official Web GUI this machine runs — phones get an injected portrait-touch adaptation, PCs get the full desktop — through one-time pairing tokens and revocable device sessions. A settings toggle binds the server to the LAN, an optional Cloudflare quick tunnel reaches the internet, and the sidebar checks for a newer dsh-web release with one-click self-update.
5
+
6
+ This repository is an external plugin package for DeepSeek Harness (DSH). It is a single dual-face package: the host half owns pairing tokens, device sessions, the `/api/pair` route family, the gated `/remote` channel, the LAN bind toggle, and the `/api/update` surface; the browser half renders the sidebar-foot entries (the download trigger and the remote-access entry beside the settings button), the pairing panel with a QR code, live device status, the authorized-device roster, the settings card, the portrait-touch adaptation layer over the official UI, and the update panel.
17
7
 
18
8
  ## What it does
19
9
 
20
- - **Entry**: a phone icon beside the settings button in both the expanded sidebar and narrow rail; its tooltip and accessible label say "Remote access".
21
- - **Panel**: "Remote access" title, "Pair a phone or another computer to access this workspace remotely" subtitle, a "Pair a device" card with the status area ("Waiting for a device" + status badge), a large QR code, separate phone (`/m/?pair=...`) and computer (`/?pair=...`) links with their own copy buttons, plus Stop / Refresh QR actions and an authorized-device list (a device name inferred from User-Agent, online/offline, last active time, per-device unpair). Credential-bearing device ids and raw User-Agent values are never rendered. Both links share one single-use token, so pairing either device invalidates the other link.
22
- - **Phone side**: scanning the QR binds the phone with a one-time,
23
- time-limited token and lands it on the **standalone mobile surface at
24
- `/m/`** — a thin client purpose-built for a small screen (see
25
- [Screenshots](#screenshots)), not the desktop UI squeezed into a phone.
26
- The page is installable as a PWA. Each installed app uses its own
27
- paired-device cookie; a storage-isolated mobile web-app context pairs by
28
- opening a fresh QR link there or pasting a fresh desktop-issued link.
29
- The link carries a `workspace` parameter so the phone lands in the same
30
- workspace the desktop was looking at.
31
- - **PC side (remote desktop pairing)**: the same QR link and pairing token
32
- also pair a PC browser — the phone flow extended to the desktop Web GUI.
33
- Copy the link from the panel and open it in a browser on another
34
- computer (LAN URL or tunnel URL); after the accept round trip the full
35
- Web GUI runs there — workspaces, sessions, chat, model switching — with
36
- its fenced same-origin traffic on the gated `/remote` channel instead of
37
- calling loopback-only host routes directly. The
38
- phone gets `/m/`, the PC gets the desktop UI: one token, one pairing
39
- flow, two surfaces. An unpaired PC sees a full blocking page with computer
40
- pairing steps and a retry action, with no workspace data rendered behind it.
41
- - **Security**: one active one-time token (a refresh invalidates the old
42
- link; an accepted token cannot be reused; tokens expire). 停止 revokes
43
- every paired device and the current token — paired devices are cut off on
44
- their next request. Pairing is this plugin's access control for its own
45
- remote channels: `/m/api` for the phone and `/remote` for a desktop
46
- browser opened at a non-loopback origin. Unpaired `/remote` callers
47
- are refused before the request body is read while `requirePairingForLan` is
48
- on (default). Loopback (127.0.0.1) keeps using
49
- `/api` directly. The default remote-desktop path does not use
50
- `--trusted-host`: the connection plugin's `/api` fence stays closed for
51
- public and LAN hosts, and the paired PC rides `/remote` instead.
52
- `--trusted-host` is a different SDK usage that trusts that host for `/api`
53
- itself — pairing does not gate `/api` (no plugin can; the fence is the
54
- SDK's own seam). The posture probe below reports that stance when `/api`
55
- is reachable without pairing.
56
- - **Paired model catalog**: authenticated paired devices can use `GET
57
- /api/pair/model-catalog`, `POST /api/pair/model-catalog/discover`, and
58
- `POST /api/pair/model-catalog/upsert` to inspect and adopt models for an
59
- existing active `llm-pi-ai` provider only. The capability fixes the
60
- provider settings address internally and cannot create providers or read or
61
- change credentials, general settings, endpoints, headers, or arbitrary
62
- configuration. The exact routes are served ahead of the connection
63
- plugin's `/api` prefix, so a paired LAN or tunnel client may call them
64
- directly, and they carry the same Host/Origin trust fence as the sibling
65
- `/api/pair` endpoints: loopback, the advertised LAN literals, or the
66
- configured public base URL. Through the `/remote` desktop channel all
67
- `/api/pair/*` paths stay loopback-only. Adoption is refused while the
68
- provider's live model catalog is unavailable or unknown. A provider whose
69
- resolved `models` list is absent or empty keeps inheriting its installed
70
- catalog until an unknown custom model must materialize that catalog;
71
- existing model overrides are preserved and translated into the resulting
72
- entries. Malformed or conflicting model profiles are refused instead of
73
- being destructively rewritten. Stop or device
74
- revocation disables it immediately; the
75
- generic `settings.*`, `credentials.*`, and `llm.discoverModels` RPC methods
76
- remain loopback-only.
77
- - **Remote desktop channel**: with `requirePairingForLan` on (default), a
78
- desktop Web GUI opened at the LAN URL or through the tunnel transparently
79
- rides `/remote` — the same UI, gated by the same pairing cookie. The
80
- rewrite installs twice-removed from the race it fixes: the host inlines a
81
- small classic script right after the opening `<head>` tag
82
- (`webserver/index-inject`), so fetch/WebSocket/EventSource and resource
83
- `src` rewrites are active before any boot entry (the connection plugin
84
- opens its event streams first); the browser half then adopts the installed
85
- seat instead of patching twice. Loopback origins skip the patch entirely. Browser
86
- requests under `/api`, `/sidebar`, `/git`, and `/pet`, including the known
87
- event, terminal, and SSH WebSockets, are re-issued to the local web server
88
- without forwarding the remote Origin or pairing cookie; the authenticated
89
- proxy supplies its own same-origin browser marker for sibling route fences. The
90
- SDK's loopback-only privileged methods (native dialogs, the settings and
91
- credentials plane) stay unreachable from a paired remote desktop;
92
- `/api/pair/*` (including the paired model-catalog routes),
93
- `/api/update/*`, `/api/plugin-manager/*`, `/api/dsh-desktop-launcher/*`
94
- and `/api/dsh-web-ui-settings/*` control endpoints stay loopback-only. Unpaired desktop browsers get a persistent
95
- full-page block instead of data (the page keys off the `unpaired` error
96
- code, not every 403). The block retires once a gated call succeeds or the
97
- channel itself is torn down — turning `requirePairingForLan` off (or the
98
- plugin off) also clears a notice raised while the channel was briefly
99
- active.
100
- - **Posture probe**: the plugin probes the SDK `/api` fence with forged
101
- Host headers (the public base and every LAN base). A 403 is the default
102
- stance (fence closed; remote access goes through pairing). Anything other
103
- than a 403 — `--trusted-host`, or the SDK's LAN auto-trust under
104
- `--host 0.0.0.0` — is surfaced as a CRITICAL log line and a red banner on
105
- the pairing panel, so the SDK `/api` trust stance is visible rather than
106
- assumed. A failed probe round drops the in-flight target key so the same
107
- origins are retried.
108
- - **Live status**: the desktop panel mirrors the pairing state in real time
109
- (waiting → connected → disconnected) over an SSE stream, including the
110
- authorized-device list.
111
- - **Remote update**: the download trigger in the sidebar foot (left of the
112
- phone icon) performs a silent status probe after the sidebar loads. When a
113
- newer registry release is available, the trigger shows a dot and the "New
114
- version available. Check for updates" tooltip. Clicking the trigger opens the
115
- update panel, which probes the npm registry for the installed
116
- `@linxin666/dsh-*` family releases. Without the aggregate package, checks and
117
- updates cover every registry-managed direct `@linxin666/*` dependency in the
118
- profile; local link/file development dependencies are skipped. When a newer
119
- release exists, the panel shows the GitHub release notes grouped into New
120
- Features / Bug Fixes / Other Changes and waits for confirmation; the exact
121
- component-version list remains available in a collapsed section. Clicking
122
- "Update now" runs the update (`pnpm update --latest` inside the
123
- owning dsh profile; when pnpm is missing it falls back to `corepack pnpm`
124
- and then `npx --yes pnpm`, and on Windows the command runs through
125
- `cmd.exe` so npm-installed `.cmd` shims resolve; the loopback-only
126
- `/api/update/status` + `/api/update/run` endpoints drive it) and asks for
127
- a dsh web restart to pick it up. After a green pnpm exit the installed
128
- versions are re-checked against the registry: a green exit that left every
129
- version in place (e.g. the pnpm `minimumReleaseAge` gate silently skipping
130
- same-day releases) is reported as a stale update with configuration
131
- guidance instead of a false success. When the anchor itself is a local link
132
- install (development mode), it reports the npm state without updating.
133
-
134
- ## Screenshots
135
-
136
- The phone surface on a 390pt viewport. Light is the default theme; a
137
- sun/moon toggle in every header flips to the dark palette at any time.
138
-
139
- - **Workspaces** — the roster, each row a workspace with its own sessions:
140
- ![Workspaces](docs/screenshots/mobile-workspaces.png)
141
- - **Sessions** — one workspace's sessions, headed by the 新建会话 button
142
- (creates a blank session attached to the workspace and opens it
143
- immediately):
144
- ![Sessions](docs/screenshots/mobile-sessions.png)
145
- - **Chat** — messages with the desktop fold discipline (collapsed
146
- 深度思考 reasoning and 工具 tool-call rows), a pinned composer with
147
- 模型 / 权限 chips, and a live stream while the agent works:
148
- ![Chat](docs/screenshots/mobile-chat.png)
149
- - **Model picker** — the bottom sheet with a provider-grouped catalog and a
150
- 思考强度 section per model (the same `session.models` directory the
151
- desktop uses):
152
- ![Model sheet](docs/screenshots/mobile-model-sheet.png)
10
+ - **Entry**: a phone icon beside the settings button in both the expanded sidebar and the narrow rail; its tooltip and accessible label say "Remote access".
11
+ - **Panel**: "Remote access" title, a "Pair a device" card with the status area ("Waiting for a device" + status badge), a large QR code, the pairing link with its copy button, Stop / Refresh QR actions, and the authorized-device roster (a device name inferred from User-Agent, online/offline, last active time, per-device unpair). Credential-bearing device ids and raw User-Agent values are never rendered. Both the phone and the PC use the same single-use link.
12
+ - **Phone side**: scanning the QR binds the phone with a one-time, time-limited token and reloads into the **official Web GUI** — there is no second interface to drift from. While the phone is in portrait, the plugin injects a touch-adaptation layer over the running UI (see "The mobile adaptation layer" below). The accept chain is cookieless (`/pair-accept` → `/pair-app`): the plugin itself serves the official shell, so the phone needs no harness browser-auth cookie at all. A reopen service worker (https origins) then owns later navigations to `/` from history, bookmarks, or tab restore, so the phone reopens straight into the app instead of a 401 dead end (see Security model).
13
+ - **PC side**: the same link opens the full desktop Web GUI on another computer over the gated `/remote` channel; unpaired PCs see a guided blocking page (with a manual pair-token input) and no workspace data behind it.
14
+ - **Security**: one active token at a time (a refresh invalidates the old link; the link stays re-usable within its expiry window so a scan handed between browsers can complete pairing; it expires). 停止 revokes every paired device and the current token — the `/remote` channel cuts them off on their next request. Pairing is this plugin's access control for the `/remote` channel; direct `/api` on a LAN-exposed bind is governed by the harness fence + browser auth (see Security model). Loopback (127.0.0.1) keeps using `/api` directly. A paired device is a **full-control credential** (see Security model).
15
+ - **LAN bind toggle**: the settings card writes a managed block into the profile `cordis.patch.yml` that pins the webserver bind to `0.0.0.0` (on) or `127.0.0.1` (off) — no `--host` command-line dance; an explicit `--host`/`--port` flag still wins. It maintains the matching host firewall rule (Windows Defender via netsh; Linux firewalld/ufw/iptables; other platforms report the firewall as unmanaged) and shows the live bind, the reachable LAN URLs, and the firewall state.
16
+ - **Live status**: the desktop badge flips to 已连接 in real time; an `/api` posture probe reports any host whose `/api` fence the SDK leaves open, and the auto-tunnel state appears on the panel while the tunnel is starting.
17
+ - **One-click self-update**: the sidebar download trigger checks for a newer dsh-web release after load, marks the button when one exists, and runs the verified update (release notes shown in the panel).
18
+
19
+ ## The mobile adaptation layer
20
+
21
+ The official desktop layout already auto-collapses the sidebar below 1024px. On top of that, while the viewport is portrait + coarse pointer + narrower than 1100px the plugin injects:
22
+
23
+ - a CSS sheet keyed on CSS-Modules **semantic suffixes** (`[class$="_composerSeat"]`) so the selectors survive official rebuilds that only change hashes: 44px touch targets on the collapsed rail, 16px inputs (prevents iOS focus zoom), safe-area padding for the composer, compact type for the message list and sidebar, a column layout for the settings modal, and a full-width PlanReview card;
24
+ - a **draggable whale button** as the entry to the collapsed sidebar (position persisted; wired to the official `ctx.layout.toggleSidebar()`), hidden while the sidebar is expanded;
25
+ - **gestures**: left-swipe collapses the sidebar, right-swipe in the conversation opens it; long-pressing a session row opens the same action menu as the desktop ellipsis; tapping a session row or anywhere outside the sidebar collapses it again;
26
+ - **input behavior for touch**: Enter only inserts a newline (send goes through the send button), programmatic composer focus is suppressed (no spurious keyboards), and official tooltip bubbles are hidden because taps leave them stuck;
27
+ - **mobile plugin scope**: while the layer is active, the right-hand details column and the desktop-oriented tool surfaces (SSH terminal, skill explorer, task board, git graph, pet, perf, usage) are hidden - keyed on the L2 semantic roots (`data-dsh-plugin`), so ownership stays with the declaring plugin and class churn cannot resurrect them. These are render suppressions; the client bundles still load. Activation also closes the details panel through the official `ctx.layout.closeDetails()`.
28
+ - a **manual opt-out**: `sessionStorage.dsh-remote-force-desktop = 1` disables the whole layer; landscape, desktop, and wide viewports are never touched.
29
+
30
+ The paired remote desktop also runs in **host mode**: on this harness line the "configuration plane is local" behavior is a client-side branch on `connection.isLoopback`, and the channel boot script installs the transport hook (`__DSH_TRANSPORT__.ownsHost = true`) on non-loopback origins before any boot entry. Settings, credentials, agent presets, and deliverables therefore work on the phone exactly as on the desktop — every call still rides the gated `/remote` channel. Four control planes stay physically local: `/api/pair/*`, `/api/update/*`, `/api/plugin-manager/*`, and `/api/dsh-desktop-launcher/*`.
153
31
 
154
32
  ## Requirements
155
33
 
156
- - A DSH installation whose `dsh` CLI supports profiles (`dsh --profile`,
157
- `dsh plugin`) — the profile/bundle mechanism this package rides on.
158
- - For LAN use the server must be reachable from the phone: start with
159
- `dsh web --host 0.0.0.0`. With the default `127.0.0.1` bind the panel
160
- shows an explicit explanation instead of a dead QR code — unless a public
161
- base URL is configured (see "Remote access over the internet" below),
162
- which makes the QR reachable from anywhere without rebinding. The panel's
163
- mint/stop endpoints are loopback-only by design: a desktop browser
164
- opened at the LAN URL sees a "配对面板仅限本机使用" banner instead —
165
- open the panel at `http://127.0.0.1` and let the phone use the paired
166
- link.
167
- - For the one-click public tunnel (`autoTunnel`), the `cloudflared`
168
- platform binary ships with the package (its postinstall downloads it; a
169
- runtime download covers installers that skip postinstall scripts). No
170
- user-side tooling, account, or domain is needed — a Cloudflare quick
171
- tunnel is free and anonymous.
172
- - 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.
34
+ - A DSH installation whose `dsh` CLI supports profiles (`dsh --profile`, `dsh plugin`) — the profile/bundle mechanism this package rides on.
35
+ - For LAN use, either flip the **局域网访问** toggle in the settings card (writes the bind block; effective from the next `dsh web` start) or start with `dsh web --host 0.0.0.0`. With the default `127.0.0.1` bind the panel shows an explicit explanation instead of a dead QR code — unless a public base URL is configured (see "Remote access over the internet" below), which makes the QR reachable from anywhere without rebinding. The panel's mint/stop endpoints are loopback-only by design: a desktop browser opened at the LAN URL sees a "配对面板仅限本机使用" banner.
36
+ - For the one-click public tunnel (`autoTunnel`), the `cloudflared` platform binary ships with the package (its postinstall downloads it; a runtime download covers installers that skip postinstall scripts). No user-side tooling, account, or domain is needed.
173
37
 
174
38
  ## Install
175
39
 
@@ -184,191 +48,30 @@ git clone https://github.com/zhu1090093659/dsh-web.git
184
48
  cd dsh-web
185
49
  pnpm install && pnpm -r build
186
50
  dsh plugin --profile web add link:$(pwd)/packages/dsh-remote-web-ui
187
-
188
51
  ```
189
52
 
190
- Restart the profile (`dsh web`), then open the phone icon in the sidebar
191
- foot. The plugin's `cordis.patch.yml` inserts the single plugin row that
192
- mounts both halves.
53
+ Restart the profile (`dsh web`), then open the phone icon in the sidebar foot. The plugin's `cordis.patch.yml` inserts the single plugin row that mounts both halves.
193
54
 
194
- > `github:<org>/<repo>` installs work for a standalone repo whose package
195
- > sits at the root (the `prepare` script builds `lib/` during install;
196
- > pnpm ≥10 blocks that until you copy the printed key into the profile's
197
- > `pnpm-workspace.yaml` `allowBuilds` and re-run). Monorepo subpackages
198
- > use the `link:` form above.
55
+ > `github:<org>/<repo>` installs work for a standalone repo whose package sits at the root (the `prepare` script builds `lib/` during install; pnpm ≥10 blocks that until you copy the printed key into the profile's `pnpm-workspace.yaml` `allowBuilds` and re-run). Monorepo subpackages use the `link:` form above.
199
56
 
200
57
  ## Use
201
58
 
202
- 1. `dsh web --host 0.0.0.0` (the printed LAN URL confirms reachability).
203
- 2. Click the phone icon → the panel mints a fresh one-time QR.
204
- 3. Scan with the phone (or open the copied link): the phone binds and
205
- lands on the **standalone mobile surface at `/m/`** — no desktop UI on a
206
- small screen. The surface is deliberately thin:
207
- - workspaces straight away (each workspace's session list offers an Agent
208
- mode picker before 新建会话: it selects the usable default preset, or the
209
- first usable preset, and passes that roster id with the workspace id to
210
- the host's `session.create`; an empty or unavailable roster keeps the
211
- host-default creation flow),
212
- - one workspace's sessions load **incrementally** (20 rows per page,
213
- "加载更多会话" continues; never the whole list at once),
214
- - opening a session fetches its chat content **on demand** (history
215
- pages, "加载更早的消息" goes further back),
216
- - a live stream shows new messages as they arrive, with a prompt box
217
- for sending your own (**Enter sends and Shift+Enter inserts a newline
218
- by default**; set `mobileEnterToSend: false` to make Enter insert a
219
- newline and reserve sending for the 发送 button),
220
- - a **light-first theme**: the surface ships a light palette by default;
221
- a sun/moon toggle in every header flips to the dark palette and the
222
- choice persists across visits (localStorage),
223
- - messages render with the desktop fold discipline: reasoning hides
224
- behind a collapsed 深度思考 disclosure, tool calls behind a collapsed
225
- 工具 row (tap to see each call's arguments), very long answers behind
226
- an explicit 展开全文 toggle, each row carries its time, and assistant replies render as GFM Markdown (headings / bold / italic / inline code / code blocks / lists / tables / quotes / links / images; a zero-dependency renderer escapes first and allow-lists protocols, so the mobile bundle size barely moves; KaTeX is not supported yet and will be evaluated separately), while user messages stay plain text — and
227
- - a composer toolbar carries the **model** picker (provider-grouped
228
- catalog with a 思考强度 effort section per model) and the **权限**
229
- picker (permission presets; 完全权限 requires an explicit confirm
230
- step). Both ride the host's own `session.models` /
231
- `session.selectModel` RPCs and the `/permission` command — the phone
232
- changes the same session settings the desktop would — plus a **显示**
233
- sheet with persistent toggles for 工具调用 (tool-call disclosures) and
234
- 系统提示词 (injected system messages), and a 上下文 usage chip that
235
- shows the latest assistant answer's context-fill percentage.
236
- 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.
237
- 5. **To pair a PC instead**: copy the same link and open it in a browser on
238
- the other computer — the desktop URL form (`/?pair=<token>`, not `/m/`).
239
- After the accept round trip the full Web GUI runs there over the gated
240
- `/remote/api` channel; unpaired PCs see the guided blocking page and no
241
- data. One active token pairs one device; mint a fresh QR for the
242
- next device.
243
- 6. The desktop badge flips to 已连接 in real time; it falls back to
244
- offline/断开 when the phone leaves.
245
- 7. 刷新二维码 invalidates the old link and issues a new one. 停止 revokes
246
- mobile access: paired devices 403 on their next request, including their
247
- live stream.
248
-
249
- The mobile surface is fully self-contained in this plugin: the `/m` page
250
- and its data channel (`/m/api`) are served by the plugin's own routes and
251
- need **no harness source changes** — the phone's RPC calls ride the
252
- plugin's `/m/api` proxy (which delegates to the host ApiProxy service and
253
- pages `session.list` itself), so the tunneled Host never has to enter the
254
- connection plugin's trust fence. The phone is gated by its paired-device
255
- cookie and an explicit method allowlist. The allowlist constrains the
256
- `/m/api` proxy alone: the paired-device cookie itself also passes the global
257
- api/gate, so a paired device is a full-control credential for the host `/api`
258
- surface — settings/credentials/host-action domains stay unreachable only
259
- because the SDK pins those privileged methods to loopback. Pairing is full
260
- device trust; the `/m/api` proxy's model
261
- reads/writes are limited to the advisory `session.models` /
262
- `session.selectModel` pair, and session control is limited to
263
- `session.cancel` (stops the active turn while preserving pending queued
264
- work — the phone has no queue management). Separately, the exact paired-only model-catalog
265
- routes may adopt models for an existing eligible `llm-pi-ai` provider; they
266
- cannot create providers or access credentials or general settings. Agent preset
267
- access to read-only `agentPreset.list`, creation to `session.create`
268
- (workspace id plus an optional id from that roster — the phone never names a
269
- working directory of its own), and the permission picker only ever sends the
270
- mode-agnostic `/permission` command
271
- through the already-allowlisted `session.prompt`); the live stream arrives
272
- 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.
273
-
274
- ### Behavior notes
275
-
276
- - While a turn runs (between its turn/start and turn/end frames), the
277
- composer's primary button switches from 发送 to a stop button — the same
278
- Send/Stop switch as the desktop input bar. Tapping it calls
279
- `session.cancel` to stop the active turn (pending queued prompts resume
280
- afterwards); the button is disabled while the stop request is in flight
281
- and flips back to 发送 when the turn ends.
282
- - The mobile composer sends on Enter by default (Shift+Enter inserts a
283
- newline). Set `mobileEnterToSend: false` in the plugin settings card (or
284
- the profile patch) to make plain Enter insert a newline instead; sending
285
- then happens only through the 发送 button. The phone reads the flag
286
- through its own `/m/api` preferences method when a chat opens. On
287
- browsers that support `field-sizing: content`, the input grows with the
288
- draft up to its 120px cap in either mode.
289
- - 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.
290
- - Installing this plugin routes fenced non-loopback desktop traffic onto the
291
- gated `/remote` channel (see `requirePairingForLan` in `src/index.ts`).
292
- While the flag is on (default), a desktop browser opened via the LAN URL or
293
- the tunnel must pair like any remote device — the unpaired state shows a
294
- persistent blocking page instead of data; loopback (127.0.0.1) is unaffected
295
- and keeps `/api`. Set `requirePairingForLan: false` in the profile patch to
296
- keep the desktop on plain `/api` and have a leftover `/remote` rewrite
297
- proxy unpaired calls (only useful with the open-LAN stance; loopback-only
298
- paths stay denied) while keeping tokens/status/revocation. Note the
299
- underlying `/api` route itself is the
300
- SDK's: on a `--host 0.0.0.0` bind the SDK auto-trusts LAN literals, so a
301
- LAN client bypassing the UI can still reach `/api` directly — the posture
302
- probe reports that stance on the panel.
303
- - Sibling host routes outside `/api` (`/pet/*`, `/git/*`, the right-panel `/aionui-panel/*`
304
- family) can consult this plugin's `remoteWebUiPairing` service: a live
305
- paired-device cookie is an allow path, `stop()` still cuts them off, and
306
- the service is absent when this plugin is not installed.
307
- - The QR link is built from the machine's non-internal IPv4 literals; a
308
- multi-homed host (Wi-Fi + wired, or a proxy/VPN virtual adapter) shows a
309
- radio picker so you can advertise the network the phone can actually
310
- reach. The first literal is the default. When `publicBaseUrl` is set, the
311
- picker adds a 公网地址 option on top — the default QR then uses the
312
- public base, and picking a LAN literal re-mints an in-network link.
313
- - A configured `publicBaseUrl` satisfies the reachable-bind requirement on
314
- its own: `dsh web` bound to `127.0.0.1` (no `--host 0.0.0.0`) still mints
315
- working public QR links through the tunnel.
59
+ 1. Open the settings card (设置 → Web 插件 → 远程访问设置) and flip **局域网访问** on if the server binds loopback; the card shows the live bind, the firewall state, and the reachable LAN URLs. The bind change takes effect from the next `dsh web` start.
60
+ 2. Start `dsh web`, click the phone icon, and the panel mints a fresh one-time QR.
61
+ 3. Scan with the phone (or open the copied link): the device pairs and boots the **official Web GUI** served cookieless by the plugin (`/pair-accept` → `/pair-app`), which reloads into `/`. On a phone, the portrait adaptation layer is already active — same layout as the desktop, same live state. Later reopens from history or a bookmark go straight back into the app (https origins; see Security model).
62
+ 4. **To pair a PC instead**: copy the same link and open it in a browser on the other computer. After the same round trip the full Web GUI runs there over the gated `/remote` channel; unpaired PCs see the guided blocking page and no data. One active token pairs one device; mint a fresh QR for the next device.
63
+ 5. The desktop badge flips to 已连接 in real time; the device roster lists the paired devices with per-device 取消配对, and 停止 revokes everything.
64
+ 6. Configure pairing lifetime, device limits, the LAN fence policy (`requirePairingForLan`), the public base URL, and the auto tunnel in the same settings card.
316
65
 
317
66
  ## Remote access over the internet (tunnels)
318
67
 
319
68
  ### One-click public tunnel (recommended)
320
69
 
321
- Turn on `autoTunnel` in the plugin settings card (or set
322
- `autoTunnel: true` in the profile patch). The plugin then runs its own
323
- Cloudflare quick tunnel — the `cloudflared` binary ships with the package,
324
- no install, account, or domain needed — and wires everything itself:
325
-
326
- - the minted `https://xxx.trycloudflare.com` URL becomes the QR base, so a
327
- phone anywhere can pair. The panel shows the tunnel status (starting /
328
- running / failed with the reason), and a crash is restarted
329
- automatically with backoff.
330
-
331
- The QR stays LAN-only until the tunnel reports its URL, and a tunnel
332
- restart mints a NEW hostname — the plugin clears the old link and mints a
333
- fresh one, so users never touch configuration. Note that a quick tunnel is
334
- public: anyone with the URL can load the static page; the paired-device
335
- cookie gates are the real fence (the phone's `/m/api` with its method
336
- allowlist, the remote desktop's `/remote/api`). The connection plugin's
337
- `/api` fence refuses the tunneled host outright — the tunneled Host never
338
- enters the connection trust fence — so **no profile or harness
339
- customization is required for the auto tunnel to work**. `--trusted-host`
340
- is not part of this path: a paired PC uses `/remote/api`.
70
+ Set **自动公网隧道** (autoTunnel) in the settings card. The plugin runs its own Cloudflare quick tunnel (`cloudflared` ships with the package), feeds the minted public URL into the QR base and the pairing fence dynamically, and keeps the posture probe informed — a phone anywhere can pair at any time. The manual public address below is ignored while this is on.
341
71
 
342
72
  ### Manual tunnels (bring your own)
343
73
 
344
- The QR link is normally a LAN URL, so a phone outside the house cannot use
345
- it. Point a tunnel at the dsh web port and tell the plugin its public
346
- address — the QR is then built from the tunnel URL. One knob is involved;
347
- `--trusted-host` is a separate SDK flag, not part of this pairing path:
348
-
349
- - **`publicBaseUrl`** (plugin config, in the profile patch or the settings
350
- card): the public origin, e.g. `https://foo.trycloudflare.com`. The QR
351
- link is built from it, and `accept`/`heartbeat`/`status` accept its host.
352
- A paired desktop browser rides the gated `/remote/api` channel through
353
- the same origin, so the desktop Web GUI works from anywhere too.
354
- Malformed values are ignored with a warning (LAN-only behavior kept).
355
- Unpaired callers of `status` see only the pairing-relevant fields (phase /
356
- LAN bases); token expiry, device roster, and the tunnel URL require a live
357
- device cookie. The accept rate limit partitions its buckets by the
358
- client-visible `X-Forwarded-For` hop so one internet client cannot
359
- exhaust the shared bucket behind the tunnel.
360
- - **`--trusted-host <tunnel-domain>`** (optional dsh web flag, not recommended
361
- for this plugin on security grounds): prefer device pairing so phones use
362
- `/m/api` and PCs use `/remote/api`. This flag makes the SDK trust the host
363
- for `/api` itself, so unpaired callers can reach the ungated host API;
364
- pairing still gates `/m/api` and `/remote/api`, but not `/api`. If it is
365
- set, the posture probe reports the open `/api` fence.
366
-
367
- ### Cloudflare Tunnel (quick tunnel — no account, no domain)
368
-
369
- Install the client once (macOS: `brew install cloudflared`; other systems:
370
- grab the `cloudflared-darwin-{arm64,amd64}` binary from the official GitHub
371
- releases). Then:
74
+ Expose the local port with any tunnel and set **公网地址** (publicBaseUrl), e.g.:
372
75
 
373
76
  ```sh
374
77
  # 1. Expose the local port (whatever dsh web is listening on):
@@ -377,58 +80,17 @@ cloudflared tunnel --url http://127.0.0.1:3080
377
80
 
378
81
  # 2. Start dsh web as usual. Do not add --trusted-host for the tunnel
379
82
  # domain unless you intentionally want the SDK to trust that host for
380
- # /api (pairing does not cover /api; remote desktop uses /remote/api).
381
- # Use --host 0.0.0.0 only when LAN access should stay available:
382
- dsh web
83
+ # /api. Keep the LAN bind off if only tunnel access is wanted.
383
84
  ```
384
85
 
385
- Then set `publicBaseUrl: https://xxxx-xxxx-xxxx.trycloudflare.com` in the
386
- profile patch (or the plugin settings card — it hot-reloads). Open the
387
- phone icon at `http://127.0.0.1`, scan the QR from anywhere: the phone
388
- binds, reloads into the mobile surface, and heartbeats keep it online. A
389
- desktop browser opened at the same tunnel URL pairs the same way and then
390
- runs the full Web GUI over the gated `/remote/api` channel.
391
-
392
- Notes:
393
-
394
- - Quick tunnels are free and need no login, but the hostname is random per
395
- run: every `cloudflared` restart changes it, so update `publicBaseUrl`
396
- with it. Cloudflare documents no uptime guarantees;
397
- in-flight-request concurrency is capped (HTTP 429 past it), and **Quick
398
- Tunnels do not forward Server-Sent Events**. `Tailscale Serve` (and
399
- `tailscale serve` on a single port) behaves the same way. SSE is how the
400
- phone receives **live messages** in real time, so over a quick tunnel or
401
- Tailscale Serve the mobile chat falls back to polling: the phone still
402
- sends and receives messages (everything else rides plain HTTP, which does
403
- forward), only a new message may arrive a few seconds late instead of
404
- instantly. The plugin polls `session.history` on a short interval once the
405
- SSE channel goes silent, and resumes streaming as soon as SSE works again.
406
- For true real-time push, point the QR at a tunnel that forwards SSE — a
407
- Cloudflare **named tunnel** (domain hosted on Cloudflare, see below), or a
408
- plain TCP port forward (LAN address, the `tailscale up` virtual-interface
409
- address, or a manual `ssh -L` / cloudflared TCP tunnel to the port). A
410
- remote desktop browser is unaffected: its event streams ride WebSocket
411
- upgrades, which quick tunnels forward.
412
- - A quick tunnel is public: anyone with the URL can load the static page.
413
- The paired-device cookie gates are the real fence (unpaired devices get
414
- 403 on every `/m/api` and `/remote/api` call) — keep `requirePairingForLan`
415
- on. `--trusted-host` is not needed for this path and opens SDK `/api`
416
- outside pairing.
417
- - For a stable hostname, create a named tunnel from the Cloudflare
418
- dashboard (Networking → Tunnels; the domain must be hosted on Cloudflare)
419
- and use its hostname as `publicBaseUrl` (only). Reachability from
420
- mainland China is not guaranteed by Cloudflare; verify locally.
421
- - Tailscale is an alternative for personal use that needs no plugin
422
- changes at all: its virtual-interface address (`100.x.y.z`) shows up in
423
- the QR's address picker automatically, and a phone on the same tailnet
424
- reaches it like a LAN host.
86
+ Then set the printed URL as 公网地址. The QR link is built from it, and the plugin's pairing fence accepts the tunneled authority. The posture probe keeps auditing which `/api` authorities the SDK fence leaves open.
425
87
 
426
88
  ## Development
427
89
 
428
90
  Work from this repository (no sibling checkout needed):
429
91
 
430
92
  ```sh
431
- cd ~/code/dsh-web-ui
93
+ cd dsh-web
432
94
  export NPM_TOKEN='<token>' # only if private @deepseek-ai auth is still required
433
95
  pnpm install
434
96
  pnpm --filter @linxin666/dsh-remote-web-ui run build
@@ -436,11 +98,7 @@ pnpm --filter @linxin666/dsh-remote-web-ui test
436
98
  pnpm --filter @linxin666/dsh-remote-web-ui run typecheck
437
99
  ```
438
100
 
439
- The peer APIs come from the official NPM SDK: every `@deepseek-ai/*` package
440
- used here is declared in devDependencies (rc.6), and TypeScript/Vitest resolve
441
- types straight from node_modules — no DSH source checkout is required. The
442
- consumer-side `prepare` build (`tsdown.prepare.config.ts`) transpiles without
443
- type checking, so git installs work without any harness checkout either.
101
+ The peer APIs come from the official NPM SDK: every `@deepseek-ai/*` package used here is declared in devDependencies (0.1.2-alpha.2 cohort), and TypeScript/Vitest resolve types straight from node_modules — no DSH source checkout is required. The consumer-side `prepare` build (`tsdown.prepare.config.ts`) transpiles without type checking, so git installs work without any harness checkout either.
444
102
 
445
103
  ## Checks
446
104
 
@@ -452,103 +110,54 @@ pnpm run build
452
110
 
453
111
  ## Harness contract dependencies
454
112
 
455
- This plugin rides three harness seams that may not exist in older checkouts:
456
-
457
- - **`api/gate` waterfall** (packages/client/connection): the /api route and
458
- event WebSocket upgrades are *designed* to emit this event after the trust
459
- fence so plugins can enforce application-level access control. Current
460
- published SDK lines do NOT emit it (the listener stays mounted for
461
- deployments that gain the seam). Pairing for the phone and the PC therefore
462
- lives on this plugin's own routes (`/m/api`, `/remote/api`); the posture
463
- probe reports the SDK `/api` fence stance rather than assuming it.
464
- - **`sidebar.remote` foot seat** (packages/client/ui-sidebar): the sidebar
465
- declares and renders the seat the phone entry occupies.
466
- - **LAN runtime connection fixes** (host-apiproxy `mintRpcId` fallback for
467
- insecure-context origins; the 20260808-branch connection loop opening the
468
- host stream after the mux stream): without them the browser runtime cannot
469
- run on a plain-HTTP LAN page at all (the mobile side of this feature).
470
-
471
- The fence helpers (`isTrustedApiRequest` / `isLoopbackHostname`) are
472
- reimplemented locally in `src/gate.ts` / `src/routes.ts`: the 20260810
473
- upstream moved the trust fence inside the connection plugin and stopped
474
- exporting them, so the pairing routes carry their own copy scoped to the
475
- literals the QR links advertise.
476
- See the Agent Notes `api-gate-and-sidebar-remote-seat` and
477
- `lan-runtime-connection-fixes` in the harness checkout.
113
+ Pinned to the 0.1.2-alpha.2 line; the seams this build relies on:
114
+
115
+ - **`sidebar.footer.action` foot seat** (the 0.1.2 shell composition): the sidebar declares and renders the seat the remote entry occupies.
116
+ - **`ctx.layout.toggleSidebar()`** (packages/client/ui-layout): the whale button expands the collapsed sidebar through the official panel-action face.
117
+ - **`ctx.connection.authenticatedUrl()`** (packages/client/connection): the sanctioned launch-token seam the proxy redeems once for its inner credential (`src/inner-auth.ts`), so re-issued `/api` calls satisfy the harness browser-auth check.
118
+ - **`__DSH_TRANSPORT__.ownsHost`** (client-connection transport hooks): the host-mode flip for paired remote desktops. There is no host-side per-method privilege pin on this line — the configuration plane branches on `connection.isLoopback` in the client — and no `api/gate` waterfall either (the gate listener stays mounted for deployments that gain the seam; pairing enforcement lives on this plugin's own `/remote` channel).
119
+ - **User-patch bind semantics**: same-id patch rows replace the row config wholesale, and the user patch layer cannot evaluate `webStartup`-dependent `!!js` expressions reliably — the LAN bind block therefore materializes static values and the plugin re-asserts it every boot.
120
+
121
+ The fence helpers (`isTrustedApiRequest` / `isLoopbackHostname`) are reimplemented locally in `src/gate.ts` / `src/routes.ts`: the connection plugin stopped exporting them, so the pairing routes carry their own copy scoped to the literals the QR links advertise.
478
122
 
479
123
  ## Manual E2E: LAN pairing round trip
480
124
 
481
- The unit/component specs cover the route family, the gate, and the panel,
482
- but the pairing loop involves a real browser on a non-loopback origin.
483
- Repeat this after any change to the wire contract or the connection loop:
484
-
485
- 1. Start the server on all interfaces with a test workspace root:
486
- `dsh web --host 0.0.0.0 --port 3190 --workspace-root /tmp/remote-e2e`.
487
- 2. Open the **loopback** URL (`http://127.0.0.1:3190`) in a browser: the
488
- phone icon sits in the sidebar foot; the panel mints a QR instantly.
489
- 3. In a second tab (or a phone) open the **LAN** URL with the pair token
490
- (e.g. `http://192.168.1.7:3190/?pair=<token>`): the page accepts, sets
491
- the HttpOnly `dsh_pair` cookie, reloads, and boots the full UI — no
492
- console errors, and a generation round trip completes.
493
- 4. The desktop badge flips to 已连接 in real time; a LAN-origin desktop
494
- page instead shows the 配对面板仅限本机使用 banner and opens no status
495
- stream.
496
- 5. 停止 on the desktop cuts the phone off: its next `/api` request 403s
497
- (reconnect loops retry until a fresh QR re-pairs).
498
-
499
- The public path is the same round trip through a tunnel (see "Remote access
500
- over the internet"): loopback mint → phone or PC opens the public QR URL →
501
- accept → UI. Only `publicBaseUrl` (plugin config) names the tunneled host;
502
- `--trusted-host` is not part of this pairing flow. The desktop panel still
503
- opens at `http://127.0.0.1`.
125
+ The unit/component specs cover the route family, the gate, the channel, the lan-bind block, and the adaptation layer, but the pairing loop involves a real browser on a non-loopback origin. Repeat this after any change to the wire contract or the connection loop:
126
+
127
+ 1. Start an isolated instance: `DSH_HOME=/tmp/dsh-qa dsh --profile web --no-open --port 3191` with the LAN bind toggle on (or a profile whose bind block pins 0.0.0.0).
128
+ 2. Open the **loopback** URL (`http://127.0.0.1:3191`) in a browser: the phone icon sits in the sidebar foot; the panel mints a QR whose link is `<lan-url>/pair-accept?pair=<token>`.
129
+ 3. In a second tab under mobile emulation (390x844, touch) open that link: the chain `/pair-accept → /pair-app?device=<id> → /` sets the device cookie, serves the patched official shell, and boots the UI — `document.body.classList` carries `dsh-remote-portrait`, the adaptation stylesheet and the whale button exist, and `__DSH_TRANSPORT__.ownsHost` is `true`. The settings surface renders host data (host mode), not a memory mirror. In an https deployment the shell also registers the reopen service worker (`/pair-app.sw.js`); reloading `/` in that tab boots the app instead of the harness 401.
130
+ 4. The desktop badge flips to 已连接 in real time; a LAN-origin desktop page instead shows the 配对面板仅限本机使用 banner and opens no status stream.
131
+ 5. 停止 on the desktop cuts the device off: its next request 403s with `unpaired` (the fence page offers the manual pair-token input).
132
+
133
+ The public path is the same round trip through a tunnel (see "Remote access over the internet"): loopback mint → device opens the public QR URL → accept → official UI. Only `publicBaseUrl` (plugin config) names the tunneled host; `--trusted-host` is not part of this pairing flow. The desktop panel still opens at `http://127.0.0.1`.
504
134
 
505
135
  ## Security model
506
136
 
507
- - Mobile unary routes and the mux event stream require a live paired-device session. A missing or revoked session receives HTTP 403 with a JSON rejection carrying `error.code: "unpaired"`; the browser's `EventSource` API exposes only the stream failure, not that response body.
137
+ - **Pairing is the access control for the `/remote` channel**: while `requirePairingForLan` is on (default), every request must carry a live paired-device cookie, enforced before any bytes are forwarded. A missing or revoked session receives HTTP 403 with a JSON rejection carrying `error.code: "unpaired"`; the browser's `EventSource` API exposes only the stream failure, not that response body.
138
+ - **The channel carries the process's own inner credential.** The harness browser-auth cookie is authority-bound (minted for the exact `host:port` a browser visited) and has no loopback exemption, so a proxied re-issue to `127.0.0.1` cannot reuse a device's cookie. The plugin therefore redeems its own launch token once — the same exchange a first browser visit performs — and attaches that cookie to re-issued requests. The credential is only ever exercised behind the pairing gate above; 停止/取消配对 immediately stop exercising it.
139
+ - **Cohort reality: pairing does not gate direct `/api`.** On the pinned 0.1.2-alpha.2 line nothing emits the `api/gate` seam, so a direct `/api` call from a LAN origin is governed solely by the harness fence (which auto-trusts LAN literals under a `0.0.0.0` bind) plus the harness browser-auth cookie. A browser credential a device has already redeemed therefore survives 停止/取消配对 until its natural expiry (30 days) — revocation binds the `/remote` channel and the pairing cookie, not that credential. The plugin probes the `/api` posture and warns loudly; treat a LAN-exposed bind as a deliberate decision, and prefer loopback plus tunnels when the machine is shared.
140
+ - **A paired device is a full-control credential.** With host mode active it reaches the complete host API — chat, sessions, settings, credentials, agent presets, deliverables — mirroring the SDK's own stance that a loopback desktop is trusted. Only the four control planes (pairing, self-update, plugin install/remove, desktop launcher) stay physically local. Only pair devices you control; 停止 or per-device 取消配对 revokes immediately.
141
+ - **Control endpoints stay loopback-only**: mint/stop/revoke, the device roster, the lan-bind status, and the update endpoints answer only to loopback. A LAN-origin browser sees the "配对面板仅限本机使用" banner.
142
+ - **The app landing is cookieless-optional.** After pairing, the QR lands the device on `/pair-app`, served by this plugin: the official shell is delivered without passing the harness index gate, and the device credential rides a `x-dsh-remote-device` header (fetch) / `device` query (WebSocket upgrades) that the boot patch attaches from sessionStorage. The flow therefore works when the device browser blocks all cookies; the pairing cookie remains the primary credential where the browser stores it, and the harness browser-auth cookie is no longer required on the phone path at all.
143
+ - **Reopens are owned by a service worker (https origins).** A paired phone returning from history, a bookmark, or tab restore navigates to bare `/` — a path the plugin does not own, where the harness fallback seat answers with its browser-auth 401 (the cookieless flow never holds that credential). The app shell therefore registers `/pair-app.sw.js` (fenced like `/pair-app`; the script is inert logic with no secrets): it intercepts navigations to `/` only, re-serves the shell network-first through `/pair-app` — which re-validates the device cookie and refreshes its presence, so every reopen also keeps the session alive — falls back to the cached shell offline, and passes the navigation through when the plugin no longer answers (a revoked device then sees the harness response or the bilingual re-scan page). Plain-HTTP LAN origins are not secure contexts and never register the worker; there a reopen means scanning a fresh QR.
144
+ - **Revocation is per-request**: a paired device whose request is already in flight when 停止 lands completes that request; the next one 403s.
145
+ - **Paired device sessions persist by default**: device sessions (not the one-time QR token) are written to `$DSH_HOME/remote-web-ui-devices.json` (0600, temp file + atomic rename). A paired cookie still works after a `dsh web` restart. Refreshing the QR still mints a new token; restarting does not restore the current QR. Sessions idle for `idleExpireMs` (default 30 days; the reopen service worker refreshes the window on every navigation it serves) are deleted and must pair again. Device ids are session credentials. Override `devicesFile` with another absolute path when needed. Changing `cookieName` invalidates existing devices (expected).
146
+ - **The LAN bind block owns the webserver row**: while the toggle has been flipped, the managed block pins the bind; the plugin re-asserts it each boot so explicit `--host`/`--port` flags win by rewriting the block. Hand-editing the block is detected and surfaced in the card (`blockHost` shows the literal).
147
+ - **The desktop gate policy is public**: `/api/pair/status` exposes only the boolean `requirePairingForLan` policy so a remote desktop can choose the correct transport before its settings scope is available. This field is not a credential and does not expose tokens, devices, counts, or tunnel URLs.
148
+ - **Quick-tunnel hostnames change per run**: a `trycloudflare.com` URL is random on every `cloudflared` start, so `publicBaseUrl` (or the auto tunnel) must be refreshed with it. A named tunnel avoids the churn.
508
149
 
509
150
  ## Known Limitations and Deferred Work
510
151
 
511
- - **Revocation is per-request**: a paired phone whose request is already in
512
- flight when 停止 lands completes that request; the next one 403s.
513
- - **Paired device sessions persist by default**: device sessions (not the
514
- one-time QR token) are written to `$DSH_HOME/remote-web-ui-devices.json`
515
- (0600, temp file + atomic rename). A paired cookie still works after a
516
- `dsh web` restart. Refreshing the QR still mints a new token; restarting
517
- does not restore the current QR. An explicit 停止 (stop) or per-device
518
- 取消配对 still revokes immediately and the revocation is persisted.
519
- Sessions idle for `idleExpireMs` (default 7 days, no heartbeat or gated
520
- request) are deleted and must pair again. Device ids are session
521
- credentials (the gate authorizes requests by the device id in the cookie).
522
- Override `devicesFile` with another absolute path when needed. Changing
523
- `cookieName` invalidates existing devices (expected).
524
- - **Device roster is loopback-only**: the pairing panel lists authorized
525
- devices using a short name inferred from User-Agent (for example,
526
- `Windows · Chrome`), online/offline state, and last active time, and can
527
- unpair one at a time. Credential-bearing device ids and raw User-Agent
528
- values are not rendered. `/api/pair/status` never returns the device id
529
- list, even to a paired phone.
530
- - **The desktop gate policy is public**: `/api/pair/status` exposes only the
531
- boolean `requirePairingForLan` policy so a remote desktop can choose the
532
- correct transport before its settings scope is available. This field is
533
- not a credential and does not expose tokens, devices, counts, or tunnel URLs.
534
- - **Quick-tunnel hostnames change per run**: a `trycloudflare.com` URL is
535
- random on every `cloudflared` start, so `publicBaseUrl` must be updated
536
- whenever the tunnel restarts. A named tunnel (fixed hostname) avoids the
537
- churn and is required for a durable installed-PWA address.
538
- - **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.
539
- - **Dev HMR**: `dsh web --dev` polls every roster bundle by path, so
540
- rebuilding this package (its own `tsdown --watch`) hot-reloads the client
541
- bundle; no harness-side watcher is involved.
152
+ - **The bind change needs a restart**: the live patch watcher cannot rebind a listening socket, and hot reload behavior is profile-shape dependent. The card shows 局域网访问 will apply after the next `dsh web` start (`pendingRestart`).
153
+ - **Plain-HTTP LAN reopens need a re-scan**: the reopen service worker registers only on secure contexts (https tunnels, localhost); a phone paired over a plain-HTTP LAN URL that navigates back to `/` hits the harness 401 and must scan a fresh QR. Pairing again is cheap in-network and restores everything.
154
+ - **The adaptation selectors track the official build**: the semantic-suffix strategy survives hash churn but not semantic renames; each official GUI update needs a visual QA pass (per the dsh-LAN reference, these suffixes have been stable across many official releases).
155
+ - **Dev HMR**: `dsh web --dev` polls every roster bundle by path, so rebuilding this package (its own `tsdown --watch`) hot-reloads the client bundle; the host half needs a restart.
542
156
 
543
157
  ## Dependency rationale
544
158
 
545
- `qrcode.react` (MIT, actively maintained, React 16–19 support) renders the
546
- QR as a dependency-free SVG component — no canvas, no server-side image
547
- generation. It is inlined into the client bundle at build time (like the
548
- official skin/turtle-ui plugins inline their non-shared deps), so profile
549
- installations need no extra runtime dependency beyond the dsh peer closure.
550
- `schemastery` is the DSH-standard config schema validator.
159
+ `qrcode.react` (MIT, actively maintained, React 16–19 support) renders the QR as a dependency-free SVG component — no canvas, no server-side image generation. It is inlined into the client bundle at build time (like the official skin/turtle-ui plugins inline their non-shared deps), so profile installations need no extra runtime dependency beyond the dsh peer closure. `schemastery` is the DSH-standard config schema validator.
551
160
 
552
161
  ## Telemetry
553
162
 
554
- The browser half sends one anonymous install heartbeat per UTC day to dsh-market.com: a random localStorage id plus this package's name, nothing else. The server stores only a salted hash of that id, never IP addresses, and exposes aggregate counts only. See [docs/telemetry.md](../../docs/telemetry.md) for the full contract.
163
+ The browser half sends one anonymous install heartbeat per UTC day to dsh-market.com: a random localStorage id plus this package's name, nothing else. The server stores only a salted hash of that id, never IP addresses, and exposes aggregate counts only. See [docs/telemetry.md](../../docs/telemetry.md) for the full contract.