dsh-mobile 0.8.1 → 0.8.2

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
@@ -28,7 +28,7 @@ import { lookup } from "node:dns/promises";
28
28
  import { constants } from "node:fs";
29
29
  import { z as z$1 } from "zod";
30
30
  import webPush from "web-push";
31
- //#region src/access.ts
31
+ // src/access.ts
32
32
  /** Stable error categories converted to deliberately terse HTTP responses. */
33
33
  var AccessError = class extends Error {
34
34
  status;
@@ -466,8 +466,7 @@ var AccessController = class {
466
466
  });
467
467
  }
468
468
  };
469
- //#endregion
470
- //#region src/ip.ts
469
+ // src/ip.ts
471
470
  /**
472
471
  * Browser-safe IP literal classification.
473
472
  *
@@ -548,8 +547,7 @@ function isIP$1(value) {
548
547
  if (leftGroups < 0 || rightGroups < 0) return 0;
549
548
  return leftGroups + rightGroups >= 8 ? 0 : 6;
550
549
  }
551
- //#endregion
552
- //#region src/network.ts
550
+ // src/network.ts
553
551
  function parseIpv4$1(address) {
554
552
  const parts = address.split(".");
555
553
  if (parts.length !== 4) throw new Error(`invalid IPv4 address ${JSON.stringify(address)}`);
@@ -826,8 +824,7 @@ async function probeTcpReachable(host, port, timeoutMs = 1500) {
826
824
  });
827
825
  });
828
826
  }
829
- //#endregion
830
- //#region src/websocket-paths.ts
827
+ // src/websocket-paths.ts
831
828
  /** Cap the admin-approved extra WebSocket upgrade paths (exact pathnames). */
832
829
  const MAX_EXTRA_WEBSOCKET_PATHS = 16;
833
830
  const MAX_WEBSOCKET_PATH_LENGTH = 256;
@@ -932,8 +929,7 @@ var WebSocketPathStore = class {
932
929
  return this.list();
933
930
  }
934
931
  };
935
- //#endregion
936
- //#region src/extension-worker-config.ts
932
+ // src/extension-worker-config.ts
937
933
  function integer$1(value, name, fallback, minimum, maximum) {
938
934
  const resolved = value ?? fallback;
939
935
  if (typeof resolved !== "number" || !Number.isSafeInteger(resolved) || resolved < minimum || resolved > maximum) throw new Error(`hostExecution.${name} must be an integer from ${minimum} through ${maximum}`);
@@ -971,8 +967,7 @@ function resolveHostExecution(raw) {
971
967
  logMessageBytes: integer$1(value.logMessageBytes, "logMessageBytes", 4096, 128, 65536)
972
968
  });
973
969
  }
974
- //#endregion
975
- //#region src/config.ts
970
+ // src/config.ts
976
971
  /** Loader-facing defaults; {@link parseGatewayConfig} enforces cross-field security rules. */
977
972
  const Config = z.object({
978
973
  hostExecution: z.object({
@@ -1206,8 +1201,7 @@ function parseGatewayConfig(raw) {
1206
1201
  maxRateLimitKeys: integer(value.maxRateLimitKeys, "maxRateLimitKeys", 256, 1, 4096)
1207
1202
  });
1208
1203
  }
1209
- //#endregion
1210
- //#region src/exec-file.ts
1204
+ // src/exec-file.ts
1211
1205
  /** Capture both output streams even when a desktop host wraps execFile without Node's promisify metadata. */
1212
1206
  function execFileText(file, args, options = {}) {
1213
1207
  return new Promise((resolve, reject) => {
@@ -1231,8 +1225,7 @@ function execFileText(file, args, options = {}) {
1231
1225
  });
1232
1226
  });
1233
1227
  }
1234
- //#endregion
1235
- //#region src/private-file.ts
1228
+ // src/private-file.ts
1236
1229
  let userSidTask;
1237
1230
  function commandFailureDetails(error) {
1238
1231
  if (error === null || typeof error !== "object") return {};
@@ -1371,8 +1364,7 @@ async function restrictPrivateDirectory(directory) {
1371
1364
  else if (info.isFile()) await restrictPrivateFile(child);
1372
1365
  }
1373
1366
  }
1374
- //#endregion
1375
- //#region src/client-module-preferences.ts
1367
+ // src/client-module-preferences.ts
1376
1368
  const MAX_RECORD_BYTES = 1048576;
1377
1369
  /** Validate untrusted JSON selections; graph membership is checked by the Gateway. */
1378
1370
  function parseExcludedClientModules(value) {
@@ -1545,8 +1537,7 @@ var ClientModulePreferenceStore = class {
1545
1537
  }
1546
1538
  }
1547
1539
  };
1548
- //#endregion
1549
- //#region src/control.ts
1540
+ // src/control.ts
1550
1541
  /** A configuration change failed; recovered distinguishes a restored runtime from an unknown configuration. */
1551
1542
  var MobileAccessReconfigurationError = class extends Error {
1552
1543
  recovered;
@@ -1868,8 +1859,7 @@ function isLocalAdminHostname(hostname) {
1868
1859
  if (octets === void 0) return false;
1869
1860
  return isLoopbackIpv4(octets) || isPrivateIpv4(octets) || isLinkLocalIpv4(octets);
1870
1861
  }
1871
- //#endregion
1872
- //#region src/http-security.ts
1862
+ // src/http-security.ts
1873
1863
  const DEVICE_COOKIE = "dsh_ma_device";
1874
1864
  const SESSION_COOKIE = "dsh_ma_session";
1875
1865
  const CSRF_COOKIE = "dsh_ma_csrf";
@@ -2053,8 +2043,7 @@ function assertLocalAdminTrust(request, requireBrowserOrigin, authenticateDeskto
2053
2043
  if (requireBrowserOrigin && site !== void 0 && site !== "same-origin") throw new HttpError(403, "forbidden");
2054
2044
  if (!loopback && !authenticateDesktop()) throw new HttpError(401, "admin_authentication_required");
2055
2045
  }
2056
- //#endregion
2057
- //#region src/browser-auth-cookies.ts
2046
+ // src/browser-auth-cookies.ts
2058
2047
  const HOST_SESSION_COOKIE = "__Host-dsh_ma_session";
2059
2048
  const HOST_CSRF_COOKIE = "__Host-dsh_ma_csrf";
2060
2049
  /** Non-secret browser identity; the persistent device credential keeps its narrow renewal path. */
@@ -2106,8 +2095,7 @@ function authenticatedCookie(values, authenticate, accepts = () => true) {
2106
2095
  if (selected === void 0) throw new HttpError(401, "authentication_failed");
2107
2096
  return selected;
2108
2097
  }
2109
- //#endregion
2110
- //#region src/auth-failure-log.ts
2098
+ // src/auth-failure-log.ts
2111
2099
  const LOG_INTERVAL_MS$3 = 3e4;
2112
2100
  const MAX_LOGGED_ORIGIN_LENGTH = 512;
2113
2101
  function requestPathCategory(path) {
@@ -2222,12 +2210,17 @@ var AuthFailureRecorder = class {
2222
2210
  }
2223
2211
  }
2224
2212
  };
