@linxin666/dsh-remote-web-ui 0.3.6 → 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 -17658
  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 -149
  138. package/lib/types/mobile/messages.d.ts.map +0 -1
  139. package/lib/types/mobile/messages.js +0 -561
  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 -63
  174. package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
  175. package/lib/types/mobile/views/ChatView.js +0 -821
  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 -236
  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 -685
  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 -268
  228. package/src/mobile/views/ChatView.test.tsx +0 -816
  229. package/src/mobile/views/ChatView.tsx +0 -1342
  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
@@ -3,12 +3,18 @@
3
3
  * under /api: the webserver matches exact paths before the connection
4
4
  * plugin's /api prefix, so these handlers own the full response lifecycle
5
5
  * and apply their own trust fence (loopback-only for control endpoints;
6
- * loopback-or-LAN for the phone-facing accept/heartbeat/status). The
7
- * cookie set on accept is the device identity the api/gate listener checks
8
- * on every other /api request.
6
+ * loopback-or-LAN for the phone-facing accept/heartbeat/status). The cookie
7
+ * set on accept is the device identity the plugin's own surfaces enforce:
8
+ * the /remote channel gate and the api/gate listener. Alongside the JSON
9
+ * family sit the phone-facing top-level pages (exact routes /pair-accept,
10
+ * /pair-app, /pair-app.sw.js) with their own LAN-or-public fence. Note that
11
+ * on the 0.1.2-alpha.2 cohort nothing emits api/gate — direct /api is
12
+ * governed by the harness fence + browser auth — while the /remote channel
13
+ * always enforces the pairing cookie itself.
9
14
  */
10
15
  import { z } from 'zod';
11
16
  import { UnknownLanAddressError } from "./pairing.js";
17
+ import { isLoopbackAddress } from "./loopback.js";
12
18
  import { isLoopbackClient, readCookie } from "./gate.js";
13
19
  import { readJsonBody, writeJson } from "./http.js";
14
20
  /**
@@ -63,6 +69,21 @@ export function isTrustedApiRequest(request, trustedHosts) {
63
69
  }
64
70
  /** Cap on pairing request bodies (tokens and workspace ids are tiny). */
65
71
  const MAX_BODY_BYTES = 4096;
72
+ /**
73
+ * The rate-limit bucket key for one accept attempt (pure; unit-tested).
74
+ * The first client-visible XFF hop separates buckets behind the auto-tunnel
75
+ * (every internet client arrives from 127.0.0.1 there) — but only for
76
+ * loopback peers, since a direct LAN client can rotate the header freely.
77
+ * @param socketIp - the socket peer address.
78
+ * @param forwarded - the first XFF hop, already trimmed, if any.
79
+ * @param bucket - page (GET /pair-accept) vs api (POST /api/pair/accept).
80
+ */
81
+ export function acceptLimitKey(socketIp, forwarded, bucket) {
82
+ if (isLoopbackAddress(socketIp) && forwarded !== undefined && forwarded !== '') {
83
+ return `${bucket}|${socketIp}|${forwarded}`;
84
+ }
85
+ return `${bucket}|${socketIp}`;
86
+ }
66
87
  /**
67
88
  * The host authority of a configured public base URL, e.g. `foo.trycloudflare.com`
68
89
  * from `https://foo.trycloudflare.com`. Undefined when the URL does not parse —
@@ -83,6 +104,126 @@ export function publicHostOf(url) {
83
104
  }
84
105
  /** Cookie lifetime: one year; revoked sessions die at the gate regardless. */
85
106
  const COOKIE_MAX_AGE_SEC = 365 * 24 * 60 * 60;
