@linxin666/dsh-remote-web-ui 0.4.6 → 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 (51) 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 +21 -90
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +398 -43
  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/settings-entry-form.d.ts.map +1 -1
  16. package/lib/types/client/settings-entry-form.js +0 -1
  17. package/lib/types/index.d.ts +14 -0
  18. package/lib/types/index.d.ts.map +1 -1
  19. package/lib/types/index.js +67 -17
  20. package/lib/types/lan-source.d.ts +65 -0
  21. package/lib/types/lan-source.d.ts.map +1 -0
  22. package/lib/types/lan-source.js +113 -0
  23. package/lib/types/lan.d.ts +49 -3
  24. package/lib/types/lan.d.ts.map +1 -1
  25. package/lib/types/lan.js +120 -6
  26. package/lib/types/remote-api.d.ts +7 -2
  27. package/lib/types/remote-api.d.ts.map +1 -1
  28. package/lib/types/remote-api.js +17 -2
  29. package/lib/types/routes.d.ts +71 -9
  30. package/lib/types/routes.d.ts.map +1 -1
  31. package/lib/types/routes.js +157 -27
  32. package/lib/types/tunnel-plan.d.ts +8 -2
  33. package/lib/types/tunnel-plan.d.ts.map +1 -1
  34. package/lib/types/tunnel-plan.js +9 -1
  35. package/package.json +20 -25
  36. package/src/client/RemoteEntry.tsx +2 -18
  37. package/src/client/RemotePanel.tsx +9 -43
  38. package/src/client/locales.ts +4 -8
  39. package/src/client/remote.module.css +2 -48
  40. package/src/client/settings-entry-form.ts +0 -1
  41. package/src/index.ts +77 -17
  42. package/src/lan-source.ts +132 -0
  43. package/src/lan.ts +126 -6
  44. package/src/remote-api.ts +24 -4
  45. package/src/routes.ts +178 -27
  46. package/src/tunnel-plan.ts +9 -2
  47. package/lib/invariant.js +0 -21
  48. package/lib/types/invariant.d.ts +0 -16
  49. package/lib/types/invariant.d.ts.map +0 -1
  50. package/lib/types/invariant.js +0 -23
  51. package/src/invariant.ts +0 -32
@@ -5,11 +5,57 @@
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
  */
22
+ import { networkInterfaces } from 'node:os';
9
23
  /**
10
- * Non-internal IPv4 interface addresses of this machine — the IP-literal
11
- * authorities an all-interfaces bind is reachable by on the LAN.
24
+ * Whether an interface name belongs to a tunnel, VPN adapter, or virtual
25
+ * machine/container bridge, so its address is not an authority a LAN peer
26
+ * can dial.
27
+ * @param name - the interface name from `os.networkInterfaces()`.
28
+ * @returns true when the interface must not be advertised.
29
+ */
30
+ export declare function isUnadvertisableInterface(name: string): boolean;
31
+ /**
32
+ * Whether an IPv4 literal can be advertised to a pairing peer.
33
+ * @param address - a dotted-quad IPv4 literal.
34
+ * @returns true when a peer could plausibly reach it.
35
+ */
36
+ export declare function isAdvertisableIPv4(address: string): boolean;
37
+ /** One sampled interface address. */
38
+ export interface LanCandidate {
39
+ /** The interface name the address was sampled from. */
40
+ iface: string;
41
+ /** The IPv4 literal. */
42
+ address: string;
43
+ }
44
+ /** The `networkInterfaces()` shape, injectable so the filter is testable. */
45
+ export type NetworkInterfaceMap = ReturnType<typeof networkInterfaces>;
46
+ /**
47
+ * Every IPv4 interface address this machine can advertise to a pairing peer:
48
+ * non-internal, on a non-tunnel and non-virtual interface, outside the ranges
49
+ * no peer can route to.
50
+ * @param interfaces - the sampled interface map (defaults to the live one).
51
+ * @returns the candidates in interface order (possibly empty).
52
+ */
53
+ export declare function lanIPv4Candidates(interfaces?: NetworkInterfaceMap): LanCandidate[];
54
+ /**
55
+ * Non-internal, reachable IPv4 interface addresses of this machine — the
56
+ * IP-literal authorities an all-interfaces bind is reachable by on the LAN.
57
+ * @param interfaces - the sampled interface map (defaults to the live one).
12
58
  * @returns the addresses in interface order (possibly empty).
13
59
  */
