@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/lib/index.js CHANGED
@@ -989,6 +989,62 @@ const REMOTE_CHANNEL_RULES = {
989
989
  /** The window global the boot patch publishes its seat under. */
990
990
  const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
991
991
  //#endregion
992
+ //#region src/lan-source.ts
993
+ /**
994
+ * The landing page's network-source decision: which origin a paired phone
995
+ * should keep using.
996
+ *
997
+ * The QR link can only name one base, and the server cannot know whether the
998
+ * scanning phone sits on this machine's own network: the public (relayed)
999
+ * base is the only one that is always reachable, so the QR names it and the
1000
+ * phone decides at connect time. The device-gated landing (/pair-app) is the
1001
+ * plugin's own page already rendered ahead of the official shell's boot, so it
1002
+ * is the natural place to probe: after the page is served, a short
1003
+ * cross-origin `fetch` per LAN candidate (a no-cors GET at
1004
+ * {@link LAN_PING_PATH}) tells the browser whether that LAN origin answers,
1005
+ * and the first one that does becomes the new origin. A probe that finds
1006
+ * nothing, or that runs out of budget, changes nothing — the page the phone
1007
+ * already holds stays paired and usable, so the probe can never block pairing.
1008
+ *
1009
+ * Browsers do not block this probe. An https page may not normally fetch
1010
+ * `http://` subresources, but a host that is an IP literal is exempt from both
1011
+ * the block and the automatic upgrade (verified against Chrome: the console
1012
+ * reports "This request was not upgraded to HTTPS because its URL's host is an
1013
+ * IP address" and delivers the response), which is exactly the shape every LAN
1014
+ * candidate has.
1015
+ *
1016
+ * @module @linxin666/dsh-remote-web-ui/lan-source
1017
+ */
1018
+ /** sessionStorage key holding this session's source decision. */
1019
+ const LAN_SOURCE_STORAGE_KEY = "dsh-remote-lan-source";
1020
+ /**
1021
+ * How recently a LAN hop must have been taken for a return to a non-LAN origin
1022
+ * to read as "the hop did not stick". A phone that fails to load the LAN page
1023
+ * re-scans the QR (which always names the public base) within seconds, and the
1024
+ * landing then has to choose the public source instead of hopping again into
1025
+ * the same black hole; a deliberate return much later is a fresh decision and
1026
+ * re-probes.
1027
+ */
1028
+ const LAN_SOURCE_RETRY_WINDOW_MS = 12e4;
1029
+ /**
1030
+ * Build the inline script that runs the source decision on the landing page.
1031
+ * Reads every browser global through `window`, so a test can run it against
1032
+ * one fake object (the same seam the parse-time boot patch uses).
1033
+ * @param hop - the probe payload for this landing render.
1034
+ * @returns the `<script>` element to splice into the landing's head.
1035
+ */
1036
+ function appShellLanSwitchScript(hop) {
1037
+ return `<script>${"(function(){try{var w=window,loc=w.location;var P=" + JSON.stringify({
1038
+ key: LAN_SOURCE_STORAGE_KEY,
1039
+ candidates: hop.candidates,
1040
+ path: hop.path,
1041
+ ping: hop.pingPath,
1042
+ grant: hop.grant,
1043
+ timeout: 800,
1044
+ retry: LAN_SOURCE_RETRY_WINDOW_MS
1045
+ }) + ";var S=w.sessionStorage;P.sig=P.candidates.join(\",\");function rd(){try{var raw=S.getItem(P.key);if(typeof raw!==\"string\"||raw===\"\")return undefined;var d=JSON.parse(raw);if(typeof d!==\"object\"||d===null)return undefined;if(d.src!==\"lan\"&&d.src!==\"public\")return undefined;if(typeof d.sig!==\"string\"||typeof d.at!==\"number\")return undefined;return d}catch(e){return undefined}}function wr(d){try{S.setItem(P.key,JSON.stringify(d))}catch(e){}}function own(){for(var i=0;i<P.candidates.length;i++){if(P.candidates[i]===loc.origin)return true}return false}function go(base){try{loc.replace(base+P.path+\"?grant=\"+encodeURIComponent(P.grant))}catch(e){}}function probe(){if(typeof w.fetch!==\"function\")return;var settled=false;var timer=w.setTimeout(function(){finish(undefined)},P.timeout);function finish(base){if(settled)return;settled=true;try{w.clearTimeout(timer)}catch(e){}if(base===undefined){wr({src:\"public\",sig:P.sig,at:Date.now()});return}wr({src:\"lan\",sig:P.sig,base:base,at:Date.now()});go(base)}for(var i=0;i<P.candidates.length;i++){(function(base){var sent;try{sent=w.fetch(base+P.ping,{mode:\"no-cors\",cache:\"no-store\",credentials:\"omit\",referrerPolicy:\"no-referrer\"})}catch(e){sent=undefined}if(sent===undefined||typeof sent.then!==\"function\")return;sent.then(function(){finish(base)},function(){})})(P.candidates[i])}}var d=rd();if(own()){if(d===undefined||d.sig!==P.sig)wr({src:\"lan\",sig:P.sig,base:loc.origin,at:Date.now()});return}if(d!==undefined&&d.sig===P.sig){if(d.src===\"public\")return;if(Date.now()-d.at<P.retry){wr({src:\"public\",sig:P.sig,at:Date.now()});return}}probe()}catch(e){}})();"}<\/script>`;
1046
+ }
1047
+ //#endregion
992
1048
  //#region src/routes.ts
993
1049
  /**
994
1050
  * Browser-trust fence for the /api/pair routes, mirroring the connection
@@ -1208,18 +1264,26 @@ const COOKIE_MAX_AGE_SEC = 365 * 24 * 60 * 60;
1208
1264
  * The cookieless device credential: pass the device id from the /pair-app
1209
1265
  * URL into sessionStorage (and localStorage for tab reloads) before any app
1210
1266
  * script runs - same key the boot patch and the channel gate read. The
1211
- * replaceState to '/' hides the credential URL from the address bar and
1212
- * leaves the SPA at its canonical root path. The reopen service worker is
1213
- * registered in the same breath: later navigations to '/' (history,
1214
- * bookmark, tab restore) must not fall through to the harness index gate,
1215
- * which the cookieless flow can never satisfy.
1267
+ * replaceState drops the one-time grant from the address bar and leaves the
1268
+ * SPA on a path this plugin answers again; which path depends on the origin.
1269
+ *
1270
+ * A secure context registers the reopen service worker in the same breath,
1271
+ * and that worker owns navigations to '/', so the SPA keeps its canonical
1272
+ * root. A plain-HTTP LAN origin is never a secure context, so the worker
1273
+ * cannot exist there and '/' falls through to the harness index gate the
1274
+ * cookieless flow can never satisfy (issue #1867). The address bar therefore
1275
+ * keeps the plugin-owned landing path, which re-validates the device
1276
+ * credential on every reload and serves the bilingual re-pair page once the
1277
+ * pairing is gone. Both targets are bare paths: the one-time grant never
1278
+ * survives the rewrite, and no credential is ever left in the URL.
1216
1279
  */
1217
1280
  const APP_DEVICE_STORAGE_KEY = "dsh-remote-device";
1218
1281
  function appShellCaptureScript(deviceId) {
1219
1282
  const safeId = JSON.stringify(deviceId);
1220
1283
  const grantGlobal = JSON.stringify(REMOTE_HOST_GRANT_GLOBAL);
1284
+ const reopenPath = JSON.stringify(PAIR_PATHS.appPage);
1221
1285
  const register = `try{if('serviceWorker' in navigator){navigator.serviceWorker.register(${JSON.stringify(PAIR_PATHS.appServiceWorker)},{scope:'/'}).catch(function(e){})}}catch(e){}`;
1222
- 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>`;
1286
+ 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>`;
1223
1287
  }
1224
1288
  /**
1225
1289
  * Patch the official index document with the device-capture script. The script
@@ -1228,9 +1292,19 @@ function appShellCaptureScript(deviceId) {
1228
1292
  * hook only when this server-served marker is already set (see
1229
1293
  * remote-channel-boot.ts), so host mode is granted by the device-gated landing
1230
1294
  * instead of being asserted from the origin.
1295
+ *
1296
+ * A landing reached on a base other than the machine's own LAN origin also
1297
+ * carries the source-decision script (lan-source.ts): the phone decides at
1298
+ * connect time whether a LAN candidate answers, and switches its origin when
1299
+ * one does. It runs after the capture script so the granted page is fully
1300
+ * usable before the probe's outcome can move it.
1301
+ * @param html - the official index document.
1302
+ * @param deviceId - the device session this landing is being served for.
1303
+ * @param hop - the LAN candidates to probe, when a switch is possible.
1304
+ * @returns the patched document.
1231
1305
  */
1232
- function patchAppShell(html, deviceId) {
1233
- const script = appShellCaptureScript(deviceId);
1306
+ function patchAppShell(html, deviceId, hop) {
1307
+ const script = appShellCaptureScript(deviceId) + (hop === void 0 ? "" : appShellLanSwitchScript(hop));
1234
1308
  const head = /<head[^>]*>/i.exec(html);
1235
1309
  if (head === null) return script + html;
1236
1310
  const end = head.index + head[0].length;
@@ -1244,9 +1318,10 @@ function patchAppShell(html, deviceId) {
1244
1318
  * worker owns navigations to `/` instead: network-first through /pair-app
1245
1319
  * (which validates the device cookie and refreshes its presence), the
1246
1320
  * cached shell for offline opens, and a pass-through of the original
1247
- * request when the plugin no longer answers. Plain-HTTP LAN origins are not
1248
- * secure contexts, so the worker never registers there — the LAN reopen
1249
- * path stays "scan a fresh QR", which is cheap in-network.
1321
+ * request when the plugin no longer answers. A plain-HTTP LAN origin is
1322
+ * never a secure context, so the worker cannot exist there; the capture
1323
+ * script keeps the plugin-owned landing path in the address bar instead,
1324
+ * and that landing re-validates the device credential on every reload.
1250
1325
  *
1251
1326
  * Kept as a plain string (same pattern as the capture script): it must load
1252
1327
  * with no build step, run on every JS engine that ships service workers,
@@ -1346,6 +1421,12 @@ const PAIR_PATHS = {
1346
1421
  /** The cookieless app landing: serves the official shell for a paired device. */
1347
1422
  appPage: "/pair-app",
1348
1423
  /**
1424
+ * The landing page's LAN reachability beacon (lan-source.ts): an inert
1425
+ * no-cors GET target, so a probe proves the origin answers without touching
1426
+ * a credential or any state.
1427
+ */
1428
+ ping: "/pair-ping",
1429
+ /**
1349
1430
  * The reopen service worker (registered by the capture script). Root-level
1350
1431
  * path so its default script-directory scope already covers `/`.
1351
1432
  */
@@ -1424,6 +1505,39 @@ var PairingEventsStream = class {
1424
1505
  }
1425
1506
  };
1426
1507
  /**
1508
+ * The accepted {@link PairingSourcePreference} values, shared with the config
1509
+ * schema so the patch that admits a value and the handler that reads it cannot
1510
+ * drift apart.
1511
+ */
1512
+ const PAIRING_SOURCE_OPTIONS = [
1513
+ "auto",
1514
+ "public",
1515
+ "lan"
1516
+ ];
1517
+ /**
1518
+ * The LAN origins a landing page served from one authority may hop to: every
1519
+ * LAN base the service advertises except the authority serving the request
1520
+ * (hopping to the current origin would be a no-op), normalized through `URL`
1521
+ * so a default port compares equal to `location.origin`.
1522
+ * @param lanBases - the advertised LAN base URLs, in interface order.
1523
+ * @param currentOrigin - the origin serving the landing page.
1524
+ * @returns the distinct candidate origins, in interface order.
1525
+ */
1526
+ function lanSourceCandidates(lanBases, currentOrigin) {
1527
+ const origins = [];
1528
+ for (const base of lanBases) {
1529
+ let origin;
1530
+ try {
1531
+ origin = new URL(base).origin;
1532
+ } catch {
1533
+ continue;
1534
+ }
1535
+ if (origin === currentOrigin || origins.includes(origin)) continue;
1536
+ origins.push(origin);
1537
+ }
1538
+ return origins;
1539
+ }
1540
+ /**
1427
1541
  * Build the /api/pair route family.
1428
1542
  * @param deps - service + fence inputs.
1429
1543
  * @returns the exact routes to register on webServer.
@@ -1431,6 +1545,26 @@ var PairingEventsStream = class {
1431
1545
  function makeRoutes(deps) {
1432
1546
  const { service, requirePairingForLan = true } = deps;
1433
1547
  const pairingRequired = () => typeof requirePairingForLan === "function" ? requirePairingForLan() : requirePairingForLan;
1548
+ /** The manual network override, re-read per request (default: automatic). */
1549
+ const sourcePreference = () => deps.pairingSource?.() ?? "auto";
1550
+ /**
1551
+ * Every authority this surface serves, as {@link isTrustedHost} wants them
1552
+ * (bare `host` or `host:port` literals). The LAN literals are re-read per
1553
+ * request: a hot rebind (the lan-bind toggle) updates them mid-process, and
1554
+ * both the fence and the reachability beacon must follow.
1555
+ */
1556
+ const servedAuthorities = () => {
1557
+ const publicHost = publicHostOf(service.publicBaseUrl);
1558
+ const extraHosts = typeof deps.trustedHosts === "function" ? deps.trustedHosts() : deps.trustedHosts ?? [];
1559
+ return [
1560
+ ...service.lanAddresses,
1561
+ ...publicHost !== void 0 ? [publicHost] : [],
1562
+ ...extraHosts,
1563
+ ...dynamicTrustedHosts
1564
+ ];
1565
+ };
1566
+ /** Every LAN base URL the service currently advertises, in interface order. */
1567
+ const lanBaseUrls = () => service.lanAddresses.map((address) => service.lanBaseUrlFor(address)).filter((base) => base !== void 0);
1434
1568
  const grants = deps.grants ?? createPairGrantStore();
1435
1569
  const events = new PairingEventsStream(service);
1436
1570
  const dynamicTrustedHosts = /* @__PURE__ */ new Set();
@@ -1443,15 +1577,7 @@ function makeRoutes(deps) {
1443
1577
  * pairing entry pages), never for an API or subresource request.
1444
1578
  */
1445
1579
  const lanFence = (req, navigation = false) => {
1446
- const publicHost = publicHostOf(service.publicBaseUrl);
1447
- const bases = service.lanAddresses;
1448
- const extraHosts = typeof deps.trustedHosts === "function" ? deps.trustedHosts() : deps.trustedHosts ?? [];
1449
- const combined = [
1450
- ...bases,
1451
- ...publicHost !== void 0 ? [publicHost] : [],
1452
- ...extraHosts,
1453
- ...dynamicTrustedHosts
1454
- ];
1580
+ const combined = servedAuthorities();
1455
1581
  if (navigation && isTopLevelDocumentNavigation(req) ? isTrustedHost(req, combined) : isTrustedApiRequest(req, combined)) return true;
1456
1582
  const privateLanHost = privateLanHostOf(req, navigation);
1457
1583
  if (privateLanHost !== void 0) {
@@ -1552,7 +1678,8 @@ function makeRoutes(deps) {
1552
1678
  const { address } = payload;
1553
1679
  try {
1554
1680
  const { token, expiresAt } = service.issue(void 0, address);
1555
- const base = address === void 0 ? service.publicBaseUrl ?? service.lanBaseUrl : service.lanBaseUrlFor(address);
1681
+ const preference = sourcePreference();
1682
+ const base = address !== void 0 ? service.lanBaseUrlFor(address) : preference === "lan" ? service.lanBaseUrl : preference === "public" ? service.publicBaseUrl : service.publicBaseUrl ?? service.lanBaseUrl;
1556
1683
  if (base === void 0) throw new Error("remote-web-ui: base unavailable");
1557
1684
  writeJson(res, 200, {
1558
1685
  ok: true,
@@ -1868,7 +1995,56 @@ function makeRoutes(deps) {
1868
1995
  "referrer-policy": "no-referrer",
1869
1996
  "set-cookie": [deviceCookie(req, service.config.cookieName, id)]
1870
1997
  });
1871
- res.end(patchAppShell(html, id));
1998
+ res.end(patchAppShell(html, id, lanHop(req, id)));
1999
+ };
2000
+ /**
2001
+ * The LAN hop this landing may take, or undefined when there is nothing to
2002
+ * decide. Three conditions have to hold together: the automatic preference
2003
+ * (both overrides answer the question already — `lan` serves the QR from a
2004
+ * LAN origin, `public` forbids the move), at least one LAN candidate other
2005
+ * than this origin, and a fresh one-time grant for the target landing. The
2006
+ * grant is what lets the target origin serve the shell: the device cookie is
2007
+ * origin-scoped, so the LAN origin has no credential of its own, and the
2008
+ * grant table lives in this process regardless of which interface answered.
2009
+ * The candidate set is carried to the client as origins (lan-source.ts
2010
+ * compares them against `location.origin`).
2011
+ * @param req - the landing request (its authority is the current origin).
2012
+ * @param deviceId - the device session the landing is served for.
2013
+ * @returns the hop payload, or undefined when no switch is possible.
2014
+ */
2015
+ const lanHop = (req, deviceId) => {
2016
+ if (sourcePreference() !== "auto") return void 0;
2017
+ const candidates = lanSourceCandidates(lanBaseUrls(), appOrigin(req));
2018
+ if (candidates.length === 0) return void 0;
2019
+ return {
2020
+ candidates,
2021
+ path: PAIR_PATHS.appPage,
2022
+ pingPath: PAIR_PATHS.ping,
2023
+ grant: grants.issue(deviceId).grant
2024
+ };
2025
+ };
2026
+ /**
2027
+ * The landing page's LAN reachability beacon: a 204 for a Host this plugin
2028
+ * serves, a 403 otherwise. It carries no credential and no state — a phone
2029
+ * that reaches it has proved the origin answers, which is all the source
2030
+ * decision needs, and the fence's browser-marker half is deliberately not
2031
+ * applied here: the probe is cross-site by construction (a page on the
2032
+ * public origin fetching a LAN origin), so the marker check would turn every
2033
+ * probe into the same 403 that means "not ours" and make the two
2034
+ * indistinguishable.
2035
+ */
2036
+ const handlePing = (req, res) => {
2037
+ if (!requireMethod(req, res, "GET")) return;
2038
+ if (!isTrustedHost(req, servedAuthorities())) {
2039
+ res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
2040
+ res.end();
2041
+ return;
2042
+ }
2043
+ res.writeHead(204, {
2044
+ "cache-control": "no-store",
2045
+ "referrer-policy": "no-referrer"
2046
+ });
2047
+ res.end();
1872
2048
  };
1873
2049
  /**
1874
2050
  * The reopen service worker script. Same phone-facing fence as the app
@@ -1938,6 +2114,11 @@ function makeRoutes(deps) {
1938
2114
  path: PAIR_PATHS.acceptPage,
1939
2115
  handler: handleAcceptPage
1940
2116
  });
2117
+ routes.push({
2118
+ kind: "exact",
2119
+ path: PAIR_PATHS.ping,
2120
+ handler: handlePing
2121
+ });
1941
2122
  if (deps.indexDocument !== void 0) {
1942
2123
  routes.push({
1943
2124
  kind: "exact",
@@ -2186,7 +2367,7 @@ function pairedDeviceIdOf(req, service) {
2186
2367
  * @returns the routes to register on webServer.
2187
2368
  */
2188
2369
  function makeRemoteApiRoutes(deps) {
2189
- const { service, port } = deps;
2370
+ const { service } = deps;
2190
2371
  const handler = (req, res) => {
2191
2372
  if (pairedDeviceIdOf(req, service) === void 0) {
2192
2373
  req.resume();
@@ -2212,6 +2393,12 @@ function makeRemoteApiRoutes(deps) {
2212
2393
  envelopeError(res, 403, "invalid-request", "forbidden", denied);
2213
2394
  return;
2214
2395
  }
2396
+ const port = deps.port();
2397
+ if (port === void 0) {
2398
+ req.resume();
2399
+ envelopeError(res, 503, "invalid-request", "port-unbound", "the local web server port is not bound yet; retry shortly");
2400
+ return;
2401
+ }
2215
2402
  proxyLoopbackHttp(req, res, port, `${inner}${url.search}`, deps.auth);
2216
2403
  };
2217
2404
  return [{
@@ -2255,7 +2442,7 @@ function upgradeInnerPath(reqUrl, fallbackPath) {
2255
2442
  * @returns the upgrade routes to register on webServer.
2256
2443
  */
2257
2444
  function makeRemoteApiUpgradeRoutes(deps) {
2258
- const { service, port } = deps;
2445
+ const { service } = deps;
2259
2446
  const handlerFor = (fallbackPath) => (req, socket, head) => {
2260
2447
  let queryDevice;
2261
2448
  try {
@@ -2266,6 +2453,12 @@ function makeRemoteApiUpgradeRoutes(deps) {
2266
2453
  socket.destroy();
2267
2454
  return;
2268
2455
  }
2456
+ const port = deps.port();
2457
+ if (port === void 0) {
2458
+ socket.write("HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n");
2459
+ socket.destroy();
2460
+ return;
2461
+ }
2269
2462
  const inner = upgradeInnerPath(req.url, fallbackPath);
2270
2463
  if (loopbackOnlyDenial(inner.split("?")[0] ?? inner) !== void 0) {
2271
2464
  socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
@@ -2475,16 +2668,132 @@ function releasePostureKey(current, attempted) {
2475
2668
  * with, so the same non-internal IPv4 derivation applies here — an external
2476
2669
  * plugin cannot read the CLI's sampled snapshot, but the fence accepts
2477
2670
  * exactly these literals, which is the property that matters.
2671
+ *
2672
+ * One derivation is deliberately narrower than the CLI's: everything this
2673
+ * module returns is advertised to a phone (the QR base, the landing page's
2674
+ * LAN probe, and the fence's trusted-literal list), and an advertised literal
2675
+ * the peer can never reach costs a phone the whole pairing attempt. A macOS
2676
+ * host running a fake-IP proxy (Clash/Surge/Mihomo) samples its TUN gateway as
2677
+ * `198.18.0.1`, which is a benchmarking-range address no LAN peer can route
2678
+ * to; the old derivation advertised it like any other interface. The filter
2679
+ * below drops the interface kinds and address ranges a peer cannot reach, and
2680
+ * keeps everything else — RFC1918, ULA-side IPv4 and a routable public literal
2681
+ * (a VPS whose NIC is its only reachable base must stay advertisable).
2682
+ *
2683
+ * @module @linxin666/dsh-remote-web-ui/lan
2684
+ */
2685
+ /**
2686
+ * Interface-name stems of tunnels and VPN adapters: interfaces whose address
2687
+ * is a private point-to-point endpoint on this machine, never an authority a
2688
+ * peer on the local network can dial. Matching is case-insensitive and
2689
+ * anchored at the start, so `utun0`/`utun4` both match while a hypothetical
2690
+ * `en0utun` does not. Windows reports adapter *descriptions* rather than POSIX
2691
+ * stems (`Tailscale`, `WireGuard Tunnel`, `OpenVPN Wintun`), which the same
2692
+ * list covers by prefix.
2693
+ */
2694
+ const TUNNEL_INTERFACE_PREFIXES = [
2695
+ "utun",
2696
+ "tun",
2697
+ "tap",
2698
+ "ppp",
2699
+ "ipsec",
2700
+ "wg",
2701
+ "wireguard",
2702
+ "tailscale",
2703
+ "nordlynx",
2704
+ "proton",
2705
+ "openvpn",
2706
+ "zt"
2707
+ ];
2708
+ /**
2709
+ * Interface-name stems of virtual machine and container bridges: `docker0`,
2710
+ * `vethXXXX`, `br-<id>`, `virbr0`, `vmnet8`, `vboxnet0` and the Hyper-V
2711
+ * `vEthernet` adapters. Their RFC1918 addresses belong to a NAT the host
2712
+ * shares with its own guests, so a phone on the physical LAN cannot reach
2713
+ * them; `bridge0`/`br0` are deliberately absent because a real bridged LAN
2714
+ * (macOS Internet Sharing) is exactly the address a phone does reach.
2715
+ */
2716
+ const VIRTUAL_INTERFACE_PREFIXES = [
2717
+ "docker",
2718
+ "veth",
2719
+ "virbr",
2720
+ "vmnet",
2721
+ "vboxnet",
2722
+ "vethernet",
2723
+ "br-"
2724
+ ];
2725
+ /**
2726
+ * IPv4 ranges no peer on the local network can reach. The list is a denial
2727
+ * list rather than an RFC1918 allowlist on purpose: a host whose only
2728
+ * interface is a routable public literal (a VPS, a standalone server) would
2729
+ * otherwise lose its only constructible base and fall back to the
2730
+ * `lan-required` banner.
2731
+ * @param octets - the four address octets, in order.
2732
+ * @returns true when an address in this range must not be advertised.
2733
+ */
2734
+ function isUnreachableIPv4(octets) {
2735
+ const [a = 0, b = 0] = octets;
2736
+ if (a === 0) return true;
2737
+ if (a === 127) return true;
2738
+ if (a === 100 && b >= 64 && b <= 127) return true;
2739
+ if (a === 169 && b === 254) return true;
2740
+ if (a === 198 && (b === 18 || b === 19)) return true;
2741
+ if (a >= 224) return true;
2742
+ return false;
2743
+ }
2744
+ /**
2745
+ * Whether an interface name belongs to a tunnel, VPN adapter, or virtual
2746
+ * machine/container bridge, so its address is not an authority a LAN peer
2747
+ * can dial.
2748
+ * @param name - the interface name from `os.networkInterfaces()`.
2749
+ * @returns true when the interface must not be advertised.
2750
+ */
2751
+ function isUnadvertisableInterface(name) {
2752
+ const lower = name.toLowerCase();
2753
+ return [...TUNNEL_INTERFACE_PREFIXES, ...VIRTUAL_INTERFACE_PREFIXES].some((prefix) => lower.startsWith(prefix));
2754
+ }
2755
+ /**
2756
+ * Whether an IPv4 literal can be advertised to a pairing peer.
2757
+ * @param address - a dotted-quad IPv4 literal.
2758
+ * @returns true when a peer could plausibly reach it.
2759
+ */
2760
+ function isAdvertisableIPv4(address) {
2761
+ const parts = address.split(".");
2762
+ if (parts.length !== 4) return false;
2763
+ const octets = parts.map((part) => /^\d{1,3}$/.test(part) ? Number(part) : NaN);
2764
+ if (octets.some((octet) => !Number.isInteger(octet) || octet > 255)) return false;
2765
+ return !isUnreachableIPv4(octets);
2766
+ }
2767
+ /**
2768
+ * Every IPv4 interface address this machine can advertise to a pairing peer:
2769
+ * non-internal, on a non-tunnel and non-virtual interface, outside the ranges
2770
+ * no peer can route to.
2771
+ * @param interfaces - the sampled interface map (defaults to the live one).
2772
+ * @returns the candidates in interface order (possibly empty).
2478
2773
  */
2774
+ function lanIPv4Candidates(interfaces = networkInterfaces()) {
2775
+ const candidates = [];
2776
+ for (const [name, addresses] of Object.entries(interfaces)) {
2777
+ if (isUnadvertisableInterface(name)) continue;
2778
+ for (const iface of addresses ?? []) {
2779
+ if (iface.family !== "IPv4" || iface.internal) continue;
2780
+ if (!isAdvertisableIPv4(iface.address)) continue;
2781
+ candidates.push({
2782
+ iface: name,
2783
+ address: iface.address
2784
+ });
2785
+ }
2786
+ }
2787
+ return candidates;
2788
+ }
2479
2789
  /**
2480
- * Non-internal IPv4 interface addresses of this machine — the IP-literal
2481
- * authorities an all-interfaces bind is reachable by on the LAN.
2790
+ * Non-internal, reachable IPv4 interface addresses of this machine — the
2791
+ * IP-literal authorities an all-interfaces bind is reachable by on the LAN.
2792
+ * @param interfaces - the sampled interface map (defaults to the live one).
2482
2793
  * @returns the addresses in interface order (possibly empty).
2483
2794
  */
2484
- function lanIPv4Addresses() {
2485
- return Object.values(networkInterfaces()).flat().filter((iface) => {
2486
- return iface !== void 0 && iface.family === "IPv4" && !iface.internal;
2487
- }).map((iface) => iface.address);
2795
+ function lanIPv4Addresses(interfaces = networkInterfaces()) {
2796
+ return lanIPv4Candidates(interfaces).map((candidate) => candidate.address);
2488
2797
  }
2489
2798
  //#endregion
2490
2799
  //#region src/firewall.ts
@@ -2979,11 +3288,18 @@ function isHttpUrl(value) {
2979
3288
  * token AND a valid public hostname (the token does not carry the hostname,
2980
3289
  * so the QR base and the fence trust would have no source without it);
2981
3290
  * otherwise no tunnel and the manual public base applies.
3291
+ *
3292
+ * The quick target URL names the local webServer port, so a plan made before
3293
+ * the listener binds cannot be quick: it comes back `off` and the caller —
3294
+ * which can observe the port settle — re-plans. The planner itself never
3295
+ * emits an `http://127.0.0.1:undefined` target.
2982
3296
  * @param value - the resolved settings slice that drives the tunnel.
2983
- * @param port - the local webServer port the tunnel forwards to.
3297
+ * @param port - the local webServer port the tunnel forwards to (undefined
3298
+ * before the listener binds).
2984
3299
  */
2985
3300
  function tunnelPlanOf(value, port) {
2986
3301
  if (value.autoTunnel === true) {
3302
+ if (port === void 0) return { mode: "off" };
2987
3303
  const ignored = [];
2988
3304
  if (value.tunnelToken !== void 0 && value.tunnelToken !== "") ignored.push("tunnelToken");
2989
3305
  if (value.publicBaseUrl !== void 0 && value.publicBaseUrl !== "") ignored.push("publicBaseUrl");
@@ -4000,12 +4316,12 @@ function mountOnce(packageName, fn) {
4000
4316
  /**
4001
4317
  * Run one piece of work outside the caller's async context.
4002
4318
  *
4003
- * The Host performs a settings mutation inside `hmr.runExclusive`, which marks
4004
- * the running async context with an AsyncLocalStorage store. Side effects that
4005
- * live on that path - writing `cordis.patch.yml`, the very file the HMR config
4006
- * watcher refreshes from - must not run under that mark, or the watcher's
4007
- * refresh can re-enter the exclusive transaction and be refused with "HMR
4008
- * transactions cannot be nested" (#1751, #1754).
4319
+ * A Host mutation that runs inside `hmr.runExclusive` marks the running async
4320
+ * context with an AsyncLocalStorage store. Side effects on that path that write
4321
+ * a file the HMR config watcher refreshes from - `cordis.patch.yml` above all -
4322
+ * must not run under that mark, or the watcher's refresh re-enters the
4323
+ * exclusive transaction and is refused with "HMR transactions cannot be nested"
4324
+ * (#1751, #1754, #1816).
4009
4325
  *
4010
4326
  * Deferring is NOT enough, and the earlier belt that this one replaces was
4011
4327
  * built on a disproven premise: Node propagates an AsyncLocalStorage store into
@@ -4027,7 +4343,7 @@ function mountOnce(packageName, fn) {
4027
4343
  * // -> undefined
4028
4344
  * ```
4029
4345
  *
4030
- * @module
4346
+ * @module dsh-web-shared/host/detached-work
4031
4347
  */
4032
4348
  /**
4033
4349
  * The escape hatch, created at module scope on purpose: a resource built while
@@ -4035,7 +4351,7 @@ function mountOnce(packageName, fn) {
4035
4351
  * instance is the only one the module ever needs. It carries no context of its
4036
4352
  * own, so every job run through it starts from the process's root context.
4037
4353
  */
4038
- const DETACHED = new AsyncResource("dsh-remote-web-ui:detached-work", { triggerAsyncId: 0 });
4354
+ const DETACHED = new AsyncResource("dsh-web:detached-work", { triggerAsyncId: 0 });
4039
4355
  /**
4040
4356
  * Run `work` outside the caller's AsyncLocalStorage context.
4041
4357
  *
@@ -4095,7 +4411,7 @@ function buildRemoteChannelBootScript(rules = REMOTE_CHANNEL_RULES) {
4095
4411
  }
4096
4412
  /** Boot-watchdog latch: one self-reload per session while the boot is broken. */
4097
4413
  const BOOT_WATCHDOG_KEY = "dsh-remote-boot-reload";
4098
- /** Total wait for the app's first conversation surface before the reload. */
4414
+ /** Post-load wait for the app's first conversation surface before the reload. */
4099
4415
  const BOOT_WATCHDOG_WAIT_MS = 15e3;
4100
4416
  /** Watchdog poll cadence. */
4101
4417
  const BOOT_WATCHDOG_POLL_MS = 1e3;
@@ -4110,7 +4426,7 @@ const BOOT_WATCHDOG_POLL_MS = 1e3;
4110
4426
  */
4111
4427
  function buildBootWatchdogScript(waitMs = BOOT_WATCHDOG_WAIT_MS, pollMs = BOOT_WATCHDOG_POLL_MS) {
4112
4428
  const key = JSON.stringify(BOOT_WATCHDOG_KEY);
4113
- return ";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}}function wTick(n){try{if(wBoot()){try{w.sessionStorage.removeItem(" + key + ")}catch(e){}return}if(n<" + waitMs + "){w.setTimeout(function(){wTick(n+" + pollMs + ")}," + pollMs + ");return}var done=false;try{done=w.sessionStorage.getItem(" + key + ")===\"1\"}catch(e){}if(done)return;try{w.sessionStorage.setItem(" + key + ",\"1\")}catch(e){}w.location.reload()}catch(e){}}if(typeof w.setTimeout===\"function\")w.setTimeout(function(){wTick(0)}," + pollMs + ");";
4429
+ return ";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}}function wTick(n){try{if(wBoot()){try{w.sessionStorage.removeItem(" + key + ")}catch(e){}return}if(!w.document||w.document.readyState!==\"complete\"){w.setTimeout(function(){wTick(0)}," + pollMs + ");return}if(n<" + waitMs + "){w.setTimeout(function(){wTick(n+" + pollMs + ")}," + pollMs + ");return}var done=false;try{done=w.sessionStorage.getItem(" + key + ")===\"1\"}catch(e){}if(done)return;try{w.sessionStorage.setItem(" + key + ",\"1\")}catch(e){}w.location.reload()}catch(e){}}if(typeof w.setTimeout===\"function\")w.setTimeout(function(){wTick(0)}," + pollMs + ");";
4114
4430
  }
4115
4431
  /** The script this plugin contributes; built once from the live rules. */
4116
4432
  const REMOTE_CHANNEL_BOOT_SCRIPT = buildRemoteChannelBootScript();
@@ -4206,10 +4522,16 @@ const Config = z.object({
4206
4522
  relay: z.boolean().default(true).volatile(),
4207
4523
  lanBind: z.boolean().volatile(),
4208
4524
  profile: z.string().pattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
4209
- enabled: z.boolean().default(true).volatile()
4525
+ enabled: z.boolean().default(true).volatile(),
4526
+ pairingSource: z.union([...PAIRING_SOURCE_OPTIONS]).default("auto").volatile()
4210
4527
  });
4211
4528
  /** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
4212
4529
  const SWEEP_INTERVAL_MS = 1e4;
4530
+ /**
4531
+ * Re-plan retry while the quick tunnel waits for the webServer port to bind
4532
+ * (a recompose pass can run this apply ahead of the listener's bind).
4533
+ */
4534
+ const PORT_SETTLE_RETRY_MS = 1e3;
4213
4535
  /** Read one resolved config field, following the live reference the schema produces. */
4214
4536
  function readConfigField(field, fallback) {
4215
4537
  if (field === void 0) return fallback;
@@ -4264,7 +4586,8 @@ const DEFAULTS = {
4264
4586
  relay: true,
4265
4587
  lanBind: void 0,
4266
4588
  profile: resolveManagedProfile(void 0, void 0, process.env.DSH_PROFILE),
4267
- enabled: true
4589
+ enabled: true,
4590
+ pairingSource: "auto"
4268
4591
  };
4269
4592
  /**
4270
4593
  * The launched profile the Host publishes on its `profileContext` service
@@ -4302,7 +4625,8 @@ function applyImpl(ctx, config) {
4302
4625
  relay: readConfigField(config?.relay, DEFAULTS.relay),
4303
4626
  lanBind: readOptionalConfigField(config?.lanBind),
4304
4627
  profile: resolveManagedProfile(readOptionalConfigField(config?.profile), launchedProfileName(ctx), process.env.DSH_PROFILE),
4305
- enabled: readConfigField(config?.enabled, DEFAULTS.enabled)
4628
+ enabled: readConfigField(config?.enabled, DEFAULTS.enabled),
4629
+ pairingSource: readConfigField(config?.pairingSource, DEFAULTS.pairingSource)
4306
4630
  });
4307
4631
  const service = new PairingService(pairingConfigOf(resolve()));
4308
4632
  const tunnel = new TunnelManager();
@@ -4346,6 +4670,14 @@ function applyImpl(ctx, config) {
4346
4670
  registrar.announce(target);
4347
4671
  };
4348
4672
  let tunnelMode = resolve().autoTunnel ? "quick" : "off";
4673
+ /**
4674
+ * Retry timer while the quick tunnel waits for the webServer port to bind
4675
+ * (undefined when no deferral is pending). Unref'd: never keeps the
4676
+ * process alive on its own.
4677
+ */
4678
+ let portSettleTimer;
4679
+ /** Dedupes the unbound-port warning to one line per unsettled episode. */
4680
+ let portSettleWarned = false;
4349
4681
  tunnel.onPhase((info) => {
4350
4682
  if (tunnelMode === "off") return;
4351
4683
  if (info.phase === "running" && info.url !== void 0) {
@@ -4372,6 +4704,7 @@ function applyImpl(ctx, config) {
4372
4704
  disposeRelayRegistrar();
4373
4705
  publicBase.dispose();
4374
4706
  tunnel.dispose();
4707
+ if (portSettleTimer !== void 0) clearTimeout(portSettleTimer);
4375
4708
  }, "remote-web-ui: auto tunnel");
4376
4709
  if ((ctx.webServer.host === "0.0.0.0" || ctx.webServer.host === "127.0.0.1") && Number.isFinite(ctx.webServer.port)) {
4377
4710
  const lanBases = ctx.webServer.host === "0.0.0.0" ? lanIPv4Addresses().map((address) => ({
@@ -4445,6 +4778,7 @@ function applyImpl(ctx, config) {
4445
4778
  const routes = [...makeRoutes({
4446
4779
  service,
4447
4780
  requirePairingForLan: () => resolve().requirePairingForLan,
4781
+ pairingSource: () => resolve().pairingSource,
4448
4782
  lanBindStatus,
4449
4783
  indexDocument: fetchAppShell,
4450
4784
  trustedHosts: () => {
@@ -4465,12 +4799,12 @@ function applyImpl(ctx, config) {
4465
4799
  }
4466
4800
  }), ...makeRemoteApiRoutes({
4467
4801
  service,
4468
- port: ctx.webServer.port,
4802
+ port: () => ctx.webServer.port,
4469
4803
  auth: innerAuth
4470
4804
  })];
4471
4805
  const upgrades = makeRemoteApiUpgradeRoutes({
4472
4806
  service,
4473
- port: ctx.webServer.port,
4807
+ port: () => ctx.webServer.port,
4474
4808
  auth: innerAuth
4475
4809
  });
4476
4810
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled);
@@ -4479,7 +4813,9 @@ function applyImpl(ctx, config) {
4479
4813
  let postureWasExposed = false;
4480
4814
  const runPostureProbe = () => {
4481
4815
  if (!resolve().enabled) return;
4482
- const targets = postureTargets(service.publicBaseUrl, service.lanAddresses, ctx.webServer.port);
4816
+ const port = ctx.webServer.port;
4817
+ if (port === void 0) return;
4818
+ const targets = postureTargets(service.publicBaseUrl, service.lanAddresses, port);
4483
4819
  if (targets.length === 0) {
4484
4820
  postureKey = void 0;
4485
4821
  service.setPosture(void 0);
@@ -4490,7 +4826,7 @@ function applyImpl(ctx, config) {
4490
4826
  if (!claim.run) return;
4491
4827
  postureKey = claim.next;
4492
4828
  probePosture({
4493
- port: ctx.webServer.port,
4829
+ port,
4494
4830
  targets
4495
4831
  }).then((snapshot) => {
4496
4832
  service.setPosture(snapshot);
@@ -4573,11 +4909,8 @@ function applyImpl(ctx, config) {
4573
4909
  ctx.effect(() => () => {
4574
4910
  lanBindWorkPending = false;
4575
4911
  }, "remote-web-ui: lan-bind work");
4576
- const sync = () => {
4577
- const value = resolve();
4578
- service.config = pairingConfigOf(value);
4579
- scheduleLanBindWork();
4580
- const plan = tunnelPlanOf(value, ctx.webServer.port);
4912
+ const applyTunnelPlan = (value, port) => {
4913
+ const plan = tunnelPlanOf(value, port);
4581
4914
  tunnelMode = plan.mode;
4582
4915
  publicBase.setMode(plan.mode);
4583
4916
  const liveTunnelUrl = publicBase.quickUrl();
@@ -4615,6 +4948,28 @@ function applyImpl(ctx, config) {
4615
4948
  service.setPublicBaseUrl(void 0);
4616
4949
  } else service.setPublicBaseUrl(value.publicBaseUrl);
4617
4950
  }
4951
+ };
4952
+ const sync = () => {
4953
+ const value = resolve();
4954
+ service.config = pairingConfigOf(value);
4955
+ scheduleLanBindWork();
4956
+ const livePort = ctx.webServer.port;
4957
+ if (livePort === void 0 && value.autoTunnel === true) {
4958
+ if (!portSettleWarned) {
4959
+ portSettleWarned = true;
4960
+ console.warn("remote-web-ui: autoTunnel is on but the web server port is not bound yet — the quick tunnel starts once the port settles");
4961
+ }
4962
+ if (portSettleTimer === void 0) {
4963
+ portSettleTimer = setTimeout$1(() => {
4964
+ portSettleTimer = void 0;
4965
+ sync();
4966
+ }, PORT_SETTLE_RETRY_MS);
4967
+ portSettleTimer.unref();
4968
+ }
4969
+ } else {
4970
+ portSettleWarned = false;
4971
+ applyTunnelPlan(value, livePort);
4972
+ }
4618
4973
  const enabled = value.enabled;
4619
4974
  if (!enabled) {
4620
4975
  service.stop();