107
+ /**
108
+ * The cookieless device credential: pass the device id from the /pair-app
109
+ * URL into sessionStorage (and localStorage for tab reloads) before any app
110
+ * script runs - same key the boot patch and the channel gate read. The
111
+ * replaceState to '/' hides the credential URL from the address bar and
112
+ * leaves the SPA at its canonical root path. The reopen service worker is
113
+ * registered in the same breath: later navigations to '/' (history,
114
+ * bookmark, tab restore) must not fall through to the harness index gate,
115
+ * which the cookieless flow can never satisfy.
116
+ */
117
+ export const APP_DEVICE_STORAGE_KEY = 'dsh-remote-device';
118
+ export function appShellCaptureScript(deviceId) {
119
+ const safeId = JSON.stringify(deviceId);
120
+ const register = `try{if('serviceWorker' in navigator){navigator.serviceWorker.register(${JSON.stringify(PAIR_PATHS.appServiceWorker)},{scope:'/'}).catch(function(e){})}}catch(e){}`;
121
+ return `<script>(function(){try{sessionStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});localStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});}catch(e){}try{history.replaceState(null,'','/')}catch(e){}${register}})()</script>`;
122
+ }
123
+ /** Patch the official index document with the device-capture script. */
124
+ export function patchAppShell(html, deviceId) {
125
+ const script = appShellCaptureScript(deviceId);
126
+ const marker = '</head>';
127
+ const at = html.indexOf(marker);
128
+ if (at === -1)
129
+ return script + html;
130
+ return html.slice(0, at) + script + html.slice(at);
131
+ }
132
+ /**
133
+ * The reopen service worker served at PAIR_PATHS.appServiceWorker. A paired
134
+ * phone comes back to `/` from history, bookmarks, or tab restore; the
135
+ * harness fallback seat answers that navigation with its browser-auth 401,
136
+ * and the cookieless mobile flow never holds a browser-auth cookie. The
137
+ * worker owns navigations to `/` instead: network-first through /pair-app
138
+ * (which validates the device cookie and refreshes its presence), the
139
+ * cached shell for offline opens, and a pass-through of the original
140
+ * request when the plugin no longer answers. Plain-HTTP LAN origins are not
141
+ * secure contexts, so the worker never registers there — the LAN reopen
142
+ * path stays "scan a fresh QR", which is cheap in-network.
143
+ *
144
+ * Kept as a plain string (same pattern as the capture script): it must load
145
+ * with no build step, run on every JS engine that ships service workers,
146
+ * and be assertable as source in tests. Bump SHELL_CACHE when the storage
147
+ * layout changes so old caches are pruned on activate.
148
+ */
149
+ export function appServiceWorkerScript() {
150
+ return `'use strict';
151
+ var SHELL_CACHE = 'dsh-remote-shell-v1';
152
+ var SHELL_KEY = '/dsh-remote-shell';
153
+ var APP_URL = '/pair-app';
154
+ self.addEventListener('install', function (event) {
155
+ event.waitUntil(refreshShell().then(function () { return self.skipWaiting(); }));
156
+ });
157
+ self.addEventListener('activate', function (event) {
158
+ event.waitUntil(self.caches.keys().then(function (names) {
159
+ return Promise.all(names.map(function (name) {
160
+ return name === SHELL_CACHE ? Promise.resolve() : self.caches.delete(name);
161
+ }));
162
+ }).then(function () { return self.clients.claim(); }));
163
+ });
164
+ self.addEventListener('fetch', function (event) {
165
+ var request = event.request;
166
+ if (request.method !== 'GET' || request.mode !== 'navigate') return;
167
+ var path;
168
+ try { path = new URL(request.url).pathname; } catch (error) { return; }
169
+ if (path !== '/') return;
170
+ event.respondWith(reopen(event));
171
+ });
172
+ /* Network-first: a live pairing gets the current shell (the /pair-app check
173
+ refreshes lastSeenAt, so every reopen also keeps the session alive); a
174
+ refused shell passes the original navigation through for the harness's
175
+ own response; an unreachable server falls back to the cached shell. */
176
+ function reopen(event) {
177
+ var request = event.request;
178
+ return fetch(APP_URL, { credentials: 'same-origin', cache: 'no-store' }).then(function (response) {
179
+ if (!response.ok) return fetch(request);
180
+ var copy = response.clone();
181
+ event.waitUntil(self.caches.open(SHELL_CACHE).then(function (cache) {
182
+ return cache.put(SHELL_KEY, copy);
183
+ }));
184
+ return response;
185
+ }, function () {
186
+ return self.caches.match(SHELL_KEY).then(function (cached) {
187
+ return cached !== undefined ? cached : fetch(request);
188
+ });
189
+ });
190
+ }
191
+ /* Best-effort shell warm-up and refresh; never rejects. */
192
+ function refreshShell() {
193
+ return fetch(APP_URL, { credentials: 'same-origin', cache: 'no-store' }).then(function (response) {
194
+ if (!response.ok) return undefined;
195
+ var copy = response.clone();
196
+ return self.caches.open(SHELL_CACHE).then(function (cache) {
197
+ return cache.put(SHELL_KEY, copy);
198
+ });
199
+ }, function () { return undefined; });
200
+ }`;
201
+ }
202
+ /**
203
+ * The dead-end guard for a failed /pair-accept and for a dead reopen (the
204
+ * reopen service worker serves this page at `/` when the pairing no longer
205
+ * passes): a device without a live pairing has no harness browser-auth
206
+ * cookie, so a redirect to bare `/` would land on the harness browser-auth
207
+ * 401 page ("authentication required"), which reads like a broken server.
208
+ * Serve a plain bilingual explanation instead; an already-paired device (live
209
+ * device cookie, browser credential redeemed during its first scan) keeps
210
+ * the old behavior and is sent on to the app.
211
+ */
212
+ function pairingFailurePage() {
213
+ return [
214
+ '<!doctype html><html><head><meta charset="utf-8">',
215
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
216
+ '<meta name="referrer" content="no-referrer">',
217
+ '<title>Pairing link invalid</title></head>',
218
+ '<body style="font-family:system-ui,sans-serif;padding:24px;max-width:32em;margin:0 auto;line-height:1.6">',
219
+ '<p><strong>配对已失效,或配对链接已过期。</strong></p>',
220
+ '<p>请在桌面端打开远程控制面板,刷新二维码后重新扫码;重新配对后本机即可恢复访问。</p>',
221
+ '<hr style="border:none;border-top:1px solid #ccc;margin:16px 0">',
222
+ '<p><strong>This pairing has expired or the link is no longer valid.</strong></p>',
223
+ '<p>Open the remote panel on the desktop, refresh the QR code, and scan again — re-pairing restores access on this device.</p>',
224
+ '</body></html>',
225
+ ].join('');
226
+ }
86
227
  /** Route paths (exact matches under /api). */