2225
- //#endregion
2226
- //#region src/http-failure-log.ts
2213
+ // src/http-failure-log.ts
2227
2214
  function safeParserCode(code) {
2228
2215
  switch (code) {
2229
2216
  case "ERR_HTTP_REQUEST_TIMEOUT":
2230
2217
  case "HPE_HEADER_OVERFLOW":
2218
+ case "HPE_INVALID_METHOD":
2219
+ case "HPE_INVALID_CONSTANT":
2220
+ case "HPE_INVALID_URL":
2221
+ case "HPE_INVALID_VERSION":
2222
+ case "HPE_LF_EXPECTED":
2223
+ case "HPE_PAUSED_H2_UPGRADE":
2231
2224
  case "HPE_INVALID_CHUNK_SIZE":
2232
2225
  case "HPE_INVALID_CONTENT_LENGTH":
2233
2226
  case "HPE_INVALID_EOF_STATE":
@@ -2262,7 +2255,7 @@ function classifyHttpParserFailure(nodeCode) {
2262
2255
  }
2263
2256
  const LOG_INTERVAL_MS$2 = 3e4;
2264
2257
  /**
2265
- * Per-Gateway diagnostics with three fixed sampling slots and no timers or request-selected keys.
2258
+ * Per-Gateway diagnostics with four fixed sampling slots and no timers or request-selected keys.
2266
2259
  * Each kind records once immediately, then at most once per 30 seconds with suppressed counts.
2267
2260
  * A sink failure cannot change a response, retry a write or expose the diagnostic sink's error.
2268
2261
  */
@@ -2275,6 +2268,10 @@ var HttpFailureRecorder = class {
2275
2268
  nextLogAt: 0,
2276
2269
  suppressed: 0
2277
2270
  },
2271
+ transport: {
2272
+ nextLogAt: 0,
2273
+ suppressed: 0
2274
+ },
2278
2275
  target: {
2279
2276
  nextLogAt: 0,
2280
2277
  suppressed: 0
@@ -2302,6 +2299,26 @@ var HttpFailureRecorder = class {
2302
2299
  });
2303
2300
  return failure;
2304
2301
  }
2302
+ /** Classify a client socket error without assigning an HTTP status to a transport failure. */
2303
+ recordClientError(nodeCode) {
2304
+ switch (nodeCode) {
2305
+ case "ECONNRESET":
2306
+ case "EPIPE":
2307
+ case "ETIMEDOUT":
2308
+ case "ERR_STREAM_PREMATURE_CLOSE": {
2309
+ const failure = {
2310
+ kind: "transport",
2311
+ code: nodeCode
2312
+ };
2313
+ this.record(failure);
2314
+ return failure;
2315
+ }
2316
+ default: return {
2317
+ kind: "parser",
2318
+ ...this.recordParser(nodeCode)
2319
+ };
2320
+ }
2321
+ }
2305
2322
  /** Sample an invalid request target without retaining its raw or decoded URL. */
2306
2323
  recordTarget() {
2307
2324
  this.record({
@@ -2345,8 +2362,7 @@ var HttpFailureRecorder = class {
2345
2362
  }
2346
2363
  }
2347
2364
  };
2348
- //#endregion
2349
- //#region src/api-latency-log.ts
2365
+ // src/api-latency-log.ts
2350
2366
  /**
2351
2367
  * Bounded API latency samples for diagnosing slow mobile requests.
2352
2368
  *
@@ -2453,8 +2469,7 @@ var ApiLatencyRecorder = class {
2453
2469
  }
2454
2470
  }
2455
2471
  };
2456
- //#endregion
2457
- //#region src/upstream-auth-log.ts
2472
+ // src/upstream-auth-log.ts
2458
2473
  const LOG_INTERVAL_MS = 3e4;
2459
2474
  /** Two fixed sampling slots keep a first failure visible even after a successful exchange. */
2460
2475
  var UpstreamAuthRecorder = class {
@@ -2519,8 +2534,7 @@ var UpstreamAuthRecorder = class {
2519
2534
  }
2520
2535
  }
2521
2536
  };
2522
- //#endregion
2523
- //#region src/client-module-recovery.ts
2537
+ // src/client-module-recovery.ts
2524
2538
  const CLIENT_MODULE_RECOVERY_COPY = {
2525
2539
  zh: {
2526
2540
  title: "移动页面模块需要调整",
@@ -2601,8 +2615,7 @@ function renderClientModuleRecoveryScript(locale) {
2601
2615
  })
2602
2616
  })()`;
2603
2617
  }
2604
- //#endregion
2605
- //#region src/mobile-compat-bootstrap.ts
2618
+ // src/mobile-compat-bootstrap.ts
2606
2619
  const MOBILE_COMPAT_PATH = `${AUTH_PREFIX}/compat.js`;
2607
2620
  /**
2608
2621
  * Name the compatibility bundle by content.
@@ -2678,8 +2691,7 @@ const EXTENSION_LIMITS = Object.freeze({
2678
2691
  assetBytes: 33554432,
2679
2692
  assetDepth: 8
2680
2693
  });
2681
- //#endregion
2682
- //#region src/http-cache.ts
2694
+ // src/http-cache.ts
2683
2695
  /** Entity-tag handling for authenticated GET and HEAD cache validation. */
2684
2696
  const ENTITY_TAG = /^[\t ]*(?:W\/)?("[\x21\x23-\x7e\x80-\xff]*")[\t ]*$/u;
2685
2697
  const ENTITY_TAG_LIST_ITEM = /(?:W\/)?"[\x21\x23-\x7e\x80-\xff]*"/gu;
@@ -2716,8 +2728,7 @@ function weakenEntityTag(etag) {
2716
2728
  const opaque = etag === void 0 ? void 0 : ENTITY_TAG.exec(etag)?.[1];
2717
2729
  return opaque === void 0 ? void 0 : `W/${opaque}`;
2718
2730
  }
2719
- //#endregion
2720
- //#region src/mobile-graph-events.ts
2731
+ // src/mobile-graph-events.ts
2721
2732
  /** Bounded SSE framing for the dedicated Mobile client graph stream. */
2722
2733
  /** Maximum retained UTF-8 frame/output bytes, with CRLF normalized to LF. */
2723
2734
  const MOBILE_GRAPH_EVENT_MAX_BYTES = 4194304;
@@ -2906,8 +2917,7 @@ var MobileGraphEventTransform = class extends Transform {
2906
2917
  function createMobileGraphEventTransform(project) {
2907
2918
  return new MobileGraphEventTransform(project);
2908
2919
  }
2909
- //#endregion
2910
- //#region src/websocket-compression.ts
2920
+ // src/websocket-compression.ts
2911
2921
  function payloadBytes(data) {
2912
2922
  if (Array.isArray(data)) return data.reduce((bytes, part) => bytes + part.byteLength, 0);
2913
2923
  return data.byteLength;
@@ -3127,15 +3137,13 @@ function bridgeCompressedWebSocket(request, client, upstream, head, target, upst
3127
3137
  }
3128
3138
  });
3129
3139
  }
3130
- //#endregion
3131
- //#region src/version.ts
3140
+ // src/version.ts
3132
3141
  const manifest = createRequire(import.meta.url)("../package.json");
3133
3142
  /** Version of the installed DSH Mobile plugin package. */
3134
3143
  const DSH_MOBILE_VERSION = typeof manifest.version === "string" ? manifest.version : "unknown";
3135
3144
  /** Oldest Android App release supported by this plugin generation. */
3136
3145
  const MINIMUM_ANDROID_APP_VERSION = "0.2.2";
3137
- //#endregion
3138
- //#region src/computer-images.ts
3146
+ // src/computer-images.ts
3139
3147
  const MAX_ENTRIES = 500;
3140
3148
  const MAX_IMAGE_BYTES = 20971520;
3141
3149
  const IMAGE_TYPES = Object.freeze({
@@ -3336,8 +3344,7 @@ async function readComputerImage(path, signal) {
3336
3344
  throw new HttpError(404, "file_unavailable");
3337
3345
  }
3338
3346
  }
3339
- //#endregion
3340
- //#region src/extension-worker.ts
3347
+ // src/extension-worker.ts
3341
3348
  /** Supervise opt-in local hosts without executing extension code on the DSH thread. */
3342
3349
  /** JSON bytes serialized and size-checked inside the worker. */
3343
3350
  var PreparedJsonResult = class {
@@ -3990,8 +3997,7 @@ var WorkerStreamBridge = class extends Readable {
3990
3997
  return super.emit(event, ...args);
3991
3998
  }
3992
3999
  };
3993
- //#endregion
3994
- //#region src/extensions.ts
4000
+ // src/extensions.ts
3995
4001
  /** A misbehaving host activation must not wedge the local watcher forever. */
3996
4002
  const HOST_ACTIVATION_TIMEOUT_MS = 5e3;
3997
4003
  /** The previous Host outlives the hidden-page refresh interval and one timed refresh. */
@@ -5064,8 +5070,7 @@ function workerLoggerAdapter(context) {
5064
5070
  function createMobileAccessService(ctx) {
5065
5071
  return new MobileAccessService(ctx);
5066
5072
  }
5067
- //#endregion
5068
- //#region src/auth-pages.ts
5073
+ // src/auth-pages.ts
5069
5074
  const COPY = {
5070
5075
  en: {
5071
5076
  lang: "en",
@@ -5293,8 +5298,7 @@ function renderLoginScript(locale) {
5293
5298
  })()
5294
5299
  `;
5295
5300
  }
5296
- //#endregion
5297
- //#region src/browser-ui-locale.ts
5301
+ // src/browser-ui-locale.ts
5298
5302
  const UI_LOCALE_COOKIE = "dsh_ma_ui_locale";
5299
5303
  /** Read presentation data only; malformed or ambiguous cookies keep browser negotiation. */