14
- export declare function lanIPv4Addresses(): string[];
60
+ export declare function lanIPv4Addresses(interfaces?: NetworkInterfaceMap): string[];
15
61
  //# sourceMappingURL=lan.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"lan.d.ts","sourceRoot":"","sources":["../../src/lan.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH;;;;GAIG;AACH,wBAAgB,gBAAgB,IAAI,MAAM,EAAE,CAI3C"}
1
+ {"version":3,"file":"lan.d.ts","sourceRoot":"","sources":["../../src/lan.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAA;AAsD3C;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAG/D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAM3D;AAED,qCAAqC;AACrC,MAAM,WAAW,YAAY;IAC3B,uDAAuD;IACvD,KAAK,EAAE,MAAM,CAAA;IACb,wBAAwB;IACxB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,6EAA6E;AAC7E,MAAM,MAAM,mBAAmB,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAA;AAEtE;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,UAAU,GAAE,mBAAyC,GAAG,YAAY,EAAE,CAWvG;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,GAAE,mBAAyC,GAAG,MAAM,EAAE,CAEhG"}
package/lib/types/lan.js CHANGED
@@ -5,15 +5,129 @@
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
  import { networkInterfaces } from 'node:os';
10
23
  /**
11
- * Non-internal IPv4 interface addresses of this machine — the IP-literal
12
- * authorities an all-interfaces bind is reachable by on the LAN.
24
+ * Interface-name stems of tunnels and VPN adapters: interfaces whose address
25
+ * is a private point-to-point endpoint on this machine, never an authority a
26
+ * peer on the local network can dial. Matching is case-insensitive and
27
+ * anchored at the start, so `utun0`/`utun4` both match while a hypothetical
28
+ * `en0utun` does not. Windows reports adapter *descriptions* rather than POSIX
29
+ * stems (`Tailscale`, `WireGuard Tunnel`, `OpenVPN Wintun`), which the same
30
+ * list covers by prefix.
31
+ */
32
+ const TUNNEL_INTERFACE_PREFIXES = [
33
+ 'utun', 'tun', 'tap', 'ppp', 'ipsec', 'wg', 'wireguard', 'tailscale', 'nordlynx', 'proton', 'openvpn', 'zt',
34
+ ];
35
+ /**
36
+ * Interface-name stems of virtual machine and container bridges: `docker0`,
37
+ * `vethXXXX`, `br-<id>`, `virbr0`, `vmnet8`, `vboxnet0` and the Hyper-V
38
+ * `vEthernet` adapters. Their RFC1918 addresses belong to a NAT the host
39
+ * shares with its own guests, so a phone on the physical LAN cannot reach
40
+ * them; `bridge0`/`br0` are deliberately absent because a real bridged LAN
41
+ * (macOS Internet Sharing) is exactly the address a phone does reach.
42
+ */
43
+ const VIRTUAL_INTERFACE_PREFIXES = [
44
+ 'docker', 'veth', 'virbr', 'vmnet', 'vboxnet', 'vethernet', 'br-',
45
+ ];
46
+ /**
47
+ * IPv4 ranges no peer on the local network can reach. The list is a denial
48
+ * list rather than an RFC1918 allowlist on purpose: a host whose only
49
+ * interface is a routable public literal (a VPS, a standalone server) would
50
+ * otherwise lose its only constructible base and fall back to the
51
+ * `lan-required` banner.
52
+ * @param octets - the four address octets, in order.
53
+ * @returns true when an address in this range must not be advertised.
54
+ */
55
+ function isUnreachableIPv4(octets) {
56
+ const [a = 0, b = 0] = octets;
57
+ // 0.0.0.0/8 "this network": never a usable address.
58
+ if (a === 0)
59
+ return true;
60
+ // 127.0.0.0/8 loopback (node already marks it internal; kept as the belt).
61
+ if (a === 127)
62
+ return true;
63
+ // 100.64.0.0/10 carrier-grade NAT: Tailscale and ISP-level NAT hand out
64
+ // addresses here, and a peer on the physical LAN cannot route to them.
65
+ if (a === 100 && b >= 64 && b <= 127)
66
+ return true;
67
+ // 169.254.0.0/16 link-local (APIPA / self-assigned).
68
+ if (a === 169 && b === 254)
69
+ 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))
72
+ return true;
73
+ // 224.0.0.0/4 multicast and 240.0.0.0/4 reserved (includes the broadcast).
74
+ if (a >= 224)
75
+ return true;
76
+ return false;
77
+ }
78
+ /**
79
+ * Whether an interface name belongs to a tunnel, VPN adapter, or virtual
80
+ * machine/container bridge, so its address is not an authority a LAN peer
81
+ * can dial.
82
+ * @param name - the interface name from `os.networkInterfaces()`.
83
+ * @returns true when the interface must not be advertised.
84
+ */
85
+ export function isUnadvertisableInterface(name) {
86
+ const lower = name.toLowerCase();
87
+ return [...TUNNEL_INTERFACE_PREFIXES, ...VIRTUAL_INTERFACE_PREFIXES].some(prefix => lower.startsWith(prefix));
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) {
95
+ const parts = address.split('.');
96
+ if (parts.length !== 4)
97
+ return false;
98
+ const octets = parts.map(part => (/^\d{1,3}$/.test(part) ? Number(part) : Number.NaN));
99
+ if (octets.some(octet => !Number.isInteger(octet) || octet > 255))
100
+ return false;
101
+ return !isUnreachableIPv4(octets);
102
+ }
103
+ /**
104
+ * Every IPv4 interface address this machine can advertise to a pairing peer:
105
+ * non-internal, on a non-tunnel and non-virtual interface, outside the ranges
106
+ * no peer can route to.
107
+ * @param interfaces - the sampled interface map (defaults to the live one).
108
+ * @returns the candidates in interface order (possibly empty).
109
+ */
110
+ export function lanIPv4Candidates(interfaces = networkInterfaces()) {
111
+ const candidates = [];
112
+ for (const [name, addresses] of Object.entries(interfaces)) {
113
+ if (isUnadvertisableInterface(name))
114
+ continue;
115
+ for (const iface of addresses ?? []) {
116
+ if (iface.family !== 'IPv4' || iface.internal)
117
+ continue;
118
+ if (!isAdvertisableIPv4(iface.address))
119
+ continue;
120
+ candidates.push({ iface: name, address: iface.address });
121
+ }
122
+ }
123
+ return candidates;
124
+ }
125
+ /**
126
+ * Non-internal, reachable IPv4 interface addresses of this machine — the
127
+ * IP-literal authorities an all-interfaces bind is reachable by on the LAN.
128
+ * @param interfaces - the sampled interface map (defaults to the live one).
13
129
  * @returns the addresses in interface order (possibly empty).
14
130
  */