87
228
  export const PAIR_PATHS = {
88
229
  issue: '/api/pair/issue',
@@ -92,6 +233,16 @@ export const PAIR_PATHS = {
92
233
  heartbeat: '/api/pair/heartbeat',
93
234
  status: '/api/pair/status',
94
235
  events: '/api/pair/events',
236
+ lanBind: '/api/pair/lan-bind',
237
+ /** Top-level accept-and-redirect entry the QR link points at. */
238
+ acceptPage: '/pair-accept',
239
+ /** The cookieless app landing: serves the official shell for a paired device. */
240
+ appPage: '/pair-app',
241
+ /**
242
+ * The reopen service worker (registered by the capture script). Root-level
243
+ * path so its default script-directory scope already covers `/`.
244
+ */
245
+ appServiceWorker: '/pair-app.sw.js',
95
246
  };
96
247
  /**
97
248
  * /api/pair request payload contracts. Each POST endpoint validates its body
@@ -102,7 +253,6 @@ export const PAIR_PATHS = {
102
253
  * are tolerated exactly as the previous manual reads ignored them.
103
254
  */
104
255
  export const issuePayloadSchema = z.object({
105
- workspaceId: z.string().min(1).optional(),
106
256
  address: z.string().min(1).optional(),
107
257
  });
108
258
  export const acceptPayloadSchema = z.object({
@@ -181,10 +331,13 @@ export function makeRoutes(deps) {
181
331
  const events = new PairingEventsStream(service);
182
332
  /** Loopback-only fence: the desktop panel's control endpoints. */
183
333
  const loopbackFence = (req) => isTrustedApiRequest(req, []);
184
- /** Phone-facing fence: loopback, the derived LAN literals, or the configured public host. */
334
+ /** Phone-facing fence: loopback, the service's live LAN literals, or the configured public host. */
185
335
  const lanFence = (req) => {
186
336
  const publicHost = publicHostOf(service.publicBaseUrl);
187
- return isTrustedApiRequest(req, publicHost === undefined ? lanAddresses : [...lanAddresses, publicHost]);
337
+ // The service's LAN bases re-read per request: a hot rebind (the lan-bind
338
+ // toggle) updates them mid-process, and the fence must follow.
339
+ const bases = service.lanAddresses;
340
+ return isTrustedApiRequest(req, publicHost === undefined ? bases : [...bases, publicHost]);
188
341
  };
189
342
  const requireMethod = (req, res, method) => {
190
343
  if (req.method === method)
@@ -197,17 +350,20 @@ export function makeRoutes(deps) {
197
350
  const acceptAttempts = new Map();
198
351
  const ACCEPT_MAX_ATTEMPTS = 10;
199
352
  const ACCEPT_WINDOW_MS = 30_000;
200
- const rateLimitAccept = (req) => {
353
+ /**
354
+ * @param bucket - the POST /api/pair/accept and the GET /pair-accept flows
355
+ * count separately: a QR re-scan (page navigation) must not consume a
356
+ * brute-force budget that belongs to token guessing (and vice versa).
357
+ */
358
+ const rateLimitAccept = (req, bucket) => {
201
359
  const socketIp = req.socket?.remoteAddress ?? 'unknown';
202
- // Behind the auto-tunnel every internet client arrives from 127.0.0.1,
203
- // so a single shared bucket would let one attacker keep the legitimate
204
- // owner rate-limited. Partition the availability bucket by the first
205
- // client-visible XFF hop (set by the tunnel edge): XFF is untrusted for
206
- // authentication and only separates buckets, it never grants access.
360
+ // XFF is honored only for loopback peers (the tunnel edge); see
361
+ // acceptLimitKey. It is untrusted for authentication and never grants
362
+ // access.
207
363
  const forwarded = typeof req.headers['x-forwarded-for'] === 'string'
208
364
  ? (req.headers['x-forwarded-for'].split(',')[0] ?? '').trim()
209
365
  : undefined;
210
- const ip = forwarded === undefined || forwarded === '' ? socketIp : socketIp + '|' + forwarded;
366
+ const ip = acceptLimitKey(socketIp, forwarded, bucket);
211
367
  const nowMs = Date.now();
212
368
  // The map lives as long as the plugin: prune expired windows once the
213
369
  // table grows past a modest size so distinct source IPs (LAN clients,
@@ -239,19 +395,22 @@ export function makeRoutes(deps) {
239
395
  writeJson(res, 400, { ok: false, code: 'bad-payload' });
240
396
  return;
241
397
  }
242
- const { workspaceId, address } = payload;
398
+ const { address } = payload;
243
399
  try {
244
- const { token, expiresAt } = service.issue(workspaceId, address);
400
+ // The QR link is the official Web GUI itself: after the accept round
401
+ // trip every device — phone or PC — boots the desktop SPA (phones get
402
+ // the injected portrait adaptation, PCs the full desktop UI), so the
403
+ // remote surface can never drift from the official one.
404
+ const { token, expiresAt } = service.issue(undefined, address);
245
405
  // The default base is the public (tunneled) URL when configured — a
246
406
  // phone anywhere can reach it — and the first LAN interface otherwise.
247
407
  // An explicit address always names a LAN literal.
248
408
  const base = address === undefined ? (service.publicBaseUrl ?? service.lanBaseUrl) : service.lanBaseUrlFor(address);
249
409
  if (base === undefined)
250
410
  throw new Error('remote-web-ui: base unavailable');
251
- const workspaceQuery = workspaceId === undefined ? '' : `&workspace=${encodeURIComponent(workspaceId)}`;
252
411
  writeJson(res, 200, {
253
412
  ok: true,
254
- url: `${base}/m/?pair=${token}${workspaceQuery}`,
413
+ url: `${base}/pair-accept?pair=${token}`,
255
414
  token,
256
415
  expiresAt,
257
416
  // Every constructible base, so a multi-homed panel can switch the
@@ -279,7 +438,7 @@ export function makeRoutes(deps) {
279
438
  writeJson(res, 403, { ok: false, code: 'forbidden' });
280
439
  return;
281
440
  }
282
- if (rateLimitAccept(req)) {
441
+ if (rateLimitAccept(req, 'api')) {
283
442
  writeJson(res, 429, { ok: false, code: 'rate-limited' });
284
443
  return;
285
444
  }
@@ -390,7 +549,148 @@ export function makeRoutes(deps) {
390
549
  // Snapshot on open: a late-opening panel converges without history.
391
550
  events.push(service.snapshot());
392
551
  };
393
- return [
552
+ /** LAN-bind facts for the settings card; loopback-only, read per request. */
553
+ const handleLanBind = (req, res) => {
554
+ if (!requireMethod(req, res, 'GET'))
555
+ return;
556
+ if (!loopbackFence(req)) {
557
+ writeJson(res, 403, { ok: false, code: 'forbidden' });
558
+ return;
559
+ }
560
+ writeJson(res, 200, { ok: true, ...(deps.lanBindStatus?.() ?? {}) });
561
+ };
562
+ /**
563
+ * The QR entry: navigate here with ?pair=<token>. Sets the device cookie,
564
+ * then redirects to the authenticated home (the connection service's
565
+ * launch-token URL), so a LAN device that has never seen this authority
566
+ * clears the browser-auth gate and boots the paired official UI in one
567
+ * chain: /pair-accept → /?token=<launch> → /. A device that is already
568
+ * authenticated (or loopback) skips straight through the same way.
569
+ */
570
+ const handleAcceptPage = async (req, res) => {
571
+ if (!requireMethod(req, res, 'GET'))
572
+ return;
573
+ if (!lanFence(req)) {
574
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
575
+ res.end('forbidden');
576
+ return;
577
+ }
578
+ if (rateLimitAccept(req, 'page')) {
579
+ res.writeHead(429, { 'content-type': 'text/plain; charset=utf-8' });
580
+ res.end('rate limited');
581
+ return;
582
+ }
583
+ const url = new URL(req.url ?? '/', 'http://pair.invalid');
584
+ const token = url.searchParams.get('pair') ?? '';
585
+ const ua = req.headers['user-agent'];
586
+ const result = token === '' ? { ok: false, code: 'invalid' } : service.accept(token, typeof ua === 'string' ? ua : undefined);
587
+ if (!result.ok) {
588
+ // accept() now refuses only expired/unknown/stopped tokens: a consumed
589
+ // token stays re-usable until its expiry or replacement, so a mobile
590
+ // flow that split across cookie contexts (camera preview, in-app
591
+ // browser, system browser) can re-pair from the same link. A failure
592
+ // here means the link is truly dead: an already-paired device is sent
593
+ // on to the app, everyone else gets the bilingual explanation page
594
+ // instead of the harness 401 dead end.
595
+ const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
596
+ if (deviceId !== undefined && service.hasDevice(deviceId)) {
597
+ // An already-paired device re-opening a dead link goes straight to
598
+ // the cookieless app landing with its live device credential.
599
+ res.writeHead(303, { location: `${appOrigin(req)}/pair-app?device=${encodeURIComponent(deviceId)}`, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
600
+ res.end();
601
+ return;
602
+ }
603
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
604
+ res.end(pairingFailurePage());
605
+ return;
606
+ }
607
+ // Land the paired device on the cookieless app page: the official shell
608
+ // served by this plugin, with the device id in the URL. No harness index
609
+ // gate, no browser-auth cookie hop - the channel gate accepts the device
610
+ // credential whether or not the browser stores cookies.
611
+ res.writeHead(303, {
612
+ location: `${appOrigin(req)}/pair-app?device=${encodeURIComponent(result.deviceId)}`,
613
+ 'cache-control': 'no-store',
614
+ 'referrer-policy': 'no-referrer',
615
+ 'set-cookie': [
616
+ `${service.config.cookieName}=${result.deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`,
617
+ ],
618
+ });
619
+ res.end();
620
+ };
621
+ /**
622
+ * The cookieless app landing. A paired device (device query or live
623
+ * pairing cookie) receives the official index shell patched with the
624
+ * device-capture script; the shell itself is the official document and
625
+ * carries no data, so serving it needs only the device credential.
626
+ */
627
+ const handleAppPage = async (req, res) => {
628
+ if (!requireMethod(req, res, 'GET'))
629
+ return;
630
+ if (!lanFence(req)) {
631
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
632
+ res.end('forbidden');
633
+ return;
634
+ }
635
+ if (rateLimitAccept(req, 'page')) {
636
+ res.writeHead(429, { 'content-type': 'text/plain; charset=utf-8' });
637
+ res.end('rate limited');
638
+ return;
639
+ }
640
+ const url = new URL(req.url ?? '/', 'http://pair.invalid');
641
+ const device = url.searchParams.get('device') ?? '';
642
+ const cookieDevice = readCookie(req.headers.cookie, service.config.cookieName);
643
+ const id = (device !== '' && service.touchDevice(device))
644
+ ? device
645
+ : (cookieDevice !== undefined && service.touchDevice(cookieDevice))
646
+ ? cookieDevice
647
+ : undefined;
648
+ if (id === undefined) {
649
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
650
+ res.end(pairingFailurePage());
651
+ return;
652
+ }
653
+ const html = await deps.indexDocument?.(id).catch(() => undefined);
654
+ if (html === undefined) {
655
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
656
+ res.end('remote device app unavailable');
657
+ return;
658
+ }
659
+ res.writeHead(200, {
660
+ 'content-type': 'text/html; charset=utf-8',
661
+ 'cache-control': 'no-store',
662
+ 'referrer-policy': 'no-referrer',
663
+ 'set-cookie': [
664
+ `${service.config.cookieName}=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`,
665
+ ],
666
+ });
667
+ res.end(patchAppShell(html, id));
668
+ };
669
+ /**
670
+ * The reopen service worker script. Same phone-facing fence as the app
671
+ * page but no rate limit and no device check: the script is inert logic
672
+ * (no secrets, no data), and browsers re-fetch it on navigations for
673
+ * update checks, which must not trip a brute-force budget. no-store keeps
674
+ * the update check meaningful across deploys.
675
+ */
676
+ const handleAppServiceWorker = (req, res) => {
677
+ if (!requireMethod(req, res, 'GET'))
678
+ return;
679
+ if (!lanFence(req)) {
680
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
681
+ res.end('forbidden');
682
+ return;
683
+ }
684
+ res.writeHead(200, {
685
+ 'content-type': 'text/javascript; charset=utf-8',
686
+ 'cache-control': 'no-store',
687
+ // Defensive: the root-level script path already allows a `/` scope,
688
+ // the header keeps that explicit for engines that want it stated.
689
+ 'service-worker-allowed': '/',
690
+ });
691
+ res.end(appServiceWorkerScript());
692
+ };
693
+ const routes = [
394
694
  { kind: 'exact', path: PAIR_PATHS.issue, handler: handleIssue },
395
695
  { kind: 'exact', path: PAIR_PATHS.accept, handler: handleAccept },
396
696
  { kind: 'exact', path: PAIR_PATHS.stop, handler: handleStop },
@@ -399,4 +699,21 @@ export function makeRoutes(deps) {
399
699
  { kind: 'exact', path: PAIR_PATHS.status, handler: handleStatus },
400
700
  { kind: 'exact', path: PAIR_PATHS.events, handler: handleEvents },
401
701
  ];
702
+ if (deps.lanBindStatus !== undefined) {
703
+ routes.push({ kind: 'exact', path: PAIR_PATHS.lanBind, handler: handleLanBind });
704
+ }
705
+ routes.push({ kind: 'exact', path: PAIR_PATHS.acceptPage, handler: handleAcceptPage });
706
+ if (deps.indexDocument !== undefined) {
707
+ routes.push({ kind: 'exact', path: PAIR_PATHS.appPage, handler: handleAppPage });
708
+ // The reopen worker is only meaningful when the app landing it
709
+ // re-serves exists (same condition as /pair-app).
710
+ routes.push({ kind: 'exact', path: PAIR_PATHS.appServiceWorker, handler: handleAppServiceWorker });
711
+ }
712
+ return routes;
713
+ }
714
+ /** The request origin (https when a tunnel edge says so). */
715
+ function appOrigin(req) {
716
+ const proto = req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
717
+ const origin = `http://${req.headers.host ?? '127.0.0.1'}`;
718
+ return proto === 'https' ? origin.replace('http://', 'https://') : origin;
402
719
  }
@@ -1 +1 @@
1
- {"version":3,"file":"tunnel.d.ts","sourceRoot":"","sources":["../../src/tunnel.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"tunnel.d.ts","sourceRoot":"","sources":["../../src/tunnel.ts"],"names":[],"mappings":"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"}
@@ -23,7 +23,13 @@ async function defaultEnsureBinary() {
23
23
  function defaultFactory(targetUrl) {
24
24
  // `--no-autoupdate`: the binary must never upgrade itself out from under
25
25
  // the manager (a self-updated binary would break the pinned lifecycle).
26
- return Tunnel.quick(targetUrl, { '--no-autoupdate': true });
26
+ // `--protocol http2`: the default `auto` prefers QUIC (UDP 7844), which
27
+ // wedges on fake-ip TUN proxies and QUIC-hostile networks — the connector
28
+ // holds the hostname but reports readyConnections: 0 forever (edge 1033),
29
+ // and the auto fallback never fires on a half-open QUIC handshake. http2
30
+ // rides TCP through the same paths reliably (verified live: auto stuck at
31
+ // ready 0 on two fresh registrations; http2 ready immediately).
32
+ return Tunnel.quick(targetUrl, { '--no-autoupdate': true, '--protocol': 'http2' });
27
33
  }
28
34
  /** Node timers. */
29
35
  const nodeTimer = { setTimeout, clearTimeout };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-remote-web-ui",
3
- "description": "Scan-to-pair mobile and PC remote control for the dsh web GUI: QR entry beside the settings button, one-time pairing tokens, a gated /remote/api desktop channel, live device status, and revocable sessions",
4
- "version": "0.3.6",
3
+ "description": "Scan-to-pair remote access for the dsh web GUI that shares one official interface: a QR beside the settings button pairs phones and PCs into the same Web GUI (a portrait-touch adaptation layer for phones, full desktop on PCs) through one-time tokens and revocable device sessions, with a LAN bind toggle, optional Cloudflare tunnel, and one-click family self-update",
4
+ "version": "0.3.9",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -36,14 +36,13 @@
36
36
  "license": "Apache-2.0",
37
37
  "dsh": {
38
38
  "engines": {
39
- "dsh": ">=0.1.1-rc.1"
39
+ "dsh": ">=0.1.2-alpha.1"
40
40
  },
41
41
  "bundle": {
42
42
  "patch": "./cordis.patch.yml"
43
43
  },
44
44
  "client": {
45
45
  "inject": [
46
- "@deepseek-ai/dsh-client-runtime",
47
46
  "@deepseek-ai/dsh-client-connection",
48
47
  "@deepseek-ai/dsh-client-ui-settings",
49
48
  "@deepseek-ai/dsh-client-ui-sidebar"
@@ -63,20 +62,23 @@
63
62
  "react-dom": "^18.2.0"
64
63
  },
65
64
  "devDependencies": {
66
- "@deepseek-ai/cordis": "^4.0.1",
67
- "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
68
- "@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
69
- "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
70
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.1-rc.2",
71
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
72
- "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.1-rc.2",
73
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
74
- "@deepseek-ai/dsh-host-apiproxy": "^0.1.1-rc.2",
75
- "@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
76
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
77
- "@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
78
- "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
79
- "@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
65
+ "@deepseek-ai/cordis": "^4.0.2",
66
+ "@deepseek-ai/dsh-agent": "^0.1.2-alpha.2",
67
+ "@deepseek-ai/dsh-api-remotes": "^0.1.2-alpha.2",
68
+ "@deepseek-ai/dsh-api-session-controller": "^0.1.2-alpha.2",
69
+ "@deepseek-ai/dsh-api-workspace-controller": "^0.1.2-alpha.2",
70
+ "@deepseek-ai/dsh-client-connection": "^0.1.2-alpha.2",
71
+ "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
72
+ "@deepseek-ai/dsh-client-store": "^0.1.2-alpha.2",
73
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2",
74
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-alpha.2",
75
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-alpha.2",
76
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.2-alpha.2",
77
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
78
+ "@deepseek-ai/dsh-commands": "^0.1.2-alpha.2",
79
+ "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
80
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
81
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
80
82
  "@testing-library/dom": "^10.4.1",
81
83
  "@testing-library/react": "^16.3.2",
82
84
  "@types/node": "^22.20.0",
@@ -1,18 +1,19 @@
1
1
  /**
2
2
  * Sidebar footer-seat wrapper for the remote-control entry.
3
3
  *
4
- * Current dsh web shells declare `sidebar.footer.action` (the seat beside the
5
- * settings trigger) instead of the legacy `sidebar.remote` seat this plugin
6
- * was written against. The footer seat supplies `{ wide }` but not the
7
- * `useWorkspaces` projection hook, so this wrapper substitutes a
8
- * workspace-agnostic selector: pairing without a deep-linked workspace is
9
- * fully supported by the host `/api/pair` routes.
4
+ * Current dsh web shells declare `sidebar.footer.action` (the seat beside
5
+ * the settings trigger) instead of the legacy `sidebar.remote` seat this
6
+ * plugin was written against. The footer seat supplies `{ wide }`; the
7
+ * pairing link is origin-agnostic, so no workspace source is involved.
10
8
  */
11
9
  import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
12
10
  import { RemoteEntry } from './RemoteEntry.tsx'
13
11
 
14
- /** Entry props: the footer seat's column state + the standard locale seat. */
15
- export type FooterRemoteEntryProps = PropsLocale<'remote'> & { wide: boolean }
12
+ /** Entry props: the footer seat's column state and the standard locale seat. */
13
+ export type FooterRemoteEntryProps = PropsLocale<'remote'> & {
14
+ /** Whether the sidebar renders wide content (false = 56px rail). */
15
+ wide: boolean
16
+ }
16
17
 
17
18
  /**
18
19
  * Render the remote-control trigger + pairing panel from the footer seat.
@@ -23,8 +24,6 @@ export function FooterRemoteEntry(props: FooterRemoteEntryProps) {
23
24
  return (
24
25
  <RemoteEntry
25
26
  wide={props.wide}
26
- useWorkspaces={() => undefined as never}
27
- useSessions={() => undefined as never}
28
27
  t={props.t}
29
28
  />
30
29
  )