5300
5304
  function readBrowserUiLocale(cookieHeader) {
@@ -5313,8 +5317,7 @@ function readBrowserUiLocale(cookieHeader) {
5313
5317
  function resolveBrowserUiLocale(cookieHeader, acceptLanguage) {
5314
5318
  return readBrowserUiLocale(cookieHeader) ?? resolveAuthPageLocale(acceptLanguage);
5315
5319
  }
5316
- //#endregion
5317
- //#region src/mobile-push/assets.ts
5320
+ // src/mobile-push/assets.ts
5318
5321
  const ICON_FILES = Object.freeze({
5319
5322
  "/mobile-access/push/icon-180.png": "icon-180.png",
5320
5323
  "/mobile-access/push/icon-192.png": "icon-192.png",
@@ -5366,16 +5369,14 @@ const loadFromPackage = createMobilePushAssetLoader(packageRoot);
5366
5369
  function mobilePushAsset(path, root = packageRoot) {
5367
5370
  return root === packageRoot ? loadFromPackage(path) : createMobilePushAssetLoader(root)(path);
5368
5371
  }
5369
- //#endregion
5370
- //#region src/mobile-push/installation-id.ts
5372
+ // src/mobile-push/installation-id.ts
5371
5373
  /** Browser-safe installation-id validation shared by the browser controller and the Gateway. */
5372
5374
  const INSTALLATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
5373
5375
  /** True only for a UUID-shaped installation id with an allowed version and variant nibble. */
5374
5376
  function isPushInstallationId(value) {
5375
5377
  return typeof value === "string" && INSTALLATION_ID.test(value);
5376
5378
  }
5377
- //#endregion
5378
- //#region src/gateway.ts
5379
+ // src/gateway.ts
5379
5380
  const MAX_CONTROL_BODY_BYTES = 16384;
5380
5381
  const MAX_HEADER_BYTES = 16384;
5381
5382
  const DISCOVERY_QUERY = Buffer.from("DSH_MOBILE_DISCOVER_V1", "ascii");
@@ -5548,6 +5549,7 @@ const RUNTIME_MODULE = "@deepseek-ai/dsh-client-runtime";
5548
5549
  const RENDERER_MODULE = "@deepseek-ai/dsh-client-ui-renderer";
5549
5550
  const SIDEBAR_MODULE = "@deepseek-ai/dsh-client-ui-sidebar";
5550
5551
  const SETTINGS_MODULE = "@deepseek-ai/dsh-client-ui-settings";
5552
+ const SETTINGS_GENERAL_MODULE = "@deepseek-ai/dsh-client-ui-settings-general";
5551
5553
  const API_GATEWAY_MODULE = "@deepseek-ai/dsh-api-gateway";
5552
5554
  const API_REMOTES_MODULE = "@deepseek-ai/dsh-api-remotes";
5553
5555
  const MOBILE_BOOT_CORE_MODULES = /* @__PURE__ */ new Set([
@@ -5558,6 +5560,7 @@ const MOBILE_BOOT_CORE_MODULES = /* @__PURE__ */ new Set([
5558
5560
  RENDERER_MODULE,
5559
5561
  SIDEBAR_MODULE,
5560
5562
  SETTINGS_MODULE,
5563
+ SETTINGS_GENERAL_MODULE,
5561
5564
  API_GATEWAY_MODULE,
5562
5565
  API_REMOTES_MODULE
5563
5566
  ]);
@@ -5678,8 +5681,8 @@ var ExcludedClientModulesError = class extends Error {
5678
5681
  /** A graph-selection conflict with a bounded diagnostic safe for authenticated callers. */
5679
5682
  var ClientModuleConflictError = class extends HttpError {
5680
5683
  detail;
5681
- constructor(detail) {
5682
- super(409, "excluded_client_modules_invalid");
5684
+ constructor(detail, code = "excluded_client_modules_invalid") {
5685
+ super(409, code);
5683
5686
  this.detail = detail.replace(/[\u0000-\u001f\u007f]/gu, " ").replace(/(?:https?:\/\/|[a-z]:[\\/])\S+/giu, "[redacted]").slice(0, 512);
5684
5687
  }
5685
5688
  };
@@ -5714,6 +5717,25 @@ function describeMobileClientModules(html) {
5714
5717
  dependencies: Object.freeze(clientModuleDependencies(entry).filter((id) => ids.has(id)))
5715
5718
  })));
5716
5719
  }
5720
+ /**
5721
+ * Canonical fingerprint of an installed catalog.
5722
+ *
5723
+ * A settings page computes "required only" client-side. If a plugin was added,
5724
+ * removed or became required while that page stayed open, the computed set is
5725
+ * based on a catalog that no longer exists. The fingerprint travels with the
5726
+ * read and back with the write, so a stale page is told to reread instead of
5727
+ * saving a selection made against an older graph.
5728
+ * @param entries - Catalog entries in any order.
5729
+ * @returns Stable 16-character hexadecimal revision.
5730
+ */
5731
+ function clientModuleCatalogRevision(entries) {
5732
+ const canonical = entries.map((entry) => [
5733
+ entry.id,
5734
+ entry.required,
5735
+ [...new Set(entry.dependencies)].sort()
5736
+ ]).sort((left, right) => left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0);
5737
+ return createHash("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, 16);
5738
+ }
5717
5739
  /** Validate graph edges before removing application rows from both manifest views. */
5718
5740
  function excludeMobileBootEntries(entries, batches, excludedIds) {
5719
5741
  if (excludedIds.length === 0) return;
@@ -6609,7 +6631,11 @@ var MobileAccessGateway = class {
6609
6631
  });
6610
6632
  server.on("clientError", (error, socket) => {
6611
6633
  const nodeCode = "code" in error && typeof error.code === "string" ? error.code : void 0;
6612
- const failure = this.httpFailures.recordParser(nodeCode);
6634
+ const failure = this.httpFailures.recordClientError(nodeCode);
6635
+ if (failure.kind === "transport" || !socket.writable) {
6636
+ socket.destroy();
6637
+ return;
6638
+ }
6613
6639
  rejectUpgrade(socket, failure.status, failure.code);
6614
6640
  });
6615
6641
  await new Promise((resolve, reject) => {
@@ -7339,18 +7365,19 @@ var MobileAccessGateway = class {
7339
7365
  try {
7340
7366
  let kind = "describe";
7341
7367
  let excluded;
7368
+ let revision;
7342
7369
  if (request.method === "POST") {
7343
7370
  const body = await readJsonObject(request, this.config.maxBodyBytes);
7344
7371
  const keys = Object.keys(body);
7345
- if (keys.length !== 1) throw new HttpError(400, "bad_request");
7346
- if (keys[0] === "reset" && body.reset === true) kind = "reset";
7347
- else if (keys[0] === "measure" && body.measure === true) kind = "measure";
7348
- else if (keys[0] === "excludedClientModules") {
7372
+ if (keys.length === 1 && keys[0] === "reset" && body.reset === true) kind = "reset";
7373
+ else if (keys.length === 1 && keys[0] === "measure" && body.measure === true) kind = "measure";
7374
+ else if (keys.every((key) => key === "excludedClientModules" || key === "revision") && Object.prototype.hasOwnProperty.call(body, "excludedClientModules") && (body.revision === void 0 || typeof body.revision === "string")) {
7349
7375
  kind = "configure";
7350
7376
  excluded = body.excludedClientModules;
7377
+ revision = body.revision;
7351
7378
  } else throw new HttpError(400, "bad_request");
7352
7379
  }
7353
- const view = await this.clientModulePreferenceOperation(kind, excluded, authorization.deviceId, operation.signal);
7380
+ const view = await this.clientModulePreferenceOperation(kind, excluded, authorization.deviceId, operation.signal, revision);
7354
7381
  operation.signal.throwIfAborted();
7355
7382
  sendJson(response, 200, view, this.tlsEnabled);
7356
7383
  } catch (error) {
@@ -7835,10 +7862,11 @@ var MobileAccessGateway = class {
7835
7862
  * @param excluded - Untrusted module IDs to validate against the current boot graph.
7836
7863
  * @param deviceId - Authenticated device identity; omitted for computer defaults.
7837
7864
  * @param callerSignal - Cancels fetching and validation, not an atomic write already started.
7865
+ * @param revision - Catalog fingerprint the selection was computed against; a mismatch is rejected.
7838
7866
  * @returns The catalog and saved selection without reloading clients.
7839
7867
  */
7840
- configureClientModules(excluded, deviceId, callerSignal) {
7841
- return this.clientModulePreferenceOperation("configure", excluded, deviceId, callerSignal);
7868
+ configureClientModules(excluded, deviceId, callerSignal, revision) {
7869
+ return this.clientModulePreferenceOperation("configure", excluded, deviceId, callerSignal, revision);
7842
7870
  }
7843
7871
  /** Reset one override without reloading connected clients.
7844
7872
  * @param deviceId - Authenticated device identity; omitted to reset computer defaults.
@@ -8024,7 +8052,7 @@ var MobileAccessGateway = class {
8024
8052
  await upstreamClosed;
8025
8053
  }
8026
8054
  }
8027
- async clientModulePreferenceOperation(kind, excluded, deviceId, callerSignal) {
8055
+ async clientModulePreferenceOperation(kind, excluded, deviceId, callerSignal, revision) {
8028
8056
  if (this.closing || !this.started) throw new HttpError(503, "unavailable");
8029
8057
  if (kind === "measure" && this.clientModuleMeasurementActive) throw new HttpError(429, "busy");
8030
8058
  if (kind === "measure") this.clientModuleMeasurementActive = true;
@@ -8043,8 +8071,10 @@ var MobileAccessGateway = class {
8043
8071
  const validate = async (selection) => {
8044
8072
  html = (await this.fetchAuthenticatedUpstreamIndex(controller.signal)).html;
8045
8073
  try {
8074
+ if (revision !== void 0 && revision !== clientModuleCatalogRevision(describeMobileClientModules(html))) throw new ClientModuleConflictError("the module catalog changed since it was read; reread it and confirm the selection again", "client_module_catalog_changed");
8046
8075
  parseMobileBootPlan(html, selection);
8047
8076
  } catch (error) {
8077
+ if (error instanceof ClientModuleConflictError) throw error;
8048
8078
  if (error instanceof ExcludedClientModulesError) throw new ClientModuleConflictError(error.message);
8049
8079
  throw new HttpError(502, "upstream_unavailable");
8050
8080
  }
@@ -8072,8 +8102,10 @@ var MobileAccessGateway = class {
8072
8102
  } catch (error) {
8073
8103
  if (!measurementExpired || callerSignal?.aborted === true || this.closing) throw error;
8074
8104
  }
8105
+ const entries = this.describeMobileModulesWithSizes(html);
8075
8106
  return Object.freeze({
8076
- entries: this.describeMobileModulesWithSizes(html),
8107
+ entries,
8108
+ revision: clientModuleCatalogRevision(entries),
8077
8109
  ...selection,
8078
8110
  ...plan.batches === void 0 ? {
8079
8111
  excludedClientModules: Object.freeze([]),
@@ -9232,8 +9264,7 @@ var MobileAccessGateway = class {
9232
9264
  };
9233
9265
  }
9234
9266
  };
9235
- //#endregion
9236
- //#region src/storage.ts
9267
+ // src/storage.ts
9237
9268
  function assertInteger(value, name) {
9238
9269
  if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error(`device state ${name} must be a non-negative integer`);
9239
9270
  }
@@ -9360,8 +9391,7 @@ var MemoryDeviceStore = class {
9360
9391
  return structuredClone(this.snapshot);
9361
9392
  }
9362
9393
  };
9363
- //#endregion
9364
- //#region src/managed-files.ts
9394
+ // src/managed-files.ts
9365
9395
  /** Refuse link-shaped ancestors inside an owned state root before accessing a child. */
9366
9396
  async function assertManagedParents(root, target, prefix) {
9367
9397
  const parent = resolve(root);
@@ -9528,8 +9558,7 @@ async function replaceManagedDirectory(root, target, candidate, prefix, operatio
9528
9558
  }
9529
9559
  }
9530
9560
  }
9531
- //#endregion
9532
- //#region src/component-download.ts
9561
+ // src/component-download.ts
9533
9562
  /**
9534
9563
  * Hosts a pinned GitHub release download may redirect to.
9535
9564
  *
@@ -9640,8 +9669,7 @@ async function downloadPinnedArtifact(request) {
9640
9669
  }
9641
9670
  throw lastError;
9642
9671
  }
9643
- //#endregion
9644
- //#region src/frp-component.ts
9672
+ // src/frp-component.ts
9645
9673
  const FRP_VERSION$1 = "0.70.1";
9646
9674
  const MAX_ARCHIVE_ENTRIES$1 = 128;
9647
9675
  const MAX_ARCHIVE_LIST_BYTES$1 = 262144;
@@ -9925,8 +9953,7 @@ var FrpComponentManager = class {
9925
9953
  return task.then(() => this.status());
9926
9954
  }
9927
9955
  };
9928
- //#endregion
9929
- //#region src/windows-msi-extract.ts
9956
+ // src/windows-msi-extract.ts
9930
9957
  /** A failed read-only MSI extraction; causes deliberately exclude raw command arguments. */
9931
9958
  var WindowsMsiExtractionError = class extends Error {
9932
9959
  stage;
@@ -10410,8 +10437,7 @@ async function extractWindowsMsiBinary(msi, destination, executableName, expecte
10410
10437
  await copyFile(binary, join(destination, executableName));
10411
10438
  });
10412
10439
  }
10413
- //#endregion
10414
- //#region src/cpolar-component.ts
10440
+ // src/cpolar-component.ts
10415
10441
  const CPOLAR_VERSION = "3.3.18";
10416
10442
  /** Pinned official cpolar release metadata for supported desktop targets. */
10417
10443
  const CPOLAR_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
@@ -10779,8 +10805,7 @@ var CpolarComponentManager = class {
10779
10805
  return task.then(() => this.status());
10780
10806
  }
10781
10807
  };
10782
- //#endregion
10783
- //#region src/frp-template.ts
10808
+ // src/frp-template.ts
10784
10809
  /** Loopback-only HTTP vhost port used between Caddy and frps. */
10785
10810
  const FRP_VHOST_HTTP_PORT = 7080;
10786
10811
  /** Caddy snippet owned entirely by DSH Mobile; the main Caddyfile only imports it. */
@@ -10909,8 +10934,7 @@ function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin, opti
10909
10934
  if (publicIpv4Address(publicHost)) lines.push(manualIpCertificateGuide(publicHost), "");
10910
10935
  return lines.join("\n");
10911
10936
  }
10912
- //#endregion
10913
- //#region src/frp-config.ts
10937
+ // src/frp-config.ts
10914
10938
  const MAX_SETTINGS_BYTES$1 = 8192;
10915
10939
  /** Public entry port used by the self-signed TCP passthrough (never 443 by requirement). */
10916
10940
  const FRP_DEFAULT_PUBLIC_PORT = 33080;
@@ -11298,8 +11322,7 @@ var FrpConfigStore = class {
11298
11322
  return this.status();
11299
11323
  }
11300
11324
  };
11301
- //#endregion
11302
- //#region src/frp-attach.ts
11325
+ // src/frp-attach.ts
11303
11326
  /** Default proof path used to confirm the public entry answered from this computer. */
11304
11327
  const FRP_ATTACH_DISCOVERY_PATH = "/mobile-access/discovery";
11305
11328
  /** Declaration printed above the VPS half of an attach template. */
@@ -11496,8 +11519,7 @@ function createFrpAttachTemplateParts(settings, options = {}) {
11496
11519
  function createFrpAttachTemplate(settings, options = {}) {
11497
11520
  return createFrpAttachTemplateParts(settings, options).text;
11498
11521
  }
11499
- //#endregion
11500
- //#region src/frp-attach-plan.ts
11522
+ // src/frp-attach-plan.ts
11501
11523
  const DEFAULT_PLAN_CONFIG_FILE = "<DSH Mobile 私有目录>/remote/frp/config/frpc.toml";
11502
11524
  function planConfigFile(options) {
11503
11525
  const file = options.configFile;
@@ -11635,8 +11657,7 @@ function createFrpAttachPlan(settings, options = {}) {
11635
11657
  ])
11636
11658
  });
11637
11659
  }
11638
- //#endregion
11639
- //#region src/cert-renewal.ts
11660
+ // src/cert-renewal.ts
11640
11661
  /** Days left below which the entry certificate is reported as expiring. */
11641
11662
  const CERT_EXPIRING_DAYS = 2;
11642
11663
  const DAY_MS = 864e5;
@@ -11742,8 +11763,7 @@ async function probeOriginCertificate(host, port, timeoutMs = 5e3) {
11742
11763
  });
11743
11764
  });
11744
11765
  }
11745
- //#endregion
11746
- //#region src/managed-setup.ts
11766
+ // src/managed-setup.ts
11747
11767
  const VIRTUAL_INTERFACE_MARKERS = [
11748
11768
  "bridge",
11749
11769
  "docker",
@@ -12241,8 +12261,7 @@ async function materializeManagedSetup(setup, table) {
12241
12261
  }
12242
12262
  };
12243
12263
  }