15
- export function lanIPv4Addresses() {
16
- return Object.values(networkInterfaces()).flat()
17
- .filter((iface) => { return iface !== undefined && iface.family === 'IPv4' && !iface.internal; })
18
- .map(iface => iface.address);
131
+ export function lanIPv4Addresses(interfaces = networkInterfaces()) {
132
+ return lanIPv4Candidates(interfaces).map(candidate => candidate.address);
19
133
  }
@@ -35,8 +35,13 @@ export { REMOTE_API_PREFIX } from './remote-methods.ts';
35
35
  export interface RemoteApiDeps {
36
36
  /** The pairing service (device gate + cookie name). */
37
37
  service: PairingService;
38
- /** Local webServer port the loopback proxy connects to. */
39
- port: number;
38
+ /**
39
+ * Local webServer port the loopback proxy connects to, read per request:
40
+ * `undefined` before the listener binds (a recompose pass can run this
41
+ * apply ahead of the bind), in which case the handlers refuse with 503
42
+ * instead of minting an `:undefined` URL.
43
+ */
44
+ port: () => number | undefined;
40
45
  /**
41
46
  * The process's inner browser-auth credential attached to re-issued
42
47
  * requests (the connection plugin's /api route enforces that cookie and
@@ -1 +1 @@
1
- {"version":3,"file":"remote-api.d.ts","sourceRoot":"","sources":["../../src/remote-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,WAAW,CAAA;AAEhE,OAAO,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAA;AAChF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAGlD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAUhD,OAAO,EACL,mBAAmB,EACnB,mBAAmB,EACnB,gBAAgB,EAChB,oBAAoB,EACpB,mBAAmB,EACnB,aAAa,EACb,oBAAoB,EACpB,2BAA2B,EAC3B,eAAe,GAChB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AAgBvD,iCAAiC;AACjC,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,OAAO,EAAE,cAAc,CAAA;IACvB,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAA;IACZ;;;;;OAKG;IACH,IAAI,CAAC,EAAE,SAAS,CAAA;CACjB;AAWD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUhE;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAExE;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,eAAe,EAAE,OAAO,EAAE,cAAc,GAAG,MAAM,GAAG,SAAS,CAQlG;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,CAwCnE;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAYzF;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,aAAa,GAAG,eAAe,EAAE,CAyCjF"}
1
+ {"version":3,"file":"remote-api.d.ts","sourceRoot":"","sources":["../../src/remote-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,WAAW,CAAA;AAEhE,OAAO,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAA;AAChF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAGlD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAUhD,OAAO,EACL,mBAAmB,EACnB,mBAAmB,EACnB,gBAAgB,EAChB,oBAAoB,EACpB,mBAAmB,EACnB,aAAa,EACb,oBAAoB,EACpB,2BAA2B,EAC3B,eAAe,GAChB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AAgBvD,iCAAiC;AACjC,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,OAAO,EAAE,cAAc,CAAA;IACvB;;;;;OAKG;IACH,IAAI,EAAE,MAAM,MAAM,GAAG,SAAS,CAAA;IAC9B;;;;;OAKG;IACH,IAAI,CAAC,EAAE,SAAS,CAAA;CACjB;AAWD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUhE;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAExE;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,eAAe,EAAE,OAAO,EAAE,cAAc,GAAG,MAAM,GAAG,SAAS,CAQlG;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,CAgDnE;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAYzF;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,aAAa,GAAG,eAAe,EAAE,CAgDjF"}
@@ -101,7 +101,7 @@ export function pairedDeviceIdOf(req, service) {
101
101
  * @returns the routes to register on webServer.
102
102
  */
103
103
  export function makeRemoteApiRoutes(deps) {
104
- const { service, port } = deps;
104
+ const { service } = deps;
105
105
  const handler = (req, res) => {
106
106
  // Paired credential first — before any bytes are buffered or forwarded.
107
107
  // Unconditional by design: this channel attaches the process's own
@@ -132,6 +132,14 @@ export function makeRemoteApiRoutes(deps) {
132
132
  envelopeError(res, 403, 'invalid-request', 'forbidden', denied);
133
133
  return;
134
134
  }
135
+ const port = deps.port();
136
+ if (port === undefined) {
137
+ // The listener has not bound yet (a recompose pass): refuse instead of
138
+ // minting an http://127.0.0.1:undefined target; the desktop retries.
139
+ req.resume();
140
+ envelopeError(res, 503, 'invalid-request', 'port-unbound', 'the local web server port is not bound yet; retry shortly');
141
+ return;
142
+ }
135
143
  proxyLoopbackHttp(req, res, port, `${inner}${url.search}`, deps.auth);
136
144
  };
137
145
  return [{ kind: 'prefix', path: REMOTE_PREFIX, handler }];
@@ -174,7 +182,7 @@ export function upgradeInnerPath(reqUrl, fallbackPath) {
174
182
  * @returns the upgrade routes to register on webServer.
175
183
  */
176
184
  export function makeRemoteApiUpgradeRoutes(deps) {
177
- const { service, port } = deps;
185
+ const { service } = deps;
178
186
  const handlerFor = (fallbackPath) => (req, socket, head) => {
179
187
  // WebSocket handshakes cannot carry headers from the Web API, so the
180
188
  // cookieless credential rides the query; the cookie stays the primary.
@@ -191,6 +199,13 @@ export function makeRemoteApiUpgradeRoutes(deps) {
191
199
  socket.destroy();
192
200
  return;
193
201
  }
202
+ const port = deps.port();
203
+ if (port === undefined) {
204
+ // Same unbound-port refusal as the HTTP leg: no :undefined handshake.
205
+ socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
206
+ socket.destroy();
207
+ return;
208
+ }
194
209
  const inner = upgradeInnerPath(req.url, fallbackPath);
195
210
  const denied = loopbackOnlyDenial(inner.split('?')[0] ?? inner);
196
211
  if (denied !== undefined) {
@@ -24,6 +24,7 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
24
24
  import { z } from 'zod';
25
25
  import { type PairingService, type PairingSnapshot } from './pairing.ts';
26
26
  import { type PairGrantStore } from './pair-grant.ts';
27
+ import { type LanSourceHop } from './lan-source.ts';
27
28
  /**
28
29
  * Browser-trust fence for the /api/pair routes, mirroring the connection
29
30
  * package's internal fence semantics (Host/Origin based, DNS-rebinding and
@@ -119,11 +120,18 @@ export declare function publicHostOf(url: string | undefined): string | undefine
119
120
  * The cookieless device credential: pass the device id from the /pair-app
120
121
  * URL into sessionStorage (and localStorage for tab reloads) before any app
121
122
  * script runs - same key the boot patch and the channel gate read. The
122
- * replaceState to '/' hides the credential URL from the address bar and
123
- * leaves the SPA at its canonical root path. The reopen service worker is
124
- * registered in the same breath: later navigations to '/' (history,
125
- * bookmark, tab restore) must not fall through to the harness index gate,
126
- * which the cookieless flow can never satisfy.
123
+ * replaceState drops the one-time grant from the address bar and leaves the
124
+ * SPA on a path this plugin answers again; which path depends on the origin.
125
+ *
126
+ * A secure context registers the reopen service worker in the same breath,
127
+ * and that worker owns navigations to '/', so the SPA keeps its canonical
128
+ * root. A plain-HTTP LAN origin is never a secure context, so the worker
129
+ * cannot exist there and '/' falls through to the harness index gate the
130
+ * cookieless flow can never satisfy (issue #1867). The address bar therefore
131
+ * keeps the plugin-owned landing path, which re-validates the device
132
+ * credential on every reload and serves the bilingual re-pair page once the
133
+ * pairing is gone. Both targets are bare paths: the one-time grant never
134
+ * survives the rewrite, and no credential is ever left in the URL.
127
135
  */
128
136
  export declare const APP_DEVICE_STORAGE_KEY = "dsh-remote-device";
129
137
  export declare function appShellCaptureScript(deviceId: string): string;
@@ -134,8 +142,18 @@ export declare function appShellCaptureScript(deviceId: string): string;
134
142
  * hook only when this server-served marker is already set (see
135
143
  * remote-channel-boot.ts), so host mode is granted by the device-gated landing
136
144
  * instead of being asserted from the origin.
145
+ *
146
+ * A landing reached on a base other than the machine's own LAN origin also
147
+ * carries the source-decision script (lan-source.ts): the phone decides at
148
+ * connect time whether a LAN candidate answers, and switches its origin when
149
+ * one does. It runs after the capture script so the granted page is fully
150
+ * usable before the probe's outcome can move it.
151
+ * @param html - the official index document.
152
+ * @param deviceId - the device session this landing is being served for.
153
+ * @param hop - the LAN candidates to probe, when a switch is possible.
154
+ * @returns the patched document.
137
155
  */
138
- export declare function patchAppShell(html: string, deviceId: string): string;
156
+ export declare function patchAppShell(html: string, deviceId: string, hop?: LanSourceHop): string;
139
157
  /**
140
158
  * The reopen service worker served at PAIR_PATHS.appServiceWorker. A paired
141
159
  * phone comes back to `/` from history, bookmarks, or tab restore; the
@@ -144,9 +162,10 @@ export declare function patchAppShell(html: string, deviceId: string): string;
144
162
  * worker owns navigations to `/` instead: network-first through /pair-app
145
163
  * (which validates the device cookie and refreshes its presence), the
146
164
  * cached shell for offline opens, and a pass-through of the original
147
- * request when the plugin no longer answers. Plain-HTTP LAN origins are not
148
- * secure contexts, so the worker never registers there — the LAN reopen
149
- * path stays "scan a fresh QR", which is cheap in-network.
165
+ * request when the plugin no longer answers. A plain-HTTP LAN origin is
166
+ * never a secure context, so the worker cannot exist there; the capture
167
+ * script keeps the plugin-owned landing path in the address bar instead,
168
+ * and that landing re-validates the device credential on every reload.
150
169
  *
151
170
  * Kept as a plain string (same pattern as the capture script): it must load
152
171
  * with no build step, run on every JS engine that ships service workers,
@@ -168,6 +187,12 @@ export declare const PAIR_PATHS: {
168
187
  readonly acceptPage: "/pair-accept";
169
188
  /** The cookieless app landing: serves the official shell for a paired device. */
170
189
  readonly appPage: "/pair-app";
190
+ /**
191
+ * The landing page's LAN reachability beacon (lan-source.ts): an inert
192
+ * no-cors GET target, so a probe proves the origin answers without touching
193
+ * a credential or any state.
194
+ */
195
+ readonly ping: "/pair-ping";
171
196
  /**
172
197
  * The reopen service worker (registered by the capture script). Root-level
173
198
  * path so its default script-directory scope already covers `/`.
@@ -206,12 +231,49 @@ export declare class PairingEventsStream {
206
231
  /** Stream count (tests/diagnostics). */
207
232
  get size(): number;
208
233
  }
234
+ /**
235
+ * The manual override of the automatic network choice, for the case where the
236
+ * phone's own probe misjudges (a firewall that answers the reachability ping
237
+ * but not the page, or a LAN the probe cannot see).
238
+ *
239
+ * - `auto` (default): the QR link names the public base when one is configured
240
+ * and the landing page probes the LAN candidates, switching to the first one
241
+ * that answers.
242
+ * - `public`: the public base only, and the landing page never probes. For a
243
+ * deployment where the LAN hop is undesirable.
244
+ * - `lan`: the first LAN literal directly, with no public hop. For a phone that
245
+ * is known to sit on this machine's own network.
246
+ */
247
+ export type PairingSourcePreference = 'auto' | 'public' | 'lan';
248
+ /**
249
+ * The accepted {@link PairingSourcePreference} values, shared with the config
250
+ * schema so the patch that admits a value and the handler that reads it cannot
251
+ * drift apart.
252
+ */
253
+ export declare const PAIRING_SOURCE_OPTIONS: readonly PairingSourcePreference[];
254
+ /**
255
+ * The LAN origins a landing page served from one authority may hop to: every
256
+ * LAN base the service advertises except the authority serving the request
257
+ * (hopping to the current origin would be a no-op), normalized through `URL`
258
+ * so a default port compares equal to `location.origin`.
259
+ * @param lanBases - the advertised LAN base URLs, in interface order.
260
+ * @param currentOrigin - the origin serving the landing page.
261
+ * @returns the distinct candidate origins, in interface order.
262
+ */
263
+ export declare function lanSourceCandidates(lanBases: readonly string[], currentOrigin: string): string[];
209
264
  /** Route-family dependencies (test seam). */
210
265
  export interface PairRoutesDeps {
211
266
  /** The pairing service. */
212
267
  service: PairingService;
213
268
  /** Current desktop gate policy, re-read for every status response. */
214
269
  requirePairingForLan?: boolean | (() => boolean);
270
+ /**
271
+ * The manual override of the automatic network choice
272
+ * ({@link PairingSourcePreference}), re-read per request. Defaults to
273
+ * `auto`: the QR link names the public base and the landing page decides
274
+ * whether to switch to a LAN origin.
275
+ */
276
+ pairingSource?: () => PairingSourcePreference;
215
277
  /**
216
278
  * LAN-bind facts for the settings card (managed patch block state, live
217
279
  * bind host/port, firewall summary). Re-read per request so a hot-reloaded
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"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"}