dsh-mobile 0.4.1 → 0.4.3

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.
package/lib/index.mjs CHANGED
@@ -116,8 +116,10 @@ var AccessController = class {
116
116
  async initialize() {
117
117
  if (this.initialized || this.closing) throw new Error("access controller cannot be initialized again");
118
118
  const snapshot = await this.store.load();
119
- if (snapshot.devices.length > this.options.maxDevices) throw new Error("device state exceeds configured maxDevices");
120
- this.devices = [...snapshot.devices];
119
+ const devices = snapshot.devices.filter((device) => device.revokedAt === void 0);
120
+ if (devices.length > this.options.maxDevices) throw new Error("device state exceeds configured maxDevices");
121
+ if (devices.length !== snapshot.devices.length) await this.store.save(this.snapshot(devices));
122
+ this.devices = devices;
121
123
  this.initialized = true;
122
124
  }
123
125
  requireInitialized() {
@@ -312,18 +314,12 @@ var AccessController = class {
312
314
  logout(authorization) {
313
315
  this.removeSession(authorization.sessionKey, "logout");
314
316
  }
315
- /** Persist revocation, then end every Session owned by that device. */
317
+ /** Durably delete the device, then end every Session owned by that device. */
316
318
  async revokeDevice(deviceId) {
317
319
  this.requireInitialized();
318
320
  return this.exclusive(async () => {
319
- const index = this.devices.findIndex((device) => device.id === deviceId);
320
- const device = this.devices[index];
321
- if (device === void 0 || device.revokedAt !== void 0) return false;
322
- const next = [...this.devices];
323
- next[index] = Object.freeze({
324
- ...device,
325
- revokedAt: this.now()
326
- });
321
+ const next = this.devices.filter((device) => device.id !== deviceId);
322
+ if (next.length === this.devices.length) return false;
327
323
  await this.store.save(this.snapshot(next));
328
324
  this.devices = next;
329
325
  for (const [key, session] of this.sessions) if (session.deviceId === deviceId) this.removeSession(key, "revoked");
@@ -644,6 +640,7 @@ const Config = z.object({
644
640
  customCssFile: z.string().hidden(),
645
641
  customScriptFile: z.string().hidden(),
646
642
  mobileLayoutFile: z.string().hidden(),
643
+ mobileCompatibilityFile: z.string().hidden(),
647
644
  instanceId: z.string().hidden(),
648
645
  pairingCaFile: z.string().hidden(),
649
646
  initiallyEnabled: z.boolean().hidden().required(),
@@ -769,6 +766,7 @@ function parseGatewayConfig(raw) {
769
766
  customCssFile: value.customCssFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.css") : absoluteFile(value.customCssFile, "customCssFile"),
770
767
  customScriptFile: value.customScriptFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.js") : absoluteFile(value.customScriptFile, "customScriptFile"),
771
768
  mobileLayoutFile: value.mobileLayoutFile === void 0 ? fileURLToPath(new URL("./mobile-layout.js", import.meta.url)) : absoluteFile(value.mobileLayoutFile, "mobileLayoutFile"),
769
+ mobileCompatibilityFile: value.mobileCompatibilityFile === void 0 ? fileURLToPath(new URL("./mobile-compat.js", import.meta.url)) : absoluteFile(value.mobileCompatibilityFile, "mobileCompatibilityFile"),
772
770
  instanceId: value.instanceId === void 0 ? createHash("sha256").update(absoluteFile(value.stateFile, "stateFile")).digest("hex") : /^[a-f\d]{64}$/u.test(value.instanceId) ? value.instanceId : (() => {
773
771
  throw new Error("instanceId must be a lowercase SHA-256 value");
774
772
  })(),
@@ -879,6 +877,17 @@ var FollowingMobileAccessRuntime = class {
879
877
  async initialize(refreshIntervalMs) {
880
878
  await this.refresh();
881
879
  if (refreshIntervalMs === void 0) return;
880
+ this.beginPolling(refreshIntervalMs);
881
+ }
882
+ /**
883
+ * Arm the refresh poller without requiring an initial selection.
884
+ * Boot-degraded path: the first selection already failed (e.g. the saved
885
+ * LAN is down), but later refreshes must still get their chance so a
886
+ * returning network recovers without a restart. Failures keep flowing to
887
+ * onRefreshError; close() stays the single teardown.
888
+ */
889
+ beginPolling(refreshIntervalMs) {
890
+ if (this.timer !== void 0) return;
882
891
  this.timer = setInterval(() => {
883
892
  this.refresh().catch(this.onRefreshError);
884
893
  }, refreshIntervalMs);
@@ -1310,6 +1319,50 @@ function assertLocalAdminTrust(request, requireBrowserOrigin) {
1310
1319
  if (requireBrowserOrigin && site !== void 0 && site !== "same-origin") throw new HttpError(403, "forbidden");
1311
1320
  }
1312
1321
  //#endregion
1322
+ //#region src/mobile-compat-bootstrap.ts
1323
+ const MOBILE_COMPAT_PATH = `${AUTH_PREFIX}/compat.js`;
1324
+ /**
1325
+ * Read one attribute out of a tag body.
1326
+ *
1327
+ * A plain `/\snonce\s*=/` search can be fooled by a value that merely contains the
1328
+ * text, and cannot tell a double-quoted attribute from a single-quoted one. Walking
1329
+ * `name=value` pairs consumes each quoted value whole, so the attribute that is
1330
+ * actually present is the one returned.
1331
+ */
1332
+ function attributeValue(tag, name) {
1333
+ for (const match of tag.matchAll(/([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("[^"]*"|'[^']*')/gu)) {
1334
+ const attribute = match[1];
1335
+ const raw = match[2];
1336
+ if (attribute === void 0 || raw === void 0) continue;
1337
+ if (attribute.toLowerCase() === name) return raw.slice(1, -1);
1338
+ }
1339
+ }
1340
+ /** Character ranges covered by an HTML comment, where markup is inert text. */
1341
+ function commentRanges(html) {
1342
+ return [...html.matchAll(/<!--[\s\S]*?-->/gu)].map((match) => [match.index, match.index + match[0].length]);
1343
+ }
1344
+ /**
1345
+ * Load the compatibility bundle synchronously, before the first DSH script.
1346
+ *
1347
+ * Returns the HTML unchanged when there is no script to anchor to. The bundle is an
1348
+ * enhancement for WebViews without Iterator helpers, so a document this function
1349
+ * cannot place it in must still be served: throwing here took the entire dedicated
1350
+ * mobile frontend down with a 502.
1351
+ */
1352
+ function ensureMobileCompatibility(html) {
1353
+ const comments = commentRanges(html);
1354
+ const isInert = (at) => comments.some(([start, end]) => at >= start && at < end);
1355
+ const scripts = [...html.matchAll(/<script\b[^>]*>/giu)].filter((match) => !isInert(match.index));
1356
+ if (scripts.length === 0) return html;
1357
+ if (scripts.some((match) => attributeValue(match[0], "src") === MOBILE_COMPAT_PATH)) return html;
1358
+ const first = scripts[0];
1359
+ if (first === void 0) return html;
1360
+ const nonce = attributeValue(first[0], "nonce")?.replace(/["'<>\s]/gu, "") ?? "";
1361
+ const attribute = nonce === "" ? "" : ` nonce="${nonce}"`;
1362
+ const script = `<script src="${MOBILE_COMPAT_PATH}"${attribute}><\/script>`;
1363
+ return `${html.slice(0, first.index)}${script}${html.slice(first.index)}`;
1364
+ }
1365
+ //#endregion
1313
1366
  //#region src/version.ts
1314
1367
  const manifest = createRequire(import.meta.url)("../package.json");
1315
1368
  /** Version of the installed DSH Mobile plugin package. */
@@ -1492,7 +1545,7 @@ function normalizeRelativePath(value, field) {
1492
1545
  if (normalized.split("/").some((part) => part === "" || part === "." || part === "..")) throw new MobileExtensionError("invalid_extension_path", `${field} escapes extension directory`);
1493
1546
  return normalized;
1494
1547
  }
1495
- async function regularFile$2(path, maximum, field) {
1548
+ async function regularFile$3(path, maximum, field) {
1496
1549
  let info;
1497
1550
  try {
1498
1551
  info = await lstat(path);
@@ -1513,7 +1566,7 @@ async function containedPath(root, relativePath, maximum, field) {
1513
1566
  const targetReal = await realpath(target);
1514
1567
  const relation = relative(rootReal, targetReal);
1515
1568
  if (relation === "" || relation.startsWith("..") || isAbsolute(relation)) throw new MobileExtensionError("invalid_extension_path", `${field} escapes extension directory`);
1516
- return regularFile$2(targetReal, maximum, field);
1569
+ return regularFile$3(targetReal, maximum, field);
1517
1570
  }
1518
1571
  async function optionalFile(root, name, maximum, field) {
1519
1572
  try {
@@ -1590,7 +1643,7 @@ async function assetSnapshot(extensionRootReal) {
1590
1643
  }
1591
1644
  async function extensionFingerprint(directory) {
1592
1645
  const root = await realExtensionRoot(directory);
1593
- const manifestFile = await regularFile$2(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
1646
+ const manifestFile = await regularFile$3(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
1594
1647
  const manifestBody = await readFile(manifestFile.path);
1595
1648
  const manifest = parseExtensionManifest(JSON.parse(manifestBody.toString("utf8")));
1596
1649
  if (manifest.id !== basename(root)) throw new MobileExtensionError("invalid_manifest", "extension id must match its directory name");
@@ -2086,7 +2139,7 @@ async function abortAndDisposeLocal(entries) {
2086
2139
  }
2087
2140
  async function loadLocalExtension(directory, context, known, parentSignal) {
2088
2141
  const root = await realExtensionRoot(directory);
2089
- const manifestFile = await regularFile$2(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
2142
+ const manifestFile = await regularFile$3(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
2090
2143
  const manifest = known?.manifest ?? parseExtensionManifest(JSON.parse(await readFile(manifestFile.path, "utf8")));
2091
2144
  if (manifest.id !== basename(root)) throw new MobileExtensionError("invalid_manifest", "extension id must match its directory name");
2092
2145
  const scriptBody = known === void 0 ? await optionalFile(root, "mobile.js", EXTENSION_LIMITS.script, "mobile.js").then((path) => path === void 0 ? void 0 : readFile(path)) : known.scriptBody;
@@ -2382,6 +2435,19 @@ const MOBILE_LAYOUT_PATH = `${AUTH_PREFIX}/mobile-layout.js`;
2382
2435
  const MOBILE_BOOT_BATCH_PREFIX = `${AUTH_PREFIX}/mobile-boot/`;
2383
2436
  const MAX_MOBILE_BOOT_BATCH_BYTES = 33554432;
2384
2437
  const MAX_MOBILE_BOOT_ENTRY_BYTES = 8388608;
2438
+ /**
2439
+ * Per-merged-batch byte budget. One upstream application batch can carry dozens
2440
+ * of client bundles whose combined size exceeds the hard batch cap; the layout
2441
+ * batch is therefore chunked into multiple merged batches, each kept safely
2442
+ * below {@link MAX_MOBILE_BOOT_BATCH_BYTES} so assembly can never reject it.
2443
+ */
2444
+ const MOBILE_BOOT_CHUNK_BYTES = 16777216;
2445
+ /** Bytes the assembly emits after each entry (`body` plus `\n;\n`). */
2446
+ const MOBILE_BOOT_SEPARATOR_BYTES = 3;
2447
+ /** Freshness window for cached upstream bundle byte sizes. */
2448
+ const MOBILE_BOOT_SIZE_CACHE_TTL_MS = 3e4;
2449
+ /** Marker header distinguishing size probes from ordinary proxied bundle fetches. */
2450
+ const MOBILE_BOOT_SIZE_PROBE_HEADER = "x-dsh-mobile-size-probe";
2385
2451
  const MAX_MOBILE_BOOT_BATCHES = 8;
2386
2452
  const MOBILE_BOOT_UPSTREAM_ATTEMPTS = 4;
2387
2453
  const MOBILE_BOOT_RETRY_DELAY_MS = 150;
@@ -2466,11 +2532,17 @@ function revisionedMobileBatchPath(entries) {
2466
2532
  path: `${MOBILE_BOOT_BATCH_PREFIX}${key}.js`
2467
2533
  };
2468
2534
  }
2469
- function rewriteMobileIndexWithBatch(html) {
2535
+ /**
2536
+ * Parse one upstream boot manifest and rewire the layout entry onto the
2537
+ * gateway-owned layout module. The layout entry's URL and revision become the
2538
+ * local `mobile-layout.js` path; the dedicated frontend is authenticated in the
2539
+ * gateway, so the requested layout needs no upstream address.
2540
+ */
2541
+ function parseMobileBootPlan(html) {
2470
2542
  const assignment = /(?:window\.__DSH_BOOT__|globalThis\["__DSH_BOOT__"\])\s*=\s*/u.exec(html);
2471
2543
  if (assignment?.index === void 0) throw new Error("upstream DSH index has no boot manifest");
2472
- const start = assignment.index;
2473
- const valueStart = start + assignment[0].length;
2544
+ const replaceStart = assignment.index;
2545
+ const valueStart = replaceStart + assignment[0].length;
2474
2546
  const scriptEnd = html.indexOf("<\/script>", valueStart);
2475
2547
  if (scriptEnd < 0) throw new Error("upstream DSH boot manifest script is incomplete");
2476
2548
  const source = html.slice(valueStart, scriptEnd).trim().replace(/;$/u, "");
@@ -2484,45 +2556,122 @@ function rewriteMobileIndexWithBatch(html) {
2484
2556
  if (dependencyProfile === void 0) throw new Error("upstream DSH layout module has unsupported dependencies");
2485
2557
  layout[0].url = MOBILE_LAYOUT_PATH;
2486
2558
  layout[0].rev = `dsh-mobile-layout-${DSH_MOBILE_VERSION}`;
2487
- const remoteSettings = orderAuthenticatedSettings(entries, dependencyProfile.slots);
2488
- let mobileBatch;
2489
- if (parsed.batches !== void 0) {
2490
- if (!Array.isArray(parsed.batches)) throw new Error("upstream DSH boot manifest batches are malformed");
2491
- const batches = parsed.batches;
2492
- const entryById = new Map(entries.map((entry) => [entry.id, entry]));
2493
- if (entryById.size !== entries.length) throw new Error("upstream DSH boot manifest has duplicate entries");
2494
- const layoutBatches = [];
2495
- for (const batch of batches) {
2496
- if (batch === null || typeof batch !== "object" || batch.phase !== "bootstrap" && batch.phase !== "application" || typeof batch.url !== "string" || typeof batch.rev !== "string" || !Array.isArray(batch.entries) || batch.entries.length === 0 || batch.entries.some((id) => typeof id !== "string" || !entryById.has(id))) throw new Error("upstream DSH boot manifest batches are malformed");
2497
- if (batch.entries.includes(MOBILE_LAYOUT_MODULE)) layoutBatches.push(batch);
2498
- }
2499
- if (layoutBatches.length !== 1 || layoutBatches[0]?.phase !== "application") throw new Error("upstream DSH boot manifest has no unique application layout batch");
2500
- const layoutBatch = layoutBatches[0];
2501
- const planEntries = layoutBatch.entries.map((id) => {
2502
- const entry = entryById.get(id);
2503
- if (entry === void 0 || typeof entry.url !== "string" || typeof entry.rev !== "string") throw new Error("upstream DSH boot manifest batches are malformed");
2504
- return Object.freeze({
2505
- id,
2559
+ const parsedBatches = parsed.batches;
2560
+ if (parsedBatches === void 0) return Object.freeze({
2561
+ parsed,
2562
+ entries,
2563
+ slotProvider: dependencyProfile.slots,
2564
+ assignment: assignment[0],
2565
+ replaceStart,
2566
+ replaceEnd: scriptEnd
2567
+ });
2568
+ if (!Array.isArray(parsedBatches)) throw new Error("upstream DSH boot manifest batches are malformed");
2569
+ const batches = parsedBatches;
2570
+ const entryById = new Map(entries.map((entry) => [entry.id, entry]));
2571
+ if (entryById.size !== entries.length) throw new Error("upstream DSH boot manifest has duplicate entries");
2572
+ const layoutBatches = [];
2573
+ for (const batch of batches) {
2574
+ if (batch === null || typeof batch !== "object" || batch.phase !== "bootstrap" && batch.phase !== "application" || typeof batch.url !== "string" || typeof batch.rev !== "string" || !Array.isArray(batch.entries) || batch.entries.length === 0 || batch.entries.some((id) => typeof id !== "string" || !entryById.has(id))) throw new Error("upstream DSH boot manifest batches are malformed");
2575
+ if (batch.entries.includes(MOBILE_LAYOUT_MODULE)) layoutBatches.push(batch);
2576
+ }
2577
+ if (layoutBatches.length !== 1 || layoutBatches[0]?.phase !== "application") throw new Error("upstream DSH boot manifest has no unique application layout batch");
2578
+ const layoutBatch = layoutBatches[0];
2579
+ const planEntries = layoutBatch.entries.map((id) => {
2580
+ const entry = entryById.get(id);
2581
+ if (entry === void 0 || typeof entry.url !== "string" || typeof entry.rev !== "string") throw new Error("upstream DSH boot manifest batches are malformed");
2582
+ return Object.freeze({
2583
+ id,
2584
+ url: entry.url,
2585
+ rev: entry.rev
2586
+ });
2587
+ });
2588
+ return Object.freeze({
2589
+ parsed,
2590
+ entries,
2591
+ batches,
2592
+ slotProvider: dependencyProfile.slots,
2593
+ assignment: assignment[0],
2594
+ replaceStart,
2595
+ replaceEnd: scriptEnd,
2596
+ layoutBatch,
2597
+ planEntries
2598
+ });
2599
+ }
2600
+ /**
2601
+ * Partition one upstream application batch into merged batches plus pass-through
2602
+ * rows. Every entry whose bundle is at or above the per-entry cap keeps its own
2603
+ * upstream `/plugins` row (the gateway proxies it verbatim), because a merged
2604
+ * batch cannot carry it. The remaining entries are greedily packed into merged
2605
+ * batches under {@link MOBILE_BOOT_CHUNK_BYTES}; the layout module always merges,
2606
+ * since its body is the gateway-owned layout file, never an upstream fetch.
2607
+ */
2608
+ function splitMobileBootBatch(entries, sizes, passThrough) {
2609
+ const rows = [];
2610
+ const plans = [];
2611
+ let chunk;
2612
+ const flush = () => {
2613
+ if (chunk === void 0 || chunk.entries.length === 0) return;
2614
+ const revision = revisionedMobileBatchPath(chunk.entries);
2615
+ rows.push({
2616
+ phase: "application",
2617
+ url: revision.path,
2618
+ rev: revision.key,
2619
+ entries: chunk.ids
2620
+ });
2621
+ plans.push(Object.freeze({
2622
+ key: revision.key,
2623
+ path: revision.path,
2624
+ entries: Object.freeze(chunk.entries)
2625
+ }));
2626
+ chunk = void 0;
2627
+ };
2628
+ for (const entry of entries) {
2629
+ const size = sizes.get(entry.url);
2630
+ if ((passThrough.has(entry.url) || size !== void 0 && size >= MAX_MOBILE_BOOT_ENTRY_BYTES) && entry.id !== MOBILE_LAYOUT_MODULE) {
2631
+ rows.push({
2632
+ phase: "application",
2506
2633
  url: entry.url,
2507
- rev: entry.rev
2634
+ rev: entry.rev,
2635
+ entries: [entry.id]
2508
2636
  });
2509
- });
2510
- const revision = revisionedMobileBatchPath(planEntries);
2511
- layoutBatch.url = revision.path;
2512
- layoutBatch.rev = revision.key;
2513
- mobileBatch = Object.freeze({
2514
- ...revision,
2515
- entries: Object.freeze(planEntries)
2516
- });
2517
- parsed.rev = createHash("sha256").update(JSON.stringify({
2518
- entries,
2519
- batches
2637
+ continue;
2638
+ }
2639
+ const bytes = (size ?? 0) + MOBILE_BOOT_SEPARATOR_BYTES;
2640
+ if (chunk !== void 0 && chunk.entries.length > 0 && chunk.bytes + bytes > MOBILE_BOOT_CHUNK_BYTES) flush();
2641
+ chunk ??= {
2642
+ ids: [],
2643
+ entries: [],
2644
+ bytes: 0
2645
+ };
2646
+ chunk.ids.push(entry.id);
2647
+ chunk.entries.push(entry);
2648
+ chunk.bytes += bytes;
2649
+ }
2650
+ flush();
2651
+ return {
2652
+ plans,
2653
+ rows
2654
+ };
2655
+ }
2656
+ function rewriteMobileIndexWithBatch(html, options = {}) {
2657
+ const plan = parseMobileBootPlan(html);
2658
+ const remoteSettings = orderAuthenticatedSettings(plan.entries, plan.slotProvider);
2659
+ let batches = [];
2660
+ if (plan.batches !== void 0 && plan.layoutBatch !== void 0 && plan.planEntries !== void 0) {
2661
+ const split = splitMobileBootBatch(plan.planEntries, options.sizes ?? /* @__PURE__ */ new Map(), options.passThrough ?? /* @__PURE__ */ new Set());
2662
+ const at = plan.batches.indexOf(plan.layoutBatch);
2663
+ if (at < 0) throw new Error("upstream DSH boot manifest has no unique application layout batch");
2664
+ plan.batches.splice(at, 1, ...split.rows);
2665
+ batches = Object.freeze(split.plans);
2666
+ plan.parsed.rev = createHash("sha256").update(JSON.stringify({
2667
+ entries: plan.entries,
2668
+ batches: plan.batches
2520
2669
  })).digest("hex").slice(0, 16);
2521
2670
  }
2522
- const replacement = `${remoteSettings ? MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP : ""}${MOBILE_CSRF_FETCH_BOOTSTRAP}window.__DSH_MOBILE_FRONTEND__="dedicated";${assignment[0]}${JSON.stringify(parsed)};`;
2671
+ const replacement = `${remoteSettings ? MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP : ""}${MOBILE_CSRF_FETCH_BOOTSTRAP}window.__DSH_MOBILE_FRONTEND__="dedicated";${plan.assignment}${JSON.stringify(plan.parsed)};`;
2523
2672
  return Object.freeze({
2524
- html: ensureMobileViewport(`${html.slice(0, start)}${replacement}${html.slice(scriptEnd)}`),
2525
- ...mobileBatch === void 0 ? {} : { batch: mobileBatch }
2673
+ html: ensureMobileViewport(ensureMobileCompatibility(`${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`)),
2674
+ batches
2526
2675
  });
2527
2676
  }
2528
2677
  /** Replace only DSH's layout client module while retaining its complete plugin graph. */
@@ -2946,6 +3095,15 @@ var MobileAccessGateway = class {
2946
3095
  server;
2947
3096
  discoverySocket;
2948
3097
  discoveryTimer;
3098
+ /**
3099
+ * Why broadcast discovery is unavailable, if it is. Windows keeps separate TCP and
3100
+ * UDP port-exclusion tables, so the port the OS handed the TCP listener can be
3101
+ * refused for UDP. That degradation is survivable but must stay observable: without
3102
+ * it, "the phone cannot find this computer" has no diagnosable cause on the host.
3103
+ */
3104
+ discoveryError;
3105
+ /** Names of the bundled mobile assets that could not be read, if any. */
3106
+ mobileAssetError;
2949
3107
  bonjour;
2950
3108
  pairingCaCertificate;
2951
3109
  listenerPort;
@@ -2953,6 +3111,8 @@ var MobileAccessGateway = class {
2953
3111
  activeRequests = /* @__PURE__ */ new Map();
2954
3112
  activeWebSockets = /* @__PURE__ */ new Map();
2955
3113
  mobileBootBatches = /* @__PURE__ */ new Map();
3114
+ /** Cached upstream bundle byte sizes (HEAD probes), keyed by resource URL. */
3115
+ bootEntrySizeCache = /* @__PURE__ */ new Map();
2956
3116
  extensionEventListeners = /* @__PURE__ */ new Set();
2957
3117
  extensionEventRevision = 0;
2958
3118
  taskEventListeners = /* @__PURE__ */ new Set();
@@ -3021,6 +3181,9 @@ var MobileAccessGateway = class {
3021
3181
  if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey) || fingerprint !== this.config.instanceId) throw new Error("pairingCaFile must be the self-signed CA identified by instanceId");
3022
3182
  this.pairingCaCertificate = certificate.raw.toString("base64");
3023
3183
  }
3184
+ const missingAssets = [];
3185
+ for (const [label, file] of [["compatibility", this.config.mobileCompatibilityFile], ["layout", this.config.mobileLayoutFile]]) if (!(await lstat(file).catch(() => void 0))?.isFile()) missingAssets.push(label);
3186
+ this.mobileAssetError = missingAssets.length === 0 ? void 0 : `mobile_assets_missing_${missingAssets.join("_")}`;
3024
3187
  const handler = (request, response) => {
3025
3188
  this.handleExternalRequest(request, response).catch((error) => {
3026
3189
  const mapped = mapError(error);
@@ -3093,24 +3256,35 @@ var MobileAccessGateway = class {
3093
3256
  if (this.closing || !message.equals(DISCOVERY_QUERY) || !addressAllowed(remote.address, this.config.allowedCidrs)) return;
3094
3257
  socket.send(announcement, remote.port, remote.address, () => void 0);
3095
3258
  });
3096
- await new Promise((resolve, reject) => {
3097
- const failed = (error) => {
3098
- reject(error);
3259
+ const bindHost = isIP(this.config.listenHost) === 4 && isLoopbackAddress(this.config.listenHost) ? this.config.listenHost : "0.0.0.0";
3260
+ if (await new Promise((resolve) => {
3261
+ const onBindError = (error) => {
3262
+ socket.off("error", onBindError);
3263
+ this.discoveryError = Object.freeze({
3264
+ code: typeof error.code === "string" ? error.code : "unknown",
3265
+ message: typeof error.message === "string" ? error.message : String(error)
3266
+ });
3267
+ resolve(false);
3099
3268
  };
3100
- socket.once("error", failed);
3101
- const bindHost = isIP(this.config.listenHost) === 4 && isLoopbackAddress(this.config.listenHost) ? this.config.listenHost : "0.0.0.0";
3269
+ socket.once("error", onBindError);
3102
3270
  socket.bind(port, bindHost, () => {
3103
- socket.off("error", failed);
3271
+ socket.off("error", onBindError);
3104
3272
  socket.setBroadcast(true);
3105
- resolve();
3273
+ resolve(true);
3106
3274
  });
3107
- });
3108
- const announce = () => {
3109
- for (const target of discoveryBroadcastTargets(this.config.allowedCidrs)) socket.send(announcement, port, target, () => void 0);
3110
- };
3111
- announce();
3112
- this.discoveryTimer = setInterval(announce, DISCOVERY_INTERVAL_MS);
3113
- this.discoveryTimer.unref();
3275
+ })) {
3276
+ const announce = () => {
3277
+ for (const target of discoveryBroadcastTargets(this.config.allowedCidrs)) socket.send(announcement, port, target, () => void 0);
3278
+ };
3279
+ announce();
3280
+ this.discoveryTimer = setInterval(announce, DISCOVERY_INTERVAL_MS);
3281
+ this.discoveryTimer.unref();
3282
+ } else {
3283
+ this.discoverySocket = void 0;
3284
+ try {
3285
+ socket.close();
3286
+ } catch {}
3287
+ }
3114
3288
  const deviceName = discoveryDeviceName();
3115
3289
  const bonjour = new Bonjour({ disableIPv6: true });
3116
3290
  this.bonjour = bonjour;
@@ -3176,6 +3350,21 @@ var MobileAccessGateway = class {
3176
3350
  origin
3177
3351
  });
3178
3352
  }
3353
+ /**
3354
+ * Report which discovery channels this gateway actually owns.
3355
+ *
3356
+ * Broadcast discovery degrades on its own when its UDP port cannot be bound, so
3357
+ * callers need to tell "discovery is off" from "discovery was never attempted".
3358
+ * mDNS is published independently and is unaffected by that failure.
3359
+ */
3360
+ discoveryStatus() {
3361
+ return Object.freeze({
3362
+ broadcast: this.discoverySocket !== void 0 && this.discoveryTimer !== void 0,
3363
+ mdns: this.bonjour !== void 0,
3364
+ ...this.discoveryError === void 0 ? {} : { errorCode: `discovery_broadcast_${this.discoveryError.code}` },
3365
+ ...this.mobileAssetError === void 0 ? {} : { mobileAssetsErrorCode: this.mobileAssetError }
3366
+ });
3367
+ }
3179
3368
  requirePolicy() {
3180
3369
  if (this.policy === void 0 || this.closing) throw new HttpError(503, "unavailable");
3181
3370
  return this.policy;
@@ -3414,6 +3603,10 @@ var MobileAccessGateway = class {
3414
3603
  file: this.config.mobileLayoutFile,
3415
3604
  contentType: "text/javascript; charset=utf-8",
3416
3605
  fallback: void 0
3606
+ } : target.decodedPathname === MOBILE_COMPAT_PATH ? {
3607
+ file: this.config.mobileCompatibilityFile,
3608
+ contentType: "text/javascript; charset=utf-8",
3609
+ fallback: void 0
3417
3610
  } : void 0 : void 0;
3418
3611
  if (customAsset === void 0 && requestedMobileBootBatch === void 0 && !computerImages && !computerImage && extensionTarget(target.decodedPathname) === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
3419
3612
  if (request.method !== "GET" && request.method !== "HEAD" && request.method !== "POST" && requestedExtension?.kind !== "route") throw new HttpError(405, "method_not_allowed");
@@ -3820,8 +4013,10 @@ var MobileAccessGateway = class {
3820
4013
  }
3821
4014
  let body;
3822
4015
  try {
3823
- const rewritten = rewriteMobileIndexWithBatch(Buffer.concat(chunks).toString("utf8"));
3824
- if (rewritten.batch !== void 0) this.rememberMobileBootBatch(rewritten.batch);
4016
+ const html = Buffer.concat(chunks).toString("utf8");
4017
+ const plan = parseMobileBootPlan(html);
4018
+ const rewritten = rewriteMobileIndexWithBatch(html, plan.planEntries === void 0 ? {} : await this.resolveMobileBootSizes(plan.planEntries));
4019
+ for (const batch of rewritten.batches) this.rememberMobileBootBatch(batch);
3825
4020
  body = Buffer.from(rewritten.html);
3826
4021
  } catch {
3827
4022
  throw new HttpError(502, "upstream_unavailable");
@@ -3857,6 +4052,98 @@ var MobileAccessGateway = class {
3857
4052
  this.mobileBootBatches.delete(oldest);
3858
4053
  }
3859
4054
  }
4055
+ /**
4056
+ * Determine which layout-batch entries can share a merged boot batch and which
4057
+ * must pass through. An entry at or above the per-entry cap, or one whose size
4058
+ * could not be probed, keeps its own upstream `/plugins` row: a merged batch
4059
+ * cannot carry it, and an unknown bundle must not risk the whole batch.
4060
+ */
4061
+ async resolveMobileBootSizes(planEntries) {
4062
+ const sizes = /* @__PURE__ */ new Map();
4063
+ const passThrough = /* @__PURE__ */ new Set();
4064
+ const candidates = planEntries.filter((entry) => entry.id !== MOBILE_LAYOUT_MODULE && entry.url.startsWith("/plugins/"));
4065
+ let cursor = 0;
4066
+ const worker = async () => {
4067
+ while (cursor < candidates.length) {
4068
+ const index = cursor++;
4069
+ const source = candidates[index].url;
4070
+ const size = await this.upstreamBundleSize(source);
4071
+ if (size === void 0 || size >= MAX_MOBILE_BOOT_ENTRY_BYTES) passThrough.add(source);
4072
+ else sizes.set(source, size);
4073
+ }
4074
+ };
4075
+ await Promise.all(Array.from({ length: Math.min(8, candidates.length) }, worker));
4076
+ return {
4077
+ sizes,
4078
+ passThrough
4079
+ };
4080
+ }
4081
+ /**
4082
+ * Measure one upstream bundle by reading its body. The upstream serves
4083
+ * `/plugins` bundles as chunked streams without a Content-Length, so a probe
4084
+ * counts bytes; it aborts instantly past the per-entry cap. Measurements are
4085
+ * cached for a short window.
4086
+ */
4087
+ async upstreamBundleSize(source) {
4088
+ if (!source.startsWith("/plugins/") || source.includes("#")) return void 0;
4089
+ const cached = this.bootEntrySizeCache.get(source);
4090
+ if (cached !== void 0 && Date.now() - cached.at < MOBILE_BOOT_SIZE_CACHE_TTL_MS) return cached.size;
4091
+ const target = new URL(source, this.config.upstreamOrigin);
4092
+ if (target.origin !== this.config.upstreamOrigin.origin) return void 0;
4093
+ const upstreamCookie = await this.upstreamCookieHeader();
4094
+ let upstreamRequest;
4095
+ try {
4096
+ const proxied = await new Promise((resolve, reject) => {
4097
+ upstreamRequest = request({
4098
+ protocol: "http:",
4099
+ hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
4100
+ port: Number(this.config.upstreamOrigin.port),
4101
+ method: "GET",
4102
+ path: `${target.pathname}${target.search}`,
4103
+ headers: {
4104
+ host: this.config.upstreamOrigin.host,
4105
+ accept: "text/javascript",
4106
+ "accept-encoding": "identity",
4107
+ [MOBILE_BOOT_SIZE_PROBE_HEADER]: "1",
4108
+ ...upstreamCookie === void 0 ? {} : { cookie: upstreamCookie }
4109
+ },
4110
+ agent: false
4111
+ });
4112
+ upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
4113
+ upstreamRequest?.destroy(upstreamTimeoutError());
4114
+ });
4115
+ upstreamRequest.once("response", resolve);
4116
+ upstreamRequest.once("error", reject);
4117
+ upstreamRequest.end();
4118
+ });
4119
+ if (proxied.statusCode !== 200) {
4120
+ proxied.resume();
4121
+ return;
4122
+ }
4123
+ const size = await new Promise((resolve, reject) => {
4124
+ let bytes = 0;
4125
+ proxied.on("data", (chunk) => {
4126
+ bytes += chunk.length;
4127
+ if (bytes > MAX_MOBILE_BOOT_ENTRY_BYTES) {
4128
+ proxied.destroy();
4129
+ resolve(void 0);
4130
+ }
4131
+ });
4132
+ proxied.once("end", () => resolve(bytes));
4133
+ proxied.once("error", reject);
4134
+ });
4135
+ if (size === void 0) return void 0;
4136
+ this.bootEntrySizeCache.set(source, {
4137
+ size,
4138
+ at: Date.now()
4139
+ });
4140
+ return size;
4141
+ } catch {
4142
+ return;
4143
+ } finally {
4144
+ upstreamRequest?.destroy();
4145
+ }
4146
+ }
3860
4147
  async serveMobileBootBatch(key, request, response, authorization) {
3861
4148
  if (request.method !== "GET" && request.method !== "HEAD") throw new HttpError(405, "method_not_allowed");
3862
4149
  const stored = this.mobileBootBatches.get(key);
@@ -3938,7 +4225,7 @@ var MobileAccessGateway = class {
3938
4225
  }
3939
4226
  };
3940
4227
  await Promise.all(Array.from({ length: Math.min(3, plan.entries.length) }, worker));
3941
- if (bodies.reduce((bytes, body) => bytes + body.byteLength + 2, 0) > MAX_MOBILE_BOOT_BATCH_BYTES) throw new HttpError(502, "upstream_unavailable");
4228
+ if (bodies.reduce((bytes, body) => bytes + body.byteLength + MOBILE_BOOT_SEPARATOR_BYTES, 0) > MAX_MOBILE_BOOT_BATCH_BYTES) throw new HttpError(502, "upstream_unavailable");
3942
4229
  return Buffer.concat(bodies.flatMap((body) => [body, Buffer.from("\n;\n")]));
3943
4230
  }
3944
4231
  /**
@@ -4047,7 +4334,39 @@ var MobileAccessGateway = class {
4047
4334
  }
4048
4335
  };
4049
4336
  }
4050
- async proxyHttp(request$2, response, authorization) {
4337
+ /**
4338
+ * Proxy one request upstream. Pass-through client bundles (`GET /plugins`)
4339
+ * receive bounded transient retries — the upstream resets a fraction of fresh
4340
+ * connections — matching the resilience the merged-batch assembly already has.
4341
+ * A request is only retried before any byte reached the client.
4342
+ */
4343
+ async proxyHttp(request, response, authorization) {
4344
+ if (!(request.method === "GET" && request.url?.split("?", 1)[0]?.startsWith("/plugins/") === true)) {
4345
+ try {
4346
+ await this.proxyHttpOnce(request, response, authorization);
4347
+ } catch (error) {
4348
+ if (error instanceof HttpError) throw error;
4349
+ if (response.headersSent) response.destroy();
4350
+ else throw new HttpError(502, "upstream_unavailable");
4351
+ }
4352
+ return;
4353
+ }
4354
+ const delay = (attempt) => new Promise((resolve) => setTimeout(resolve, MOBILE_BOOT_RETRY_DELAY_MS * attempt));
4355
+ for (let attempt = 1; attempt <= MOBILE_BOOT_UPSTREAM_ATTEMPTS; attempt++) try {
4356
+ await this.proxyHttpOnce(request, response, authorization);
4357
+ return;
4358
+ } catch (error) {
4359
+ if (response.headersSent) {
4360
+ response.destroy();
4361
+ return;
4362
+ }
4363
+ if (error instanceof HttpError || !isTransientUpstreamError(error)) throw error instanceof HttpError ? error : new HttpError(502, "upstream_unavailable");
4364
+ if (attempt === MOBILE_BOOT_UPSTREAM_ATTEMPTS) throw new HttpError(502, "upstream_unavailable");
4365
+ await delay(attempt);
4366
+ }
4367
+ throw new HttpError(502, "upstream_unavailable");
4368
+ }
4369
+ async proxyHttpOnce(request$2, response, authorization) {
4051
4370
  const declared = request$2.headers["content-length"];
4052
4371
  if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > this.config.maxBodyBytes)) throw new HttpError(413, "payload_too_large");
4053
4372
  const holder = {};
@@ -4101,7 +4420,7 @@ var MobileAccessGateway = class {
4101
4420
  await bodyDone?.catch(() => void 0);
4102
4421
  if (error instanceof HttpError) throw error;
4103
4422
  if (response.headersSent) response.destroy();
4104
- else throw new HttpError(502, "upstream_unavailable");
4423
+ throw error;
4105
4424
  } finally {
4106
4425
  operation.release();
4107
4426
  }
@@ -4402,7 +4721,8 @@ var MobileAccessGateway = class {
4402
4721
  connections: this.connectedSockets.size,
4403
4722
  activeRequests: this.activeRequests.size,
4404
4723
  webSockets: this.activeWebSockets.size
4405
- }
4724
+ },
4725
+ discovery: this.discoveryStatus()
4406
4726
  }, false);
4407
4727
  return;
4408
4728
  }
@@ -4693,11 +5013,11 @@ const FRP_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
4693
5013
  downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION$1}/frp_${FRP_VERSION$1}_darwin_arm64.tar.gz`
4694
5014
  }
4695
5015
  ].map((release) => [`${release.platform}-${release.arch}`, Object.freeze(release)])));
4696
- function inside$1(parent, child) {
5016
+ function inside$2(parent, child) {
4697
5017
  const candidate = relative(parent, child);
4698
5018
  return candidate !== "" && !candidate.startsWith("..") && !isAbsolute(candidate);
4699
5019
  }
4700
- async function regularFile$1(file) {
5020
+ async function regularFile$2(file) {
4701
5021
  try {
4702
5022
  const entry = await lstat(file);
4703
5023
  return entry.isFile() && !entry.isSymbolicLink();
@@ -4737,7 +5057,7 @@ async function replaceDirectory(target, candidate) {
4737
5057
  });
4738
5058
  }
4739
5059
  }
4740
- function sha256$1(bytes) {
5060
+ function sha256$2(bytes) {
4741
5061
  return createHash("sha256").update(bytes).digest("hex");
4742
5062
  }
4743
5063
  async function runCapture(file, args) {
@@ -4789,10 +5109,10 @@ async function defaultExtractArtifact$1(archive, destination, executableName) {
4789
5109
  executableEntry
4790
5110
  ]);
4791
5111
  const extracted = join(unpacked, ...validatedArchiveEntry(executableEntry));
4792
- if (!await regularFile$1(extracted)) throw new Error("frp_archive_executable_invalid");
5112
+ if (!await regularFile$2(extracted)) throw new Error("frp_archive_executable_invalid");
4793
5113
  await copyFile(extracted, join(destination, executableName));
4794
5114
  }
4795
- async function defaultFetchArtifact$1(artifact, signal) {
5115
+ async function defaultFetchArtifact$2(artifact, signal) {
4796
5116
  const response = await fetch(artifact.downloadUrl, {
4797
5117
  redirect: "follow",
4798
5118
  signal
@@ -4861,14 +5181,14 @@ var FrpComponentManager = class {
4861
5181
  this.componentStorage,
4862
5182
  this.logRoot,
4863
5183
  this.stagingRoot
4864
- ]) if (!inside$1(stateDirectory, child)) throw new Error("frp component path escaped its state directory");
4865
- this.fetchArtifact = options.fetchArtifact ?? defaultFetchArtifact$1;
5184
+ ]) if (!inside$2(stateDirectory, child)) throw new Error("frp component path escaped its state directory");
5185
+ this.fetchArtifact = options.fetchArtifact ?? defaultFetchArtifact$2;
4866
5186
  this.extractArtifact = options.extractArtifact ?? defaultExtractArtifact$1;
4867
5187
  this.inspectExecutable = options.inspectExecutable ?? defaultInspectExecutable;
4868
5188
  }
4869
5189
  /** Inspect the managed executable without relying on global FRP installations. */
4870
5190
  async initialize() {
4871
- this.installed = await regularFile$1(this.executable);
5191
+ this.installed = await regularFile$2(this.executable);
4872
5192
  this.installedBytes = this.installed ? (await stat(this.executable)).size : 0;
4873
5193
  if (this.installed) try {
4874
5194
  if (await this.inspectExecutable(this.executable) !== FRP_VERSION$1) throw new Error("frp_component_version_mismatch");
@@ -4915,7 +5235,7 @@ var FrpComponentManager = class {
4915
5235
  clearTimeout(timeout);
4916
5236
  }
4917
5237
  if (bytes.byteLength !== artifact.downloadBytes) throw new Error("frp_download_size_mismatch");
4918
- if (sha256$1(bytes) !== artifact.downloadSha256) throw new Error("frp_download_hash_mismatch");
5238
+ if (sha256$2(bytes) !== artifact.downloadSha256) throw new Error("frp_download_hash_mismatch");
4919
5239
  const archive = join(staging, artifact.archiveName);
4920
5240
  await writeFile(archive, bytes, {
4921
5241
  flag: "wx",
@@ -4923,7 +5243,7 @@ var FrpComponentManager = class {
4923
5243
  });
4924
5244
  await this.extractArtifact(archive, staging, artifact.executableName);
4925
5245
  const extracted = join(staging, artifact.executableName);
4926
- if (!await regularFile$1(extracted)) throw new Error("frp_executable_missing");
5246
+ if (!await regularFile$2(extracted)) throw new Error("frp_executable_missing");
4927
5247
  await chmod(extracted, 448);
4928
5248
  if (await this.inspectExecutable(extracted) !== FRP_VERSION$1) throw new Error("frp_component_version_mismatch");
4929
5249
  const candidate = join(this.componentRoot, `.install-${randomBytes(12).toString("hex")}`);
@@ -5179,8 +5499,8 @@ function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
5179
5499
  }
5180
5500
  //#endregion
5181
5501
  //#region src/frp-config.ts
5182
- const MAX_SETTINGS_BYTES = 8192;
5183
- function hostname$1(value) {
5502
+ const MAX_SETTINGS_BYTES$1 = 8192;
5503
+ function hostname$2(value) {
5184
5504
  if (value.length > 253 || !value.includes(".")) return false;
5185
5505
  return value.split(".").every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label));
5186
5506
  }
@@ -5188,7 +5508,7 @@ function hostname$1(value) {
5188
5508
  function validateFrpServerAddress(value) {
5189
5509
  if (typeof value !== "string" || value !== value.trim() || value.length === 0 || value.length > 253 || /[\s\u0000-\u001f\u007f/\\@?#]/u.test(value)) throw new Error("frp_server_address_invalid");
5190
5510
  const normalized = value.toLowerCase().replace(/\.$/u, "");
5191
- if (isIP(normalized) === 0 && !hostname$1(normalized)) throw new Error("frp_server_address_invalid");
5511
+ if (isIP(normalized) === 0 && !hostname$2(normalized)) throw new Error("frp_server_address_invalid");
5192
5512
  return normalized;
5193
5513
  }
5194
5514
  /** Validate the FRP control port. */
@@ -5211,7 +5531,7 @@ function validateFrpPublicOrigin(value) {
5211
5531
  throw new Error("frp_public_origin_invalid");
5212
5532
  }
5213
5533
  const publicHost = url.hostname;
5214
- if (url.protocol !== "https:" || url.port !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "" || isIP(publicHost) !== 4 && !hostname$1(publicHost)) throw new Error("frp_public_origin_invalid");
5534
+ if (url.protocol !== "https:" || url.port !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "" || isIP(publicHost) !== 4 && !hostname$2(publicHost)) throw new Error("frp_public_origin_invalid");
5215
5535
  if (isIP(publicHost) === 4 && !isGloballyRoutableIpv4(publicHost)) throw new Error("frp_public_origin_invalid");
5216
5536
  return url.origin;
5217
5537
  }
@@ -5290,7 +5610,7 @@ function createFrpcToml(settings, localPort) {
5290
5610
  function createFrpServerTemplate(settings) {
5291
5611
  return createRestrictedFrpServerTemplate(settings.serverPort, settings.token, settings.publicOrigin);
5292
5612
  }
5293
- async function atomicPrivateWrite(file, body) {
5613
+ async function atomicPrivateWrite$2(file, body) {
5294
5614
  const directory = dirname(file);
5295
5615
  await mkdir(directory, {
5296
5616
  recursive: true,
@@ -5338,7 +5658,7 @@ var FrpConfigStore = class {
5338
5658
  if (error.code === "ENOENT") return;
5339
5659
  throw error;
5340
5660
  }
5341
- if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_SETTINGS_BYTES) {
5661
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_SETTINGS_BYTES$1) {
5342
5662
  this.errorCode = "frp_config_invalid";
5343
5663
  return;
5344
5664
  }
@@ -5373,7 +5693,7 @@ var FrpConfigStore = class {
5373
5693
  /** Atomically replace private FRP settings. */
5374
5694
  async configure(value) {
5375
5695
  const settings = parseFrpSettings(value);
5376
- await atomicPrivateWrite(this.settingsFile, `${JSON.stringify(settings)}\n`);
5696
+ await atomicPrivateWrite$2(this.settingsFile, `${JSON.stringify(settings)}\n`);
5377
5697
  await rm(this.runtimeConfigFile, { force: true });
5378
5698
  this.settingsValue = settings;
5379
5699
  this.errorCode = void 0;
@@ -5383,7 +5703,7 @@ var FrpConfigStore = class {
5383
5703
  async writeRuntimeConfig(localPort) {
5384
5704
  const settings = this.settingsValue;
5385
5705
  if (settings === void 0) throw new Error("frp_config_missing");
5386
- await atomicPrivateWrite(this.runtimeConfigFile, createFrpcToml(settings, localPort));
5706
+ await atomicPrivateWrite$2(this.runtimeConfigFile, createFrpcToml(settings, localPort));
5387
5707
  return this.runtimeConfigFile;
5388
5708
  }
5389
5709
  /** Remove only configuration files owned by the FRP provider. */
@@ -5399,11 +5719,13 @@ var FrpConfigStore = class {
5399
5719
  };
5400
5720
  //#endregion
5401
5721
  //#region src/remote.ts
5402
- const REMOTE_PROVIDERS = [
5722
+ const REMOTE_PROVIDERS = Object.freeze([
5403
5723
  "tailscale",
5404
5724
  "cpolar",
5405
- "frp"
5406
- ];
5725
+ "cloudflared",
5726
+ "frp",
5727
+ "origin"
5728
+ ]);
5407
5729
  function aggregateErrors(errors, message) {
5408
5730
  if (errors.length === 0) return void 0;
5409
5731
  if (errors.length === 1 && errors[0] instanceof Error) return errors[0];
@@ -5529,7 +5851,7 @@ async function terminateRemoteProcess(child, gracefulTimeoutMs = 1500, forcedTim
5529
5851
  function parseRemoteProviderState(value) {
5530
5852
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("remote provider state must be an object");
5531
5853
  const record = value;
5532
- if (record.version !== 1 || record.provider !== "tailscale" && record.provider !== "cpolar" && record.provider !== "frp" || Reflect.ownKeys(record).some((key) => key !== "version" && key !== "provider")) throw new Error("remote provider state has an unsupported format");
5854
+ if (record.version !== 1 || record.provider !== "tailscale" && record.provider !== "cpolar" && record.provider !== "cloudflared" && record.provider !== "frp" && record.provider !== "origin" || Reflect.ownKeys(record).some((key) => key !== "version" && key !== "provider")) throw new Error("remote provider state has an unsupported format");
5533
5855
  return Object.freeze({
5534
5856
  version: 1,
5535
5857
  provider: record.provider
@@ -5595,17 +5917,17 @@ var JsonRemoteProviderStore = class {
5595
5917
  /** Resolve the first-run provider without letting environment values bypass validation. */
5596
5918
  function configuredRemoteProvider(environment) {
5597
5919
  const value = environment.DSH_MOBILE_REMOTE_PROVIDER ?? "tailscale";
5598
- if (value !== "tailscale" && value !== "cpolar" && value !== "frp") throw new Error("DSH_MOBILE_REMOTE_PROVIDER must be tailscale, cpolar, or frp");
5920
+ if (value !== "tailscale" && value !== "cpolar" && value !== "cloudflared" && value !== "frp" && value !== "origin") throw new Error("DSH_MOBILE_REMOTE_PROVIDER must be tailscale, cpolar, cloudflared, frp, or origin");
5599
5921
  return value;
5600
5922
  }
5601
5923
  //#endregion
5602
5924
  //#region src/frp.ts
5603
- const START_TIMEOUT_MS$1 = 45e3;
5925
+ const START_TIMEOUT_MS$2 = 45e3;
5604
5926
  const DISCOVERY_REQUEST_TIMEOUT_MS = 5e3;
5605
5927
  const DISCOVERY_RETRY_MS = 1e3;
5606
5928
  const MAX_DISCOVERY_BYTES = 16384;
5607
5929
  const VHOST_PROBE_TIMEOUT_MS = 1500;
5608
- function publicStatus$2(status) {
5930
+ function publicStatus$3(status) {
5609
5931
  return Object.freeze({
5610
5932
  enabled: status.enabled,
5611
5933
  state: status.state,
@@ -5741,7 +6063,7 @@ var FrpController = class {
5741
6063
  child;
5742
6064
  gatewayValue;
5743
6065
  generation = 0;
5744
- latest = publicStatus$2({
6066
+ latest = publicStatus$3({
5745
6067
  enabled: false,
5746
6068
  state: "off"
5747
6069
  });
@@ -5769,7 +6091,7 @@ var FrpController = class {
5769
6091
  }
5770
6092
  /** Return state safe for the desktop control UI. */
5771
6093
  status() {
5772
- return publicStatus$2(this.latest);
6094
+ return publicStatus$3(this.latest);
5773
6095
  }
5774
6096
  /** Enable or disable FRP without changing LAN or another provider. */
5775
6097
  async setEnabled(enabled) {
@@ -5835,7 +6157,7 @@ var FrpController = class {
5835
6157
  return task;
5836
6158
  }
5837
6159
  publish(status) {
5838
- this.latest = publicStatus$2(status);
6160
+ this.latest = publicStatus$3(status);
5839
6161
  try {
5840
6162
  this.options.onStatus?.(this.status());
5841
6163
  } catch {}
@@ -5950,7 +6272,7 @@ var FrpController = class {
5950
6272
  this.waitForDiscovery(generation, settings.publicOrigin, controller.signal);
5951
6273
  }
5952
6274
  async waitForDiscovery(generation, origin, signal) {
5953
- const deadline = Date.now() + (this.options.startTimeoutMs ?? START_TIMEOUT_MS$1);
6275
+ const deadline = Date.now() + (this.options.startTimeoutMs ?? START_TIMEOUT_MS$2);
5954
6276
  const probe = this.options.probeDiscovery ?? defaultProbeDiscovery;
5955
6277
  while (!signal.aborted && Date.now() < deadline) {
5956
6278
  try {
@@ -6017,80 +6339,1436 @@ var FrpController = class {
6017
6339
  }
6018
6340
  };
6019
6341
  //#endregion
6020
- //#region src/task-events.ts
6021
- const TASK_EVENT_DEBOUNCE_MS = 1e3;
6022
- function turnNumber(value) {
6023
- return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
6024
- }
6025
- function turnData(value) {
6026
- if (value === null || typeof value !== "object" || Array.isArray(value)) return {
6027
- turn: 0,
6028
- completed: false
6029
- };
6030
- const record = value;
6031
- const reason = record.reason;
6032
- const completed = typeof reason === "string" ? reason === "completed" : reason !== null && typeof reason === "object" && reason.kind === "completed";
6033
- return {
6034
- turn: turnNumber(record.turn),
6035
- completed
6036
- };
6037
- }
6342
+ //#region src/origin-proxy-config.ts
6343
+ const DEFAULT_ORIGIN_LISTEN_PORT = 3444;
6344
+ const MAX_SETTINGS_BYTES = 8192;
6038
6345
  /**
6039
- * Subscribe to completed root turns. Subagent turns are skipped so one task
6040
- * announces once, and rapid turn boundaries collapse into a single event.
6041
- * Returns a disposer that also drops pending debounces.
6346
+ * Ports a user-space listener can actually hold on every supported platform. Below
6347
+ * this the bind needs privileges on Linux and macOS and fails with EACCES, which
6348
+ * surfaces as a generic start failure rather than a rejected choice.
6042
6349
  */
6043
- function watchTaskCompletions(ctx, options) {
6044
- const debounceMs = options.debounceMs ?? 1e3;
6045
- const pending = /* @__PURE__ */ new Map();
6046
- const disposeListener = ctx.on("session/event", (session, event) => {
6047
- if (event.type !== "turn/end") return;
6048
- const { turn, completed } = turnData(event.data);
6049
- if (!completed) return;
6050
- if (session.header?.parentSession !== void 0 && session.header?.parentSession !== null) return;
6051
- const sessionId = String(session.id);
6052
- options.log?.("task-completed-observed", {
6053
- sessionId,
6054
- turn
6055
- });
6056
- const previous = pending.get(sessionId);
6057
- if (previous !== void 0) clearTimeout(previous);
6058
- pending.set(sessionId, setTimeout(() => {
6059
- pending.delete(sessionId);
6060
- options.log?.("task-completed-announced", {
6061
- sessionId,
6062
- turn
6063
- });
6064
- options.onTaskCompleted(Object.freeze({
6065
- sessionId,
6066
- turn
6067
- }));
6068
- }, debounceMs));
6350
+ const MIN_ORIGIN_LISTEN_PORT = 1024;
6351
+ /** The LAN gateway's HTTPS port, held for as long as DSH runs. */
6352
+ const RESERVED_LAN_GATEWAY_PORT$1 = 3443;
6353
+ /** DSH's own WebServer port; the plugin cannot function while it is taken. */
6354
+ const RESERVED_DSH_WEB_PORT = 3080;
6355
+ const PRIVATE_NETWORKS = [
6356
+ "10.0.0.0/8",
6357
+ "172.16.0.0/12",
6358
+ "192.168.0.0/16"
6359
+ ].map(parseCidr);
6360
+ const TRUSTED_NETWORKS = [...PRIVATE_NETWORKS, parseCidr("127.0.0.0/8")];
6361
+ /** Require a public HTTPS origin; custom external ports are supported. */
6362
+ function validateOriginPublicOrigin(value) {
6363
+ if (typeof value !== "string" || value.length > 512 || /[\s\u0000-\u001f\u007f\\@?#]/u.test(value)) throw new Error("origin_public_origin_invalid");
6364
+ let url;
6365
+ try {
6366
+ url = new URL(value);
6367
+ } catch {
6368
+ throw new Error("origin_public_origin_invalid");
6369
+ }
6370
+ const host = url.hostname;
6371
+ const validHostname = host.length <= 253 && host.includes(".") && host.split(".").every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label)) && ![
6372
+ "localhost",
6373
+ "local",
6374
+ "lan",
6375
+ "home",
6376
+ "internal",
6377
+ "home.arpa"
6378
+ ].some((suffix) => host === suffix || host.endsWith("." + suffix));
6379
+ if (url.protocol !== "https:" || url.pathname !== "/" || url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "" || url.port !== "" && Number(url.port) < 1 || (isIP(host) === 4 ? !isGloballyRoutableIpv4(host) : isIP(host) !== 0 || !validHostname)) throw new Error("origin_public_origin_invalid");
6380
+ return url.origin;
6381
+ }
6382
+ /** Bind only one explicit loopback or RFC1918 IPv4 interface, never all interfaces. */
6383
+ function validateOriginListenHost(value) {
6384
+ if (typeof value !== "string" || isIP(value) !== 4 || !isLoopbackAddress(value) && !addressAllowed(value, PRIVATE_NETWORKS)) throw new Error("origin_listen_host_invalid");
6385
+ return value;
6386
+ }
6387
+ function validateOriginListenPort(value) {
6388
+ if (!Number.isSafeInteger(value) || Number(value) < MIN_ORIGIN_LISTEN_PORT || Number(value) > 65535) throw new Error("origin_listen_port_invalid");
6389
+ if (value === RESERVED_LAN_GATEWAY_PORT$1 || value === RESERVED_DSH_WEB_PORT) throw new Error("origin_listen_port_reserved");
6390
+ return Number(value);
6391
+ }
6392
+ /** Authorize direct proxy socket peers, not untrusted forwarded client headers. */
6393
+ function validateOriginAllowedCidrs(value, listenHost) {
6394
+ const input = value === void 0 && isLoopbackAddress(listenHost) ? ["127.0.0.0/8"] : value;
6395
+ if (!Array.isArray(input) || input.length === 0 || input.length > 16) throw new Error("origin_allowed_cidrs_invalid");
6396
+ const cidrs = [];
6397
+ for (const entry of input) {
6398
+ if (typeof entry !== "string" || entry.length > 64 || isIP(entry.split("/")[0] ?? "") !== 4) throw new Error("origin_allowed_cidrs_invalid");
6399
+ try {
6400
+ const cidr = parseCidr(entry);
6401
+ if (cidr.bits !== 32 || !TRUSTED_NETWORKS.some((range) => cidr.prefix >= range.prefix && addressAllowed(entry.split("/")[0], [range]))) throw new Error("untrusted CIDR");
6402
+ cidrs.push(cidr.source);
6403
+ } catch {
6404
+ throw new Error("origin_allowed_cidrs_invalid");
6405
+ }
6406
+ }
6407
+ if (!isLoopbackAddress(listenHost) && !cidrs.some((cidr) => !isLoopbackAddress(cidr.split("/")[0]))) throw new Error("origin_allowed_cidrs_invalid");
6408
+ return Object.freeze([...new Set(cidrs)]);
6409
+ }
6410
+ /** Validate both saved settings and local administrative requests. */
6411
+ function parseOriginSettings(value) {
6412
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("origin_settings_invalid");
6413
+ const record = value;
6414
+ if (Reflect.ownKeys(record).some((key) => ![
6415
+ "version",
6416
+ "publicOrigin",
6417
+ "listenHost",
6418
+ "listenPort",
6419
+ "allowedCidrs"
6420
+ ].includes(String(key))) || record.version !== void 0 && record.version !== 1) throw new Error("origin_settings_invalid");
6421
+ const listenHost = validateOriginListenHost(record.listenHost === void 0 ? "127.0.0.1" : record.listenHost);
6422
+ return Object.freeze({
6423
+ version: 1,
6424
+ publicOrigin: validateOriginPublicOrigin(record.publicOrigin),
6425
+ listenHost,
6426
+ listenPort: validateOriginListenPort(record.listenPort === void 0 ? DEFAULT_ORIGIN_LISTEN_PORT : record.listenPort),
6427
+ allowedCidrs: validateOriginAllowedCidrs(record.allowedCidrs, listenHost)
6069
6428
  });
6070
- return () => {
6071
- disposeListener();
6072
- for (const timer of pending.values()) clearTimeout(timer);
6073
- pending.clear();
6074
- };
6075
6429
  }
6076
- /** One subscription feeding every live gateway; gateways register on start. */
6077
- var TaskEventHub = class {
6078
- sinks = /* @__PURE__ */ new Set();
6079
- /** Register a sink; returns its disposer. */
6080
- add(sink) {
6081
- this.sinks.add(sink);
6082
- return () => {
6083
- this.sinks.delete(sink);
6084
- };
6430
+ async function atomicPrivateWrite$1(file, body) {
6431
+ const directory = dirname(file);
6432
+ await mkdir(directory, {
6433
+ recursive: true,
6434
+ mode: 448
6435
+ });
6436
+ try {
6437
+ const current = await lstat(file);
6438
+ if (!current.isFile() || current.isSymbolicLink()) throw new Error("origin_config_target_invalid");
6439
+ } catch (error) {
6440
+ if (error.code !== "ENOENT") throw error;
6085
6441
  }
6086
- /** Fan out to a snapshot so a failing sink cannot break its siblings. */
6087
- broadcast(event) {
6088
- for (const sink of [...this.sinks]) try {
6089
- sink.broadcastTaskEvent(event);
6090
- } catch {}
6442
+ const temporary = join(directory, "." + basename(file) + "." + randomBytes(12).toString("hex") + ".tmp");
6443
+ try {
6444
+ await writeFile(temporary, body, {
6445
+ encoding: "utf8",
6446
+ flag: "wx",
6447
+ mode: 384
6448
+ });
6449
+ await rename(temporary, file);
6450
+ await restrictPrivateFile(file);
6451
+ } catch (error) {
6452
+ await rm(temporary, { force: true });
6453
+ throw error;
6091
6454
  }
6092
- /** Visible for tests. */
6093
- get size() {
6455
+ }
6456
+ /** Owns only origin settings; shared paired-device storage is never removed. */
6457
+ var OriginConfigStore = class {
6458
+ stateRoot;
6459
+ settingsFile;
6460
+ settingsValue;
6461
+ errorCode;
6462
+ constructor(stateDirectory) {
6463
+ if (!isAbsolute(stateDirectory)) throw new Error("origin config state directory must be absolute");
6464
+ this.stateRoot = resolve(stateDirectory);
6465
+ this.settingsFile = join(this.stateRoot, "settings.json");
6466
+ }
6467
+ async initialize() {
6468
+ this.settingsValue = void 0;
6469
+ this.errorCode = void 0;
6470
+ let entry;
6471
+ try {
6472
+ entry = await lstat(this.settingsFile);
6473
+ } catch (error) {
6474
+ if (error.code === "ENOENT") return;
6475
+ throw error;
6476
+ }
6477
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_SETTINGS_BYTES) {
6478
+ this.errorCode = "origin_config_invalid";
6479
+ return;
6480
+ }
6481
+ await restrictPrivateFile(this.settingsFile);
6482
+ try {
6483
+ this.settingsValue = parseOriginSettings(JSON.parse(await readFile(this.settingsFile, "utf8")));
6484
+ } catch {
6485
+ this.errorCode = "origin_config_invalid";
6486
+ }
6487
+ }
6488
+ status() {
6489
+ const settings = this.settingsValue;
6490
+ return Object.freeze({
6491
+ configured: settings !== void 0,
6492
+ ...settings === void 0 ? {} : {
6493
+ publicOrigin: settings.publicOrigin,
6494
+ listenHost: settings.listenHost,
6495
+ listenPort: settings.listenPort,
6496
+ allowedCidrs: settings.allowedCidrs,
6497
+ backendOrigin: "http://" + settings.listenHost + ":" + String(settings.listenPort)
6498
+ },
6499
+ storagePath: this.stateRoot,
6500
+ ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
6501
+ });
6502
+ }
6503
+ settings() {
6504
+ return this.settingsValue;
6505
+ }
6506
+ async configure(value) {
6507
+ const settings = parseOriginSettings(value);
6508
+ await atomicPrivateWrite$1(this.settingsFile, JSON.stringify(settings) + "\n");
6509
+ this.settingsValue = settings;
6510
+ this.errorCode = void 0;
6511
+ return this.status();
6512
+ }
6513
+ async purge() {
6514
+ await rm(this.settingsFile, { force: true });
6515
+ this.settingsValue = void 0;
6516
+ this.errorCode = void 0;
6517
+ return this.status();
6518
+ }
6519
+ };
6520
+ //#endregion
6521
+ //#region src/origin-proxy.ts
6522
+ /** Owns an authenticated HTTP gateway, with no tunnel process or public network probes. */
6523
+ var OriginController = class {
6524
+ options;
6525
+ enabled = false;
6526
+ initialized = false;
6527
+ disposed = false;
6528
+ gatewayValue;
6529
+ latest = Object.freeze({
6530
+ enabled: false,
6531
+ state: "off"
6532
+ });
6533
+ queue = Promise.resolve();
6534
+ constructor(options) {
6535
+ this.options = options;
6536
+ }
6537
+ async initialize() {
6538
+ await this.enqueue(async () => {
6539
+ if (this.disposed) throw new Error("origin_controller_unavailable");
6540
+ if (this.initialized) return;
6541
+ this.enabled = (await this.options.store.load()).enabled;
6542
+ this.initialized = true;
6543
+ if (this.enabled) await this.start();
6544
+ else this.publish({
6545
+ enabled: false,
6546
+ state: "off"
6547
+ });
6548
+ });
6549
+ }
6550
+ gateway() {
6551
+ return this.gatewayValue;
6552
+ }
6553
+ status() {
6554
+ return this.latest;
6555
+ }
6556
+ async setEnabled(enabled) {
6557
+ this.assertAvailable();
6558
+ await this.enqueue(async () => {
6559
+ this.assertAvailable();
6560
+ if (this.enabled === enabled && (!enabled || this.gatewayValue !== void 0)) return;
6561
+ if (!enabled) await this.stop();
6562
+ await this.options.store.save({
6563
+ version: 1,
6564
+ enabled
6565
+ });
6566
+ this.enabled = enabled;
6567
+ if (enabled) await this.start();
6568
+ else this.publish({
6569
+ enabled: false,
6570
+ state: "off"
6571
+ });
6572
+ });
6573
+ return this.status();
6574
+ }
6575
+ async reconnect() {
6576
+ this.assertAvailable();
6577
+ await this.enqueue(async () => {
6578
+ this.assertAvailable();
6579
+ if (!this.enabled) {
6580
+ await this.options.store.save({
6581
+ version: 1,
6582
+ enabled: true
6583
+ });
6584
+ this.enabled = true;
6585
+ }
6586
+ await this.stop();
6587
+ await this.start();
6588
+ });
6589
+ return this.status();
6590
+ }
6591
+ /** Reset the switch, retaining both settings and shared remote device pairings. */
6592
+ async reset() {
6593
+ return this.setEnabled(false);
6594
+ }
6595
+ /** Stop the listener without changing the durable switch. */
6596
+ async close() {
6597
+ this.disposed = true;
6598
+ await this.enqueue(async () => {
6599
+ await this.stop();
6600
+ this.publish({
6601
+ enabled: this.enabled,
6602
+ state: "off"
6603
+ });
6604
+ });
6605
+ }
6606
+ assertAvailable() {
6607
+ if (!this.initialized || this.disposed) throw new Error("origin_controller_unavailable");
6608
+ }
6609
+ enqueue(operation) {
6610
+ const task = this.queue.then(operation, operation);
6611
+ this.queue = task.then(() => void 0, () => void 0);
6612
+ return task;
6613
+ }
6614
+ publish(status) {
6615
+ this.latest = Object.freeze({ ...status });
6616
+ try {
6617
+ this.options.onStatus?.(this.latest);
6618
+ } catch {}
6619
+ }
6620
+ async start() {
6621
+ const settings = this.options.config.settings();
6622
+ if (settings === void 0) {
6623
+ this.publish({
6624
+ enabled: true,
6625
+ state: "unavailable",
6626
+ errorCode: this.options.config.status().errorCode ?? "origin_config_missing"
6627
+ });
6628
+ return;
6629
+ }
6630
+ this.publish({
6631
+ enabled: true,
6632
+ state: "starting",
6633
+ origin: settings.publicOrigin
6634
+ });
6635
+ let gateway;
6636
+ try {
6637
+ gateway = await this.options.createGateway(settings);
6638
+ } catch (error) {
6639
+ const code = typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
6640
+ this.publish({
6641
+ enabled: true,
6642
+ state: "error",
6643
+ origin: settings.publicOrigin,
6644
+ errorCode: code === "EADDRINUSE" ? "origin_listen_port_in_use" : code === "EADDRNOTAVAIL" ? "origin_listen_address_unavailable" : "origin_gateway_start_failed"
6645
+ });
6646
+ return;
6647
+ }
6648
+ this.gatewayValue = gateway;
6649
+ if (this.disposed) {
6650
+ await this.stop();
6651
+ return;
6652
+ }
6653
+ this.publish({
6654
+ enabled: true,
6655
+ state: "ready",
6656
+ origin: settings.publicOrigin,
6657
+ backendOrigin: "http://" + settings.listenHost + ":" + String(gateway.address().port)
6658
+ });
6659
+ }
6660
+ async stop() {
6661
+ const gateway = this.gatewayValue;
6662
+ if (gateway === void 0) return;
6663
+ await gateway.close();
6664
+ this.gatewayValue = void 0;
6665
+ this.publish({
6666
+ enabled: this.enabled,
6667
+ state: "off"
6668
+ });
6669
+ }
6670
+ };
6671
+ //#endregion
6672
+ //#region src/component-download.ts
6673
+ /**
6674
+ * Hosts a pinned GitHub release download may redirect to.
6675
+ *
6676
+ * `github.com/<owner>/<repo>/releases/download/<tag>/<asset>` does not serve the bytes itself: it
6677
+ * answers 302 with a signed URL on a release-asset host. A fetch configured with
6678
+ * `redirect: 'error'` fails that hop as `TypeError: fetch failed` (cause: `unexpected redirect`)
6679
+ * before a single byte is transferred, which is exactly how the cloudflared component could never
6680
+ * install. Following one validated hop keeps the origin restriction while allowing the shape
6681
+ * every GitHub release download uses.
6682
+ */
6683
+ const RELEASE_ASSET_HOSTS = Object.freeze(["release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
6684
+ /** Follow one redirect hop after checking its scheme and host. A second hop is refused. */
6685
+ async function followValidatedRedirect(response, request) {
6686
+ const location = response.headers.get("location");
6687
+ if (location === null) throw new Error(`${request.errorPrefix}_download_redirect_missing`);
6688
+ let target;
6689
+ try {
6690
+ target = new URL(location, request.url);
6691
+ } catch {
6692
+ throw new Error(`${request.errorPrefix}_download_redirect_invalid`);
6693
+ }
6694
+ const hosts = request.redirectHosts ?? RELEASE_ASSET_HOSTS;
6695
+ if (target.protocol !== "https:" || !hosts.includes(target.hostname)) throw new Error(`${request.errorPrefix}_download_redirect_rejected`);
6696
+ return fetch(target, {
6697
+ redirect: "error",
6698
+ signal: request.signal
6699
+ });
6700
+ }
6701
+ /** Attempts per install. A 55 MB transfer through a TUN proxy can reset mid-stream: the first
6702
+ * real run in the development environment failed after 1.5 MB and succeeded on the next attempt,
6703
+ * which is exactly the confusing "nothing happened" the user saw. */
6704
+ const DOWNLOAD_ATTEMPTS = 2;
6705
+ const RETRY_DELAY_MS = 750;
6706
+ /** Whether a failure is a transport problem worth a second attempt rather than a verdict. */
6707
+ function isRetryable(error) {
6708
+ if (error instanceof TypeError) return true;
6709
+ if (error instanceof Error && /_download_http_5\d\d$/u.test(error.message)) return true;
6710
+ return false;
6711
+ }
6712
+ function delay(ms) {
6713
+ return new Promise((resolve) => {
6714
+ setTimeout(resolve, ms);
6715
+ });
6716
+ }
6717
+ async function attemptDownload(request) {
6718
+ const first = await fetch(request.url, {
6719
+ redirect: "manual",
6720
+ signal: request.signal
6721
+ });
6722
+ const response = first.status >= 300 && first.status < 400 ? await followValidatedRedirect(first, request) : first;
6723
+ if (!response.ok) throw new Error(`${request.errorPrefix}_download_http_${String(response.status)}`);
6724
+ const declared = Number(response.headers.get("content-length"));
6725
+ if (Number.isFinite(declared) && declared !== request.expectedBytes) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
6726
+ const bytes = new Uint8Array(await response.arrayBuffer());
6727
+ if (bytes.byteLength !== request.expectedBytes) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
6728
+ return bytes;
6729
+ }
6730
+ /**
6731
+ * Download a pinned artifact, following at most one validated redirect, and check its exact length.
6732
+ * A transport failure is retried once; a length or redirect verdict is not. The caller still
6733
+ * verifies the SHA-256 before publishing anything.
6734
+ * @param request - the pinned URL, its exact byte length and the caller's error prefix.
6735
+ * @returns the artifact bytes.
6736
+ */
6737
+ async function downloadPinnedArtifact(request) {
6738
+ let lastError;
6739
+ for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt += 1) try {
6740
+ return await attemptDownload(request);
6741
+ } catch (error) {
6742
+ lastError = error;
6743
+ if (attempt === DOWNLOAD_ATTEMPTS || !isRetryable(error)) throw error;
6744
+ await delay(RETRY_DELAY_MS);
6745
+ }
6746
+ throw lastError;
6747
+ }
6748
+ //#endregion
6749
+ //#region src/cloudflared-component.ts
6750
+ const CLOUDFLARED_VERSION = "2026.9.1";
6751
+ /** Pinned official cloudflared release metadata for supported desktop targets. */
6752
+ const CLOUDFLARED_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
6753
+ {
6754
+ version: CLOUDFLARED_VERSION,
6755
+ platform: "win32",
6756
+ arch: "x64",
6757
+ downloadUrl: `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-windows-amd64.exe`,
6758
+ downloadBytes: 54976432,
6759
+ downloadSha256: "2837888cc0f5d58f15b6dc478376de90b4d3ba5241c7947455d1e0a0df429712",
6760
+ executableName: "cloudflared.exe"
6761
+ },
6762
+ {
6763
+ version: CLOUDFLARED_VERSION,
6764
+ platform: "linux",
6765
+ arch: "x64",
6766
+ downloadUrl: `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64`,
6767
+ downloadBytes: 39838488,
6768
+ downloadSha256: "03f1f25d1cc93b9ad6c60569d44060bc4f17ed97075760ed8cfca4b12dcd68cc",
6769
+ executableName: "cloudflared"
6770
+ },
6771
+ {
6772
+ version: CLOUDFLARED_VERSION,
6773
+ platform: "linux",
6774
+ arch: "arm64",
6775
+ downloadUrl: `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-arm64`,
6776
+ downloadBytes: 37466252,
6777
+ downloadSha256: "3d97437c71848bd8df68041e12436b484a661d95073ea1937f01a845ce88faa3",
6778
+ executableName: "cloudflared"
6779
+ }
6780
+ ].map((release) => [`${release.platform}-${release.arch}`, Object.freeze(release)])));
6781
+ /**
6782
+ * Canonical release metadata. New code should select from
6783
+ * {@link CLOUDFLARED_COMPONENT_RELEASES} by platform and architecture; this
6784
+ * alias preserves the original Windows x64 entry for existing callers.
6785
+ */
6786
+ const CLOUDFLARED_COMPONENT_RELEASE = CLOUDFLARED_COMPONENT_RELEASES["win32-x64"];
6787
+ const DOWNLOAD_PAGE$1 = "https://github.com/cloudflare/cloudflared/releases";
6788
+ const TERMS_URL$1 = "https://www.cloudflare.com/website-terms/";
6789
+ function inside$1(parent, child) {
6790
+ const candidate = relative(parent, child);
6791
+ return candidate !== "" && !candidate.startsWith("..") && !isAbsolute(candidate);
6792
+ }
6793
+ async function sha256$1(file) {
6794
+ return createHash("sha256").update(await readFile(file)).digest("hex");
6795
+ }
6796
+ async function regularFile$1(file, expectedBytes) {
6797
+ try {
6798
+ const stat = await lstat(file);
6799
+ return stat.isFile() && !stat.isSymbolicLink() && (expectedBytes === void 0 || stat.size === expectedBytes);
6800
+ } catch (error) {
6801
+ if (error.code === "ENOENT") return false;
6802
+ throw error;
6803
+ }
6804
+ }
6805
+ async function defaultFetchArtifact$1(url, signal, expectedBytes) {
6806
+ return downloadPinnedArtifact({
6807
+ url,
6808
+ expectedBytes,
6809
+ errorPrefix: "cloudflared",
6810
+ signal
6811
+ });
6812
+ }
6813
+ /** Select the pinned artifact for one host, or undefined where unsupported. */
6814
+ function lookupRelease(platform, arch) {
6815
+ return CLOUDFLARED_COMPONENT_RELEASES[`${platform}-${arch}`];
6816
+ }
6817
+ /** Owns the optional cloudflared binary inside DSH Mobile state. */
6818
+ var CloudflaredComponentManager = class {
6819
+ executable;
6820
+ componentRoot;
6821
+ componentStorage;
6822
+ stateRoot;
6823
+ logRoot;
6824
+ stagingRoot;
6825
+ platform;
6826
+ arch;
6827
+ release;
6828
+ fetchArtifact;
6829
+ installed = false;
6830
+ errorCode;
6831
+ queue = Promise.resolve();
6832
+ constructor(options) {
6833
+ const stateDirectory = resolve(options.stateDirectory);
6834
+ if (!isAbsolute(stateDirectory)) throw new Error("cloudflared state directory must be absolute");
6835
+ this.platform = options.platform ?? process.platform;
6836
+ this.arch = options.arch ?? process.arch;
6837
+ this.release = lookupRelease(this.platform, this.arch);
6838
+ this.componentRoot = join(stateDirectory, "components", "cloudflared");
6839
+ this.componentStorage = join(this.componentRoot, this.release?.version ?? CLOUDFLARED_VERSION);
6840
+ this.executable = join(this.componentStorage, this.release?.executableName ?? "cloudflared");
6841
+ this.stateRoot = join(stateDirectory, "state", "cloudflared");
6842
+ this.logRoot = join(stateDirectory, "logs", "cloudflared");
6843
+ this.stagingRoot = join(stateDirectory, "staging", "cloudflared");
6844
+ for (const child of [
6845
+ this.componentRoot,
6846
+ this.componentStorage,
6847
+ this.stateRoot,
6848
+ this.logRoot,
6849
+ this.stagingRoot
6850
+ ]) if (!inside$1(stateDirectory, child)) throw new Error("cloudflared component path escaped its state directory");
6851
+ const release = this.release;
6852
+ this.fetchArtifact = options.fetchArtifact ?? ((url, signal) => defaultFetchArtifact$1(url, signal, release?.downloadBytes ?? 0));
6853
+ }
6854
+ /** Inspect the managed binary without using any global cloudflared state. */
6855
+ async initialize() {
6856
+ const release = this.release;
6857
+ this.installed = release !== void 0 && await regularFile$1(this.executable, release.downloadBytes);
6858
+ if (this.installed && release !== void 0 && await sha256$1(this.executable) !== release.downloadSha256) {
6859
+ this.installed = false;
6860
+ this.errorCode = "cloudflared_component_invalid";
6861
+ }
6862
+ }
6863
+ /** Return a safe status that never includes machine-specific account data. */
6864
+ status() {
6865
+ const release = this.release ?? CLOUDFLARED_COMPONENT_RELEASE;
6866
+ return Object.freeze({
6867
+ supported: this.release !== void 0,
6868
+ installed: this.installed,
6869
+ version: release.version,
6870
+ downloadBytes: release.downloadBytes,
6871
+ installedBytes: release.downloadBytes,
6872
+ sourceUrl: release.downloadUrl,
6873
+ downloadPage: DOWNLOAD_PAGE$1,
6874
+ termsUrl: TERMS_URL$1,
6875
+ storagePath: this.componentRoot,
6876
+ ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
6877
+ });
6878
+ }
6879
+ /** Download, verify, and install the pinned cloudflared executable after explicit confirmation. */
6880
+ install() {
6881
+ return this.enqueue(async () => {
6882
+ const release = this.release;
6883
+ if (release === void 0) throw new Error("cloudflared_component_unsupported");
6884
+ await mkdir(this.stagingRoot, {
6885
+ recursive: true,
6886
+ mode: 448
6887
+ });
6888
+ const staging = await mkdtemp(join(this.stagingRoot, "install-"));
6889
+ try {
6890
+ const controller = new AbortController();
6891
+ const timeout = setTimeout(() => {
6892
+ controller.abort();
6893
+ }, 3e5);
6894
+ timeout.unref();
6895
+ let bytes;
6896
+ try {
6897
+ bytes = await this.fetchArtifact(release.downloadUrl, controller.signal);
6898
+ } finally {
6899
+ clearTimeout(timeout);
6900
+ }
6901
+ if (bytes.byteLength !== release.downloadBytes) throw new Error("cloudflared_download_size_mismatch");
6902
+ if (createHash("sha256").update(bytes).digest("hex") !== release.downloadSha256) throw new Error("cloudflared_download_hash_mismatch");
6903
+ const staged = join(staging, release.executableName);
6904
+ await writeFile(staged, bytes, {
6905
+ flag: "wx",
6906
+ mode: 384
6907
+ });
6908
+ await chmod(staged, 448);
6909
+ if (!await regularFile$1(staged, release.downloadBytes) || await sha256$1(staged) !== release.downloadSha256) throw new Error("cloudflared_executable_hash_mismatch");
6910
+ const candidate = join(this.componentRoot, `.install-${randomBytes(12).toString("hex")}`);
6911
+ await mkdir(candidate, {
6912
+ recursive: true,
6913
+ mode: 448
6914
+ });
6915
+ await copyFile(staged, join(candidate, release.executableName));
6916
+ await chmod(join(candidate, release.executableName), 448);
6917
+ await rm(this.componentStorage, {
6918
+ recursive: true,
6919
+ force: true
6920
+ });
6921
+ await rename(candidate, this.componentStorage);
6922
+ this.installed = true;
6923
+ this.errorCode = void 0;
6924
+ } finally {
6925
+ await rm(staging, {
6926
+ recursive: true,
6927
+ force: true
6928
+ });
6929
+ }
6930
+ });
6931
+ }
6932
+ /** Remove every cloudflared file owned by DSH Mobile without touching global state. */
6933
+ purge() {
6934
+ return this.enqueue(async () => {
6935
+ await Promise.all([
6936
+ rm(this.componentRoot, {
6937
+ recursive: true,
6938
+ force: true
6939
+ }),
6940
+ rm(this.stateRoot, {
6941
+ recursive: true,
6942
+ force: true
6943
+ }),
6944
+ rm(this.logRoot, {
6945
+ recursive: true,
6946
+ force: true
6947
+ }),
6948
+ rm(this.stagingRoot, {
6949
+ recursive: true,
6950
+ force: true
6951
+ })
6952
+ ]);
6953
+ this.installed = false;
6954
+ this.errorCode = void 0;
6955
+ });
6956
+ }
6957
+ enqueue(operation) {
6958
+ const task = this.queue.then(operation, operation);
6959
+ this.queue = task.then(() => void 0, () => void 0);
6960
+ return task.then(() => this.status());
6961
+ }
6962
+ };
6963
+ //#endregion
6964
+ //#region src/cloudflared.ts
6965
+ const MAX_LOG_BUFFER_BYTES$1 = 65536;
6966
+ /**
6967
+ * A quick tunnel has to be allocated and registered with the Cloudflare edge
6968
+ * before the public hostname appears. That is slower than a cpolar control
6969
+ * handshake but still bounded; 60 s keeps a genuinely stalled client from
6970
+ * looking healthy for longer than a user will wait.
6971
+ */
6972
+ const START_TIMEOUT_MS$1 = 6e4;
6973
+ /**
6974
+ * How many startup-timeout rounds a named tunnel may wait through while its
6975
+ * connector process is still alive. A rebooted PC often autostarts DSH before
6976
+ * Wi-Fi / VPN / DNS is usable; cloudflared retries the edge on its own, so
6977
+ * killing it after a single 60 s window turns a slow boot into a manual
6978
+ * reconnect loop. An exited connector (bad token, bad config) still fails fast
6979
+ * on the first round.
6980
+ */
6981
+ const NAMED_STARTUP_ROUNDS = 5;
6982
+ const CLOUDFLARED_HOST_SUFFIX = ".trycloudflare.com";
6983
+ /**
6984
+ * Subdomains Cloudflare reserves for its own control plane.
6985
+ *
6986
+ * The quick-tunnel banner prints `https://api.trycloudflare.com` (the registration API) before it
6987
+ * prints the tunnel's own hostname, and that address satisfies a bare suffix check. Adopting it
6988
+ * would put the API host in the pairing QR code, where the phone gets `{"code":10005","message":
6989
+ * "Method Not Allowed"}` instead of DSH. A quick tunnel is always one random label under the
6990
+ * suffix, so the reserved single labels are refused outright.
6991
+ */
6992
+ const RESERVED_CLOUDFLARED_LABELS = Object.freeze([
6993
+ "api",
6994
+ "www",
6995
+ "update",
6996
+ "login"
6997
+ ]);
6998
+ function publicStatus$2(status) {
6999
+ return Object.freeze({
7000
+ enabled: status.enabled,
7001
+ state: status.state,
7002
+ ...status.origin === void 0 ? {} : { origin: status.origin },
7003
+ ...status.errorCode === void 0 ? {} : { errorCode: status.errorCode }
7004
+ });
7005
+ }
7006
+ /**
7007
+ * Whether a hostname is a quick-tunnel host: exactly one random label under the provider suffix,
7008
+ * never one of the reserved control-plane labels.
7009
+ */
7010
+ function isCloudflaredHost(hostname) {
7011
+ const lower = hostname.toLowerCase();
7012
+ if (!lower.endsWith(CLOUDFLARED_HOST_SUFFIX)) return false;
7013
+ const label = lower.slice(0, -18);
7014
+ return label !== "" && !label.includes(".") && !RESERVED_CLOUDFLARED_LABELS.includes(label);
7015
+ }
7016
+ /**
7017
+ * Extract a validated public HTTPS origin from one cloudflared output line.
7018
+ *
7019
+ * Quick tunnels print the public hostname inside a decorated banner, so the
7020
+ * candidate is taken from the line and then re-parsed as a URL: a lookalike such
7021
+ * as `https://x.trycloudflare.com.evil.test` fails the hostname check instead of
7022
+ * being truncated into an accepted origin.
7023
+ */
7024
+ function parseCloudflaredOrigin(line) {
7025
+ if (!line.toLowerCase().includes("trycloudflare.com")) return void 0;
7026
+ const match = /https?:\/\/[^\s"'|<>]+/u.exec(line);
7027
+ if (match === null) return void 0;
7028
+ const candidate = match[0].replace(/[),.;:]+$/u, "");
7029
+ let url;
7030
+ try {
7031
+ url = new URL(candidate);
7032
+ } catch {
7033
+ throw new Error("invalid_cloudflared_origin");
7034
+ }
7035
+ if (url.protocol !== "https:" || url.port !== "" || !isCloudflaredHost(url.hostname) || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "") throw new Error("invalid_cloudflared_origin");
7036
+ return url.origin;
7037
+ }
7038
+ /**
7039
+ * Whether one cloudflared log line reports an established edge connection.
7040
+ *
7041
+ * A named tunnel prints no banner, so registration is the only signal that the
7042
+ * connector reached Cloudflare and the public hostname can serve traffic. Both
7043
+ * the current and the older wording are accepted; anything else is ignored so a
7044
+ * chatty log line cannot be mistaken for readiness.
7045
+ */
7046
+ function isCloudflaredRegistration(line) {
7047
+ return /registered tunnel connection/iu.test(line) || /connection\s+[0-9a-f][0-9a-f-]{7,}\s+registered/iu.test(line);
7048
+ }
7049
+ async function reserveLoopbackPort$1(requestedPort) {
7050
+ const server = createServer((socket) => {
7051
+ socket.destroy();
7052
+ });
7053
+ await new Promise((resolveListen, reject) => {
7054
+ server.once("error", reject);
7055
+ server.listen(requestedPort ?? 0, "127.0.0.1", () => {
7056
+ server.off("error", reject);
7057
+ resolveListen();
7058
+ });
7059
+ });
7060
+ const address = server.address();
7061
+ if (address === null || typeof address === "string") {
7062
+ server.close();
7063
+ throw new Error("cloudflared_port_reservation_failed");
7064
+ }
7065
+ let released = false;
7066
+ return {
7067
+ port: address.port,
7068
+ release: async () => {
7069
+ if (released) return;
7070
+ released = true;
7071
+ await new Promise((resolveClose) => {
7072
+ server.close(() => resolveClose());
7073
+ });
7074
+ }
7075
+ };
7076
+ }
7077
+ function spawnCloudflaredProcess(executable, args, environment) {
7078
+ return spawn(executable, [...args], {
7079
+ env: environment,
7080
+ shell: false,
7081
+ stdio: [
7082
+ "pipe",
7083
+ "pipe",
7084
+ "pipe"
7085
+ ],
7086
+ windowsHide: true
7087
+ });
7088
+ }
7089
+ function withoutProxyEnvironment$1(environment) {
7090
+ const blocked = /* @__PURE__ */ new Set([
7091
+ "HTTP_PROXY",
7092
+ "HTTPS_PROXY",
7093
+ "ALL_PROXY",
7094
+ "NO_PROXY"
7095
+ ]);
7096
+ return Object.fromEntries(Object.entries(environment).filter(([name]) => !blocked.has(name.toUpperCase())));
7097
+ }
7098
+ /**
7099
+ * Owns an installed cloudflared client and a provider-specific DSH remote gateway.
7100
+ *
7101
+ * Quick tunnels allocate a random hostname and a random forward port on every
7102
+ * start. Named tunnels instead read an account token and a stable public
7103
+ * hostname from the tunnel store, forward to the configured loopback port, and
7104
+ * learn readiness from the connector's registration line rather than a banner.
7105
+ */
7106
+ var CloudflaredController = class {
7107
+ options;
7108
+ enabled = false;
7109
+ initialized = false;
7110
+ disposed = false;
7111
+ child;
7112
+ gatewayValue;
7113
+ reservation;
7114
+ generation = 0;
7115
+ buffer = "";
7116
+ mode = "quick";
7117
+ namedOrigin;
7118
+ latest = publicStatus$2({
7119
+ enabled: false,
7120
+ state: "off"
7121
+ });
7122
+ queue = Promise.resolve();
7123
+ startupTimer;
7124
+ startupRounds = 0;
7125
+ constructor(options) {
7126
+ this.options = options;
7127
+ if (!isAbsolute(options.executable)) throw new Error("cloudflared executable path must be absolute");
7128
+ }
7129
+ /** Restore the remembered cloudflared switch independently from LAN and other providers. */
7130
+ async initialize() {
7131
+ const state = await this.options.store.load();
7132
+ this.enabled = state.enabled;
7133
+ this.initialized = true;
7134
+ if (this.enabled) await this.start();
7135
+ else this.publish({
7136
+ enabled: false,
7137
+ state: "off"
7138
+ });
7139
+ }
7140
+ /** Return the active cloudflared-backed DSH gateway. */
7141
+ gateway() {
7142
+ return this.gatewayValue;
7143
+ }
7144
+ /** Return state safe for the desktop control UI. */
7145
+ status() {
7146
+ return publicStatus$2(this.latest);
7147
+ }
7148
+ /** Enable or disable cloudflared without changing LAN or other provider state. */
7149
+ async setEnabled(enabled) {
7150
+ if (!this.initialized || this.disposed) throw new Error("cloudflared controller is unavailable");
7151
+ await this.enqueue(async () => {
7152
+ if (this.enabled === enabled && (enabled === false || this.child !== void 0)) return;
7153
+ if (!enabled) await this.stop();
7154
+ this.enabled = enabled;
7155
+ await this.options.store.save({
7156
+ version: 1,
7157
+ enabled
7158
+ });
7159
+ if (enabled) await this.start();
7160
+ else this.publish({
7161
+ enabled: false,
7162
+ state: "off"
7163
+ });
7164
+ });
7165
+ return this.status();
7166
+ }
7167
+ /** Restart cloudflared and allocate a fresh quick tunnel. */
7168
+ async reconnect() {
7169
+ if (!this.initialized || this.disposed) throw new Error("cloudflared controller is unavailable");
7170
+ await this.enqueue(async () => {
7171
+ if (!this.enabled) {
7172
+ this.enabled = true;
7173
+ await this.options.store.save({
7174
+ version: 1,
7175
+ enabled: true
7176
+ });
7177
+ }
7178
+ await this.stop();
7179
+ await this.start();
7180
+ });
7181
+ return this.status();
7182
+ }
7183
+ /** Disable cloudflared without deleting the installed component. */
7184
+ async reset() {
7185
+ if (!this.initialized || this.disposed) throw new Error("cloudflared controller is unavailable");
7186
+ await this.enqueue(async () => {
7187
+ await this.stop();
7188
+ this.enabled = false;
7189
+ await this.options.store.save({
7190
+ version: 1,
7191
+ enabled: false
7192
+ });
7193
+ this.publish({
7194
+ enabled: false,
7195
+ state: "off"
7196
+ });
7197
+ });
7198
+ return this.status();
7199
+ }
7200
+ /** Stop owned resources without changing the remembered switch. */
7201
+ async close() {
7202
+ if (this.disposed) return;
7203
+ this.disposed = true;
7204
+ await this.enqueue(() => this.stop());
7205
+ }
7206
+ enqueue(operation) {
7207
+ const task = this.queue.then(operation, operation);
7208
+ this.queue = task.then(() => void 0, () => void 0);
7209
+ return task;
7210
+ }
7211
+ publish(status) {
7212
+ this.latest = publicStatus$2(status);
7213
+ try {
7214
+ this.options.onStatus?.(this.status());
7215
+ } catch {}
7216
+ }
7217
+ async start() {
7218
+ const generation = ++this.generation;
7219
+ let executableEntry;
7220
+ try {
7221
+ executableEntry = await lstat(this.options.executable);
7222
+ } catch {
7223
+ this.publish({
7224
+ enabled: true,
7225
+ state: "unavailable",
7226
+ errorCode: "cloudflared_component_missing"
7227
+ });
7228
+ return;
7229
+ }
7230
+ if (!executableEntry.isFile() || executableEntry.isSymbolicLink()) {
7231
+ this.publish({
7232
+ enabled: true,
7233
+ state: "unavailable",
7234
+ errorCode: "cloudflared_component_invalid"
7235
+ });
7236
+ return;
7237
+ }
7238
+ const settings = this.options.tunnel.settings();
7239
+ const named = settings.mode === "named" ? settings : void 0;
7240
+ this.mode = settings.mode;
7241
+ this.namedOrigin = named === void 0 ? void 0 : `https://${named.hostname}`;
7242
+ let reservation;
7243
+ try {
7244
+ reservation = await reserveLoopbackPort$1(named?.port);
7245
+ } catch (error) {
7246
+ const conflict = error?.code === "EADDRINUSE";
7247
+ this.publish({
7248
+ enabled: true,
7249
+ state: "error",
7250
+ errorCode: conflict ? named === void 0 ? "cloudflared_port_unavailable" : "cloudflared_tunnel_port_unavailable" : "cloudflared_port_reservation_failed"
7251
+ });
7252
+ return;
7253
+ }
7254
+ this.reservation = reservation;
7255
+ this.buffer = "";
7256
+ this.startupRounds = 0;
7257
+ this.publish({
7258
+ enabled: true,
7259
+ state: "starting"
7260
+ });
7261
+ if (named !== void 0) {
7262
+ const origin = this.namedOrigin;
7263
+ this.publish({
7264
+ enabled: true,
7265
+ state: "connecting",
7266
+ origin
7267
+ });
7268
+ await reservation.release();
7269
+ if (this.reservation === reservation) this.reservation = void 0;
7270
+ let gateway;
7271
+ try {
7272
+ gateway = await this.options.createGateway(origin, reservation.port);
7273
+ } catch (error) {
7274
+ const conflict = error?.code === "EADDRINUSE";
7275
+ this.publish({
7276
+ enabled: true,
7277
+ state: "error",
7278
+ errorCode: conflict ? "cloudflared_tunnel_port_unavailable" : "gateway_start_failed"
7279
+ });
7280
+ return;
7281
+ }
7282
+ if (generation !== this.generation || !this.enabled || this.disposed) {
7283
+ await gateway.close();
7284
+ return;
7285
+ }
7286
+ this.gatewayValue = gateway;
7287
+ }
7288
+ const args = named === void 0 ? [
7289
+ "tunnel",
7290
+ "--url",
7291
+ `http://127.0.0.1:${String(reservation.port)}`,
7292
+ "--no-autoupdate"
7293
+ ] : [
7294
+ "tunnel",
7295
+ "--no-autoupdate",
7296
+ "run"
7297
+ ];
7298
+ const environment = withoutProxyEnvironment$1(process.env);
7299
+ if (named !== void 0) environment.TUNNEL_TOKEN = named.token;
7300
+ const child = (this.options.spawnProcess ?? spawnCloudflaredProcess)(this.options.executable, args, environment);
7301
+ this.child = child;
7302
+ child.stdout.setEncoding("utf8");
7303
+ child.stderr.setEncoding("utf8");
7304
+ child.stdout.on("data", (chunk) => {
7305
+ this.consume(generation, String(chunk));
7306
+ });
7307
+ child.stderr.on("data", (chunk) => {
7308
+ this.consume(generation, String(chunk));
7309
+ });
7310
+ child.once("error", () => {
7311
+ this.enqueue(() => this.failGeneration(generation, "cloudflared_launch_failed"));
7312
+ });
7313
+ child.once("close", (code) => {
7314
+ if (generation !== this.generation || this.child !== child) return;
7315
+ this.child = void 0;
7316
+ if (this.enabled) this.enqueue(() => this.failGeneration(generation, code === 0 ? "cloudflared_stopped" : "cloudflared_exited"));
7317
+ });
7318
+ this.armStartupTimer(generation);
7319
+ }
7320
+ armStartupTimer(generation) {
7321
+ if (this.startupTimer !== void 0) clearTimeout(this.startupTimer);
7322
+ this.startupTimer = setTimeout(() => {
7323
+ this.enqueue(() => this.checkStartupTimeout(generation));
7324
+ }, this.options.startupTimeoutMs ?? START_TIMEOUT_MS$1);
7325
+ this.startupTimer.unref();
7326
+ }
7327
+ /**
7328
+ * Give a live named connector more time instead of killing it: it retries the
7329
+ * edge on its own and usually registers once the rebooted network is usable.
7330
+ * A dead connector, or an exhausted budget, fails the generation as before.
7331
+ */
7332
+ async checkStartupTimeout(generation) {
7333
+ if (generation !== this.generation || !this.enabled || this.disposed) return;
7334
+ if (this.latest.state === "ready") return;
7335
+ const childAlive = this.child !== void 0 && this.child.exitCode === null;
7336
+ const budget = this.options.maxStartupRounds ?? NAMED_STARTUP_ROUNDS;
7337
+ if (this.mode === "named" && childAlive && this.startupRounds + 1 < budget) {
7338
+ this.startupRounds += 1;
7339
+ this.armStartupTimer(generation);
7340
+ return;
7341
+ }
7342
+ await this.failGeneration(generation, "cloudflared_start_timeout");
7343
+ }
7344
+ consume(generation, chunk) {
7345
+ if (generation !== this.generation) return;
7346
+ this.buffer += chunk;
7347
+ if (Buffer.byteLength(this.buffer, "utf8") > MAX_LOG_BUFFER_BYTES$1 && !this.buffer.includes("\n")) {
7348
+ this.enqueue(() => this.failGeneration(generation, "cloudflared_invalid_output"));
7349
+ return;
7350
+ }
7351
+ while (true) {
7352
+ const newline = this.buffer.indexOf("\n");
7353
+ if (newline < 0) return;
7354
+ const line = this.buffer.slice(0, newline).replace(/\r$/u, "");
7355
+ this.buffer = this.buffer.slice(newline + 1);
7356
+ if (this.mode === "named") {
7357
+ if (isCloudflaredRegistration(line)) this.enqueue(() => this.confirmNamedReady(generation));
7358
+ continue;
7359
+ }
7360
+ let origin;
7361
+ try {
7362
+ origin = parseCloudflaredOrigin(line);
7363
+ } catch {
7364
+ this.enqueue(() => this.failGeneration(generation, "cloudflared_invalid_origin"));
7365
+ return;
7366
+ }
7367
+ if (origin !== void 0) this.enqueue(() => this.attachGateway(generation, origin));
7368
+ }
7369
+ }
7370
+ /** Mark a named tunnel ready once the connector has registered with the edge. */
7371
+ async confirmNamedReady(generation) {
7372
+ if (generation !== this.generation || !this.enabled || this.disposed) return;
7373
+ if (this.gatewayValue === void 0 || this.latest.state === "ready") return;
7374
+ if (this.startupTimer !== void 0) clearTimeout(this.startupTimer);
7375
+ this.startupTimer = void 0;
7376
+ this.publish({
7377
+ enabled: true,
7378
+ state: "ready",
7379
+ ...this.namedOrigin === void 0 ? {} : { origin: this.namedOrigin }
7380
+ });
7381
+ }
7382
+ async attachGateway(generation, origin) {
7383
+ if (generation !== this.generation || !this.enabled || this.disposed) return;
7384
+ const current = this.gatewayValue;
7385
+ if (current !== void 0) {
7386
+ if (current.address().origin === origin) return;
7387
+ await this.rotateGateway(generation, origin, current);
7388
+ return;
7389
+ }
7390
+ const reservation = this.reservation;
7391
+ if (reservation === void 0) return;
7392
+ this.publish({
7393
+ enabled: true,
7394
+ state: "connecting",
7395
+ origin
7396
+ });
7397
+ await reservation.release();
7398
+ if (this.reservation === reservation) this.reservation = void 0;
7399
+ let gateway;
7400
+ try {
7401
+ gateway = await this.options.createGateway(origin, reservation.port);
7402
+ } catch {
7403
+ await this.failGeneration(generation, "gateway_start_failed");
7404
+ return;
7405
+ }
7406
+ if (generation !== this.generation || !this.enabled || this.disposed || this.child === void 0) {
7407
+ await gateway.close();
7408
+ return;
7409
+ }
7410
+ this.gatewayValue = gateway;
7411
+ if (this.startupTimer !== void 0) clearTimeout(this.startupTimer);
7412
+ this.startupTimer = void 0;
7413
+ this.publish({
7414
+ enabled: true,
7415
+ state: "ready",
7416
+ origin
7417
+ });
7418
+ }
7419
+ /** Replace the gateway authority when a quick tunnel restarts on a new hostname. */
7420
+ async rotateGateway(generation, origin, current) {
7421
+ const listenPort = current.address().port;
7422
+ if (this.gatewayValue === current) this.gatewayValue = void 0;
7423
+ this.publish({
7424
+ enabled: true,
7425
+ state: "connecting",
7426
+ origin
7427
+ });
7428
+ try {
7429
+ await current.close();
7430
+ } catch {
7431
+ await this.failGeneration(generation, "gateway_start_failed");
7432
+ return;
7433
+ }
7434
+ if (generation !== this.generation || !this.enabled || this.disposed || this.child === void 0) return;
7435
+ let replacement;
7436
+ try {
7437
+ replacement = await this.options.createGateway(origin, listenPort);
7438
+ } catch {
7439
+ await this.failGeneration(generation, "gateway_start_failed");
7440
+ return;
7441
+ }
7442
+ if (generation !== this.generation || !this.enabled || this.disposed || this.child === void 0) {
7443
+ await replacement.close();
7444
+ return;
7445
+ }
7446
+ this.gatewayValue = replacement;
7447
+ this.publish({
7448
+ enabled: true,
7449
+ state: "ready",
7450
+ origin
7451
+ });
7452
+ }
7453
+ async failGeneration(generation, code) {
7454
+ if (generation !== this.generation) return;
7455
+ await this.stopProcessAndGateway();
7456
+ if (this.enabled) this.publish({
7457
+ enabled: true,
7458
+ state: "error",
7459
+ errorCode: code
7460
+ });
7461
+ }
7462
+ async stop() {
7463
+ ++this.generation;
7464
+ await this.stopProcessAndGateway();
7465
+ }
7466
+ async stopProcessAndGateway() {
7467
+ if (this.startupTimer !== void 0) clearTimeout(this.startupTimer);
7468
+ this.startupTimer = void 0;
7469
+ const reservation = this.reservation;
7470
+ this.reservation = void 0;
7471
+ const child = this.child;
7472
+ this.child = void 0;
7473
+ const gateway = this.gatewayValue;
7474
+ this.gatewayValue = void 0;
7475
+ await settleRemoteResources([
7476
+ () => reservation?.release(),
7477
+ () => child !== void 0 && child.exitCode === null ? terminateRemoteProcess(child) : void 0,
7478
+ () => gateway?.close()
7479
+ ], "cloudflared resource cleanup failed");
7480
+ }
7481
+ };
7482
+ //#endregion
7483
+ //#region src/cloudflared-tunnel.ts
7484
+ const MAX_CONFIG_BYTES = 8192;
7485
+ const QUICK_TUNNEL_SUFFIX = ".trycloudflare.com";
7486
+ /** Cloudflare's own tunnel-routing domain; it can never be a public hostname. */
7487
+ const TUNNEL_ROUTING_SUFFIX = ".cfargotunnel.com";
7488
+ /**
7489
+ * The bare registrable names behind those suffixes. A suffix test alone accepts
7490
+ * them, yet Cloudflare owns both apexes outright, so neither can ever be routed to
7491
+ * a customer tunnel.
7492
+ */
7493
+ const RESERVED_APEX_NAMES = Object.freeze([QUICK_TUNNEL_SUFFIX.slice(1), TUNNEL_ROUTING_SUFFIX.slice(1)]);
7494
+ /** Ports below this need privileges on every supported platform. */
7495
+ const MIN_PORT = 1024;
7496
+ const MAX_PORT = 65535;
7497
+ /**
7498
+ * The LAN gateway's HTTPS port, held by the plugin for as long as DSH runs. It is
7499
+ * never available to a tunnel, so a configuration naming it must be refused up
7500
+ * front rather than failing later as a generic "port in use".
7501
+ */
7502
+ const RESERVED_LAN_GATEWAY_PORT = 3443;
7503
+ const MAX_TOKEN_LENGTH = 4096;
7504
+ function hostname$1(value) {
7505
+ if (value.length > 253 || !value.includes(".")) return false;
7506
+ return value.split(".").every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label));
7507
+ }
7508
+ /**
7509
+ * Validate the public hostname Cloudflare routes to this tunnel.
7510
+ *
7511
+ * The name must be a real DNS name, not an IP literal or a wildcard, and it
7512
+ * cannot sit under Cloudflare's own control-plane suffixes: `trycloudflare.com`
7513
+ * belongs to quick tunnels, and a `cfargotunnel.com` name is the routing target
7514
+ * rather than a routable public address.
7515
+ */
7516
+ function validateCloudflaredTunnelHostname(value) {
7517
+ if (typeof value !== "string" || value !== value.trim() || value.length === 0 || value.length > 253) throw new Error("cloudflared_tunnel_hostname_invalid");
7518
+ const normalized = value.toLowerCase().replace(/\.$/u, "");
7519
+ if (isIP(normalized) !== 0 || !hostname$1(normalized) || normalized.includes("*") || RESERVED_APEX_NAMES.includes(normalized) || normalized.endsWith(QUICK_TUNNEL_SUFFIX) || normalized.endsWith(TUNNEL_ROUTING_SUFFIX)) throw new Error("cloudflared_tunnel_hostname_invalid");
7520
+ return normalized;
7521
+ }
7522
+ /** Validate the stable loopback port Cloudflare's ingress forwards to. */
7523
+ function validateCloudflaredTunnelPort(value) {
7524
+ if (!Number.isSafeInteger(value) || Number(value) < MIN_PORT || Number(value) > MAX_PORT) throw new Error("cloudflared_tunnel_port_invalid");
7525
+ if (value === RESERVED_LAN_GATEWAY_PORT) throw new Error("cloudflared_tunnel_port_reserved");
7526
+ return Number(value);
7527
+ }
7528
+ /**
7529
+ * Validate a connector token before durable storage.
7530
+ *
7531
+ * A token is a base64url-encoded JSON blob that carries the account, tunnel id
7532
+ * and tunnel secret; the provider passes it through the environment rather than
7533
+ * the command line, and it is never returned to any client.
7534
+ */
7535
+ function validateCloudflaredTunnelToken(value) {
7536
+ if (typeof value !== "string" || value !== value.trim() || value.length < 32 || value.length > MAX_TOKEN_LENGTH || !/^[A-Za-z0-9_=+/-]+$/u.test(value)) throw new Error("cloudflared_tunnel_token_invalid");
7537
+ return value;
7538
+ }
7539
+ /** Parse tunnel settings at the request and filesystem boundaries. */
7540
+ function parseCloudflaredTunnelSettings(value) {
7541
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("cloudflared_tunnel_settings_invalid");
7542
+ const record = value;
7543
+ const allowed = [
7544
+ "version",
7545
+ "mode",
7546
+ "token",
7547
+ "hostname",
7548
+ "port"
7549
+ ];
7550
+ if (Reflect.ownKeys(record).some((key) => !allowed.includes(String(key)))) throw new Error("cloudflared_tunnel_settings_invalid");
7551
+ if (record.version !== void 0 && record.version !== 1) throw new Error("cloudflared_tunnel_settings_invalid");
7552
+ const mode = record.mode ?? "quick";
7553
+ if (mode === "quick") {
7554
+ if (record.token !== void 0 || record.hostname !== void 0 || record.port !== void 0) throw new Error("cloudflared_tunnel_settings_invalid");
7555
+ return Object.freeze({
7556
+ version: 1,
7557
+ mode: "quick"
7558
+ });
7559
+ }
7560
+ if (mode !== "named") throw new Error("cloudflared_tunnel_settings_invalid");
7561
+ return Object.freeze({
7562
+ version: 1,
7563
+ mode: "named",
7564
+ token: validateCloudflaredTunnelToken(record.token),
7565
+ hostname: validateCloudflaredTunnelHostname(record.hostname),
7566
+ port: validateCloudflaredTunnelPort(record.port)
7567
+ });
7568
+ }
7569
+ /**
7570
+ * Merge a partial panel request with the saved named-tunnel settings so a blank
7571
+ * field keeps its stored value. The token in particular is write-only, so the
7572
+ * panel submits an empty string to mean "keep the existing connector token".
7573
+ */
7574
+ function mergeSavedCloudflaredTunnelSettings(partial, saved) {
7575
+ const requested = partial.mode ?? saved?.mode ?? "quick";
7576
+ if (requested !== "quick" && requested !== "named") throw new Error("cloudflared_tunnel_settings_invalid");
7577
+ if (requested === "quick") return parseCloudflaredTunnelSettings({
7578
+ version: 1,
7579
+ mode: "quick"
7580
+ });
7581
+ const previous = saved?.mode === "named" ? saved : void 0;
7582
+ const token = partial.token === "" || partial.token === void 0 ? previous?.token : partial.token;
7583
+ const hostname = partial.hostname === "" || partial.hostname === void 0 ? previous?.hostname : partial.hostname;
7584
+ const port = partial.port === void 0 || partial.port === "" ? previous?.port : partial.port;
7585
+ if (token === void 0 || hostname === void 0 || port === void 0) throw new Error("cloudflared_tunnel_config_missing");
7586
+ return parseCloudflaredTunnelSettings({
7587
+ version: 1,
7588
+ mode: "named",
7589
+ token,
7590
+ hostname,
7591
+ port
7592
+ });
7593
+ }
7594
+ async function atomicPrivateWrite(file, body) {
7595
+ const directory = dirname(file);
7596
+ await mkdir(directory, {
7597
+ recursive: true,
7598
+ mode: 448
7599
+ });
7600
+ try {
7601
+ const current = await lstat(file);
7602
+ if (!current.isFile() || current.isSymbolicLink()) throw new Error("cloudflared_tunnel_target_invalid");
7603
+ } catch (error) {
7604
+ if (error.code !== "ENOENT") throw error;
7605
+ }
7606
+ const temporary = join(directory, `.${basename(file)}.${randomBytes(12).toString("hex")}.tmp`);
7607
+ try {
7608
+ await writeFile(temporary, body, {
7609
+ encoding: "utf8",
7610
+ flag: "wx",
7611
+ mode: 384
7612
+ });
7613
+ await rename(temporary, file);
7614
+ await restrictPrivateFile(file);
7615
+ } catch (error) {
7616
+ await rm(temporary, { force: true });
7617
+ throw error;
7618
+ }
7619
+ }
7620
+ /** Owns the private cloudflared tunnel configuration for one DSH installation. */
7621
+ var CloudflaredTunnelStore = class {
7622
+ stateRoot;
7623
+ settingsFile;
7624
+ settingsValue = Object.freeze({
7625
+ version: 1,
7626
+ mode: "quick"
7627
+ });
7628
+ errorCode;
7629
+ constructor(stateDirectory) {
7630
+ if (!isAbsolute(stateDirectory)) throw new Error("cloudflared tunnel state directory must be absolute");
7631
+ this.stateRoot = resolve(stateDirectory);
7632
+ this.settingsFile = join(this.stateRoot, "tunnel.json");
7633
+ }
7634
+ /** Load private settings while rejecting links, oversized files and unknown fields. */
7635
+ async initialize() {
7636
+ let entry;
7637
+ try {
7638
+ entry = await lstat(this.settingsFile);
7639
+ } catch (error) {
7640
+ if (error.code === "ENOENT") return;
7641
+ throw error;
7642
+ }
7643
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_CONFIG_BYTES) {
7644
+ this.errorCode = "cloudflared_tunnel_config_invalid";
7645
+ return;
7646
+ }
7647
+ await restrictPrivateFile(this.settingsFile);
7648
+ try {
7649
+ this.settingsValue = parseCloudflaredTunnelSettings(JSON.parse(await readFile(this.settingsFile, "utf8")));
7650
+ this.errorCode = void 0;
7651
+ } catch {
7652
+ this.settingsValue = Object.freeze({
7653
+ version: 1,
7654
+ mode: "quick"
7655
+ });
7656
+ this.errorCode = "cloudflared_tunnel_config_invalid";
7657
+ }
7658
+ }
7659
+ /** Return configuration metadata without exposing the connector token. */
7660
+ status() {
7661
+ const settings = this.settingsValue;
7662
+ return Object.freeze({
7663
+ mode: settings.mode,
7664
+ configured: settings.mode === "named",
7665
+ ...settings.mode === "named" ? {
7666
+ hostname: settings.hostname,
7667
+ port: settings.port
7668
+ } : {},
7669
+ storagePath: this.stateRoot,
7670
+ ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
7671
+ });
7672
+ }
7673
+ /** Return private settings only to the provider lifecycle. */
7674
+ settings() {
7675
+ return this.settingsValue;
7676
+ }
7677
+ /** Atomically replace the tunnel configuration. */
7678
+ async configure(value) {
7679
+ const settings = parseCloudflaredTunnelSettings(value);
7680
+ if (settings.mode === "quick") await rm(this.settingsFile, { force: true });
7681
+ else await atomicPrivateWrite(this.settingsFile, `${JSON.stringify(settings)}\n`);
7682
+ this.settingsValue = settings;
7683
+ this.errorCode = void 0;
7684
+ return this.status();
7685
+ }
7686
+ /** Forget a named tunnel and its connector token. */
7687
+ async purge() {
7688
+ await rm(this.settingsFile, { force: true });
7689
+ this.settingsValue = Object.freeze({
7690
+ version: 1,
7691
+ mode: "quick"
7692
+ });
7693
+ this.errorCode = void 0;
7694
+ return this.status();
7695
+ }
7696
+ };
7697
+ //#endregion
7698
+ //#region src/task-events.ts
7699
+ const TASK_EVENT_DEBOUNCE_MS = 1e3;
7700
+ function turnNumber(value) {
7701
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
7702
+ }
7703
+ function turnData(value) {
7704
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return {
7705
+ turn: 0,
7706
+ completed: false
7707
+ };
7708
+ const record = value;
7709
+ const reason = record.reason;
7710
+ const completed = typeof reason === "string" ? reason === "completed" : reason !== null && typeof reason === "object" && reason.kind === "completed";
7711
+ return {
7712
+ turn: turnNumber(record.turn),
7713
+ completed
7714
+ };
7715
+ }
7716
+ /**
7717
+ * Subscribe to completed root turns. Subagent turns are skipped so one task
7718
+ * announces once, and rapid turn boundaries collapse into a single event.
7719
+ * Returns a disposer that also drops pending debounces.
7720
+ */
7721
+ function watchTaskCompletions(ctx, options) {
7722
+ const debounceMs = options.debounceMs ?? 1e3;
7723
+ const pending = /* @__PURE__ */ new Map();
7724
+ const disposeListener = ctx.on("session/event", (session, event) => {
7725
+ if (event.type !== "turn/end") return;
7726
+ const { turn, completed } = turnData(event.data);
7727
+ if (!completed) return;
7728
+ if (session.header?.parentSession !== void 0 && session.header?.parentSession !== null) return;
7729
+ const sessionId = String(session.id);
7730
+ options.log?.("task-completed-observed", {
7731
+ sessionId,
7732
+ turn
7733
+ });
7734
+ const previous = pending.get(sessionId);
7735
+ if (previous !== void 0) clearTimeout(previous);
7736
+ pending.set(sessionId, setTimeout(() => {
7737
+ pending.delete(sessionId);
7738
+ options.log?.("task-completed-announced", {
7739
+ sessionId,
7740
+ turn
7741
+ });
7742
+ options.onTaskCompleted(Object.freeze({
7743
+ sessionId,
7744
+ turn
7745
+ }));
7746
+ }, debounceMs));
7747
+ });
7748
+ return () => {
7749
+ disposeListener();
7750
+ for (const timer of pending.values()) clearTimeout(timer);
7751
+ pending.clear();
7752
+ };
7753
+ }
7754
+ /** One subscription feeding every live gateway; gateways register on start. */
7755
+ var TaskEventHub = class {
7756
+ sinks = /* @__PURE__ */ new Set();
7757
+ /** Register a sink; returns its disposer. */
7758
+ add(sink) {
7759
+ this.sinks.add(sink);
7760
+ return () => {
7761
+ this.sinks.delete(sink);
7762
+ };
7763
+ }
7764
+ /** Fan out to a snapshot so a failing sink cannot break its siblings. */
7765
+ broadcast(event) {
7766
+ for (const sink of [...this.sinks]) try {
7767
+ sink.broadcastTaskEvent(event);
7768
+ } catch {}
7769
+ }
7770
+ /** Visible for tests. */
7771
+ get size() {
6094
7772
  return this.sinks.size;
6095
7773
  }
6096
7774
  };
@@ -6114,6 +7792,20 @@ const REMOTE_ERROR_GUIDANCE = Object.freeze({
6114
7792
  cpolar_start_timeout: "检查网络后点击“重新连接”。",
6115
7793
  cpolar_stopped: "点击“重新连接”。",
6116
7794
  cpolar_exited: "点击“重新连接”;仍失败时复制诊断报告。",
7795
+ cloudflared_component_missing: "先安装 cloudflared 官方组件。",
7796
+ cloudflared_component_invalid: "彻底移除 cloudflared 组件后重新安装。",
7797
+ cloudflared_port_unavailable: "无法分配本机远程网关端口,请重试。",
7798
+ cloudflared_launch_failed: "重新安装 cloudflared 官方组件后重试。",
7799
+ cloudflared_start_timeout: "检查网络后点击“重新连接”。",
7800
+ cloudflared_stopped: "点击“重新连接”。",
7801
+ cloudflared_exited: "点击“重新连接”;仍失败时复制诊断报告。",
7802
+ cloudflared_invalid_output: "cloudflared 返回了无法识别的状态。",
7803
+ cloudflared_invalid_origin: "cloudflared 返回的公网地址未通过校验。",
7804
+ origin_config_missing: "先保存自有反向代理配置。",
7805
+ origin_config_invalid: "重新保存自有反向代理配置。",
7806
+ origin_listen_port_in_use: "更换 HTTP 后端端口;不要使用局域网的 3443、DSH 自身的 3080,或已被 cloudflared 隧道占用的 3444。",
7807
+ origin_listen_address_unavailable: "监听地址不属于当前电脑,请重新选择本机的私网 IPv4 地址。",
7808
+ origin_gateway_start_failed: "检查 HTTP 后端监听地址和端口后重新连接。",
6117
7809
  frp_component_missing: "先安装 FRP 官方组件。",
6118
7810
  frp_component_invalid: "彻底清理 FRP 组件后重新安装。",
6119
7811
  frp_config_missing: "先保存自建 FRP 连接配置。",
@@ -6155,6 +7847,7 @@ function remoteSuffix(origin) {
6155
7847
  try {
6156
7848
  const hostname = new URL(origin).hostname;
6157
7849
  if (hostname.endsWith(".ts.net")) return "*.ts.net";
7850
+ if (hostname.endsWith(".trycloudflare.com")) return "*.trycloudflare.com";
6158
7851
  for (const suffix of [
6159
7852
  ".cpolar.cn",
6160
7853
  ".cpolar.io",
@@ -6247,7 +7940,7 @@ function reportLine(entry) {
6247
7940
  /** Run bounded read-only checks and return a report safe to paste into an issue. */
6248
7941
  async function collectConnectionDiagnostics(snapshot, probes = {}) {
6249
7942
  const checks = [];
6250
- const remoteProbe = snapshot.remote.running && snapshot.remote.state === "ready" && snapshot.remote.origin !== void 0 ? (probes.remote ?? defaultRemoteProbe)(snapshot.remote.origin) : Promise.resolve({ state: "not-applicable" });
7943
+ const remoteProbe = snapshot.remote.provider !== "origin" && snapshot.remote.running && snapshot.remote.state === "ready" && snapshot.remote.origin !== void 0 ? (probes.remote ?? defaultRemoteProbe)(snapshot.remote.origin) : Promise.resolve({ state: "not-applicable" });
6251
7944
  const [firewall, remoteObservation] = await Promise.all([(probes.firewall ?? defaultFirewallProbe())(snapshot.lan.port), remoteProbe]);
6252
7945
  checks.push(check("versions", "ok", "versions-current", "版本兼容", `插件 ${DSH_MOBILE_VERSION},DSH ${snapshot.dshVersion},Android App 最低 ${MINIMUM_ANDROID_APP_VERSION}。`));
6253
7946
  if (snapshot.lan.configured === false) checks.push(check("network", "error", "lan-setup-required", "局域网配置", "尚未完成局域网初始化。", "返回局域网页选择网卡并完成配置。"));
@@ -6264,6 +7957,7 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
6264
7957
  else if (firewall.state === "missing") checks.push(check("firewall", "warning", "firewall-missing", "Windows 防火墙", "未找到完整的局域网放行规则。", "以管理员身份重新运行 dsh-mobile setup。"));
6265
7958
  else if (firewall.state === "unknown") checks.push(check("firewall", "info", "firewall-unknown", "Windows 防火墙", "系统未允许插件读取防火墙状态。", "若手机找不到电脑,以管理员身份重新运行 setup。"));
6266
7959
  if (!snapshot.remote.running || snapshot.remote.state === "off") checks.push(check("remote", "info", "remote-off", "远程通道", "当前未启用。", void 0, { provider: snapshot.remote.provider }));
7960
+ else if (snapshot.remote.provider === "origin" && snapshot.remote.state === "ready") checks.push(check("remote", "info", "remote-origin-ready", "远程通道", "私网 HTTP 后端已就绪;未检测公网 HTTPS、证书或 WebSocket 连通性。", "在手机上经反向代理验证连接;代理须保留原始 Host(含端口)并支持 WebSocket。", { provider: "origin" }));
6267
7961
  else if (snapshot.remote.state === "ready" && snapshot.remote.origin !== void 0) {
6268
7962
  const endpointSuffix = remoteSuffix(snapshot.remote.origin);
6269
7963
  const facts = {
@@ -8130,21 +9824,56 @@ var CpolarController = class {
8130
9824
  };
8131
9825
  //#endregion
8132
9826
  //#region src/cpolar-component.ts
8133
- /** Pinned cpolar Windows component fetched only after an explicit user action. */
8134
- const CPOLAR_COMPONENT_RELEASE = Object.freeze({
8135
- version: "3.3.18",
8136
- platform: "win32",
8137
- arch: "x64",
8138
- downloadUrl: "https://www.cpolar.com/static/downloads/releases/3.3.18/cpolar-stable-windows-amd64-setup.zip",
8139
- downloadBytes: 7603505,
8140
- downloadSha256: "fb8cf60289058ee26079f995d2eeea0b21768a742d90c93015afe96e83428830",
8141
- executableBytes: 19637680,
8142
- executableSha256: "b2d865ee505e842d22ceca5493a872efa893a79b079a7a8ee2bd3aa5343a5c41",
8143
- downloadPage: "https://www.cpolar.com/download",
8144
- signupUrl: "https://dashboard.cpolar.com/signup",
8145
- dashboardUrl: "https://dashboard.cpolar.com/auth",
8146
- termsUrl: "https://www.cpolar.com/tos"
8147
- });
9827
+ const CPOLAR_VERSION = "3.3.18";
9828
+ /** Pinned official cpolar release metadata for supported desktop targets. */
9829
+ const CPOLAR_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
9830
+ {
9831
+ version: CPOLAR_VERSION,
9832
+ platform: "win32",
9833
+ arch: "x64",
9834
+ downloadUrl: `https://www.cpolar.com/static/downloads/releases/${CPOLAR_VERSION}/cpolar-stable-windows-amd64-setup.zip`,
9835
+ downloadBytes: 7603505,
9836
+ downloadSha256: "fb8cf60289058ee26079f995d2eeea0b21768a742d90c93015afe96e83428830",
9837
+ executableName: "cpolar.exe",
9838
+ executableBytes: 19637680,
9839
+ executableSha256: "b2d865ee505e842d22ceca5493a872efa893a79b079a7a8ee2bd3aa5343a5c41",
9840
+ archiveKind: "msi-zip"
9841
+ },
9842
+ {
9843
+ version: CPOLAR_VERSION,
9844
+ platform: "linux",
9845
+ arch: "x64",
9846
+ downloadUrl: `https://www.cpolar.com/static/downloads/releases/${CPOLAR_VERSION}/cpolar-stable-linux-amd64.tar.gz`,
9847
+ downloadBytes: 7404781,
9848
+ downloadSha256: "5cd3320c4369928ccb509c4f5fa2ec3b86151ead77e1b77c1694aa64c43e32e7",
9849
+ executableName: "cpolar",
9850
+ executableBytes: 19328632,
9851
+ executableSha256: "c076e1109372a3f88031841c5989030e49b6871b54aee3260c796da9122dec05",
9852
+ archiveKind: "tar.gz"
9853
+ },
9854
+ {
9855
+ version: CPOLAR_VERSION,
9856
+ platform: "linux",
9857
+ arch: "arm64",
9858
+ downloadUrl: `https://www.cpolar.com/static/downloads/releases/${CPOLAR_VERSION}/cpolar-stable-linux-arm64.tar.gz`,
9859
+ downloadBytes: 6855666,
9860
+ downloadSha256: "8a61a97983f18ae5ffb4b8bac4c9b3d8e2399a6ee101844e7b0b30d7f326157c",
9861
+ executableName: "cpolar",
9862
+ executableBytes: 19017169,
9863
+ executableSha256: "8d76a1b7e518df45f387f107c7a428079c67ecd9b971a2915c53b947ea9b443a",
9864
+ archiveKind: "tar.gz"
9865
+ }
9866
+ ].map((release) => [`${release.platform}-${release.arch}`, Object.freeze(release)])));
9867
+ /**
9868
+ * Canonical release metadata. New code should select from
9869
+ * {@link CPOLAR_COMPONENT_RELEASES} by platform and architecture; this alias
9870
+ * preserves the original Windows x64 entry for existing callers.
9871
+ */
9872
+ const CPOLAR_COMPONENT_RELEASE = CPOLAR_COMPONENT_RELEASES["win32-x64"];
9873
+ const DOWNLOAD_PAGE = "https://www.cpolar.com/download";
9874
+ const SIGNUP_URL = "https://dashboard.cpolar.com/signup";
9875
+ const DASHBOARD_URL = "https://dashboard.cpolar.com/auth";
9876
+ const TERMS_URL = "https://www.cpolar.com/tos";
8148
9877
  function inside(parent, child) {
8149
9878
  const candidate = relative(parent, child);
8150
9879
  return candidate !== "" && !candidate.startsWith("..") && !isAbsolute(candidate);
@@ -8172,19 +9901,15 @@ async function run(file, args) {
8172
9901
  });
8173
9902
  });
8174
9903
  }
8175
- async function defaultFetchArtifact(url, signal) {
8176
- const response = await fetch(url, {
8177
- redirect: "error",
9904
+ async function defaultFetchArtifact(url, signal, expectedBytes) {
9905
+ return downloadPinnedArtifact({
9906
+ url,
9907
+ expectedBytes,
9908
+ errorPrefix: "cpolar",
8178
9909
  signal
8179
9910
  });
8180
- if (!response.ok) throw new Error(`cpolar_download_http_${String(response.status)}`);
8181
- const length = Number(response.headers.get("content-length"));
8182
- if (Number.isFinite(length) && length !== CPOLAR_COMPONENT_RELEASE.downloadBytes) throw new Error("cpolar_download_size_mismatch");
8183
- const bytes = new Uint8Array(await response.arrayBuffer());
8184
- if (bytes.byteLength !== CPOLAR_COMPONENT_RELEASE.downloadBytes) throw new Error("cpolar_download_size_mismatch");
8185
- return bytes;
8186
9911
  }
8187
- async function defaultExtractArtifact(archive, destination) {
9912
+ async function extractWindowsMsi(archive, destination, executableName) {
8188
9913
  if (process.platform !== "win32") throw new Error("cpolar_component_unsupported");
8189
9914
  const unpacked = join(destination, "archive");
8190
9915
  const administrative = join(destination, "administrative");
@@ -8210,9 +9935,29 @@ async function defaultExtractArtifact(archive, destination) {
8210
9935
  "/qn",
8211
9936
  `TARGETDIR=${administrative}`
8212
9937
  ]);
8213
- const executableRelative = (await readdir(administrative, { recursive: true })).find((entry) => basename(entry).toLowerCase() === "cpolar.exe");
9938
+ const executableRelative = (await readdir(administrative, { recursive: true })).find((entry) => basename(entry).toLowerCase() === executableName.toLowerCase());
9939
+ if (executableRelative === void 0) throw new Error("cpolar_executable_missing");
9940
+ await copyFile(join(administrative, executableRelative), join(destination, executableName));
9941
+ }
9942
+ async function extractLinuxTarball(archive, destination, executableName) {
9943
+ const unpacked = join(destination, "archive");
9944
+ await mkdir(unpacked, {
9945
+ recursive: true,
9946
+ mode: 448
9947
+ });
9948
+ await run(process.platform === "win32" ? "tar.exe" : "tar", [
9949
+ "-xzf",
9950
+ archive,
9951
+ "-C",
9952
+ unpacked
9953
+ ]);
9954
+ const executableRelative = (await readdir(unpacked, { recursive: true })).find((entry) => basename(entry).toLowerCase() === executableName.toLowerCase());
8214
9955
  if (executableRelative === void 0) throw new Error("cpolar_executable_missing");
8215
- await copyFile(join(administrative, executableRelative), join(destination, "cpolar.exe"));
9956
+ await copyFile(join(unpacked, executableRelative), join(destination, executableName));
9957
+ }
9958
+ async function defaultExtractArtifact(archive, destination, release) {
9959
+ if (release.archiveKind === "tar.gz") return extractLinuxTarball(archive, destination, release.executableName);
9960
+ return extractWindowsMsi(archive, destination, release.executableName);
8216
9961
  }
8217
9962
  /** Validate a cpolar Authtoken before it crosses the durable-file boundary. */
8218
9963
  function validateCpolarAuthtoken(value) {
@@ -8230,6 +9975,7 @@ var CpolarComponentManager = class {
8230
9975
  stagingRoot;
8231
9976
  platform;
8232
9977
  arch;
9978
+ release;
8233
9979
  fetchArtifact;
8234
9980
  extractArtifact;
8235
9981
  installed = false;
@@ -8241,9 +9987,10 @@ var CpolarComponentManager = class {
8241
9987
  if (!isAbsolute(stateDirectory)) throw new Error("cpolar state directory must be absolute");
8242
9988
  this.platform = options.platform ?? process.platform;
8243
9989
  this.arch = options.arch ?? process.arch;
9990
+ this.release = CPOLAR_COMPONENT_RELEASES[`${this.platform}-${this.arch}`];
8244
9991
  this.componentRoot = join(stateDirectory, "components", "cpolar");
8245
- this.componentStorage = join(this.componentRoot, CPOLAR_COMPONENT_RELEASE.version);
8246
- this.executable = join(this.componentStorage, "cpolar.exe");
9992
+ this.componentStorage = join(this.componentRoot, this.release?.version ?? CPOLAR_VERSION);
9993
+ this.executable = join(this.componentStorage, this.release?.executableName ?? "cpolar");
8247
9994
  this.stateRoot = join(stateDirectory, "state", "cpolar");
8248
9995
  this.configFile = join(this.stateRoot, "cpolar.yml");
8249
9996
  this.logRoot = join(stateDirectory, "logs", "cpolar");
@@ -8255,13 +10002,18 @@ var CpolarComponentManager = class {
8255
10002
  this.logRoot,
8256
10003
  this.stagingRoot
8257
10004
  ]) if (!inside(stateDirectory, child)) throw new Error("cpolar component path escaped its state directory");
8258
- this.fetchArtifact = options.fetchArtifact ?? defaultFetchArtifact;
8259
- this.extractArtifact = options.extractArtifact ?? defaultExtractArtifact;
10005
+ const release = this.release;
10006
+ this.fetchArtifact = options.fetchArtifact ?? ((url, signal) => defaultFetchArtifact(url, signal, release?.downloadBytes ?? 0));
10007
+ this.extractArtifact = options.extractArtifact ?? ((archive, destination) => {
10008
+ if (release === void 0) throw new Error("cpolar_component_unsupported");
10009
+ return defaultExtractArtifact(archive, destination, release);
10010
+ });
8260
10011
  }
8261
10012
  /** Inspect the managed binary and configuration without using global cpolar state. */
8262
10013
  async initialize() {
8263
- this.installed = await regularFile(this.executable, CPOLAR_COMPONENT_RELEASE.executableBytes);
8264
- if (this.installed && await sha256(this.executable) !== CPOLAR_COMPONENT_RELEASE.executableSha256) {
10014
+ const release = this.release;
10015
+ this.installed = release !== void 0 && await regularFile(this.executable, release.executableBytes);
10016
+ if (this.installed && release !== void 0 && await sha256(this.executable) !== release.executableSha256) {
8265
10017
  this.installed = false;
8266
10018
  this.errorCode = "cpolar_component_invalid";
8267
10019
  }
@@ -8270,18 +10022,19 @@ var CpolarComponentManager = class {
8270
10022
  }
8271
10023
  /** Return a safe status that never includes the account token. */
8272
10024
  status() {
10025
+ const release = this.release ?? CPOLAR_COMPONENT_RELEASE;
8273
10026
  return Object.freeze({
8274
- supported: this.platform === CPOLAR_COMPONENT_RELEASE.platform && this.arch === CPOLAR_COMPONENT_RELEASE.arch,
10027
+ supported: this.release !== void 0,
8275
10028
  installed: this.installed,
8276
10029
  configured: this.configured,
8277
- version: CPOLAR_COMPONENT_RELEASE.version,
8278
- downloadBytes: CPOLAR_COMPONENT_RELEASE.downloadBytes,
8279
- installedBytes: CPOLAR_COMPONENT_RELEASE.executableBytes,
8280
- sourceUrl: CPOLAR_COMPONENT_RELEASE.downloadUrl,
8281
- downloadPage: CPOLAR_COMPONENT_RELEASE.downloadPage,
8282
- signupUrl: CPOLAR_COMPONENT_RELEASE.signupUrl,
8283
- dashboardUrl: CPOLAR_COMPONENT_RELEASE.dashboardUrl,
8284
- termsUrl: CPOLAR_COMPONENT_RELEASE.termsUrl,
10030
+ version: release.version,
10031
+ downloadBytes: release.downloadBytes,
10032
+ installedBytes: release.executableBytes,
10033
+ sourceUrl: release.downloadUrl,
10034
+ downloadPage: DOWNLOAD_PAGE,
10035
+ signupUrl: SIGNUP_URL,
10036
+ dashboardUrl: DASHBOARD_URL,
10037
+ termsUrl: TERMS_URL,
8285
10038
  storagePath: this.componentRoot,
8286
10039
  ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
8287
10040
  });
@@ -8289,7 +10042,8 @@ var CpolarComponentManager = class {
8289
10042
  /** Download, verify, and administratively extract cpolar after explicit confirmation. */
8290
10043
  install() {
8291
10044
  return this.enqueue(async () => {
8292
- if (this.platform !== CPOLAR_COMPONENT_RELEASE.platform || this.arch !== CPOLAR_COMPONENT_RELEASE.arch) throw new Error("cpolar_component_unsupported");
10045
+ const release = this.release;
10046
+ if (release === void 0) throw new Error("cpolar_component_unsupported");
8293
10047
  await mkdir(this.stagingRoot, {
8294
10048
  recursive: true,
8295
10049
  mode: 448
@@ -8303,26 +10057,26 @@ var CpolarComponentManager = class {
8303
10057
  timeout.unref();
8304
10058
  let bytes;
8305
10059
  try {
8306
- bytes = await this.fetchArtifact(CPOLAR_COMPONENT_RELEASE.downloadUrl, controller.signal);
10060
+ bytes = await this.fetchArtifact(release.downloadUrl, controller.signal);
8307
10061
  } finally {
8308
10062
  clearTimeout(timeout);
8309
10063
  }
8310
- if (createHash("sha256").update(bytes).digest("hex") !== CPOLAR_COMPONENT_RELEASE.downloadSha256) throw new Error("cpolar_download_hash_mismatch");
8311
- const archive = join(staging, "cpolar.zip");
10064
+ if (createHash("sha256").update(bytes).digest("hex") !== release.downloadSha256) throw new Error("cpolar_download_hash_mismatch");
10065
+ const archive = join(staging, release.archiveKind === "tar.gz" ? "cpolar.tar.gz" : "cpolar.zip");
8312
10066
  await writeFile(archive, bytes, {
8313
10067
  flag: "wx",
8314
10068
  mode: 384
8315
10069
  });
8316
10070
  await this.extractArtifact(archive, staging);
8317
- const extracted = join(staging, "cpolar.exe");
8318
- if (!await regularFile(extracted, CPOLAR_COMPONENT_RELEASE.executableBytes) || await sha256(extracted) !== CPOLAR_COMPONENT_RELEASE.executableSha256) throw new Error("cpolar_executable_hash_mismatch");
10071
+ const extracted = join(staging, release.executableName);
10072
+ if (!await regularFile(extracted, release.executableBytes) || await sha256(extracted) !== release.executableSha256) throw new Error("cpolar_executable_hash_mismatch");
8319
10073
  const candidate = join(this.componentRoot, `.install-${randomBytes(12).toString("hex")}`);
8320
10074
  await mkdir(candidate, {
8321
10075
  recursive: true,
8322
10076
  mode: 448
8323
10077
  });
8324
- await copyFile(extracted, join(candidate, "cpolar.exe"));
8325
- await chmod(join(candidate, "cpolar.exe"), 448);
10078
+ await copyFile(extracted, join(candidate, release.executableName));
10079
+ await chmod(join(candidate, release.executableName), 448);
8326
10080
  await rm(this.componentStorage, {
8327
10081
  recursive: true,
8328
10082
  force: true
@@ -9012,6 +10766,16 @@ function selectLanNetwork(requestedAddress, requestedInterface, table, preferred
9012
10766
  if (physicalCandidates.length === 1) return physicalCandidates[0];
9013
10767
  throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => `${entry.name}=${entry.address}`).join(", ")}`);
9014
10768
  }
10769
+ /**
10770
+ * True when the failure came from LAN selection (the configured network is
10771
+ * gone, nothing is up, or several are) rather than from config/TLS code.
10772
+ * Callers use it to degrade (warn + stay down) instead of failing boot.
10773
+ * Pinned to the selectLanNetwork message contract — keep in sync.
10774
+ */
10775
+ function isNetworkSelectionError(error) {
10776
+ const message = error instanceof Error ? error.message : String(error ?? "");
10777
+ return message.includes("is not connected") || message.includes("no active private LAN address was found") || message.includes("more than one LAN address is active") || message.includes("has more than one private IPv4 address") || message.includes("is not an active private LAN address");
10778
+ }
9015
10779
  function assertMatchingCa(certPem, keyPem) {
9016
10780
  const certificate = new X509Certificate(certPem);
9017
10781
  if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) throw new Error("managed TLS CA must be a self-signed CA certificate");
@@ -9290,6 +11054,7 @@ function mapAdminError(error) {
9290
11054
  if (error instanceof Error && error.message.startsWith("saved LAN interface ")) return new HttpError(409, "network_interface_unavailable");
9291
11055
  if (error instanceof Error && error.message === "cpolar_authtoken_invalid") return new HttpError(400, "cpolar_authtoken_invalid");
9292
11056
  if (error instanceof Error && error.message.startsWith("cpolar_")) return new HttpError(409, error.message);
11057
+ if (error instanceof Error && error.message.startsWith("cloudflared_")) return new HttpError(409, error.message);
9293
11058
  if (error instanceof Error && [
9294
11059
  "frp_server_address_invalid",
9295
11060
  "frp_server_port_invalid",
@@ -9298,6 +11063,15 @@ function mapAdminError(error) {
9298
11063
  "frp_settings_invalid"
9299
11064
  ].includes(error.message)) return new HttpError(400, error.message);
9300
11065
  if (error instanceof Error && error.message.startsWith("frp_")) return new HttpError(409, error.message);
11066
+ if (error instanceof Error && [
11067
+ "origin_settings_invalid",
11068
+ "origin_public_origin_invalid",
11069
+ "origin_listen_host_invalid",
11070
+ "origin_listen_port_invalid",
11071
+ "origin_listen_port_reserved",
11072
+ "origin_allowed_cidrs_invalid"
11073
+ ].includes(error.message)) return new HttpError(400, error.message);
11074
+ if (error instanceof Error && error.message.startsWith("origin_")) return new HttpError(409, error.message);
9301
11075
  if (error instanceof Error && error.message.startsWith("vps_")) return new HttpError(409, error.message);
9302
11076
  if (error instanceof Error && error.message === "plugin_update_failed") return new HttpError(500, error.message);
9303
11077
  if (error instanceof Error && error.message.startsWith("plugin_update_")) return new HttpError(409, error.message);
@@ -9406,12 +11180,23 @@ function remoteGatewayConfig(template, publicOrigin, stateFile, instanceId, list
9406
11180
  discovery: false
9407
11181
  });
9408
11182
  }
9409
- function remoteControlPayload(provider, status, gateway, providerStatuses, cpolarComponent, frpComponent, frpConfiguration) {
11183
+ /** Reuse remote HTTPS policy while binding a separately validated private HTTP origin. */
11184
+ function originGatewayConfig(template, settings, stateFile, instanceId, listenPort = settings.listenPort) {
11185
+ const validated = parseOriginSettings(settings);
11186
+ if (listenPort !== 0) validateOriginListenPort(listenPort);
11187
+ return Object.freeze({
11188
+ ...remoteGatewayConfig(template, validated.publicOrigin, stateFile, instanceId, listenPort),
11189
+ listenHost: validated.listenHost,
11190
+ allowedCidrs: Object.freeze(validated.allowedCidrs.map(parseCidr))
11191
+ });
11192
+ }
11193
+ function remoteControlPayload(provider, status, gateway, providerStatuses, cpolarComponent, cloudflaredComponent, cloudflaredConfiguration, frpComponent, frpConfiguration, originConfiguration) {
9410
11194
  return {
9411
11195
  provider,
9412
11196
  running: status.enabled,
9413
11197
  state: status.state,
9414
11198
  ...status.origin === void 0 ? {} : { origin: status.origin },
11199
+ ...status.backendOrigin === void 0 ? {} : { backendOrigin: status.backendOrigin },
9415
11200
  ...status.loginUrl === void 0 ? {} : { loginUrl: status.loginUrl },
9416
11201
  ...status.setupUrl === void 0 ? {} : { setupUrl: status.setupUrl },
9417
11202
  ...status.errorCode === void 0 ? {} : { errorCode: status.errorCode },
@@ -9428,12 +11213,25 @@ function remoteControlPayload(provider, status, gateway, providerStatuses, cpola
9428
11213
  state: providerStatuses.cpolar.state,
9429
11214
  component: cpolarComponent
9430
11215
  },
11216
+ cloudflared: {
11217
+ bundled: false,
11218
+ running: providerStatuses.cloudflared.enabled,
11219
+ state: providerStatuses.cloudflared.state,
11220
+ component: cloudflaredComponent,
11221
+ configuration: cloudflaredConfiguration
11222
+ },
9431
11223
  frp: {
9432
11224
  bundled: false,
9433
11225
  running: providerStatuses.frp.enabled,
9434
11226
  state: providerStatuses.frp.state,
9435
11227
  component: frpComponent,
9436
11228
  configuration: frpConfiguration
11229
+ },
11230
+ origin: {
11231
+ bundled: true,
11232
+ running: providerStatuses.origin.enabled,
11233
+ state: providerStatuses.origin.state,
11234
+ configuration: originConfiguration
9437
11235
  }
9438
11236
  }
9439
11237
  };
@@ -9464,10 +11262,16 @@ async function apply(ctx, config) {
9464
11262
  const initialRemoteProvider = (await remoteProviderStore.load()).provider;
9465
11263
  const cpolarComponent = new CpolarComponentManager({ stateDirectory });
9466
11264
  await cpolarComponent.initialize();
11265
+ const cloudflaredComponent = new CloudflaredComponentManager({ stateDirectory });
11266
+ await cloudflaredComponent.initialize();
9467
11267
  const frpComponent = new FrpComponentManager({ stateDirectory });
9468
11268
  await frpComponent.initialize();
9469
11269
  const frpConfig = new FrpConfigStore(join(remoteDirectory, "frp", "config"));
9470
11270
  await frpConfig.initialize();
11271
+ const originConfig = new OriginConfigStore(join(remoteDirectory, "origin", "config"));
11272
+ await originConfig.initialize();
11273
+ const cloudflaredTunnel = new CloudflaredTunnelStore(join(remoteDirectory, "cloudflared"));
11274
+ await cloudflaredTunnel.initialize();
9471
11275
  const unregisterBuiltin = mobileAccess.registerExtension({
9472
11276
  schemaVersion: 1,
9473
11277
  id: "computer-images",
@@ -9533,7 +11337,14 @@ async function apply(ctx, config) {
9533
11337
  }, (error) => {
9534
11338
  process.emitWarning(`DSH Mobile could not follow the current LAN address: ${error instanceof Error ? error.message : String(error)}`, { code: "DSH_MOBILE_NETWORK_REFRESH" });
9535
11339
  });
9536
- await following.initialize(2e3);
11340
+ try {
11341
+ await following.initialize(2e3);
11342
+ } catch (error) {
11343
+ if (!isNetworkSelectionError(error)) throw error;
11344
+ const detail = error instanceof Error ? error.message : String(error);
11345
+ process.emitWarning(`DSH Mobile: ${detail}; mobile access stays dormant until the network returns or setup is re-run — DSH itself keeps running.`, { code: "DSH_MOBILE_NETWORK_UNAVAILABLE" });
11346
+ following.beginPolling(2e3);
11347
+ }
9537
11348
  return following;
9538
11349
  };
9539
11350
  const lanControlFile = parseControlFile(config.controlFile);
@@ -9561,9 +11372,22 @@ async function apply(ctx, config) {
9561
11372
  await candidate.start();
9562
11373
  return candidate;
9563
11374
  };
11375
+ const createOriginGateway = async (settings) => {
11376
+ const resolved = originGatewayConfig(template, settings, remoteDeviceFile, instanceId);
11377
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
11378
+ try {
11379
+ await candidate.start();
11380
+ } catch (error) {
11381
+ await candidate.close();
11382
+ throw error;
11383
+ }
11384
+ return candidate;
11385
+ };
9564
11386
  const tailscaleStore = new JsonMobileAccessControlStore(join(remoteDirectory, "control.json"), false);
9565
11387
  const cpolarStore = new JsonMobileAccessControlStore(join(remoteDirectory, "cpolar", "control.json"), false);
11388
+ const cloudflaredStore = new JsonMobileAccessControlStore(join(remoteDirectory, "cloudflared", "control.json"), false);
9566
11389
  const frpStore = new JsonMobileAccessControlStore(join(remoteDirectory, "frp", "control.json"), false);
11390
+ const originStore = new JsonMobileAccessControlStore(join(remoteDirectory, "origin", "control.json"), false);
9567
11391
  const remoteControllers = {
9568
11392
  tailscale: new FunnelController({
9569
11393
  store: tailscaleStore,
@@ -9578,21 +11402,28 @@ async function apply(ctx, config) {
9578
11402
  configFile: cpolarComponent.configFile,
9579
11403
  createGateway: createRemoteGateway
9580
11404
  }),
11405
+ cloudflared: new CloudflaredController({
11406
+ store: cloudflaredStore,
11407
+ executable: cloudflaredComponent.executable,
11408
+ tunnel: cloudflaredTunnel,
11409
+ createGateway: createRemoteGateway
11410
+ }),
9581
11411
  frp: new FrpController({
9582
11412
  store: frpStore,
9583
11413
  executable: frpComponent.executable,
9584
11414
  config: frpConfig,
9585
11415
  instanceId,
9586
11416
  createGateway: createRemoteGateway
11417
+ }),
11418
+ origin: new OriginController({
11419
+ store: originStore,
11420
+ config: originConfig,
11421
+ createGateway: createOriginGateway
9587
11422
  })
9588
11423
  };
9589
11424
  const remoteProviders = new RemoteProviderCoordinator(initialRemoteProvider, remoteControllers, remoteProviderStore);
9590
11425
  const remoteController = () => remoteProviders.controller();
9591
- for (const provider of [
9592
- "tailscale",
9593
- "cpolar",
9594
- "frp"
9595
- ]) {
11426
+ for (const provider of REMOTE_PROVIDERS) {
9596
11427
  const controller = remoteControllers[provider];
9597
11428
  taskEventHub.add({ broadcastTaskEvent: (event) => {
9598
11429
  controller.gateway()?.broadcastTaskEvent(event);
@@ -9601,8 +11432,10 @@ async function apply(ctx, config) {
9601
11432
  const remotePayload = () => remoteControlPayload(remoteProviders.selected, remoteController().status(), remoteController().gateway(), {
9602
11433
  tailscale: remoteControllers.tailscale.status(),
9603
11434
  cpolar: remoteControllers.cpolar.status(),
9604
- frp: remoteControllers.frp.status()
9605
- }, cpolarComponent.status(), frpComponent.status(), frpConfig.status());
11435
+ cloudflared: remoteControllers.cloudflared.status(),
11436
+ frp: remoteControllers.frp.status(),
11437
+ origin: remoteControllers.origin.status()
11438
+ }, cpolarComponent.status(), cloudflaredComponent.status(), cloudflaredTunnel.status(), frpComponent.status(), frpConfig.status(), originConfig.status());
9606
11439
  const lanPayload = () => ({
9607
11440
  ...loaded.kind === "unconfigured" ? {
9608
11441
  configured: preparedLanSetup !== void 0,
@@ -9737,11 +11570,29 @@ async function apply(ctx, config) {
9737
11570
  }
9738
11571
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/provider`) {
9739
11572
  const body = await readJsonObject(request, 4096);
9740
- if (body.provider !== "tailscale" && body.provider !== "cpolar" && body.provider !== "frp") throw new HttpError(400, "bad_request");
11573
+ if (body.provider !== "tailscale" && body.provider !== "cpolar" && body.provider !== "cloudflared" && body.provider !== "frp" && body.provider !== "origin") throw new HttpError(400, "bad_request");
9741
11574
  await remoteProviders.select(body.provider);
9742
11575
  sendJson(response, 200, remotePayload(), false);
9743
11576
  return;
9744
11577
  }
11578
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/origin/configure`) {
11579
+ const body = await readJsonObject(request, 8192);
11580
+ await remoteProviders.mutate(async () => {
11581
+ await originConfig.configure(body);
11582
+ if (remoteControllers.origin.status().enabled) await remoteControllers.origin.reconnect();
11583
+ });
11584
+ sendJson(response, 200, remotePayload(), false);
11585
+ return;
11586
+ }
11587
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/origin/purge`) {
11588
+ if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
11589
+ await remoteProviders.mutate(async () => {
11590
+ await remoteControllers.origin.setEnabled(false);
11591
+ await originConfig.purge();
11592
+ });
11593
+ sendJson(response, 200, remotePayload(), false);
11594
+ return;
11595
+ }
9745
11596
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cpolar/component/install`) {
9746
11597
  if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
9747
11598
  await remoteProviders.mutate(async () => cpolarComponent.install());
@@ -9763,6 +11614,46 @@ async function apply(ctx, config) {
9763
11614
  sendJson(response, 200, remotePayload(), false);
9764
11615
  return;
9765
11616
  }
11617
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cloudflared/component/install`) {
11618
+ if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
11619
+ logger.info("cloudflared component install started");
11620
+ try {
11621
+ await remoteProviders.mutate(async () => cloudflaredComponent.install());
11622
+ logger.info("cloudflared component install completed");
11623
+ } catch (error) {
11624
+ logger.error("cloudflared component install failed: %s", error instanceof Error ? error.stack ?? error.message : String(error));
11625
+ throw error;
11626
+ }
11627
+ sendJson(response, 200, remotePayload(), false);
11628
+ return;
11629
+ }
11630
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cloudflared/component/purge`) {
11631
+ if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
11632
+ await remoteProviders.mutate(async () => {
11633
+ await remoteControllers.cloudflared.setEnabled(false);
11634
+ await cloudflaredComponent.purge();
11635
+ });
11636
+ sendJson(response, 200, remotePayload(), false);
11637
+ return;
11638
+ }
11639
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cloudflared/tunnel`) {
11640
+ const body = await readJsonObject(request, 8192);
11641
+ await remoteProviders.mutate(async () => {
11642
+ await cloudflaredTunnel.configure(mergeSavedCloudflaredTunnelSettings(body, cloudflaredTunnel.settings()));
11643
+ if (remoteControllers.cloudflared.status().enabled) await remoteControllers.cloudflared.reconnect();
11644
+ });
11645
+ sendJson(response, 200, remotePayload(), false);
11646
+ return;
11647
+ }
11648
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cloudflared/tunnel/purge`) {
11649
+ if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
11650
+ await remoteProviders.mutate(async () => {
11651
+ await remoteControllers.cloudflared.setEnabled(false);
11652
+ await cloudflaredTunnel.purge();
11653
+ });
11654
+ sendJson(response, 200, remotePayload(), false);
11655
+ return;
11656
+ }
9766
11657
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/component/install`) {
9767
11658
  if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
9768
11659
  logger.info("frpc component install started");
@@ -9981,17 +11872,18 @@ async function apply(ctx, config) {
9981
11872
  const stores = {
9982
11873
  tailscale: tailscaleStore,
9983
11874
  cpolar: cpolarStore,
9984
- frp: frpStore
11875
+ cloudflared: cloudflaredStore,
11876
+ frp: frpStore,
11877
+ origin: originStore
9985
11878
  };
9986
11879
  await Promise.all(Object.keys(stores).filter((provider) => provider !== remoteProviders.selected).map((provider) => stores[provider].save({
9987
11880
  version: 1,
9988
11881
  enabled: false
9989
11882
  })));
9990
- for (const provider of [
9991
- "tailscale",
9992
- "cpolar",
9993
- "frp"
9994
- ]) await remoteControllers[provider].initialize();
11883
+ for (const provider of REMOTE_PROVIDERS) await remoteControllers[provider].initialize();
11884
+ const lanDiscovery = lanGateway?.discoveryStatus();
11885
+ if (lanDiscovery !== void 0 && !lanDiscovery.broadcast) logger.warn("broadcast discovery is unavailable (%s); mDNS is still published, so pair manually or free the UDP port", lanDiscovery.errorCode ?? "unknown_cause");
11886
+ if (lanDiscovery?.mobileAssetsErrorCode !== void 0) logger.warn("a bundled mobile asset is missing (%s); the phone frontend will serve without it", lanDiscovery.mobileAssetsErrorCode);
9995
11887
  } catch (error) {
9996
11888
  try {
9997
11889
  await settleCleanupSteps([
@@ -10028,6 +11920,6 @@ async function apply(ctx, config) {
10028
11920
  }, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
10029
11921
  }
10030
11922
  //#endregion
10031
- export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CSRF_COOKIE, CSRF_HEADER, Config, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, inject, isGloballyRoutableIpv4, isLoopbackAddress, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, parseAuthority, parseCidr, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseRemoteProviderState, resolveAuthority, rewriteMobileIndex, validateFrpPublicOrigin, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateWebSocketPath, watchTaskCompletions };
11923
+ export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CLOUDFLARED_COMPONENT_RELEASE, CSRF_COOKIE, CSRF_HEADER, CloudflaredComponentManager, CloudflaredController, CloudflaredTunnelStore, Config, DEFAULT_ORIGIN_LISTEN_PORT, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, OriginConfigStore, OriginController, REMOTE_PROVIDERS, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, inject, isCloudflaredRegistration, isGloballyRoutableIpv4, isLoopbackAddress, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, resolveAuthority, rewriteMobileIndex, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpPublicOrigin, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validateWebSocketPath, watchTaskCompletions };
10032
11924
 
10033
11925
  //# sourceMappingURL=index.mjs.map