12244
- //#endregion
12245
- //#region src/frp-ingress.ts
12264
+ // src/frp-ingress.ts
12246
12265
  /** Private ingress directory, always a sibling of the remote device file. */
12247
12266
  function frpIngressPaths(stateFile) {
12248
12267
  const directory = join(dirname(stateFile), "ingress");
@@ -12462,8 +12481,7 @@ async function purgeFrpIngressCertificates(stateFile, ownedRoot = dirname(stateF
12462
12481
  throw error;
12463
12482
  }
12464
12483
  }
12465
- //#endregion
12466
- //#region src/remote.ts
12484
+ // src/remote.ts
12467
12485
  const REMOTE_PROVIDERS = Object.freeze([
12468
12486
  "tailscale",
12469
12487
  "cpolar",
@@ -12699,8 +12717,7 @@ function configuredRemoteProvider(environment) {
12699
12717
  if (value !== "tailscale" && value !== "cpolar" && value !== "cloudflared" && value !== "frp" && value !== "origin" && value !== "iroh") throw new Error("DSH_MOBILE_REMOTE_PROVIDER must be tailscale, cpolar, cloudflared, frp, origin, or iroh");
12700
12718
  return value;
12701
12719
  }
12702
- //#endregion
12703
- //#region src/frp.ts
12720
+ // src/frp.ts
12704
12721
  const START_TIMEOUT_MS$2 = 45e3;
12705
12722
  const DISCOVERY_REQUEST_TIMEOUT_MS = 5e3;
12706
12723
  const DISCOVERY_RETRY_MS = 1e3;
@@ -13310,8 +13327,7 @@ var FrpController = class {
13310
13327
  ], "FRP resource cleanup failed");
13311
13328
  }
13312
13329
  };
13313
- //#endregion
13314
- //#region src/origin-proxy-config.ts
13330
+ // src/origin-proxy-config.ts
13315
13331
  const DEFAULT_ORIGIN_LISTEN_PORT = 3444;
13316
13332
  const MAX_SETTINGS_BYTES = 8192;
13317
13333
  /**
@@ -13481,8 +13497,7 @@ var OriginConfigStore = class {
13481
13497
  return this.status();
13482
13498
  }
13483
13499
  };
13484
- //#endregion
13485
- //#region src/origin-proxy.ts
13500
+ // src/origin-proxy.ts
13486
13501
  /** Owns an authenticated HTTP gateway, with no tunnel process or public network probes. */
13487
13502
  var OriginController = class {
13488
13503
  options;
@@ -13632,8 +13647,7 @@ var OriginController = class {
13632
13647
  });
13633
13648
  }
13634
13649
  };
13635
- //#endregion
13636
- //#region src/cloudflared-component.ts
13650
+ // src/cloudflared-component.ts
13637
13651
  /** Upper bound for `tar -t` output; a pinned release listing is a few lines. */
13638
13652
  const MAX_ARCHIVE_LIST_BYTES = 1048576;
13639
13653
  const MAX_ARCHIVE_ENTRIES = 256;
@@ -13958,8 +13972,7 @@ var CloudflaredComponentManager = class {
13958
13972
  return task.then(() => this.status());
13959
13973
  }
13960
13974
  };
13961
- //#endregion
13962
- //#region src/cloudflared.ts
13975
+ // src/cloudflared.ts
13963
13976
  const MAX_LOG_BUFFER_BYTES$1 = 65536;
13964
13977
  /**
13965
13978
  * A quick tunnel has to be allocated and registered with the Cloudflare edge
@@ -14477,8 +14490,7 @@ var CloudflaredController = class {
14477
14490
  ], "cloudflared resource cleanup failed");
14478
14491
  }
14479
14492
  };
14480
- //#endregion
14481
- //#region src/cloudflared-tunnel.ts
14493
+ // src/cloudflared-tunnel.ts
14482
14494
  const MAX_CONFIG_BYTES$1 = 8192;
14483
14495
  const QUICK_TUNNEL_SUFFIX = ".trycloudflare.com";
14484
14496
  /** Cloudflare's own tunnel-routing domain; it can never be a public hostname. */
@@ -14684,8 +14696,7 @@ var CloudflaredTunnelStore = class {
14684
14696
  return this.status();
14685
14697
  }
14686
14698
  };
14687
- //#endregion
14688
- //#region src/iroh-files.ts
14699
+ // src/iroh-files.ts
14689
14700
  async function renameIrohPath(root, source, destination) {
14690
14701
  await renameManagedPath(root, source, destination, "iroh");
14691
14702
  }
@@ -14695,8 +14706,7 @@ async function ensureIrohDirectory(root, directory) {
14695
14706
  async function removeIrohTree(root, target) {
14696
14707
  await removeManagedTree(root, target, "iroh");
14697
14708
  }
14698
- //#endregion
14699
- //#region src/iroh-manifest.ts
14709
+ // src/iroh-manifest.ts
14700
14710
  /** Checksum-gated resolution of the iroh transport host binary.
14701
14711
  *
14702
14712
  * The sidecar is built and published by the independent `dsh-mobile-iroh`
@@ -14800,8 +14810,7 @@ async function resolveIrohSidecarAt(companionRoot, platform, arch) {
14800
14810
  if (process.platform !== "win32" && platform !== "win32" && (before.mode & 73) === 0) throw new Error("iroh_native_not_executable");
14801
14811
  return path;
14802
14812
  }
14803
- //#endregion
14804
- //#region src/iroh-component.ts
14813
+ // src/iroh-component.ts
14805
14814
  /** Managed view of the iroh transport host sidecar.
14806
14815
  *
14807
14816
  * Explicit installation verifies and promotes a pinned sidecar release into
@@ -15115,8 +15124,7 @@ var IrohComponentManager = class {
15115
15124
  return this.cached;
15116
15125
  }
15117
15126
  };
15118
- //#endregion
15119
- //#region src/iroh-sidecar.ts
15127
+ // src/iroh-sidecar.ts
15120
15128
  /** Versioned parent-to-sidecar protocol for the iroh transport host.
15121
15129
  *
15122
15130
  * Ported from the independent `dsh-mobile-iroh` companion's sidecar
@@ -15246,8 +15254,7 @@ function decodeIrohEvent(line) {
15246
15254
  }
15247
15255
  return Object.freeze(event);
15248
15256
  }
15249
- //#endregion
15250
- //#region src/iroh-origin.ts
15257
+ // src/iroh-origin.ts
15251
15258
  /** Identity-derived browser origins for the raw TLS-over-iroh carrier.
15252
15259
  *
15253
15260
  * Ported from the independent `dsh-mobile-iroh` companion's origin contract.
@@ -15278,8 +15285,7 @@ function validateIrohOrigin(value, endpointId, localPort = DEFAULT_IROH_LOCAL_PO
15278
15285
  if (value !== expected) throw new Error("iroh origin does not match the selected endpoint and local port");
15279
15286
  return expected;
15280
15287
  }
15281
- //#endregion
15282
- //#region src/iroh-pairing.ts
15288
+ // src/iroh-pairing.ts
15283
15289
  /** Short-lived iroh pairing descriptors for QR transport.
15284
15290
  *
15285
15291
  * Byte-compatible with the independent `dsh-mobile-iroh` companion's
@@ -15387,8 +15393,7 @@ function decodePairingDescriptor(encoded, options = {}) {
15387
15393
  }
15388
15394
  /** Phone-side listener port advertised in QR codes minted by this computer. */
15389
15395
  const PAIRING_DESCRIPTOR_LOCAL_PORT = DEFAULT_IROH_LOCAL_PORT;
15390
- //#endregion
15391
- //#region src/iroh.ts
15396
+ // src/iroh.ts
15392
15397
  /** Experimental iroh remote transport as a first-class remote provider.
15393
15398
  *
15394
15399
  * The sidecar is distributed by this plugin itself: an explicit install
@@ -15939,8 +15944,7 @@ var IrohController = class {
15939
15944
  });
15940
15945
  }
15941
15946
  };
15942
- //#endregion
15943
- //#region src/iroh-tls.ts
15947
+ // src/iroh-tls.ts
15944
15948
  /** Self-signed TLS identity for the experimental iroh gateway.
15945
15949
  *
15946
15950
  * Reuses this repository's managed CA path (`managed-setup.ts`) instead of
@@ -16107,8 +16111,7 @@ function irohGatewayConfig(template, endpointId, stateFile, tls, listenPort = 0)
16107
16111
  pairingTtlMs: 3e5
16108
16112
  });
16109
16113
  }
16110
- //#endregion
16111
- //#region src/task-events.ts
16114
+ // src/task-events.ts
16112
16115
  const TASK_EVENT_DEBOUNCE_MS = 1e3;
16113
16116
  function turnNumber(value) {
16114
16117
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
@@ -16185,8 +16188,99 @@ var TaskEventHub = class {
16185
16188
  return this.sinks.size;
16186
16189
  }
16187
16190
  };
16188
- //#endregion
16189
- //#region src/diagnostics.ts
16191
+ // src/diagnostic-network-context.ts
16192
+ function addressKind(address) {
16193
+ if (isIP(address) === 4) {
16194
+ const [first, second] = address.split(".").map(Number);
16195
+ if (first === 100 && second >= 64 && second <= 127) return "tailnet";
16196
+ if (first === 198 && (second === 18 || second === 19)) return "fake-ip";
16197
+ if (first === 0 || first === 10 || first === 127 || first >= 224 || first === 169 && second === 254 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168) return "private";
16198
+ return "public";
16199
+ }
16200
+ if (isIP(address) === 6) {
16201
+ const lower = address.toLowerCase();
16202
+ if (lower.startsWith("::ffff:") && isIP(lower.slice(7)) === 4) return addressKind(lower.slice(7));
16203
+ if (lower.startsWith("fd7a:115c:a1e0:")) return "tailnet";
16204
+ if (lower === "::" || lower === "::1" || /^(?:f[cd]|fe[89ab]|ff)/u.test(lower)) return "private";
16205
+ return "public";
16206
+ }
16207
+ return "unknown";
16208
+ }
16209
+ /**
16210
+ * Collapse system resolver results into non-identifying categories, without retaining addresses.
16211
+ * @param addresses - Addresses supplied by the system resolver.
16212
+ * @returns Address category and observed IP families; mixed answers are not treated as tailnet-only.
16213
+ */
16214
+ function classifyDiagnosticDns(addresses) {
16215
+ const kinds = new Set(addresses.map((entry) => addressKind(entry.address)));
16216
+ const families = new Set(addresses.map((entry) => isIP(entry.address)).filter((value) => value !== 0));
16217
+ return {
16218
+ kind: kinds.size === 0 ? "unknown" : kinds.size === 1 ? [...kinds][0] : "mixed",
16219
+ family: families.size === 0 ? "unknown" : families.size > 1 ? "dual" : families.has(4) ? "ipv4" : "ipv6"
16220
+ };
16221
+ }
16222
+ /**
16223
+ * Read bounded system-DNS context; never consult an external DNS service or modify settings.
16224
+ * @param hostname - The configured endpoint's hostname.
16225
+ * @param budgetMs - Maximum wait; OS lookup completion itself cannot be cancelled.
16226
+ * @param resolve - System lookup or an instance-local controlled fixture.
16227
+ * @returns Unknown on failure/deadline, not a conclusion that DNS or the public service is down.
16228
+ */
16229
+ async function probeDiagnosticDns(hostname, budgetMs, resolve = (host) => lookup(host, { all: true })) {
16230
+ let timer;
16231
+ try {
16232
+ return classifyDiagnosticDns(await Promise.race([resolve(hostname), new Promise((_resolve, reject) => {
16233
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error("dns_context_timeout")), Math.max(1, budgetMs));
16234
+ timer.unref();
16235
+ })]));
16236
+ } catch {
16237
+ return {
16238
+ kind: "unknown",
16239
+ family: "unknown"
16240
+ };
16241
+ } finally {
16242
+ clearTimeout(timer);
16243
+ }
16244
+ }
16245
+ /**
16246
+ * Classify fixed transport machine codes without reflecting messages, URLs or nested errors.
16247
+ * @param error - A failed endpoint request.
16248
+ * @returns The last observed transport stage, or unknown rather than a guessed cause.
16249
+ */
16250
+ function diagnosticFailureStage(error) {
16251
+ let current = error;
16252
+ for (let depth = 0; depth < 4 && current !== null && typeof current === "object"; depth++) {
16253
+ const code = "code" in current && typeof current.code === "string" ? current.code : "";
16254
+ if ("name" in current && current.name === "TimeoutError") return "timeout";
16255
+ if (["ENOTFOUND", "EAI_AGAIN"].includes(code)) return "dns";
16256
+ if ([
16257
+ "ECONNREFUSED",
16258
+ "ECONNRESET",
16259
+ "EPIPE",
16260
+ "ENETUNREACH",
16261
+ "EHOSTUNREACH",
16262
+ "UND_ERR_SOCKET"
16263
+ ].includes(code)) return "connect";
16264
+ if ([
16265
+ "ETIMEDOUT",
16266
+ "UND_ERR_CONNECT_TIMEOUT",
16267
+ "UND_ERR_HEADERS_TIMEOUT",
16268
+ "UND_ERR_BODY_TIMEOUT"
16269
+ ].includes(code)) return "timeout";
16270
+ if ([
16271
+ "ERR_TLS_CERT_ALTNAME_INVALID",
16272
+ "CERT_HAS_EXPIRED",
16273
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
16274
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
16275
+ "SELF_SIGNED_CERT_IN_CHAIN",
16276
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
16277
+ "ERR_SSL_WRONG_VERSION_NUMBER"
16278
+ ].includes(code)) return "tls";
16279
+ current = "cause" in current ? current.cause : void 0;
16280
+ }
16281
+ return "unknown";
16282
+ }
16283
+ // src/diagnostics.ts
16190
16284
  /** Recognize the active remote-web-ui boot hook without evaluating or modifying it. */
