@linxin666/dsh-remote-web-ui 0.4.5 → 0.4.7

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 (65) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +13 -13
  3. package/README.zh.md +13 -13
  4. package/lib/client.js +25 -92
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +408 -53
  7. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  8. package/lib/types/client/RemoteEntry.js +3 -15
  9. package/lib/types/client/RemotePanel.d.ts +1 -9
  10. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  11. package/lib/types/client/RemotePanel.js +7 -4
  12. package/lib/types/client/locales.d.ts +4 -8
  13. package/lib/types/client/locales.d.ts.map +1 -1
  14. package/lib/types/client/locales.js +4 -8
  15. package/lib/types/client/mobile-adapt.d.ts.map +1 -1
  16. package/lib/types/client/mobile-adapt.js +30 -3
  17. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  18. package/lib/types/client/plugin-card-seat.d.ts.map +1 -1
  19. package/lib/types/client/plugin-card-seat.js +2 -0
  20. package/lib/types/client/settings-entry-form.d.ts.map +1 -1
  21. package/lib/types/client/settings-entry-form.js +0 -1
  22. package/lib/types/detached-work.d.ts +7 -7
  23. package/lib/types/detached-work.d.ts.map +1 -1
  24. package/lib/types/detached-work.js +9 -8
  25. package/lib/types/index.d.ts +14 -0
  26. package/lib/types/index.d.ts.map +1 -1
  27. package/lib/types/index.js +67 -17
  28. package/lib/types/lan-source.d.ts +65 -0
  29. package/lib/types/lan-source.d.ts.map +1 -0
  30. package/lib/types/lan-source.js +113 -0
  31. package/lib/types/lan.d.ts +49 -3
  32. package/lib/types/lan.d.ts.map +1 -1
  33. package/lib/types/lan.js +120 -6
  34. package/lib/types/remote-api.d.ts +7 -2
  35. package/lib/types/remote-api.d.ts.map +1 -1
  36. package/lib/types/remote-api.js +17 -2
  37. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  38. package/lib/types/remote-channel-boot.js +4 -1
  39. package/lib/types/routes.d.ts +71 -9
  40. package/lib/types/routes.d.ts.map +1 -1
  41. package/lib/types/routes.js +157 -27
  42. package/lib/types/tunnel-plan.d.ts +8 -2
  43. package/lib/types/tunnel-plan.d.ts.map +1 -1
  44. package/lib/types/tunnel-plan.js +9 -1
  45. package/package.json +20 -25
  46. package/src/client/RemoteEntry.tsx +2 -18
  47. package/src/client/RemotePanel.tsx +9 -43
  48. package/src/client/locales.ts +4 -8
  49. package/src/client/mobile-adapt.ts +30 -3
  50. package/src/client/plugin-card-seat.ts +7 -0
  51. package/src/client/remote.module.css +2 -48
  52. package/src/client/settings-entry-form.ts +0 -1
  53. package/src/detached-work.ts +9 -8
  54. package/src/index.ts +77 -17
  55. package/src/lan-source.ts +132 -0
  56. package/src/lan.ts +126 -6
  57. package/src/remote-api.ts +24 -4
  58. package/src/remote-channel-boot.ts +4 -1
  59. package/src/routes.ts +178 -27
  60. package/src/tunnel-plan.ts +9 -2
  61. package/lib/invariant.js +0 -21
  62. package/lib/types/invariant.d.ts +0 -16
  63. package/lib/types/invariant.d.ts.map +0 -1
  64. package/lib/types/invariant.js +0 -23
  65. package/src/invariant.ts +0 -32
package/src/lan.ts CHANGED
@@ -5,17 +5,137 @@
5
5
  * with, so the same non-internal IPv4 derivation applies here — an external
6
6
  * plugin cannot read the CLI's sampled snapshot, but the fence accepts
7
7
  * exactly these literals, which is the property that matters.
