dsh-mobile 0.6.3 → 0.6.4

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
@@ -5,6 +5,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import z from "@deepseek-ai/schemastery";
6
6
  import { connect, createServer } from "node:net";
7
7
  import { chmod, copyFile, lstat, mkdir, mkdtemp, open, opendir, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
8
+ import { performance as performance$1 } from "node:perf_hooks";
8
9
  import { execFile, spawn } from "node:child_process";
9
10
  import { createSocket } from "node:dgram";
10
11
  import { homedir, hostname, networkInterfaces, tmpdir } from "node:os";
@@ -1151,24 +1152,75 @@ function execFileText(file, args, options = {}) {
1151
1152
  //#endregion
1152
1153
  //#region src/private-file.ts
1153
1154
  let userSidTask;
1155
+ function commandFailureDetails(error) {
1156
+ if (error === null || typeof error !== "object") return {};
1157
+ let descriptors;
1158
+ try {
1159
+ descriptors = Object.getOwnPropertyDescriptors(error);
1160
+ } catch (_error) {
1161
+ return {};
1162
+ }
1163
+ const code = descriptors.code?.value;
1164
+ const signal = descriptors.signal?.value;
1165
+ const killed = descriptors.killed?.value;
1166
+ return {
1167
+ ...code === null || typeof code === "number" && Number.isSafeInteger(code) || typeof code === "string" && /^[A-Z][A-Z0-9_]{0,63}$/u.test(code) ? { code } : {},
1168
+ ...signal === null || typeof signal === "string" && /^SIG[A-Z0-9]{1,16}$/u.test(signal) ? { signal } : {},
1169
+ ...typeof killed === "boolean" ? { killed } : {}
1170
+ };
1171
+ }
1172
+ /** Phase and full await duration, including subprocess creation, without arguments, output or private paths. */
1173
+ var PrivateFilePermissionError = class extends Error {
1174
+ stage;
1175
+ elapsedMs;
1176
+ code;
1177
+ signal;
1178
+ killed;
1179
+ constructor(stage, elapsedMs, error) {
1180
+ const details = commandFailureDetails(error);
1181
+ const fields = [
1182
+ ...details.code === void 0 ? [] : [`code=${String(details.code)}`],
1183
+ ...details.signal === void 0 ? [] : [`signal=${String(details.signal)}`],
1184
+ ...details.killed === void 0 ? [] : [`killed=${String(details.killed)}`]
1185
+ ];
1186
+ const summary = `Windows private-file ${stage} failed after ${String(elapsedMs)} ms${fields.length === 0 ? "" : ` (${fields.join(", ")})`}`;
1187
+ const cause = Object.assign(/* @__PURE__ */ new Error("Windows permission operation failed"), details);
1188
+ super(summary, { cause });
1189
+ this.stage = stage;
1190
+ this.elapsedMs = elapsedMs;
1191
+ this.name = "PrivateFilePermissionError";
1192
+ this.code = details.code;
1193
+ this.signal = details.signal;
1194
+ this.killed = details.killed;
1195
+ }
1196
+ };
1197
+ async function permissionOperation(stage, operation) {
1198
+ const started = performance$1.now();
1199
+ try {
1200
+ return await operation();
1201
+ } catch (error) {
1202
+ throw new PrivateFilePermissionError(stage, Math.max(0, Math.round(performance$1.now() - started)), error);
1203
+ }
1204
+ }
1154
1205
  function windowsSystemTool(name) {
1155
1206
  const systemRoot = process.env.SystemRoot ?? process.env.WINDIR;
1156
- if (systemRoot === void 0 || !win32.isAbsolute(systemRoot)) throw new Error("Windows system root is unavailable");
1207
+ if (systemRoot === void 0 || !win32.isAbsolute(systemRoot)) throw Object.assign(/* @__PURE__ */ new Error("Windows system root is unavailable"), { code: "WINDOWS_SYSTEM_ROOT_UNAVAILABLE" });
1157
1208
  return win32.join(systemRoot, "System32", name);
1158
1209
  }
1159
1210
  async function currentWindowsUserSid() {
1160
- userSidTask ??= execFileText(windowsSystemTool("whoami.exe"), [
1161
- "/user",
1162
- "/fo",
1163
- "csv",
1164
- "/nh"
1165
- ], {
1166
- encoding: "utf8",
1167
- windowsHide: true,
1168
- timeout: 1e4
1169
- }).then(({ stdout }) => {
1211
+ userSidTask ??= permissionOperation("sid-query", async () => {
1212
+ const { stdout } = await execFileText(windowsSystemTool("whoami.exe"), [
1213
+ "/user",
1214
+ "/fo",
1215
+ "csv",
1216
+ "/nh"
1217
+ ], {
1218
+ encoding: "utf8",
1219
+ windowsHide: true,
1220
+ timeout: 1e4
1221
+ });
1170
1222
  const match = /,"(S-\d(?:-\d+)+)"\s*$/u.exec(stdout.trim());
1171
- if (match?.[1] === void 0) throw new Error("unable to resolve the current Windows user SID");
1223
+ if (match?.[1] === void 0) throw Object.assign(/* @__PURE__ */ new Error("unable to resolve the current Windows user SID"), { code: "WINDOWS_USER_SID_INVALID" });
1172
1224
  return match[1];
1173
1225
  }).catch((error) => {
1174
1226
  userSidTask = void 0;
@@ -1176,12 +1228,12 @@ async function currentWindowsUserSid() {
1176
1228
  });
1177
1229
  return userSidTask;
1178
1230
  }
1179
- /** Restrict a sensitive regular file to the current user and Windows administrators. */
1231
+ /** Restrict a sensitive regular file to the current user and Windows administrators; reject Windows permission failures with redacted phase and duration. */
1180
1232
  async function restrictPrivateFile(file, mode = 384) {
1181
1233
  await chmod(file, mode);
1182
1234
  if (process.platform !== "win32") return;
1183
1235
  const userSid = await currentWindowsUserSid();
1184
- await execFileText(windowsSystemTool("icacls.exe"), [
1236
+ await permissionOperation("acl-apply", () => execFileText(windowsSystemTool("icacls.exe"), [
1185
1237
  file,
1186
1238
  "/inheritance:r",
1187
1239
  "/grant:r",
@@ -1196,7 +1248,7 @@ async function restrictPrivateFile(file, mode = 384) {
1196
1248
  encoding: "utf8",
1197
1249
  windowsHide: true,
1198
1250
  timeout: 1e4
1199
- });
1251
+ }));
1200
1252
  }
1201
1253
  //#endregion
1202
1254
  //#region src/client-module-preferences.ts
@@ -1457,24 +1509,23 @@ var JsonMobileAccessControlStore = class {
1457
1509
  this.file = file;
1458
1510
  this.initiallyEnabled = initiallyEnabled;
1459
1511
  }
1460
- /** Avoid writing an absent already-disabled state without changing recovery of existing files. */
1512
+ /** Disable an existing preference, returning the state only after private-file preparation completes. */
1461
1513
  async disableIfPresent() {
1514
+ const disabled = Object.freeze({
1515
+ version: 1,
1516
+ enabled: false
1517
+ });
1462
1518
  try {
1463
1519
  await lstat(this.file);
1464
1520
  } catch (error) {
1465
1521
  if (error.code === "ENOENT") {
1466
- if (this.initiallyEnabled) await this.save({
1467
- version: 1,
1468
- enabled: false
1469
- });
1470
- return;
1522
+ if (this.initiallyEnabled) await this.save(disabled);
1523
+ return disabled;
1471
1524
  }
1472
1525
  throw error;
1473
1526
  }
1474
- await this.save({
1475
- version: 1,
1476
- enabled: false
1477
- });
1527
+ await this.save(disabled);
1528
+ return disabled;
1478
1529
  }
1479
1530
  async load() {
1480
1531
  let stat;
@@ -1517,8 +1568,8 @@ var JsonMobileAccessControlStore = class {
1517
1568
  flag: "wx",
1518
1569
  mode: 384
1519
1570
  });
1571
+ await restrictPrivateFile(temporary);
1520
1572
  await rename(temporary, this.file);
1521
- await restrictPrivateFile(this.file);
1522
1573
  } catch (error) {
1523
1574
  try {
1524
1575
  await rm(temporary, { force: true });
@@ -1960,6 +2011,44 @@ function ensureMobileCompatibility(html) {
1960
2011
  return `${html.slice(0, first.index)}${script}${html.slice(first.index)}`;
1961
2012
  }
1962
2013
  //#endregion
2014
+ //#region src/http-cache.ts
2015
+ /** Entity-tag handling for authenticated GET and HEAD cache validation. */
2016
+ const ENTITY_TAG = /^[\t ]*(?:W\/)?("[\x21\x23-\x7e\x80-\xff]*")[\t ]*$/u;
2017
+ const ENTITY_TAG_LIST_ITEM = /(?:W\/)?"[\x21\x23-\x7e\x80-\xff]*"/gu;
2018
+ /**
2019
+ * Compare a GET or HEAD If-None-Match field with an existing representation.
2020
+ * Invalid fields do not match; empty list elements are ignored per HTTP list syntax.
2021
+ * @param field - The received conditional request field.
2022
+ * @param etag - The selected representation's valid entity tag.
2023
+ * @returns Whether the representation is unchanged under weak comparison.
2024
+ */
2025
+ function matchesIfNoneMatch(field, etag) {
2026
+ if (field === void 0) return false;
2027
+ const current = ENTITY_TAG.exec(etag)?.[1];
2028
+ if (current === void 0) return false;
2029
+ if (/^[\t ]*\*[\t ]*$/u.test(field)) return true;
2030
+ let cursor = 0;
2031
+ let count = 0;
2032
+ let matches = false;
2033
+ for (const item of field.matchAll(ENTITY_TAG_LIST_ITEM)) {
2034
+ const gap = field.slice(cursor, item.index);
2035
+ if (!(count === 0 ? /^[\t ,]*$/u : /^[\t ]*,[\t ,]*$/u).test(gap)) return false;
2036
+ if ((item[0].startsWith("W/") ? item[0].slice(2) : item[0]) === current) matches = true;
2037
+ cursor = item.index + item[0].length;
2038
+ count++;
2039
+ }
2040
+ return count > 0 && /^[\t ,]*$/u.test(field.slice(cursor)) && matches;
2041
+ }
2042
+ /**
2043
+ * Keep an upstream validator usable after lossless content encoding changes.
2044
+ * @param etag - An upstream entity tag, if supplied.
2045
+ * @returns A weak quoted entity tag, or undefined for an absent or invalid tag.
2046
+ */
2047
+ function weakenEntityTag(etag) {
2048
+ const opaque = etag === void 0 ? void 0 : ENTITY_TAG.exec(etag)?.[1];
2049
+ return opaque === void 0 ? void 0 : `W/${opaque}`;
2050
+ }
2051
+ //#endregion
1963
2052
  //#region src/browser-auth-cookies.ts
1964
2053
  const HOST_SESSION_COOKIE = "__Host-dsh_ma_session";
1965
2054
  const HOST_CSRF_COOKIE = "__Host-dsh_ma_csrf";
@@ -2993,15 +3082,15 @@ const HOST_ACTIVATION_TIMEOUT_MS = 5e3;
2993
3082
  const RETIRED_GENERATION_TTL_MS = 6e5;
2994
3083
  /** Extension teardown is advisory and must never stop watcher progress. */
2995
3084
  const HOST_TEARDOWN_TIMEOUT_MS = 2e3;
2996
- async function withActivationTimeout(promise, id, signal) {
3085
+ async function withActivationTimeout(promise, id, signal, timeoutMs) {
2997
3086
  let timer;
2998
3087
  let onAbort;
2999
3088
  try {
3000
3089
  return await Promise.race([
3001
3090
  promise,
3002
- new Promise((_, reject) => {
3003
- timer = setTimeout(() => reject(new MobileExtensionError("host_load_timeout", `extension ${id} activation timed out`, 500)), HOST_ACTIVATION_TIMEOUT_MS);
3004
- }),
3091
+ ...timeoutMs === void 0 ? [] : [new Promise((_, reject) => {
3092
+ timer = setTimeout(() => reject(new MobileExtensionError("host_load_timeout", `extension ${id} activation timed out`, 500)), timeoutMs);
3093
+ })],
3005
3094
  new Promise((_, reject) => {
3006
3095
  const abort = () => {
3007
3096
  reject(new MobileExtensionError("host_activation_closed", `extension ${id} activation is closed`, 409));
@@ -3554,7 +3643,7 @@ var MobileAccessService = class extends Service {
3554
3643
  if (extension === void 0) throw new MobileExtensionError("extension_not_found", "extension not found", 404);
3555
3644
  const route = ("host" in extension ? extension.host : extension).routes?.find((candidate) => {
3556
3645
  if (candidate.method !== method) return false;
3557
- return (candidate.kind ?? "exact") === "exact" ? candidate.path === pathname : pathname === candidate.path || pathname.startsWith(`${candidate.path}/`);
3646
+ return (candidate.kind ?? "exact") === "exact" ? candidate.path === pathname : candidate.path === "/" || pathname === candidate.path || pathname.startsWith(`${candidate.path}/`);
3558
3647
  });
3559
3648
  if (route === void 0) throw new MobileExtensionError("route_not_found", "route not found", 404);
3560
3649
  if ("host" in extension) extension.controller.signal.throwIfAborted();
@@ -3963,7 +4052,7 @@ async function loadLocalExtension(directory, context, known, parentSignal, execu
3963
4052
  let host;
3964
4053
  if (worker !== void 0) {
3965
4054
  try {
3966
- await withActivationTimeout(worker.activate(), manifest.id, controller.signal);
4055
+ await withActivationTimeout(worker.activate(), manifest.id, controller.signal, void 0);
3967
4056
  } catch (error) {
3968
4057
  await worker.terminate("activation-failed");
3969
4058
  throw error;
@@ -4007,7 +4096,7 @@ async function loadLocalExtension(directory, context, known, parentSignal, execu
4007
4096
  }
4008
4097
  await Promise.all(pendingEffects);
4009
4098
  };
4010
- await withActivationTimeout(activate(), manifest.id, controller.signal);
4099
+ await withActivationTimeout(activate(), manifest.id, controller.signal, HOST_ACTIVATION_TIMEOUT_MS);
4011
4100
  host = validateDefinition({
4012
4101
  ...manifest,
4013
4102
  actions,
@@ -4070,8 +4159,14 @@ const COPY = {
4070
4159
  deviceName: "Device name",
4071
4160
  pair: "Pair",
4072
4161
  pairing: "Pairing…",
4073
- pairFailed: "Pairing failed",
4074
- unavailable: "The computer is unavailable.",
4162
+ pairingCodeHint: "Use the 43-character pairing code shown on the computer. Keys starting with dsh1. or dsh2. are for the Android app.",
4163
+ invalidPairingCode: "Enter the 43-character pairing code from the current pairing window on the computer.",
4164
+ appKeyNotCode: "This is an Android app key. Use it in the app, or copy the browser pairing code from the computer.",
4165
+ pairFailed: "Pairing failed. Check the code or generate a new one on the computer.",
4166
+ pairingExpired: "The pairing code is invalid or has expired. Generate a new code on the computer and try again.",
4167
+ pairingLimited: "Too many pairing attempts. Wait a moment and try again.",
4168
+ deviceLimit: "The paired-device limit has been reached. Remove an unused device and generate a new pairing code on the computer.",
4169
+ unavailable: "The computer is unavailable. Check the connection and try again.",
4075
4170
  reconnectTitle: "Reconnect DSH mobile access",
4076
4171
  reconnectHeading: "Reconnect this device",
4077
4172
  restoring: "Restoring the secure Session…",
@@ -4086,8 +4181,14 @@ const COPY = {
4086
4181
  deviceName: "设备名称",
4087
4182
  pair: "配对",
4088
4183
  pairing: "正在配对…",
4089
- pairFailed: "配对失败",
4090
- unavailable: "电脑当前不可用。",
4184
+ pairingCodeHint: "请输入电脑端显示的 43 位配对码。以 dsh1. 或 dsh2. 开头的密钥用于 Android App。",
4185
+ invalidPairingCode: "请输入电脑端当前配对窗口中的 43 位配对码。",
4186
+ appKeyNotCode: "这是 Android App 密钥。请在 App 中使用,或回到电脑端复制浏览器配对码。",
4187
+ pairFailed: "配对失败,请检查输入或在电脑端重新生成配对码。",
4188
+ pairingExpired: "配对码无效或已过期,请在电脑端重新生成后重试。",
4189
+ pairingLimited: "配对尝试过于频繁,请稍后再试。",
4190
+ deviceLimit: "已达到配对设备上限,请在电脑端移除不需要的设备后重新生成配对码。",
4191
+ unavailable: "电脑当前不可用,请检查连接后重试。",
4091
4192
  reconnectTitle: "重新连接 DSH 移动访问",
4092
4193
  reconnectHeading: "重新连接此设备",
4093
4194
  restoring: "正在恢复安全会话…",
@@ -4102,8 +4203,14 @@ const COPY = {
4102
4203
  deviceName: "Nome dispositivo",
4103
4204
  pair: "Abbina",
4104
4205
  pairing: "Abbinamento…",
4105
- pairFailed: "Abbinamento non riuscito",
4106
- unavailable: "Il computer non è disponibile.",
4206
+ pairingCodeHint: "Usa il codice di abbinamento di 43 caratteri mostrato sul computer. Le chiavi che iniziano con dsh1. o dsh2. sono per l’app Android.",
4207
+ invalidPairingCode: "Inserisci il codice di 43 caratteri dalla finestra di abbinamento attuale sul computer.",
4208
+ appKeyNotCode: "Questa è una chiave per l’app Android. Usala nell’app oppure copia il codice di abbinamento per il browser dal computer.",
4209
+ pairFailed: "Abbinamento non riuscito. Controlla il codice o generane uno nuovo sul computer.",
4210
+ pairingExpired: "Il codice non è valido o è scaduto. Genera un nuovo codice sul computer e riprova.",
4211
+ pairingLimited: "Troppi tentativi di abbinamento. Attendi un momento e riprova.",
4212
+ deviceLimit: "È stato raggiunto il limite di dispositivi abbinati. Rimuovi un dispositivo inutilizzato e genera un nuovo codice sul computer.",
4213
+ unavailable: "Il computer non è disponibile. Controlla la connessione e riprova.",
4107
4214
  reconnectTitle: "Riconnetti accesso mobile DSH",
4108
4215
  reconnectHeading: "Riconnetti questo dispositivo",
4109
4216
  restoring: "Ripristino della sessione sicura…",
@@ -4142,7 +4249,8 @@ function renderPairPage(locale) {
4142
4249
  <main>
4143
4250
  <h1>${copy.pairHeading}</h1>
4144
4251
  <form id="pair-form">
4145
- <label>${copy.pairingCode} <input id="pair-token" autocomplete="one-time-code" required></label>
4252
+ <label>${copy.pairingCode} <input id="pair-token" autocomplete="one-time-code" autocapitalize="off" spellcheck="false" aria-describedby="pair-token-hint pair-status" required></label>
4253
+ <p id="pair-token-hint">${copy.pairingCodeHint}</p>
4146
4254
  <label>${copy.deviceName} <input id="device-label" maxlength="64" autocomplete="off"></label>
4147
4255
  <button type="submit">${copy.pair}</button>
4148
4256
  <output id="pair-status" aria-live="polite"></output>
@@ -4160,27 +4268,54 @@ function renderPairScript(locale) {
4160
4268
  const token = document.getElementById('pair-token')
4161
4269
  const label = document.getElementById('device-label')
4162
4270
  const status = document.getElementById('pair-status')
4271
+ const submit = form.querySelector('button[type="submit"]')
4272
+ let submitting = false
4163
4273
  const fragment = new URLSearchParams(location.hash.slice(1))
4164
4274
  const supplied = fragment.get('token')
4165
4275
  history.replaceState(null, '', location.pathname)
4166
4276
  if (supplied) token.value = supplied
4277
+ token.addEventListener('input', () => {
4278
+ token.removeAttribute('aria-invalid')
4279
+ if (!submitting) status.value = ''
4280
+ })
4167
4281
  form.addEventListener('submit', async (event) => {
4168
4282
  event.preventDefault()
4283
+ if (submitting) return
4284
+ const code = token.value.trim()
4285
+ token.removeAttribute('aria-invalid')
4286
+ if (/^dsh[12]\\./u.test(code) || !/^[A-Za-z0-9_-]{43}$/u.test(code)) {
4287
+ status.value = /^dsh[12]\\./u.test(code)
4288
+ ? ${JSON.stringify(copy.appKeyNotCode)} : ${JSON.stringify(copy.invalidPairingCode)}
4289
+ token.setAttribute('aria-invalid', 'true')
4290
+ token.focus()
4291
+ return
4292
+ }
4293
+ submitting = true
4294
+ submit.disabled = true
4169
4295
  status.value = ${JSON.stringify(copy.pairing)}
4170
4296
  try {
4171
4297
  const response = await fetch('/mobile-access/auth/pair', {
4172
4298
  method: 'POST',
4173
4299
  credentials: 'same-origin',
4174
4300
  headers: { 'content-type': 'application/json' },
4175
- body: JSON.stringify({ token: token.value, label: label.value || undefined }),
4301
+ body: JSON.stringify({ token: code, label: label.value || undefined }),
4176
4302
  })
4177
4303
  if (!response.ok) {
4178
- status.value = ${JSON.stringify(copy.pairFailed)}
4304
+ if (token.value.trim() !== code) { status.value = ''; return }
4305
+ status.value = response.status === 401 ? ${JSON.stringify(copy.pairingExpired)}
4306
+ : response.status === 429 ? ${JSON.stringify(copy.pairingLimited)}
4307
+ : response.status === 409 ? ${JSON.stringify(copy.deviceLimit)}
4308
+ : response.status >= 500 ? ${JSON.stringify(copy.unavailable)}
4309
+ : ${JSON.stringify(copy.pairFailed)}
4310
+ if (response.status === 401) token.setAttribute('aria-invalid', 'true')
4179
4311
  return
4180
4312
  }
4181
4313
  location.replace('/')
4182
4314
  } catch {
4183
- status.value = ${JSON.stringify(copy.unavailable)}
4315
+ status.value = token.value.trim() === code ? ${JSON.stringify(copy.unavailable)} : ''
4316
+ } finally {
4317
+ submitting = false
4318
+ submit.disabled = false
4184
4319
  }
4185
4320
  })
4186
4321
  })()
@@ -4793,6 +4928,7 @@ function sanitizeRequestHeaders(request, upstream) {
4793
4928
  "if-match",
4794
4929
  "if-modified-since",
4795
4930
  "if-none-match",
4931
+ "if-range",
4796
4932
  "if-unmodified-since",
4797
4933
  "range",
4798
4934
  "user-agent"
@@ -4872,13 +5008,13 @@ function isCompressibleContentType(value) {
4872
5008
  const mediaType = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
4873
5009
  return mediaType.startsWith("text/") || /^(?:application\/(?:javascript|json|xml|x-javascript)|image\/svg\+xml)$/u.test(mediaType);
4874
5010
  }
4875
- function shouldCompressResponse(request, response) {
5011
+ function canCompressResponse(request, response) {
4876
5012
  const pathname = request.url?.split("?", 1)[0] ?? "";
4877
- return request.method === "GET" && (pathname.startsWith("/plugins/") || pathname.startsWith("/assets/")) && response.statusCode === 200 && request.headers.range === void 0 && response.headers["content-range"] === void 0 && response.headers["content-encoding"] === void 0 && acceptsGzip(request.headers["accept-encoding"]) && isCompressibleContentType(response.headers["content-type"]);
5013
+ return (request.method === "GET" || request.method === "HEAD") && (pathname.startsWith("/plugins/") || pathname.startsWith("/assets/")) && response.statusCode === 200 && request.headers.range === void 0 && response.headers["content-range"] === void 0 && response.headers["content-encoding"] === void 0 && isCompressibleContentType(response.headers["content-type"]);
4878
5014
  }
4879
5015
  function revisionedStaticCacheControl(request, statusCode) {
4880
5016
  if (request.method !== "GET" && request.method !== "HEAD") return void 0;
4881
- if (statusCode !== 200) return void 0;
5017
+ if (statusCode !== 200 && statusCode !== 304) return void 0;
4882
5018
  let target;
4883
5019
  try {
4884
5020
  target = new URL(request.url ?? "/", "https://dsh-mobile.invalid");
@@ -5689,7 +5825,7 @@ var MobileAccessGateway = class {
5689
5825
  const clientModules = target.decodedPathname === `${AUTH_PREFIX}/client-modules`;
5690
5826
  const requestedExtension = extensionTarget(target.decodedPathname);
5691
5827
  const requestedMobileBootBatch = mobileBootBatchKey(target.decodedPathname);
5692
- const customAsset = request.method === "GET" ? target.decodedPathname === `/mobile-access/custom.css` ? {
5828
+ const customAsset = request.method === "GET" || request.method === "HEAD" ? target.decodedPathname === `/mobile-access/custom.css` ? {
5693
5829
  file: this.config.customCssFile,
5694
5830
  contentType: "text/css; charset=utf-8",
5695
5831
  fallback: CUSTOM_STYLE_FALLBACK
@@ -5798,23 +5934,23 @@ var MobileAccessGateway = class {
5798
5934
  body = Buffer.from(customAsset.fallback);
5799
5935
  }
5800
5936
  if (body.byteLength > 262144) throw new HttpError(413, "payload_too_large");
5801
- const etag = createHash("sha256").update(body).digest("hex");
5802
- const ifNoneMatch = headerValue(request.headers, "if-none-match");
5803
- if (ifNoneMatch !== void 0 && ifNoneMatch === etag) {
5937
+ const etag = `"${createHash("sha256").update(body).digest("hex")}"`;
5938
+ const responseHeaders = { ETag: etag };
5939
+ if (mtime !== void 0) responseHeaders["Last-Modified"] = mtime.toUTCString();
5940
+ if (matchesIfNoneMatch(headerValue(request.headers, "if-none-match"), etag)) {
5804
5941
  setSecurityHeaders(response, this.tlsEnabled);
5805
- response.writeHead(304);
5942
+ response.writeHead(304, responseHeaders);
5806
5943
  response.end();
5807
5944
  return;
5808
5945
  }
5809
5946
  setSecurityHeaders(response, this.tlsEnabled);
5810
- const responseHeaders = {
5947
+ response.writeHead(200, {
5948
+ ...responseHeaders,
5811
5949
  "Content-Type": customAsset.contentType,
5812
- "Content-Length": body.byteLength,
5813
- "ETag": etag
5814
- };
5815
- if (mtime !== void 0) responseHeaders["Last-Modified"] = mtime.toUTCString();
5816
- response.writeHead(200, responseHeaders);
5817
- response.end(body);
5950
+ "Content-Length": body.byteLength
5951
+ });
5952
+ if (request.method === "HEAD") response.end();
5953
+ else response.end(body);
5818
5954
  return;
5819
5955
  } finally {
5820
5956
  operation.release();
@@ -5890,10 +6026,10 @@ var MobileAccessGateway = class {
5890
6026
  styleRevision
5891
6027
  }
5892
6028
  }));
5893
- const etag = createHash("sha256").update(body).update(extensions.contentDigest()).digest("hex");
5894
- if (headerValue(request.headers, "if-none-match") === etag) {
6029
+ const etag = `"${createHash("sha256").update(body).update(extensions.contentDigest()).digest("hex")}"`;
6030
+ if (matchesIfNoneMatch(headerValue(request.headers, "if-none-match"), etag)) {
5895
6031
  setSecurityHeaders(response, this.tlsEnabled);
5896
- response.writeHead(304);
6032
+ response.writeHead(304, { ETag: etag });
5897
6033
  response.end();
5898
6034
  return;
5899
6035
  }
@@ -5916,9 +6052,10 @@ var MobileAccessGateway = class {
5916
6052
  const operation = this.allocateRequest(authorization, response, {});
5917
6053
  try {
5918
6054
  const file = targetInfo.kind === "script" ? await extensions.readClientFile(targetInfo.id, "script", operation.signal, generation) : targetInfo.kind === "style" ? await extensions.readClientFile(targetInfo.id, "style", operation.signal, generation) : await extensions.readAsset(targetInfo.id, targetInfo.path ?? "", operation.signal, generation);
5919
- if (headerValue(request.headers, "if-none-match") === file.digest) {
6055
+ const etag = `"${file.digest}"`;
6056
+ if (matchesIfNoneMatch(headerValue(request.headers, "if-none-match"), etag)) {
5920
6057
  setSecurityHeaders(response, this.tlsEnabled);
5921
- response.writeHead(304);
6058
+ response.writeHead(304, { ETag: etag });
5922
6059
  response.end();
5923
6060
  return;
5924
6061
  }
@@ -5927,7 +6064,7 @@ var MobileAccessGateway = class {
5927
6064
  response.writeHead(200, {
5928
6065
  "Content-Type": contentType,
5929
6066
  "Content-Length": file.body.byteLength,
5930
- ETag: file.digest
6067
+ ETag: etag
5931
6068
  });
5932
6069
  if (request.method === "HEAD") response.end();
5933
6070
  else response.end(file.body);
@@ -6034,36 +6171,41 @@ var MobileAccessGateway = class {
6034
6171
  }
6035
6172
  }
6036
6173
  async sendExtensionResponse(response, result, head) {
6037
- const status = result.status ?? 200;
6038
- if (!Number.isSafeInteger(status) || status < 200 || status > 599) throw new MobileExtensionError("invalid_route_response", "extension returned an invalid HTTP status", 500);
6039
- const contentType = result.contentType ?? "application/octet-stream";
6040
- if (contentType.length > 1024 || !/^[\x20-\x7e]+$/u.test(contentType) || !/^[\w!#$&+.^-]+\/[\w!#$&+.^-]+(?:;[\x20-\x7e]*)?$/u.test(contentType)) throw new MobileExtensionError("invalid_route_response", "extension returned an invalid content type", 500);
6041
- const safeHeaders = {};
6042
- for (const [name, value] of Object.entries(result.headers ?? {})) {
6043
- if (!/^(?:content-disposition|cache-control|etag)$/iu.test(name) || /[\r\n]/u.test(value)) continue;
6044
- safeHeaders[name] = value;
6045
- }
6046
- setSecurityHeaders(response, this.tlsEnabled);
6047
- if (typeof result.body === "string" || result.body instanceof Uint8Array) {
6048
- const body = typeof result.body === "string" ? Buffer.from(result.body) : Buffer.from(result.body);
6049
- if (body.byteLength > 4194304) throw new MobileExtensionError("extension_result_too_large", "extension response is too large", 500);
6174
+ try {
6175
+ const status = result.status ?? 200;
6176
+ if (!Number.isSafeInteger(status) || status < 200 || status > 599) throw new MobileExtensionError("invalid_route_response", "extension returned an invalid HTTP status", 500);
6177
+ const contentType = result.contentType ?? "application/octet-stream";
6178
+ if (contentType.length > 1024 || !/^[\x20-\x7e]+$/u.test(contentType) || !/^[\w!#$&+.^-]+\/[\w!#$&+.^-]+(?:;[\x20-\x7e]*)?$/u.test(contentType)) throw new MobileExtensionError("invalid_route_response", "extension returned an invalid content type", 500);
6179
+ const safeHeaders = {};
6180
+ for (const [name, value] of Object.entries(result.headers ?? {})) {
6181
+ if (!/^(?:content-disposition|cache-control|etag)$/iu.test(name) || /[\r\n]/u.test(value)) continue;
6182
+ safeHeaders[name] = value;
6183
+ }
6184
+ setSecurityHeaders(response, this.tlsEnabled);
6185
+ if (typeof result.body === "string" || result.body instanceof Uint8Array) {
6186
+ const body = typeof result.body === "string" ? Buffer.from(result.body) : Buffer.from(result.body);
6187
+ if (body.byteLength > 4194304) throw new MobileExtensionError("extension_result_too_large", "extension response is too large", 500);
6188
+ response.writeHead(status, {
6189
+ ...safeHeaders,
6190
+ "Content-Type": contentType,
6191
+ "Content-Length": body.byteLength
6192
+ });
6193
+ if (head) response.end();
6194
+ else response.end(body);
6195
+ return;
6196
+ }
6050
6197
  response.writeHead(status, {
6051
6198
  ...safeHeaders,
6052
- "Content-Type": contentType,
6053
- "Content-Length": body.byteLength
6199
+ "Content-Type": contentType
6054
6200
  });
6055
- if (head) response.end();
6056
- else response.end(body);
6057
- return;
6058
- }
6059
- response.writeHead(status, {
6060
- ...safeHeaders,
6061
- "Content-Type": contentType
6062
- });
6063
- if (head) {
6064
- result.body.destroy();
6065
- response.end();
6066
- return;
6201
+ if (head) {
6202
+ result.body.destroy();
6203
+ response.end();
6204
+ return;
6205
+ }
6206
+ } catch (error) {
6207
+ disposeLateExtensionResponse(result);
6208
+ throw error;
6067
6209
  }
6068
6210
  await pipeline(result.body, new ByteLimitTransform(4194304), response);
6069
6211
  }
@@ -6479,7 +6621,7 @@ var MobileAccessGateway = class {
6479
6621
  const assembled = stored.body;
6480
6622
  if (assembled === void 0) throw new HttpError(502, "upstream_unavailable");
6481
6623
  const body = compressed ? stored.gzipBody ??= await gzipBuffer(assembled) : assembled;
6482
- const etag = compressed ? `${stored.etag}-gzip` : stored.etag;
6624
+ const etag = `"${compressed ? `${stored.etag}-gzip` : stored.etag}"`;
6483
6625
  const headers = {
6484
6626
  "Content-Type": "text/javascript; charset=utf-8",
6485
6627
  "Content-Length": body.byteLength,
@@ -6488,7 +6630,7 @@ var MobileAccessGateway = class {
6488
6630
  };
6489
6631
  if (compressed) headers["Content-Encoding"] = "gzip";
6490
6632
  addVaryAcceptEncoding(headers);
6491
- if (headerValue(request.headers, "if-none-match") === etag) {
6633
+ if (matchesIfNoneMatch(headerValue(request.headers, "if-none-match"), etag)) {
6492
6634
  setSecurityHeaders(response, this.tlsEnabled);
6493
6635
  response.writeHead(304, {
6494
6636
  ETag: etag,
@@ -6735,16 +6877,28 @@ var MobileAccessGateway = class {
6735
6877
  const statusCode = proxied.statusCode ?? 502;
6736
6878
  const cacheControl = revisionedStaticCacheControl(request$2, statusCode);
6737
6879
  if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
6738
- const compressed = shouldCompressResponse(request$2, proxied);
6880
+ const compressible = canCompressResponse(request$2, proxied);
6881
+ const compressed = compressible && acceptsGzip(request$2.headers["accept-encoding"]);
6882
+ if (compressible) addVaryAcceptEncoding(headers);
6739
6883
  if (compressed) {
6740
6884
  delete headers["accept-ranges"];
6741
6885
  delete headers["content-length"];
6742
- delete headers.etag;
6886
+ const etag = weakenEntityTag(proxied.headers.etag);
6887
+ if (etag === void 0) delete headers.etag;
6888
+ else headers.etag = etag;
6743
6889
  headers["content-encoding"] = "gzip";
6890
+ } else if (statusCode === 304 && (request$2.method === "GET" || request$2.method === "HEAD") && request$2.headers.range === void 0 && (request$2.url?.split("?", 1)[0]?.startsWith("/plugins/") === true || request$2.url?.split("?", 1)[0]?.startsWith("/assets/") === true)) {
6891
+ if (proxied.headers["content-encoding"] === void 0 && acceptsGzip(request$2.headers["accept-encoding"])) {
6892
+ delete headers["content-length"];
6893
+ delete headers["accept-ranges"];
6894
+ const etag = weakenEntityTag(proxied.headers.etag);
6895
+ if (etag === void 0) delete headers.etag;
6896
+ else headers.etag = etag;
6897
+ }
6744
6898
  addVaryAcceptEncoding(headers);
6745
6899
  }
6746
6900
  response.writeHead(statusCode, headers);
6747
- await Promise.all([bodyDone, compressed ? pipeline(proxied, createGzip(), response) : pipeline(proxied, response)]);
6901
+ await Promise.all([bodyDone, compressed && request$2.method !== "HEAD" ? pipeline(proxied, createGzip(), response) : pipeline(proxied, response)]);
6748
6902
  } catch (error) {
6749
6903
  holder.request?.destroy();
6750
6904
  await bodyDone?.catch(() => void 0);
@@ -7475,6 +7629,118 @@ async function replaceManagedDirectory(root, target, candidate, prefix, operatio
7475
7629
  }
7476
7630
  }
7477
7631
  //#endregion
7632
+ //#region src/component-download.ts
7633
+ /**
7634
+ * Hosts a pinned GitHub release download may redirect to.
7635
+ *
7636
+ * `github.com/<owner>/<repo>/releases/download/<tag>/<asset>` does not serve the bytes itself: it
7637
+ * answers 302 with a signed URL on a release-asset host. A fetch configured with
7638
+ * `redirect: 'error'` fails that hop as `TypeError: fetch failed` (cause: `unexpected redirect`)
7639
+ * before a single byte is transferred, which is exactly how the cloudflared component could never
7640
+ * install. Following one validated hop keeps the origin restriction while allowing the shape
7641
+ * every GitHub release download uses.
7642
+ */
7643
+ const RELEASE_ASSET_HOSTS = Object.freeze(["release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
7644
+ /** Release an unused body without replacing a download or validation failure. */
7645
+ async function discardBody(body) {
7646
+ if (body === null) return;
7647
+ try {
7648
+ await body.cancel();
7649
+ } catch (_error) {}
7650
+ }
7651
+ /** Follow one redirect hop after checking its scheme and host. A second hop is refused. */
7652
+ async function followValidatedRedirect(response, request) {
7653
+ let target;
7654
+ try {
7655
+ const location = response.headers.get("location");
7656
+ if (location === null) throw new Error(`${request.errorPrefix}_download_redirect_missing`);
7657
+ try {
7658
+ target = new URL(location, request.url);
7659
+ } catch {
7660
+ throw new Error(`${request.errorPrefix}_download_redirect_invalid`);
7661
+ }
7662
+ const hosts = request.redirectHosts ?? RELEASE_ASSET_HOSTS;
7663
+ if (target.protocol !== "https:" || !hosts.includes(target.hostname)) throw new Error(`${request.errorPrefix}_download_redirect_rejected`);
7664
+ } finally {
7665
+ await discardBody(response.body);
7666
+ }
7667
+ return fetch(target, {
7668
+ redirect: "error",
7669
+ signal: request.signal
7670
+ });
7671
+ }
7672
+ /** Attempts per install. A 55 MB transfer through a TUN proxy can reset mid-stream: the first
7673
+ * real run in the development environment failed after 1.5 MB and succeeded on the next attempt,
7674
+ * which is exactly the confusing "nothing happened" the user saw. */
7675
+ const DOWNLOAD_ATTEMPTS = 2;
7676
+ const RETRY_DELAY_MS = 750;
7677
+ /** Whether a failure is a transport problem worth a second attempt rather than a verdict. */
7678
+ function isRetryable(error) {
7679
+ if (error instanceof TypeError) return true;
7680
+ if (error instanceof Error && /_download_http_5\d\d$/u.test(error.message)) return true;
7681
+ return false;
7682
+ }
7683
+ function delay(ms) {
7684
+ return new Promise((resolve) => {
7685
+ setTimeout(resolve, ms);
7686
+ });
7687
+ }
7688
+ async function attemptDownload(request) {
7689
+ const first = await fetch(request.url, {
7690
+ redirect: "manual",
7691
+ signal: request.signal
7692
+ });
7693
+ const response = first.status >= 300 && first.status < 400 ? await followValidatedRedirect(first, request) : first;
7694
+ let reader;
7695
+ let ended = false;
7696
+ try {
7697
+ if (!response.ok) throw new Error(`${request.errorPrefix}_download_http_${String(response.status)}`);
7698
+ const contentLength = response.headers.get("content-length");
7699
+ if (contentLength !== null && (!/^\d+$/u.test(contentLength) || Number(contentLength) !== request.expectedBytes)) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
7700
+ if (response.body === null) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
7701
+ const bytes = new Uint8Array(request.expectedBytes);
7702
+ reader = response.body.getReader();
7703
+ let received = 0;
7704
+ while (true) {
7705
+ const chunk = await reader.read();
7706
+ if (chunk.done) {
7707
+ ended = true;
7708
+ break;
7709
+ }
7710
+ if (chunk.value.byteLength > bytes.byteLength - received) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
7711
+ bytes.set(chunk.value, received);
7712
+ received += chunk.value.byteLength;
7713
+ }
7714
+ if (received !== request.expectedBytes) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
7715
+ return bytes;
7716
+ } finally {
7717
+ if (reader === void 0) await discardBody(response.body);
7718
+ else try {
7719
+ if (!ended) await discardBody(reader);
7720
+ } finally {
7721
+ reader.releaseLock();
7722
+ }
7723
+ }
7724
+ }
7725
+ /**
7726
+ * Download a pinned artifact, following at most one validated redirect, and check its exact length.
7727
+ * A transport failure is retried once; a length or redirect verdict is not. The caller still
7728
+ * verifies the SHA-256 before publishing anything.
7729
+ * @param request - the pinned URL, its exact byte length and the caller's error prefix.
7730
+ * @returns the artifact bytes.
7731
+ */
7732
+ async function downloadPinnedArtifact(request) {
7733
+ let lastError;
7734
+ for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt += 1) try {
7735
+ return await attemptDownload(request);
7736
+ } catch (error) {
7737
+ lastError = error;
7738
+ if (attempt === DOWNLOAD_ATTEMPTS || !isRetryable(error)) throw error;
7739
+ await delay(RETRY_DELAY_MS);
7740
+ }
7741
+ throw lastError;
7742
+ }
7743
+ //#endregion
7478
7744
  //#region src/frp-component.ts
7479
7745
  const FRP_VERSION$1 = "0.70.1";
7480
7746
  const MAX_ARCHIVE_ENTRIES$1 = 128;
@@ -7605,39 +7871,12 @@ async function defaultExtractArtifact$2(archive, destination, executableName) {
7605
7871
  await copyFile(extracted, join(destination, executableName));
7606
7872
  }
7607
7873
  async function defaultFetchArtifact$2(artifact, signal) {
7608
- const response = await fetch(artifact.downloadUrl, {
7609
- redirect: "follow",
7874
+ return downloadPinnedArtifact({
7875
+ url: artifact.downloadUrl,
7876
+ expectedBytes: artifact.downloadBytes,
7877
+ errorPrefix: "frp",
7610
7878
  signal
7611
7879
  });
7612
- if (!response.ok) throw new Error(`frp_download_http_${String(response.status)}`);
7613
- const finalUrl = new URL(response.url);
7614
- const officialHost = finalUrl.hostname === "github.com" || finalUrl.hostname.endsWith(".githubusercontent.com");
7615
- if (finalUrl.protocol !== "https:" || !officialHost) throw new Error("frp_download_origin_invalid");
7616
- const lengthHeader = response.headers.get("content-length");
7617
- const declaredLength = lengthHeader === null ? void 0 : Number(lengthHeader);
7618
- if (declaredLength !== void 0 && (!Number.isFinite(declaredLength) || declaredLength !== artifact.downloadBytes)) throw new Error("frp_download_size_mismatch");
7619
- if (response.body === null) throw new Error("frp_download_empty");
7620
- const chunks = [];
7621
- let received = 0;
7622
- const reader = response.body.getReader();
7623
- while (true) {
7624
- const result = await reader.read();
7625
- if (result.done) break;
7626
- received += result.value.byteLength;
7627
- if (received > artifact.downloadBytes) {
7628
- await reader.cancel();
7629
- throw new Error("frp_download_size_mismatch");
7630
- }
7631
- chunks.push(result.value);
7632
- }
7633
- if (received !== artifact.downloadBytes) throw new Error("frp_download_size_mismatch");
7634
- const bytes = new Uint8Array(received);
7635
- let offset = 0;
7636
- for (const chunk of chunks) {
7637
- bytes.set(chunk, offset);
7638
- offset += chunk.byteLength;
7639
- }
7640
- return bytes;
7641
7880
  }
7642
7881
  async function defaultInspectExecutable(executable) {
7643
7882
  return (await runCapture$1(executable, ["--version"])).trim();
@@ -8984,7 +9223,7 @@ async function readManagedCa(setup) {
8984
9223
  const [certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
8985
9224
  return assertMatchingCa(certPem, keyPem);
8986
9225
  }
8987
- async function atomicWrite(file, contents) {
9226
+ async function stagePrivateFile(file, contents) {
8988
9227
  const directory = dirname(file);
8989
9228
  await mkdir(directory, {
8990
9229
  recursive: true,
@@ -8992,17 +9231,85 @@ async function atomicWrite(file, contents) {
8992
9231
  });
8993
9232
  const temporary = join(directory, `.${basename(file)}.${randomBytes(12).toString("hex")}.tmp`);
8994
9233
  try {
8995
- await writeFile(temporary, contents, {
9234
+ await writeFile(temporary, "", {
8996
9235
  mode: 384,
8997
9236
  flag: "wx"
8998
9237
  });
8999
9238
  await restrictPrivateFile(temporary);
9239
+ await writeFile(temporary, contents);
9240
+ return temporary;
9241
+ } catch (error) {
9242
+ await rm(temporary, { force: true });
9243
+ throw error;
9244
+ }
9245
+ }
9246
+ async function atomicWrite(file, contents) {
9247
+ const temporary = await stagePrivateFile(file, contents);
9248
+ try {
9000
9249
  await rename(temporary, file);
9001
9250
  await restrictPrivateFile(file);
9002
9251
  } finally {
9003
9252
  await rm(temporary, { force: true });
9004
9253
  }
9005
9254
  }
9255
+ const certificatePublications = /* @__PURE__ */ new Map();
9256
+ /** Stage both files before promotion and retain a private rollback copy until the pair is installed. */
9257
+ async function installServerCertificatePair(output, cert, key) {
9258
+ const identity = `${output.certFile}\0${output.keyFile}`;
9259
+ const publication = (certificatePublications.get(identity) ?? Promise.resolve()).catch(() => void 0).then(async () => {
9260
+ const files = [output.certFile, output.keyFile];
9261
+ const contents = [cert, key];
9262
+ const staged = [];
9263
+ const backups = [];
9264
+ let promoted = 0;
9265
+ let restored = true;
9266
+ const failures = [];
9267
+ try {
9268
+ for (let index = 0; index < files.length; index += 1) {
9269
+ const file = files[index];
9270
+ staged.push(await stagePrivateFile(file, contents[index]));
9271
+ let old;
9272
+ try {
9273
+ const metadata = await lstat(file);
9274
+ if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error("managed TLS output is not a regular file");
9275
+ old = await readFile(file);
9276
+ } catch (error) {
9277
+ if (error.code !== "ENOENT") throw error;
9278
+ }
9279
+ backups.push(old === void 0 ? void 0 : await stagePrivateFile(file, old));
9280
+ }
9281
+ for (let index = 0; index < files.length; index += 1) {
9282
+ await rename(staged[index], files[index]);
9283
+ promoted += 1;
9284
+ }
9285
+ } catch (error) {
9286
+ failures.push(error);
9287
+ for (let index = promoted - 1; index >= 0; index -= 1) try {
9288
+ const backup = backups[index];
9289
+ if (backup === void 0) await rm(files[index], { force: true });
9290
+ else await rename(backup, files[index]);
9291
+ } catch (restoreError) {
9292
+ restored = false;
9293
+ failures.push(restoreError);
9294
+ }
9295
+ } finally {
9296
+ const cleanup = await Promise.allSettled(staged.map((file) => rm(file, { force: true })));
9297
+ for (const result of cleanup) if (result.status === "rejected") failures.push(result.reason);
9298
+ if (restored) {
9299
+ const backupCleanup = await Promise.allSettled(backups.filter((file) => file !== void 0).map((file) => rm(file, { force: true })));
9300
+ for (const result of backupCleanup) if (result.status === "rejected") failures.push(result.reason);
9301
+ }
9302
+ }
9303
+ if (failures.length > 1) throw new AggregateError(failures, "managed TLS certificate replacement, rollback or cleanup failed");
9304
+ if (failures.length === 1) throw failures[0];
9305
+ });
9306
+ certificatePublications.set(identity, publication);
9307
+ try {
9308
+ await publication;
9309
+ } finally {
9310
+ if (certificatePublications.get(identity) === publication) certificatePublications.delete(identity);
9311
+ }
9312
+ }
9006
9313
  /** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
9007
9314
  async function ensureManagedCa(setup, legacy) {
9008
9315
  let certPem;
@@ -9117,7 +9424,7 @@ async function issueServerCertificate(authority, target, output, lifetimeDays =
9117
9424
  }
9118
9425
  ]
9119
9426
  });
9120
- await Promise.all([atomicWrite(output.certFile, server.cert), atomicWrite(output.keyFile, server.private)]);
9427
+ await installServerCertificatePair(output, server.cert, server.private);
9121
9428
  }
9122
9429
  /** Sign and atomically install a server leaf for the interface's current address. */
9123
9430
  async function refreshManagedServerCertificate(setup, address) {
@@ -9254,12 +9561,12 @@ async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now()
9254
9561
  if (previousFingerprint === void 0) await writeIngressIdentity(paths.statusFile, caFingerprint, ownedRoot);
9255
9562
  const publicHost = new URL(settings.publicOrigin).hostname;
9256
9563
  let leaf;
9564
+ let matchingKey = false;
9257
9565
  try {
9258
9566
  leaf = new X509Certificate(await readFile(paths.certFile));
9259
- } catch {
9260
- leaf = void 0;
9261
- }
9262
- if (!(leaf !== void 0 && Date.parse(leaf.validTo) > now + 864e5 && coversPublicIp(leaf, publicHost) && leaf.verify(ca.publicKey))) {
9567
+ if (leafKeyExists) matchingKey = leaf.checkPrivateKey(createPrivateKey(await readFile(paths.keyFile)));
9568
+ } catch (_error) {}
9569
+ if (!(leaf !== void 0 && matchingKey && Date.parse(leaf.validFrom) <= now && Date.parse(leaf.validTo) > now + 864e5 && coversPublicIp(leaf, publicHost) && leaf.verify(ca.publicKey))) {
9263
9570
  await assertManagedParents(ownedRoot, paths.certFile, "frp_ingress");
9264
9571
  await issueServerCertificate({
9265
9572
  caCertFile: paths.caCertFile,
@@ -9385,6 +9692,18 @@ function aggregateErrors(errors, message) {
9385
9692
  if (errors.length === 1 && errors[0] instanceof Error) return errors[0];
9386
9693
  return new AggregateError(errors, message);
9387
9694
  }
9695
+ /**
9696
+ * Prepare each inactive provider's disabled state and settle every write before reporting a failure.
9697
+ * @param selected - Provider whose startup must still read its durable preference.
9698
+ * @param stores - The same stores supplied to the corresponding provider controllers.
9699
+ * @returns Fresh disabled states for this initialization only; no permission-success cache is retained.
9700
+ */
9701
+ async function prepareDisabledRemoteProviders(selected, stores) {
9702
+ const results = await Promise.allSettled(REMOTE_PROVIDERS.filter((provider) => provider !== selected).map(async (provider) => [provider, await stores[provider].disableIfPresent()]));
9703
+ const failure = aggregateErrors(results.filter((result) => result.status === "rejected").map((result) => result.reason), "remote provider control preparation failed");
9704
+ if (failure !== void 0) throw failure;
9705
+ return new Map(results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []));
9706
+ }
9388
9707
  /** Settle independent remote cleanup work before reporting any collected failure. */
9389
9708
  async function settleRemoteResources(steps, message = "remote resource cleanup failed") {
9390
9709
  const failure = aggregateErrors((await Promise.allSettled(steps.map(async (step) => step()))).filter((result) => result.status === "rejected").map((result) => result.reason), message);
@@ -9837,8 +10156,8 @@ var FrpController = class {
9837
10156
  if (options.ingressCertificateCheckMs !== void 0 && (!Number.isSafeInteger(options.ingressCertificateCheckMs) || options.ingressCertificateCheckMs < 1 || options.ingressCertificateCheckMs > 2147483647)) throw new Error("frp_ingress_check_interval_invalid");
9838
10157
  }
9839
10158
  /** Restore the remembered FRP switch without changing LAN or other providers. */
9840
- async initialize() {
9841
- const state = await this.options.store.load();
10159
+ async initialize(initialState) {
10160
+ const state = initialState ?? await this.options.store.load();
9842
10161
  this.enabled = state.enabled;
9843
10162
  this.initialized = true;
9844
10163
  if (this.enabled) await this.start();
@@ -9860,12 +10179,12 @@ var FrpController = class {
9860
10179
  if (!this.initialized || this.disposed) throw new Error("FRP controller is unavailable");
9861
10180
  await this.enqueue(async () => {
9862
10181
  if (this.enabled === enabled && (enabled === false || this.child !== void 0)) return;
9863
- if (!enabled) await this.stop();
9864
- this.enabled = enabled;
9865
10182
  await this.options.store.save({
9866
10183
  version: 1,
9867
10184
  enabled
9868
10185
  });
10186
+ this.enabled = enabled;
10187
+ if (!enabled) await this.stop();
9869
10188
  if (enabled) await this.start();
9870
10189
  else this.publish({
9871
10190
  enabled: false,
@@ -9879,11 +10198,11 @@ var FrpController = class {
9879
10198
  if (!this.initialized || this.disposed) throw new Error("FRP controller is unavailable");
9880
10199
  await this.enqueue(async () => {
9881
10200
  if (!this.enabled) {
9882
- this.enabled = true;
9883
10201
  await this.options.store.save({
9884
10202
  version: 1,
9885
10203
  enabled: true
9886
10204
  });
10205
+ this.enabled = true;
9887
10206
  }
9888
10207
  await this.stop();
9889
10208
  await this.start();
@@ -9894,12 +10213,12 @@ var FrpController = class {
9894
10213
  async reset() {
9895
10214
  if (!this.initialized || this.disposed) throw new Error("FRP controller is unavailable");
9896
10215
  await this.enqueue(async () => {
9897
- await this.stop();
9898
- this.enabled = false;
9899
10216
  await this.options.store.save({
9900
10217
  version: 1,
9901
10218
  enabled: false
9902
10219
  });
10220
+ this.enabled = false;
10221
+ await this.stop();
9903
10222
  this.publish({
9904
10223
  enabled: false,
9905
10224
  state: "off"
@@ -10509,96 +10828,6 @@ var OriginController = class {
10509
10828
  }
10510
10829
  };
10511
10830
  //#endregion
10512
- //#region src/component-download.ts
10513
- /**
10514
- * Hosts a pinned GitHub release download may redirect to.
10515
- *
10516
- * `github.com/<owner>/<repo>/releases/download/<tag>/<asset>` does not serve the bytes itself: it
10517
- * answers 302 with a signed URL on a release-asset host. A fetch configured with
10518
- * `redirect: 'error'` fails that hop as `TypeError: fetch failed` (cause: `unexpected redirect`)
10519
- * before a single byte is transferred, which is exactly how the cloudflared component could never
10520
- * install. Following one validated hop keeps the origin restriction while allowing the shape
10521
- * every GitHub release download uses.
10522
- */
10523
- const RELEASE_ASSET_HOSTS = Object.freeze(["release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
10524
- /** Follow one redirect hop after checking its scheme and host. A second hop is refused. */
10525
- async function followValidatedRedirect(response, request) {
10526
- const location = response.headers.get("location");
10527
- if (location === null) throw new Error(`${request.errorPrefix}_download_redirect_missing`);
10528
- let target;
10529
- try {
10530
- target = new URL(location, request.url);
10531
- } catch {
10532
- throw new Error(`${request.errorPrefix}_download_redirect_invalid`);
10533
- }
10534
- const hosts = request.redirectHosts ?? RELEASE_ASSET_HOSTS;
10535
- if (target.protocol !== "https:" || !hosts.includes(target.hostname)) throw new Error(`${request.errorPrefix}_download_redirect_rejected`);
10536
- return fetch(target, {
10537
- redirect: "error",
10538
- signal: request.signal
10539
- });
10540
- }
10541
- /** Attempts per install. A 55 MB transfer through a TUN proxy can reset mid-stream: the first
10542
- * real run in the development environment failed after 1.5 MB and succeeded on the next attempt,
10543
- * which is exactly the confusing "nothing happened" the user saw. */
10544
- const DOWNLOAD_ATTEMPTS = 2;
10545
- const RETRY_DELAY_MS = 750;
10546
- /** Whether a failure is a transport problem worth a second attempt rather than a verdict. */
10547
- function isRetryable(error) {
10548
- if (error instanceof TypeError) return true;
10549
- if (error instanceof Error && /_download_http_5\d\d$/u.test(error.message)) return true;
10550
- return false;
10551
- }
10552
- function delay(ms) {
10553
- return new Promise((resolve) => {
10554
- setTimeout(resolve, ms);
10555
- });
10556
- }
10557
- async function attemptDownload(request) {
10558
- const first = await fetch(request.url, {
10559
- redirect: "manual",
10560
- signal: request.signal
10561
- });
10562
- const response = first.status >= 300 && first.status < 400 ? await followValidatedRedirect(first, request) : first;
10563
- if (!response.ok) throw new Error(`${request.errorPrefix}_download_http_${String(response.status)}`);
10564
- const contentLength = response.headers.get("content-length");
10565
- if (contentLength !== null && (!/^\d+$/u.test(contentLength) || Number(contentLength) !== request.expectedBytes)) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
10566
- if (response.body === null) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
10567
- const bytes = new Uint8Array(request.expectedBytes);
10568
- const reader = response.body.getReader();
10569
- let received = 0;
10570
- while (true) {
10571
- const chunk = await reader.read();
10572
- if (chunk.done) break;
10573
- if (chunk.value.byteLength > bytes.byteLength - received) {
10574
- await reader.cancel();
10575
- throw new Error(`${request.errorPrefix}_download_size_mismatch`);
10576
- }
10577
- bytes.set(chunk.value, received);
10578
- received += chunk.value.byteLength;
10579
- }
10580
- if (received !== request.expectedBytes) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
10581
- return bytes;
10582
- }
10583
- /**
10584
- * Download a pinned artifact, following at most one validated redirect, and check its exact length.
10585
- * A transport failure is retried once; a length or redirect verdict is not. The caller still
10586
- * verifies the SHA-256 before publishing anything.
10587
- * @param request - the pinned URL, its exact byte length and the caller's error prefix.
10588
- * @returns the artifact bytes.
10589
- */
10590
- async function downloadPinnedArtifact(request) {
10591
- let lastError;
10592
- for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt += 1) try {
10593
- return await attemptDownload(request);
10594
- } catch (error) {
10595
- lastError = error;
10596
- if (attempt === DOWNLOAD_ATTEMPTS || !isRetryable(error)) throw error;
10597
- await delay(RETRY_DELAY_MS);
10598
- }
10599
- throw lastError;
10600
- }
10601
- //#endregion
10602
10831
  //#region src/cloudflared-component.ts
10603
10832
  /** Upper bound for `tar -t` output; a pinned release listing is a few lines. */
10604
10833
  const MAX_ARCHIVE_LIST_BYTES = 1048576;
@@ -11091,8 +11320,8 @@ var CloudflaredController = class {
11091
11320
  if (!isAbsolute(options.executable)) throw new Error("cloudflared executable path must be absolute");
11092
11321
  }
11093
11322
  /** Restore the remembered cloudflared switch independently from LAN and other providers. */
11094
- async initialize() {
11095
- const state = await this.options.store.load();
11323
+ async initialize(initialState) {
11324
+ const state = initialState ?? await this.options.store.load();
11096
11325
  this.enabled = state.enabled;
11097
11326
  this.initialized = true;
11098
11327
  if (this.enabled) await this.start();
@@ -11114,12 +11343,12 @@ var CloudflaredController = class {
11114
11343
  if (!this.initialized || this.disposed) throw new Error("cloudflared controller is unavailable");
11115
11344
  await this.enqueue(async () => {
11116
11345
  if (this.enabled === enabled && (enabled === false || this.child !== void 0)) return;
11117
- if (!enabled) await this.stop();
11118
- this.enabled = enabled;
11119
11346
  await this.options.store.save({
11120
11347
  version: 1,
11121
11348
  enabled
11122
11349
  });
11350
+ this.enabled = enabled;
11351
+ if (!enabled) await this.stop();
11123
11352
  if (enabled) await this.start();
11124
11353
  else this.publish({
11125
11354
  enabled: false,
@@ -11133,11 +11362,11 @@ var CloudflaredController = class {
11133
11362
  if (!this.initialized || this.disposed) throw new Error("cloudflared controller is unavailable");
11134
11363
  await this.enqueue(async () => {
11135
11364
  if (!this.enabled) {
11136
- this.enabled = true;
11137
11365
  await this.options.store.save({
11138
11366
  version: 1,
11139
11367
  enabled: true
11140
11368
  });
11369
+ this.enabled = true;
11141
11370
  }
11142
11371
  await this.stop();
11143
11372
  await this.start();
@@ -11148,12 +11377,12 @@ var CloudflaredController = class {
11148
11377
  async reset() {
11149
11378
  if (!this.initialized || this.disposed) throw new Error("cloudflared controller is unavailable");
11150
11379
  await this.enqueue(async () => {
11151
- await this.stop();
11152
- this.enabled = false;
11153
11380
  await this.options.store.save({
11154
11381
  version: 1,
11155
11382
  enabled: false
11156
11383
  });
11384
+ this.enabled = false;
11385
+ await this.stop();
11157
11386
  this.publish({
11158
11387
  enabled: false,
11159
11388
  state: "off"
@@ -12695,17 +12924,6 @@ else
12695
12924
  printf '%s\n' "$caddy_import" > /etc/caddy/Caddyfile
12696
12925
  chmod 0644 /etc/caddy/Caddyfile
12697
12926
  caddyfile_ready=true
12698
- elif grep -q '^# Managed by DSH Mobile$' /etc/caddy/Caddyfile; then
12699
- # Legacy whole-file layout: the entire file is ours by construction.
12700
- printf '%s\n' "$caddy_import" > /etc/caddy/Caddyfile
12701
- chmod 0644 /etc/caddy/Caddyfile
12702
- caddyfile_ready=true
12703
- elif grep -q '^:80[[:space:]]*{' /etc/caddy/Caddyfile \
12704
- && grep -q 'root [*] /usr/share/caddy' /etc/caddy/Caddyfile \
12705
- && grep -q '^[[:space:]]*file_server[[:space:]]*$' /etc/caddy/Caddyfile; then
12706
- printf '%s\n' "$caddy_import" > /etc/caddy/Caddyfile
12707
- chmod 0644 /etc/caddy/Caddyfile
12708
- caddyfile_ready=true
12709
12927
  fi
12710
12928
  fi
12711
12929
  if [ "$caddyfile_ready" != true ]; then
@@ -13126,13 +13344,8 @@ if grep -Eq '^[[:space:]]*import[[:space:]]+/etc/caddy/dsh-mobile-dsh\.caddy([[:
13126
13344
  fi
13127
13345
  check caddy ok "已移除 DSH Mobile 站点引入;其余 Caddy 配置保持原样。"
13128
13346
  elif [ -f /etc/caddy/Caddyfile ] && grep -q '^# Managed by DSH Mobile$' /etc/caddy/Caddyfile; then
13129
- # Legacy whole-file layout (pre-snippet releases): the entire file is ours.
13130
- printf '# DSH Mobile removed its site. Restore your own Caddyfile or reinstall the Caddy defaults.\\n' > /etc/caddy/Caddyfile
13131
- chmod 0644 /etc/caddy/Caddyfile
13132
- if command -v caddy >/dev/null 2>&1; then
13133
- caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy.service || systemctl restart caddy.service || true
13134
- fi
13135
- check caddy ok "已清空旧版 DSH Mobile 管理的 Caddy 站点;请按需恢复自己的配置。"
13347
+ # A legacy marker does not establish ownership of subsequently added sites.
13348
+ check caddy warning "已保留旧版 Caddyfile;请手动移除 DSH Mobile 站点,不要删除其他站点。"
13136
13349
  else
13137
13350
  check caddy ok "Caddyfile 非 DSH Mobile 管理,保持原样。"
13138
13351
  fi
@@ -13339,6 +13552,7 @@ var FunnelController = class {
13339
13552
  state: "off"
13340
13553
  });
13341
13554
  queue = Promise.resolve();
13555
+ closeTask;
13342
13556
  startTimer;
13343
13557
  ownedRoot;
13344
13558
  constructor(options) {
@@ -13348,7 +13562,7 @@ var FunnelController = class {
13348
13562
  if (!isAbsolute(this.ownedRoot) || resolve(this.ownedRoot) === resolve(options.stateDirectory)) throw new Error("Funnel state must be a child of its owned root");
13349
13563
  }
13350
13564
  /** Restore the remote switch without coupling it to LAN availability. */
13351
- async initialize() {
13565
+ async initialize(initialState) {
13352
13566
  try {
13353
13567
  await this.assertStateParents();
13354
13568
  } catch (_error) {
@@ -13361,7 +13575,7 @@ var FunnelController = class {
13361
13575
  });
13362
13576
  return;
13363
13577
  }
13364
- const state = await this.options.store.load();
13578
+ const state = initialState ?? await this.options.store.load();
13365
13579
  this.enabled = state.enabled;
13366
13580
  this.initialized = true;
13367
13581
  if (this.enabled) await this.start();
@@ -13382,29 +13596,67 @@ var FunnelController = class {
13382
13596
  async setEnabled(enabled) {
13383
13597
  if (!this.initialized || this.disposed) throw new Error("Funnel controller is unavailable");
13384
13598
  await this.enqueue(async () => {
13385
- if (this.enabled === enabled && (enabled === false || this.child !== void 0)) return;
13386
- if (!enabled) await this.stop();
13599
+ const ownsResources = this.child !== void 0 || this.gatewayValue !== void 0;
13600
+ if (this.enabled === enabled && (enabled ? this.child !== void 0 : !ownsResources && this.latest.state === "off")) return;
13601
+ if (!enabled) {
13602
+ try {
13603
+ await this.assertStateParents();
13604
+ } catch (error) {
13605
+ try {
13606
+ await this.stop();
13607
+ } catch (cleanupError) {
13608
+ this.publish({
13609
+ enabled: this.enabled,
13610
+ state: "error",
13611
+ errorCode: "funnel_cleanup_failed"
13612
+ });
13613
+ throw new AggregateError([error, cleanupError], "Funnel state validation and cleanup failed");
13614
+ }
13615
+ this.publish({
13616
+ enabled: this.enabled,
13617
+ state: "unavailable",
13618
+ errorCode: "funnel_state_invalid"
13619
+ });
13620
+ throw error;
13621
+ }
13622
+ await this.options.store.save({
13623
+ version: 1,
13624
+ enabled: false
13625
+ });
13626
+ this.enabled = false;
13627
+ try {
13628
+ await this.stop();
13629
+ } catch (error) {
13630
+ this.publish({
13631
+ enabled: false,
13632
+ state: "error",
13633
+ errorCode: "funnel_cleanup_failed"
13634
+ });
13635
+ throw error;
13636
+ }
13637
+ this.publish({
13638
+ enabled: false,
13639
+ state: "off"
13640
+ });
13641
+ return;
13642
+ }
13387
13643
  try {
13388
13644
  await this.assertStateParents();
13389
13645
  } catch (error) {
13390
- this.enabled = false;
13391
13646
  this.publish({
13392
- enabled: false,
13647
+ enabled: this.enabled,
13393
13648
  state: "unavailable",
13394
13649
  errorCode: "funnel_state_invalid"
13395
13650
  });
13396
13651
  throw error;
13397
13652
  }
13398
- this.enabled = enabled;
13399
13653
  await this.options.store.save({
13400
13654
  version: 1,
13401
- enabled
13402
- });
13403
- if (enabled) await this.start();
13404
- else this.publish({
13405
- enabled: false,
13406
- state: "off"
13655
+ enabled: true
13407
13656
  });
13657
+ this.enabled = true;
13658
+ if (ownsResources) await this.stop();
13659
+ await this.start();
13408
13660
  });
13409
13661
  return this.status();
13410
13662
  }
@@ -13412,7 +13664,6 @@ var FunnelController = class {
13412
13664
  async reconnect() {
13413
13665
  if (!this.initialized || this.disposed) throw new Error("Funnel controller is unavailable");
13414
13666
  await this.enqueue(async () => {
13415
- await this.stop();
13416
13667
  try {
13417
13668
  await this.assertStateParents();
13418
13669
  } catch (error) {
@@ -13424,12 +13675,13 @@ var FunnelController = class {
13424
13675
  throw error;
13425
13676
  }
13426
13677
  if (!this.enabled) {
13427
- this.enabled = true;
13428
13678
  await this.options.store.save({
13429
13679
  version: 1,
13430
13680
  enabled: true
13431
13681
  });
13682
+ this.enabled = true;
13432
13683
  }
13684
+ await this.stop();
13433
13685
  await this.start();
13434
13686
  });
13435
13687
  return this.status();
@@ -13438,8 +13690,26 @@ var FunnelController = class {
13438
13690
  async reset() {
13439
13691
  if (!this.initialized || this.disposed) throw new Error("Funnel controller is unavailable");
13440
13692
  await this.enqueue(async () => {
13441
- await this.stop();
13693
+ try {
13694
+ await assertManagedParents(this.ownedRoot, this.options.stateDirectory, "funnel");
13695
+ const selected = await lstat(this.options.stateDirectory).catch((error) => {
13696
+ if (error.code !== "ENOENT") throw error;
13697
+ });
13698
+ if (selected !== void 0 && (selected.isSymbolicLink() || !selected.isDirectory())) await removeManagedTree(this.ownedRoot, this.options.stateDirectory, "funnel");
13699
+ } catch (error) {
13700
+ this.publish({
13701
+ enabled: this.enabled,
13702
+ state: "off",
13703
+ errorCode: "funnel_state_invalid"
13704
+ });
13705
+ throw error;
13706
+ }
13707
+ await this.options.store.save({
13708
+ version: 1,
13709
+ enabled: false
13710
+ });
13442
13711
  this.enabled = false;
13712
+ await this.stop();
13443
13713
  try {
13444
13714
  await removeManagedTree(this.ownedRoot, this.options.stateDirectory, "funnel");
13445
13715
  await this.assertStateParents();
@@ -13463,10 +13733,15 @@ var FunnelController = class {
13463
13733
  return this.status();
13464
13734
  }
13465
13735
  /** Stop all remote resources without modifying the remembered switch. */
13466
- async close() {
13467
- if (this.disposed) return;
13736
+ close() {
13737
+ if (this.closeTask !== void 0) return this.closeTask;
13468
13738
  this.disposed = true;
13469
- await this.enqueue(() => this.stop());
13739
+ const task = this.enqueue(() => this.stop());
13740
+ this.closeTask = task.catch((error) => {
13741
+ this.closeTask = void 0;
13742
+ throw error;
13743
+ });
13744
+ return this.closeTask;
13470
13745
  }
13471
13746
  enqueue(operation) {
13472
13747
  const task = this.queue.then(operation, operation);
@@ -13656,13 +13931,17 @@ var FunnelController = class {
13656
13931
  async stopProcessAndGateway() {
13657
13932
  this.clearStartTimer();
13658
13933
  const child = this.child;
13659
- this.child = void 0;
13660
13934
  const gateway = this.gatewayValue;
13661
- this.gatewayValue = void 0;
13662
13935
  await settleRemoteResources([async () => {
13663
13936
  child?.stdin.end();
13664
13937
  if (child !== void 0 && child.exitCode === null) await terminateRemoteProcess(child);
13665
- }, () => gateway?.close()], "Funnel resource cleanup failed");
13938
+ if (this.child === child) this.child = void 0;
13939
+ }, async () => {
13940
+ if (gateway !== void 0) {
13941
+ await gateway.close();
13942
+ if (this.gatewayValue === gateway) this.gatewayValue = void 0;
13943
+ }
13944
+ }], "Funnel resource cleanup failed");
13666
13945
  }
13667
13946
  clearStartTimer() {
13668
13947
  if (this.startTimer === void 0) return;
@@ -13788,8 +14067,8 @@ var CpolarController = class {
13788
14067
  if (options.region !== void 0 && !/^[a-z][a-z0-9_]{0,31}$/u.test(options.region)) throw new Error("cpolar region is invalid");
13789
14068
  }
13790
14069
  /** Restore the remembered cpolar switch independently from LAN and Funnel state. */
13791
- async initialize() {
13792
- const state = await this.options.store.load();
14070
+ async initialize(initialState) {
14071
+ const state = initialState ?? await this.options.store.load();
13793
14072
  this.enabled = state.enabled;
13794
14073
  this.initialized = true;
13795
14074
  if (this.enabled) await this.start();
@@ -13811,12 +14090,12 @@ var CpolarController = class {
13811
14090
  if (!this.initialized || this.disposed) throw new Error("cpolar controller is unavailable");
13812
14091
  await this.enqueue(async () => {
13813
14092
  if (this.enabled === enabled && (enabled === false || this.child !== void 0)) return;
13814
- if (!enabled) await this.stop();
13815
- this.enabled = enabled;
13816
14093
  await this.options.store.save({
13817
14094
  version: 1,
13818
14095
  enabled
13819
14096
  });
14097
+ this.enabled = enabled;
14098
+ if (!enabled) await this.stop();
13820
14099
  if (enabled) await this.start();
13821
14100
  else this.publish({
13822
14101
  enabled: false,
@@ -13830,11 +14109,11 @@ var CpolarController = class {
13830
14109
  if (!this.initialized || this.disposed) throw new Error("cpolar controller is unavailable");
13831
14110
  await this.enqueue(async () => {
13832
14111
  if (!this.enabled) {
13833
- this.enabled = true;
13834
14112
  await this.options.store.save({
13835
14113
  version: 1,
13836
14114
  enabled: true
13837
14115
  });
14116
+ this.enabled = true;
13838
14117
  }
13839
14118
  await this.stop();
13840
14119
  await this.start();
@@ -13845,12 +14124,12 @@ var CpolarController = class {
13845
14124
  async reset() {
13846
14125
  if (!this.initialized || this.disposed) throw new Error("cpolar controller is unavailable");
13847
14126
  await this.enqueue(async () => {
13848
- await this.stop();
13849
- this.enabled = false;
13850
14127
  await this.options.store.save({
13851
14128
  version: 1,
13852
14129
  enabled: false
13853
14130
  });
14131
+ this.enabled = false;
14132
+ await this.stop();
13854
14133
  this.publish({
13855
14134
  enabled: false,
13856
14135
  state: "off"
@@ -15292,12 +15571,12 @@ var ManagedOriginController = class {
15292
15571
  gateway() {
15293
15572
  return this.enabled ? this.controller().gateway() : void 0;
15294
15573
  }
15295
- initialize() {
15574
+ initialize(initialState) {
15296
15575
  return this.enqueue(async () => {
15297
15576
  if (this.initialized) return;
15298
15577
  if (this.disposed) throw new Error("origin_controller_unavailable");
15299
15578
  this.selected = (await this.options.modeStore.load()).mode;
15300
- this.enabled = (await this.options.store.load()).enabled;
15579
+ this.enabled = (initialState ?? await this.options.store.load()).enabled;
15301
15580
  await this.options.external.initialize();
15302
15581
  await this.options.managed.initialize();
15303
15582
  await this.options.external.setEnabled(false);
@@ -15336,11 +15615,11 @@ var ManagedOriginController = class {
15336
15615
  if (!enabled) this.options.managed.cancelPendingStart?.();
15337
15616
  return this.enqueue(async () => {
15338
15617
  this.assertAvailable();
15339
- if (!enabled) await this.stopBoth();
15340
15618
  await this.options.store.save({
15341
15619
  version: 1,
15342
15620
  enabled
15343
15621
  });
15622
+ if (!enabled) await this.stopBoth();
15344
15623
  this.enabled = enabled;
15345
15624
  if (enabled && !this.disposed) try {
15346
15625
  await this.controller().setEnabled(true);
@@ -15354,11 +15633,11 @@ var ManagedOriginController = class {
15354
15633
  this.options.managed.cancelPendingStart?.();
15355
15634
  return this.enqueue(async () => {
15356
15635
  this.assertAvailable();
15357
- await this.stopBoth();
15358
15636
  await this.options.store.save({
15359
15637
  version: 1,
15360
15638
  enabled: true
15361
15639
  });
15640
+ await this.stopBoth();
15362
15641
  this.enabled = true;
15363
15642
  if (!this.disposed) try {
15364
15643
  await this.controller().reconnect();
@@ -17229,8 +17508,9 @@ async function apply(ctx, config) {
17229
17508
  frp: frpStore,
17230
17509
  origin: originStore
17231
17510
  };
17232
- await Promise.all(Object.keys(stores).filter((provider) => provider !== remoteProviders.selected).map((provider) => stores[provider].disableIfPresent()));
17233
- for (const provider of REMOTE_PROVIDERS) await remoteControllers[provider].initialize();
17511
+ const prepared = await prepareDisabledRemoteProviders(remoteProviders.selected, stores);
17512
+ for (const provider of REMOTE_PROVIDERS) if (provider === remoteProviders.selected) await remoteControllers[provider].initialize();
17513
+ else await remoteControllers[provider].initialize(prepared.get(provider));
17234
17514
  const lanDiscovery = lanGateway?.discoveryStatus();
17235
17515
  if (lanDiscovery !== void 0 && !lanDiscovery.broadcast) logger.warn("broadcast discovery is unavailable (%s); mDNS is still published, so pair manually or free the UDP port", lanDiscovery.errorCode ?? "unknown_cause");
17236
17516
  if (lanDiscovery?.mobileAssetsErrorCode !== void 0) logger.warn("a bundled mobile asset is missing (%s); the phone frontend will serve without it", lanDiscovery.mobileAssetsErrorCode);