@linxin666/dsh-remote-web-ui 0.3.12 → 0.3.14

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 +26 -6
  3. package/README.zh.md +26 -6
  4. package/lib/client.js +77 -12
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +412 -52
  7. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  8. package/lib/types/client/RemoteEntry.js +1 -0
  9. package/lib/types/client/RemotePanel.d.ts +3 -1
  10. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  11. package/lib/types/client/RemotePanel.js +1 -1
  12. package/lib/types/client/RemoteSettingsCard.d.ts +16 -0
  13. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/RemoteSettingsCard.js +6 -2
  15. package/lib/types/client/locales.d.ts +12 -0
  16. package/lib/types/client/locales.d.ts.map +1 -1
  17. package/lib/types/client/locales.js +16 -4
  18. package/lib/types/client/pair-api.d.ts +10 -0
  19. package/lib/types/client/pair-api.d.ts.map +1 -1
  20. package/lib/types/client/remote-channel.d.ts +0 -2
  21. package/lib/types/client/remote-channel.d.ts.map +1 -1
  22. package/lib/types/client/remote-channel.js +0 -4
  23. package/lib/types/index.d.ts +28 -2
  24. package/lib/types/index.d.ts.map +1 -1
  25. package/lib/types/index.js +138 -23
  26. package/lib/types/pairing.d.ts +17 -0
  27. package/lib/types/pairing.d.ts.map +1 -1
  28. package/lib/types/pairing.js +13 -0
  29. package/lib/types/relay-registry.d.ts +97 -0
  30. package/lib/types/relay-registry.d.ts.map +1 -0
  31. package/lib/types/relay-registry.js +191 -0
  32. package/lib/types/remote-api.d.ts +1 -1
  33. package/lib/types/remote-api.d.ts.map +1 -1
  34. package/lib/types/remote-api.js +1 -1
  35. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  36. package/lib/types/remote-channel-boot.js +0 -1
  37. package/lib/types/remote-channel-rules.d.ts +0 -1
  38. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  39. package/lib/types/remote-channel-rules.js +0 -1
  40. package/lib/types/remote-methods.d.ts +1 -4
  41. package/lib/types/remote-methods.d.ts.map +1 -1
  42. package/lib/types/remote-methods.js +1 -5
  43. package/lib/types/tunnel-plan.d.ts +36 -0
  44. package/lib/types/tunnel-plan.d.ts.map +1 -0
  45. package/lib/types/tunnel-plan.js +43 -0
  46. package/lib/types/tunnel.d.ts +66 -13
  47. package/lib/types/tunnel.d.ts.map +1 -1
  48. package/lib/types/tunnel.js +93 -21
  49. package/package.json +18 -18
  50. package/src/client/RemoteEntry.tsx +2 -1
  51. package/src/client/RemotePanel.tsx +11 -0
  52. package/src/client/RemoteSettingsCard.tsx +43 -1
  53. package/src/client/locales.ts +16 -4
  54. package/src/client/pair-api.ts +11 -0
  55. package/src/client/remote-channel.ts +0 -3
  56. package/src/client/settings-card.module.css +14 -8
  57. package/src/index.ts +153 -25
  58. package/src/pairing.ts +30 -0
  59. package/src/relay-registry.ts +232 -0
  60. package/src/remote-api.ts +0 -1
  61. package/src/remote-channel-boot.ts +0 -1
  62. package/src/remote-channel-rules.ts +0 -2
  63. package/src/remote-methods.ts +1 -6
  64. package/src/tunnel-plan.ts +51 -0
  65. package/src/tunnel.ts +120 -24
package/lib/index.js CHANGED
@@ -11,6 +11,7 @@ import { z as z$1 } from "zod";
11
11
  import http, { request } from "node:http";
12
12
  import { connect } from "node:net";
13
13
  import { spawn, spawnSync } from "node:child_process";
14
+ import { EventEmitter } from "node:events";
14
15
  import { Tunnel, bin, install } from "cloudflared";
15
16
  //#region src/pairing.ts
16
17
  /**
@@ -86,6 +87,7 @@ var PairingService = class {
86
87
  publicBase;
87
88
  /** Auto-tunnel status, while the auto-tunnel feature is active. */
88
89
  tunnelStatus;
90
+ relayStatus;
89
91
  posture;
90
92
  /** True when lastSeenAt changed since the last persist (flushed on sweep). */
91
93
  dirty = false;
@@ -201,6 +203,11 @@ var PairingService = class {
201
203
  this.tunnelStatus = status;
202
204
  this.notify();
203
205
  }
206
+ /** Set or clear the relay-registry status frame (undefined when not in play). */
207
+ setRelayStatus(status) {
208
+ this.relayStatus = status;
209
+ this.notify();
210
+ }
204
211
  /** Set the latest /api posture probe result (see posture.ts). */