8
+ *
9
+ * One derivation is deliberately narrower than the CLI's: everything this
10
+ * module returns is advertised to a phone (the QR base, the landing page's
11
+ * LAN probe, and the fence's trusted-literal list), and an advertised literal
12
+ * the peer can never reach costs a phone the whole pairing attempt. A macOS
13
+ * host running a fake-IP proxy (Clash/Surge/Mihomo) samples its TUN gateway as
14
+ * `198.18.0.1`, which is a benchmarking-range address no LAN peer can route
15
+ * to; the old derivation advertised it like any other interface. The filter
16
+ * below drops the interface kinds and address ranges a peer cannot reach, and
17
+ * keeps everything else — RFC1918, ULA-side IPv4 and a routable public literal
18
+ * (a VPS whose NIC is its only reachable base must stay advertisable).
19
+ *
20
+ * @module @linxin666/dsh-remote-web-ui/lan
8
21
  */
9
22
 
10
23
  import { networkInterfaces } from 'node:os'
11
24
 
12
25
  /**
13
- * Non-internal IPv4 interface addresses of this machine — the IP-literal
14
- * authorities an all-interfaces bind is reachable by on the LAN.
26
+ * Interface-name stems of tunnels and VPN adapters: interfaces whose address
27
+ * is a private point-to-point endpoint on this machine, never an authority a
28
+ * peer on the local network can dial. Matching is case-insensitive and
29
+ * anchored at the start, so `utun0`/`utun4` both match while a hypothetical
30
+ * `en0utun` does not. Windows reports adapter *descriptions* rather than POSIX
31
+ * stems (`Tailscale`, `WireGuard Tunnel`, `OpenVPN Wintun`), which the same
32
+ * list covers by prefix.
33
+ */
34
+ const TUNNEL_INTERFACE_PREFIXES: readonly string[] = [
35
+ 'utun', 'tun', 'tap', 'ppp', 'ipsec', 'wg', 'wireguard', 'tailscale', 'nordlynx', 'proton', 'openvpn', 'zt',
36
+ ]
37
+
38
+ /**
39
+ * Interface-name stems of virtual machine and container bridges: `docker0`,
40
+ * `vethXXXX`, `br-<id>`, `virbr0`, `vmnet8`, `vboxnet0` and the Hyper-V
41
+ * `vEthernet` adapters. Their RFC1918 addresses belong to a NAT the host
42
+ * shares with its own guests, so a phone on the physical LAN cannot reach
43
+ * them; `bridge0`/`br0` are deliberately absent because a real bridged LAN
44
+ * (macOS Internet Sharing) is exactly the address a phone does reach.
45
+ */
46
+ const VIRTUAL_INTERFACE_PREFIXES: readonly string[] = [
47
+ 'docker', 'veth', 'virbr', 'vmnet', 'vboxnet', 'vethernet', 'br-',
48
+ ]
49
+
50
+ /**
51
+ * IPv4 ranges no peer on the local network can reach. The list is a denial
52
+ * list rather than an RFC1918 allowlist on purpose: a host whose only
53
+ * interface is a routable public literal (a VPS, a standalone server) would
54
+ * otherwise lose its only constructible base and fall back to the
55
+ * `lan-required` banner.
56
+ * @param octets - the four address octets, in order.
57
+ * @returns true when an address in this range must not be advertised.
58
+ */
59
+ function isUnreachableIPv4(octets: readonly number[]): boolean {
60
+ const [a = 0, b = 0] = octets
61
+ // 0.0.0.0/8 "this network": never a usable address.
62
+ if (a === 0) return true
63
+ // 127.0.0.0/8 loopback (node already marks it internal; kept as the belt).
64
+ if (a === 127) return true
65
+ // 100.64.0.0/10 carrier-grade NAT: Tailscale and ISP-level NAT hand out
66
+ // addresses here, and a peer on the physical LAN cannot route to them.
67
+ if (a === 100 && b >= 64 && b <= 127) return true
68
+ // 169.254.0.0/16 link-local (APIPA / self-assigned).
69
+ if (a === 169 && b === 254) return true
70
+ // 198.18.0.0/15 benchmarking: the range a fake-IP TUN gateway takes.
71
+ if (a === 198 && (b === 18 || b === 19)) return true
72
+ // 224.0.0.0/4 multicast and 240.0.0.0/4 reserved (includes the broadcast).
73
+ if (a >= 224) return true
74
+ return false
75
+ }
76
+
77
+ /**
78
+ * Whether an interface name belongs to a tunnel, VPN adapter, or virtual
79
+ * machine/container bridge, so its address is not an authority a LAN peer
80
+ * can dial.
81
+ * @param name - the interface name from `os.networkInterfaces()`.
82
+ * @returns true when the interface must not be advertised.
83
+ */
84
+ export function isUnadvertisableInterface(name: string): boolean {
85
+ const lower = name.toLowerCase()
86
+ return [...TUNNEL_INTERFACE_PREFIXES, ...VIRTUAL_INTERFACE_PREFIXES].some(prefix => lower.startsWith(prefix))
87
+ }
88
+
89
+ /**
90
+ * Whether an IPv4 literal can be advertised to a pairing peer.
91
+ * @param address - a dotted-quad IPv4 literal.
92
+ * @returns true when a peer could plausibly reach it.
93
+ */
94
+ export function isAdvertisableIPv4(address: string): boolean {
95
+ const parts = address.split('.')
96
+ if (parts.length !== 4) return false
97
+ const octets = parts.map(part => (/^\d{1,3}$/.test(part) ? Number(part) : Number.NaN))
98
+ if (octets.some(octet => !Number.isInteger(octet) || octet > 255)) return false
99
+ return !isUnreachableIPv4(octets)
100
+ }
101
+
102
+ /** One sampled interface address. */
103
+ export interface LanCandidate {
104
+ /** The interface name the address was sampled from. */
105
+ iface: string
106
+ /** The IPv4 literal. */
107
+ address: string
108
+ }
109
+
110
+ /** The `networkInterfaces()` shape, injectable so the filter is testable. */
111
+ export type NetworkInterfaceMap = ReturnType<typeof networkInterfaces>
112
+
113
+ /**
114
+ * Every IPv4 interface address this machine can advertise to a pairing peer:
115
+ * non-internal, on a non-tunnel and non-virtual interface, outside the ranges
116
+ * no peer can route to.
117
+ * @param interfaces - the sampled interface map (defaults to the live one).
118
+ * @returns the candidates in interface order (possibly empty).
119
+ */
120
+ export function lanIPv4Candidates(interfaces: NetworkInterfaceMap = networkInterfaces()): LanCandidate[] {
121
+ const candidates: LanCandidate[] = []
122
+ for (const [name, addresses] of Object.entries(interfaces)) {
123
+ if (isUnadvertisableInterface(name)) continue
124
+ for (const iface of addresses ?? []) {
125
+ if (iface.family !== 'IPv4' || iface.internal) continue
126
+ if (!isAdvertisableIPv4(iface.address)) continue
127
+ candidates.push({ iface: name, address: iface.address })
128
+ }
129
+ }
130
+ return candidates
131
+ }
132
+
133
+ /**
134
+ * Non-internal, reachable IPv4 interface addresses of this machine — the
135
+ * IP-literal authorities an all-interfaces bind is reachable by on the LAN.
136
+ * @param interfaces - the sampled interface map (defaults to the live one).
15
137
  * @returns the addresses in interface order (possibly empty).
16
138
  */
17
- export function lanIPv4Addresses(): string[] {
18
- return Object.values(networkInterfaces()).flat()
19
- .filter((iface): iface is NonNullable<typeof iface> => { return iface !== undefined && iface.family === 'IPv4' && !iface.internal })
20
- .map(iface => iface.address)
139
+ export function lanIPv4Addresses(interfaces: NetworkInterfaceMap = networkInterfaces()): string[] {
140
+ return lanIPv4Candidates(interfaces).map(candidate => candidate.address)
21
141
  }
package/src/remote-api.ts CHANGED
@@ -73,8 +73,13 @@ function isSafeSegment(segment: string): boolean {
73
73
  export interface RemoteApiDeps {
74
74
  /** The pairing service (device gate + cookie name). */
75
75
  service: PairingService
76
- /** Local webServer port the loopback proxy connects to. */
77
- port: number
76
+ /**
77
+ * Local webServer port the loopback proxy connects to, read per request:
78
+ * `undefined` before the listener binds (a recompose pass can run this
79
+ * apply ahead of the bind), in which case the handlers refuse with 503
80
+ * instead of minting an `:undefined` URL.
81
+ */
82
+ port: () => number | undefined
78
83
  /**
79
84
  * The process's inner browser-auth credential attached to re-issued
80
85
  * requests (the connection plugin's /api route enforces that cookie and
@@ -140,7 +145,7 @@ export function pairedDeviceIdOf(req: IncomingMessage, service: PairingService):
140
145
  * @returns the routes to register on webServer.
141
146
  */
142
147
  export function makeRemoteApiRoutes(deps: RemoteApiDeps): WebRoute[] {
143
- const { service, port } = deps
148
+ const { service } = deps
144
149
 
145
150
  const handler = (req: IncomingMessage, res: ServerResponse): void => {
146
151
  // Paired credential first — before any bytes are buffered or forwarded.
@@ -175,6 +180,14 @@ export function makeRemoteApiRoutes(deps: RemoteApiDeps): WebRoute[] {
175
180
  return
176
181
  }
177
182
 
183
+ const port = deps.port()
184
+ if (port === undefined) {
185
+ // The listener has not bound yet (a recompose pass): refuse instead of
186
+ // minting an http://127.0.0.1:undefined target; the desktop retries.
187
+ req.resume()
188
+ envelopeError(res, 503, 'invalid-request', 'port-unbound', 'the local web server port is not bound yet; retry shortly')
189
+ return
190
+ }
178
191
  proxyLoopbackHttp(req, res, port, `${inner}${url.search}`, deps.auth)
179
192
  }
180
193
 
@@ -217,7 +230,7 @@ export function upgradeInnerPath(reqUrl: string | undefined, fallbackPath: strin
217
230
  * @returns the upgrade routes to register on webServer.
218
231
  */
219
232
  export function makeRemoteApiUpgradeRoutes(deps: RemoteApiDeps): WebUpgradeRoute[] {
220
- const { service, port } = deps
233
+ const { service } = deps
221
234
 
222
235
  const handlerFor = (fallbackPath: string) => (req: IncomingMessage, socket: Duplex, head: Buffer): void => {
223
236
  // WebSocket handshakes cannot carry headers from the Web API, so the
@@ -234,6 +247,13 @@ export function makeRemoteApiUpgradeRoutes(deps: RemoteApiDeps): WebUpgradeRoute
234
247
  socket.destroy()
235
248
  return
236
249
  }
250
+ const port = deps.port()
251
+ if (port === undefined) {
252
+ // Same unbound-port refusal as the HTTP leg: no :undefined handshake.
253
+ socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n')
254
+ socket.destroy()
255
+ return
256
+ }
237
257
  const inner = upgradeInnerPath(req.url, fallbackPath)
238
258
  const denied = loopbackOnlyDenial(inner.split('?')[0] ?? inner)
239
259
  if (denied !== undefined) {
@@ -204,7 +204,7 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
204
204
  /** Boot-watchdog latch: one self-reload per session while the boot is broken. */
205
205
  export const BOOT_WATCHDOG_KEY = 'dsh-remote-boot-reload'
206
206
 
207
- /** Total wait for the app's first conversation surface before the reload. */
207
+ /** Post-load wait for the app's first conversation surface before the reload. */
208
208
  const BOOT_WATCHDOG_WAIT_MS = 15_000
209
209
 
210
210
  /** Watchdog poll cadence. */
@@ -227,6 +227,9 @@ export function buildBootWatchdogScript(waitMs = BOOT_WATCHDOG_WAIT_MS, pollMs =
227
227
  ';function wBoot(){try{return !!(w.document&&w.document.querySelector&&(w.document.querySelector("[data-conversation-scroll]")||w.document.querySelector("[data-slot=\\"conversation\\"]")))}catch(e){return false}}' +
228
228
  'function wTick(n){try{' +
229
229
  'if(wBoot()){try{w.sessionStorage.removeItem(' + key + ')}catch(e){}return}' +
230
+ // Parse-time installation must not spend the recovery budget downloading
231
+ // the initial scripts/styles over a slow remote link (issue #1845).
232
+ 'if(!w.document||w.document.readyState!=="complete"){w.setTimeout(function(){wTick(0)},' + pollMs + ');return}' +
230
233
  'if(n<' + waitMs + '){w.setTimeout(function(){wTick(n+' + pollMs + ')},' + pollMs + ');return}' +
231
234
  'var done=false;try{done=w.sessionStorage.getItem(' + key + ')==="1"}catch(e){}' +
232
235
  'if(done)return;' +
package/src/routes.ts CHANGED
@@ -29,6 +29,7 @@ import { isLoopbackClient, readCookie } from './gate.ts'
29
29
  import { readJsonBody, writeJson } from './http.ts'
30
30
  import { createPairGrantStore, type PairGrantStore } from './pair-grant.ts'
31
31
  import { REMOTE_HOST_GRANT_GLOBAL } from './remote-channel-rules.ts'
32
+ import { appShellLanSwitchScript, type LanSourceHop } from './lan-source.ts'
32
33
 
33
34
  /**
34
35
  * Browser-trust fence for the /api/pair routes, mirroring the connection
@@ -293,19 +294,27 @@ const COOKIE_MAX_AGE_SEC = 365 * 24 * 60 * 60
293
294
  * The cookieless device credential: pass the device id from the /pair-app
294
295
  * URL into sessionStorage (and localStorage for tab reloads) before any app
295
296
  * script runs - same key the boot patch and the channel gate read. The
296
- * replaceState to '/' hides the credential URL from the address bar and
297
- * leaves the SPA at its canonical root path. The reopen service worker is
298
- * registered in the same breath: later navigations to '/' (history,
299
- * bookmark, tab restore) must not fall through to the harness index gate,
300
- * which the cookieless flow can never satisfy.
297
+ * replaceState drops the one-time grant from the address bar and leaves the
298
+ * SPA on a path this plugin answers again; which path depends on the origin.
299
+ *
300
+ * A secure context registers the reopen service worker in the same breath,
301
+ * and that worker owns navigations to '/', so the SPA keeps its canonical
302
+ * root. A plain-HTTP LAN origin is never a secure context, so the worker
303
+ * cannot exist there and '/' falls through to the harness index gate the
304
+ * cookieless flow can never satisfy (issue #1867). The address bar therefore
305
+ * keeps the plugin-owned landing path, which re-validates the device
306
+ * credential on every reload and serves the bilingual re-pair page once the
307
+ * pairing is gone. Both targets are bare paths: the one-time grant never
308
+ * survives the rewrite, and no credential is ever left in the URL.
301
309
  */
302
310
  export const APP_DEVICE_STORAGE_KEY = 'dsh-remote-device'
303
311
 
304
312
  export function appShellCaptureScript(deviceId: string): string {
305
313
  const safeId = JSON.stringify(deviceId)
306
314
  const grantGlobal = JSON.stringify(REMOTE_HOST_GRANT_GLOBAL)
315
+ const reopenPath = JSON.stringify(PAIR_PATHS.appPage)
307
316
  const register = `try{if('serviceWorker' in navigator){navigator.serviceWorker.register(${JSON.stringify(PAIR_PATHS.appServiceWorker)},{scope:'/'}).catch(function(e){})}}catch(e){}`
308
- return `<script>(function(){try{sessionStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});}catch(e){}try{history.replaceState(null,'','/')}catch(e){}try{window[${grantGlobal}]=true}catch(e){}${register}})()</script>`
317
+ return `<script>(function(){try{sessionStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});}catch(e){}try{history.replaceState(null,'',window.isSecureContext?'/':${reopenPath})}catch(e){}try{window[${grantGlobal}]=true}catch(e){}${register}})()</script>`
309
318
  }
310
319
 
311
320
  /**
@@ -315,9 +324,19 @@ export function appShellCaptureScript(deviceId: string): string {
315
324
  * hook only when this server-served marker is already set (see
316
325
  * remote-channel-boot.ts), so host mode is granted by the device-gated landing
317
326
  * instead of being asserted from the origin.
327
+ *
328
+ * A landing reached on a base other than the machine's own LAN origin also
329
+ * carries the source-decision script (lan-source.ts): the phone decides at
330
+ * connect time whether a LAN candidate answers, and switches its origin when
331
+ * one does. It runs after the capture script so the granted page is fully
332
+ * usable before the probe's outcome can move it.
333
+ * @param html - the official index document.
334
+ * @param deviceId - the device session this landing is being served for.
335
+ * @param hop - the LAN candidates to probe, when a switch is possible.
336
+ * @returns the patched document.
318
337
  */
319
- export function patchAppShell(html: string, deviceId: string): string {
320
- const script = appShellCaptureScript(deviceId)
338
+ export function patchAppShell(html: string, deviceId: string, hop?: LanSourceHop): string {
339
+ const script = appShellCaptureScript(deviceId) + (hop === undefined ? '' : appShellLanSwitchScript(hop))
321
340
  const head = /<head[^>]*>/i.exec(html)
322
341
  if (head === null) return script + html
323
342
  const end = head.index + head[0].length
@@ -332,9 +351,10 @@ export function patchAppShell(html: string, deviceId: string): string {
332
351
  * worker owns navigations to `/` instead: network-first through /pair-app
333
352
  * (which validates the device cookie and refreshes its presence), the
334
353
  * cached shell for offline opens, and a pass-through of the original
335
- * request when the plugin no longer answers. Plain-HTTP LAN origins are not
336
- * secure contexts, so the worker never registers there — the LAN reopen
337
- * path stays "scan a fresh QR", which is cheap in-network.
354
+ * request when the plugin no longer answers. A plain-HTTP LAN origin is
355
+ * never a secure context, so the worker cannot exist there; the capture
356
+ * script keeps the plugin-owned landing path in the address bar instead,
357
+ * and that landing re-validates the device credential on every reload.
338
358
  *
339
359
  * Kept as a plain string (same pattern as the capture script): it must load
340
360
  * with no build step, run on every JS engine that ships service workers,
@@ -435,6 +455,12 @@ export const PAIR_PATHS = {
435
455
  acceptPage: '/pair-accept',
436
456
  /** The cookieless app landing: serves the official shell for a paired device. */
437
457
  appPage: '/pair-app',
458
+ /**
459
+ * The landing page's LAN reachability beacon (lan-source.ts): an inert
460
+ * no-cors GET target, so a probe proves the origin answers without touching
461
+ * a credential or any state.
462
+ */
463
+ ping: '/pair-ping',
438
464
  /**
439
465
  * The reopen service worker (registered by the capture script). Root-level
440
466
  * path so its default script-directory scope already covers `/`.
@@ -527,12 +553,65 @@ export class PairingEventsStream {
527
553
  }
528
554
  }
529
555
 
556
+ /**
557
+ * The manual override of the automatic network choice, for the case where the
558
+ * phone's own probe misjudges (a firewall that answers the reachability ping
559
+ * but not the page, or a LAN the probe cannot see).
560
+ *
561
+ * - `auto` (default): the QR link names the public base when one is configured
562
+ * and the landing page probes the LAN candidates, switching to the first one
563
+ * that answers.
564
+ * - `public`: the public base only, and the landing page never probes. For a
565
+ * deployment where the LAN hop is undesirable.
566
+ * - `lan`: the first LAN literal directly, with no public hop. For a phone that
567
+ * is known to sit on this machine's own network.
568
+ */
569
+ export type PairingSourcePreference = 'auto' | 'public' | 'lan'
570
+
571
+ /**
572
+ * The accepted {@link PairingSourcePreference} values, shared with the config
573
+ * schema so the patch that admits a value and the handler that reads it cannot
574
+ * drift apart.
575
+ */
576
+ export const PAIRING_SOURCE_OPTIONS: readonly PairingSourcePreference[] = ['auto', 'public', 'lan']
577
+
578
+ /**
579
+ * The LAN origins a landing page served from one authority may hop to: every
580
+ * LAN base the service advertises except the authority serving the request
581
+ * (hopping to the current origin would be a no-op), normalized through `URL`
582
+ * so a default port compares equal to `location.origin`.
583
+ * @param lanBases - the advertised LAN base URLs, in interface order.
584
+ * @param currentOrigin - the origin serving the landing page.
585
+ * @returns the distinct candidate origins, in interface order.
586
+ */
587
+ export function lanSourceCandidates(lanBases: readonly string[], currentOrigin: string): string[] {
588
+ const origins: string[] = []
589
+ for (const base of lanBases) {
590
+ let origin: string
591
+ try {
592
+ origin = new URL(base).origin
593
+ } catch {
594
+ continue
595
+ }
596
+ if (origin === currentOrigin || origins.includes(origin)) continue
597
+ origins.push(origin)
598
+ }
599
+ return origins
600
+ }
601
+
530
602
  /** Route-family dependencies (test seam). */
531
603
  export interface PairRoutesDeps {
532
604
  /** The pairing service. */
533
605
  service: PairingService
534
606
  /** Current desktop gate policy, re-read for every status response. */
535
607
  requirePairingForLan?: boolean | (() => boolean)
608
+ /**
609
+ * The manual override of the automatic network choice
610
+ * ({@link PairingSourcePreference}), re-read per request. Defaults to
611
+ * `auto`: the QR link names the public base and the landing page decides
612
+ * whether to switch to a LAN origin.
613
+ */
614
+ pairingSource?: () => PairingSourcePreference
536
615
  /**
537
616
  * LAN-bind facts for the settings card (managed patch block state, live
538
617
  * bind host/port, firewall summary). Re-read per request so a hot-reloaded
@@ -568,6 +647,28 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
568
647
  const pairingRequired = (): boolean => typeof requirePairingForLan === 'function'
569
648
  ? requirePairingForLan()
570
649
  : requirePairingForLan
650
+ /** The manual network override, re-read per request (default: automatic). */
651
+ const sourcePreference = (): PairingSourcePreference => deps.pairingSource?.() ?? 'auto'
652
+ /**
653
+ * Every authority this surface serves, as {@link isTrustedHost} wants them
654
+ * (bare `host` or `host:port` literals). The LAN literals are re-read per
655
+ * request: a hot rebind (the lan-bind toggle) updates them mid-process, and
656
+ * both the fence and the reachability beacon must follow.
657
+ */
658
+ const servedAuthorities = (): string[] => {
659
+ const publicHost = publicHostOf(service.publicBaseUrl)
660
+ const extraHosts = typeof deps.trustedHosts === 'function' ? deps.trustedHosts() : (deps.trustedHosts ?? [])
661
+ return [
662
+ ...service.lanAddresses,
663
+ ...(publicHost !== undefined ? [publicHost] : []),
664
+ ...extraHosts,
665
+ ...dynamicTrustedHosts,
666
+ ]
667
+ }
668
+ /** Every LAN base URL the service currently advertises, in interface order. */
669
+ const lanBaseUrls = (): string[] => service.lanAddresses
670
+ .map(address => service.lanBaseUrlFor(address))
671
+ .filter((base): base is string => base !== undefined)
571
672
  const grants = deps.grants ?? createPairGrantStore()
572
673
  const events = new PairingEventsStream(service)
573
674
  const dynamicTrustedHosts = new Set<string>()
@@ -581,17 +682,7 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
581
682
  * pairing entry pages), never for an API or subresource request.
582
683
  */
583
684
  const lanFence = (req: IncomingMessage, navigation = false): boolean => {
584
- const publicHost = publicHostOf(service.publicBaseUrl)
585
- // The service's LAN bases re-read per request: a hot rebind (the lan-bind
586
- // toggle) updates them mid-process, and the fence must follow.
587
- const bases = service.lanAddresses
588
- const extraHosts = typeof deps.trustedHosts === 'function' ? deps.trustedHosts() : (deps.trustedHosts ?? [])
589
- const combined = [
590
- ...bases,
591
- ...(publicHost !== undefined ? [publicHost] : []),
592
- ...extraHosts,
593
- ...dynamicTrustedHosts,
594
- ]
685
+ const combined = servedAuthorities()
595
686
  if (navigation && isTopLevelDocumentNavigation(req) ? isTrustedHost(req, combined) : isTrustedApiRequest(req, combined)) return true
596
687
 
597
688
  // If request carries a valid paired device cookie from a private LAN host (e.g. after service restart), trust and remember it
@@ -702,10 +793,23 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
702
793
  // the injected portrait adaptation, PCs the full desktop UI), so the
703
794
  // remote surface can never drift from the official one.
704
795
  const { token, expiresAt } = service.issue(undefined, address)
705
- // The default base is the public (tunneled) URL when configured — a
706
- // phone anywhere can reach it — and the first LAN interface otherwise.
707
- // An explicit address always names a LAN literal.
708
- const base = address === undefined ? (service.publicBaseUrl ?? service.lanBaseUrl) : service.lanBaseUrlFor(address)
796
+ // The advertised base is the public (tunneled/relayed) one whenever it is
797
+ // configured: it is the only base a phone anywhere can reach, so the QR
798
+ // never depends on the scanning phone being on this machine's network.
799
+ // The landing page then decides at connect time whether to move to a LAN
800
+ // origin (lan-source.ts). `lan` is the manual override that pins the QR to
801
+ // a LAN literal; an explicit address always names one.
802
+ const preference = sourcePreference()
803
+ const base = address !== undefined
804
+ ? service.lanBaseUrlFor(address)
805
+ : preference === 'lan'
806
+ ? service.lanBaseUrl
807
+ : preference === 'public'
808
+ // Pinned to public: with none configured there is no satisfiable
809
+ // link, which the caller reports as lan-required rather than
810
+ // silently advertising a base the override excluded.
811
+ ? service.publicBaseUrl
812
+ : (service.publicBaseUrl ?? service.lanBaseUrl)
709
813
  if (base === undefined) throw new Error('remote-web-ui: base unavailable')
710
814
  writeJson(res, 200, {
711
815
  ok: true,
@@ -992,7 +1096,50 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
992
1096
  'referrer-policy': 'no-referrer',
993
1097
  'set-cookie': [deviceCookie(req, service.config.cookieName, id)],
994
1098
  })
995
- res.end(patchAppShell(html, id))
1099
+ res.end(patchAppShell(html, id, lanHop(req, id)))
1100
+ }
1101
+
1102
+ /**
1103
+ * The LAN hop this landing may take, or undefined when there is nothing to
1104
+ * decide. Three conditions have to hold together: the automatic preference
1105
+ * (both overrides answer the question already — `lan` serves the QR from a
1106
+ * LAN origin, `public` forbids the move), at least one LAN candidate other
1107
+ * than this origin, and a fresh one-time grant for the target landing. The
1108
+ * grant is what lets the target origin serve the shell: the device cookie is
1109
+ * origin-scoped, so the LAN origin has no credential of its own, and the
1110
+ * grant table lives in this process regardless of which interface answered.
1111
+ * The candidate set is carried to the client as origins (lan-source.ts
1112
+ * compares them against `location.origin`).
1113
+ * @param req - the landing request (its authority is the current origin).
1114
+ * @param deviceId - the device session the landing is served for.
1115
+ * @returns the hop payload, or undefined when no switch is possible.
1116
+ */
1117
+ const lanHop = (req: IncomingMessage, deviceId: string): LanSourceHop | undefined => {
1118
+ if (sourcePreference() !== 'auto') return undefined
1119
+ const candidates = lanSourceCandidates(lanBaseUrls(), appOrigin(req))
1120
+ if (candidates.length === 0) return undefined
1121
+ return { candidates, path: PAIR_PATHS.appPage, pingPath: PAIR_PATHS.ping, grant: grants.issue(deviceId).grant }
1122
+ }
1123
+
1124
+ /**
1125
+ * The landing page's LAN reachability beacon: a 204 for a Host this plugin
1126
+ * serves, a 403 otherwise. It carries no credential and no state — a phone
1127
+ * that reaches it has proved the origin answers, which is all the source
1128
+ * decision needs, and the fence's browser-marker half is deliberately not
1129
+ * applied here: the probe is cross-site by construction (a page on the
1130
+ * public origin fetching a LAN origin), so the marker check would turn every
1131
+ * probe into the same 403 that means "not ours" and make the two
1132
+ * indistinguishable.
1133
+ */
1134
+ const handlePing = (req: IncomingMessage, res: ServerResponse): void => {
1135
+ if (!requireMethod(req, res, 'GET')) return
1136
+ if (!isTrustedHost(req, servedAuthorities())) {
1137
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
1138
+ res.end()
1139
+ return
1140
+ }
1141
+ res.writeHead(204, { 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' })
1142
+ res.end()
996
1143
  }
997
1144
 
998
1145
  /**
@@ -1032,6 +1179,10 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
1032
1179
  routes.push({ kind: 'exact', path: PAIR_PATHS.lanBind, handler: handleLanBind })
1033
1180
  }
1034
1181
  routes.push({ kind: 'exact', path: PAIR_PATHS.acceptPage, handler: handleAcceptPage })
1182
+ // The beacon is independent of the app landing: a deployment with no
1183
+ // indexDocument still advertises LAN bases, and a landing served some other
1184
+ // way may still probe them.
1185
+ routes.push({ kind: 'exact', path: PAIR_PATHS.ping, handler: handlePing })
1035
1186
  if (deps.indexDocument !== undefined) {
1036
1187
  routes.push({ kind: 'exact', path: PAIR_PATHS.appPage, handler: handleAppPage })
1037
1188
  // The reopen worker is only meaningful when the app landing it
@@ -28,14 +28,21 @@ export type TunnelPlan =
28
28
  * token AND a valid public hostname (the token does not carry the hostname,
29
29
  * so the QR base and the fence trust would have no source without it);
30
30
  * otherwise no tunnel and the manual public base applies.
31
+ *
32
+ * The quick target URL names the local webServer port, so a plan made before
33
+ * the listener binds cannot be quick: it comes back `off` and the caller —
34
+ * which can observe the port settle — re-plans. The planner itself never
35
+ * emits an `http://127.0.0.1:undefined` target.
31
36
  * @param value - the resolved settings slice that drives the tunnel.
32
- * @param port - the local webServer port the tunnel forwards to.
37
+ * @param port - the local webServer port the tunnel forwards to (undefined
38
+ * before the listener binds).
33
39
  */
34
40
  export function tunnelPlanOf(
35
41
  value: { autoTunnel?: boolean; tunnelToken?: string; publicBaseUrl?: string },
36
- port: number,
42
+ port: number | undefined,
37
43
  ): TunnelPlan {
38
44
  if (value.autoTunnel === true) {
45
+ if (port === undefined) return { mode: 'off' }
39
46
  const ignored: Array<'tunnelToken' | 'publicBaseUrl'> = []
40
47
  if (value.tunnelToken !== undefined && value.tunnelToken !== '') ignored.push('tunnelToken')
41
48
  if (value.publicBaseUrl !== undefined && value.publicBaseUrl !== '') ignored.push('publicBaseUrl')
package/lib/invariant.js DELETED
@@ -1,21 +0,0 @@
1
- //#region src/invariant.ts
2
- const PACKAGE_NAME = "@linxin666/dsh-remote-web-ui";
3
- /** Cordis companion plugin name. */
4
- const name = "remote-web-ui-invariant";
5
- /** Service required before the companion can reserve package ownership. */
6
- const inject = ["invariants"];
7
- /**
8
- * No runtime invariant: this package owns no durable package-local event
9
- * stream; its route-table and device-session relationships are asserted by
10
- * the package's own specs (route register/dispose symmetry via the
11
- * webServer disposer contract, token/revocation semantics on the service).
12
- */
13
- const install = () => {};
14
- /**
15
- * Register this package's invariant companion.
16
- * @param ctx - Cordis context carrying the invariant service.
17
- * @returns the installed registration's disposer after setup succeeds.
18
- */
19
- const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
20
- //#endregion
21
- export { apply, inject, name };
@@ -1,16 +0,0 @@
1
- /**
2
- * Package-owned invariant companion for `@linxin666/dsh-remote-web-ui`.
3
- * @module @linxin666/dsh-remote-web-ui/invariant
4
- */
5
- import type { Context } from '@deepseek-ai/cordis';
6
- /** Cordis companion plugin name. */
7
- export declare const name = "remote-web-ui-invariant";
8
- /** Service required before the companion can reserve package ownership. */
9
- export declare const inject: string[];
10
- /**
11
- * Register this package's invariant companion.
12
- * @param ctx - Cordis context carrying the invariant service.
13
- * @returns the installed registration's disposer after setup succeeds.
14
- */
15
- export declare const apply: (ctx: Context) => Promise<() => void>;
16
- //# sourceMappingURL=invariant.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"invariant.d.ts","sourceRoot":"","sources":["../../src/invariant.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAKlD,oCAAoC;AACpC,eAAO,MAAM,IAAI,4BAA4B,CAAA;AAC7C,2EAA2E;AAC3E,eAAO,MAAM,MAAM,UAAiB,CAAA;AAUpC;;;;GAIG;AACH,eAAO,MAAM,KAAK,GAAI,KAAK,OAAO,KAAG,OAAO,CAAC,MAAM,IAAI,CACU,CAAA"}
@@ -1,23 +0,0 @@
1
- /**
2
- * Package-owned invariant companion for `@linxin666/dsh-remote-web-ui`.
3
- * @module @linxin666/dsh-remote-web-ui/invariant
4
- */
5
- const PACKAGE_NAME = '@linxin666/dsh-remote-web-ui';
6
- /** Cordis companion plugin name. */
7
- export const name = 'remote-web-ui-invariant';
8
- /** Service required before the companion can reserve package ownership. */
9
- export const inject = ['invariants'];
10
- /**
11
- * No runtime invariant: this package owns no durable package-local event
12
- * stream; its route-table and device-session relationships are asserted by
13
- * the package's own specs (route register/dispose symmetry via the
14
- * webServer disposer contract, token/revocation semantics on the service).
15
- */
16
- const install = () => { };
17
- /**
18
- * Register this package's invariant companion.
19
- * @param ctx - Cordis context carrying the invariant service.
20
- * @returns the installed registration's disposer after setup succeeds.
21
- */
22
- export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
23
- /* jscpd:ignore-end */
package/src/invariant.ts DELETED
@@ -1,32 +0,0 @@
1
- /**
2
- * Package-owned invariant companion for `@linxin666/dsh-remote-web-ui`.
3
- * @module @linxin666/dsh-remote-web-ui/invariant
4
- */
5
-
6
- /* jscpd:ignore-start */
7
- import type { Context } from '@deepseek-ai/cordis'
8
- import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
9
-
10
- const PACKAGE_NAME = '@linxin666/dsh-remote-web-ui'
11
-
12
- /** Cordis companion plugin name. */
13
- export const name = 'remote-web-ui-invariant'
14
- /** Service required before the companion can reserve package ownership. */
15
- export const inject = ['invariants']
16
-
17
- /**
18
- * No runtime invariant: this package owns no durable package-local event
19
- * stream; its route-table and device-session relationships are asserted by
20
- * the package's own specs (route register/dispose symmetry via the
21
- * webServer disposer contract, token/revocation semantics on the service).
22
- */
23
- const install: InvariantInstaller = () => {}
24
-
25
- /**
26
- * Register this package's invariant companion.
27
- * @param ctx - Cordis context carrying the invariant service.
28
- * @returns the installed registration's disposer after setup succeeds.
29
- */
30
- export const apply = (ctx: Context): Promise<() => void> =>
31
- Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
32
- /* jscpd:ignore-end */