dsh-mobile 0.4.2 → 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
@@ -877,6 +877,17 @@ var FollowingMobileAccessRuntime = class {
877
877
  async initialize(refreshIntervalMs) {
878
878
  await this.refresh();
879
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;
880
891
  this.timer = setInterval(() => {
881
892
  this.refresh().catch(this.onRefreshError);
882
893
  }, refreshIntervalMs);
@@ -2424,6 +2435,19 @@ const MOBILE_LAYOUT_PATH = `${AUTH_PREFIX}/mobile-layout.js`;
2424
2435
  const MOBILE_BOOT_BATCH_PREFIX = `${AUTH_PREFIX}/mobile-boot/`;
2425
2436
  const MAX_MOBILE_BOOT_BATCH_BYTES = 33554432;
2426
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";
2427
2451
  const MAX_MOBILE_BOOT_BATCHES = 8;
2428
2452
  const MOBILE_BOOT_UPSTREAM_ATTEMPTS = 4;
2429
2453
  const MOBILE_BOOT_RETRY_DELAY_MS = 150;
@@ -2508,11 +2532,17 @@ function revisionedMobileBatchPath(entries) {
2508
2532
  path: `${MOBILE_BOOT_BATCH_PREFIX}${key}.js`
2509
2533
  };
2510
2534
  }
2511
- 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) {
2512
2542
  const assignment = /(?:window\.__DSH_BOOT__|globalThis\["__DSH_BOOT__"\])\s*=\s*/u.exec(html);
2513
2543
  if (assignment?.index === void 0) throw new Error("upstream DSH index has no boot manifest");
2514
- const start = assignment.index;
2515
- const valueStart = start + assignment[0].length;
2544
+ const replaceStart = assignment.index;
2545
+ const valueStart = replaceStart + assignment[0].length;
2516
2546
  const scriptEnd = html.indexOf("<\/script>", valueStart);
2517
2547
  if (scriptEnd < 0) throw new Error("upstream DSH boot manifest script is incomplete");
2518
2548
  const source = html.slice(valueStart, scriptEnd).trim().replace(/;$/u, "");
@@ -2526,45 +2556,122 @@ function rewriteMobileIndexWithBatch(html) {
2526
2556
  if (dependencyProfile === void 0) throw new Error("upstream DSH layout module has unsupported dependencies");
2527
2557
  layout[0].url = MOBILE_LAYOUT_PATH;
2528
2558
  layout[0].rev = `dsh-mobile-layout-${DSH_MOBILE_VERSION}`;
2529
- const remoteSettings = orderAuthenticatedSettings(entries, dependencyProfile.slots);
2530
- let mobileBatch;
2531
- if (parsed.batches !== void 0) {
2532
- if (!Array.isArray(parsed.batches)) throw new Error("upstream DSH boot manifest batches are malformed");
2533
- const batches = parsed.batches;
2534
- const entryById = new Map(entries.map((entry) => [entry.id, entry]));
2535
- if (entryById.size !== entries.length) throw new Error("upstream DSH boot manifest has duplicate entries");
2536
- const layoutBatches = [];
2537
- for (const batch of batches) {
2538
- 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");
2539
- if (batch.entries.includes(MOBILE_LAYOUT_MODULE)) layoutBatches.push(batch);
2540
- }
2541
- if (layoutBatches.length !== 1 || layoutBatches[0]?.phase !== "application") throw new Error("upstream DSH boot manifest has no unique application layout batch");
2542
- const layoutBatch = layoutBatches[0];
2543
- const planEntries = layoutBatch.entries.map((id) => {
2544
- const entry = entryById.get(id);
2545
- if (entry === void 0 || typeof entry.url !== "string" || typeof entry.rev !== "string") throw new Error("upstream DSH boot manifest batches are malformed");
2546
- return Object.freeze({
2547
- 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",
2548
2633
  url: entry.url,
2549
- rev: entry.rev
2634
+ rev: entry.rev,
2635
+ entries: [entry.id]
2550
2636
  });
2551
- });
2552
- const revision = revisionedMobileBatchPath(planEntries);
2553
- layoutBatch.url = revision.path;
2554
- layoutBatch.rev = revision.key;
2555
- mobileBatch = Object.freeze({
2556
- ...revision,
2557
- entries: Object.freeze(planEntries)
2558
- });
2559
- parsed.rev = createHash("sha256").update(JSON.stringify({
2560
- entries,
2561
- 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
2562
2669
  })).digest("hex").slice(0, 16);
2563
2670
  }
2564
- 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)};`;
2565
2672
  return Object.freeze({
2566
- html: ensureMobileViewport(ensureMobileCompatibility(`${html.slice(0, start)}${replacement}${html.slice(scriptEnd)}`)),
2567
- ...mobileBatch === void 0 ? {} : { batch: mobileBatch }
2673
+ html: ensureMobileViewport(ensureMobileCompatibility(`${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`)),
2674
+ batches
2568
2675
  });
2569
2676
  }
2570
2677
  /** Replace only DSH's layout client module while retaining its complete plugin graph. */
@@ -3004,6 +3111,8 @@ var MobileAccessGateway = class {
3004
3111
  activeRequests = /* @__PURE__ */ new Map();
3005
3112
  activeWebSockets = /* @__PURE__ */ new Map();
3006
3113
  mobileBootBatches = /* @__PURE__ */ new Map();
3114
+ /** Cached upstream bundle byte sizes (HEAD probes), keyed by resource URL. */
3115
+ bootEntrySizeCache = /* @__PURE__ */ new Map();
3007
3116
  extensionEventListeners = /* @__PURE__ */ new Set();
3008
3117
  extensionEventRevision = 0;
3009
3118
  taskEventListeners = /* @__PURE__ */ new Set();
@@ -3904,8 +4013,10 @@ var MobileAccessGateway = class {
3904
4013
  }
3905
4014
  let body;
3906
4015
  try {
3907
- const rewritten = rewriteMobileIndexWithBatch(Buffer.concat(chunks).toString("utf8"));
3908
- 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);
3909
4020
  body = Buffer.from(rewritten.html);
3910
4021
  } catch {
3911
4022
  throw new HttpError(502, "upstream_unavailable");
@@ -3941,6 +4052,98 @@ var MobileAccessGateway = class {
3941
4052
  this.mobileBootBatches.delete(oldest);
3942
4053
  }
3943
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
+ }
3944
4147
  async serveMobileBootBatch(key, request, response, authorization) {
3945
4148
  if (request.method !== "GET" && request.method !== "HEAD") throw new HttpError(405, "method_not_allowed");
3946
4149
  const stored = this.mobileBootBatches.get(key);
@@ -4022,7 +4225,7 @@ var MobileAccessGateway = class {
4022
4225
  }
4023
4226
  };
4024
4227
  await Promise.all(Array.from({ length: Math.min(3, plan.entries.length) }, worker));
4025
- 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");
4026
4229
  return Buffer.concat(bodies.flatMap((body) => [body, Buffer.from("\n;\n")]));
4027
4230
  }
4028
4231
  /**
@@ -4131,7 +4334,39 @@ var MobileAccessGateway = class {
4131
4334
  }
4132
4335
  };
4133
4336
  }
4134
- 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) {
4135
4370
  const declared = request$2.headers["content-length"];
4136
4371
  if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > this.config.maxBodyBytes)) throw new HttpError(413, "payload_too_large");
4137
4372
  const holder = {};
@@ -4185,7 +4420,7 @@ var MobileAccessGateway = class {
4185
4420
  await bodyDone?.catch(() => void 0);
4186
4421
  if (error instanceof HttpError) throw error;
4187
4422
  if (response.headersSent) response.destroy();
4188
- else throw new HttpError(502, "upstream_unavailable");
4423
+ throw error;
4189
4424
  } finally {
4190
4425
  operation.release();
4191
4426
  }
@@ -6512,24 +6747,45 @@ async function downloadPinnedArtifact(request) {
6512
6747
  }
6513
6748
  //#endregion
6514
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)])));
6515
6781
  /**
6516
- * Pinned cloudflared Windows component fetched only after an explicit user action.
6517
- *
6518
- * Unlike the cpolar archive this artifact IS the executable: there is nothing to
6519
- * unpack, so the download digest and the installed digest are the same pair.
6520
- * `--no-autoupdate` is passed at runtime as well, so the pinned bytes stay the
6521
- * bytes that were verified.
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.
6522
6785
  */
6523
- const CLOUDFLARED_COMPONENT_RELEASE = Object.freeze({
6524
- version: "2026.9.1",
6525
- platform: "win32",
6526
- arch: "x64",
6527
- downloadUrl: "https://github.com/cloudflare/cloudflared/releases/download/2026.9.1/cloudflared-windows-amd64.exe",
6528
- downloadBytes: 54976432,
6529
- downloadSha256: "2837888cc0f5d58f15b6dc478376de90b4d3ba5241c7947455d1e0a0df429712",
6530
- downloadPage: "https://github.com/cloudflare/cloudflared/releases",
6531
- termsUrl: "https://www.cloudflare.com/website-terms/"
6532
- });
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/";
6533
6789
  function inside$1(parent, child) {
6534
6790
  const candidate = relative(parent, child);
6535
6791
  return candidate !== "" && !candidate.startsWith("..") && !isAbsolute(candidate);
@@ -6546,14 +6802,18 @@ async function regularFile$1(file, expectedBytes) {
6546
6802
  throw error;
6547
6803
  }
6548
6804
  }
6549
- async function defaultFetchArtifact$1(url, signal) {
6805
+ async function defaultFetchArtifact$1(url, signal, expectedBytes) {
6550
6806
  return downloadPinnedArtifact({
6551
6807
  url,
6552
- expectedBytes: CLOUDFLARED_COMPONENT_RELEASE.downloadBytes,
6808
+ expectedBytes,
6553
6809
  errorPrefix: "cloudflared",
6554
6810
  signal
6555
6811
  });
6556
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
+ }
6557
6817
  /** Owns the optional cloudflared binary inside DSH Mobile state. */
6558
6818
  var CloudflaredComponentManager = class {
6559
6819
  executable;
@@ -6564,6 +6824,7 @@ var CloudflaredComponentManager = class {
6564
6824
  stagingRoot;
6565
6825
  platform;
6566
6826
  arch;
6827
+ release;
6567
6828
  fetchArtifact;
6568
6829
  installed = false;
6569
6830
  errorCode;
@@ -6573,9 +6834,10 @@ var CloudflaredComponentManager = class {
6573
6834
  if (!isAbsolute(stateDirectory)) throw new Error("cloudflared state directory must be absolute");
6574
6835
  this.platform = options.platform ?? process.platform;
6575
6836
  this.arch = options.arch ?? process.arch;
6837
+ this.release = lookupRelease(this.platform, this.arch);
6576
6838
  this.componentRoot = join(stateDirectory, "components", "cloudflared");
6577
- this.componentStorage = join(this.componentRoot, CLOUDFLARED_COMPONENT_RELEASE.version);
6578
- this.executable = join(this.componentStorage, "cloudflared.exe");
6839
+ this.componentStorage = join(this.componentRoot, this.release?.version ?? CLOUDFLARED_VERSION);
6840
+ this.executable = join(this.componentStorage, this.release?.executableName ?? "cloudflared");
6579
6841
  this.stateRoot = join(stateDirectory, "state", "cloudflared");
6580
6842
  this.logRoot = join(stateDirectory, "logs", "cloudflared");
6581
6843
  this.stagingRoot = join(stateDirectory, "staging", "cloudflared");
@@ -6586,27 +6848,30 @@ var CloudflaredComponentManager = class {
6586
6848
  this.logRoot,
6587
6849
  this.stagingRoot
6588
6850
  ]) if (!inside$1(stateDirectory, child)) throw new Error("cloudflared component path escaped its state directory");
6589
- this.fetchArtifact = options.fetchArtifact ?? defaultFetchArtifact$1;
6851
+ const release = this.release;
6852
+ this.fetchArtifact = options.fetchArtifact ?? ((url, signal) => defaultFetchArtifact$1(url, signal, release?.downloadBytes ?? 0));
6590
6853
  }
6591
6854
  /** Inspect the managed binary without using any global cloudflared state. */
6592
6855
  async initialize() {
6593
- this.installed = await regularFile$1(this.executable, CLOUDFLARED_COMPONENT_RELEASE.downloadBytes);
6594
- if (this.installed && await sha256$1(this.executable) !== CLOUDFLARED_COMPONENT_RELEASE.downloadSha256) {
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) {
6595
6859
  this.installed = false;
6596
6860
  this.errorCode = "cloudflared_component_invalid";
6597
6861
  }
6598
6862
  }
6599
6863
  /** Return a safe status that never includes machine-specific account data. */
6600
6864
  status() {
6865
+ const release = this.release ?? CLOUDFLARED_COMPONENT_RELEASE;
6601
6866
  return Object.freeze({
6602
- supported: this.platform === CLOUDFLARED_COMPONENT_RELEASE.platform && this.arch === CLOUDFLARED_COMPONENT_RELEASE.arch,
6867
+ supported: this.release !== void 0,
6603
6868
  installed: this.installed,
6604
- version: CLOUDFLARED_COMPONENT_RELEASE.version,
6605
- downloadBytes: CLOUDFLARED_COMPONENT_RELEASE.downloadBytes,
6606
- installedBytes: CLOUDFLARED_COMPONENT_RELEASE.downloadBytes,
6607
- sourceUrl: CLOUDFLARED_COMPONENT_RELEASE.downloadUrl,
6608
- downloadPage: CLOUDFLARED_COMPONENT_RELEASE.downloadPage,
6609
- termsUrl: CLOUDFLARED_COMPONENT_RELEASE.termsUrl,
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,
6610
6875
  storagePath: this.componentRoot,
6611
6876
  ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
6612
6877
  });
@@ -6614,7 +6879,8 @@ var CloudflaredComponentManager = class {
6614
6879
  /** Download, verify, and install the pinned cloudflared executable after explicit confirmation. */
6615
6880
  install() {
6616
6881
  return this.enqueue(async () => {
6617
- if (this.platform !== CLOUDFLARED_COMPONENT_RELEASE.platform || this.arch !== CLOUDFLARED_COMPONENT_RELEASE.arch) throw new Error("cloudflared_component_unsupported");
6882
+ const release = this.release;
6883
+ if (release === void 0) throw new Error("cloudflared_component_unsupported");
6618
6884
  await mkdir(this.stagingRoot, {
6619
6885
  recursive: true,
6620
6886
  mode: 448
@@ -6628,26 +6894,26 @@ var CloudflaredComponentManager = class {
6628
6894
  timeout.unref();
6629
6895
  let bytes;
6630
6896
  try {
6631
- bytes = await this.fetchArtifact(CLOUDFLARED_COMPONENT_RELEASE.downloadUrl, controller.signal);
6897
+ bytes = await this.fetchArtifact(release.downloadUrl, controller.signal);
6632
6898
  } finally {
6633
6899
  clearTimeout(timeout);
6634
6900
  }
6635
- if (bytes.byteLength !== CLOUDFLARED_COMPONENT_RELEASE.downloadBytes) throw new Error("cloudflared_download_size_mismatch");
6636
- if (createHash("sha256").update(bytes).digest("hex") !== CLOUDFLARED_COMPONENT_RELEASE.downloadSha256) throw new Error("cloudflared_download_hash_mismatch");
6637
- const staged = join(staging, "cloudflared.exe");
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);
6638
6904
  await writeFile(staged, bytes, {
6639
6905
  flag: "wx",
6640
6906
  mode: 384
6641
6907
  });
6642
6908
  await chmod(staged, 448);
6643
- if (!await regularFile$1(staged, CLOUDFLARED_COMPONENT_RELEASE.downloadBytes) || await sha256$1(staged) !== CLOUDFLARED_COMPONENT_RELEASE.downloadSha256) throw new Error("cloudflared_executable_hash_mismatch");
6909
+ if (!await regularFile$1(staged, release.downloadBytes) || await sha256$1(staged) !== release.downloadSha256) throw new Error("cloudflared_executable_hash_mismatch");
6644
6910
  const candidate = join(this.componentRoot, `.install-${randomBytes(12).toString("hex")}`);
6645
6911
  await mkdir(candidate, {
6646
6912
  recursive: true,
6647
6913
  mode: 448
6648
6914
  });
6649
- await copyFile(staged, join(candidate, "cloudflared.exe"));
6650
- await chmod(join(candidate, "cloudflared.exe"), 448);
6915
+ await copyFile(staged, join(candidate, release.executableName));
6916
+ await chmod(join(candidate, release.executableName), 448);
6651
6917
  await rm(this.componentStorage, {
6652
6918
  recursive: true,
6653
6919
  force: true
@@ -6704,6 +6970,15 @@ const MAX_LOG_BUFFER_BYTES$1 = 65536;
6704
6970
  * looking healthy for longer than a user will wait.
6705
6971
  */
6706
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;
6707
6982
  const CLOUDFLARED_HOST_SUFFIX = ".trycloudflare.com";
6708
6983
  /**
6709
6984
  * Subdomains Cloudflare reserves for its own control plane.
@@ -6846,6 +7121,7 @@ var CloudflaredController = class {
6846
7121
  });
6847
7122
  queue = Promise.resolve();
6848
7123
  startupTimer;
7124
+ startupRounds = 0;
6849
7125
  constructor(options) {
6850
7126
  this.options = options;
6851
7127
  if (!isAbsolute(options.executable)) throw new Error("cloudflared executable path must be absolute");
@@ -6977,6 +7253,7 @@ var CloudflaredController = class {
6977
7253
  }
6978
7254
  this.reservation = reservation;
6979
7255
  this.buffer = "";
7256
+ this.startupRounds = 0;
6980
7257
  this.publish({
6981
7258
  enabled: true,
6982
7259
  state: "starting"
@@ -7038,11 +7315,32 @@ var CloudflaredController = class {
7038
7315
  this.child = void 0;
7039
7316
  if (this.enabled) this.enqueue(() => this.failGeneration(generation, code === 0 ? "cloudflared_stopped" : "cloudflared_exited"));
7040
7317
  });
7318
+ this.armStartupTimer(generation);
7319
+ }
7320
+ armStartupTimer(generation) {
7321
+ if (this.startupTimer !== void 0) clearTimeout(this.startupTimer);
7041
7322
  this.startupTimer = setTimeout(() => {
7042
- this.enqueue(() => this.failGeneration(generation, "cloudflared_start_timeout"));
7323
+ this.enqueue(() => this.checkStartupTimeout(generation));
7043
7324
  }, this.options.startupTimeoutMs ?? START_TIMEOUT_MS$1);
7044
7325
  this.startupTimer.unref();
7045
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
+ }
7046
7344
  consume(generation, chunk) {
7047
7345
  if (generation !== this.generation) return;
7048
7346
  this.buffer += chunk;
@@ -9526,21 +9824,56 @@ var CpolarController = class {
9526
9824
  };
9527
9825
  //#endregion
9528
9826
  //#region src/cpolar-component.ts
9529
- /** Pinned cpolar Windows component fetched only after an explicit user action. */
9530
- const CPOLAR_COMPONENT_RELEASE = Object.freeze({
9531
- version: "3.3.18",
9532
- platform: "win32",
9533
- arch: "x64",
9534
- downloadUrl: "https://www.cpolar.com/static/downloads/releases/3.3.18/cpolar-stable-windows-amd64-setup.zip",
9535
- downloadBytes: 7603505,
9536
- downloadSha256: "fb8cf60289058ee26079f995d2eeea0b21768a742d90c93015afe96e83428830",
9537
- executableBytes: 19637680,
9538
- executableSha256: "b2d865ee505e842d22ceca5493a872efa893a79b079a7a8ee2bd3aa5343a5c41",
9539
- downloadPage: "https://www.cpolar.com/download",
9540
- signupUrl: "https://dashboard.cpolar.com/signup",
9541
- dashboardUrl: "https://dashboard.cpolar.com/auth",
9542
- termsUrl: "https://www.cpolar.com/tos"
9543
- });
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";
9544
9877
  function inside(parent, child) {
9545
9878
  const candidate = relative(parent, child);
9546
9879
  return candidate !== "" && !candidate.startsWith("..") && !isAbsolute(candidate);
@@ -9568,15 +9901,15 @@ async function run(file, args) {
9568
9901
  });
9569
9902
  });
9570
9903
  }
9571
- async function defaultFetchArtifact(url, signal) {
9904
+ async function defaultFetchArtifact(url, signal, expectedBytes) {
9572
9905
  return downloadPinnedArtifact({
9573
9906
  url,
9574
- expectedBytes: CPOLAR_COMPONENT_RELEASE.downloadBytes,
9907
+ expectedBytes,
9575
9908
  errorPrefix: "cpolar",
9576
9909
  signal
9577
9910
  });
9578
9911
  }
9579
- async function defaultExtractArtifact(archive, destination) {
9912
+ async function extractWindowsMsi(archive, destination, executableName) {
9580
9913
  if (process.platform !== "win32") throw new Error("cpolar_component_unsupported");
9581
9914
  const unpacked = join(destination, "archive");
9582
9915
  const administrative = join(destination, "administrative");
@@ -9602,9 +9935,29 @@ async function defaultExtractArtifact(archive, destination) {
9602
9935
  "/qn",
9603
9936
  `TARGETDIR=${administrative}`
9604
9937
  ]);
9605
- 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());
9606
9955
  if (executableRelative === void 0) throw new Error("cpolar_executable_missing");
9607
- 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);
9608
9961
  }
9609
9962
  /** Validate a cpolar Authtoken before it crosses the durable-file boundary. */
9610
9963
  function validateCpolarAuthtoken(value) {
@@ -9622,6 +9975,7 @@ var CpolarComponentManager = class {
9622
9975
  stagingRoot;
9623
9976
  platform;
9624
9977
  arch;
9978
+ release;
9625
9979
  fetchArtifact;
9626
9980
  extractArtifact;
9627
9981
  installed = false;
@@ -9633,9 +9987,10 @@ var CpolarComponentManager = class {
9633
9987
  if (!isAbsolute(stateDirectory)) throw new Error("cpolar state directory must be absolute");
9634
9988
  this.platform = options.platform ?? process.platform;
9635
9989
  this.arch = options.arch ?? process.arch;
9990
+ this.release = CPOLAR_COMPONENT_RELEASES[`${this.platform}-${this.arch}`];
9636
9991
  this.componentRoot = join(stateDirectory, "components", "cpolar");
9637
- this.componentStorage = join(this.componentRoot, CPOLAR_COMPONENT_RELEASE.version);
9638
- 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");
9639
9994
  this.stateRoot = join(stateDirectory, "state", "cpolar");
9640
9995
  this.configFile = join(this.stateRoot, "cpolar.yml");
9641
9996
  this.logRoot = join(stateDirectory, "logs", "cpolar");
@@ -9647,13 +10002,18 @@ var CpolarComponentManager = class {
9647
10002
  this.logRoot,
9648
10003
  this.stagingRoot
9649
10004
  ]) if (!inside(stateDirectory, child)) throw new Error("cpolar component path escaped its state directory");
9650
- this.fetchArtifact = options.fetchArtifact ?? defaultFetchArtifact;
9651
- 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
+ });
9652
10011
  }
9653
10012
  /** Inspect the managed binary and configuration without using global cpolar state. */
9654
10013
  async initialize() {
9655
- this.installed = await regularFile(this.executable, CPOLAR_COMPONENT_RELEASE.executableBytes);
9656
- 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) {
9657
10017
  this.installed = false;
9658
10018
  this.errorCode = "cpolar_component_invalid";
9659
10019
  }
@@ -9662,18 +10022,19 @@ var CpolarComponentManager = class {
9662
10022
  }
9663
10023
  /** Return a safe status that never includes the account token. */
9664
10024
  status() {
10025
+ const release = this.release ?? CPOLAR_COMPONENT_RELEASE;
9665
10026
  return Object.freeze({
9666
- supported: this.platform === CPOLAR_COMPONENT_RELEASE.platform && this.arch === CPOLAR_COMPONENT_RELEASE.arch,
10027
+ supported: this.release !== void 0,
9667
10028
  installed: this.installed,
9668
10029
  configured: this.configured,
9669
- version: CPOLAR_COMPONENT_RELEASE.version,
9670
- downloadBytes: CPOLAR_COMPONENT_RELEASE.downloadBytes,
9671
- installedBytes: CPOLAR_COMPONENT_RELEASE.executableBytes,
9672
- sourceUrl: CPOLAR_COMPONENT_RELEASE.downloadUrl,
9673
- downloadPage: CPOLAR_COMPONENT_RELEASE.downloadPage,
9674
- signupUrl: CPOLAR_COMPONENT_RELEASE.signupUrl,
9675
- dashboardUrl: CPOLAR_COMPONENT_RELEASE.dashboardUrl,
9676
- 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,
9677
10038
  storagePath: this.componentRoot,
9678
10039
  ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
9679
10040
  });
@@ -9681,7 +10042,8 @@ var CpolarComponentManager = class {
9681
10042
  /** Download, verify, and administratively extract cpolar after explicit confirmation. */
9682
10043
  install() {
9683
10044
  return this.enqueue(async () => {
9684
- 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");
9685
10047
  await mkdir(this.stagingRoot, {
9686
10048
  recursive: true,
9687
10049
  mode: 448
@@ -9695,26 +10057,26 @@ var CpolarComponentManager = class {
9695
10057
  timeout.unref();
9696
10058
  let bytes;
9697
10059
  try {
9698
- bytes = await this.fetchArtifact(CPOLAR_COMPONENT_RELEASE.downloadUrl, controller.signal);
10060
+ bytes = await this.fetchArtifact(release.downloadUrl, controller.signal);
9699
10061
  } finally {
9700
10062
  clearTimeout(timeout);
9701
10063
  }
9702
- if (createHash("sha256").update(bytes).digest("hex") !== CPOLAR_COMPONENT_RELEASE.downloadSha256) throw new Error("cpolar_download_hash_mismatch");
9703
- 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");
9704
10066
  await writeFile(archive, bytes, {
9705
10067
  flag: "wx",
9706
10068
  mode: 384
9707
10069
  });
9708
10070
  await this.extractArtifact(archive, staging);
9709
- const extracted = join(staging, "cpolar.exe");
9710
- 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");
9711
10073
  const candidate = join(this.componentRoot, `.install-${randomBytes(12).toString("hex")}`);
9712
10074
  await mkdir(candidate, {
9713
10075
  recursive: true,
9714
10076
  mode: 448
9715
10077
  });
9716
- await copyFile(extracted, join(candidate, "cpolar.exe"));
9717
- await chmod(join(candidate, "cpolar.exe"), 448);
10078
+ await copyFile(extracted, join(candidate, release.executableName));
10079
+ await chmod(join(candidate, release.executableName), 448);
9718
10080
  await rm(this.componentStorage, {
9719
10081
  recursive: true,
9720
10082
  force: true
@@ -10404,6 +10766,16 @@ function selectLanNetwork(requestedAddress, requestedInterface, table, preferred
10404
10766
  if (physicalCandidates.length === 1) return physicalCandidates[0];
10405
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(", ")}`);
10406
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
+ }
10407
10779
  function assertMatchingCa(certPem, keyPem) {
10408
10780
  const certificate = new X509Certificate(certPem);
10409
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");
@@ -10965,7 +11337,14 @@ async function apply(ctx, config) {
10965
11337
  }, (error) => {
10966
11338
  process.emitWarning(`DSH Mobile could not follow the current LAN address: ${error instanceof Error ? error.message : String(error)}`, { code: "DSH_MOBILE_NETWORK_REFRESH" });
10967
11339
  });
10968
- 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
+ }
10969
11348
  return following;
10970
11349
  };
10971
11350
  const lanControlFile = parseControlFile(config.controlFile);