16191
16285
  function hasCompetingRemoteChannelBoot(rows) {
16192
16286
  return rows.some((row) => row.kind === "script" && row.text?.includes("__DSH_REMOTE_CHANNEL_BOOT__") === true);
@@ -16245,6 +16339,7 @@ function check(id, status, reason, label, detail, action, facts) {
16245
16339
  id,
16246
16340
  status,
16247
16341
  reason,
16342
+ scope: id === "versions" ? "configuration" : id === "phone-network" ? "phone" : "computer",
16248
16343
  ...facts === void 0 ? {} : { facts: Object.freeze(facts) },
16249
16344
  label,
16250
16345
  detail,
@@ -16489,28 +16584,6 @@ async function relayProbeThroughProxy(target, proxy, budgetMs = remoteDiagnostic
16489
16584
  hopRequest.end();
16490
16585
  });
16491
16586
  }
16492
- /** Bounded supplementary DNS context for a failed direct attempt. */
16493
- async function unreachableWithDnsContext(hostname, budgetMs) {
16494
- let fakeIp = false;
16495
- let timer;
16496
- try {
16497
- fakeIp = (await Promise.race([lookup(hostname, { all: true }), new Promise((_resolve, reject) => {
16498
- timer = setTimeout(() => {
16499
- reject(/* @__PURE__ */ new Error("dns_context_timeout"));
16500
- }, Math.max(1, budgetMs));
16501
- timer.unref();
16502
- })])).some(({ address }) => {
16503
- const [first, second] = address.split(".").map(Number);
16504
- return first === 198 && (second === 18 || second === 19);
16505
- });
16506
- } catch {} finally {
16507
- clearTimeout(timer);
16508
- }
16509
- return {
16510
- state: "unreachable",
16511
- ...fakeIp ? { fakeIp: true } : {}
16512
- };
16513
- }
16514
16587
  async function fetchRemoteHealth(target, budgetMs) {
16515
16588
  const started = performance.now();
16516
16589
  try {
@@ -16520,19 +16593,28 @@ async function fetchRemoteHealth(target, budgetMs) {
16520
16593
  signal: AbortSignal.timeout(Math.max(1, budgetMs - 500))
16521
16594
  });
16522
16595
  const latencyMs = Math.max(0, Math.round(performance.now() - started));
16523
- if (response.status === 429) return {
16524
- state: "rate-limited",
16525
- latencyMs
16526
- };
16527
- return response.ok ? {
16596
+ if (response.status === 429) {
16597
+ await response.body?.cancel();
16598
+ return {
16599
+ state: "rate-limited",
16600
+ latencyMs
16601
+ };
16602
+ }
16603
+ const observation = response.ok ? {
16528
16604
  state: "ready",
16529
16605
  latencyMs
16530
16606
  } : {
16531
16607
  state: "unreachable",
16532
- latencyMs
16608
+ latencyMs,
16609
+ failureStage: "http"
16610
+ };
16611
+ await response.body?.cancel();
16612
+ return observation;
16613
+ } catch (error) {
16614
+ return {
16615
+ state: "unreachable",
16616
+ failureStage: diagnosticFailureStage(error)
16533
16617
  };
16534
- } catch {
16535
- return unreachableWithDnsContext(target.hostname, Math.max(1, budgetMs - (performance.now() - started)));
16536
16618
  }
16537
16619
  }
16538
16620
  /**
@@ -16555,7 +16637,14 @@ async function probeRemoteHealth(origin, options = {}) {
16555
16637
  return { state: "not-applicable" };
16556
16638
  }
16557
16639
  const budgetMs = remoteDiagnosticTimeoutMs(origin);
16558
- const observation = await (options.direct ?? fetchRemoteHealth)(target, budgetMs);
16640
+ const direct = options.direct ?? fetchRemoteHealth;
16641
+ const [observed, dns] = await Promise.all([direct(target, budgetMs), options.direct !== void 0 && options.lookup === void 0 ? void 0 : probeDiagnosticDns(target.hostname, Math.min(750, budgetMs), options.lookup)]);
16642
+ const context = dns === void 0 ? {} : { dns };
16643
+ const observation = {
16644
+ ...observed,
16645
+ ...context,
16646
+ ...dns?.kind === "fake-ip" ? { fakeIp: true } : {}
16647
+ };
16559
16648
  if (observation.state !== "unreachable") return observation;
16560
16649
  const relay = configuredProxyFor(origin, options.env ?? process.env);
16561
16650
  if (relay === void 0) return observation;
@@ -16565,12 +16654,14 @@ async function probeRemoteHealth(origin, options = {}) {
16565
16654
  if (status === 429) return {
16566
16655
  state: "rate-limited",
16567
16656
  latencyMs,
16568
- viaProxy: true
16657
+ viaProxy: true,
16658
+ ...context
16569
16659
  };
16570
16660
  return status >= 200 && status < 300 ? {
16571
16661
  state: "ready",
16572
16662
  latencyMs,
16573
- viaProxy: true
16663
+ viaProxy: true,
16664
+ ...context
16574
16665
  } : observation;
16575
16666
  } catch {
16576
16667
  return observation;
@@ -16580,28 +16671,38 @@ async function defaultRemoteProbe(origin) {
16580
16671
  return probeRemoteHealth(origin);
16581
16672
  }
16582
16673
  function reportLine(entry) {
16583
- return `[${entry.status.toUpperCase()}] ${entry.label}: ${entry.detail}${entry.action === void 0 ? "" : ` ${entry.action}`}`;
16674
+ return `[${entry.status.toUpperCase()}][${entry.scope}] ${entry.label}: ${entry.detail}${entry.action === void 0 ? "" : ` ${entry.action}`}`;
16584
16675
  }
16585
16676
  /** Run bounded read-only checks and return a report safe to paste into an issue. */
