dsh-mobile 0.5.4 → 0.6.0

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
@@ -70,14 +70,14 @@ var BoundedRateLimiter = class {
70
70
  function opaqueToken() {
71
71
  return randomBytes(32).toString("base64url");
72
72
  }
73
- function digest(value) {
73
+ function digest$1(value) {
74
74
  return createHash("sha256").update(value, "utf8").digest();
75
75
  }
76
76
  function digestHex(value) {
77
- return digest(value).toString("hex");
77
+ return digest$1(value).toString("hex");
78
78
  }
79
79
  function matchesDigest(value, expected) {
80
- return timingSafeEqual(digest(value), expected);
80
+ return timingSafeEqual(digest$1(value), expected);
81
81
  }
82
82
  function normalizeLabel(value) {
83
83
  const label = (value ?? "Mobile device").normalize("NFC").trim();
@@ -175,7 +175,7 @@ var AccessController = class {
175
175
  const record = Object.freeze({
176
176
  key,
177
177
  deviceId,
178
- csrfDigest: digest(csrfToken),
178
+ csrfDigest: digest$1(csrfToken),
179
179
  createdAt: now,
180
180
  expiresAt: Math.min(now + this.options.sessionTtlMs, deviceExpiresAt)
181
181
  });
@@ -196,7 +196,7 @@ var AccessController = class {
196
196
  const token = opaqueToken();
197
197
  const expiresAt = this.now() + ttl;
198
198
  this.pairingWindow = Object.freeze({
199
- digest: digest(token),
199
+ digest: digest$1(token),
200
200
  expiresAt
201
201
  });
202
202
  return Object.freeze({
@@ -289,7 +289,7 @@ var AccessController = class {
289
289
  }
290
290
  requireDevice(deviceToken, now) {
291
291
  if (deviceToken.length > 512) throw new AccessError(401, "authentication_failed");
292
- const tokenDigest = digest(deviceToken);
292
+ const tokenDigest = digest$1(deviceToken);
293
293
  const device = this.devices.find((candidate) => timingSafeEqual(Buffer.from(candidate.tokenDigest, "hex"), tokenDigest));
294
294
  if (device === void 0) throw new AccessError(401, "authentication_failed");
295
295
  if (device.revokedAt !== void 0) throw new AccessError(401, "device_revoked");
@@ -904,6 +904,7 @@ const Config = z.object({
904
904
  maxWebSockets: z.natural(),
905
905
  maxBodyBytes: z.natural(),
906
906
  upstreamTimeoutMs: z.natural(),
907
+ upstreamApiTimeoutMs: z.natural(),
907
908
  rateLimitWindowMs: z.natural(),
908
909
  maxPairingAttempts: z.natural(),
909
910
  maxRateLimitKeys: z.natural()
@@ -1052,6 +1053,7 @@ function parseGatewayConfig(raw) {
1052
1053
  maxWebSockets: integer(value.maxWebSockets, "maxWebSockets", 16, 1, 256),
1053
1054
  maxBodyBytes: integer(value.maxBodyBytes, "maxBodyBytes", 167772160, 1024, 268435456),
1054
1055
  upstreamTimeoutMs: integer(value.upstreamTimeoutMs, "upstreamTimeoutMs", 3e4, 1e3, 3e5),
1056
+ upstreamApiTimeoutMs: integer(value.upstreamApiTimeoutMs, "upstreamApiTimeoutMs", 0, 0, 2147483647),
1055
1057
  rateLimitWindowMs: integer(value.rateLimitWindowMs, "rateLimitWindowMs", 6e4, 1e3, 36e5),
1056
1058
  maxPairingAttempts: integer(value.maxPairingAttempts, "maxPairingAttempts", 8, 1, 100),
1057
1059
  maxRateLimitKeys: integer(value.maxRateLimitKeys, "maxRateLimitKeys", 256, 1, 4096)
@@ -1133,6 +1135,180 @@ async function restrictPrivateFile(file, mode = 384) {
1133
1135
  });
1134
1136
  }
1135
1137
  //#endregion
1138
+ //#region src/client-module-preferences.ts
1139
+ const MAX_RECORD_BYTES = 1048576;
1140
+ /** Validate untrusted JSON selections; graph membership is checked by the Gateway. */
1141
+ function parseExcludedClientModules(value) {
1142
+ if (!Array.isArray(value) || value.some((id) => typeof id !== "string" || id.length === 0 || id.trim() !== id || /[\u0000-\u001f\u007f]/u.test(id))) throw new Error("excluded_client_modules_invalid");
1143
+ const ids = value;
1144
+ if (new Set(ids).size !== ids.length) throw new Error("excluded_client_modules_invalid");
1145
+ return Object.freeze([...ids]);
1146
+ }
1147
+ function deviceKey(deviceId) {
1148
+ if (!/^[a-f\d]{32}$/u.test(deviceId)) throw new Error("client_module_device_invalid");
1149
+ }
1150
+ function parseRecord(value) {
1151
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("client_module_preferences_invalid");
1152
+ const record = value;
1153
+ if (record.version !== 1 || record.devices === null || typeof record.devices !== "object" || Array.isArray(record.devices) || Object.keys(record).some((key) => ![
1154
+ "version",
1155
+ "computer",
1156
+ "devices"
1157
+ ].includes(key))) throw new Error("client_module_preferences_invalid");
1158
+ const devices = {};
1159
+ for (const [id, excluded] of Object.entries(record.devices)) {
1160
+ deviceKey(id);
1161
+ devices[id] = parseExcludedClientModules(excluded);
1162
+ }
1163
+ return Object.freeze({
1164
+ version: 1,
1165
+ devices: Object.freeze(devices),
1166
+ ...record.computer === void 0 ? {} : { computer: parseExcludedClientModules(record.computer) }
1167
+ });
1168
+ }
1169
+ /** Atomic, serialized preferences shared by the LAN and remote Gateway instances. */
1170
+ var ClientModulePreferenceStore = class {
1171
+ file;
1172
+ defaults;
1173
+ value = Object.freeze({
1174
+ version: 1,
1175
+ devices: Object.freeze({})
1176
+ });
1177
+ initialization;
1178
+ queue = Promise.resolve();
1179
+ constructor(file, defaults) {
1180
+ this.file = file;
1181
+ if (!isAbsolute(file)) throw new Error("client module preference path must be absolute");
1182
+ this.defaults = parseExcludedClientModules(defaults);
1183
+ }
1184
+ /** Read existing state once. Missing state is not written during initialization. */
1185
+ initialize() {
1186
+ this.initialization ??= this.load();
1187
+ return this.initialization;
1188
+ }
1189
+ async load() {
1190
+ let information;
1191
+ try {
1192
+ information = await lstat(this.file);
1193
+ } catch (error) {
1194
+ if (error.code === "ENOENT") return;
1195
+ throw error;
1196
+ }
1197
+ if (!information.isFile() || information.isSymbolicLink() || information.size > MAX_RECORD_BYTES) throw new Error("client module preferences must be a regular file no larger than 1 MiB");
1198
+ this.value = parseRecord(JSON.parse(await readFile(this.file, "utf8")));
1199
+ }
1200
+ selection(deviceId) {
1201
+ if (deviceId !== void 0) deviceKey(deviceId);
1202
+ const computer = this.value.computer ?? this.defaults;
1203
+ const device = deviceId === void 0 ? void 0 : this.value.devices[deviceId];
1204
+ return Object.freeze({
1205
+ excludedClientModules: device ?? computer,
1206
+ defaultExcludedClientModules: computer,
1207
+ source: device !== void 0 ? "device" : this.value.computer !== void 0 ? "computer" : "plugin"
1208
+ });
1209
+ }
1210
+ exclusive(action) {
1211
+ const operation = this.queue.then(action);
1212
+ this.queue = operation.then(() => void 0, () => void 0);
1213
+ return operation;
1214
+ }
1215
+ /** Resolve one device override, otherwise the computer or plugin default. */
1216
+ async read(deviceId) {
1217
+ await this.initialize();
1218
+ return this.exclusive(async () => this.selection(deviceId));
1219
+ }
1220
+ /** Revalidate against the current boot manifest inside the serialized mutation. */
1221
+ async configure(excluded, deviceId, validate) {
1222
+ const ids = parseExcludedClientModules(excluded);
1223
+ if (deviceId !== void 0) deviceKey(deviceId);
1224
+ await this.initialize();
1225
+ return this.exclusive(async () => {
1226
+ await validate(ids);
1227
+ const next = deviceId === void 0 ? {
1228
+ ...this.value,
1229
+ computer: ids
1230
+ } : {
1231
+ ...this.value,
1232
+ devices: {
1233
+ ...this.value.devices,
1234
+ [deviceId]: ids
1235
+ }
1236
+ };
1237
+ await this.save(next);
1238
+ return this.selection(deviceId);
1239
+ });
1240
+ }
1241
+ /** Remove only the selected override; retain all other device selections. */
1242
+ async reset(deviceId, validate) {
1243
+ if (deviceId !== void 0) deviceKey(deviceId);
1244
+ await this.initialize();
1245
+ return this.exclusive(async () => {
1246
+ const devices = { ...this.value.devices };
1247
+ if (deviceId !== void 0) delete devices[deviceId];
1248
+ const next = {
1249
+ version: 1,
1250
+ devices,
1251
+ ...deviceId === void 0 || this.value.computer === void 0 ? {} : { computer: this.value.computer }
1252
+ };
1253
+ await validate(deviceId === void 0 ? this.defaults : this.value.computer ?? this.defaults);
1254
+ await this.save(next);
1255
+ return this.selection(deviceId);
1256
+ });
1257
+ }
1258
+ /** Remove exactly the revoked device's record without evaluating its obsolete graph. */
1259
+ removeDevice(deviceId) {
1260
+ return this.removeDevices([deviceId]);
1261
+ }
1262
+ /** Remove an explicit set of revoked identities, not every record in the shared store. */
1263
+ async removeDevices(deviceIds) {
1264
+ for (const deviceId of deviceIds) deviceKey(deviceId);
1265
+ await this.initialize();
1266
+ await this.exclusive(async () => {
1267
+ if (!deviceIds.some((id) => this.value.devices[id] !== void 0)) return;
1268
+ const devices = { ...this.value.devices };
1269
+ for (const deviceId of deviceIds) delete devices[deviceId];
1270
+ await this.save({
1271
+ ...this.value,
1272
+ devices
1273
+ });
1274
+ });
1275
+ }
1276
+ async save(record) {
1277
+ const valid = parseRecord(record);
1278
+ const body = `${JSON.stringify(valid)}\n`;
1279
+ if (Buffer.byteLength(body) > MAX_RECORD_BYTES) throw new Error("client_module_preferences_limit");
1280
+ const directory = dirname(this.file);
1281
+ if (await mkdir(directory, {
1282
+ recursive: true,
1283
+ mode: 448
1284
+ }) !== void 0) await restrictPrivateFile(directory, 448);
1285
+ try {
1286
+ const existing = await lstat(this.file);
1287
+ if (!existing.isFile() || existing.isSymbolicLink()) throw new Error("client module preference target must be a regular file");
1288
+ } catch (error) {
1289
+ if (error.code !== "ENOENT") throw error;
1290
+ }
1291
+ const temporary = join(directory, `.${basename(this.file)}.${randomBytes(12).toString("hex")}.tmp`);
1292
+ try {
1293
+ await writeFile(temporary, body, {
1294
+ encoding: "utf8",
1295
+ flag: "wx",
1296
+ mode: 384
1297
+ });
1298
+ await restrictPrivateFile(temporary);
1299
+ await rename(temporary, this.file);
1300
+ this.value = valid;
1301
+ } catch (error) {
1302
+ try {
1303
+ await rm(temporary, { force: true });
1304
+ } catch (cleanupError) {
1305
+ throw new AggregateError([error, cleanupError], "client module preference write and temporary cleanup failed");
1306
+ }
1307
+ throw error;
1308
+ }
1309
+ }
1310
+ };
1311
+ //#endregion
1136
1312
  //#region src/control.ts
1137
1313
  /** Keeps one runtime aligned with a changing network selection. */
1138
1314
  var FollowingMobileAccessRuntime = class {
@@ -1380,6 +1556,88 @@ var MobileAccessGatewayController = class {
1380
1556
  return run;
1381
1557
  }
1382
1558
  };
1559
+ //#endregion
1560
+ //#region src/client-module-recovery.ts
1561
+ const CLIENT_MODULE_RECOVERY_COPY = {
1562
+ zh: {
1563
+ title: "移动页面模块需要调整",
1564
+ explanation: "电脑上的插件或依赖关系已变化,之前保存的模块选择无法加载当前页面。",
1565
+ scope: "可为本设备加载全部模块并重新打开 DSH。不会修改电脑默认设置、其他设备或已安装的插件。",
1566
+ action: "为本设备加载全部模块并重试",
1567
+ working: "正在更新本设备的模块选择…",
1568
+ failed: "未能恢复。请确认电脑和远程通道仍在线,然后重试。"
1569
+ },
1570
+ en: {
1571
+ title: "Mobile page modules need attention",
1572
+ explanation: "The computer’s plugins or dependencies have changed. The saved module selection can no longer load this page.",
1573
+ scope: "Load all modules for this device and reopen DSH. Computer defaults, other devices, and installed plugins are unchanged.",
1574
+ action: "Load all modules for this device and retry",
1575
+ working: "Updating this device’s module selection…",
1576
+ failed: "Could not restore the page. Check that the computer and remote connection are online, then retry."
1577
+ },
1578
+ it: {
1579
+ title: "I moduli della pagina mobile richiedono attenzione",
1580
+ explanation: "I plugin o le dipendenze del computer sono cambiati. La selezione salvata non può più caricare questa pagina.",
1581
+ scope: "Carica tutti i moduli per questo dispositivo e riapri DSH. I predefiniti del computer, gli altri dispositivi e i plugin installati non cambiano.",
1582
+ action: "Carica tutti i moduli per questo dispositivo e riprova",
1583
+ working: "Aggiornamento dei moduli per questo dispositivo…",
1584
+ failed: "Ripristino non riuscito. Verifica che il computer e la connessione remota siano online, poi riprova."
1585
+ }
1586
+ };
1587
+ /** Render an explicit device-only recovery action without interpolating graph diagnostics or credentials.
1588
+ * @param locale - Negotiated browser display language.
1589
+ * @returns An owned HTML page which changes no selection until the user activates its button.
1590
+ */
1591
+ function renderClientModuleRecoveryPage(locale) {
1592
+ const copy = CLIENT_MODULE_RECOVERY_COPY[locale];
1593
+ return `<!doctype html>
1594
+ <html lang="${locale === "zh" ? "zh-CN" : locale}">
1595
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
1596
+ <meta name="color-scheme" content="light dark"><title>${copy.title}</title>
1597
+ <style>
1598
+ body{margin:0;background:Canvas;color:CanvasText;font:16px/1.5 system-ui,sans-serif}
1599
+ main{box-sizing:border-box;max-width:640px;margin:32px auto;padding:24px max(24px,env(safe-area-inset-left)) 24px max(24px,env(safe-area-inset-right));overflow-wrap:anywhere}
1600
+ h1{font-size:24px;line-height:1.3}button{min-height:48px;max-width:100%;padding:12px 20px;border:1px solid ButtonText;border-radius:12px;background:ButtonFace;color:ButtonText;font:inherit;cursor:pointer}
1601
+ button:focus-visible{outline:2px solid Highlight;outline-offset:3px}button:disabled{opacity:.6;cursor:wait}#recovery-status{min-height:1.5em}
1602
+ </style></head>
1603
+ <body><main><h1>${copy.title}</h1><p>${copy.explanation}</p><p>${copy.scope}</p>
1604
+ <button id="load-all-modules" type="button">${copy.action}</button><p id="recovery-status" role="status" aria-live="polite"></p>
1605
+ </main><script>${renderClientModuleRecoveryScript(locale)}<\/script></body></html>
1606
+ `;
1607
+ }
1608
+ /** Render the fixed same-origin action; CSRF values are read from the active browser cookie only at user activation.
1609
+ * @param locale - Negotiated browser display language.
1610
+ * @returns JavaScript that preserves selections on failure and reloads only after an authorized save succeeds.
1611
+ */
1612
+ function renderClientModuleRecoveryScript(locale) {
1613
+ const copy = CLIENT_MODULE_RECOVERY_COPY[locale];
1614
+ return `(() => {
1615
+ const button = document.getElementById('load-all-modules')
1616
+ const status = document.getElementById('recovery-status')
1617
+ button.addEventListener('click', async () => {
1618
+ if (button.disabled) return
1619
+ button.disabled = true
1620
+ status.textContent = ${JSON.stringify(copy.working)}
1621
+ try {
1622
+ const values = document.cookie.split(';').map(value => value.trim())
1623
+ const host = values.filter(value => value.startsWith('__Host-dsh_ma_csrf='))
1624
+ const candidates = host.length === 0 ? values.filter(value => value.startsWith('dsh_ma_csrf=')) : host
1625
+ if (candidates.length !== 1) throw new Error('csrf_unavailable')
1626
+ const csrf = candidates[0].slice(candidates[0].indexOf('=') + 1)
1627
+ const response = await fetch('/mobile-access/client-modules', {
1628
+ method: 'POST', credentials: 'same-origin', redirect: 'error', cache: 'no-store',
1629
+ headers: { 'content-type': 'application/json', 'x-dsh-mobile-csrf': csrf },
1630
+ body: JSON.stringify({ excludedClientModules: [] }),
1631
+ })
1632
+ if (!response.ok) throw new Error('recovery_failed')
1633
+ location.reload()
1634
+ } catch {
1635
+ status.textContent = ${JSON.stringify(copy.failed)}
1636
+ button.disabled = false
1637
+ }
1638
+ })
1639
+ })()`;
1640
+ }
1383
1641
  function unwrapBrackets(hostname) {
1384
1642
  return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
1385
1643
  }
@@ -1621,10 +1879,8 @@ function commentRanges(html) {
1621
1879
  /**
1622
1880
  * Load the compatibility bundle synchronously, before the first DSH script.
1623
1881
  *
1624
- * Returns the HTML unchanged when there is no script to anchor to. The bundle is an
1625
- * enhancement for WebViews without Iterator helpers, so a document this function
1626
- * cannot place it in must still be served: throwing here took the entire dedicated
1627
- * mobile frontend down with a 502.
1882
+ * Returns the HTML unchanged when there is no executable script to anchor to.
1883
+ * The bundle supplies missing browser APIs before the page's scripts execute.
1628
1884
  */
1629
1885
  function ensureMobileCompatibility(html) {
1630
1886
  const comments = commentRanges(html);
@@ -2233,6 +2489,12 @@ function normalizeRoutePath(value) {
2233
2489
  if (normalizedInput.split("/").some((part) => part === ".." || part === ".")) throw new MobileExtensionError("invalid_route", "extension route path is invalid");
2234
2490
  return normalizedInput === "/" ? "/" : normalizedInput.replace(/\/+$/u, "");
2235
2491
  }
2492
+ /** Validate and snapshot an optional per-operation timeout override. */
2493
+ function operationTimeout(value, code) {
2494
+ if (value === void 0) return void 0;
2495
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 3e5) throw new MobileExtensionError(code, "operation timeoutMs is invalid");
2496
+ return value;
2497
+ }
2236
2498
  function validateDefinition(definition) {
2237
2499
  const manifest = parseExtensionManifest({
2238
2500
  schemaVersion: definition.schemaVersion,
@@ -2242,9 +2504,17 @@ function validateDefinition(definition) {
2242
2504
  ...definition.description === void 0 ? {} : { description: definition.description }
2243
2505
  });
2244
2506
  const actionNames = /* @__PURE__ */ new Set();
2507
+ const actions = {};
2245
2508
  for (const [name, action] of Object.entries(definition.actions ?? {})) {
2246
2509
  if (!/^[a-z][a-z0-9-]{0,63}$/u.test(name) || action === null || typeof action !== "object" || typeof action.run !== "function" || actionNames.has(name)) throw new MobileExtensionError("invalid_action", `invalid action ${name}`);
2247
2510
  actionNames.add(name);
2511
+ const actionTimeout = operationTimeout(action.timeoutMs, "invalid_action");
2512
+ const actionInput = action.input;
2513
+ actions[name] = Object.freeze({
2514
+ ...actionInput === void 0 ? {} : { input: actionInput },
2515
+ run: action.run.bind(action),
2516
+ ...actionTimeout === void 0 ? {} : { timeoutMs: actionTimeout }
2517
+ });
2248
2518
  }
2249
2519
  const routeNames = /* @__PURE__ */ new Set();
2250
2520
  const routes = (definition.routes ?? []).map((route) => {
@@ -2258,11 +2528,14 @@ function validateDefinition(definition) {
2258
2528
  "PATCH",
2259
2529
  "DELETE"
2260
2530
  ].includes(method)) throw new MobileExtensionError("invalid_route", "unsupported extension route method");
2261
- const normalized = {
2262
- ...route,
2531
+ const routeTimeout = operationTimeout(route.timeoutMs, "invalid_route");
2532
+ const normalized = Object.freeze({
2263
2533
  method,
2264
- path: normalizeRoutePath(route.path)
2265
- };
2534
+ path: normalizeRoutePath(route.path),
2535
+ ...route.kind === void 0 ? {} : { kind: route.kind },
2536
+ handle: route.handle.bind(route),
2537
+ ...routeTimeout === void 0 ? {} : { timeoutMs: routeTimeout }
2538
+ });
2266
2539
  const key = routeKey(normalized);
2267
2540
  if (routeNames.has(key)) throw new MobileExtensionError("duplicate_route", `duplicate route ${key}`);
2268
2541
  routeNames.add(key);
@@ -2270,7 +2543,7 @@ function validateDefinition(definition) {
2270
2543
  });
2271
2544
  return Object.freeze({
2272
2545
  ...manifest,
2273
- ...definition.actions === void 0 ? {} : { actions: Object.freeze({ ...definition.actions }) },
2546
+ ...definition.actions === void 0 ? {} : { actions: Object.freeze(actions) },
2274
2547
  ...routes.length === 0 ? {} : { routes: Object.freeze(routes) }
2275
2548
  });
2276
2549
  }
@@ -2315,6 +2588,8 @@ var MobileAccessService = class extends Service {
2315
2588
  local = /* @__PURE__ */ new Map();
2316
2589
  retired = /* @__PURE__ */ new Map();
2317
2590
  failures = /* @__PURE__ */ new Map();
2591
+ /** Extensions with caller-detached, still-unresolved operations. */
2592
+ abandoned = /* @__PURE__ */ new Map();
2318
2593
  contentListeners = /* @__PURE__ */ new Set();
2319
2594
  contentHash = createHash("sha256").update("").digest("hex");
2320
2595
  localRoot;
@@ -2434,30 +2709,65 @@ var MobileAccessService = class extends Service {
2434
2709
  name: asset.name
2435
2710
  };
2436
2711
  }
2712
+ /** Reject new dispatches to an extension whose abandoned work is unresolved. */
2713
+ requireAdmission(id) {
2714
+ if ((this.abandoned.get(id) ?? 0) > 0) throw new MobileExtensionError("extension_busy", `extension ${id} has unresolved abandoned work`, 503);
2715
+ }
2716
+ /** Account for one operation until its underlying work actually settles. */
2717
+ trackOperation(id, operation) {
2718
+ operation.settled.then(() => {
2719
+ if (operation.detached()) {
2720
+ const remaining = (this.abandoned.get(id) ?? 1) - 1;
2721
+ if (remaining <= 0) this.abandoned.delete(id);
2722
+ else this.abandoned.set(id, remaining);
2723
+ }
2724
+ }).catch(() => void 0);
2725
+ }
2437
2726
  /** Invoke one action after parsing its input and binding the request lifetime. */
2438
2727
  async invoke(id, actionName, input, context, generation) {
2439
2728
  const extension = this.extension(id, generation);
2440
2729
  if (extension === void 0) throw new MobileExtensionError("extension_not_found", "extension not found", 404);
2441
2730
  const action = ("host" in extension ? extension.host : extension).actions?.[actionName];
2442
2731
  if (action === void 0) throw new MobileExtensionError("action_not_found", "action not found", 404);
2443
- let parsed;
2732
+ if ("host" in extension) extension.controller.signal.throwIfAborted();
2733
+ context.signal.throwIfAborted();
2734
+ this.requireAdmission(id);
2735
+ const external = "host" in extension ? combineSignalLifetime(extension.controller.signal, context.signal) : void 0;
2736
+ let operation;
2444
2737
  try {
2445
- parsed = parseActionInput(action.input, input);
2446
- } catch {
2447
- throw new MobileExtensionError("invalid_action_input", "action input is invalid", 400);
2448
- }
2449
- const lifetime = "host" in extension ? combineSignalLifetime(extension.controller.signal, context.signal) : void 0;
2450
- const signal = lifetime?.signal ?? context.signal;
2451
- try {
2452
- return await action.run({
2453
- ...context,
2454
- signal
2455
- }, parsed);
2738
+ operation = startBoundedOperation((signal) => {
2739
+ let parsed;
2740
+ try {
2741
+ parsed = parseActionInput(action.input, input);
2742
+ } catch {
2743
+ throw new MobileExtensionError("invalid_action_input", "action input is invalid", 400);
2744
+ }
2745
+ return Promise.resolve(parsed).then((value) => {
2746
+ signal.throwIfAborted();
2747
+ return action.run({
2748
+ ...context,
2749
+ signal
2750
+ }, value);
2751
+ }, () => {
2752
+ throw new MobileExtensionError("invalid_action_input", "action input is invalid", 400);
2753
+ });
2754
+ }, {
2755
+ external: external?.signal ?? context.signal,
2756
+ timeoutMs: action.timeoutMs ?? 3e4,
2757
+ timeoutError: () => new MobileExtensionError("extension_action_timeout", `extension ${id} action ${actionName} timed out`, 500),
2758
+ onDetach: () => {
2759
+ this.abandoned.set(id, (this.abandoned.get(id) ?? 0) + 1);
2760
+ }
2761
+ });
2762
+ this.trackOperation(id, operation);
2763
+ return await operation.caller;
2456
2764
  } catch (error) {
2765
+ if (error instanceof OperationCancelled) throw error.reason;
2457
2766
  if (error instanceof MobileExtensionError) throw error;
2458
2767
  throw new MobileExtensionError("extension_failed", "extension action failed", 500);
2459
2768
  } finally {
2460
- lifetime?.cleanup();
2769
+ operation?.release();
2770
+ external?.cleanup();
2461
2771
  }
2462
2772
  }
2463
2773
  /** Match one route and invoke it with a generation-bound abort signal. */
@@ -2469,25 +2779,47 @@ var MobileAccessService = class extends Service {
2469
2779
  return (candidate.kind ?? "exact") === "exact" ? candidate.path === pathname : pathname === candidate.path || pathname.startsWith(`${candidate.path}/`);
2470
2780
  });
2471
2781
  if (route === void 0) throw new MobileExtensionError("route_not_found", "route not found", 404);
2472
- const lifetime = "host" in extension ? combineSignalLifetime(extension.controller.signal, request.signal) : void 0;
2473
- let releaseLifetime = true;
2782
+ if ("host" in extension) extension.controller.signal.throwIfAborted();
2783
+ request.signal.throwIfAborted();
2784
+ this.requireAdmission(id);
2785
+ const external = "host" in extension ? combineSignalLifetime(extension.controller.signal, request.signal) : void 0;
2786
+ let operation;
2787
+ let holdForStream = false;
2474
2788
  try {
2475
- const routeRequest = lifetime === void 0 ? request : {
2789
+ operation = startBoundedOperation((signal) => route.handle({
2476
2790
  ...request,
2477
- signal: lifetime.signal
2478
- };
2479
- const result = await route.handle(routeRequest);
2791
+ signal
2792
+ }), {
2793
+ external: external?.signal ?? request.signal,
2794
+ timeoutMs: route.timeoutMs ?? 3e4,
2795
+ timeoutError: () => new MobileExtensionError("extension_route_timeout", `extension ${id} route ${method} ${pathname} timed out`, 500),
2796
+ onDetach: () => {
2797
+ this.abandoned.set(id, (this.abandoned.get(id) ?? 0) + 1);
2798
+ }
2799
+ });
2800
+ this.trackOperation(id, operation);
2801
+ operation.settled.then(() => {
2802
+ disposeLateExtensionResponse(operation?.lateValue());
2803
+ }).catch(() => void 0);
2804
+ const result = await operation.caller;
2480
2805
  if (result === null || typeof result !== "object" || typeof result.body !== "string" && !(result.body instanceof Uint8Array) && !isReadable(result.body)) throw new MobileExtensionError("invalid_route_response", "extension returned an invalid response", 500);
2481
- if (lifetime !== void 0 && isReadable(result.body)) {
2482
- releaseLifetime = false;
2483
- releaseSignalLifetimeWhenStreamSettles(result.body, lifetime.cleanup);
2806
+ if (isReadable(result.body)) {
2807
+ holdForStream = true;
2808
+ releaseSignalLifetimeWhenStreamSettles(result.body, () => {
2809
+ operation?.release();
2810
+ external?.cleanup();
2811
+ });
2484
2812
  }
2485
2813
  return result;
2486
2814
  } catch (error) {
2815
+ if (error instanceof OperationCancelled) throw error.reason;
2487
2816
  if (error instanceof MobileExtensionError) throw error;
2488
2817
  throw new MobileExtensionError("extension_failed", "extension route failed", 500);
2489
2818
  } finally {
2490
- if (releaseLifetime) lifetime?.cleanup();
2819
+ if (!holdForStream) {
2820
+ operation?.release();
2821
+ external?.cleanup();
2822
+ }
2491
2823
  }
2492
2824
  }
2493
2825
  /** Start the local directory watcher; an absent directory is intentionally inert. */
@@ -2658,6 +2990,16 @@ var MobileAccessService = class extends Service {
2658
2990
  function isReadable(value) {
2659
2991
  return value !== null && typeof value === "object" && typeof value.pipe === "function";
2660
2992
  }
2993
+ /** Destroy a route response that arrived after its caller detached; must never throw or emit unhandled stream errors. */
2994
+ function disposeLateExtensionResponse(value) {
2995
+ try {
2996
+ if (value === null || typeof value !== "object") return;
2997
+ const body = value.body;
2998
+ if (!isReadable(body) || typeof body.once !== "function" || typeof body.destroy !== "function") return;
2999
+ body.once("error", () => void 0);
3000
+ body.destroy();
3001
+ } catch {}
3002
+ }
2661
3003
  function releaseSignalLifetimeWhenStreamSettles(stream, cleanup) {
2662
3004
  let stopObserving;
2663
3005
  stopObserving = finished(stream, () => {
@@ -2680,6 +3022,77 @@ async function settleBounded(pending, timeoutMs) {
2680
3022
  })]);
2681
3023
  if (timer !== void 0) clearTimeout(timer);
2682
3024
  }
3025
+ /** Sentinel distinguishing caller detachment (deadline or external cancel) from a host error. */
3026
+ var OperationCancelled = class {
3027
+ reason;
3028
+ constructor(reason) {
3029
+ this.reason = reason;
3030
+ }
3031
+ };
3032
+ function startBoundedOperation(start, options) {
3033
+ const deadline = new AbortController();
3034
+ const combined = options.external === void 0 ? {
3035
+ signal: deadline.signal,
3036
+ cleanup: () => void 0
3037
+ } : combineSignalLifetime(options.external, deadline.signal);
3038
+ let detached = false;
3039
+ let workState;
3040
+ let lateFulfilled;
3041
+ let notifySettled;
3042
+ let callerTimer;
3043
+ let onExternal;
3044
+ const settled = new Promise((resolve) => {
3045
+ notifySettled = resolve;
3046
+ });
3047
+ const detach = (reject, reason) => {
3048
+ if (detached || workState !== void 0) return;
3049
+ detached = true;
3050
+ if (callerTimer !== void 0) clearTimeout(callerTimer);
3051
+ if (onExternal !== void 0) options.external?.removeEventListener("abort", onExternal);
3052
+ options.onDetach?.();
3053
+ deadline.abort(reason);
3054
+ reject(new OperationCancelled(reason));
3055
+ };
3056
+ const caller = new Promise((resolve, reject) => {
3057
+ callerTimer = setTimeout(() => detach(reject, options.timeoutError()), options.timeoutMs);
3058
+ onExternal = () => {
3059
+ detach(reject, combined.signal.reason);
3060
+ };
3061
+ options.external?.addEventListener("abort", onExternal, { once: true });
3062
+ (async () => {
3063
+ try {
3064
+ const value = await start(combined.signal);
3065
+ workState = "fulfilled";
3066
+ if (callerTimer !== void 0) clearTimeout(callerTimer);
3067
+ if (onExternal !== void 0) options.external?.removeEventListener("abort", onExternal);
3068
+ if (detached) combined.cleanup();
3069
+ if (detached) lateFulfilled = value;
3070
+ else resolve(value);
3071
+ notifySettled?.();
3072
+ } catch (error) {
3073
+ workState = "rejected";
3074
+ if (callerTimer !== void 0) clearTimeout(callerTimer);
3075
+ if (onExternal !== void 0) options.external?.removeEventListener("abort", onExternal);
3076
+ if (detached) combined.cleanup();
3077
+ if (!detached) reject(error);
3078
+ notifySettled?.();
3079
+ }
3080
+ })();
3081
+ });
3082
+ let released = false;
3083
+ return {
3084
+ caller,
3085
+ settled,
3086
+ detached: () => detached,
3087
+ lateValue: () => detached && workState === "fulfilled" ? lateFulfilled : void 0,
3088
+ release: () => {
3089
+ if (!released) {
3090
+ released = true;
3091
+ combined.cleanup();
3092
+ }
3093
+ }
3094
+ };
3095
+ }
2683
3096
  async function abortAndDisposeLocal(entries) {
2684
3097
  const pending = [];
2685
3098
  for (const entry of entries) {
@@ -2867,6 +3280,7 @@ function renderPairPage(locale) {
2867
3280
  <html lang="${copy.lang}">
2868
3281
  <meta charset="utf-8">
2869
3282
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
3283
+ <style>input,textarea{font-size:16px}</style>
2870
3284
  <title>${copy.pairTitle}</title>
2871
3285
  <main>
2872
3286
  <h1>${copy.pairHeading}</h1>
@@ -3160,32 +3574,60 @@ var ExcludedClientModulesError = class extends Error {
3160
3574
  super(`excludedClientModules: ${message}`);
3161
3575
  }
3162
3576
  };
3163
- /** Validate graph edges before removing application rows from both manifest views. */
3164
- function excludeMobileBootEntries(entries, batches, excludedIds) {
3165
- if (excludedIds.length === 0) return;
3166
- const excluded = new Set(excludedIds);
3167
- if (excluded.size !== excludedIds.length) throw new ExcludedClientModulesError("duplicate module ids");
3168
- const byId = new Map(entries.map((entry) => [entry.id, entry]));
3577
+ /** A graph-selection conflict with a bounded diagnostic safe for authenticated callers. */
3578
+ var ClientModuleConflictError = class extends HttpError {
3579
+ detail;
3580
+ constructor(detail) {
3581
+ super(409, "excluded_client_modules_invalid");
3582
+ this.detail = detail.replace(/[\u0000-\u001f\u007f]/gu, " ").replace(/(?:https?:\/\/|[a-z]:[\\/])\S+/giu, "[redacted]").slice(0, 512);
3583
+ }
3584
+ };
3585
+ function requiredClientModule(entry, phase) {
3586
+ return MOBILE_BOOT_CORE_MODULES.has(entry.id) || entry.immediately === true || phase !== "application";
3587
+ }
3588
+ function clientModuleDependencies(entry) {
3589
+ const dependencies = /* @__PURE__ */ new Set();
3590
+ for (const [field, edges] of [["inject", entry.inject], ["external", entry.external]]) {
3591
+ if (edges !== void 0 && (!Array.isArray(edges) || edges.some((id) => typeof id !== "string"))) throw new ExcludedClientModulesError(`upstream module ${entry.id} has malformed ${field} dependencies`);
3592
+ for (const dependency of edges ?? []) dependencies.add(dependency.endsWith("/client") ? dependency.slice(0, -7) : dependency);
3593
+ }
3594
+ return Object.freeze([...dependencies]);
3595
+ }
3596
+ function clientModulePhases(entries, batches) {
3169
3597
  const phases = /* @__PURE__ */ new Map();
3170
3598
  for (const batch of batches) for (const id of batch.entries) {
3171
3599
  if (phases.has(id)) throw new ExcludedClientModulesError(`upstream module ${id} belongs to multiple batches`);
3172
3600
  phases.set(id, batch.phase);
3173
3601
  }
3174
3602
  for (const entry of entries) if (!phases.has(entry.id)) throw new ExcludedClientModulesError(`upstream module ${entry.id} belongs to no batch`);
3603
+ return phases;
3604
+ }
3605
+ /** Read only installed module ids and declared edges from the same boot parser used for serving. */
3606
+ function describeMobileClientModules(html) {
3607
+ const plan = parseMobileBootPlan(html);
3608
+ const ids = new Set(plan.entries.map((entry) => entry.id));
3609
+ const phases = plan.batches === void 0 ? /* @__PURE__ */ new Map() : clientModulePhases(plan.entries, plan.batches);
3610
+ return Object.freeze(plan.entries.map((entry) => Object.freeze({
3611
+ id: entry.id,
3612
+ required: requiredClientModule(entry, phases.get(entry.id)),
3613
+ dependencies: Object.freeze(clientModuleDependencies(entry).filter((id) => ids.has(id)))
3614
+ })));
3615
+ }
3616
+ /** Validate graph edges before removing application rows from both manifest views. */
3617
+ function excludeMobileBootEntries(entries, batches, excludedIds) {
3618
+ if (excludedIds.length === 0) return;
3619
+ const excluded = new Set(excludedIds);
3620
+ if (excluded.size !== excludedIds.length) throw new ExcludedClientModulesError("duplicate module ids");
3621
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
3622
+ const phases = clientModulePhases(entries, batches);
3175
3623
  for (const id of excluded) {
3176
3624
  const entry = byId.get(id);
3177
3625
  if (entry === void 0) throw new ExcludedClientModulesError(`module ${id} is not installed in this DSH client graph`);
3178
- if (MOBILE_BOOT_CORE_MODULES.has(id) || entry.immediately === true || phases.get(id) !== "application") throw new ExcludedClientModulesError(`module ${id} is required for mobile boot or belongs to a bootstrap batch`);
3626
+ if (requiredClientModule(entry, phases.get(id))) throw new ExcludedClientModulesError(`module ${id} is required for mobile boot or belongs to a bootstrap batch`);
3179
3627
  }
3180
3628
  for (const entry of entries) {
3181
3629
  if (excluded.has(entry.id)) continue;
3182
- for (const [field, edges] of [["inject", entry.inject], ["external", entry.external]]) {
3183
- if (edges !== void 0 && (!Array.isArray(edges) || edges.some((id) => typeof id !== "string"))) throw new ExcludedClientModulesError(`upstream module ${entry.id} has malformed ${field} dependencies`);
3184
- for (const dependency of edges ?? []) {
3185
- const packageId = dependency.endsWith("/client") ? dependency.slice(0, -7) : dependency;
3186
- if (excluded.has(packageId)) throw new ExcludedClientModulesError(`module ${entry.id} still depends on excluded module ${packageId}; exclude the dependent too or keep ${packageId}`);
3187
- }
3188
- }
3630
+ for (const packageId of clientModuleDependencies(entry)) if (excluded.has(packageId)) throw new ExcludedClientModulesError(`module ${entry.id} still depends on excluded module ${packageId}; exclude the dependent too or keep ${packageId}`);
3189
3631
  }
3190
3632
  entries.splice(0, entries.length, ...entries.filter((entry) => !excluded.has(entry.id)));
3191
3633
  for (const batch of batches) batch.entries = batch.entries.filter((id) => !excluded.has(id));
@@ -3830,13 +4272,18 @@ var MobileAccessGateway = class {
3830
4272
  removeExtensionContentListener;
3831
4273
  renewLimiter;
3832
4274
  probeLimiter;
3833
- constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded) {
4275
+ clientModulePreferences;
4276
+ clientModuleOperations = /* @__PURE__ */ new Set();
4277
+ clientModuleTasks = /* @__PURE__ */ new Set();
4278
+ clientModuleRevocations = /* @__PURE__ */ new Set();
4279
+ constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences) {
3834
4280
  this.config = config;
3835
4281
  this.extensions = extensions;
3836
4282
  this.upstreamAuthenticatedUrl = upstreamAuthenticatedUrl;
3837
4283
  this.extraWebSocketPaths = extraWebSocketPaths;
3838
4284
  this.blockedUpgradeLog = blockedUpgradeLog;
3839
4285
  this.onDiscoveryDegraded = onDiscoveryDegraded;
4286
+ this.clientModulePreferences = clientModulePreferences ?? new ClientModulePreferenceStore(join(dirname(config.stateFile), `${basename(config.stateFile)}.client-modules.json`), config.excludedClientModules);
3840
4287
  this.listenerTlsEnabled = config.tls.mode === "provided";
3841
4288
  this.tlsEnabled = config.publicTls;
3842
4289
  this.access = new AccessController(store, {
@@ -3853,6 +4300,11 @@ var MobileAccessGateway = class {
3853
4300
  this.probeLimiter = new BoundedRateLimiter(Math.min(1e3, config.maxDevices * 4), config.rateLimitWindowMs, config.maxRateLimitKeys);
3854
4301
  this.removeSessionListener = this.access.onSessionEnded((authorization, reason) => {
3855
4302
  if (reason === "revoked") {
4303
+ const cleanup = this.clientModulePreferences.removeDevice(authorization.deviceId).catch(() => {
4304
+ process.emitWarning("Unable to remove revoked device client preferences", { code: "DSH_MOBILE_PREFERENCE_CLEANUP_FAILED" });
4305
+ });
4306
+ this.clientModuleRevocations.add(cleanup);
4307
+ cleanup.finally(() => this.clientModuleRevocations.delete(cleanup));
3856
4308
  if (!this.pendingDeviceRevocations.has(authorization.deviceId)) {
3857
4309
  this.pendingDeviceRevocations.add(authorization.deviceId);
3858
4310
  this.broadcastDeviceRevoked(authorization.deviceId);
@@ -3873,6 +4325,7 @@ var MobileAccessGateway = class {
3873
4325
  this.started = true;
3874
4326
  await this.access.initialize();
3875
4327
  try {
4328
+ await this.clientModulePreferences.initialize();
3876
4329
  if (this.config.pairingCaFile !== void 0) {
3877
4330
  const certificate = new X509Certificate(await readFile(this.config.pairingCaFile));
3878
4331
  const fingerprint = certificate.fingerprint256.replaceAll(":", "").toLowerCase();
@@ -4376,6 +4829,7 @@ var MobileAccessGateway = class {
4376
4829
  }
4377
4830
  const computerImages = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-images`;
4378
4831
  const computerImage = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-image`;
4832
+ const clientModules = target.decodedPathname === `${AUTH_PREFIX}/client-modules`;
4379
4833
  const requestedExtension = extensionTarget(target.decodedPathname);
4380
4834
  const requestedMobileBootBatch = mobileBootBatchKey(target.decodedPathname);
4381
4835
  const customAsset = request.method === "GET" ? target.decodedPathname === `/mobile-access/custom.css` ? {
@@ -4395,7 +4849,7 @@ var MobileAccessGateway = class {
4395
4849
  contentType: "text/javascript; charset=utf-8",
4396
4850
  fallback: void 0
4397
4851
  } : void 0 : void 0;
4398
- 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");
4852
+ if (customAsset === void 0 && requestedMobileBootBatch === void 0 && !computerImages && !computerImage && !clientModules && extensionTarget(target.decodedPathname) === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
4399
4853
  if (![
4400
4854
  "GET",
4401
4855
  "HEAD",
@@ -4424,6 +4878,44 @@ var MobileAccessGateway = class {
4424
4878
  throw error;
4425
4879
  }
4426
4880
  if (isMutation) this.requireCsrf(request, authorization);
4881
+ if (clientModules) {
4882
+ if (target.search !== "") throw new HttpError(400, "bad_request");
4883
+ if (request.method !== "GET" && request.method !== "POST") throw new HttpError(405, "method_not_allowed");
4884
+ const operation = this.allocateRequest(authorization, response, {});
4885
+ const onClose = () => {
4886
+ operation.abort();
4887
+ operation.release();
4888
+ };
4889
+ response.once("close", onClose);
4890
+ try {
4891
+ let kind = "describe";
4892
+ let excluded;
4893
+ if (request.method === "POST") {
4894
+ const body = await readJsonObject(request, this.config.maxBodyBytes);
4895
+ const keys = Object.keys(body);
4896
+ if (keys.length !== 1) throw new HttpError(400, "bad_request");
4897
+ if (keys[0] === "reset" && body.reset === true) kind = "reset";
4898
+ else if (keys[0] === "excludedClientModules") {
4899
+ kind = "configure";
4900
+ excluded = body.excludedClientModules;
4901
+ } else throw new HttpError(400, "bad_request");
4902
+ }
4903
+ const view = await this.clientModulePreferenceOperation(kind, excluded, authorization.deviceId, operation.signal);
4904
+ operation.signal.throwIfAborted();
4905
+ sendJson(response, 200, view, this.tlsEnabled);
4906
+ } catch (error) {
4907
+ if (operation.signal.aborted) return;
4908
+ if (error instanceof ClientModuleConflictError) sendJson(response, 409, {
4909
+ error: error.code,
4910
+ detail: error.detail
4911
+ }, this.tlsEnabled);
4912
+ else throw error;
4913
+ } finally {
4914
+ response.removeListener("close", onClose);
4915
+ operation.release();
4916
+ }
4917
+ return;
4918
+ }
4427
4919
  const extension = requestedExtension;
4428
4920
  if (extension !== void 0) {
4429
4921
  await this.handleExtensionRequest(extension, target, request, response, authorization);
@@ -4594,6 +5086,7 @@ var MobileAccessGateway = class {
4594
5086
  const operation = this.allocateRequest(authorization, response, {});
4595
5087
  const abort = new AbortController();
4596
5088
  response.once("close", () => {
5089
+ operation.release();
4597
5090
  abort.abort();
4598
5091
  });
4599
5092
  const generationSignal = extensions.signal(targetInfo.id, generation);
@@ -4639,6 +5132,7 @@ var MobileAccessGateway = class {
4639
5132
  const operation = this.allocateRequest(authorization, response, {});
4640
5133
  const abort = new AbortController();
4641
5134
  response.once("close", () => {
5135
+ operation.release();
4642
5136
  abort.abort();
4643
5137
  });
4644
5138
  const generationSignal = extensions.signal(targetInfo.id, generation);
@@ -4742,7 +5236,7 @@ var MobileAccessGateway = class {
4742
5236
  });
4743
5237
  this.upstreamAuthRequest = upstreamRequest;
4744
5238
  upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
4745
- upstreamRequest.destroy(/* @__PURE__ */ new Error("upstream timeout"));
5239
+ upstreamRequest.destroy(new HttpError(504, "upstream_timeout"));
4746
5240
  });
4747
5241
  upstreamRequest.once("response", resolve);
4748
5242
  upstreamRequest.once("error", reject);
@@ -4770,33 +5264,125 @@ var MobileAccessGateway = class {
4770
5264
  this.upstreamAuthRequest = void 0;
4771
5265
  }
4772
5266
  }
4773
- async proxyMobileIndex(request$2, response, authorization) {
4774
- const holder = {};
4775
- const operation = this.allocateRequest(authorization, response, holder);
5267
+ /** List installed modules and resolve the next-open selection.
5268
+ * @param deviceId - Authenticated device identity; omitted for computer defaults.
5269
+ * @param callerSignal - Cancels catalog fetching when the admin request closes.
5270
+ * @returns The installed catalog and effective module selection.
5271
+ */
5272
+ describeClientModules(deviceId, callerSignal) {
5273
+ return this.clientModulePreferenceOperation("describe", void 0, deviceId, callerSignal);
5274
+ }
5275
+ /** Persist a validated device override or computer default.
5276
+ * @param excluded - Untrusted module IDs to validate against the current boot graph.
5277
+ * @param deviceId - Authenticated device identity; omitted for computer defaults.
5278
+ * @param callerSignal - Cancels fetching and validation, not an atomic write already started.
5279
+ * @returns The catalog and saved selection without reloading clients.
5280
+ */
5281
+ configureClientModules(excluded, deviceId, callerSignal) {
5282
+ return this.clientModulePreferenceOperation("configure", excluded, deviceId, callerSignal);
5283
+ }
5284
+ /** Reset one override without reloading connected clients.
5285
+ * @param deviceId - Authenticated device identity; omitted to reset computer defaults.
5286
+ * @param callerSignal - Cancels fetching and validation, not an atomic write already started.
5287
+ * @returns The catalog and inherited selection.
5288
+ */
5289
+ resetClientModules(deviceId, callerSignal) {
5290
+ return this.clientModulePreferenceOperation("reset", void 0, deviceId, callerSignal);
5291
+ }
5292
+ async clientModulePreferenceOperation(kind, excluded, deviceId, callerSignal) {
5293
+ if (this.closing || !this.started) throw new HttpError(503, "unavailable");
5294
+ const controller = new AbortController();
5295
+ const abort = () => controller.abort();
5296
+ if (callerSignal?.aborted === true) controller.abort();
5297
+ callerSignal?.addEventListener("abort", abort, { once: true });
5298
+ this.clientModuleOperations.add(controller);
5299
+ const task = (async () => {
5300
+ let html = "";
5301
+ const validate = async (selection) => {
5302
+ html = (await this.fetchAuthenticatedUpstreamIndex(controller.signal)).html;
5303
+ try {
5304
+ parseMobileBootPlan(html, selection);
5305
+ } catch (error) {
5306
+ if (error instanceof ExcludedClientModulesError) throw new ClientModuleConflictError(error.message);
5307
+ throw new HttpError(502, "upstream_unavailable");
5308
+ }
5309
+ controller.signal.throwIfAborted();
5310
+ };
5311
+ let selection;
5312
+ if (kind === "configure") {
5313
+ let ids;
5314
+ try {
5315
+ ids = parseExcludedClientModules(excluded);
5316
+ } catch {
5317
+ throw new HttpError(400, "excluded_client_modules_invalid");
5318
+ }
5319
+ selection = await this.clientModulePreferences.configure(ids, deviceId, validate);
5320
+ } else if (kind === "reset") selection = await this.clientModulePreferences.reset(deviceId, validate);
5321
+ else {
5322
+ html = (await this.fetchAuthenticatedUpstreamIndex(controller.signal)).html;
5323
+ selection = await this.clientModulePreferences.read(deviceId);
5324
+ }
5325
+ controller.signal.throwIfAborted();
5326
+ try {
5327
+ const plan = parseMobileBootPlan(html);
5328
+ return Object.freeze({
5329
+ entries: describeMobileClientModules(html),
5330
+ ...selection,
5331
+ ...plan.batches === void 0 ? {
5332
+ excludedClientModules: Object.freeze([]),
5333
+ defaultExcludedClientModules: Object.freeze([])
5334
+ } : {},
5335
+ reloadRequired: true
5336
+ });
5337
+ } catch (error) {
5338
+ if (error instanceof HttpError) throw error;
5339
+ throw new HttpError(502, "upstream_unavailable");
5340
+ }
5341
+ })();
5342
+ this.clientModuleTasks.add(task);
4776
5343
  try {
4777
- const upstreamHeaders = sanitizeRequestHeaders(request$2, this.config.upstreamOrigin);
4778
- const upstreamCookie = await this.upstreamCookieHeader();
4779
- if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
4780
- upstreamHeaders["accept-encoding"] = "identity";
5344
+ return await task;
5345
+ } finally {
5346
+ callerSignal?.removeEventListener("abort", abort);
5347
+ this.clientModuleOperations.delete(controller);
5348
+ this.clientModuleTasks.delete(task);
5349
+ }
5350
+ }
5351
+ async fetchAuthenticatedUpstreamIndex(signal, incoming) {
5352
+ signal.throwIfAborted();
5353
+ let upstreamRequest;
5354
+ const abort = () => {
5355
+ upstreamRequest?.destroy(requestAbortedError());
5356
+ };
5357
+ signal.addEventListener("abort", abort, { once: true });
5358
+ try {
5359
+ const headers = incoming === void 0 ? {
5360
+ host: this.config.upstreamOrigin.host,
5361
+ accept: "text/html",
5362
+ "accept-encoding": "identity"
5363
+ } : sanitizeRequestHeaders(incoming, this.config.upstreamOrigin);
5364
+ const upstreamCookie = await waitForRequestTask(this.upstreamCookieHeader(), signal);
5365
+ signal.throwIfAborted();
5366
+ if (upstreamCookie !== void 0) headers.cookie = upstreamCookie;
5367
+ headers["accept-encoding"] = "identity";
4781
5368
  const proxied = await new Promise((resolve, reject) => {
4782
- const upstreamRequest = request({
5369
+ upstreamRequest = request({
4783
5370
  protocol: "http:",
4784
5371
  hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
4785
5372
  port: Number(this.config.upstreamOrigin.port),
4786
5373
  method: "GET",
4787
5374
  path: "/",
4788
- headers: upstreamHeaders,
5375
+ headers,
4789
5376
  agent: false
4790
5377
  });
4791
- holder.request = upstreamRequest;
4792
5378
  upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
4793
- upstreamRequest.destroy(/* @__PURE__ */ new Error("upstream timeout"));
5379
+ upstreamRequest?.destroy(new HttpError(504, "upstream_timeout"));
4794
5380
  });
4795
5381
  upstreamRequest.once("response", resolve);
4796
5382
  upstreamRequest.once("error", reject);
4797
5383
  upstreamRequest.end();
4798
5384
  });
4799
- if ((proxied.statusCode ?? 502) !== 200) throw new HttpError(502, "upstream_unavailable");
5385
+ if (proxied.statusCode !== 200) throw new HttpError(502, "upstream_unavailable");
4800
5386
  const chunks = [];
4801
5387
  let bytes = 0;
4802
5388
  for await (const chunk of proxied) {
@@ -4805,15 +5391,40 @@ var MobileAccessGateway = class {
4805
5391
  if (bytes > 4194304) throw new HttpError(502, "upstream_unavailable");
4806
5392
  chunks.push(buffer);
4807
5393
  }
5394
+ signal.throwIfAborted();
5395
+ return {
5396
+ html: Buffer.concat(chunks).toString("utf8"),
5397
+ headers: proxied.headers
5398
+ };
5399
+ } catch (error) {
5400
+ if (signal.aborted || error instanceof HttpError) throw error;
5401
+ throw new HttpError(502, "upstream_unavailable");
5402
+ } finally {
5403
+ signal.removeEventListener("abort", abort);
5404
+ upstreamRequest?.destroy();
5405
+ }
5406
+ }
5407
+ async proxyMobileIndex(request, response, authorization) {
5408
+ const holder = {};
5409
+ const operation = this.allocateRequest(authorization, response, holder);
5410
+ const onClose = () => {
5411
+ operation.abort();
5412
+ operation.release();
5413
+ };
5414
+ response.once("close", onClose);
5415
+ try {
5416
+ const index = await this.fetchAuthenticatedUpstreamIndex(operation.signal, request);
5417
+ const preference = await this.clientModulePreferences.read(authorization.deviceId);
5418
+ const excludedClientModules = preference.excludedClientModules;
4808
5419
  let body;
4809
5420
  try {
4810
- const html = Buffer.concat(chunks).toString("utf8");
4811
- const plan = parseMobileBootPlan(html, this.config.excludedClientModules);
4812
- const options = plan.rewrittenBatchEntries === void 0 ? { excludedClientModules: this.config.excludedClientModules } : {
4813
- ...await this.resolveMobileBootSizes(plan.rewrittenBatchEntries.flatMap((batch) => batch.entries)),
4814
- excludedClientModules: this.config.excludedClientModules
5421
+ const html = index.html;
5422
+ const plan = parseMobileBootPlan(html, excludedClientModules);
5423
+ const options = plan.rewrittenBatchEntries === void 0 ? { excludedClientModules } : {
5424
+ ...await this.resolveMobileBootSizes(plan.rewrittenBatchEntries.flatMap((batch) => batch.entries), operation.signal),
5425
+ excludedClientModules
4815
5426
  };
4816
- const cookies = browserAuthCookies(request$2.headers.cookie);
5427
+ const cookies = browserAuthCookies(request.headers.cookie);
4817
5428
  const csrfCookie = this.tlsEnabled && cookies.has("__Host-dsh_ma_session") ? HOST_CSRF_COOKIE : CSRF_COOKIE;
4818
5429
  const rewritten = rewriteMobileIndexWithBatch(html, {
4819
5430
  ...options,
@@ -4828,6 +5439,18 @@ var MobileAccessGateway = class {
4828
5439
  this.lastModuleExclusionError = detail;
4829
5440
  process.emitWarning(detail, { code: "DSH_MOBILE_MODULE_EXCLUSION_INVALID" });
4830
5441
  }
5442
+ if (preference.source === "device" || preference.source === "computer") {
5443
+ operation.signal.throwIfAborted();
5444
+ const locale = resolveAuthPageLocale(headerValue(request.headers, "accept-language"));
5445
+ const recovery = Buffer.from(renderClientModuleRecoveryPage(locale));
5446
+ setSecurityHeaders(response, this.tlsEnabled);
5447
+ response.writeHead(200, {
5448
+ "Content-Type": "text/html; charset=utf-8",
5449
+ "Content-Length": recovery.byteLength
5450
+ });
5451
+ response.end(recovery);
5452
+ return;
5453
+ }
4831
5454
  sendJson(response, 409, {
4832
5455
  error: "excluded_client_modules_invalid",
4833
5456
  detail
@@ -4836,7 +5459,8 @@ var MobileAccessGateway = class {
4836
5459
  }
4837
5460
  throw new HttpError(502, "upstream_unavailable");
4838
5461
  }
4839
- const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
5462
+ operation.signal.throwIfAborted();
5463
+ const headers = sanitizeResponseHeaders(index.headers, this.config.upstreamOrigin);
4840
5464
  delete headers["content-length"];
4841
5465
  delete headers["content-encoding"];
4842
5466
  delete headers.etag;
@@ -4853,6 +5477,7 @@ var MobileAccessGateway = class {
4853
5477
  if (response.headersSent) response.destroy();
4854
5478
  else throw new HttpError(502, "upstream_unavailable");
4855
5479
  } finally {
5480
+ response.removeListener("close", onClose);
4856
5481
  operation.release();
4857
5482
  }
4858
5483
  }
@@ -4873,16 +5498,17 @@ var MobileAccessGateway = class {
4873
5498
  * could not be probed, keeps its own upstream `/plugins` row: a merged batch
4874
5499
  * cannot carry it, and an unknown bundle must not risk the whole batch.
4875
5500
  */
4876
- async resolveMobileBootSizes(planEntries) {
5501
+ async resolveMobileBootSizes(planEntries, signal) {
4877
5502
  const sizes = /* @__PURE__ */ new Map();
4878
5503
  const passThrough = /* @__PURE__ */ new Set();
4879
5504
  const candidates = planEntries.filter((entry) => entry.id !== MOBILE_LAYOUT_MODULE && upstreamPluginBundleUrl(entry.url, this.config.upstreamOrigin) !== void 0);
4880
5505
  let cursor = 0;
4881
5506
  const worker = async () => {
4882
5507
  while (cursor < candidates.length) {
5508
+ signal?.throwIfAborted();
4883
5509
  const index = cursor++;
4884
5510
  const source = candidates[index].url;
4885
- const size = await this.upstreamBundleSize(source);
5511
+ const size = await this.upstreamBundleSize(source, signal);
4886
5512
  if (size === void 0 || size >= MAX_MOBILE_BOOT_ENTRY_BYTES) passThrough.add(source);
4887
5513
  else sizes.set(source, size);
4888
5514
  }
@@ -4899,14 +5525,20 @@ var MobileAccessGateway = class {
4899
5525
  * counts bytes; it aborts instantly past the per-entry cap. Measurements are
4900
5526
  * cached for a short window.
4901
5527
  */
4902
- async upstreamBundleSize(source) {
5528
+ async upstreamBundleSize(source, signal) {
5529
+ signal?.throwIfAborted();
4903
5530
  const target = upstreamPluginBundleUrl(source, this.config.upstreamOrigin);
4904
5531
  if (target === void 0) return void 0;
4905
5532
  const cached = this.bootEntrySizeCache.get(source);
4906
5533
  if (cached !== void 0 && Date.now() - cached.at < MOBILE_BOOT_SIZE_CACHE_TTL_MS) return cached.size;
4907
- const upstreamCookie = await this.upstreamCookieHeader();
4908
5534
  let upstreamRequest;
5535
+ const abort = () => {
5536
+ upstreamRequest?.destroy(requestAbortedError());
5537
+ };
5538
+ signal?.addEventListener("abort", abort, { once: true });
4909
5539
  try {
5540
+ const upstreamCookie = signal === void 0 ? await this.upstreamCookieHeader() : await waitForRequestTask(this.upstreamCookieHeader(), signal);
5541
+ signal?.throwIfAborted();
4910
5542
  const proxied = await new Promise((resolve, reject) => {
4911
5543
  upstreamRequest = request({
4912
5544
  protocol: "http:",
@@ -4952,9 +5584,11 @@ var MobileAccessGateway = class {
4952
5584
  at: Date.now()
4953
5585
  });
4954
5586
  return size;
4955
- } catch {
5587
+ } catch (error) {
5588
+ if (signal?.aborted === true) throw error;
4956
5589
  return;
4957
5590
  } finally {
5591
+ signal?.removeEventListener("abort", abort);
4958
5592
  upstreamRequest?.destroy();
4959
5593
  }
4960
5594
  }
@@ -5121,6 +5755,7 @@ var MobileAccessGateway = class {
5121
5755
  }
5122
5756
  }
5123
5757
  allocateRequest(authorization, response, upstream) {
5758
+ if (this.closing || response.destroyed || response.writableEnded) throw requestAbortedError();
5124
5759
  if (this.activeRequests.size >= this.config.maxActiveRequests) throw new HttpError(429, "busy");
5125
5760
  const id = this.nextOperationId++;
5126
5761
  const controller = new AbortController();
@@ -5148,72 +5783,88 @@ var MobileAccessGateway = class {
5148
5783
  };
5149
5784
  }
5150
5785
  /**
5151
- * Proxy one request upstream. Pass-through client bundles (`GET /plugins`)
5152
- * receive bounded transient retries — the upstream resets a fraction of fresh
5153
- * connections — matching the resilience the merged-batch assembly already has.
5154
- * A request is only retried before any byte reached the client.
5786
+ * Proxy one request, retrying transient failures for plugin and asset GETs.
5787
+ * Only bodyless GETs can be replayed, before response headers reach the client.
5788
+ * One request slot and cancellation signal cover attempts and backoff together.
5789
+ * API requests and requests with bodies are forwarded once.
5155
5790
  */
5156
5791
  async proxyHttp(request, response, authorization) {
5157
- if (!(request.method === "GET" && request.url?.split("?", 1)[0]?.startsWith("/plugins/") === true)) {
5158
- try {
5159
- await this.proxyHttpOnce(request, response, authorization);
5160
- } catch (error) {
5161
- if (error instanceof HttpError) throw error;
5162
- if (response.headersSent) response.destroy();
5163
- else throw new HttpError(502, "upstream_unavailable");
5164
- }
5165
- return;
5166
- }
5167
- const delay = (attempt) => new Promise((resolve) => setTimeout(resolve, MOBILE_BOOT_RETRY_DELAY_MS * attempt));
5168
- for (let attempt = 1; attempt <= MOBILE_BOOT_UPSTREAM_ATTEMPTS; attempt++) try {
5169
- await this.proxyHttpOnce(request, response, authorization);
5170
- return;
5171
- } catch (error) {
5172
- if (response.headersSent) {
5173
- response.destroy();
5174
- return;
5175
- }
5176
- if (error instanceof HttpError || !isTransientUpstreamError(error)) throw error instanceof HttpError ? error : new HttpError(502, "upstream_unavailable");
5177
- if (attempt === MOBILE_BOOT_UPSTREAM_ATTEMPTS) throw new HttpError(502, "upstream_unavailable");
5178
- await delay(attempt);
5179
- }
5180
- throw new HttpError(502, "upstream_unavailable");
5181
- }
5182
- async proxyHttpOnce(request$3, response, authorization) {
5183
- const declared = request$3.headers["content-length"];
5792
+ const declared = request.headers["content-length"];
5184
5793
  if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > this.config.maxBodyBytes)) throw new HttpError(413, "payload_too_large");
5794
+ const replayable = request.method === "GET" && request.headers["transfer-encoding"] === void 0 && (declared === void 0 || Number(declared) === 0) && (request.url?.split("?", 1)[0]?.startsWith("/plugins/") === true || request.url?.split("?", 1)[0]?.startsWith("/assets/") === true);
5185
5795
  const holder = {};
5186
5796
  const operation = this.allocateRequest(authorization, response, holder);
5187
- let bodyDone;
5797
+ const onClose = () => {
5798
+ operation.release();
5799
+ operation.abort();
5800
+ };
5801
+ response.once("close", onClose);
5802
+ if (replayable) request.resume();
5188
5803
  try {
5189
- const upstreamHeaders = sanitizeRequestHeaders(request$3, this.config.upstreamOrigin);
5190
- const upstreamCookie = await this.upstreamCookieHeader();
5191
- if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
5804
+ const attempts = replayable ? MOBILE_BOOT_UPSTREAM_ATTEMPTS : 1;
5805
+ for (let attempt = 1; attempt <= attempts; attempt++) {
5806
+ operation.signal.throwIfAborted();
5807
+ try {
5808
+ await this.proxyHttpOnce(request, response, holder, operation.signal, replayable);
5809
+ return;
5810
+ } catch (error) {
5811
+ if (operation.signal.aborted || response.destroyed) return;
5812
+ if (response.headersSent) {
5813
+ response.destroy();
5814
+ return;
5815
+ }
5816
+ if (error instanceof HttpError || !isTransientUpstreamError(error)) throw error instanceof HttpError ? error : new HttpError(502, "upstream_unavailable");
5817
+ if (attempt === attempts) throw new HttpError(502, "upstream_unavailable");
5818
+ await waitForAbortableDelay(MOBILE_BOOT_RETRY_DELAY_MS * attempt, operation.signal);
5819
+ }
5820
+ }
5821
+ } catch (error) {
5822
+ if (!operation.signal.aborted) throw error;
5823
+ } finally {
5824
+ response.removeListener("close", onClose);
5825
+ operation.release();
5826
+ }
5827
+ }
5828
+ async proxyHttpOnce(request$2, response, holder, signal, replayable) {
5829
+ let bodyDone;
5830
+ try {
5831
+ const upstreamHeaders = sanitizeRequestHeaders(request$2, this.config.upstreamOrigin);
5832
+ if (request$2.headers["transfer-encoding"] !== void 0) upstreamHeaders["transfer-encoding"] = request$2.headers["transfer-encoding"];
5833
+ const upstreamCookie = await this.upstreamCookieHeader();
5834
+ signal.throwIfAborted();
5835
+ if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
5192
5836
  const proxied = await new Promise((resolve, reject) => {
5193
5837
  const upstreamRequest = request({
5194
5838
  protocol: "http:",
5195
5839
  hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
5196
5840
  port: Number(this.config.upstreamOrigin.port),
5197
- method: request$3.method,
5198
- path: request$3.url,
5841
+ method: request$2.method,
5842
+ path: request$2.url,
5199
5843
  headers: upstreamHeaders,
5200
5844
  agent: false
5201
5845
  });
5202
5846
  holder.request = upstreamRequest;
5203
5847
  upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
5204
- upstreamRequest.destroy(/* @__PURE__ */ new Error("upstream timeout"));
5848
+ upstreamRequest.destroy(new HttpError(504, "upstream_timeout"));
5849
+ });
5850
+ const pathname = parseRequestTarget(request$2.url).decodedPathname;
5851
+ if (pathname === "/api" || pathname.startsWith("/api/")) upstreamRequest.once("finish", () => {
5852
+ upstreamRequest.setTimeout(this.config.upstreamApiTimeoutMs);
5205
5853
  });
5206
5854
  upstreamRequest.once("response", resolve);
5207
5855
  upstreamRequest.once("error", reject);
5208
- bodyDone = pipeline(request$3, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
5209
- bodyDone.catch(reject);
5856
+ if (replayable) upstreamRequest.end();
5857
+ else {
5858
+ bodyDone = pipeline(request$2, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
5859
+ bodyDone.catch(reject);
5860
+ }
5210
5861
  });
5211
5862
  setSecurityHeaders(response, this.tlsEnabled, "proxied");
5212
5863
  const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
5213
5864
  const statusCode = proxied.statusCode ?? 502;
5214
- const cacheControl = revisionedStaticCacheControl(request$3, statusCode);
5865
+ const cacheControl = revisionedStaticCacheControl(request$2, statusCode);
5215
5866
  if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
5216
- const compressed = shouldCompressResponse(request$3, proxied);
5867
+ const compressed = shouldCompressResponse(request$2, proxied);
5217
5868
  if (compressed) {
5218
5869
  delete headers["accept-ranges"];
5219
5870
  delete headers["content-length"];
@@ -5230,7 +5881,7 @@ var MobileAccessGateway = class {
5230
5881
  if (response.headersSent) response.destroy();
5231
5882
  throw error;
5232
5883
  } finally {
5233
- operation.release();
5884
+ delete holder.request;
5234
5885
  }
5235
5886
  }
5236
5887
  abortSessionResources(sessionKey) {
@@ -5579,12 +6230,19 @@ var MobileAccessGateway = class {
5579
6230
  const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5580
6231
  if (typeof body.deviceId !== "string" || !/^[a-f\d]{32}$/u.test(body.deviceId)) throw new HttpError(400, "bad_request");
5581
6232
  if (!await this.access.revokeDevice(body.deviceId)) throw new HttpError(404, "not_found");
6233
+ await this.clientModulePreferences.removeDevice(body.deviceId).catch(() => {
6234
+ process.emitWarning("Unable to remove revoked device client preferences", { code: "DSH_MOBILE_PREFERENCE_CLEANUP_FAILED" });
6235
+ });
5582
6236
  sendJson(response, 200, { revoked: true }, false);
5583
6237
  return;
5584
6238
  }
5585
6239
  if (request.method === "POST" && target.decodedPathname === `${prefix}/devices/reset`) {
5586
6240
  if ((await readJsonObject(request, MAX_CONTROL_BODY_BYTES)).confirm !== true) throw new HttpError(400, "bad_request");
6241
+ const removed = this.access.listDevices().map((device) => device.id);
5587
6242
  await this.access.resetDevices();
6243
+ await this.clientModulePreferences.removeDevices(removed).catch(() => {
6244
+ process.emitWarning("Unable to remove revoked device client preferences", { code: "DSH_MOBILE_PREFERENCE_CLEANUP_FAILED" });
6245
+ });
5588
6246
  sendJson(response, 200, { reset: true }, false);
5589
6247
  return;
5590
6248
  }
@@ -5605,6 +6263,7 @@ var MobileAccessGateway = class {
5605
6263
  }
5606
6264
  async performClose() {
5607
6265
  this.closing = true;
6266
+ for (const controller of this.clientModuleOperations) controller.abort();
5608
6267
  if (this.extensionChangeTimer !== void 0) clearInterval(this.extensionChangeTimer);
5609
6268
  this.extensionChangeTimer = void 0;
5610
6269
  this.removeExtensionContentListener();
@@ -5637,6 +6296,8 @@ var MobileAccessGateway = class {
5637
6296
  });
5638
6297
  }
5639
6298
  await accessClose;
6299
+ await Promise.allSettled(this.clientModuleTasks);
6300
+ await Promise.allSettled(this.clientModuleRevocations);
5640
6301
  this.activeRequests.clear();
5641
6302
  this.activeWebSockets.clear();
5642
6303
  this.connectedSockets.clear();
@@ -5777,8 +6438,8 @@ var MemoryDeviceStore = class {
5777
6438
  //#endregion
5778
6439
  //#region src/frp-component.ts
5779
6440
  const FRP_VERSION$1 = "0.70.1";
5780
- const MAX_ARCHIVE_ENTRIES = 128;
5781
- const MAX_ARCHIVE_LIST_BYTES = 262144;
6441
+ const MAX_ARCHIVE_ENTRIES$1 = 128;
6442
+ const MAX_ARCHIVE_LIST_BYTES$1 = 262144;
5782
6443
  /** Pinned official FRP release metadata for supported desktop targets. */
5783
6444
  const FRP_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
5784
6445
  {
@@ -5883,12 +6544,12 @@ async function replaceDirectory(target, candidate) {
5883
6544
  function sha256$2(bytes) {
5884
6545
  return createHash("sha256").update(bytes).digest("hex");
5885
6546
  }
5886
- async function runCapture(file, args) {
6547
+ async function runCapture$1(file, args) {
5887
6548
  return new Promise((resolveRun, reject) => {
5888
6549
  execFile(file, [...args], {
5889
6550
  windowsHide: true,
5890
6551
  timeout: 12e4,
5891
- maxBuffer: MAX_ARCHIVE_LIST_BYTES,
6552
+ maxBuffer: MAX_ARCHIVE_LIST_BYTES$1,
5892
6553
  encoding: "utf8"
5893
6554
  }, (error, stdout) => {
5894
6555
  if (error === null) resolveRun(stdout);
@@ -5896,7 +6557,7 @@ async function runCapture(file, args) {
5896
6557
  });
5897
6558
  });
5898
6559
  }
5899
- function validatedArchiveEntry(rawEntry) {
6560
+ function validatedArchiveEntry$1(rawEntry) {
5900
6561
  if (rawEntry.length === 0 || rawEntry.includes("\\") || rawEntry.includes("\0") || rawEntry.startsWith("/") || /^[a-zA-Z]:/u.test(rawEntry)) throw new Error("frp_archive_path_invalid");
5901
6562
  const segments = rawEntry.replace(/\/$/u, "").split("/");
5902
6563
  if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) throw new Error("frp_archive_path_invalid");
@@ -5904,10 +6565,10 @@ function validatedArchiveEntry(rawEntry) {
5904
6565
  }
5905
6566
  /** Select exactly one nested frpc executable from a safe archive listing. */
5906
6567
  function selectFrpExecutableEntry(entries, executableName) {
5907
- if (entries.length === 0 || entries.length > MAX_ARCHIVE_ENTRIES) throw new Error("frp_archive_entries_invalid");
6568
+ if (entries.length === 0 || entries.length > MAX_ARCHIVE_ENTRIES$1) throw new Error("frp_archive_entries_invalid");
5908
6569
  let executableEntry;
5909
6570
  for (const entry of entries) {
5910
- const segments = validatedArchiveEntry(entry);
6571
+ const segments = validatedArchiveEntry$1(entry);
5911
6572
  if (segments.length >= 2 && segments.at(-1) === executableName) {
5912
6573
  if (executableEntry !== void 0) throw new Error("frp_archive_executable_ambiguous");
5913
6574
  executableEntry = entry.replace(/\/$/u, "");
@@ -5916,22 +6577,22 @@ function selectFrpExecutableEntry(entries, executableName) {
5916
6577
  if (executableEntry === void 0) throw new Error("frp_archive_executable_missing");
5917
6578
  return executableEntry;
5918
6579
  }
5919
- async function defaultExtractArtifact$1(archive, destination, executableName) {
6580
+ async function defaultExtractArtifact$2(archive, destination, executableName) {
5920
6581
  const tar = process.platform === "win32" ? "tar.exe" : "tar";
5921
- const executableEntry = selectFrpExecutableEntry((await runCapture(tar, ["-tf", archive])).split(/\r?\n/u).filter((entry) => entry.length > 0), executableName);
6582
+ const executableEntry = selectFrpExecutableEntry((await runCapture$1(tar, ["-tf", archive])).split(/\r?\n/u).filter((entry) => entry.length > 0), executableName);
5922
6583
  const unpacked = join(destination, "archive");
5923
6584
  await mkdir(unpacked, {
5924
6585
  recursive: true,
5925
6586
  mode: 448
5926
6587
  });
5927
- await runCapture(tar, [
6588
+ await runCapture$1(tar, [
5928
6589
  "-xf",
5929
6590
  archive,
5930
6591
  "-C",
5931
6592
  unpacked,
5932
6593
  executableEntry
5933
6594
  ]);
5934
- const extracted = join(unpacked, ...validatedArchiveEntry(executableEntry));
6595
+ const extracted = join(unpacked, ...validatedArchiveEntry$1(executableEntry));
5935
6596
  if (!await regularFile$2(extracted)) throw new Error("frp_archive_executable_invalid");
5936
6597
  await copyFile(extracted, join(destination, executableName));
5937
6598
  }
@@ -5971,7 +6632,7 @@ async function defaultFetchArtifact$2(artifact, signal) {
5971
6632
  return bytes;
5972
6633
  }
5973
6634
  async function defaultInspectExecutable(executable) {
5974
- return (await runCapture(executable, ["--version"])).trim();
6635
+ return (await runCapture$1(executable, ["--version"])).trim();
5975
6636
  }
5976
6637
  /** Owns the optional official frpc binary inside the DSH Mobile state directory. */
5977
6638
  var FrpComponentManager = class {
@@ -6006,7 +6667,7 @@ var FrpComponentManager = class {
6006
6667
  this.stagingRoot
6007
6668
  ]) if (!inside$2(stateDirectory, child)) throw new Error("frp component path escaped its state directory");
6008
6669
  this.fetchArtifact = options.fetchArtifact ?? defaultFetchArtifact$2;
6009
- this.extractArtifact = options.extractArtifact ?? defaultExtractArtifact$1;
6670
+ this.extractArtifact = options.extractArtifact ?? defaultExtractArtifact$2;
6010
6671
  this.inspectExecutable = options.inspectExecutable ?? defaultInspectExecutable;
6011
6672
  }
6012
6673
  /** Inspect the managed executable without relying on global FRP installations. */
@@ -6345,6 +7006,14 @@ function resolveFrpVhostHttpPort(settings) {
6345
7006
  function resolveFrpPublicPort(settings) {
6346
7007
  return settings.publicPort ?? 33080;
6347
7008
  }
7009
+ /** Public HTTPS endpoint the user opens, independent of the frps control address. */
7010
+ function resolveFrpEntryProbe(settings) {
7011
+ const origin = new URL(frpEntryOrigin(settings));
7012
+ return {
7013
+ host: origin.hostname,
7014
+ port: origin.port === "" ? 443 : Number(origin.port)
7015
+ };
7016
+ }
6348
7017
  /** HTTPS origin users actually open; the self-signed TCP entry is not on 443. */
6349
7018
  function frpEntryOrigin(settings) {
6350
7019
  if (!isFrpSelfSignedIngress(settings)) return settings.publicOrigin;
@@ -7127,7 +7796,8 @@ function parseManagedSetup(value) {
7127
7796
  "networkInterface",
7128
7797
  "listenPort",
7129
7798
  "upstreamOrigin",
7130
- "tls"
7799
+ "tls",
7800
+ "extraAllowedCidrs"
7131
7801
  ].includes(key))) throw new Error("mobile setup file has an unsupported format");
7132
7802
  if (!Number.isSafeInteger(record.listenPort) || record.listenPort < 1024 || record.listenPort > 65535) throw new Error("mobile setup listenPort must be from 1024 through 65535");
7133
7803
  if (typeof record.tls !== "object" || record.tls === null || Array.isArray(record.tls)) throw new Error("mobile setup tls must be an object");
@@ -7144,6 +7814,7 @@ function parseManagedSetup(value) {
7144
7814
  networkInterface: requiredString(record.networkInterface, "mobile setup networkInterface"),
7145
7815
  listenPort: record.listenPort,
7146
7816
  upstreamOrigin: requiredString(record.upstreamOrigin, "mobile setup upstreamOrigin"),
7817
+ ...record.extraAllowedCidrs === void 0 ? {} : { extraAllowedCidrs: parseAdditionalTrustedNetworks(record.extraAllowedCidrs) },
7147
7818
  tls: Object.freeze({
7148
7819
  mode: "managed",
7149
7820
  caCertFile: requiredString(tls.caCertFile, "mobile setup tls.caCertFile"),
@@ -7153,6 +7824,43 @@ function parseManagedSetup(value) {
7153
7824
  })
7154
7825
  });
7155
7826
  }
7827
+ /** Validate explicitly trusted networks without adding implicit VPN or private ranges.
7828
+ * @param value - CIDR strings submitted by the computer administrator.
7829
+ * @returns Deduplicated validated CIDRs in input order.
7830
+ */
7831
+ function parseAdditionalTrustedNetworks(value) {
7832
+ if (!Array.isArray(value)) throw new Error("extraAllowedCidrs must be an array of CIDR strings");
7833
+ const sources = value.map((entry) => {
7834
+ if (typeof entry !== "string" || entry.trim() === "") throw new Error("extraAllowedCidrs must be an array of CIDR strings");
7835
+ return entry.trim();
7836
+ });
7837
+ const entries = [];
7838
+ const seen = /* @__PURE__ */ new Set();
7839
+ for (const cidr of sources) {
7840
+ const parsed = parseCidr(cidr);
7841
+ const key = `${parsed.bits}:${parsed.network}:${parsed.prefix}`;
7842
+ if (seen.has(key)) continue;
7843
+ seen.add(key);
7844
+ entries.push(cidr);
7845
+ }
7846
+ return Object.freeze(entries);
7847
+ }
7848
+ /** Save extra trusted networks without replacing the interface or pairing identity.
7849
+ * @param setupFile - Existing managed setup file.
7850
+ * @param values - Explicitly selected additional networks.
7851
+ * @returns The saved managed setup.
7852
+ */
7853
+ async function saveManagedTrustedNetworks(setupFile, values) {
7854
+ const extraAllowedCidrs = parseAdditionalTrustedNetworks(values);
7855
+ const setup = parseManagedSetup(JSON.parse(await readFile(setupFile, "utf8")));
7856
+ const next = Object.freeze({
7857
+ ...setup,
7858
+ extraAllowedCidrs
7859
+ });
7860
+ await atomicWrite(setupFile, `${JSON.stringify(next, null, 2)}\n`);
7861
+ await restrictPrivateFile(setupFile);
7862
+ return next;
7863
+ }
7156
7864
  function privateIpv4(value) {
7157
7865
  const parts = value.split(".").map(Number);
7158
7866
  return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
@@ -7293,10 +8001,18 @@ async function atomicWrite(file, contents) {
7293
8001
  recursive: true,
7294
8002
  mode: 448
7295
8003
  });
7296
- const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`);
7297
- await writeFile(temporary, contents, { mode: 384 });
7298
- await rename(temporary, file);
7299
- await restrictPrivateFile(file);
8004
+ const temporary = join(directory, `.${basename(file)}.${randomBytes(12).toString("hex")}.tmp`);
8005
+ try {
8006
+ await writeFile(temporary, contents, {
8007
+ mode: 384,
8008
+ flag: "wx"
8009
+ });
8010
+ await restrictPrivateFile(temporary);
8011
+ await rename(temporary, file);
8012
+ await restrictPrivateFile(file);
8013
+ } finally {
8014
+ await rm(temporary, { force: true });
8015
+ }
7300
8016
  }
7301
8017
  /** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
7302
8018
  async function ensureManagedCa(setup, legacy) {
@@ -7435,7 +8151,7 @@ async function materializeManagedSetup(setup, table) {
7435
8151
  return {
7436
8152
  publicOrigin: `https://${network.address}:${String(setup.listenPort)}`,
7437
8153
  listenHost: network.address,
7438
- allowedCidrs: [network.cidr],
8154
+ allowedCidrs: parseAdditionalTrustedNetworks([network.cidr, ...setup.extraAllowedCidrs ?? []]),
7439
8155
  instanceId: ca.fingerprint256.replaceAll(":", "").toLowerCase(),
7440
8156
  pairingCaFile: setup.tls.caCertFile,
7441
8157
  tls: {
@@ -7718,6 +8434,17 @@ var RemoteProviderCoordinator = class {
7718
8434
  mutate(operation) {
7719
8435
  return this.enqueue(() => operation(this.controller()));
7720
8436
  }
8437
+ /** Apply one operation only while its intended provider remains selected.
8438
+ * @param provider - Provider the caller explicitly chose.
8439
+ * @param operation - Configuration and activation performed in the same queue item.
8440
+ * @returns The operation result, or a rejection when another selection won the queue.
8441
+ */
8442
+ mutateSelected(provider, operation) {
8443
+ return this.enqueue(async () => {
8444
+ if (provider !== this.selectedValue) throw new Error("remote_provider_changed");
8445
+ return operation(this.controller());
8446
+ });
8447
+ }
7721
8448
  /** Disable the previous provider, persist the new selection, and retain rollback on write failure. */
7722
8449
  select(provider) {
7723
8450
  return this.enqueue(async () => {
@@ -8909,6 +9636,10 @@ async function downloadPinnedArtifact(request) {
8909
9636
  }
8910
9637
  //#endregion
8911
9638
  //#region src/cloudflared-component.ts
9639
+ /** Upper bound for `tar -t` output; a pinned release listing is a few lines. */
9640
+ const MAX_ARCHIVE_LIST_BYTES = 1048576;
9641
+ const MAX_ARCHIVE_ENTRIES = 256;
9642
+ const DOWNLOAD_TIMEOUT_MS = 3e5;
8912
9643
  const CLOUDFLARED_VERSION = "2026.9.1";
8913
9644
  /** Pinned official cloudflared release metadata for supported desktop targets. */
8914
9645
  const CLOUDFLARED_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
@@ -8919,7 +9650,9 @@ const CLOUDFLARED_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
8919
9650
  downloadUrl: `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-windows-amd64.exe`,
8920
9651
  downloadBytes: 54976432,
8921
9652
  downloadSha256: "2837888cc0f5d58f15b6dc478376de90b4d3ba5241c7947455d1e0a0df429712",
8922
- executableName: "cloudflared.exe"
9653
+ executableName: "cloudflared.exe",
9654
+ executableBytes: 54976432,
9655
+ executableSha256: "2837888cc0f5d58f15b6dc478376de90b4d3ba5241c7947455d1e0a0df429712"
8923
9656
  },
8924
9657
  {
8925
9658
  version: CLOUDFLARED_VERSION,
@@ -8928,7 +9661,9 @@ const CLOUDFLARED_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
8928
9661
  downloadUrl: `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64`,
8929
9662
  downloadBytes: 39838488,
8930
9663
  downloadSha256: "03f1f25d1cc93b9ad6c60569d44060bc4f17ed97075760ed8cfca4b12dcd68cc",
8931
- executableName: "cloudflared"
9664
+ executableName: "cloudflared",
9665
+ executableBytes: 39838488,
9666
+ executableSha256: "03f1f25d1cc93b9ad6c60569d44060bc4f17ed97075760ed8cfca4b12dcd68cc"
8932
9667
  },
8933
9668
  {
8934
9669
  version: CLOUDFLARED_VERSION,
@@ -8937,7 +9672,39 @@ const CLOUDFLARED_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
8937
9672
  downloadUrl: `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-arm64`,
8938
9673
  downloadBytes: 37466252,
8939
9674
  downloadSha256: "3d97437c71848bd8df68041e12436b484a661d95073ea1937f01a845ce88faa3",
8940
- executableName: "cloudflared"
9675
+ executableName: "cloudflared",
9676
+ executableBytes: 37466252,
9677
+ executableSha256: "3d97437c71848bd8df68041e12436b484a661d95073ea1937f01a845ce88faa3"
9678
+ },
9679
+ {
9680
+ version: CLOUDFLARED_VERSION,
9681
+ platform: "darwin",
9682
+ arch: "x64",
9683
+ downloadUrl: `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-darwin-amd64.tgz`,
9684
+ downloadBytes: 21118723,
9685
+ downloadSha256: "ff0d3b51d5ff70eceef89d6b32145fee985018a2174596a5dbe405e2766e2ac4",
9686
+ executableName: "cloudflared",
9687
+ executableBytes: 41731488,
9688
+ executableSha256: "1ea07ae775b03236bd6be18ca1848d6bdc4af2f4f3bce398823b5a36e5761b75",
9689
+ archive: {
9690
+ format: "tgz",
9691
+ member: "cloudflared"
9692
+ }
9693
+ },
9694
+ {
9695
+ version: CLOUDFLARED_VERSION,
9696
+ platform: "darwin",
9697
+ arch: "arm64",
9698
+ downloadUrl: `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-darwin-arm64.tgz`,
9699
+ downloadBytes: 19217478,
9700
+ downloadSha256: "c27ab8fd0aa489449e3d201eb02f957ef460a13b613662928b1b23394bf1bcfe",
9701
+ executableName: "cloudflared",
9702
+ executableBytes: 38893072,
9703
+ executableSha256: "9a0b19f67dc7a3011bc6b972c7ce06a5fcea8784ac6bd599ffa382ea4aeb5a6e",
9704
+ archive: {
9705
+ format: "tgz",
9706
+ member: "cloudflared"
9707
+ }
8941
9708
  }
8942
9709
  ].map((release) => [`${release.platform}-${release.arch}`, Object.freeze(release)])));
8943
9710
  /**
@@ -8964,6 +9731,60 @@ async function regularFile$1(file, expectedBytes) {
8964
9731
  throw error;
8965
9732
  }
8966
9733
  }
9734
+ async function runCapture(file, args) {
9735
+ return new Promise((resolveRun, reject) => {
9736
+ execFile(file, [...args], {
9737
+ windowsHide: true,
9738
+ timeout: 12e4,
9739
+ maxBuffer: MAX_ARCHIVE_LIST_BYTES,
9740
+ encoding: "utf8"
9741
+ }, (error, stdout) => {
9742
+ if (error === null) resolveRun(stdout);
9743
+ else reject(error);
9744
+ });
9745
+ });
9746
+ }
9747
+ /** Reject absolute, traversing, or Windows-shaped archive members. */
9748
+ function validatedArchiveEntry(rawEntry) {
9749
+ if (rawEntry.length === 0 || rawEntry.includes("\\") || rawEntry.includes("\0") || rawEntry.startsWith("/") || /^[a-zA-Z]:/u.test(rawEntry)) throw new Error("cloudflared_archive_path_invalid");
9750
+ const segments = rawEntry.replace(/\/$/u, "").split("/");
9751
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) throw new Error("cloudflared_archive_path_invalid");
9752
+ return segments;
9753
+ }
9754
+ /** Select exactly one archive member named `member`, wherever it sits in the tree. */
9755
+ function selectCloudflaredArchiveEntry(entries, member) {
9756
+ if (entries.length === 0 || entries.length > MAX_ARCHIVE_ENTRIES) throw new Error("cloudflared_archive_entries_invalid");
9757
+ let selected;
9758
+ for (const entry of entries) if (validatedArchiveEntry(entry).at(-1) === member) {
9759
+ if (selected !== void 0) throw new Error("cloudflared_archive_executable_ambiguous");
9760
+ selected = entry.replace(/\/$/u, "");
9761
+ }
9762
+ if (selected === void 0) throw new Error("cloudflared_archive_executable_missing");
9763
+ return selected;
9764
+ }
9765
+ /**
9766
+ * Unpack the pinned member out of a `.tgz` using the system `tar`, which ships
9767
+ * with Windows 10+, macOS, and every supported Linux base.
9768
+ */
9769
+ async function defaultExtractArtifact$1(archive, destination, member) {
9770
+ const tar = process.platform === "win32" ? "tar.exe" : "tar";
9771
+ const selected = selectCloudflaredArchiveEntry((await runCapture(tar, ["-tzf", archive])).split(/\r?\n/u).filter((entry) => entry.length > 0), member);
9772
+ const unpacked = join(destination, "archive");
9773
+ await mkdir(unpacked, {
9774
+ recursive: true,
9775
+ mode: 448
9776
+ });
9777
+ await runCapture(tar, [
9778
+ "-xzf",
9779
+ archive,
9780
+ "-C",
9781
+ unpacked,
9782
+ selected
9783
+ ]);
9784
+ const extracted = join(unpacked, ...validatedArchiveEntry(selected));
9785
+ if (!await regularFile$1(extracted)) throw new Error("cloudflared_archive_executable_invalid");
9786
+ await copyFile(extracted, join(destination, member));
9787
+ }
8967
9788
  async function defaultFetchArtifact$1(url, signal, expectedBytes) {
8968
9789
  return downloadPinnedArtifact({
8969
9790
  url,
@@ -8988,6 +9809,7 @@ var CloudflaredComponentManager = class {
8988
9809
  arch;
8989
9810
  release;
8990
9811
  fetchArtifact;
9812
+ extractArtifact;
8991
9813
  installed = false;
8992
9814
  errorCode;
8993
9815
  queue = Promise.resolve();
@@ -9012,12 +9834,14 @@ var CloudflaredComponentManager = class {
9012
9834
  ]) if (!inside$1(stateDirectory, child)) throw new Error("cloudflared component path escaped its state directory");
9013
9835
  const release = this.release;
9014
9836
  this.fetchArtifact = options.fetchArtifact ?? ((url, signal) => defaultFetchArtifact$1(url, signal, release?.downloadBytes ?? 0));
9837
+ this.extractArtifact = options.extractArtifact ?? defaultExtractArtifact$1;
9015
9838
  }
9016
9839
  /** Inspect the managed binary without using any global cloudflared state. */
9017
9840
  async initialize() {
9018
9841
  const release = this.release;
9019
- this.installed = release !== void 0 && await regularFile$1(this.executable, release.downloadBytes);
9020
- if (this.installed && release !== void 0 && await sha256$1(this.executable) !== release.downloadSha256) {
9842
+ this.errorCode = void 0;
9843
+ this.installed = release !== void 0 && await regularFile$1(this.executable, release.executableBytes);
9844
+ if (this.installed && release !== void 0 && await sha256$1(this.executable) !== release.executableSha256) {
9021
9845
  this.installed = false;
9022
9846
  this.errorCode = "cloudflared_component_invalid";
9023
9847
  }
@@ -9030,7 +9854,7 @@ var CloudflaredComponentManager = class {
9030
9854
  installed: this.installed,
9031
9855
  version: release.version,
9032
9856
  downloadBytes: release.downloadBytes,
9033
- installedBytes: release.downloadBytes,
9857
+ installedBytes: release.executableBytes,
9034
9858
  sourceUrl: release.downloadUrl,
9035
9859
  downloadPage: DOWNLOAD_PAGE$1,
9036
9860
  termsUrl: TERMS_URL$1,
@@ -9052,7 +9876,7 @@ var CloudflaredComponentManager = class {
9052
9876
  const controller = new AbortController();
9053
9877
  const timeout = setTimeout(() => {
9054
9878
  controller.abort();
9055
- }, 3e5);
9879
+ }, DOWNLOAD_TIMEOUT_MS);
9056
9880
  timeout.unref();
9057
9881
  let bytes;
9058
9882
  try {
@@ -9063,12 +9887,24 @@ var CloudflaredComponentManager = class {
9063
9887
  if (bytes.byteLength !== release.downloadBytes) throw new Error("cloudflared_download_size_mismatch");
9064
9888
  if (createHash("sha256").update(bytes).digest("hex") !== release.downloadSha256) throw new Error("cloudflared_download_hash_mismatch");
9065
9889
  const staged = join(staging, release.executableName);
9066
- await writeFile(staged, bytes, {
9890
+ if (release.archive === void 0) await writeFile(staged, bytes, {
9067
9891
  flag: "wx",
9068
9892
  mode: 384
9069
9893
  });
9894
+ else {
9895
+ const downloaded = join(staging, `${release.executableName}.download`);
9896
+ await writeFile(downloaded, bytes, {
9897
+ flag: "wx",
9898
+ mode: 384
9899
+ });
9900
+ try {
9901
+ await this.extractArtifact(downloaded, staging, release.archive.member);
9902
+ } finally {
9903
+ await rm(downloaded, { force: true });
9904
+ }
9905
+ }
9070
9906
  await chmod(staged, 448);
9071
- if (!await regularFile$1(staged, release.downloadBytes) || await sha256$1(staged) !== release.downloadSha256) throw new Error("cloudflared_executable_hash_mismatch");
9907
+ if (!await regularFile$1(staged, release.executableBytes) || await sha256$1(staged) !== release.executableSha256) throw new Error("cloudflared_executable_hash_mismatch");
9072
9908
  const candidate = join(this.componentRoot, `.install-${randomBytes(12).toString("hex")}`);
9073
9909
  await mkdir(candidate, {
9074
9910
  recursive: true,
@@ -9643,7 +10479,7 @@ var CloudflaredController = class {
9643
10479
  };
9644
10480
  //#endregion
9645
10481
  //#region src/cloudflared-tunnel.ts
9646
- const MAX_CONFIG_BYTES = 8192;
10482
+ const MAX_CONFIG_BYTES$1 = 8192;
9647
10483
  const QUICK_TUNNEL_SUFFIX = ".trycloudflare.com";
9648
10484
  /** Cloudflare's own tunnel-routing domain; it can never be a public hostname. */
9649
10485
  const TUNNEL_ROUTING_SUFFIX = ".cfargotunnel.com";
@@ -9802,7 +10638,7 @@ var CloudflaredTunnelStore = class {
9802
10638
  if (error.code === "ENOENT") return;
9803
10639
  throw error;
9804
10640
  }
9805
- if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_CONFIG_BYTES) {
10641
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_CONFIG_BYTES$1) {
9806
10642
  this.errorCode = "cloudflared_tunnel_config_invalid";
9807
10643
  return;
9808
10644
  }
@@ -10440,6 +11276,7 @@ const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手
10440
11276
  - 目录:$DSH_HOME/mobile-access/extensions/<id>/,id 用小写字母数字和连字符(如 media-remote)
10441
11277
  - extension.json:{"schemaVersion":1,"id":"<id>","name":"显示名","version":"0.1.0","description":"说明"}
10442
11278
  - host.mjs:电脑端 Node.js 代码(可信本地代码,可读写文件、执行命令)。导出默认函数 (api) => { ... },用 api.action('名称', { input, run }) 注册动作、api.route({ method, path, handle }) 注册路由、api.effect(fn) 注册清理。input 可以直接使用 api.schema.object(...) 等 Schemastery schema,也可以传入带 parse(value) 的适配器;两种形式都会在动作执行前校验并规范化输入。
11279
+ - 动作与路由的执行有时间预算:默认 30 秒(输入校验也计入),可用 api.action('名称', { input, run, timeoutMs }) 或 api.route({ ..., timeoutMs }) 按操作覆盖(1–300000 的整数毫秒)。超时或调用方取消后,调用方收到 500 extension_action_timeout / extension_route_timeout(或取消原因);这只表示调用方不再等待,底层代码可能仍在运行并已产生副作用,请在 run / handle 中响应 signal 实现协作式取消。路由的预算到 handle() 返回响应为止,不影响已返回流的存活。若某扩展存在调用方已离开(超时或取消)但仍未结束的操作,后续对该扩展的新调用会收到 503 extension_busy,直到该操作真正结束。
10443
11280
  - mobile.js:手机端脚本,用 window.dshMobile.define({ apiVersion:1, id:'<id>', activate(api) { ... } }),activate 返回清理函数
10444
11281
  - mobile.css:手机端样式(可选)
10445
11282
  - assets/:手机端静态资源(可选)
@@ -10448,6 +11285,7 @@ const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手
10448
11285
 
10449
11286
  安全约束:
10450
11287
  - host.mjs 拥有电脑用户的完整权限,绝不能放入不可信代码,也不要让手机端无条件执行任意命令
11288
+ - host.mjs 的同步阻塞(如 while (true) {} 或长时间同步循环)会卡死整个电脑端网关进程,超时与取消机制都无法打断;绝不编写同步阻塞代码
10451
11289
  - 所有改动只限 $DSH_HOME/mobile-access/,不要动 DeepSeek Harness 源码
10452
11290
 
10453
11291
  完成前请自检:
@@ -12631,139 +13469,1098 @@ var CpolarComponentManager = class {
12631
13469
  }
12632
13470
  };
12633
13471
  //#endregion
12634
- //#region src/release-update.ts
12635
- const PACKAGE_NAME = "dsh-mobile";
12636
- const NPM_LATEST_URL = "https://registry.npmjs.org/dsh-mobile/latest";
12637
- const GITHUB_LATEST_URL = "https://github.com/saya-ch/dsh-mobile/releases/latest";
12638
- const GITHUB_API_LATEST_URL = "https://api.github.com/repos/saya-ch/dsh-mobile/releases/latest";
12639
- const GITHUB_RELEASES_URL = "https://github.com/saya-ch/dsh-mobile/releases";
12640
- const RELEASE_NOTES_MAX_CHARS = 4e3;
12641
- const STATUS_CACHE_MS = 6e5;
12642
- const REQUEST_TIMEOUT_MS = 8e3;
12643
- const UPDATE_TIMEOUT_MS = 12e4;
12644
- const UPDATE_TERMINATION_GRACE_MS = 1500;
12645
- const NUMERIC_VERSION_IDENTIFIER = "(?:0|[1-9]\\d*)";
12646
- const WILDCARD_VERSION_IDENTIFIER = "(?:[xX*])";
12647
- const RANGE_VERSION = `(?:${`${NUMERIC_VERSION_IDENTIFIER}\\.${NUMERIC_VERSION_IDENTIFIER}\\.${NUMERIC_VERSION_IDENTIFIER}(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?`}|${`(?:${WILDCARD_VERSION_IDENTIFIER}|${NUMERIC_VERSION_IDENTIFIER}(?:\\.(?:${WILDCARD_VERSION_IDENTIFIER}|${NUMERIC_VERSION_IDENTIFIER}(?:\\.(?:${WILDCARD_VERSION_IDENTIFIER}|${NUMERIC_VERSION_IDENTIFIER}))?))?)`})`;
12648
- const COMPARATOR = new RegExp(`^(?:<=|>=|<|>|=|~|\\^)?${RANGE_VERSION}$`, "u");
12649
- const HYPHEN_RANGE = new RegExp(`^${RANGE_VERSION} +[-] +${RANGE_VERSION}$`, "u");
12650
- const DIST_TAG = /^[A-Za-z][A-Za-z0-9._-]{0,127}$/u;
12651
- function parseSemver(value) {
12652
- const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(value);
12653
- if (match === null) return void 0;
12654
- const core = [
12655
- Number(match[1]),
12656
- Number(match[2]),
12657
- Number(match[3])
12658
- ];
12659
- if (core.some((part) => !Number.isSafeInteger(part))) return void 0;
12660
- const prerelease = match[4] === void 0 ? [] : match[4].split(".").map((part) => /^\d+$/u.test(part) ? Number(part) : part);
12661
- if (prerelease.some((part) => typeof part === "number" && !Number.isSafeInteger(part))) return void 0;
12662
- return Object.freeze({
12663
- core,
12664
- prerelease: Object.freeze(prerelease)
12665
- });
12666
- }
12667
- /** Compare two strict SemVer strings, including prerelease precedence. */
12668
- function comparePluginVersions(left, right) {
12669
- const a = parseSemver(left);
12670
- const b = parseSemver(right);
12671
- if (a === void 0 || b === void 0) return void 0;
12672
- for (let index = 0; index < a.core.length; index += 1) {
12673
- const difference = a.core[index] - b.core[index];
12674
- if (difference !== 0) return Math.sign(difference);
12675
- }
12676
- if (a.prerelease.length === 0 || b.prerelease.length === 0) return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length === 0 ? 1 : -1;
12677
- const length = Math.max(a.prerelease.length, b.prerelease.length);
12678
- for (let index = 0; index < length; index += 1) {
12679
- const leftPart = a.prerelease[index];
12680
- const rightPart = b.prerelease[index];
12681
- if (leftPart === void 0 || rightPart === void 0) return leftPart === void 0 ? -1 : 1;
12682
- if (leftPart === rightPart) continue;
12683
- if (typeof leftPart === "number" && typeof rightPart === "number") return Math.sign(leftPart - rightPart);
12684
- if (typeof leftPart === "number") return -1;
12685
- if (typeof rightPart === "number") return 1;
12686
- return leftPart < rightPart ? -1 : 1;
12687
- }
12688
- return 0;
12689
- }
12690
- function isComparatorSet(value) {
12691
- if (HYPHEN_RANGE.test(value)) return true;
12692
- const comparators = value.replace(/(<=|>=|<|>|=|~|\^) +/gu, "$1").split(/ +/u);
12693
- return comparators.length > 0 && comparators.every((comparator) => COMPARATOR.test(comparator));
12694
- }
12695
- function isNpmVersionRange(value) {
12696
- if (!/^[0-9xX*<>=~^|.+\- ]+$/u.test(value)) return false;
12697
- const alternatives = value.split(/ *\|\| */u);
12698
- return alternatives.length > 0 && alternatives.every((alternative) => alternative !== "" && isComparatorSet(alternative));
12699
- }
12700
- /** Return whether pnpm may safely replace this profile dependency from an npm version, range, or tag. */
12701
- function isRegistryPluginSpec(value) {
12702
- if (typeof value !== "string" || value.trim() !== value || value === "" || /[\u0000-\u001f\u007f]/u.test(value)) return false;
12703
- if (/\.(?:tgz|tar(?:\.gz)?)$/iu.test(value)) return false;
12704
- return parseSemver(value) !== void 0 || isNpmVersionRange(value) || DIST_TAG.test(value);
12705
- }
12706
- /** Resolve the DSH profile named by the current launcher arguments. */
12707
- function launchedProfileName(argv) {
12708
- for (let index = 0; index < argv.length; index += 1) {
12709
- if (argv[index] === "--profile") {
12710
- const candidate = argv[index + 1];
12711
- if (candidate !== void 0 && /^[\w.-]+$/u.test(candidate)) return candidate;
13472
+ //#region src/caddy-files.ts
13473
+ /** Refuse link-shaped ancestors within the owned state root before filesystem operations. */
13474
+ async function assertCaddyParents(root, target) {
13475
+ const parent = resolve(root);
13476
+ const candidate = resolve(target);
13477
+ const offset = relative(parent, candidate);
13478
+ if (!isAbsolute(root) || offset !== "" && (offset.startsWith("..") || isAbsolute(offset))) throw new Error("caddy_path_invalid");
13479
+ const paths = [parent];
13480
+ let cursor = parent;
13481
+ for (const segment of relative(parent, dirname(candidate)).split(/[\\/]/u).filter(Boolean)) {
13482
+ cursor = join(cursor, segment);
13483
+ paths.push(cursor);
13484
+ }
13485
+ for (const path of paths) {
13486
+ let entry;
13487
+ try {
13488
+ entry = await lstat(path);
13489
+ } catch (error) {
13490
+ if (error.code === "ENOENT") continue;
13491
+ throw error;
12712
13492
  }
12713
- const match = /^--profile=([\w.-]+)$/u.exec(argv[index] ?? "");
12714
- if (match?.[1] !== void 0) return match[1];
13493
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("caddy_path_invalid");
12715
13494
  }
12716
- return "web";
12717
13495
  }
12718
- /** Resolve the launcher-owned Desktop profile, or the CLI profile outside Desktop. */
12719
- function releaseProfileDirectory(ctx, dshHome, argv) {
12720
- const desktopProfiles = ctx.get("desktopProfiles");
12721
- const desktopDirectory = desktopProfiles?.current?.dir;
12722
- if (typeof desktopDirectory === "string" && isAbsolute(desktopDirectory)) return desktopDirectory;
12723
- if (desktopProfiles !== void 0 || ctx.get("desktopRuntime") !== void 0) return void 0;
12724
- return join(dshHome, "profiles", launchedProfileName(argv));
13496
+ /** Create a private real directory, never traversing a link owned by the component. */
13497
+ async function ensureCaddyDirectory(root, directory) {
13498
+ await assertCaddyParents(root, directory);
13499
+ await mkdir(directory, {
13500
+ recursive: true,
13501
+ mode: 448
13502
+ });
13503
+ const entry = await lstat(directory);
13504
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("caddy_path_invalid");
13505
+ await restrictPrivateFile(directory, 448);
12725
13506
  }
12726
- async function profileDependencySpec(profileDirectory) {
13507
+ /** Atomically replace one private configuration file under a real owned directory. */
13508
+ async function writeCaddyPrivateFile(root, file, body) {
13509
+ await ensureCaddyDirectory(root, dirname(file));
12727
13510
  try {
12728
- const value = JSON.parse(await readFile(join(profileDirectory, "package.json"), "utf8")).dependencies?.[PACKAGE_NAME];
12729
- return typeof value === "string" ? value : void 0;
12730
- } catch {
12731
- return;
13511
+ const entry = await lstat(file);
13512
+ if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("caddy_config_target_invalid");
13513
+ } catch (error) {
13514
+ if (error.code !== "ENOENT") throw error;
13515
+ }
13516
+ const temporary = join(dirname(file), "." + basename(file) + "." + randomBytes(12).toString("hex") + ".tmp");
13517
+ try {
13518
+ await writeFile(temporary, body, {
13519
+ encoding: "utf8",
13520
+ flag: "wx",
13521
+ mode: 384
13522
+ });
13523
+ await restrictPrivateFile(temporary);
13524
+ await rename(temporary, file);
13525
+ } finally {
13526
+ await rm(temporary, { force: true });
12732
13527
  }
12733
13528
  }
12734
- async function fetchNpmVersion(fetcher) {
12735
- const response = await fetcher(NPM_LATEST_URL, {
12736
- headers: {
12737
- accept: "application/json",
12738
- "user-agent": "dsh-mobile-release-check"
12739
- },
12740
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
12741
- });
12742
- if (!response.ok) return void 0;
12743
- const payload = await response.json();
12744
- return typeof payload.version === "string" && parseSemver(payload.version) !== void 0 ? payload.version : void 0;
12745
- }
12746
- function githubReleaseVersion(location, responseUrl) {
12747
- let url;
13529
+ /** Delete only owned entries; junctions and symlinks are unlinked, never descended into. */
13530
+ async function removeCaddyTree(root, target) {
13531
+ await assertCaddyParents(root, target);
13532
+ let entry;
12748
13533
  try {
12749
- url = new URL(location ?? responseUrl, GITHUB_LATEST_URL);
12750
- } catch {
13534
+ entry = await lstat(target);
13535
+ } catch (error) {
13536
+ if (error.code === "ENOENT") return;
13537
+ throw error;
13538
+ }
13539
+ if (entry.isSymbolicLink()) {
13540
+ await unlink(target);
12751
13541
  return;
12752
13542
  }
12753
- if (url.origin !== "https://github.com" || url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") return void 0;
12754
- if (!url.pathname.startsWith("/saya-ch/dsh-mobile/releases/tag/v")) return void 0;
12755
- let version;
12756
- try {
12757
- version = decodeURIComponent(url.pathname.slice(34));
12758
- } catch {
13543
+ if (!entry.isDirectory()) {
13544
+ await unlink(target);
12759
13545
  return;
12760
13546
  }
12761
- return parseSemver(version) === void 0 ? void 0 : version;
13547
+ for (const name of await readdir(target)) await removeCaddyTree(root, join(target, name));
13548
+ await rmdir(target);
12762
13549
  }
12763
- function androidReleaseDownloadUrl(version) {
12764
- if (version === void 0) return GITHUB_RELEASES_URL;
12765
- const tag = `v${version}`;
12766
- return `https://github.com/saya-ch/dsh-mobile/releases/download/${encodeURIComponent(tag)}/dsh-mobile-android-${encodeURIComponent(tag)}.apk`;
13550
+ //#endregion
13551
+ //#region src/caddy-component.ts
13552
+ const CADDY_VERSION = "2.11.6";
13553
+ const CADDY_DNS_PLUGIN_VERSION = "v0.4.3";
13554
+ const CADDY_DOWNLOAD_PAGE = "https://caddyserver.com/download";
13555
+ const CADDY_COMPONENT_RELEASES = Object.freeze({});
13556
+ Object.freeze({
13557
+ version: CADDY_VERSION,
13558
+ dnsPluginVersion: CADDY_DNS_PLUGIN_VERSION
13559
+ });
13560
+ function isCaddyDnsProvider(value) {
13561
+ return value === "tencentcloud";
13562
+ }
13563
+ async function digest(file) {
13564
+ return createHash("sha256").update(await readFile(file)).digest("hex");
13565
+ }
13566
+ /** Minimal process environment for version/module checks, without ambient API credentials or proxy overrides. */
13567
+ function caddyInspectionEnvironment(environment = process.env) {
13568
+ const result = {};
13569
+ for (const name of [
13570
+ "SystemRoot",
13571
+ "WINDIR",
13572
+ "TEMP",
13573
+ "TMP",
13574
+ "LANG",
13575
+ "LC_ALL"
13576
+ ]) if (environment[name] !== void 0) result[name] = environment[name];
13577
+ return result;
13578
+ }
13579
+ async function inspect(executable) {
13580
+ const options = {
13581
+ env: caddyInspectionEnvironment(),
13582
+ timeout: 15e3,
13583
+ maxBuffer: 262144
13584
+ };
13585
+ return {
13586
+ version: (await execFileText(executable, ["version"], options)).stdout.trim(),
13587
+ modules: (await execFileText(executable, ["list-modules", "--versions"], options)).stdout
13588
+ };
13589
+ }
13590
+ /** Owns only the optional pinned executable; runtime configuration belongs to CaddyConfigStore. */
13591
+ var CaddyComponentManager = class {
13592
+ executable;
13593
+ componentRoot;
13594
+ componentStorage;
13595
+ logRoot;
13596
+ stateDirectory;
13597
+ stagingRoot;
13598
+ artifact;
13599
+ fetchArtifact;
13600
+ inspectExecutable;
13601
+ promote;
13602
+ installed = false;
13603
+ errorCode;
13604
+ queue = Promise.resolve();
13605
+ constructor(options) {
13606
+ if (!isAbsolute(options.stateDirectory)) throw new Error("caddy state directory must be absolute");
13607
+ this.stateDirectory = resolve(options.stateDirectory);
13608
+ const platform = options.platform ?? process.platform;
13609
+ const arch = options.arch ?? process.arch;
13610
+ const artifact = options.artifact ?? CADDY_COMPONENT_RELEASES[`${platform}-${arch}`];
13611
+ if (artifact !== void 0 && (artifact.platform !== platform || artifact.arch !== arch || !/^https:\/\//u.test(artifact.downloadUrl) || !/^[a-f0-9]{64}$/u.test(artifact.downloadSha256) || !/^[a-f0-9]{64}$/u.test(artifact.executableSha256) || artifact.downloadBytes < 1 || artifact.executableBytes < 1 || !/^[a-z0-9.-]+$/u.test(artifact.executableName))) throw new Error("caddy_artifact_invalid");
13612
+ this.artifact = artifact;
13613
+ this.componentRoot = join(this.stateDirectory, "components", "caddy");
13614
+ this.componentStorage = join(this.componentRoot, artifact?.version ?? "2.11.6");
13615
+ this.executable = join(this.componentStorage, artifact?.executableName ?? (platform === "win32" ? "caddy.exe" : "caddy"));
13616
+ this.logRoot = join(this.stateDirectory, "caddy", "logs");
13617
+ this.stagingRoot = join(this.stateDirectory, "staging", "caddy");
13618
+ this.fetchArtifact = options.fetchArtifact ?? ((url, signal) => downloadPinnedArtifact({
13619
+ url,
13620
+ signal,
13621
+ expectedBytes: artifact?.downloadBytes ?? 0,
13622
+ errorPrefix: "caddy"
13623
+ }));
13624
+ this.inspectExecutable = options.inspectExecutable ?? inspect;
13625
+ this.promote = options.promoteDirectory ?? rename;
13626
+ }
13627
+ /** Recheck a pinned executable before launching; no unverified local binary can run. */
13628
+ async ensureExecutable() {
13629
+ const artifact = this.artifact;
13630
+ if (artifact === void 0) throw new Error("caddy_component_unavailable");
13631
+ await assertCaddyParents(this.stateDirectory, this.executable);
13632
+ const entry = await lstat(this.executable);
13633
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size !== artifact.executableBytes || await digest(this.executable) !== artifact.executableSha256) throw new Error("caddy_component_invalid");
13634
+ await this.verifyMetadata(this.executable, artifact);
13635
+ }
13636
+ async initialize() {
13637
+ this.installed = false;
13638
+ this.errorCode = void 0;
13639
+ if (this.artifact === void 0) {
13640
+ this.errorCode = "caddy_component_unavailable";
13641
+ return;
13642
+ }
13643
+ try {
13644
+ await this.ensureExecutable();
13645
+ this.installed = true;
13646
+ } catch (error) {
13647
+ if (error.code !== "ENOENT") this.errorCode = "caddy_component_invalid";
13648
+ }
13649
+ }
13650
+ status() {
13651
+ const artifact = this.artifact;
13652
+ return Object.freeze({
13653
+ supported: artifact !== void 0,
13654
+ installed: this.installed,
13655
+ version: artifact?.version ?? "2.11.6",
13656
+ downloadBytes: artifact?.downloadBytes ?? 0,
13657
+ installedBytes: this.installed ? artifact?.executableBytes ?? 0 : 0,
13658
+ sourceUrl: artifact?.downloadUrl ?? "https://caddyserver.com/download",
13659
+ downloadPage: CADDY_DOWNLOAD_PAGE,
13660
+ storagePath: this.componentRoot,
13661
+ ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
13662
+ });
13663
+ }
13664
+ /** Explicit installation verifies bytes and reported modules before preserving-old promotion. */
13665
+ install() {
13666
+ return this.enqueue(async () => {
13667
+ const artifact = this.artifact;
13668
+ if (artifact === void 0) throw new Error("caddy_component_unavailable");
13669
+ await ensureCaddyDirectory(this.stateDirectory, this.stagingRoot);
13670
+ await ensureCaddyDirectory(this.stateDirectory, this.componentRoot);
13671
+ const staging = await mkdtemp(join(this.stagingRoot, "install-"));
13672
+ const candidate = join(this.componentRoot, ".install-" + randomBytes(12).toString("hex"));
13673
+ const backup = join(this.componentRoot, ".previous-" + randomBytes(12).toString("hex"));
13674
+ let previous = false;
13675
+ let promoted = false;
13676
+ try {
13677
+ const controller = new AbortController();
13678
+ const timeout = setTimeout(() => {
13679
+ controller.abort();
13680
+ }, 6e5);
13681
+ timeout.unref();
13682
+ let bytes;
13683
+ try {
13684
+ bytes = await this.fetchArtifact(artifact.downloadUrl, controller.signal);
13685
+ } finally {
13686
+ clearTimeout(timeout);
13687
+ }
13688
+ if (bytes.byteLength !== artifact.downloadBytes) throw new Error("caddy_download_size_mismatch");
13689
+ if (createHash("sha256").update(bytes).digest("hex") !== artifact.downloadSha256) throw new Error("caddy_download_hash_mismatch");
13690
+ await ensureCaddyDirectory(this.stateDirectory, candidate);
13691
+ const executable = join(candidate, artifact.executableName);
13692
+ await writeFile(executable, bytes, {
13693
+ flag: "wx",
13694
+ mode: 448
13695
+ });
13696
+ await chmod(executable, 448);
13697
+ if (bytes.byteLength !== artifact.executableBytes || await digest(executable) !== artifact.executableSha256) throw new Error("caddy_executable_hash_mismatch");
13698
+ await this.verifyMetadata(executable, artifact);
13699
+ try {
13700
+ const entry = await lstat(this.componentStorage);
13701
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("caddy_path_invalid");
13702
+ await rename(this.componentStorage, backup);
13703
+ previous = true;
13704
+ } catch (error) {
13705
+ if (error.code !== "ENOENT") throw error;
13706
+ }
13707
+ try {
13708
+ await this.promote(candidate, this.componentStorage);
13709
+ promoted = true;
13710
+ } catch (error) {
13711
+ if (previous) {
13712
+ await rename(backup, this.componentStorage);
13713
+ previous = false;
13714
+ }
13715
+ throw error;
13716
+ }
13717
+ this.installed = true;
13718
+ this.errorCode = void 0;
13719
+ } finally {
13720
+ await removeCaddyTree(this.stateDirectory, staging);
13721
+ await removeCaddyTree(this.stateDirectory, candidate);
13722
+ if (previous && promoted) await removeCaddyTree(this.stateDirectory, backup);
13723
+ }
13724
+ });
13725
+ }
13726
+ /** Remove optional component files; callers stop the controller and purge runtime configuration separately. */
13727
+ purge() {
13728
+ return this.enqueue(async () => {
13729
+ await removeCaddyTree(this.stateDirectory, this.componentRoot);
13730
+ await removeCaddyTree(this.stateDirectory, this.stagingRoot);
13731
+ this.installed = false;
13732
+ this.errorCode = this.artifact === void 0 ? "caddy_component_unavailable" : void 0;
13733
+ });
13734
+ }
13735
+ async verifyMetadata(executable, artifact) {
13736
+ const info = await this.inspectExecutable(executable);
13737
+ if (!info.version.startsWith("v" + artifact.version + " ")) throw new Error("caddy_executable_version_mismatch");
13738
+ const nonstandard = info.modules.split(/\r?\n/u).filter((line) => line.startsWith("dns.providers."));
13739
+ if (nonstandard.length !== 1 || nonstandard[0] !== "dns.providers.tencentcloud " + artifact.dnsPluginVersion || !info.modules.includes("Non-standard modules: 1")) throw new Error("caddy_executable_modules_mismatch");
13740
+ }
13741
+ enqueue(operation) {
13742
+ const task = this.queue.then(operation, operation);
13743
+ this.queue = task.then(() => void 0, () => void 0);
13744
+ return task.then(() => this.status());
13745
+ }
13746
+ };
13747
+ const MAX_CONFIG_BYTES = 16384;
13748
+ /** Inject only the DNS credentials needed by the managed child. */
13749
+ function caddyCredentialEnvironment(provider, credentials) {
13750
+ if (!isCaddyDnsProvider(provider)) throw new Error("caddy_dns_provider_invalid");
13751
+ return {
13752
+ TENCENTCLOUD_SECRET_ID: credentials.secretId,
13753
+ TENCENTCLOUD_SECRET_KEY: credentials.secretKey
13754
+ };
13755
+ }
13756
+ /** Validate saved or administrative settings, including ports reserved by DSH. */
13757
+ function parseCaddySettings(value) {
13758
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("caddy_settings_invalid");
13759
+ const record = value;
13760
+ if (Reflect.ownKeys(record).some((key) => ![
13761
+ "version",
13762
+ "publicOrigin",
13763
+ "dnsProvider",
13764
+ "listenPort"
13765
+ ].includes(String(key))) || record.version !== void 0 && record.version !== 1) throw new Error("caddy_settings_invalid");
13766
+ if (!isCaddyDnsProvider(record.dnsProvider)) throw new Error("caddy_dns_provider_invalid");
13767
+ const listenPort = record.listenPort ?? 8443;
13768
+ if (!Number.isSafeInteger(listenPort) || Number(listenPort) < 1024 || Number(listenPort) > 65535) throw new Error("caddy_listen_port_invalid");
13769
+ if ([
13770
+ 3444,
13771
+ 3443,
13772
+ 3080
13773
+ ].includes(Number(listenPort))) throw new Error("caddy_listen_port_reserved");
13774
+ const publicOrigin = validateOriginPublicOrigin(record.publicOrigin);
13775
+ if (isIP(new URL(publicOrigin).hostname) !== 0) throw new Error("caddy_domain_required");
13776
+ return Object.freeze({
13777
+ version: 1,
13778
+ publicOrigin,
13779
+ dnsProvider: record.dnsProvider,
13780
+ listenPort: Number(listenPort)
13781
+ });
13782
+ }
13783
+ function parseCredentials(value) {
13784
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("caddy_credentials_invalid");
13785
+ const record = value;
13786
+ if (Reflect.ownKeys(record).some((key) => key !== "secretId" && key !== "secretKey")) throw new Error("caddy_credentials_invalid");
13787
+ const { secretId, secretKey } = record;
13788
+ if (typeof secretId !== "string" || typeof secretKey !== "string" || secretId.length === 0 || secretKey.length === 0 || secretId.length > 512 || secretKey.length > 512 || /[\s\u0000-\u001f\u007f]/u.test(secretId + secretKey)) throw new Error("caddy_credentials_invalid");
13789
+ return Object.freeze({
13790
+ secretId,
13791
+ secretKey
13792
+ });
13793
+ }
13794
+ /** Render a single TLS site, with no admin API, config autosave, or embedded DNS secrets. */
13795
+ function renderCaddyfile(settings, stateDirectory, backendPort = 3444) {
13796
+ const validated = parseCaddySettings(settings);
13797
+ if (!Number.isSafeInteger(backendPort) || backendPort < 1 || backendPort > 65535) throw new Error("caddy_backend_port_invalid");
13798
+ const root = resolve(stateDirectory, "caddy");
13799
+ const dataDirectory = join(root, "data").replaceAll("\\", "/");
13800
+ const hostname = new URL(validated.publicOrigin).hostname;
13801
+ return [
13802
+ "{",
13803
+ " admin off",
13804
+ " persist_config off",
13805
+ " auto_https disable_redirects",
13806
+ `\tstorage file_system ${JSON.stringify(dataDirectory)}`,
13807
+ " log {",
13808
+ " output discard",
13809
+ " }",
13810
+ "}",
13811
+ "",
13812
+ `https://${hostname}:${String(validated.listenPort)} {`,
13813
+ " tls {",
13814
+ " dns tencentcloud {",
13815
+ " secret_id {env.TENCENTCLOUD_SECRET_ID}",
13816
+ " secret_key {env.TENCENTCLOUD_SECRET_KEY}",
13817
+ " }",
13818
+ " }",
13819
+ `\treverse_proxy 127.0.0.1:${String(backendPort)} {`,
13820
+ " header_up Host {http.request.hostport}",
13821
+ " transport http {",
13822
+ " keepalive off",
13823
+ " dial_timeout 5s",
13824
+ " }",
13825
+ " }",
13826
+ "}",
13827
+ ""
13828
+ ].join("\n");
13829
+ }
13830
+ /** Owns one atomic configuration record and every Caddy-created private file. */
13831
+ var CaddyConfigStore = class {
13832
+ stateDirectory;
13833
+ rootDirectory;
13834
+ settingsFile;
13835
+ envFile;
13836
+ caddyfile;
13837
+ dataDirectory;
13838
+ configDirectory;
13839
+ logsDirectory;
13840
+ settingsValue;
13841
+ credentialsValue;
13842
+ errorCodeValue;
13843
+ queue = Promise.resolve();
13844
+ constructor(stateDirectory) {
13845
+ this.stateDirectory = stateDirectory;
13846
+ if (!isAbsolute(stateDirectory)) throw new Error("caddy config state directory must be absolute");
13847
+ this.rootDirectory = resolve(stateDirectory, "caddy");
13848
+ this.settingsFile = join(this.rootDirectory, "config.json");
13849
+ this.envFile = this.settingsFile;
13850
+ this.caddyfile = join(this.rootDirectory, "Caddyfile");
13851
+ this.dataDirectory = join(this.rootDirectory, "data");
13852
+ this.configDirectory = join(this.rootDirectory, "xdg-config");
13853
+ this.logsDirectory = join(this.rootDirectory, "logs");
13854
+ }
13855
+ settings() {
13856
+ return this.settingsValue;
13857
+ }
13858
+ credentials() {
13859
+ return this.credentialsValue;
13860
+ }
13861
+ errorCode() {
13862
+ return this.errorCodeValue;
13863
+ }
13864
+ status() {
13865
+ const settings = this.settingsValue;
13866
+ return Object.freeze({
13867
+ configured: settings !== void 0 && this.credentialsValue !== void 0,
13868
+ credentialsConfigured: this.credentialsValue !== void 0,
13869
+ storagePath: this.rootDirectory,
13870
+ ...settings === void 0 ? {} : {
13871
+ publicOrigin: settings.publicOrigin,
13872
+ listenPort: settings.listenPort,
13873
+ dnsProvider: settings.dnsProvider
13874
+ },
13875
+ ...this.errorCodeValue === void 0 ? {} : { errorCode: this.errorCodeValue }
13876
+ });
13877
+ }
13878
+ async initialize() {
13879
+ await this.enqueue(async () => {
13880
+ this.settingsValue = void 0;
13881
+ this.credentialsValue = void 0;
13882
+ this.errorCodeValue = void 0;
13883
+ await assertCaddyParents(this.stateDirectory, this.settingsFile);
13884
+ let entry;
13885
+ try {
13886
+ entry = await lstat(this.settingsFile);
13887
+ } catch (error) {
13888
+ if (error.code === "ENOENT") return;
13889
+ throw error;
13890
+ }
13891
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_CONFIG_BYTES) {
13892
+ this.errorCodeValue = "caddy_config_invalid";
13893
+ return;
13894
+ }
13895
+ await restrictPrivateFile(this.settingsFile);
13896
+ try {
13897
+ const record = JSON.parse(await readFile(this.settingsFile, "utf8"));
13898
+ if (typeof record !== "object" || record === null || Array.isArray(record)) throw new Error("invalid");
13899
+ const values = record;
13900
+ if (Reflect.ownKeys(values).some((key) => key !== "settings" && key !== "credentials")) throw new Error("invalid");
13901
+ const settings = parseCaddySettings(values.settings);
13902
+ const credentials = parseCredentials(values.credentials);
13903
+ this.settingsValue = settings;
13904
+ this.credentialsValue = credentials;
13905
+ } catch {
13906
+ this.errorCodeValue = "caddy_config_invalid";
13907
+ }
13908
+ });
13909
+ }
13910
+ /** Atomically save validated settings and credentials; omitted or both-blank secrets retain saved ones. */
13911
+ configure(value, credentialInput) {
13912
+ return this.enqueue(async () => {
13913
+ const settings = parseCaddySettings(value);
13914
+ const blank = typeof credentialInput === "object" && credentialInput !== null && !Array.isArray(credentialInput) && credentialInput.secretId === "" && credentialInput.secretKey === "" && Reflect.ownKeys(credentialInput).every((key) => key === "secretId" || key === "secretKey");
13915
+ const credentials = credentialInput === void 0 || blank ? this.credentialsValue : parseCredentials(credentialInput);
13916
+ if (credentials === void 0) throw new Error("caddy_credentials_missing");
13917
+ await writeCaddyPrivateFile(this.stateDirectory, this.settingsFile, JSON.stringify({
13918
+ settings,
13919
+ credentials
13920
+ }) + "\n");
13921
+ this.settingsValue = settings;
13922
+ this.credentialsValue = credentials;
13923
+ this.errorCodeValue = void 0;
13924
+ });
13925
+ }
13926
+ /** Write derived runtime files after authoritative settings are available. */
13927
+ prepareCaddyfile(backendPort) {
13928
+ return this.enqueue(async () => {
13929
+ const settings = this.settingsValue;
13930
+ if (settings === void 0 || this.credentialsValue === void 0) throw new Error("caddy_config_missing");
13931
+ for (const directory of [
13932
+ this.dataDirectory,
13933
+ this.configDirectory,
13934
+ this.logsDirectory
13935
+ ]) await ensureCaddyDirectory(this.stateDirectory, directory);
13936
+ await writeCaddyPrivateFile(this.stateDirectory, this.caddyfile, renderCaddyfile(settings, this.stateDirectory, backendPort));
13937
+ });
13938
+ }
13939
+ /** Remove credentials, certificates, private keys, configuration, logs, and runtime cache. */
13940
+ purge() {
13941
+ return this.enqueue(async () => {
13942
+ await removeCaddyTree(this.stateDirectory, this.rootDirectory);
13943
+ this.settingsValue = void 0;
13944
+ this.credentialsValue = void 0;
13945
+ this.errorCodeValue = void 0;
13946
+ });
13947
+ }
13948
+ enqueue(operation) {
13949
+ const task = this.queue.then(operation, operation);
13950
+ this.queue = task.then(() => void 0, () => void 0);
13951
+ return task;
13952
+ }
13953
+ };
13954
+ //#endregion
13955
+ //#region src/caddy.ts
13956
+ /** Private directories override Caddy's platform defaults; ambient API keys and admin/proxy overrides are omitted. */
13957
+ function caddyProcessEnvironment(config, settings, environment = process.env) {
13958
+ const credentials = config.credentials();
13959
+ if (credentials === void 0) throw new Error("caddy_credentials_missing");
13960
+ return {
13961
+ ...caddyInspectionEnvironment(environment),
13962
+ ...caddyCredentialEnvironment(settings.dnsProvider, credentials),
13963
+ HOME: config.rootDirectory,
13964
+ USERPROFILE: config.rootDirectory,
13965
+ APPDATA: config.configDirectory,
13966
+ LOCALAPPDATA: config.dataDirectory,
13967
+ XDG_DATA_HOME: config.dataDirectory,
13968
+ XDG_CONFIG_HOME: config.configDirectory
13969
+ };
13970
+ }
13971
+ function spawnCaddy(executable, args, environment) {
13972
+ return spawn(executable, [...args], {
13973
+ env: environment,
13974
+ shell: false,
13975
+ stdio: [
13976
+ "pipe",
13977
+ "pipe",
13978
+ "pipe"
13979
+ ],
13980
+ windowsHide: true
13981
+ });
13982
+ }
13983
+ async function validate(executable, caddyfile, environment, signal) {
13984
+ try {
13985
+ await execFileText(executable, [
13986
+ "adapt",
13987
+ "--config",
13988
+ caddyfile,
13989
+ "--adapter",
13990
+ "caddyfile"
13991
+ ], {
13992
+ env: environment,
13993
+ signal,
13994
+ timeout: 15e3,
13995
+ maxBuffer: 262144
13996
+ });
13997
+ } catch {
13998
+ throw new Error("caddy_config_validation_failed");
13999
+ }
14000
+ }
14001
+ /** Owns Caddy and a loopback Gateway until both have stopped; ready requires verified public TLS and instance identity. */
14002
+ var CaddyController = class {
14003
+ options;
14004
+ enabled = false;
14005
+ initialized = false;
14006
+ disposed = false;
14007
+ child;
14008
+ gatewayValue;
14009
+ generation = 0;
14010
+ startupAbort;
14011
+ latest = Object.freeze({
14012
+ enabled: false,
14013
+ state: "off"
14014
+ });
14015
+ queue = Promise.resolve();
14016
+ closeTask;
14017
+ constructor(options) {
14018
+ this.options = options;
14019
+ }
14020
+ initialize() {
14021
+ return this.enqueue(async () => {
14022
+ if (this.initialized) return;
14023
+ if (this.disposed) throw new Error("caddy_controller_unavailable");
14024
+ this.enabled = (await this.options.store.load()).enabled;
14025
+ this.initialized = true;
14026
+ if (this.enabled) await this.start();
14027
+ });
14028
+ }
14029
+ gateway() {
14030
+ return this.latest.state === "ready" ? this.gatewayValue : void 0;
14031
+ }
14032
+ status() {
14033
+ return this.latest;
14034
+ }
14035
+ /** Cancel a readiness attempt before its owning coordinator waits for queued teardown. */
14036
+ cancelPendingStart() {
14037
+ this.startupAbort?.abort();
14038
+ }
14039
+ setEnabled(enabled) {
14040
+ if (!enabled) this.startupAbort?.abort();
14041
+ return this.enqueue(async () => {
14042
+ this.assertAvailable();
14043
+ if (this.enabled === enabled && (!enabled || this.latest.state === "ready")) return;
14044
+ if (!enabled) await this.stop();
14045
+ await this.options.store.save({
14046
+ version: 1,
14047
+ enabled
14048
+ });
14049
+ this.enabled = enabled;
14050
+ if (enabled) await this.start();
14051
+ else this.publish({
14052
+ enabled: false,
14053
+ state: "off"
14054
+ });
14055
+ }).then(() => this.status());
14056
+ }
14057
+ reconnect() {
14058
+ this.startupAbort?.abort();
14059
+ return this.enqueue(async () => {
14060
+ this.assertAvailable();
14061
+ await this.stop();
14062
+ await this.options.store.save({
14063
+ version: 1,
14064
+ enabled: true
14065
+ });
14066
+ this.enabled = true;
14067
+ await this.start();
14068
+ }).then(() => this.status());
14069
+ }
14070
+ reset() {
14071
+ return this.setEnabled(false);
14072
+ }
14073
+ close() {
14074
+ this.disposed = true;
14075
+ this.startupAbort?.abort();
14076
+ this.closeTask ??= this.enqueue(async () => {
14077
+ await this.stop();
14078
+ this.publish({
14079
+ enabled: this.enabled,
14080
+ state: "off"
14081
+ });
14082
+ }).catch((error) => {
14083
+ this.closeTask = void 0;
14084
+ throw error;
14085
+ });
14086
+ return this.closeTask;
14087
+ }
14088
+ assertAvailable() {
14089
+ if (!this.initialized || this.disposed) throw new Error("caddy_controller_unavailable");
14090
+ }
14091
+ enqueue(operation) {
14092
+ const task = this.queue.then(operation, operation);
14093
+ this.queue = task.then(() => void 0, () => void 0);
14094
+ return task;
14095
+ }
14096
+ publish(status) {
14097
+ this.latest = Object.freeze(status);
14098
+ try {
14099
+ this.options.onStatus?.(this.latest);
14100
+ } catch {}
14101
+ }
14102
+ async start() {
14103
+ const generation = ++this.generation;
14104
+ const startup = new AbortController();
14105
+ this.startupAbort = startup;
14106
+ const settings = this.options.config.settings();
14107
+ if (settings === void 0 || this.options.config.credentials() === void 0) {
14108
+ this.publish({
14109
+ enabled: true,
14110
+ state: "unavailable",
14111
+ errorCode: "caddy_config_missing"
14112
+ });
14113
+ this.startupAbort = void 0;
14114
+ return;
14115
+ }
14116
+ if (!this.options.component.status().supported) {
14117
+ this.publish({
14118
+ enabled: true,
14119
+ state: "unavailable",
14120
+ errorCode: "caddy_component_unavailable"
14121
+ });
14122
+ this.startupAbort = void 0;
14123
+ return;
14124
+ }
14125
+ this.publish({
14126
+ enabled: true,
14127
+ state: "starting",
14128
+ origin: settings.publicOrigin
14129
+ });
14130
+ let childFailure;
14131
+ const deadline = setTimeout(() => {
14132
+ startup.abort();
14133
+ }, this.options.startupTimeoutMs ?? 12e4);
14134
+ deadline.unref();
14135
+ try {
14136
+ await this.options.component.ensureExecutable();
14137
+ if (startup.signal.aborted || this.disposed) throw new Error("caddy_start_cancelled");
14138
+ const gateway = await this.options.createGateway(settings);
14139
+ this.gatewayValue = gateway;
14140
+ if (startup.signal.aborted || this.disposed) throw new Error("caddy_start_cancelled");
14141
+ await this.options.config.prepareCaddyfile(gateway.address().port);
14142
+ const environment = caddyProcessEnvironment(this.options.config, settings);
14143
+ await (this.options.validateConfig ?? validate)(this.options.component.executable, this.options.config.caddyfile, environment, startup.signal);
14144
+ if (startup.signal.aborted || this.disposed) throw new Error("caddy_start_cancelled");
14145
+ const child = (this.options.spawnProcess ?? spawnCaddy)(this.options.component.executable, [
14146
+ "run",
14147
+ "--config",
14148
+ this.options.config.caddyfile,
14149
+ "--adapter",
14150
+ "caddyfile"
14151
+ ], environment);
14152
+ this.child = child;
14153
+ child.stdout.resume();
14154
+ child.stderr.resume();
14155
+ child.stdin.end();
14156
+ const failed = (code) => {
14157
+ if (generation !== this.generation || this.child !== child) return;
14158
+ childFailure = code;
14159
+ startup.abort();
14160
+ if (this.latest.state === "ready") this.enqueue(async () => {
14161
+ if (generation !== this.generation) return;
14162
+ await this.stop();
14163
+ if (this.enabled && !this.disposed) this.publish({
14164
+ enabled: true,
14165
+ state: "error",
14166
+ errorCode: code
14167
+ });
14168
+ }).catch(() => {
14169
+ this.publish({
14170
+ enabled: this.enabled,
14171
+ state: "error",
14172
+ errorCode: "caddy_cleanup_failed"
14173
+ });
14174
+ });
14175
+ };
14176
+ child.once("error", () => {
14177
+ failed("caddy_launch_failed");
14178
+ });
14179
+ child.once("close", () => {
14180
+ failed("caddy_exited");
14181
+ });
14182
+ const probe = this.options.probeDiscovery ?? ((origin, expected, signal) => defaultProbeDiscovery({ origin }, expected, signal));
14183
+ while (true) {
14184
+ if (startup.signal.aborted || this.disposed) throw new Error(childFailure ?? "caddy_start_timeout");
14185
+ let ready = false;
14186
+ try {
14187
+ ready = await probe(settings.publicOrigin, this.options.instanceId, startup.signal);
14188
+ } catch (error) {
14189
+ if (error instanceof Error && (error.message === "frp_discovery_mismatch" || error.message === "frp_discovery_invalid")) throw new Error("caddy_public_identity_invalid");
14190
+ }
14191
+ if (startup.signal.aborted || this.disposed || generation !== this.generation) throw new Error(childFailure ?? "caddy_start_timeout");
14192
+ if (ready) break;
14193
+ await this.waitForProbe(startup.signal);
14194
+ }
14195
+ this.publish({
14196
+ enabled: true,
14197
+ state: "ready",
14198
+ origin: settings.publicOrigin,
14199
+ backendOrigin: "http://127.0.0.1:" + String(gateway.address().port)
14200
+ });
14201
+ } catch (error) {
14202
+ ++this.generation;
14203
+ await this.stopResources();
14204
+ if (!this.disposed) this.publish({
14205
+ enabled: this.enabled,
14206
+ state: "error",
14207
+ errorCode: childFailure ?? (error instanceof Error && error.message.startsWith("caddy_") ? error.message : error.code === "EADDRINUSE" ? "caddy_backend_port_in_use" : "caddy_start_failed")
14208
+ });
14209
+ } finally {
14210
+ clearTimeout(deadline);
14211
+ if (this.startupAbort === startup) this.startupAbort = void 0;
14212
+ }
14213
+ }
14214
+ waitForProbe(signal) {
14215
+ if (signal.aborted) return Promise.resolve();
14216
+ return new Promise((resolve) => {
14217
+ const done = () => {
14218
+ clearTimeout(timer);
14219
+ signal.removeEventListener("abort", done);
14220
+ resolve();
14221
+ };
14222
+ const timer = setTimeout(done, this.options.probeIntervalMs ?? 1e3);
14223
+ timer.unref();
14224
+ signal.addEventListener("abort", done, { once: true });
14225
+ });
14226
+ }
14227
+ async stop() {
14228
+ ++this.generation;
14229
+ this.startupAbort?.abort();
14230
+ await this.stopResources();
14231
+ }
14232
+ async stopResources() {
14233
+ const child = this.child;
14234
+ const gateway = this.gatewayValue;
14235
+ await settleRemoteResources([async () => {
14236
+ if (child !== void 0) {
14237
+ await (this.options.terminateProcess ?? terminateRemoteProcess)(child);
14238
+ if (this.child === child) this.child = void 0;
14239
+ }
14240
+ }, async () => {
14241
+ if (gateway !== void 0) {
14242
+ await gateway.close();
14243
+ if (this.gatewayValue === gateway) this.gatewayValue = void 0;
14244
+ }
14245
+ }], "caddy cleanup failed");
14246
+ }
14247
+ };
14248
+ //#endregion
14249
+ //#region src/managed-origin.ts
14250
+ /** Children never independently restore a durable enabled switch. */
14251
+ function createVolatileRemoteControlStore() {
14252
+ let value = {
14253
+ version: 1,
14254
+ enabled: false
14255
+ };
14256
+ return {
14257
+ load: async () => value,
14258
+ save: async (state) => {
14259
+ value = Object.freeze({ ...state });
14260
+ }
14261
+ };
14262
+ }
14263
+ function parseMode(value) {
14264
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("origin_mode_invalid");
14265
+ const record = value;
14266
+ if (record.version !== 1 || record.mode !== "external" && record.mode !== "managed" || Reflect.ownKeys(record).some((key) => key !== "version" && key !== "mode")) throw new Error("origin_mode_invalid");
14267
+ return Object.freeze({
14268
+ version: 1,
14269
+ mode: record.mode
14270
+ });
14271
+ }
14272
+ /** Persist only origin's upstream mode, defaulting to the existing external-proxy behavior. */
14273
+ var JsonOriginModeStore = class {
14274
+ file;
14275
+ constructor(file) {
14276
+ this.file = file;
14277
+ if (!isAbsolute(file)) throw new Error("origin mode file must be absolute");
14278
+ }
14279
+ async load() {
14280
+ await assertCaddyParents(dirname(this.file), this.file);
14281
+ let entry;
14282
+ try {
14283
+ entry = await lstat(this.file);
14284
+ } catch (error) {
14285
+ if (error.code === "ENOENT") return {
14286
+ version: 1,
14287
+ mode: "external"
14288
+ };
14289
+ throw error;
14290
+ }
14291
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size > 1024) throw new Error("origin_mode_invalid");
14292
+ await restrictPrivateFile(this.file);
14293
+ return parseMode(JSON.parse(await readFile(this.file, "utf8")));
14294
+ }
14295
+ async save(value) {
14296
+ await writeCaddyPrivateFile(dirname(this.file), this.file, JSON.stringify(parseMode(value)) + "\n");
14297
+ }
14298
+ };
14299
+ /** One origin-provider owner selects exactly one upstream; external settings and shared device records remain untouched. */
14300
+ var ManagedOriginController = class {
14301
+ options;
14302
+ selected = "external";
14303
+ enabled = false;
14304
+ initialized = false;
14305
+ disposed = false;
14306
+ queue = Promise.resolve();
14307
+ closeTask;
14308
+ constructor(options) {
14309
+ this.options = options;
14310
+ }
14311
+ mode() {
14312
+ return this.selected;
14313
+ }
14314
+ status() {
14315
+ return Object.freeze({
14316
+ ...this.controller().status(),
14317
+ enabled: this.enabled,
14318
+ mode: this.selected
14319
+ });
14320
+ }
14321
+ gateway() {
14322
+ return this.enabled ? this.controller().gateway() : void 0;
14323
+ }
14324
+ initialize() {
14325
+ return this.enqueue(async () => {
14326
+ if (this.initialized) return;
14327
+ if (this.disposed) throw new Error("origin_controller_unavailable");
14328
+ this.selected = (await this.options.modeStore.load()).mode;
14329
+ this.enabled = (await this.options.store.load()).enabled;
14330
+ await this.options.external.initialize();
14331
+ await this.options.managed.initialize();
14332
+ await this.options.external.setEnabled(false);
14333
+ await this.options.managed.setEnabled(false);
14334
+ this.initialized = true;
14335
+ if (this.enabled && !this.disposed) await this.controller().setEnabled(true);
14336
+ });
14337
+ }
14338
+ selectMode(mode) {
14339
+ if (mode !== this.selected) this.options.managed.cancelPendingStart?.();
14340
+ return this.enqueue(async () => {
14341
+ this.assertAvailable();
14342
+ if (mode !== "external" && mode !== "managed") throw new Error("origin_mode_invalid");
14343
+ if (mode === this.selected) return;
14344
+ const previous = this.controller();
14345
+ await previous.setEnabled(false);
14346
+ try {
14347
+ await this.options.modeStore.save({
14348
+ version: 1,
14349
+ mode
14350
+ });
14351
+ } catch (error) {
14352
+ if (this.enabled && !this.disposed) await previous.setEnabled(true);
14353
+ throw error;
14354
+ }
14355
+ this.selected = mode;
14356
+ if (this.enabled && !this.disposed) try {
14357
+ await this.controller().setEnabled(true);
14358
+ } catch (error) {
14359
+ await this.stopBoth();
14360
+ throw error;
14361
+ }
14362
+ }).then(() => this.status());
14363
+ }
14364
+ setEnabled(enabled) {
14365
+ if (!enabled) this.options.managed.cancelPendingStart?.();
14366
+ return this.enqueue(async () => {
14367
+ this.assertAvailable();
14368
+ if (!enabled) await this.stopBoth();
14369
+ await this.options.store.save({
14370
+ version: 1,
14371
+ enabled
14372
+ });
14373
+ this.enabled = enabled;
14374
+ if (enabled && !this.disposed) try {
14375
+ await this.controller().setEnabled(true);
14376
+ } catch (error) {
14377
+ await this.stopBoth();
14378
+ throw error;
14379
+ }
14380
+ }).then(() => this.status());
14381
+ }
14382
+ reconnect() {
14383
+ this.options.managed.cancelPendingStart?.();
14384
+ return this.enqueue(async () => {
14385
+ this.assertAvailable();
14386
+ await this.stopBoth();
14387
+ await this.options.store.save({
14388
+ version: 1,
14389
+ enabled: true
14390
+ });
14391
+ this.enabled = true;
14392
+ if (!this.disposed) try {
14393
+ await this.controller().reconnect();
14394
+ } catch (error) {
14395
+ await this.stopBoth();
14396
+ throw error;
14397
+ }
14398
+ }).then(() => this.status());
14399
+ }
14400
+ reset() {
14401
+ return this.setEnabled(false);
14402
+ }
14403
+ close() {
14404
+ this.disposed = true;
14405
+ this.closeTask ??= settleRemoteResources([() => this.options.external.close(), () => this.options.managed.close()]).then(() => this.queue).catch((error) => {
14406
+ this.closeTask = void 0;
14407
+ throw error;
14408
+ });
14409
+ return this.closeTask;
14410
+ }
14411
+ controller() {
14412
+ return this.options[this.selected];
14413
+ }
14414
+ assertAvailable() {
14415
+ if (!this.initialized || this.disposed) throw new Error("origin_controller_unavailable");
14416
+ }
14417
+ stopBoth() {
14418
+ return settleRemoteResources([async () => {
14419
+ await this.options.external.setEnabled(false);
14420
+ }, async () => {
14421
+ await this.options.managed.setEnabled(false);
14422
+ }]);
14423
+ }
14424
+ enqueue(operation) {
14425
+ const task = this.queue.then(operation, operation);
14426
+ this.queue = task.then(() => void 0, () => void 0);
14427
+ return task;
14428
+ }
14429
+ };
14430
+ //#endregion
14431
+ //#region src/release-update.ts
14432
+ const PACKAGE_NAME = "dsh-mobile";
14433
+ const NPM_LATEST_URL = "https://registry.npmjs.org/dsh-mobile/latest";
14434
+ const GITHUB_LATEST_URL = "https://github.com/saya-ch/dsh-mobile/releases/latest";
14435
+ const GITHUB_API_LATEST_URL = "https://api.github.com/repos/saya-ch/dsh-mobile/releases/latest";
14436
+ const GITHUB_RELEASES_URL = "https://github.com/saya-ch/dsh-mobile/releases";
14437
+ const RELEASE_NOTES_MAX_CHARS = 4e3;
14438
+ const STATUS_CACHE_MS = 6e5;
14439
+ const REQUEST_TIMEOUT_MS = 8e3;
14440
+ const UPDATE_TIMEOUT_MS = 12e4;
14441
+ const UPDATE_TERMINATION_GRACE_MS = 1500;
14442
+ const NUMERIC_VERSION_IDENTIFIER = "(?:0|[1-9]\\d*)";
14443
+ const WILDCARD_VERSION_IDENTIFIER = "(?:[xX*])";
14444
+ const RANGE_VERSION = `(?:${`${NUMERIC_VERSION_IDENTIFIER}\\.${NUMERIC_VERSION_IDENTIFIER}\\.${NUMERIC_VERSION_IDENTIFIER}(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?`}|${`(?:${WILDCARD_VERSION_IDENTIFIER}|${NUMERIC_VERSION_IDENTIFIER}(?:\\.(?:${WILDCARD_VERSION_IDENTIFIER}|${NUMERIC_VERSION_IDENTIFIER}(?:\\.(?:${WILDCARD_VERSION_IDENTIFIER}|${NUMERIC_VERSION_IDENTIFIER}))?))?)`})`;
14445
+ const COMPARATOR = new RegExp(`^(?:<=|>=|<|>|=|~|\\^)?${RANGE_VERSION}$`, "u");
14446
+ const HYPHEN_RANGE = new RegExp(`^${RANGE_VERSION} +[-] +${RANGE_VERSION}$`, "u");
14447
+ const DIST_TAG = /^[A-Za-z][A-Za-z0-9._-]{0,127}$/u;
14448
+ function parseSemver(value) {
14449
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(value);
14450
+ if (match === null) return void 0;
14451
+ const core = [
14452
+ Number(match[1]),
14453
+ Number(match[2]),
14454
+ Number(match[3])
14455
+ ];
14456
+ if (core.some((part) => !Number.isSafeInteger(part))) return void 0;
14457
+ const prerelease = match[4] === void 0 ? [] : match[4].split(".").map((part) => /^\d+$/u.test(part) ? Number(part) : part);
14458
+ if (prerelease.some((part) => typeof part === "number" && !Number.isSafeInteger(part))) return void 0;
14459
+ return Object.freeze({
14460
+ core,
14461
+ prerelease: Object.freeze(prerelease)
14462
+ });
14463
+ }
14464
+ /** Compare two strict SemVer strings, including prerelease precedence. */
14465
+ function comparePluginVersions(left, right) {
14466
+ const a = parseSemver(left);
14467
+ const b = parseSemver(right);
14468
+ if (a === void 0 || b === void 0) return void 0;
14469
+ for (let index = 0; index < a.core.length; index += 1) {
14470
+ const difference = a.core[index] - b.core[index];
14471
+ if (difference !== 0) return Math.sign(difference);
14472
+ }
14473
+ if (a.prerelease.length === 0 || b.prerelease.length === 0) return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length === 0 ? 1 : -1;
14474
+ const length = Math.max(a.prerelease.length, b.prerelease.length);
14475
+ for (let index = 0; index < length; index += 1) {
14476
+ const leftPart = a.prerelease[index];
14477
+ const rightPart = b.prerelease[index];
14478
+ if (leftPart === void 0 || rightPart === void 0) return leftPart === void 0 ? -1 : 1;
14479
+ if (leftPart === rightPart) continue;
14480
+ if (typeof leftPart === "number" && typeof rightPart === "number") return Math.sign(leftPart - rightPart);
14481
+ if (typeof leftPart === "number") return -1;
14482
+ if (typeof rightPart === "number") return 1;
14483
+ return leftPart < rightPart ? -1 : 1;
14484
+ }
14485
+ return 0;
14486
+ }
14487
+ function isComparatorSet(value) {
14488
+ if (HYPHEN_RANGE.test(value)) return true;
14489
+ const comparators = value.replace(/(<=|>=|<|>|=|~|\^) +/gu, "$1").split(/ +/u);
14490
+ return comparators.length > 0 && comparators.every((comparator) => COMPARATOR.test(comparator));
14491
+ }
14492
+ function isNpmVersionRange(value) {
14493
+ if (!/^[0-9xX*<>=~^|.+\- ]+$/u.test(value)) return false;
14494
+ const alternatives = value.split(/ *\|\| */u);
14495
+ return alternatives.length > 0 && alternatives.every((alternative) => alternative !== "" && isComparatorSet(alternative));
14496
+ }
14497
+ /** Return whether pnpm may safely replace this profile dependency from an npm version, range, or tag. */
14498
+ function isRegistryPluginSpec(value) {
14499
+ if (typeof value !== "string" || value.trim() !== value || value === "" || /[\u0000-\u001f\u007f]/u.test(value)) return false;
14500
+ if (/\.(?:tgz|tar(?:\.gz)?)$/iu.test(value)) return false;
14501
+ return parseSemver(value) !== void 0 || isNpmVersionRange(value) || DIST_TAG.test(value);
14502
+ }
14503
+ /** Resolve the DSH profile named by the current launcher arguments. */
14504
+ function launchedProfileName(argv) {
14505
+ for (let index = 0; index < argv.length; index += 1) {
14506
+ if (argv[index] === "--profile") {
14507
+ const candidate = argv[index + 1];
14508
+ if (candidate !== void 0 && /^[\w.-]+$/u.test(candidate)) return candidate;
14509
+ }
14510
+ const match = /^--profile=([\w.-]+)$/u.exec(argv[index] ?? "");
14511
+ if (match?.[1] !== void 0) return match[1];
14512
+ }
14513
+ return "web";
14514
+ }
14515
+ /** Resolve the launcher-owned Desktop profile, or the CLI profile outside Desktop. */
14516
+ function releaseProfileDirectory(ctx, dshHome, argv) {
14517
+ const desktopProfiles = ctx.get("desktopProfiles");
14518
+ const desktopDirectory = desktopProfiles?.current?.dir;
14519
+ if (typeof desktopDirectory === "string" && isAbsolute(desktopDirectory)) return desktopDirectory;
14520
+ if (desktopProfiles !== void 0 || ctx.get("desktopRuntime") !== void 0) return void 0;
14521
+ return join(dshHome, "profiles", launchedProfileName(argv));
14522
+ }
14523
+ async function profileDependencySpec(profileDirectory) {
14524
+ try {
14525
+ const value = JSON.parse(await readFile(join(profileDirectory, "package.json"), "utf8")).dependencies?.[PACKAGE_NAME];
14526
+ return typeof value === "string" ? value : void 0;
14527
+ } catch {
14528
+ return;
14529
+ }
14530
+ }
14531
+ async function fetchNpmVersion(fetcher) {
14532
+ const response = await fetcher(NPM_LATEST_URL, {
14533
+ headers: {
14534
+ accept: "application/json",
14535
+ "user-agent": "dsh-mobile-release-check"
14536
+ },
14537
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
14538
+ });
14539
+ if (!response.ok) return void 0;
14540
+ const payload = await response.json();
14541
+ return typeof payload.version === "string" && parseSemver(payload.version) !== void 0 ? payload.version : void 0;
14542
+ }
14543
+ function githubReleaseVersion(location, responseUrl) {
14544
+ let url;
14545
+ try {
14546
+ url = new URL(location ?? responseUrl, GITHUB_LATEST_URL);
14547
+ } catch {
14548
+ return;
14549
+ }
14550
+ if (url.origin !== "https://github.com" || url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") return void 0;
14551
+ if (!url.pathname.startsWith("/saya-ch/dsh-mobile/releases/tag/v")) return void 0;
14552
+ let version;
14553
+ try {
14554
+ version = decodeURIComponent(url.pathname.slice(34));
14555
+ } catch {
14556
+ return;
14557
+ }
14558
+ return parseSemver(version) === void 0 ? void 0 : version;
14559
+ }
14560
+ function androidReleaseDownloadUrl(version) {
14561
+ if (version === void 0) return GITHUB_RELEASES_URL;
14562
+ const tag = `v${version}`;
14563
+ return `https://github.com/saya-ch/dsh-mobile/releases/download/${encodeURIComponent(tag)}/dsh-mobile-android-${encodeURIComponent(tag)}.apk`;
12767
14564
  }
12768
14565
  async function fetchAndroidVersion(fetcher) {
12769
14566
  const response = await fetcher(GITHUB_LATEST_URL, {
@@ -13324,7 +15121,8 @@ async function loadSetup(config) {
13324
15121
  if (record.version === 2) return {
13325
15122
  kind: "managed",
13326
15123
  config: withoutSetupKeys(config),
13327
- setup: parseManagedSetup(record)
15124
+ setup: parseManagedSetup(record),
15125
+ setupFile: resolve(config.setupFile)
13328
15126
  };
13329
15127
  if (record.version !== 1 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || !SETUP_KEYS.has(key))) throw new Error("mobile setup file has an unsupported format");
13330
15128
  const { version: _version, ...setup } = record;
@@ -13432,7 +15230,7 @@ function originGatewayConfig(template, settings, stateFile, instanceId, listenPo
13432
15230
  allowedCidrs: Object.freeze(validated.allowedCidrs.map(parseCidr))
13433
15231
  });
13434
15232
  }
13435
- function remoteControlPayload(provider, status, gateway, providerStatuses, cpolarComponent, cloudflaredComponent, cloudflaredConfiguration, frpComponent, frpConfiguration, originConfiguration) {
15233
+ function remoteControlPayload(provider, status, gateway, providerStatuses, cpolarComponent, cloudflaredComponent, cloudflaredConfiguration, frpComponent, frpConfiguration, originConfiguration, caddyComponentStatus) {
13436
15234
  return {
13437
15235
  provider,
13438
15236
  running: status.enabled,
@@ -13473,7 +15271,8 @@ function remoteControlPayload(provider, status, gateway, providerStatuses, cpola
13473
15271
  bundled: true,
13474
15272
  running: providerStatuses.origin.enabled,
13475
15273
  state: providerStatuses.origin.state,
13476
- configuration: originConfiguration
15274
+ configuration: originConfiguration,
15275
+ component: caddyComponentStatus
13477
15276
  }
13478
15277
  }
13479
15278
  };
@@ -13513,6 +15312,10 @@ async function apply(ctx, config) {
13513
15312
  await frpConfig.initialize();
13514
15313
  const originConfig = new OriginConfigStore(join(remoteDirectory, "origin", "config"));
13515
15314
  await originConfig.initialize();
15315
+ const caddyComponent = new CaddyComponentManager({ stateDirectory });
15316
+ await caddyComponent.initialize();
15317
+ const caddyConfig = new CaddyConfigStore(stateDirectory);
15318
+ await caddyConfig.initialize();
13516
15319
  const cloudflaredTunnel = new CloudflaredTunnelStore(join(remoteDirectory, "cloudflared"));
13517
15320
  await cloudflaredTunnel.initialize();
13518
15321
  const unregisterBuiltin = mobileAccess.registerExtension({
@@ -13548,8 +15351,13 @@ async function apply(ctx, config) {
13548
15351
  const webSocketPaths = new WebSocketPathStore(join(stateDirectory, "websocket-paths.json"));
13549
15352
  await webSocketPaths.load();
13550
15353
  const blockedUpgradePaths = new BlockedUpgradePathLog();
15354
+ const clientModulePreferences = new ClientModulePreferenceStore(join(stateDirectory, "client-modules.json"), template.excludedClientModules);
15355
+ await clientModulePreferences.initialize();
13551
15356
  let lanGateway;
13552
15357
  let preparedLanSetup;
15358
+ let trustedNetworksRestartRequired = false;
15359
+ let trustedNetworksWrite = Promise.resolve();
15360
+ let adminClosing = false;
13553
15361
  const startGateway = async (candidateConfig) => {
13554
15362
  const resolved = parseGatewayConfig({
13555
15363
  ...candidateConfig,
@@ -13557,7 +15365,7 @@ async function apply(ctx, config) {
13557
15365
  });
13558
15366
  const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, (source, code) => {
13559
15367
  logger.warn("%s discovery degraded while DSH remains available: %s", source, code);
13560
- });
15368
+ }, clientModulePreferences);
13561
15369
  await candidate.start();
13562
15370
  lanGateway = candidate;
13563
15371
  const removeTaskSink = taskEventHub.add(candidate);
@@ -13613,7 +15421,7 @@ async function apply(ctx, config) {
13613
15421
  }
13614
15422
  const createRemoteGateway = async (publicOrigin, listenPort = 0) => {
13615
15423
  const resolved = remoteGatewayConfig(template, publicOrigin, remoteDeviceFile, instanceId, listenPort);
13616
- const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
15424
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
13617
15425
  await candidate.start();
13618
15426
  return candidate;
13619
15427
  };
@@ -13629,7 +15437,7 @@ async function apply(ctx, config) {
13629
15437
  if (!isFrpSelfSignedIngress(settings)) return createRemoteGateway(publicOrigin);
13630
15438
  const ingress = await ensureFrpIngressCertificate(settings, remoteDeviceFile);
13631
15439
  const resolved = frpIngressGatewayConfig(template, settings, remoteDeviceFile, ingress);
13632
- const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
15440
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
13633
15441
  try {
13634
15442
  await candidate.start();
13635
15443
  } catch (error) {
@@ -13640,7 +15448,7 @@ async function apply(ctx, config) {
13640
15448
  };
13641
15449
  const createOriginGateway = async (settings) => {
13642
15450
  const resolved = originGatewayConfig(template, settings, remoteDeviceFile, instanceId);
13643
- const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
15451
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
13644
15452
  try {
13645
15453
  await candidate.start();
13646
15454
  } catch (error) {
@@ -13654,6 +15462,23 @@ async function apply(ctx, config) {
13654
15462
  const cloudflaredStore = new JsonMobileAccessControlStore(join(remoteDirectory, "cloudflared", "control.json"), false);
13655
15463
  const frpStore = new JsonMobileAccessControlStore(join(remoteDirectory, "frp", "control.json"), false);
13656
15464
  const originStore = new JsonMobileAccessControlStore(join(remoteDirectory, "origin", "control.json"), false);
15465
+ const caddyController = new CaddyController({
15466
+ store: createVolatileRemoteControlStore(),
15467
+ component: caddyComponent,
15468
+ config: caddyConfig,
15469
+ instanceId,
15470
+ createGateway: (settings) => createRemoteGateway(settings.publicOrigin)
15471
+ });
15472
+ const originController = new ManagedOriginController({
15473
+ store: originStore,
15474
+ modeStore: new JsonOriginModeStore(join(remoteDirectory, "origin", "upstream.json")),
15475
+ external: new OriginController({
15476
+ store: createVolatileRemoteControlStore(),
15477
+ config: originConfig,
15478
+ createGateway: createOriginGateway
15479
+ }),
15480
+ managed: caddyController
15481
+ });
13657
15482
  const remoteControllers = {
13658
15483
  tailscale: new FunnelController({
13659
15484
  store: tailscaleStore,
@@ -13692,11 +15517,7 @@ async function apply(ctx, config) {
13692
15517
  await gateway.refreshProvidedTls();
13693
15518
  }
13694
15519
  }),
13695
- origin: new OriginController({
13696
- store: originStore,
13697
- config: originConfig,
13698
- createGateway: createOriginGateway
13699
- })
15520
+ origin: originController
13700
15521
  };
13701
15522
  const remoteProviders = new RemoteProviderCoordinator(initialRemoteProvider, remoteControllers, remoteProviderStore);
13702
15523
  const remoteController = () => remoteProviders.controller();
@@ -13706,13 +15527,18 @@ async function apply(ctx, config) {
13706
15527
  controller.gateway()?.broadcastTaskEvent(event);
13707
15528
  } });
13708
15529
  }
13709
- const remotePayload = () => remoteControlPayload(remoteProviders.selected, remoteController().status(), remoteController().gateway(), {
13710
- tailscale: remoteControllers.tailscale.status(),
13711
- cpolar: remoteControllers.cpolar.status(),
13712
- cloudflared: remoteControllers.cloudflared.status(),
13713
- frp: remoteControllers.frp.status(),
13714
- origin: remoteControllers.origin.status()
13715
- }, cpolarComponent.status(), cloudflaredComponent.status(), cloudflaredTunnel.status(), frpComponent.status(), frpConfig.status(), originConfig.status());
15530
+ const remotePayload = () => ({
15531
+ ...remoteControlPayload(remoteProviders.selected, remoteController().status(), remoteController().gateway(), {
15532
+ tailscale: remoteControllers.tailscale.status(),
15533
+ cpolar: remoteControllers.cpolar.status(),
15534
+ cloudflared: remoteControllers.cloudflared.status(),
15535
+ frp: remoteControllers.frp.status(),
15536
+ origin: remoteControllers.origin.status()
15537
+ }, cpolarComponent.status(), cloudflaredComponent.status(), cloudflaredTunnel.status(), frpComponent.status(), frpConfig.status(), originConfig.status(), caddyComponent.status()),
15538
+ originMode: originController.mode(),
15539
+ caddyConfiguration: caddyConfig.status(),
15540
+ caddyState: caddyController.status()
15541
+ });
13716
15542
  const lanPayload = () => ({
13717
15543
  ...loaded.kind === "unconfigured" ? {
13718
15544
  configured: preparedLanSetup !== void 0,
@@ -13743,6 +15569,15 @@ async function apply(ctx, config) {
13743
15569
  windowsFirewall: process.platform === "win32"
13744
15570
  };
13745
15571
  };
15572
+ const trustedNetworksPayload = async () => {
15573
+ if (loaded.kind !== "managed") return { supported: false };
15574
+ return {
15575
+ supported: true,
15576
+ extraAllowedCidrs: parseManagedSetup(JSON.parse(await readFile(loaded.setupFile, "utf8"))).extraAllowedCidrs ?? [],
15577
+ restartRequired: trustedNetworksRestartRequired,
15578
+ windowsFirewall: process.platform === "win32"
15579
+ };
15580
+ };
13746
15581
  const diagnosticsPayload = async () => {
13747
15582
  let interfaceName;
13748
15583
  let networkError;
@@ -13808,6 +15643,7 @@ async function apply(ctx, config) {
13808
15643
  try {
13809
15644
  const target = parseRequestTarget(request.url);
13810
15645
  assertLocalAdminTrust(request, request.method === "POST", () => authenticateDesktopAdmin(request));
15646
+ if (adminClosing) throw new HttpError(503, "unavailable");
13811
15647
  if (target.search !== "") throw new HttpError(400, "bad_request");
13812
15648
  const lanControl = target.decodedPathname === `/api/mobile-access/control` || target.decodedPathname === `/api/mobile-access/lan/control`;
13813
15649
  if (request.method === "GET" && lanControl) {
@@ -13818,6 +15654,59 @@ async function apply(ctx, config) {
13818
15654
  sendJson(response, 200, await lanSetupPayload(), false);
13819
15655
  return;
13820
15656
  }
15657
+ if (target.decodedPathname === `/api/mobile-access/client-modules`) {
15658
+ const gateway = lanGateway ?? remoteController().gateway();
15659
+ if (gateway === void 0) throw new HttpError(409, "mobile_access_not_running");
15660
+ const operation = new AbortController();
15661
+ const abort = () => {
15662
+ operation.abort();
15663
+ };
15664
+ response.once("close", abort);
15665
+ try {
15666
+ if (request.method === "GET") {
15667
+ sendJson(response, 200, await gateway.describeClientModules(void 0, operation.signal), false);
15668
+ return;
15669
+ }
15670
+ if (request.method !== "POST") throw new HttpError(405, "method_not_allowed");
15671
+ const body = await readJsonObject(request, template.maxBodyBytes);
15672
+ if (Object.keys(body).length !== 1 || Object.keys(body).some((key) => key !== "reset" && key !== "excludedClientModules")) throw new HttpError(400, "bad_request");
15673
+ sendJson(response, 200, body.reset === true ? await gateway.resetClientModules(void 0, operation.signal) : await gateway.configureClientModules(body.excludedClientModules, void 0, operation.signal), false);
15674
+ } catch (error) {
15675
+ if (!(error instanceof ClientModuleConflictError)) throw error;
15676
+ sendJson(response, 409, {
15677
+ error: "excluded_client_modules_invalid",
15678
+ detail: error.detail
15679
+ }, false);
15680
+ } finally {
15681
+ response.removeListener("close", abort);
15682
+ operation.abort();
15683
+ }
15684
+ return;
15685
+ }
15686
+ if (target.decodedPathname === `/api/mobile-access/lan/trusted-networks`) {
15687
+ if (request.method === "GET") {
15688
+ sendJson(response, 200, await trustedNetworksPayload(), false);
15689
+ return;
15690
+ }
15691
+ if (request.method !== "POST") throw new HttpError(405, "method_not_allowed");
15692
+ if (loaded.kind !== "managed") throw new HttpError(409, "lan_trusted_networks_unavailable");
15693
+ const body = await readJsonObject(request, 4096);
15694
+ let values;
15695
+ try {
15696
+ values = parseAdditionalTrustedNetworks(body.extraAllowedCidrs);
15697
+ } catch {
15698
+ throw new HttpError(400, "lan_trusted_networks_invalid");
15699
+ }
15700
+ const write = trustedNetworksWrite.then(async () => {
15701
+ if (adminClosing) throw new HttpError(503, "unavailable");
15702
+ await saveManagedTrustedNetworks(loaded.setupFile, values);
15703
+ trustedNetworksRestartRequired = true;
15704
+ });
15705
+ trustedNetworksWrite = write.catch(() => void 0);
15706
+ await write;
15707
+ sendJson(response, 200, await trustedNetworksPayload(), false);
15708
+ return;
15709
+ }
13821
15710
  if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/diagnostics`) {
13822
15711
  sendJson(response, 200, await diagnosticsPayload(), false);
13823
15712
  return;
@@ -13945,6 +15834,63 @@ async function apply(ctx, config) {
13945
15834
  sendJson(response, 200, remotePayload(), false);
13946
15835
  return;
13947
15836
  }
15837
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/caddy/component/install`) {
15838
+ if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
15839
+ logger.info("caddy component install started");
15840
+ try {
15841
+ await remoteProviders.mutate(async () => caddyComponent.install());
15842
+ logger.info("caddy component install completed");
15843
+ } catch (error) {
15844
+ logger.error("caddy component install failed: %s", error instanceof Error ? error.stack ?? error.message : String(error));
15845
+ throw error;
15846
+ }
15847
+ sendJson(response, 200, remotePayload(), false);
15848
+ return;
15849
+ }
15850
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/caddy/settings`) {
15851
+ const body = await readJsonObject(request, 8192);
15852
+ if (body.connect !== void 0 && typeof body.connect !== "boolean") throw new HttpError(400, "bad_request");
15853
+ const configure = async () => {
15854
+ if (body.connect === true && !caddyComponent.status().installed) throw new HttpError(409, "caddy_component_unavailable");
15855
+ await caddyConfig.configure(body.settings, {
15856
+ secretId: body.secretId ?? "",
15857
+ secretKey: body.secretKey ?? ""
15858
+ });
15859
+ if (body.connect === true) {
15860
+ await originController.setEnabled(false);
15861
+ await originController.selectMode("managed");
15862
+ await originController.reconnect();
15863
+ } else if (originController.mode() === "managed" && originController.status().enabled) await originController.reconnect();
15864
+ };
15865
+ if (body.connect === true) await remoteProviders.mutateSelected("origin", configure);
15866
+ else await remoteProviders.mutate(configure);
15867
+ sendJson(response, 200, remotePayload(), false);
15868
+ return;
15869
+ }
15870
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/origin/mode`) {
15871
+ const body = await readJsonObject(request, 4096);
15872
+ if (body.mode !== "external" && body.mode !== "managed") throw new HttpError(400, "bad_request");
15873
+ const mode = body.mode;
15874
+ if (mode === "managed" && !caddyComponent.status().installed) throw new HttpError(409, "caddy_component_unavailable");
15875
+ await remoteProviders.mutate(async () => {
15876
+ await originController.selectMode(mode);
15877
+ });
15878
+ sendJson(response, 200, remotePayload(), false);
15879
+ return;
15880
+ }
15881
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/caddy/component/purge`) {
15882
+ if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
15883
+ await remoteProviders.mutate(async () => {
15884
+ if (originController.mode() === "managed") {
15885
+ await originController.setEnabled(false);
15886
+ await originController.selectMode("external");
15887
+ }
15888
+ await caddyComponent.purge();
15889
+ await caddyConfig.purge();
15890
+ });
15891
+ sendJson(response, 200, remotePayload(), false);
15892
+ return;
15893
+ }
13948
15894
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cloudflared/tunnel`) {
13949
15895
  const body = await readJsonObject(request, 8192);
13950
15896
  await remoteProviders.mutate(async () => {
@@ -14003,7 +15949,8 @@ async function apply(ctx, config) {
14003
15949
  const settings = frpConfig.settings();
14004
15950
  if (settings === void 0) throw new HttpError(409, "frp_config_missing");
14005
15951
  const check = await frpIngressSelfCheck(settings, remoteDeviceFile);
14006
- const [frpsReachable, entryReachable] = await Promise.all([probeTcpReachable(settings.serverAddress, settings.serverPort), probeTcpReachable(settings.serverAddress, resolveFrpPublicPort(settings))]);
15952
+ const entry = resolveFrpEntryProbe(settings);
15953
+ const [frpsReachable, entryReachable] = await Promise.all([probeTcpReachable(settings.serverAddress, settings.serverPort), probeTcpReachable(entry.host, entry.port)]);
14007
15954
  sendJson(response, 200, {
14008
15955
  ...remotePayload(),
14009
15956
  frpSelfCheck: {
@@ -14171,6 +16118,10 @@ async function apply(ctx, config) {
14171
16118
  };
14172
16119
  await ctx.effect(async () => {
14173
16120
  const unregister = ctx.webServer.register(adminRoute);
16121
+ const stopAdmin = () => {
16122
+ adminClosing = true;
16123
+ unregister();
16124
+ };
14174
16125
  const disposeMobileCommand = ctx.commands.register({
14175
16126
  name: "mobile",
14176
16127
  description: "定制手机界面或添加电脑能力",
@@ -14227,7 +16178,8 @@ async function apply(ctx, config) {
14227
16178
  } catch (error) {
14228
16179
  try {
14229
16180
  await settleCleanupSteps([
14230
- unregister,
16181
+ stopAdmin,
16182
+ () => trustedNetworksWrite,
14231
16183
  disposeMobileCommand,
14232
16184
  disposeTaskEvents,
14233
16185
  async () => {
@@ -14245,7 +16197,8 @@ async function apply(ctx, config) {
14245
16197
  }
14246
16198
  return async () => {
14247
16199
  await settleCleanupSteps([
14248
- unregister,
16200
+ stopAdmin,
16201
+ () => trustedNetworksWrite,
14249
16202
  disposeMobileCommand,
14250
16203
  disposeTaskEvents,
14251
16204
  async () => {
@@ -14260,6 +16213,6 @@ async function apply(ctx, config) {
14260
16213
  }, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
14261
16214
  }
14262
16215
  //#endregion
14263
- export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CERT_EXPIRING_DAYS, 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_ATTACH_DISCOVERY_PATH, FRP_ATTACH_LOCAL_DECLARATION, FRP_ATTACH_SELF_SIGNED_DECLARATION, FRP_ATTACH_VPS_DECLARATION, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FRP_DEFAULT_PROXY_NAME, FRP_DEFAULT_PUBLIC_PORT, FRP_RESERVED_PORTS, 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, createFrpAttachPlan, createFrpAttachTemplate, createFrpAttachTemplateParts, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, ensureFrpIngressCertificate, evaluateCertificateLifetime, frpAttachVpsParts, frpIngressPaths, frpIngressSelfCheck, frpProxyName, inject, isCloudflaredRegistration, isFrpSelfSignedIngress, isGloballyRoutableIpv4, isLoopbackAddress, issueServerCertificate, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, probeOriginCertificate, readCertificateRenewal, resolveAuthority, resolveFrpEntryTls, resolveFrpMode, resolveFrpPublicPort, resolveFrpVhostHttpPort, rewriteMobileIndex, validateAttachSettings, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpEntryTls, validateFrpMode, validateFrpProxyName, validateFrpPublicOrigin, validateFrpPublicPort, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateFrpVhostHttpPort, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validateWebSocketPath, watchTaskCompletions };
16216
+ export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CERT_EXPIRING_DAYS, CLOUDFLARED_COMPONENT_RELEASE, CLOUDFLARED_COMPONENT_RELEASES, CSRF_COOKIE, CSRF_HEADER, ClientModuleConflictError, ClientModulePreferenceStore, 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_ATTACH_DISCOVERY_PATH, FRP_ATTACH_LOCAL_DECLARATION, FRP_ATTACH_SELF_SIGNED_DECLARATION, FRP_ATTACH_VPS_DECLARATION, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FRP_DEFAULT_PROXY_NAME, FRP_DEFAULT_PUBLIC_PORT, FRP_RESERVED_PORTS, 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, createFrpAttachPlan, createFrpAttachTemplate, createFrpAttachTemplateParts, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, ensureFrpIngressCertificate, evaluateCertificateLifetime, frpAttachVpsParts, frpIngressPaths, frpIngressSelfCheck, frpProxyName, inject, isCloudflaredRegistration, isFrpSelfSignedIngress, isGloballyRoutableIpv4, isLoopbackAddress, issueServerCertificate, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, probeOriginCertificate, readCertificateRenewal, resolveAuthority, resolveFrpEntryTls, resolveFrpMode, resolveFrpPublicPort, resolveFrpVhostHttpPort, rewriteMobileIndex, validateAttachSettings, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpEntryTls, validateFrpMode, validateFrpProxyName, validateFrpPublicOrigin, validateFrpPublicPort, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateFrpVhostHttpPort, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validateWebSocketPath, watchTaskCompletions };
14264
16217
 
14265
16218
  //# sourceMappingURL=index.mjs.map