205
212
  setPosture(snapshot) {
206
213
  this.posture = snapshot;
@@ -349,6 +356,7 @@ var PairingService = class {
349
356
  lanAddresses: [...this.lanBases.keys()],
350
357
  ...this.publicBase !== void 0 ? { publicUrl: this.publicBase } : {},
351
358
  ...this.tunnelStatus !== void 0 ? { tunnel: this.tunnelStatus } : {},
359
+ ...this.relayStatus !== void 0 ? { relay: this.relayStatus } : {},
352
360
  ...this.posture !== void 0 ? { posture: this.posture } : {},
353
361
  ...token !== void 0 ? {
354
362
  tokenId: token.record.id,
@@ -430,7 +438,7 @@ var PairingService = class {
430
438
  };
431
439
  /** Structural equality over the snapshot's wire fields. */
432
440
  function snapshotsEqual(a, b) {
433
- return a.phase === b.phase && a.lanAvailable === b.lanAvailable && sameStrings(a.lanAddresses, b.lanAddresses) && a.publicUrl === b.publicUrl && tunnelEqual(a.tunnel, b.tunnel) && a.tokenId === b.tokenId && a.tokenExpiresAt === b.tokenExpiresAt && a.deviceCount === b.deviceCount && a.onlineCount === b.onlineCount && devicesEqual(a.devices, b.devices);
441
+ return a.phase === b.phase && a.lanAvailable === b.lanAvailable && sameStrings(a.lanAddresses, b.lanAddresses) && a.publicUrl === b.publicUrl && tunnelEqual(a.tunnel, b.tunnel) && relayEqual(a.relay, b.relay) && a.tokenId === b.tokenId && a.tokenExpiresAt === b.tokenExpiresAt && a.deviceCount === b.deviceCount && a.onlineCount === b.onlineCount && devicesEqual(a.devices, b.devices);
434
442
  }
435
443
  /** Per-device roster equality (order is pairing time). */
436
444
  function devicesEqual(a, b) {
@@ -443,6 +451,10 @@ function devicesEqual(a, b) {
443
451
  function tunnelEqual(a, b) {
444
452
  return a === b || a !== void 0 && b !== void 0 && a.state === b.state && a.url === b.url && a.error === b.error;
445
453
  }
454
+ /** Relay frame equality (same shape as the tunnel frame). */
455
+ function relayEqual(a, b) {
456
+ return a === b || a !== void 0 && b !== void 0 && a.state === b.state && a.url === b.url && a.error === b.error;
457
+ }
446
458
  /** Element-wise string list equality (interface order is meaningful). */
447
459
  function sameStrings(a, b) {
448
460
  return a.length === b.length && a.every((value, index) => value === b[index]);
@@ -1565,8 +1577,6 @@ const REMOTE_UPGRADE_PATHS = [
1565
1577
  ];
1566
1578
  /** Plugin-manager HTTP prefix: install/remove stay physically local. */
1567
1579
  const PLUGIN_MANAGER_PATH = "/api/plugin-manager";
1568
- /** Desktop-launcher HTTP prefix: shortcut create and host shutdown stay physically local. */
1569
- const DESKTOP_LAUNCHER_PATH = "/api/dsh-desktop-launcher";
1570
1580
  /**
1571
1581
  * The cookieless device credential: the boot patch reads the device id from
1572
1582
  * the /pair-app URL, keeps it in sessionStorage, and attaches it to every
@@ -1583,14 +1593,12 @@ const REMOTE_DEVICE_QUERY = "device";
1583
1593
  * paired remote desktop may use the full host API (chat, sessions,
1584
1594
  * settings, credentials, presets — it is a full-control credential), but it
1585
1595
  * must not reach the machine-control planes: pairing control itself, the
1586
- * dsh-web self-update installer, plugin install/remove, and desktop
1587
- * launcher actions (host shutdown, shortcuts).
1596
+ * dsh-web self-update installer, and plugin install/remove.
1588
1597
  */
1589
1598
  const LOCAL_ONLY_PREFIXES = [
1590
1599
  "/api/pair",
1591
1600
  "/api/update",
1592
- PLUGIN_MANAGER_PATH,
1593
- DESKTOP_LAUNCHER_PATH
1601
+ PLUGIN_MANAGER_PATH
1594
1602
  ];
1595
1603
  /**
1596
1604
  * Whether a paired inner path must stay physically local.
@@ -2308,6 +2316,224 @@ function writeLanBind(host, port, profile, home = dshHome()) {
2308
2316
  renameSync(temp, file);
2309
2317
  }
2310
2318
  //#endregion
2319
+ //#region src/tunnel-plan.ts
2320
+ /**
2321
+ * Pure planner for the settings-sync tunnel branch: given the resolved
2322
+ * settings slice, decide which tunnel mode runs and with what target. Kept
2323
+ * free of cordis types so the precedence matrix is unit-testable without a
2324
+ * host context.
2325
+ */
2326
+ /** Whether a configured public base is a parseable http(s) URL with a host. */
2327
+ function isHttpUrl(value) {
2328
+ try {
2329
+ const url = new URL(value);
2330
+ return (url.protocol === "http:" || url.protocol === "https:") && url.hostname !== "";
2331
+ } catch {
2332
+ return false;
2333
+ }
2334
+ }
2335
+ /**
2336
+ * Decide the tunnel mode and target from one resolved settings value.
2337
+ * Precedence: the quick tunnel wins when `autoTunnel` is on (anything else
2338
+ * configured is reported as ignored); the named tunnel runs only with a
2339
+ * token AND a valid public hostname (the token does not carry the hostname,
2340
+ * so the QR base and the fence trust would have no source without it);
2341
+ * otherwise no tunnel and the manual public base applies.
2342
+ * @param value - the resolved settings slice that drives the tunnel.
2343
+ * @param port - the local webServer port the tunnel forwards to.
2344
+ */
2345
+ function tunnelPlanOf(value, port) {
2346
+ if (value.autoTunnel === true) {
2347
+ const ignored = [];
2348
+ if (value.tunnelToken !== void 0 && value.tunnelToken !== "") ignored.push("tunnelToken");
2349
+ if (value.publicBaseUrl !== void 0 && value.publicBaseUrl !== "") ignored.push("publicBaseUrl");
2350
+ return {
2351
+ mode: "quick",
2352
+ targetUrl: `http://127.0.0.1:${String(port)}`,
2353
+ ignored
2354
+ };
2355
+ }
2356
+ if (value.tunnelToken !== void 0 && value.tunnelToken !== "") {
2357
+ if (value.publicBaseUrl !== void 0 && isHttpUrl(value.publicBaseUrl)) return {
2358
+ mode: "named",
2359
+ token: value.tunnelToken,
2360
+ publicUrl: value.publicBaseUrl
2361
+ };
2362
+ return { mode: "off" };
2363
+ }
2364
+ return { mode: "off" };
2365
+ }
2366
+ const RELAY_UNREGISTER_URL = "https://dsh-market.com/api/relay/unregister";
2367
+ /**
2368
+ * The public origin template: one stable single-label subdomain per
2369
+ * registration id. Single label on purpose — Universal SSL covers exactly
2370
+ * one subdomain level, so a two-level hostname would get no certificate.
2371
+ */
2372
+ const RELAY_BASE_SUFFIX = ".dsh-market.com";
2373
+ const RELAY_ID_RE = /^[a-z0-9]{16}$/;
2374
+ const RELAY_SECRET_RE = /^[A-Za-z0-9_-]{43}$/;
2375
+ /** Registry storage layout: one file per profile under the relay directory. */
2376
+ function relayIdentityFile(profile, home = dshHome()) {
2377
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(profile)) throw new Error(`relay registry: profile ${JSON.stringify(profile)} is not a safe path segment`);
2378
+ return join(home, "remote-web-ui-registry", `${profile}.json`);
2379
+ }
2380
+ /** Generate one identity pair (id: 8-byte hex slug; secret: 32-byte base64url). */
2381
+ function generateRelayIdentity(random = randomBytes) {
2382
+ return {
2383
+ id: random(8).toString("hex"),
2384
+ secret: random(32).toString("base64url"),
2385
+ fresh: true
2386
+ };
2387
+ }
2388
+ /**
2389
+ * Load (or lazily mint) the per-profile relay identity. The file is written
2390
+ * owner-only on first mint; a corrupt file is reminted (the phone then
2391
+ * re-pairs once — the same cost as losing the devices file).
2392
+ */
2393
+ function loadRelayIdentity(profile, home = dshHome(), random = randomBytes) {
2394
+ const file = relayIdentityFile(profile, home);
2395
+ try {
2396
+ if (existsSync(file)) {
2397
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
2398
+ if (typeof parsed.id === "string" && RELAY_ID_RE.test(parsed.id) && typeof parsed.secret === "string" && RELAY_SECRET_RE.test(parsed.secret)) return {
2399
+ id: parsed.id,
2400
+ secret: parsed.secret
2401
+ };
2402
+ console.warn(`relay registry: ${file} is malformed — reminting the identity (paired devices must scan the QR once more)`);
2403
+ }
2404
+ } catch (error) {
2405
+ console.warn(`relay registry: could not read ${file} (${error instanceof Error ? error.message : String(error)}) — reminting`);
2406
+ }
2407
+ const identity = generateRelayIdentity(random);
2408
+ mkdirSync(dirname(file), { recursive: true });
2409
+ writeFileSync(file, JSON.stringify({
2410
+ id: identity.id,
2411
+ secret: identity.secret
2412
+ }, null, 2) + "\n", { mode: 384 });
2413
+ return identity;
2414
+ }
2415
+ /** Build the registration request body for one identity + tunnel target. */
2416
+ function registrationBody(identity, target) {
2417
+ return JSON.stringify({
2418
+ id: identity.id,
2419
+ secret: identity.secret,
2420
+ new_secret: identity.fresh === true ? identity.secret : void 0,
2421
+ target
2422
+ });
2423
+ }
2424
+ /** Best-effort removal of the mapping (used on disable; errors are swallowed). */
2425
+ async function unregisterRelay(identity, endpoint = RELAY_UNREGISTER_URL, fetchFn = fetch) {
2426
+ await fetchFn(endpoint, {
2427
+ method: "POST",
2428
+ headers: { "content-type": "application/json" },
2429
+ body: JSON.stringify({
2430
+ id: identity.id,
2431
+ secret: identity.secret
2432
+ })
2433
+ });
2434
+ }
2435
+ /**
2436
+ * The stable relay base for one identity — the origin the QR and the phone
2437
+ * bookmark use while (or after) the registration is accepted.
2438
+ */
2439
+ function relayBaseOf(identity) {
2440
+ return `https://${identity.id}${RELAY_BASE_SUFFIX}`;
2441
+ }
2442
+ /**
2443
+ * Registration loop with capped exponential backoff. Re-registration happens
2444
+ * on every tunnel start (the quick URL can change after a crash-restart);
2445
+ * failures retry with backoff and surface the last error to the card.
2446
+ */
2447
+ var RelayRegistrar = class {
2448
+ identity;
2449
+ setState;
2450
+ options;
2451
+ timer;
2452
+ attempts = 0;
2453
+ lastError;
2454
+ disposed = false;
2455
+ constructor(identity, setState, options = {}) {
2456
+ this.identity = identity;
2457
+ this.setState = setState;
2458
+ this.options = options;
2459
+ }
2460
+ /** The stable relay base for the QR while registered. */
2461
+ get baseUrl() {
2462
+ return relayBaseOf(this.identity);
2463
+ }
2464
+ /** The last registration failure detail (undefined while healthy). */
2465
+ get error() {
2466
+ return this.lastError;
2467
+ }
2468
+ /** Push one tunnel target; resolves the stable base once accepted. */
2469
+ async announce(target) {
2470
+ if (this.disposed) return void 0;
2471
+ if (this.timer !== void 0) {
2472
+ clearTimeout(this.timer);
2473
+ this.timer = void 0;
2474
+ }
2475
+ this.setState({ state: "registering" });
2476
+ try {
2477
+ const response = await (this.options.fetchFn ?? fetch)(this.options.registerUrl ?? "https://dsh-market.com/api/relay/register", {
2478
+ method: "PUT",
2479
+ headers: { "content-type": "application/json" },
2480
+ body: registrationBody(this.identity, target)
2481
+ });
2482
+ if (!response.ok) {
2483
+ const detail = await response.json().catch(() => void 0);
2484
+ const error = detail && typeof detail.error === "string" ? detail.error : `HTTP ${String(response.status)}`;
2485
+ throw new Error(error);
2486
+ }
2487
+ this.identity.fresh = false;
2488
+ this.attempts = 0;
2489
+ this.lastError = void 0;
2490
+ this.setState({
2491
+ state: "running",
2492
+ url: this.baseUrl
2493
+ });
2494
+ return this.baseUrl;
2495
+ } catch (error) {
2496
+ this.lastError = error instanceof Error ? error.message : String(error);
2497
+ if (this.lastError === "invalid-params" && this.identity.fresh !== true) this.identity.fresh = true;
2498
+ this.setState({
2499
+ state: "failed",
2500
+ error: this.lastError
2501
+ });
2502
+ this.scheduleRetry(target);
2503
+ return;
2504
+ }
2505
+ }
2506
+ /** Best-effort removal of the registry row (the relay toggle turned off). */
2507
+ async unregister() {
2508
+ await unregisterRelay(this.identity, this.options.unregisterUrl, this.options.fetchFn);
2509
+ }
2510
+ scheduleRetry(target) {
2511
+ if (this.disposed) return;
2512
+ const base = this.options.baseDelayMs ?? 5e3;
2513
+ const max = this.options.maxDelayMs ?? 6e4;
2514
+ const delay = Math.min(base * 2 ** this.attempts, max);
2515
+ this.attempts += 1;
2516
+ const timerFn = this.options.timerFn ?? ((fn, ms) => {
2517
+ const timer = setTimeout(fn, ms);
2518
+ timer.unref?.();
2519
+ return timer;
2520
+ });
2521
+ this.timer = timerFn(() => {
2522
+ this.timer = void 0;
2523
+ this.announce(target);
2524
+ }, delay);
2525
+ }
2526
+ /** Stop retrying and clear the card state (tunnel stopped or disabled). */
2527
+ dispose() {
2528
+ this.disposed = true;
2529
+ if (this.timer !== void 0) {
2530
+ clearTimeout(this.timer);
2531
+ this.timer = void 0;
2532
+ }
2533
+ this.setState({ state: "off" });
2534
+ }
2535
+ };
2536
+ //#endregion
2311
2537
  //#region src/lan-bind-plan.ts
2312
2538
  /**
2313
2539
  * The bind host the managed block should pin: an explicit CLI --host wins,
@@ -2406,11 +2632,14 @@ function createInnerAuth(launchUrl, fetchImpl = fetch) {
2406
2632
  //#endregion
2407
2633
  //#region src/tunnel.ts
2408
2634
  /**
2409
- * Auto-tunnel manager: spawns a Cloudflare quick tunnel (`cloudflared
2410
- * tunnel --url <local>`) through the `cloudflared` npm package — its
2411
- * postinstall downloads the platform binary, so no user-side tooling is
2412
- * involved — surfaces the minted `https://xxx.trycloudflare.com` URL, and
2413
- * restarts the process after unexpected exits with exponential backoff.
2635
+ * Auto-tunnel manager: spawns a Cloudflare tunnel through the `cloudflared`
2636
+ * npm package — its postinstall downloads the platform binary, so no
2637
+ * user-side tooling is involved — surfaces the public URL, and restarts the
2638
+ * process after unexpected exits with exponential backoff. Two modes: the
2639
+ * accountless quick tunnel (`https://xxx.trycloudflare.com`, hostname minted
2640
+ * per start) and an account named tunnel (`cloudflared tunnel run --token`,
2641
+ * fixed public hostname — the mode that lets a paired phone keep its
2642
+ * bookmark and pairing cookie across restarts).
2414
2643
  *
2415
2644
  * The cloudflared package's Tunnel is a thin spawn wrapper; this manager
2416
2645
  * owns the lifecycle policy (binary readiness, URL timeout, restart
@@ -2418,17 +2647,75 @@ function createInnerAuth(launchUrl, fetchImpl = fetch) {
2418
2647
  * timers — are injectable so the whole lifecycle is unit-testable without
2419
2648
  * a real binary or network.
2420
2649
  */
2650
+ /** Compare two targets for the start idempotence check. */
2651
+ function sameTarget(left, right) {
2652
+ if (left === void 0) return false;
2653
+ return JSON.stringify(left) === JSON.stringify(right);
2654
+ }
2655
+ /**
2656
+ * Wrap a named-tunnel process so it fits the quick-tunnel handle shape: the
2657
+ * fixed public URL is reported through the same `url` event once the first
2658
+ * edge connection registers (cloudflared fires `connected` per connection,
2659
+ * so only the first is taken), and exit/error pass through. The manager's
2660
+ * URL timeout, crash-restart backoff, and stop semantics then stay fully
2661
+ * mode-agnostic.
2662
+ * @param inner - the running named-tunnel process.
2663
+ * @param publicUrl - the fixed public hostname ingress maps to this server.
2664
+ * @returns a handle emitting `url` once the tunnel is reachable.
2665
+ */
2666
+ function namedTunnelHandle(inner, publicUrl) {
2667
+ const handle = new EventEmitter();
2668
+ let registered = false;
2669
+ const onConnected = () => {
2670
+ if (registered) return;
2671
+ registered = true;
2672
+ handle.emit("url", publicUrl);
2673
+ };
2674
+ const onExit = (code, signal) => {
2675
+ handle.emit("exit", code, signal);
2676
+ };
2677
+ const onError = (value) => {
2678
+ handle.emit("error", value);
2679
+ };
2680
+ inner.on("connected", onConnected);
2681
+ inner.on("exit", onExit);
2682
+ inner.on("error", onError);
2683
+ handle.stop = () => {
2684
+ inner.off("connected", onConnected);
2685
+ inner.off("exit", onExit);
2686
+ inner.off("error", onError);
2687
+ return inner.stop();
2688
+ };
2689
+ return handle;
2690
+ }
2421
2691
  /** Default binary readiness: download the platform binary on first use. */
2422
2692
  async function defaultEnsureBinary() {
2423
2693
  if (existsSync(bin)) return;
2424
2694
  await install(bin);
2425
2695
  }
2426
- /** Default factory: the cloudflared package's quick tunnel (no account). */
2427
- function defaultFactory(targetUrl) {
2428
- return Tunnel.quick(targetUrl, {
2429
- "--no-autoupdate": true,
2430
- "--protocol": "http2"
2431
- });
2696
+ /** The flags every tunnel mode shares (see the quick factory comment). */
2697
+ const SHARED_TUNNEL_FLAGS = {
2698
+ "--no-autoupdate": true,
2699
+ "--protocol": "http2"
2700
+ };
2701
+ /**
2702
+ * Spawn flags for one quick target. `originHostHeader` stamps the Host the
2703
+ * local webserver sees: the plugin's phone-facing fence trusts the configured
2704
+ * public host (the stable relay origin, Host-bound) and the harness
2705
+ * browser-auth binds cookies to the Host authority, while the Workers relay
2706
+ * cannot control the origin-side Host at all (fetch forces it to the URL
2707
+ * authority) — so the connector must restamp it with the stable origin.
2708
+ */
2709
+ function quickTunnelFlags(originHostHeader) {
2710
+ return originHostHeader === void 0 ? { ...SHARED_TUNNEL_FLAGS } : {
2711
+ ...SHARED_TUNNEL_FLAGS,
2712
+ "--http-host-header": originHostHeader
2713
+ };
2714
+ }
2715
+ /** Default factory: the cloudflared package's quick and named tunnels. */
2716
+ function defaultFactory(target) {
2717
+ if (target.kind === "quick") return Tunnel.quick(target.targetUrl, quickTunnelFlags(target.originHostHeader));
2718
+ return namedTunnelHandle(Tunnel.withToken(target.token, { ...SHARED_TUNNEL_FLAGS }), target.publicUrl);
2432
2719
  }
2433
2720
  /** Node timers. */
2434
2721
  const nodeTimer = {
@@ -2449,7 +2736,7 @@ var TunnelManager = class {
2449
2736
  phase = "stopped";
2450
2737
  url;
2451
2738
  error;
2452
- targetUrl;
2739
+ target;
2453
2740
  handle;
2454
2741
  urlTimer;
2455
2742
  restartTimer;
@@ -2478,16 +2765,24 @@ var TunnelManager = class {
2478
2765
  };
2479
2766
  }
2480
2767
  /**
2481
- * Start (or keep) a quick tunnel toward `targetUrl`. Restarting with a
2482
- * different target tears the old tunnel down first; restarting with the
2483
- * same target while running is a no-op.
2484
- * @param targetUrl - the local URL to expose, e.g. `http://127.0.0.1:3080`.
2768
+ * Start (or keep) a tunnel toward `target`. Restarting with a different
2769
+ * target tears the old tunnel down first; restarting with the same target
2770
+ * while running is a no-op. A string is the quick mode's local target URL.
2771
+ *
2772
+ * In quick mode the URL surfaces when cloudflared mints the ephemeral
2773
+ * trycloudflare hostname; in named mode the fixed `publicUrl` surfaces
2774
+ * once the first edge connection registers (see {@link namedTunnelHandle}).
2775
+ * @param target - what to run (a string means quick toward that local URL).
2485
2776
  */
2486
- start(targetUrl) {
2487
- if (this.targetUrl === targetUrl && (this.phase === "starting" || this.phase === "running")) return;
2777
+ start(target) {
2778
+ const resolved = typeof target === "string" ? {
2779
+ kind: "quick",
2780
+ targetUrl: target
2781
+ } : target;
2782
+ if (sameTarget(this.target, resolved) && (this.phase === "starting" || this.phase === "running")) return;
2488
2783
  this.teardown();
2489
2784
  this.stopping = false;
2490
- this.targetUrl = targetUrl;
2785
+ this.target = resolved;
2491
2786
  this.attempts = 0;
2492
2787
  this.generation += 1;
2493
2788
  this.attempt();
@@ -2496,7 +2791,7 @@ var TunnelManager = class {
2496
2791
  stop() {
2497
2792
  this.teardown();
2498
2793
  this.stopping = false;
2499
- this.targetUrl = void 0;
2794
+ this.target = void 0;
2500
2795
  this.setPhase("stopped");
2501
2796
  }
2502
2797
  /** Alias of {@link stop} for plugin-effect disposal. */
@@ -2518,15 +2813,15 @@ var TunnelManager = class {
2518
2813
  };
2519
2814
  }
2520
2815
  attempt() {
2521
- if (this.stopping || this.targetUrl === void 0) return;
2816
+ if (this.stopping || this.target === void 0) return;
2522
2817
  const gen = this.generation;
2523
2818
  this.setPhase("starting");
2524
2819
  this.handle = void 0;
2525
2820
  this.url = void 0;
2526
2821
  this.error = void 0;
2527
2822
  this.ensureBinary().then(() => {
2528
- if (this.stopping || this.targetUrl === void 0 || gen !== this.generation) return;
2529
- const handle = this.factory(this.targetUrl);
2823
+ if (this.stopping || this.target === void 0 || gen !== this.generation) return;
2824
+ const handle = this.factory(this.target);
2530
2825
  this.handle = handle;
2531
2826
  this.urlTimer = this.timer.setTimeout(() => {
2532
2827
  this.fail("timed out waiting for the tunnel URL");
@@ -2544,7 +2839,7 @@ var TunnelManager = class {
2544
2839
  this.error = value instanceof Error ? value.message : String(value);
2545
2840
  });
2546
2841
  }).catch((value) => {
2547
- if (this.stopping || this.targetUrl === void 0 || gen !== this.generation) return;
2842
+ if (this.stopping || this.target === void 0 || gen !== this.generation) return;
2548
2843
  const message = value instanceof Error ? value.message : String(value);
2549
2844
  this.fail(`could not obtain the cloudflared binary: ${message}`);
2550
2845
  });
@@ -3281,7 +3576,6 @@ const REMOTE_CHANNEL_RULES = {
3281
3576
  apiPrefix: "/api/",
3282
3577
  pairPrefix: "/api/pair/",
3283
3578
  updatePrefix: "/api/update/",
3284
- desktopLauncherPrefix: "/api/dsh-desktop-launcher",
3285
3579
  settingsBridgePrefix: "/api/dsh-web-ui-settings",
3286
3580
  sidebarPrefix: "/sidebar/",
3287
3581
  gitPrefix: "/git/",
@@ -3331,7 +3625,7 @@ const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
3331
3625
  function buildRemoteChannelBootScript(rules = REMOTE_CHANNEL_RULES) {
3332
3626
  const json = JSON.stringify(rules);
3333
3627
  const seat = JSON.stringify(REMOTE_CHANNEL_BOOT_GLOBAL);
3334
- return "(function(){try{var w=window,loc=w.location,h=loc.hostname;if(h==='localhost'||h==='::1'||/^127(\\.\\d{1,3}){3}$/.test(h))return;try{if(w.__DSH_TRANSPORT__===undefined)w.__DSH_TRANSPORT__={};w.__DSH_TRANSPORT__.ownsHost=true}catch(e){}var R=" + json + ";function rdv(){try{return w.sessionStorage.getItem(R.deviceKey)}catch(e){return null}}function att(init){var dv=rdv();if(dv===null)return init;var h=init&&init.headers;if(typeof Headers!==\"undefined\"&&h instanceof Headers){try{h.set(R.deviceHeader,dv)}catch(e){}return init}if(typeof h===\"object\"&&h!==null){var o={};for(var k in h)o[k]=h[k];o[R.deviceHeader]=dv;return Object.assign({},init,{headers:o})}return init}function sf(p){if(p.indexOf(R.pairPrefix)===0)return false;if(p.indexOf(R.updatePrefix)===0)return false;if(p===R.desktopLauncherPrefix||p.indexOf(R.desktopLauncherPrefix+\"/\")===0)return false;if(p===R.settingsBridgePrefix||p.indexOf(R.settingsBridgePrefix+\"/\")===0)return false;if(p.indexOf(R.apiPrefix)===0)return true;if(p.indexOf(R.sidebarPrefix)===0||p===\"/sidebar\")return true;if(p.indexOf(R.gitPrefix)===0||p===\"/git\")return true;if(p.indexOf(R.petPrefix)===0||p===\"/pet\")return true;return false}function sw(p){return R.wsPaths.indexOf(p)!==-1}function rp(p){return R.remotePrefix+p}function so(u){return u.origin===loc.origin}function rr(raw){var u;try{u=new URL(raw,loc.href)}catch(e){return raw}if(u.origin!==loc.origin)return raw;if(!sf(u.pathname))return raw;u.pathname=rp(u.pathname);if(raw.charAt(0)===\"/\"&&raw.charAt(1)!==\"/\")return u.pathname+u.search+u.hash;return u.href}function uc(v){if(typeof v!==\"object\"||v===null)return undefined;var n=v.result;if(typeof n===\"object\"&&n!==null){var e=n.error;if(typeof e===\"object\"&&e!==null&&typeof e.code===\"string\")return e.code}var t=v.error;if(typeof t===\"object\"&&t!==null&&typeof t.code===\"string\")return t.code;return undefined}var seat={onUnpaired:null,onPaired:null,pendingUnpaired:false,restore:function(){}};function denied(res){if(res.status!==403)return false;return res.clone().json().then(function(b){return uc(b)===\"unpaired\"}).catch(function(){return false})}function signal(unpaired){if(unpaired){if(seat.onUnpaired)seat.onUnpaired();else seat.pendingUnpaired=true}else{seat.pendingUnpaired=false;if(seat.onPaired)seat.onPaired()}}var of=w.fetch;w.fetch=function(input,init){var raw=typeof input===\"string\"||input instanceof URL?input.toString():input.url;var url=new URL(raw,loc.href);if(so(url)&&sf(url.pathname)){var next=new URL(url);next.pathname=rp(url.pathname);var target=typeof input===\"string\"||input instanceof URL?next.toString():new Request(next,input);return Promise.resolve(of.call(w,target,att(init))).then(function(res){void Promise.resolve(denied(res)).then(signal);return res})}return of.call(w,input,init)};var OW=w.WebSocket;w.WebSocket=function(url,protocols){var p=new URL(url.toString(),loc.href);var o=p.protocol===\"wss:\"?\"https://\"+p.host:p.protocol===\"ws:\"?\"http://\"+p.host:\"\";if(o!==\"\"&&o===loc.origin&&sw(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);var dvv=rdv();if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);return protocols!==undefined?new OW(nx,protocols):new OW(nx)}return protocols!==undefined?new OW(url,protocols):new OW(url)};w.WebSocket.prototype=OW.prototype;w.WebSocket.CONNECTING=OW.CONNECTING;w.WebSocket.OPEN=OW.OPEN;w.WebSocket.CLOSING=OW.CLOSING;w.WebSocket.CLOSED=OW.CLOSED;var OE=w.EventSource;if(OE!==undefined){w.EventSource=function(url,cfg){var p=new URL(url.toString(),loc.href);if(so(p)&&sf(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);var dvv=rdv();if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);return new OE(nx,cfg)}return new OE(url,cfg)};w.EventSource.prototype=OE.prototype}var restores=[];function patchSrc(C){if(C===undefined)return;var d=Object.getOwnPropertyDescriptor(C.prototype,\"src\");if(d===undefined||d.configurable===false||d.set===undefined)return;var os=d.set;Object.defineProperty(C.prototype,\"src\",{configurable:true,enumerable:d.enumerable!==false,get:d.get,set:function(v){os.call(this,rr(String(v)))}});restores.push(function(){Object.defineProperty(C.prototype,\"src\",d)})}patchSrc(w.HTMLImageElement);patchSrc(w.HTMLScriptElement);patchSrc(w.HTMLIFrameElement);seat.restore=function(){w.fetch=of;w.WebSocket=OW;if(OE!==undefined)w.EventSource=OE;for(var i=0;i<restores.length;i++)restores[i]();try{delete w[" + seat + "]}catch(e){w[" + seat + "]=undefined}};w[" + seat + "]=seat" + buildBootWatchdogScript() + "}catch(e){}})();";
3628
+ return "(function(){try{var w=window,loc=w.location,h=loc.hostname;if(h==='localhost'||h==='::1'||/^127(\\.\\d{1,3}){3}$/.test(h))return;try{if(w.__DSH_TRANSPORT__===undefined)w.__DSH_TRANSPORT__={};w.__DSH_TRANSPORT__.ownsHost=true}catch(e){}var R=" + json + ";function rdv(){try{return w.sessionStorage.getItem(R.deviceKey)}catch(e){return null}}function att(init){var dv=rdv();if(dv===null)return init;var h=init&&init.headers;if(typeof Headers!==\"undefined\"&&h instanceof Headers){try{h.set(R.deviceHeader,dv)}catch(e){}return init}if(typeof h===\"object\"&&h!==null){var o={};for(var k in h)o[k]=h[k];o[R.deviceHeader]=dv;return Object.assign({},init,{headers:o})}return init}function sf(p){if(p.indexOf(R.pairPrefix)===0)return false;if(p.indexOf(R.updatePrefix)===0)return false;if(p===R.settingsBridgePrefix||p.indexOf(R.settingsBridgePrefix+\"/\")===0)return false;if(p.indexOf(R.apiPrefix)===0)return true;if(p.indexOf(R.sidebarPrefix)===0||p===\"/sidebar\")return true;if(p.indexOf(R.gitPrefix)===0||p===\"/git\")return true;if(p.indexOf(R.petPrefix)===0||p===\"/pet\")return true;return false}function sw(p){return R.wsPaths.indexOf(p)!==-1}function rp(p){return R.remotePrefix+p}function so(u){return u.origin===loc.origin}function rr(raw){var u;try{u=new URL(raw,loc.href)}catch(e){return raw}if(u.origin!==loc.origin)return raw;if(!sf(u.pathname))return raw;u.pathname=rp(u.pathname);if(raw.charAt(0)===\"/\"&&raw.charAt(1)!==\"/\")return u.pathname+u.search+u.hash;return u.href}function uc(v){if(typeof v!==\"object\"||v===null)return undefined;var n=v.result;if(typeof n===\"object\"&&n!==null){var e=n.error;if(typeof e===\"object\"&&e!==null&&typeof e.code===\"string\")return e.code}var t=v.error;if(typeof t===\"object\"&&t!==null&&typeof t.code===\"string\")return t.code;return undefined}var seat={onUnpaired:null,onPaired:null,pendingUnpaired:false,restore:function(){}};function denied(res){if(res.status!==403)return false;return res.clone().json().then(function(b){return uc(b)===\"unpaired\"}).catch(function(){return false})}function signal(unpaired){if(unpaired){if(seat.onUnpaired)seat.onUnpaired();else seat.pendingUnpaired=true}else{seat.pendingUnpaired=false;if(seat.onPaired)seat.onPaired()}}var of=w.fetch;w.fetch=function(input,init){var raw=typeof input===\"string\"||input instanceof URL?input.toString():input.url;var url=new URL(raw,loc.href);if(so(url)&&sf(url.pathname)){var next=new URL(url);next.pathname=rp(url.pathname);var target=typeof input===\"string\"||input instanceof URL?next.toString():new Request(next,input);return Promise.resolve(of.call(w,target,att(init))).then(function(res){void Promise.resolve(denied(res)).then(signal);return res})}return of.call(w,input,init)};var OW=w.WebSocket;w.WebSocket=function(url,protocols){var p=new URL(url.toString(),loc.href);var o=p.protocol===\"wss:\"?\"https://\"+p.host:p.protocol===\"ws:\"?\"http://\"+p.host:\"\";if(o!==\"\"&&o===loc.origin&&sw(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);var dvv=rdv();if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);return protocols!==undefined?new OW(nx,protocols):new OW(nx)}return protocols!==undefined?new OW(url,protocols):new OW(url)};w.WebSocket.prototype=OW.prototype;w.WebSocket.CONNECTING=OW.CONNECTING;w.WebSocket.OPEN=OW.OPEN;w.WebSocket.CLOSING=OW.CLOSING;w.WebSocket.CLOSED=OW.CLOSED;var OE=w.EventSource;if(OE!==undefined){w.EventSource=function(url,cfg){var p=new URL(url.toString(),loc.href);if(so(p)&&sf(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);var dvv=rdv();if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);return new OE(nx,cfg)}return new OE(url,cfg)};w.EventSource.prototype=OE.prototype}var restores=[];function patchSrc(C){if(C===undefined)return;var d=Object.getOwnPropertyDescriptor(C.prototype,\"src\");if(d===undefined||d.configurable===false||d.set===undefined)return;var os=d.set;Object.defineProperty(C.prototype,\"src\",{configurable:true,enumerable:d.enumerable!==false,get:d.get,set:function(v){os.call(this,rr(String(v)))}});restores.push(function(){Object.defineProperty(C.prototype,\"src\",d)})}patchSrc(w.HTMLImageElement);patchSrc(w.HTMLScriptElement);patchSrc(w.HTMLIFrameElement);seat.restore=function(){w.fetch=of;w.WebSocket=OW;if(OE!==undefined)w.EventSource=OE;for(var i=0;i<restores.length;i++)restores[i]();try{delete w[" + seat + "]}catch(e){w[" + seat + "]=undefined}};w[" + seat + "]=seat" + buildBootWatchdogScript() + "}catch(e){}})();";
3335
3629
  }
3336
3630
  /** Boot-watchdog latch: one self-reload per session while the boot is broken. */
3337
3631
  const BOOT_WATCHDOG_KEY = "dsh-remote-boot-reload";
@@ -3418,6 +3712,8 @@ const Config = z.object({
3418
3712
  publicBaseUrl: z.string(),
3419
3713
  devicesFile: z.string(),
3420
3714
  autoTunnel: z.boolean().default(false),
3715
+ tunnelToken: z.string().role("secret"),
3716
+ relay: z.boolean().default(true),
3421
3717
  lanBind: z.boolean(),
3422
3718
  profile: z.string().pattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
3423
3719
  enabled: z.boolean().default(true)
@@ -3455,6 +3751,8 @@ const DEFAULTS = {
3455
3751
  publicBaseUrl: void 0,
3456
3752
  devicesFile: defaultDevicesFile(),
3457
3753
  autoTunnel: false,
3754
+ tunnelToken: void 0,
3755
+ relay: true,
3458
3756
  lanBind: void 0,
3459
3757
  profile: process.env.DSH_PROFILE ?? "web",
3460
3758
  enabled: true
@@ -3476,6 +3774,8 @@ function applyImpl(ctx, config) {
3476
3774
  publicBaseUrl: config?.publicBaseUrl,
3477
3775
  devicesFile: config?.devicesFile ?? DEFAULTS.devicesFile,
3478
3776
  autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
3777
+ tunnelToken: config?.tunnelToken,
3778
+ relay: config?.relay ?? DEFAULTS.relay,
3479
3779
  lanBind: config?.lanBind,
3480
3780
  profile: config?.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
3481
3781
  enabled: config?.enabled ?? DEFAULTS.enabled
@@ -3493,6 +3793,8 @@ function applyImpl(ctx, config) {
3493
3793
  publicBaseUrl: value.publicBaseUrl,
3494
3794
  devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
3495
3795
  autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
3796
+ tunnelToken: value.tunnelToken,
3797
+ relay: value.relay ?? DEFAULTS.relay,
3496
3798
  lanBind: value.lanBind,
3497
3799
  profile: value.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
3498
3800
  enabled: value.enabled ?? DEFAULTS.enabled
@@ -3500,21 +3802,64 @@ function applyImpl(ctx, config) {
3500
3802
  };
3501
3803
  const service = new PairingService(pairingConfigOf(resolved));
3502
3804
  const tunnel = new TunnelManager();
3503
- let autoTunnel = resolved.autoTunnel;
3805
+ let relayRegistrar;
3806
+ let relayUrl;
3807
+ let rawTunnelUrl;
3808
+ /** The tunnel target the registrar last announced (dedupes sync re-runs). */
3809
+ let relayAnnouncedFor;
3810
+ const setPublicBase = () => {
3811
+ service.setPublicBaseUrl(relayUrl ?? rawTunnelUrl);
3812
+ };
3813
+ const disposeRelayRegistrar = (unregister = false) => {
3814
+ const registrar = relayRegistrar;
3815
+ relayRegistrar = void 0;
3816
+ relayUrl = void 0;
3817
+ relayAnnouncedFor = void 0;
3818
+ if (registrar === void 0) return;
3819
+ if (unregister) registrar.unregister().catch(() => void 0);
3820
+ registrar.dispose();
3821
+ };
3822
+ const ensureRelayRegistrar = () => {
3823
+ if (resolve().relay === false) return void 0;
3824
+ if (relayRegistrar === void 0) try {
3825
+ relayRegistrar = new RelayRegistrar(loadRelayIdentity(resolve().profile), (state) => {
3826
+ service.setRelayStatus(state.state === "off" ? void 0 : state);
3827
+ if (state.state === "running") relayUrl = state.url;
3828
+ else if (state.state === "off") relayUrl = void 0;
3829
+ else if (state.state === "failed") console.warn(`remote-web-ui: relay registration failed (${state.error}) — the stable origin may serve its offline page until the retry lands`);
3830
+ setPublicBase();
3831
+ });
3832
+ } catch (error) {
3833
+ console.warn(`remote-web-ui: relay registry unavailable (${error instanceof Error ? error.message : String(error)}) — the quick URL is the QR base`);
3834
+ }
3835
+ return relayRegistrar;
3836
+ };
3837
+ const announceRelay = (registrar, target) => {
3838
+ if (relayAnnouncedFor === target) return;
3839
+ relayAnnouncedFor = target;
3840
+ registrar.announce(target);
3841
+ };
3842
+ let tunnelMode = resolved.autoTunnel ? "quick" : "off";
3504
3843
  tunnel.onPhase((info) => {
3505
- if (!autoTunnel) return;
3844
+ if (tunnelMode === "off") return;
3506
3845
  if (info.phase === "running" && info.url !== void 0) {
3507
- service.setPublicBaseUrl(info.url);
3846
+ rawTunnelUrl = info.url;
3847
+ setPublicBase();
3508
3848
  service.setTunnelStatus({
3509
3849
  state: "running",
3510
3850
  url: info.url
3511
3851
  });
3852
+ const registrar = tunnelMode === "quick" ? ensureRelayRegistrar() : void 0;
3853
+ if (registrar !== void 0) announceRelay(registrar, info.url);
3512
3854
  runPostureProbe();
3513
3855
  } else if (info.phase === "starting") {
3514
- service.setPublicBaseUrl(void 0);
3856
+ rawTunnelUrl = void 0;
3857
+ relayUrl = void 0;
3858
+ setPublicBase();
3515
3859
  service.setTunnelStatus({ state: "starting" });
3516
3860
  } else if (info.phase === "failed") {
3517
- service.setPublicBaseUrl(void 0);
3861
+ rawTunnelUrl = void 0;
3862
+ setPublicBase();
3518
3863
  service.setTunnelStatus(info.error === void 0 ? { state: "failed" } : {
3519
3864
  state: "failed",
3520
3865
  error: info.error
@@ -3522,6 +3867,7 @@ function applyImpl(ctx, config) {
3522
3867
  }
3523
3868
  });
3524
3869
  ctx.effect(() => () => {
3870
+ disposeRelayRegistrar();
3525
3871
  tunnel.dispose();
3526
3872
  }, "remote-web-ui: auto tunnel");
3527
3873
  if ((ctx.webServer.host === "0.0.0.0" || ctx.webServer.host === "127.0.0.1") && Number.isFinite(ctx.webServer.port)) {
@@ -3762,12 +4108,35 @@ function applyImpl(ctx, config) {
3762
4108
  else console.error("remote-web-ui: the host firewall rule could not be updated (admin rights required on managed platforms)");
3763
4109
  }
3764
4110
  }
3765
- autoTunnel = value.autoTunnel === true;
3766
- if (autoTunnel) {
3767
- if (value.publicBaseUrl !== void 0) console.warn("remote-web-ui: autoTunnel is on — ignoring the manually configured publicBaseUrl");
3768
- tunnel.start(`http://127.0.0.1:${String(ctx.webServer.port)}`);
3769
- } else {
4111
+ const plan = tunnelPlanOf(value, ctx.webServer.port);
4112
+ tunnelMode = plan.mode;
4113
+ if (plan.mode !== "quick") {
4114
+ disposeRelayRegistrar();
4115
+ if (plan.mode !== "named") setPublicBase();
4116
+ } else if (value.relay === false) {
4117
+ disposeRelayRegistrar(true);
4118
+ setPublicBase();
4119
+ } else if (rawTunnelUrl !== void 0) {
4120
+ const registrar = ensureRelayRegistrar();
4121
+ if (registrar !== void 0) announceRelay(registrar, rawTunnelUrl);
4122
+ }
4123
+ if (plan.mode === "quick") {
4124
+ for (const ignored of plan.ignored) console.warn(`remote-web-ui: autoTunnel is on — ignoring the configured ${ignored}`);
4125
+ const registrar = ensureRelayRegistrar();
4126
+ const originHostHeader = registrar === void 0 ? void 0 : new URL(registrar.baseUrl).host;
4127
+ tunnel.start(originHostHeader === void 0 ? plan.targetUrl : {
4128
+ kind: "quick",
4129
+ targetUrl: plan.targetUrl,
4130
+ originHostHeader
4131
+ });
4132
+ } else if (plan.mode === "named") tunnel.start({
4133
+ kind: "named",
4134
+ token: plan.token,
4135
+ publicUrl: plan.publicUrl
4136
+ });
4137
+ else {
3770
4138
  tunnel.stop();
4139
+ if (value.tunnelToken !== void 0 && value.tunnelToken !== "") console.warn("remote-web-ui: tunnelToken is set but publicBaseUrl is missing or not a valid URL — fill the fixed public hostname of the named tunnel (e.g. https://dsh.example.com) to run it");
3771
4140
  if (value.publicBaseUrl !== void 0 && !isHttpUrl(value.publicBaseUrl)) {
3772
4141
  console.warn(`remote-web-ui: ignoring malformed publicBaseUrl ${JSON.stringify(value.publicBaseUrl)} (expected https://host[:port])`);
3773
4142
  service.setPublicBaseUrl(void 0);
@@ -3827,14 +4196,5 @@ function applyImpl(ctx, config) {
3827
4196
  });
3828
4197
  sync();
3829
4198
  }
3830
- /** Whether a configured public base is a parseable http(s) URL with a host. */
3831
- function isHttpUrl(value) {
3832
- try {
3833
- const url = new URL(value);
3834
- return (url.protocol === "http:" || url.protocol === "https:") && url.hostname !== "";
3835
- } catch {
3836
- return false;
3837
- }
3838
- }
3839
4199
  //#endregion
3840
4200
  export { Config, REMOTE_WEB_UI_SETTINGS_NAMESPACE, apply, defaultDevicesFile, inject, name, pairingConfigOf };