16586
16677
  async function collectConnectionDiagnostics(snapshot, probes = {}) {
16587
16678
  const checks = [];
16588
16679
  const remoteProbe = snapshot.remote.provider !== "origin" && snapshot.remote.provider !== "iroh" && snapshot.remote.running && snapshot.remote.state === "ready" && snapshot.remote.origin !== void 0 ? (probes.remote ?? defaultRemoteProbe)(snapshot.remote.origin) : Promise.resolve({ state: "not-applicable" });
16589
- const [firewall, remoteObservation] = await Promise.all([(probes.firewall ?? defaultFirewallProbe())(snapshot.lan.port), remoteProbe]);
16590
- checks.push(check("versions", "ok", "versions-current", "版本兼容", `插件 ${DSH_MOBILE_VERSION},DSH ${snapshot.dshVersion},Android App 最低 ${MINIMUM_ANDROID_APP_VERSION}。`));
16680
+ const [firewall, remoteObservation] = await Promise.all([snapshot.lan.running ? (probes.firewall ?? defaultFirewallProbe())(snapshot.lan.port) : Promise.resolve({ state: "not-applicable" }), remoteProbe]);
16681
+ checks.push(check("versions", "ok", "versions-current", "版本信息", `插件 ${DSH_MOBILE_VERSION},DSH ${snapshot.dshVersion},Android App 最低 ${MINIMUM_ANDROID_APP_VERSION}。`));
16591
16682
  if (snapshot.competingRemoteChannelBoot === true) checks.push(check("remote-plugin-conflict", "warning", "competing-remote-channel", "第三方远程插件", "检测到另一套远程请求通道脚本;手机页面可能进入该插件自己的配对流程。", "在电脑端关闭 dsh-remote-web-ui 的远程访问后刷新,再使用 DSH Mobile 当前二维码配对;不要混用两种配对码。"));
16592
- if (snapshot.lan.configured === false) checks.push(check("network", "error", "lan-setup-required", "局域网配置", "尚未完成局域网初始化。", "返回局域网页选择网卡并完成配置。"));
16593
- else if (snapshot.lan.networkError !== void 0) checks.push(check("network", "error", "network-unavailable", "局域网网卡", "已保存的网卡当前不可用。", "重新运行 dsh-mobile setup。"));
16683
+ if (snapshot.indexInspectionUnavailable === true) checks.push(check("plugin-inspection", "warning", "plugin-inspection-unavailable", "页面脚本检查", "本次未能检查活动页面脚本;不能据此认定不存在其他远程通道。", "其余网络项目独立检查;保留复现环境并核对相关插件。"));
16684
+ if (snapshot.lan.configured === false) checks.push(check("network", snapshot.lan.running ? "error" : "info", "lan-setup-required", "局域网配置", "尚未完成局域网初始化。", "需要局域网连接时,返回局域网页选择网卡并完成配置。"));
16685
+ else if (snapshot.lan.networkError !== void 0) checks.push(check("network", snapshot.lan.running ? "error" : "info", "network-unavailable", "局域网网卡", "已保存的网卡当前不可用。", snapshot.lan.running ? "在局域网页重新确认网卡配置。" : "局域网未启用;此配置不用于判断远程入口。"));
16594
16686
  else if (snapshot.lan.configuredInterface !== void 0) {
16595
16687
  const interfaceName = snapshot.lan.interfaceName ?? snapshot.lan.configuredInterface;
16596
16688
  checks.push(check("network", "ok", "network-interface", "局域网网卡", `正在跟随 ${interfaceName}。`, void 0, { interfaceName }));
16597
16689
  } else checks.push(check("network", "info", "network-fixed", "局域网网卡", "当前使用固定网络配置。"));
16598
16690
  if (snapshot.lan.running && snapshot.lan.origin !== void 0) {
16599
16691
  const endpointSuffix = maskLanOrigin(snapshot.lan.origin);
16600
- checks.push(check("lan", "ok", "lan-ready", "局域网网关", `已监听 ${endpointSuffix},配对入口可用。`, void 0, { endpointSuffix }));
16692
+ checks.push(check("lan", "ok", "lan-ready", "局域网网关", `电脑网关已监听 ${endpointSuffix};尚未验证手机能否到达。`, void 0, { endpointSuffix }));
16601
16693
  } else checks.push(check("lan", "info", "lan-off", "局域网网关", "当前未开启。", "需要手机直连时开启局域网访问。"));
16602
- if (firewall.state === "ready") checks.push(check("firewall", "ok", "firewall-ready", "Windows 防火墙", "局域网 TCP 与发现规则已启用。"));
16603
- else if (firewall.state === "missing") checks.push(check("firewall", "warning", "firewall-missing", "Windows 防火墙", "未找到完整的局域网放行规则。", "以管理员身份重新运行 dsh-mobile setup。"));
16694
+ if (!snapshot.lan.running) checks.push(check("firewall", "info", "firewall-inactive", "Windows 防火墙", "局域网未启用,本次未检查局域网防火墙规则。", "不据此判断远程入口;需要局域网时再启用并检查。"));
16695
+ else if (firewall.state === "ready") checks.push(check("firewall", "ok", "firewall-ready", "Windows 防火墙", "局域网 TCP 与发现规则已启用。"));
16696
+ else if (firewall.state === "missing") checks.push(check("firewall", snapshot.lan.running ? "warning" : "info", "firewall-missing", "Windows 防火墙", "未找到完整的局域网放行规则。", snapshot.lan.running ? "确认需要局域网连接,再检查相应规则;不要关闭整个防火墙。" : "局域网未启用,不据此判断远程入口失败。"));
16604
16697
  else if (firewall.state === "unknown") checks.push(check("firewall", "info", "firewall-unknown", "Windows 防火墙", "系统未允许插件读取防火墙状态。", "若手机找不到电脑,以管理员身份重新运行 setup。"));
16698
+ if (snapshot.remote.running && snapshot.remote.state === "ready") checks.push(check("remote-service", "ok", "remote-service-ready", "本机通道状态", "提供方报告本机组件已就绪;不代表公网或手机路径已验证。", void 0, { provider: snapshot.remote.provider }));
16699
+ if (remoteObservation.dns !== void 0) {
16700
+ const kind = remoteObservation.dns.kind;
16701
+ checks.push(check("remote-dns", kind === "fake-ip" ? "warning" : "info", `dns-${kind}`, "系统 DNS", `电脑系统解析分类:${kind};IP:${remoteObservation.dns.family}。未查询公共 DNS,地址不写入报告。`, void 0, {
16702
+ dnsKind: kind,
16703
+ dnsFamily: remoteObservation.dns.family
16704
+ }));
16705
+ }
16605
16706
  if (!snapshot.remote.running || snapshot.remote.state === "off") checks.push(check("remote", "info", "remote-off", "远程通道", "当前未启用。", void 0, { provider: snapshot.remote.provider }));
16606
16707
  else if (snapshot.remote.provider === "origin" && snapshot.remote.state === "ready") checks.push(check("remote", "info", "remote-origin-ready", "远程通道", "私网 HTTP 后端已就绪;未检测公网 HTTPS、证书或 WebSocket 连通性。", "在手机上经反向代理验证连接;代理须保留原始 Host(含端口)并支持 WebSocket。", { provider: "origin" }));
16607
16708
  else if (snapshot.remote.provider === "iroh" && snapshot.remote.state === "ready") checks.push(check("remote", "info", "remote-iroh-ready", "远程通道", "iroh 通道已就绪;端点身份为回环 TLS,不从电脑端探测手机可达性。", "在手机上用配对二维码验证连接;二维码过期或通道报错时重新生成并重连。", { provider: "iroh" }));
@@ -16613,10 +16714,14 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
16613
16714
  ...remoteObservation.latencyMs === void 0 ? {} : { latencyMs: remoteObservation.latencyMs },
16614
16715
  ...remoteObservation.viaProxy === true ? { viaProxy: true } : {}
16615
16716
  };
16616
- if (remoteObservation.state === "ready") checks.push(check("remote", remoteObservation.viaProxy === true ? "warning" : "ok", "remote-ready", "远程通道", remoteObservation.viaProxy === true ? `电脑经代理可访问 ${endpointSuffix},但尚未验证手机能否连接。` : `${snapshot.remote.provider} 公共地址 ${endpointSuffix} 可达,往返约 ${String(remoteObservation.latencyMs ?? 0)} ms。`, remoteObservation.viaProxy === true ? "请在手机上测试远程连接。" : void 0, facts));
16717
+ if (remoteObservation.state === "ready") checks.push(check("remote", remoteObservation.viaProxy === true ? "warning" : "ok", "remote-ready", "远程通道", remoteObservation.viaProxy === true ? `电脑经代理可访问 ${endpointSuffix},但尚未验证手机能否连接。` : `电脑当前出口可访问 ${snapshot.remote.provider} 入口 ${endpointSuffix},往返约 ${String(remoteObservation.latencyMs ?? 0)} ms;公网及手机路径未独立验证。`, remoteObservation.viaProxy === true ? "请在手机上测试远程连接。" : void 0, facts));
16617
16718
  else if (remoteObservation.state === "rate-limited") checks.push(check("remote", "warning", "remote-rate-limited", "远程通道", remoteObservation.viaProxy === true ? "电脑经代理访问时观察到限流,尚未验证手机能否连接。" : "公共地址可达,但本次检查观察到服务限流。", "稍后重试;旧会话会按需加载以减少流量。", facts));
16618
- else if (snapshot.remote.provider === "tailscale" && remoteObservation.fakeIp === true) checks.push(check("remote", "error", "remote-fake-ip", "远程通道", "Tailscale 地址被当前 VPN 或 DNS 代理接管,但 TLS 链路未建立。", "切换 VPN 节点或代理模式;仍失败时改用 cpolar。", facts));
16619
- else checks.push(check("remote", "error", "remote-unreachable", "远程通道", "提供方显示已就绪,但公共地址暂不可达。", "点击“重新连接”;仍失败时检查提供方状态。", facts));
16719
+ else if (snapshot.remote.provider === "tailscale" && remoteObservation.dns?.kind === "tailnet" && (remoteObservation.failureStage === "connect" || remoteObservation.failureStage === "timeout")) checks.push(check("remote", "warning", "remote-tailnet-shadowed", "电脑出口探测", "本机系统 DNS 返回 tailnet 地址,当前 Funnel-only 监听不接受此私网路径;本次失败不能证明公网 Funnel 故障。", "用 tailnet 外的网络单独验证;如自愿临时调整设备 DNS,注意其对其他私网服务的影响并在对照后恢复。", facts));
16720
+ else if (snapshot.remote.provider === "tailscale" && (remoteObservation.fakeIp === true || remoteObservation.dns?.kind === "fake-ip") && remoteObservation.failureStage !== "tls" && remoteObservation.failureStage !== "http") checks.push(check("remote", "error", "remote-fake-ip", "远程通道", "本机系统 DNS 返回 Fake-IP 网段,本次出口探测未成功;这不是公网故障的证明。", "核对 DNS 与代理路由,用其他网络对照,不要关闭证书验证。", facts));
16721
+ else {
16722
+ const reason = remoteObservation.failureStage === "dns" ? "remote-dns-failed" : remoteObservation.failureStage === "connect" ? "remote-connect-failed" : remoteObservation.failureStage === "tls" ? "remote-tls-failed" : remoteObservation.failureStage === "timeout" ? "remote-probe-timeout" : remoteObservation.failureStage === "http" ? "remote-http-failed" : "remote-unreachable";
16723
+ checks.push(check("remote", "error", reason, "电脑出口探测", "电脑当前出口的探测未通过;不代表其他网络或手机一定失败。", "按失败阶段核对 DNS、路由、证书或代理,并用手机当前网络对照。", facts));
16724
+ }
16620
16725
  } else if (snapshot.remote.state === "starting" || snapshot.remote.state === "connecting" || snapshot.remote.state === "needs-login") {
16621
16726
  const needsLogin = snapshot.remote.state === "needs-login";
16622
16727
  checks.push(check("remote", "warning", needsLogin ? "remote-needs-login" : "remote-connecting", "远程通道", needsLogin ? "等待完成 Tailscale 登录。" : "仍在建立连接。", needsLogin ? "返回远程页继续登录。" : "等待片刻后重新检查。", { provider: snapshot.remote.provider }));
@@ -16627,15 +16732,16 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
16627
16732
  controllerCode
16628
16733
  }));
16629
16734
  }
16630
- checks.push(check("phone-network", "info", "phone-network-unknown", "手机网络", "电脑无法判断路由器是否隔离了手机。", "局域网仍失败时,确认手机与电脑在同一网络,并关闭访客网络或 AP 隔离。"));
16735
+ checks.push(check("phone-network", "info", "phone-network-unknown", "手机网络", "电脑没有验证手机网络、认证页面、资源加载或 WebSocket。", "请用手机当前网络实际连接,记录失败阶段;不要提供配对凭据或直接关闭安全隔离措施。"));
16631
16736
  const overall = checks.some((entry) => entry.status === "error") ? "error" : checks.some((entry) => entry.status === "warning") ? "attention" : "ok";
16632
- const summary = overall === "ok" ? "连接基础检查正常。" : overall === "attention" ? "发现需要留意的项目。" : "发现会影响连接的问题。";
16737
+ const summary = overall === "ok" ? "电脑端基础检查完成;手机、认证页面、资源与 WebSocket 尚未验证。" : overall === "attention" ? "电脑端检查有待确认项目;请区分测量路径与实际手机连接。" : "电脑端检查有失败项目;不据此推断所有网络均不可达。";
16633
16738
  const report = [
16634
16739
  "DSH Mobile 诊断报告",
16635
16740
  `生成时间: ${(/* @__PURE__ */ new Date()).toISOString()}`,
16636
16741
  `版本: plugin=${DSH_MOBILE_VERSION}; dsh=${snapshot.dshVersion}; min-app=${MINIMUM_ANDROID_APP_VERSION}`,
16637
16742
  `LAN: ${snapshot.lan.running ? "on" : "off"}; endpoint=${maskLanOrigin(snapshot.lan.origin)}`,
16638
16743
  `Remote: provider=${snapshot.remote.provider}; state=${snapshot.remote.state}; endpoint=${remoteSuffix(snapshot.remote.origin)}`,
16744
+ "Coverage: computer-side only; phone, authenticated page, resources and WebSocket remain unverified.",
16639
16745
  ...checks.map(reportLine)
16640
16746
  ].join("\n");
16641
16747
  return Object.freeze({
@@ -16649,11 +16755,19 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
16649
16755
  }),
16650
16756
  summary,
16651
16757
  checks: Object.freeze(checks),
16652
- report
16758
+ report,
16759
+ coverage: Object.freeze({
16760
+ measuredFrom: "computer",
16761
+ unverified: Object.freeze([
16762
+ "phone",
16763
+ "authenticated-page",
16764
+ "resources",
16765
+ "websocket"
16766
+ ])
16767
+ })
16653
16768
  });
16654
16769
  }
16655
- //#endregion
16656
- //#region src/mobile-guide.ts
16770
+ // src/mobile-guide.ts
16657
16771
  /** Compose the guide with the current customization state injected. */
16658
16772
  function buildMobileGuide(state) {
16659
16773
  const styleLine = state.hasCustomCss ? "存在(已检测到文件,需在已配对页面验证加载)" : "不存在(使用内置默认样式)";
@@ -16718,8 +16832,7 @@ const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手
16718
16832
  - 创建或修改扩展后,确认 extension.json 的 schemaVersion 为 1、id 与目录名一致、且 id 只含小写字母数字和连字符
16719
16833
  - 扩展的 host.mjs 若在完成前无法激活,先修正而不是留下损坏的扩展
16720
16834
  - 完成后检查自己实际写入了哪些文件,向用户简要说明改了什么、手机端会有什么变化`;
16721
- //#endregion
16722
- //#region src/vps-deploy.ts
16835
+ // src/vps-deploy.ts
16723
16836
  const FRP_VERSION = "0.70.1";
16724
16837
  const SSH_TIMEOUT_MS = 3e5;
16725
16838
  const LINUX_ARTIFACTS = Object.freeze({
@@ -17690,8 +17803,7 @@ async function uninstallVps(serverAddress, uninstall, input, options = {}) {
17690
17803
  checks
17691
17804
  });
17692
17805
  }
17693
- //#endregion
17694
- //#region src/lan-rebind.ts
17806
+ // src/lan-rebind.ts
17695
17807
  /** An unreadable setup is rejected before stopping the listener or replacing any file. */
17696
17808
  var ManagedLanConfigurationError = class extends Error {
17697
17809
  constructor(cause) {
@@ -17728,8 +17840,7 @@ async function rebindManagedLan(options) {
17728
17840
  running: result.running
17729
17841
  };
17730
17842
  }
17731
- //#endregion
17732
- //#region src/funnel.ts
17843
+ // src/funnel.ts
17733
17844
  const MAX_PROTOCOL_LINE_BYTES = 16384;
17734
17845
  const FUNNEL_START_TIMEOUT_MS = 45e3;
17735
17846
  function publicStatus$1(status) {
@@ -18243,8 +18354,7 @@ function funnelExecutable(importMetaUrl, environment = process.env) {
18243
18354
  const file = `dsh-mobile-funnel-${process.platform}-${process.arch}${suffix}`;
18244
18355
  return resolve(fileURLToPath(new URL(`../bin/${file}`, importMetaUrl)));
18245
18356
  }
18246
- //#endregion
18247
- //#region src/cpolar.ts
18357
+ // src/cpolar.ts
18248
18358
  const MAX_LOG_BUFFER_BYTES = 65536;
18249
18359
  const START_TIMEOUT_MS = 45e3;
18250
18360
  const CPOLAR_HOST_SUFFIXES = Object.freeze([
@@ -18660,8 +18770,7 @@ var CpolarController = class {
18660
18770
  ], "cpolar resource cleanup failed");
18661
18771
  }
18662
18772
  };
18663
- //#endregion
18664
- //#region src/caddy-files.ts
18773
+ // src/caddy-files.ts
18665
18774
  /** Refuse link-shaped ancestors within Caddy's owned state root. */
18666
18775
  async function assertCaddyParents(root, target) {
18667
18776
  await assertManagedParents(root, target, "caddy");
@@ -18682,8 +18791,7 @@ async function writeCaddyPrivateFile(root, file, body) {
18682
18791
  async function removeCaddyTree(root, target) {
18683
18792
  await removeManagedTree(root, target, "caddy");
18684
18793
  }
18685
- //#endregion
18686
- //#region src/caddy-component.ts
18794
+ // src/caddy-component.ts
18687
18795
  const CADDY_VERSION = "2.11.7";
18688
18796
  const CADDY_DNS_PLUGIN_VERSION = "v0.4.3";
18689
18797
  const CADDY_DOWNLOAD_PAGE = "https://github.com/saya-ch/dsh-mobile/releases/tag/caddy-component-2.11.7-tencentcloud-0.4.3-r1";
@@ -19202,8 +19310,7 @@ var CaddyConfigStore = class {
19202
19310
  return task;
19203
19311
  }
19204
19312
  };
19205
- //#endregion
19206
- //#region src/caddy.ts
19313
+ // src/caddy.ts
19207
19314
  /** Private directories override Caddy's platform defaults; ambient API keys and admin/proxy overrides are omitted. */
19208
19315
  function caddyProcessEnvironment(config, settings, environment = process.env) {
19209
19316
  const credentials = config.credentials();
@@ -19533,8 +19640,7 @@ var CaddyController = class {
19533
19640
  }
19534
19641
  }
19535
19642
  };
19536
- //#endregion
19537
- //#region src/managed-origin.ts
19643
+ // src/managed-origin.ts
19538
19644
  /** Children never independently restore a durable enabled switch. */
19539
19645
  function createVolatileRemoteControlStore() {
19540
19646
  let value = {
@@ -19715,8 +19821,7 @@ var ManagedOriginController = class {
19715
19821
  return task;
19716
19822
  }
19717
19823
  };
19718
- //#endregion
19719
- //#region src/release-update.ts
19824
+ // src/release-update.ts
19720
19825
  const PACKAGE_NAME = "dsh-mobile";
19721
19826
  const NPM_LATEST_URL = "https://registry.npmjs.org/dsh-mobile/latest";
19722
19827
  const ANDROID_RELEASE_DESCRIPTOR_URL = "https://raw.githubusercontent.com/saya-ch/dsh-mobile/main/apps/mobile/release.json";
@@ -20179,8 +20284,7 @@ var PluginReleaseManager = class {
20179
20284
  });
20180
20285
  }
20181
20286
  };
20182
- //#endregion
20183
- //#region src/file-logger.ts
20287
+ // src/file-logger.ts
20184
20288
  const MAX_LOG_BYTES = 5242880;
20185
20289
  const MAX_QUEUED_BYTES = 262144;
20186
20290
  const PATH_PREFIX = "mobile_log";
@@ -20290,14 +20394,12 @@ async function installMobileFileLogger(ctx, stateDirectory, options = {}) {
20290
20394
  }
20291
20395
  return file;
20292
20396
  }
20293
- //#endregion
20294
- //#region src/mobile-push/contracts.ts
20397
+ // src/mobile-push/contracts.ts
20295
20398
  const OUTCOME_COALESCE_MS = 1e3;
20296
20399
  const ATTENTION_TTL_MS = 3e5;
20297
20400
  const OUTCOME_TTL_MS = 18e5;
20298
20401
  const ATTEMPT_TIMEOUT_MS = 1e4;
20299
- //#endregion
20300
- //#region src/mobile-push/requests.ts
20402
+ // src/mobile-push/requests.ts
20301
20403
  var RequestLifetimes = class {
20302
20404
  #records = /* @__PURE__ */ new Map();
20303
20405
  #byCall = /* @__PURE__ */ new Map();
@@ -20477,8 +20579,7 @@ var RequestLifetimes = class {
20477
20579
  }, 250);
20478
20580
  }
20479
20581
  };
20480
- //#endregion
20481
- //#region src/mobile-push/outcomes.ts
20582
+ // src/mobile-push/outcomes.ts
20482
20583
  const isCompletedGoalPhase = (phase) => phase === "complete";
20483
20584
  var OutcomeLifetimes = class {
20484
20585
  #ctx;
@@ -20658,8 +20759,7 @@ var OutcomeLifetimes = class {
20658
20759
  return current;
20659
20760
  }
20660
20761
  };
20661
- //#endregion
20662
- //#region src/mobile-push/reconcile.ts
20762
+ // src/mobile-push/reconcile.ts
20663
20763
  const TURN_REASONS = [
20664
20764
  "completed",
20665
20765
  "error",
@@ -20931,8 +21031,7 @@ var PushReconciler = class {
20931
21031
  return verifyOutcome(source, values, agent);
20932
21032
  }
20933
21033
  };
20934
- //#endregion
20935
- //#region src/mobile-push/host.ts
21034
+ // src/mobile-push/host.ts
20936
21035
  function hasContract(ctx) {
20937
21036
  return typeof ctx.on === "function" && typeof ctx.sessionProjections?.stateOf === "function" && typeof ctx.sessionProjections.snapshot === "function" && typeof ctx.sessionProjections.register === "function" && typeof ctx.sessionQuery?.observeSession === "function" && typeof ctx.agents?.get === "function" && typeof ctx.agents.list === "function" && typeof ctx.agents.roots === "function" && typeof ctx.agents.isOwnedBy === "function" && ctx.userQuestions !== void 0 && ctx.approval !== void 0;
20938
21037
  }
@@ -21143,8 +21242,7 @@ function createPushHost(ctx) {
21143
21242
  }
21144
21243
  };
21145
21244
  }
21146
- //#endregion
21147
- //#region src/mobile-push/endpoint.ts
21245
+ // src/mobile-push/endpoint.ts
21148
21246
  const MAX_ENDPOINT_BYTES = 2048;
21149
21247
  const APPLE_SUFFIX = ".push.apple.com";
21150
21248
  const BASE64URL = /^[A-Za-z0-9_-]+$/u;
@@ -21261,8 +21359,7 @@ async function resolveAppleEndpoint(endpoint, resolver = systemResolver) {
21261
21359
  addresses: Object.freeze([...addresses])
21262
21360
  });
21263
21361
  }
21264
- //#endregion
21265
- //#region src/mobile-push/store.ts
21362
+ // src/mobile-push/store.ts
21266
21363
  const MAX_STATE_BYTES = 4194304;
21267
21364
  const emptySnapshot = () => ({
21268
21365
  version: 1,
@@ -21525,8 +21622,7 @@ var JsonPushStore = class {
21525
21622
  }
21526
21623
  }
21527
21624
  };
21528
- //#endregion
21529
- //#region src/mobile-push/registry.ts
21625
+ // src/mobile-push/registry.ts
21530
21626
  const empty = () => ({
21531
21627
  version: 1,
21532
21628
  ownerGenerations: [],
@@ -21914,8 +22010,7 @@ var PushRegistry = class {
21914
22010
  };
21915
22011
  }
21916
22012
  };
21917
- //#endregion
21918
- //#region src/mobile-push/payload.ts
22013
+ // src/mobile-push/payload.ts
21919
22014
  const messages = {
21920
22015
  en: {
21921
22016
  question: ["Input requested", "A response needs your attention."],
@@ -21967,8 +22062,7 @@ function buildDeclarativePayload(category, locale, navigate, tag) {
21967
22062
  if (Buffer.byteLength(payload, "utf8") > 3072) throw new TypeError("push_payload_too_large");
21968
22063
  return payload;
21969
22064
  }
21970
- //#endregion
21971
- //#region src/mobile-push/retention.ts
22065
+ // src/mobile-push/retention.ts
21972
22066
  /** Pure defer-membership retention helpers shared by delivery maintenance. */
21973
22067
  /** Removes deferred-recheck entries whose owning pending intent no longer exists; returns how many were removed. */
21974
22068
  function pruneOrphanedDeferrals(deferred, pending) {
@@ -21981,8 +22075,7 @@ function pruneOrphanedDeferrals(deferred, pending) {
21981
22075
  }
21982
22076
  return removed;
21983
22077
  }
21984
- //#endregion
21985
- //#region src/mobile-push/sender.ts
22078
+ // src/mobile-push/sender.ts
21986
22079
  const MAX_RESPONSE_BYTES = 8192;
21987
22080
  const MAX_TTL_SECONDS = 1800;
21988
22081
  const CONTENT_ENCODING = "aes128gcm";
@@ -22268,8 +22361,7 @@ var PushSender = class {
22268
22361
  }
22269
22362
  }
22270
22363
  };
22271
- //#endregion
22272
- //#region src/mobile-push/delivery.ts
22364
+ // src/mobile-push/delivery.ts
22273
22365
  const RECONCILE_INTERVAL_MS = 3e4;
22274
22366
  const RETRY_BACKOFF_MS = [1e3, 5e3];
22275
22367
  const MAX_TEST_QUEUE = 64;
@@ -23055,8 +23147,7 @@ var PushDelivery = class {
23055
23147
  }, delay);
23056
23148
  }
23057
23149
  };
23058
- //#endregion
23059
- //#region src/mobile-push/gateway.ts
23150
+ // src/mobile-push/gateway.ts
23060
23151
  /** One service shared by every Gateway while each Gateway owns a separate auth/origin attachment. */
23061
23152
  var PushGatewayCoordinator = class {
23062
23153
  host;
@@ -23179,8 +23270,7 @@ var PushGatewayCoordinator = class {
23179
23270
  return true;
23180
23271
  }
23181
23272
  };
23182
- //#endregion
23183
- //#region src/installation-id.ts
23273
+ // src/installation-id.ts
23184
23274
  const INSTALLATION_ID_PATTERN = /^[a-f0-9]{64}\n$/u;
23185
23275
  const INSTALLATION_ID_BYTES = 65;
23186
23276
  async function readInstallationId(file) {
@@ -23244,8 +23334,7 @@ async function loadOrCreateInstallationId(file) {
23244
23334
  }
23245
23335
  }
23246
23336
  }
23247
- //#endregion
23248
- //#region src/gateway-identity.ts
23337
+ // src/gateway-identity.ts
23249
23338
  /**
23250
23339
  * Attach the authenticated installation identity while retaining existing entry identity rules.
23251
23340
  * @param config - Validated gateway configuration containing the legacy identity.
@@ -23265,8 +23354,7 @@ function withInstallationIdentity(config, installationId, explicitIdentity) {
23265
23354
  legacyInstanceId: config.instanceId
23266
23355
  });
23267
23356
  }
23268
- //#endregion
23269
- //#region src/lan-setup.ts
23357
+ // src/lan-setup.ts
23270
23358
  const FIREWALL_TCP_RULE = "DSH Mobile HTTPS";
23271
23359
  const FIREWALL_UDP_RULE = "DSH Mobile Discovery";
23272
23360
  async function runElevatedPowerShell(script) {
@@ -23338,8 +23426,7 @@ async function prepareManagedLanSetup(options) {
23338
23426
  androidCertificate
23339
23427
  });
23340
23428
  }
23341
- //#endregion
23342
- //#region src/plugin.ts
23429
+ // src/plugin.ts
23343
23430
  /** Stable Cordis plugin name. */
23344
23431
  const name = "dsh-mobile";
23345
23432
  /** The stock WebServer serves the control card; Connection authenticates the loopback DSH origin. */
@@ -24324,7 +24411,18 @@ async function apply(ctx, config) {
24324
24411
  windowsFirewall: process.platform === "win32"
24325
24412
  };
24326
24413
  };
24327
- const diagnosticsPayload = async () => {
24414
+ const diagnosticsPayload = async (request) => {
24415
+ const admission = ctx.connection?.admit?.(request);
24416
+ if (admission !== void 0 && "rejection" in admission) throw new HttpError(admission.rejection, "authentication_failed");
24417
+ const indexCollector = ctx.webServer;
24418
+ let indexInjections = [];
24419
+ let indexInspectionUnavailable = false;
24420
+ if (typeof indexCollector.collectIndexInjections === "function") try {
24421
+ indexInjections = indexCollector.collectIndexInjections(admission?.peer);
24422
+ } catch (inspectionError) {
24423
+ indexInspectionUnavailable = true;
24424
+ }
24425
+ else indexInspectionUnavailable = true;
24328
24426
  let interfaceName;
24329
24427
  let networkError;
24330
24428
  if (loaded.kind === "managed") try {
@@ -24354,7 +24452,8 @@ async function apply(ctx, config) {
24354
24452
  } : void 0;
24355
24453
  return collectConnectionDiagnostics({
24356
24454
  dshVersion,
24357
- competingRemoteChannelBoot: hasCompetingRemoteChannelBoot(ctx.webServer.collectIndexInjections?.() ?? []),
24455
+ competingRemoteChannelBoot: hasCompetingRemoteChannelBoot(indexInjections),
24456
+ indexInspectionUnavailable,
24358
24457
  lan: {
24359
24458
  configured: loaded.kind !== "unconfigured" || preparedLanSetup !== void 0,
24360
24459
  running: lanController.isRunning(),
@@ -24425,12 +24524,15 @@ async function apply(ctx, config) {
24425
24524
  }
24426
24525
  if (request.method !== "POST") throw new HttpError(405, "method_not_allowed");
24427
24526
  const body = await readJsonObject(request, template.maxBodyBytes);
24428
- if (Object.keys(body).length !== 1 || Object.keys(body).some((key) => key !== "reset" && key !== "excludedClientModules" && key !== "measure") || "measure" in body && body.measure !== true) throw new HttpError(400, "bad_request");
24429
- sendJson(response, 200, body.measure === true ? await gateway.measureClientModules(void 0, operation.signal) : body.reset === true ? await gateway.resetClientModules(void 0, operation.signal) : await gateway.configureClientModules(body.excludedClientModules, void 0, operation.signal), false);
24527
+ const keys = Object.keys(body);
24528
+ const configure = keys.every((key) => key === "excludedClientModules" || key === "revision") && Object.prototype.hasOwnProperty.call(body, "excludedClientModules") && (body.revision === void 0 || typeof body.revision === "string");
24529
+ const single = keys.length === 1 && (body.reset === true || body.measure === true);
24530
+ if (!configure && !single) throw new HttpError(400, "bad_request");
24531
+ sendJson(response, 200, body.measure === true ? await gateway.measureClientModules(void 0, operation.signal) : body.reset === true ? await gateway.resetClientModules(void 0, operation.signal) : await gateway.configureClientModules(body.excludedClientModules, void 0, operation.signal, typeof body.revision === "string" ? body.revision : void 0), false);
24430
24532
  } catch (error) {
24431
24533
  if (!(error instanceof ClientModuleConflictError)) throw error;
24432
24534
  sendJson(response, 409, {
24433
- error: "excluded_client_modules_invalid",
24535
+ error: error.code,
24434
24536
  detail: error.detail
24435
24537
  }, false);
24436
24538
  } finally {
@@ -24461,7 +24563,7 @@ async function apply(ctx, config) {
24461
24563
  return;
24462
24564
  }
24463
24565
  if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/diagnostics`) {
24464
- sendJson(response, 200, await diagnosticsPayload(), false);
24566
+ sendJson(response, 200, await diagnosticsPayload(request), false);
24465
24567
  return;
24466
24568
  }
24467
24569
  if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/release`) {
@@ -25146,7 +25248,6 @@ async function apply(ctx, config) {
25146
25248
  };
25147
25249
  }, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
25148
25250
  }
25149
- //#endregion
25150
25251
  export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CERT_EXPIRING_DAYS, CLOUDFLARED_COMPONENT_RELEASE, CLOUDFLARED_COMPONENT_RELEASES, CPOLAR_COMPONENT_RELEASE, CPOLAR_COMPONENT_RELEASES, CSRF_COOKIE, CSRF_HEADER, ClientModuleConflictError, ClientModulePreferenceStore, CloudflaredComponentManager, CloudflaredController, CloudflaredTunnelStore, Config, CpolarComponentManager, DEFAULT_IROH_LOCAL_PORT, 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, IROH_COMPONENT_RELEASES, IROH_LEAF_RENEW_BEFORE_DAYS, IROH_LEAF_VALIDITY_DAYS, IROH_VERSION, IrohComponentManager, IrohController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_PAIRING_DESCRIPTOR_BYTES, MAX_PAIRING_LIFETIME_MS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, OriginConfigStore, OriginController, PAIRING_DESCRIPTOR_LOCAL_PORT, PAIRING_DESCRIPTOR_TYPE, PAIRING_DESCRIPTOR_VERSION, 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, decodePairingDescriptor, encodePairingDescriptor, ensureFrpIngressCertificate, ensureIrohTls, evaluateCertificateLifetime, frpAttachVpsParts, frpIngressPaths, frpIngressSelfCheck, frpProxyName, inject, irohGatewayConfig, irohHostname, irohOrigin, irohTlsPaths, 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, resolveHostExecution, rewriteMobileIndex, validateAttachSettings, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpEntryTls, validateFrpMode, validateFrpProxyName, validateFrpPublicOrigin, validateFrpPublicPort, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateFrpVhostHttpPort, validateIrohOrigin, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validatePairingDescriptor, validateWebSocketPath, watchTaskCompletions };
25151
25252
 
25152
25253
  //# sourceMappingURL=index.mjs.map