dsh-mobile 0.8.0 → 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.
Files changed (40) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/CONTRIBUTORS.md +24 -2
  3. package/README.en.md +8 -5
  4. package/README.md +10 -5
  5. package/SECURITY.md +1 -1
  6. package/apps/mobile/README.md +8 -4
  7. package/apps/mobile/README.zh-CN.md +8 -4
  8. package/apps/mobile/release.json +3 -3
  9. package/docs/CLIENT_MODULES.en.md +6 -2
  10. package/docs/CLIENT_MODULES.md +6 -2
  11. package/docs/CONNECTION_DIAGNOSTICS.en.md +50 -0
  12. package/docs/CONNECTION_DIAGNOSTICS.md +50 -0
  13. package/docs/IROH.en.md +6 -0
  14. package/docs/IROH.md +6 -0
  15. package/docs/MOBILE_PLUGIN_ADAPTATION.en.md +18 -0
  16. package/docs/MOBILE_PLUGIN_ADAPTATION.md +18 -0
  17. package/docs/MOBILE_PLUGIN_BASELINE.en.md +117 -0
  18. package/docs/MOBILE_PLUGIN_BASELINE.md +117 -0
  19. package/docs/README.md +2 -0
  20. package/docs/SKIN_COMPATIBILITY.en.md +1 -1
  21. package/docs/SKIN_COMPATIBILITY.md +1 -1
  22. package/docs/SLOW_CONNECTIONS.en.md +3 -1
  23. package/docs/SLOW_CONNECTIONS.md +3 -1
  24. package/lib/cli.js +6 -12
  25. package/lib/cli.js.map +1 -1
  26. package/lib/client.js +428 -126
  27. package/lib/client.js.map +1 -1
  28. package/lib/extension-worker-runtime.mjs +4 -8
  29. package/lib/extension-worker-runtime.mjs.map +1 -1
  30. package/lib/index.d.mts +16 -89
  31. package/lib/index.mjs +519 -253
  32. package/lib/index.mjs.map +1 -1
  33. package/lib/mobile-compat.js +2 -2
  34. package/lib/mobile-compat.js.map +1 -1
  35. package/lib/mobile-layout.js +13 -6
  36. package/lib/mobile-layout.js.map +1 -1
  37. package/package.json +10 -6
  38. package/packages/question-fixes/lib/client.js +1 -2
  39. package/packages/question-fixes/lib/index.mjs +1 -2
  40. package/packages/question-fixes/package.json +1 -1
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,16 @@ var ClientModulePreferenceStore = class {
1545
1537
  }
1546
1538
  }
1547
1539
  };
1548
- //#endregion
1549
- //#region src/control.ts
1540
+ // src/control.ts
1541
+ /** A configuration change failed; recovered distinguishes a restored runtime from an unknown configuration. */
1542
+ var MobileAccessReconfigurationError = class extends Error {
1543
+ recovered;
1544
+ constructor(recovered, cause) {
1545
+ super(recovered ? "mobile access configuration restored" : "mobile access configuration requires recovery", { cause });
1546
+ this.recovered = recovered;
1547
+ this.name = "MobileAccessReconfigurationError";
1548
+ }
1549
+ };
1550
1550
  /** Keeps one runtime aligned with a changing network selection. */
1551
1551
  var FollowingMobileAccessRuntime = class {
1552
1552
  select;
@@ -1737,6 +1737,36 @@ var MobileAccessGatewayController = class {
1737
1737
  else await this.disable();
1738
1738
  });
1739
1739
  }
1740
+ /** Change configuration with the runtime stopped, without changing its durable enabled preference. */
1741
+ reconfigure(change, restore) {
1742
+ if (this.closing) return Promise.reject(/* @__PURE__ */ new Error("mobile-access control is closing"));
1743
+ return this.enqueue(async () => {
1744
+ if (!this.initialized) throw new Error("mobile-access control is not initialized");
1745
+ const previous = this.runtime;
1746
+ if (previous !== void 0) try {
1747
+ await previous.close();
1748
+ } catch (error) {
1749
+ throw new MobileAccessReconfigurationError(false, error);
1750
+ }
1751
+ this.runtime = void 0;
1752
+ try {
1753
+ const value = await change();
1754
+ if (previous !== void 0) this.runtime = await this.startRuntime();
1755
+ return {
1756
+ value,
1757
+ running: previous !== void 0
1758
+ };
1759
+ } catch (error) {
1760
+ try {
1761
+ await restore();
1762
+ if (previous !== void 0) this.runtime = await this.startRuntime();
1763
+ } catch (restoreError) {
1764
+ throw new MobileAccessReconfigurationError(false, new AggregateError([error, restoreError]));
1765
+ }
1766
+ throw new MobileAccessReconfigurationError(true, error);
1767
+ }
1768
+ });
1769
+ }
1740
1770
  /** Stop the runtime after earlier transitions without changing the restart preference. */
1741
1771
  close() {
1742
1772
  if (this.closeTask !== void 0) return this.closeTask;
@@ -1829,8 +1859,7 @@ function isLocalAdminHostname(hostname) {
1829
1859
  if (octets === void 0) return false;
1830
1860
  return isLoopbackIpv4(octets) || isPrivateIpv4(octets) || isLinkLocalIpv4(octets);
1831
1861
  }
1832
- //#endregion
1833
- //#region src/http-security.ts
1862
+ // src/http-security.ts
1834
1863
  const DEVICE_COOKIE = "dsh_ma_device";
1835
1864
  const SESSION_COOKIE = "dsh_ma_session";
1836
1865
  const CSRF_COOKIE = "dsh_ma_csrf";
@@ -2014,8 +2043,7 @@ function assertLocalAdminTrust(request, requireBrowserOrigin, authenticateDeskto
2014
2043
  if (requireBrowserOrigin && site !== void 0 && site !== "same-origin") throw new HttpError(403, "forbidden");
2015
2044
  if (!loopback && !authenticateDesktop()) throw new HttpError(401, "admin_authentication_required");
2016
2045
  }
2017
- //#endregion
2018
- //#region src/browser-auth-cookies.ts
2046
+ // src/browser-auth-cookies.ts
2019
2047
  const HOST_SESSION_COOKIE = "__Host-dsh_ma_session";
2020
2048
  const HOST_CSRF_COOKIE = "__Host-dsh_ma_csrf";
2021
2049
  /** Non-secret browser identity; the persistent device credential keeps its narrow renewal path. */
@@ -2067,8 +2095,7 @@ function authenticatedCookie(values, authenticate, accepts = () => true) {
2067
2095
  if (selected === void 0) throw new HttpError(401, "authentication_failed");
2068
2096
  return selected;
2069
2097
  }
2070
- //#endregion
2071
- //#region src/auth-failure-log.ts
2098
+ // src/auth-failure-log.ts
2072
2099
  const LOG_INTERVAL_MS$3 = 3e4;
2073
2100
  const MAX_LOGGED_ORIGIN_LENGTH = 512;
2074
2101
  function requestPathCategory(path) {
@@ -2183,12 +2210,17 @@ var AuthFailureRecorder = class {
2183
2210
  }
2184
2211
  }
2185
2212
  };
2186
- //#endregion
2187
- //#region src/http-failure-log.ts
2213
+ // src/http-failure-log.ts
2188
2214
  function safeParserCode(code) {
2189
2215
  switch (code) {
2190
2216
  case "ERR_HTTP_REQUEST_TIMEOUT":
2191
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":
2192
2224
  case "HPE_INVALID_CHUNK_SIZE":
2193
2225
  case "HPE_INVALID_CONTENT_LENGTH":
2194
2226
  case "HPE_INVALID_EOF_STATE":
@@ -2223,7 +2255,7 @@ function classifyHttpParserFailure(nodeCode) {
2223
2255
  }
2224
2256
  const LOG_INTERVAL_MS$2 = 3e4;
2225
2257
  /**
2226
- * 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.
2227
2259
  * Each kind records once immediately, then at most once per 30 seconds with suppressed counts.
2228
2260
  * A sink failure cannot change a response, retry a write or expose the diagnostic sink's error.
2229
2261
  */
@@ -2236,6 +2268,10 @@ var HttpFailureRecorder = class {
2236
2268
  nextLogAt: 0,
2237
2269
  suppressed: 0
2238
2270
  },
2271
+ transport: {
2272
+ nextLogAt: 0,
2273
+ suppressed: 0
2274
+ },
2239
2275
  target: {
2240
2276
  nextLogAt: 0,
2241
2277
  suppressed: 0
@@ -2263,6 +2299,26 @@ var HttpFailureRecorder = class {
2263
2299
  });
2264
2300
  return failure;
2265
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
+ }
2266
2322
  /** Sample an invalid request target without retaining its raw or decoded URL. */
2267
2323
  recordTarget() {
2268
2324
  this.record({
@@ -2306,8 +2362,7 @@ var HttpFailureRecorder = class {
2306
2362
  }
2307
2363
  }
2308
2364
  };
2309
- //#endregion
2310
- //#region src/api-latency-log.ts
2365
+ // src/api-latency-log.ts
2311
2366
  /**
2312
2367
  * Bounded API latency samples for diagnosing slow mobile requests.
2313
2368
  *
@@ -2414,8 +2469,7 @@ var ApiLatencyRecorder = class {
2414
2469
  }
2415
2470
  }
2416
2471
  };
2417
- //#endregion
2418
- //#region src/upstream-auth-log.ts
2472
+ // src/upstream-auth-log.ts
2419
2473
  const LOG_INTERVAL_MS = 3e4;
2420
2474
  /** Two fixed sampling slots keep a first failure visible even after a successful exchange. */
2421
2475
  var UpstreamAuthRecorder = class {
@@ -2480,8 +2534,7 @@ var UpstreamAuthRecorder = class {
2480
2534
  }
2481
2535
  }
2482
2536
  };
2483
- //#endregion
2484
- //#region src/client-module-recovery.ts
2537
+ // src/client-module-recovery.ts
2485
2538
  const CLIENT_MODULE_RECOVERY_COPY = {
2486
2539
  zh: {
2487
2540
  title: "移动页面模块需要调整",
@@ -2562,8 +2615,7 @@ function renderClientModuleRecoveryScript(locale) {
2562
2615
  })
2563
2616
  })()`;
2564
2617
  }
2565
- //#endregion
2566
- //#region src/mobile-compat-bootstrap.ts
2618
+ // src/mobile-compat-bootstrap.ts
2567
2619
  const MOBILE_COMPAT_PATH = `${AUTH_PREFIX}/compat.js`;
2568
2620
  /**
2569
2621
  * Name the compatibility bundle by content.
@@ -2639,8 +2691,7 @@ const EXTENSION_LIMITS = Object.freeze({
2639
2691
  assetBytes: 33554432,
2640
2692
  assetDepth: 8
2641
2693
  });
2642
- //#endregion
2643
- //#region src/http-cache.ts
2694
+ // src/http-cache.ts
2644
2695
  /** Entity-tag handling for authenticated GET and HEAD cache validation. */
2645
2696
  const ENTITY_TAG = /^[\t ]*(?:W\/)?("[\x21\x23-\x7e\x80-\xff]*")[\t ]*$/u;
2646
2697
  const ENTITY_TAG_LIST_ITEM = /(?:W\/)?"[\x21\x23-\x7e\x80-\xff]*"/gu;
@@ -2677,8 +2728,7 @@ function weakenEntityTag(etag) {
2677
2728
  const opaque = etag === void 0 ? void 0 : ENTITY_TAG.exec(etag)?.[1];
2678
2729
  return opaque === void 0 ? void 0 : `W/${opaque}`;
2679
2730
  }
2680
- //#endregion
2681
- //#region src/mobile-graph-events.ts
2731
+ // src/mobile-graph-events.ts
2682
2732
  /** Bounded SSE framing for the dedicated Mobile client graph stream. */
2683
2733
  /** Maximum retained UTF-8 frame/output bytes, with CRLF normalized to LF. */
2684
2734
  const MOBILE_GRAPH_EVENT_MAX_BYTES = 4194304;
@@ -2867,8 +2917,7 @@ var MobileGraphEventTransform = class extends Transform {
2867
2917
  function createMobileGraphEventTransform(project) {
2868
2918
  return new MobileGraphEventTransform(project);
2869
2919
  }
2870
- //#endregion
2871
- //#region src/websocket-compression.ts
2920
+ // src/websocket-compression.ts
2872
2921
  function payloadBytes(data) {
2873
2922
  if (Array.isArray(data)) return data.reduce((bytes, part) => bytes + part.byteLength, 0);
2874
2923
  return data.byteLength;
@@ -3088,15 +3137,13 @@ function bridgeCompressedWebSocket(request, client, upstream, head, target, upst
3088
3137
  }
3089
3138
  });
3090
3139
  }
3091
- //#endregion
3092
- //#region src/version.ts
3140
+ // src/version.ts
3093
3141
  const manifest = createRequire(import.meta.url)("../package.json");
3094
3142
  /** Version of the installed DSH Mobile plugin package. */
3095
3143
  const DSH_MOBILE_VERSION = typeof manifest.version === "string" ? manifest.version : "unknown";
3096
3144
  /** Oldest Android App release supported by this plugin generation. */
3097
3145
  const MINIMUM_ANDROID_APP_VERSION = "0.2.2";
3098
- //#endregion
3099
- //#region src/computer-images.ts
3146
+ // src/computer-images.ts
3100
3147
  const MAX_ENTRIES = 500;
3101
3148
  const MAX_IMAGE_BYTES = 20971520;
3102
3149
  const IMAGE_TYPES = Object.freeze({
@@ -3297,8 +3344,7 @@ async function readComputerImage(path, signal) {
3297
3344
  throw new HttpError(404, "file_unavailable");
3298
3345
  }
3299
3346
  }
3300
- //#endregion
3301
- //#region src/extension-worker.ts
3347
+ // src/extension-worker.ts
3302
3348
  /** Supervise opt-in local hosts without executing extension code on the DSH thread. */
3303
3349
  /** JSON bytes serialized and size-checked inside the worker. */
3304
3350
  var PreparedJsonResult = class {
@@ -3951,8 +3997,7 @@ var WorkerStreamBridge = class extends Readable {
3951
3997
  return super.emit(event, ...args);
3952
3998
  }
3953
3999
  };
3954
- //#endregion
3955
- //#region src/extensions.ts
4000
+ // src/extensions.ts
3956
4001
  /** A misbehaving host activation must not wedge the local watcher forever. */
3957
4002
  const HOST_ACTIVATION_TIMEOUT_MS = 5e3;
3958
4003
  /** The previous Host outlives the hidden-page refresh interval and one timed refresh. */
@@ -5025,8 +5070,7 @@ function workerLoggerAdapter(context) {
5025
5070
  function createMobileAccessService(ctx) {
5026
5071
  return new MobileAccessService(ctx);
5027
5072
  }
5028
- //#endregion
5029
- //#region src/auth-pages.ts
5073
+ // src/auth-pages.ts
5030
5074
  const COPY = {
5031
5075
  en: {
5032
5076
  lang: "en",
@@ -5254,8 +5298,7 @@ function renderLoginScript(locale) {
5254
5298
  })()
5255
5299
  `;
5256
5300
  }
5257
- //#endregion
5258
- //#region src/browser-ui-locale.ts
5301
+ // src/browser-ui-locale.ts
5259
5302
  const UI_LOCALE_COOKIE = "dsh_ma_ui_locale";
5260
5303
  /** Read presentation data only; malformed or ambiguous cookies keep browser negotiation. */
5261
5304
  function readBrowserUiLocale(cookieHeader) {
@@ -5274,8 +5317,7 @@ function readBrowserUiLocale(cookieHeader) {
5274
5317
  function resolveBrowserUiLocale(cookieHeader, acceptLanguage) {
5275
5318
  return readBrowserUiLocale(cookieHeader) ?? resolveAuthPageLocale(acceptLanguage);
5276
5319
  }
5277
- //#endregion
5278
- //#region src/mobile-push/assets.ts
5320
+ // src/mobile-push/assets.ts
5279
5321
  const ICON_FILES = Object.freeze({
5280
5322
  "/mobile-access/push/icon-180.png": "icon-180.png",
5281
5323
  "/mobile-access/push/icon-192.png": "icon-192.png",
@@ -5327,16 +5369,14 @@ const loadFromPackage = createMobilePushAssetLoader(packageRoot);
5327
5369
  function mobilePushAsset(path, root = packageRoot) {
5328
5370
  return root === packageRoot ? loadFromPackage(path) : createMobilePushAssetLoader(root)(path);
5329
5371
  }
5330
- //#endregion
5331
- //#region src/mobile-push/installation-id.ts
5372
+ // src/mobile-push/installation-id.ts
5332
5373
  /** Browser-safe installation-id validation shared by the browser controller and the Gateway. */
5333
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;
5334
5375
  /** True only for a UUID-shaped installation id with an allowed version and variant nibble. */
5335
5376
  function isPushInstallationId(value) {
5336
5377
  return typeof value === "string" && INSTALLATION_ID.test(value);
5337
5378
  }
5338
- //#endregion
5339
- //#region src/gateway.ts
5379
+ // src/gateway.ts
5340
5380
  const MAX_CONTROL_BODY_BYTES = 16384;
5341
5381
  const MAX_HEADER_BYTES = 16384;
5342
5382
  const DISCOVERY_QUERY = Buffer.from("DSH_MOBILE_DISCOVER_V1", "ascii");
@@ -5509,6 +5549,7 @@ const RUNTIME_MODULE = "@deepseek-ai/dsh-client-runtime";
5509
5549
  const RENDERER_MODULE = "@deepseek-ai/dsh-client-ui-renderer";
5510
5550
  const SIDEBAR_MODULE = "@deepseek-ai/dsh-client-ui-sidebar";
5511
5551
  const SETTINGS_MODULE = "@deepseek-ai/dsh-client-ui-settings";
5552
+ const SETTINGS_GENERAL_MODULE = "@deepseek-ai/dsh-client-ui-settings-general";
5512
5553
  const API_GATEWAY_MODULE = "@deepseek-ai/dsh-api-gateway";
5513
5554
  const API_REMOTES_MODULE = "@deepseek-ai/dsh-api-remotes";
5514
5555
  const MOBILE_BOOT_CORE_MODULES = /* @__PURE__ */ new Set([
@@ -5519,6 +5560,7 @@ const MOBILE_BOOT_CORE_MODULES = /* @__PURE__ */ new Set([
5519
5560
  RENDERER_MODULE,
5520
5561
  SIDEBAR_MODULE,
5521
5562
  SETTINGS_MODULE,
5563
+ SETTINGS_GENERAL_MODULE,
5522
5564
  API_GATEWAY_MODULE,
5523
5565
  API_REMOTES_MODULE
5524
5566
  ]);
@@ -5639,8 +5681,8 @@ var ExcludedClientModulesError = class extends Error {
5639
5681
  /** A graph-selection conflict with a bounded diagnostic safe for authenticated callers. */
5640
5682
  var ClientModuleConflictError = class extends HttpError {
5641
5683
  detail;
5642
- constructor(detail) {
5643
- super(409, "excluded_client_modules_invalid");
5684
+ constructor(detail, code = "excluded_client_modules_invalid") {
5685
+ super(409, code);
5644
5686
  this.detail = detail.replace(/[\u0000-\u001f\u007f]/gu, " ").replace(/(?:https?:\/\/|[a-z]:[\\/])\S+/giu, "[redacted]").slice(0, 512);
5645
5687
  }
5646
5688
  };
@@ -5675,6 +5717,25 @@ function describeMobileClientModules(html) {
5675
5717
  dependencies: Object.freeze(clientModuleDependencies(entry).filter((id) => ids.has(id)))
5676
5718
  })));
5677
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
+ }
5678
5739
  /** Validate graph edges before removing application rows from both manifest views. */
5679
5740
  function excludeMobileBootEntries(entries, batches, excludedIds) {
5680
5741
  if (excludedIds.length === 0) return;
@@ -6570,7 +6631,11 @@ var MobileAccessGateway = class {
6570
6631
  });
6571
6632
  server.on("clientError", (error, socket) => {
6572
6633
  const nodeCode = "code" in error && typeof error.code === "string" ? error.code : void 0;
6573
- 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
+ }
6574
6639
  rejectUpgrade(socket, failure.status, failure.code);
6575
6640
  });
6576
6641
  await new Promise((resolve, reject) => {
@@ -7300,18 +7365,19 @@ var MobileAccessGateway = class {
7300
7365
  try {
7301
7366
  let kind = "describe";
7302
7367
  let excluded;
7368
+ let revision;
7303
7369
  if (request.method === "POST") {
7304
7370
  const body = await readJsonObject(request, this.config.maxBodyBytes);
7305
7371
  const keys = Object.keys(body);
7306
- if (keys.length !== 1) throw new HttpError(400, "bad_request");
7307
- if (keys[0] === "reset" && body.reset === true) kind = "reset";
7308
- else if (keys[0] === "measure" && body.measure === true) kind = "measure";
7309
- 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")) {
7310
7375
  kind = "configure";
7311
7376
  excluded = body.excludedClientModules;
7377
+ revision = body.revision;
7312
7378
  } else throw new HttpError(400, "bad_request");
7313
7379
  }
7314
- const view = await this.clientModulePreferenceOperation(kind, excluded, authorization.deviceId, operation.signal);
7380
+ const view = await this.clientModulePreferenceOperation(kind, excluded, authorization.deviceId, operation.signal, revision);
7315
7381
  operation.signal.throwIfAborted();
7316
7382
  sendJson(response, 200, view, this.tlsEnabled);
7317
7383
  } catch (error) {
@@ -7796,10 +7862,11 @@ var MobileAccessGateway = class {
7796
7862
  * @param excluded - Untrusted module IDs to validate against the current boot graph.
7797
7863
  * @param deviceId - Authenticated device identity; omitted for computer defaults.
7798
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.
7799
7866
  * @returns The catalog and saved selection without reloading clients.
7800
7867
  */
7801
- configureClientModules(excluded, deviceId, callerSignal) {
7802
- return this.clientModulePreferenceOperation("configure", excluded, deviceId, callerSignal);
7868
+ configureClientModules(excluded, deviceId, callerSignal, revision) {
7869
+ return this.clientModulePreferenceOperation("configure", excluded, deviceId, callerSignal, revision);
7803
7870
  }
7804
7871
  /** Reset one override without reloading connected clients.
7805
7872
  * @param deviceId - Authenticated device identity; omitted to reset computer defaults.
@@ -7985,7 +8052,7 @@ var MobileAccessGateway = class {
7985
8052
  await upstreamClosed;
7986
8053
  }
7987
8054
  }
7988
- async clientModulePreferenceOperation(kind, excluded, deviceId, callerSignal) {
8055
+ async clientModulePreferenceOperation(kind, excluded, deviceId, callerSignal, revision) {
7989
8056
  if (this.closing || !this.started) throw new HttpError(503, "unavailable");
7990
8057
  if (kind === "measure" && this.clientModuleMeasurementActive) throw new HttpError(429, "busy");
7991
8058
  if (kind === "measure") this.clientModuleMeasurementActive = true;
@@ -8004,8 +8071,10 @@ var MobileAccessGateway = class {
8004
8071
  const validate = async (selection) => {
8005
8072
  html = (await this.fetchAuthenticatedUpstreamIndex(controller.signal)).html;
8006
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");
8007
8075
  parseMobileBootPlan(html, selection);
8008
8076
  } catch (error) {
8077
+ if (error instanceof ClientModuleConflictError) throw error;
8009
8078
  if (error instanceof ExcludedClientModulesError) throw new ClientModuleConflictError(error.message);
8010
8079
  throw new HttpError(502, "upstream_unavailable");
8011
8080
  }
@@ -8033,8 +8102,10 @@ var MobileAccessGateway = class {
8033
8102
  } catch (error) {
8034
8103
  if (!measurementExpired || callerSignal?.aborted === true || this.closing) throw error;
8035
8104
  }
8105
+ const entries = this.describeMobileModulesWithSizes(html);
8036
8106
  return Object.freeze({
8037
- entries: this.describeMobileModulesWithSizes(html),
8107
+ entries,
8108
+ revision: clientModuleCatalogRevision(entries),
8038
8109
  ...selection,
8039
8110
  ...plan.batches === void 0 ? {
8040
8111
  excludedClientModules: Object.freeze([]),
@@ -9193,8 +9264,7 @@ var MobileAccessGateway = class {
9193
9264
  };
9194
9265
  }
9195
9266
  };
9196
- //#endregion
9197
- //#region src/storage.ts
9267
+ // src/storage.ts
9198
9268
  function assertInteger(value, name) {
9199
9269
  if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error(`device state ${name} must be a non-negative integer`);
9200
9270
  }
@@ -9321,8 +9391,7 @@ var MemoryDeviceStore = class {
9321
9391
  return structuredClone(this.snapshot);
9322
9392
  }
9323
9393
  };
9324
- //#endregion
9325
- //#region src/managed-files.ts
9394
+ // src/managed-files.ts
9326
9395
  /** Refuse link-shaped ancestors inside an owned state root before accessing a child. */
9327
9396
  async function assertManagedParents(root, target, prefix) {
9328
9397
  const parent = resolve(root);
@@ -9489,8 +9558,7 @@ async function replaceManagedDirectory(root, target, candidate, prefix, operatio
9489
9558
  }
9490
9559
  }
9491
9560
  }
9492
- //#endregion
9493
- //#region src/component-download.ts
9561
+ // src/component-download.ts
9494
9562
  /**
9495
9563
  * Hosts a pinned GitHub release download may redirect to.
9496
9564
  *
@@ -9601,8 +9669,7 @@ async function downloadPinnedArtifact(request) {
9601
9669
  }
9602
9670
  throw lastError;
9603
9671
  }
9604
- //#endregion
9605
- //#region src/frp-component.ts
9672
+ // src/frp-component.ts
9606
9673
  const FRP_VERSION$1 = "0.70.1";
9607
9674
  const MAX_ARCHIVE_ENTRIES$1 = 128;
9608
9675
  const MAX_ARCHIVE_LIST_BYTES$1 = 262144;
@@ -9886,8 +9953,7 @@ var FrpComponentManager = class {
9886
9953
  return task.then(() => this.status());
9887
9954
  }
9888
9955
  };
9889
- //#endregion
9890
- //#region src/windows-msi-extract.ts
9956
+ // src/windows-msi-extract.ts
9891
9957
  /** A failed read-only MSI extraction; causes deliberately exclude raw command arguments. */
9892
9958
  var WindowsMsiExtractionError = class extends Error {
9893
9959
  stage;
@@ -10371,8 +10437,7 @@ async function extractWindowsMsiBinary(msi, destination, executableName, expecte
10371
10437
  await copyFile(binary, join(destination, executableName));
10372
10438
  });
10373
10439
  }
10374
- //#endregion
10375
- //#region src/cpolar-component.ts
10440
+ // src/cpolar-component.ts
10376
10441
  const CPOLAR_VERSION = "3.3.18";
10377
10442
  /** Pinned official cpolar release metadata for supported desktop targets. */
10378
10443
  const CPOLAR_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
@@ -10740,8 +10805,7 @@ var CpolarComponentManager = class {
10740
10805
  return task.then(() => this.status());
10741
10806
  }
10742
10807
  };
10743
- //#endregion
10744
- //#region src/frp-template.ts
10808
+ // src/frp-template.ts
10745
10809
  /** Loopback-only HTTP vhost port used between Caddy and frps. */
10746
10810
  const FRP_VHOST_HTTP_PORT = 7080;
10747
10811
  /** Caddy snippet owned entirely by DSH Mobile; the main Caddyfile only imports it. */
@@ -10870,8 +10934,7 @@ function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin, opti
10870
10934
  if (publicIpv4Address(publicHost)) lines.push(manualIpCertificateGuide(publicHost), "");
10871
10935
  return lines.join("\n");
10872
10936
  }
10873
- //#endregion
10874
- //#region src/frp-config.ts
10937
+ // src/frp-config.ts
10875
10938
  const MAX_SETTINGS_BYTES$1 = 8192;
10876
10939
  /** Public entry port used by the self-signed TCP passthrough (never 443 by requirement). */
10877
10940
  const FRP_DEFAULT_PUBLIC_PORT = 33080;
@@ -11259,8 +11322,7 @@ var FrpConfigStore = class {
11259
11322
  return this.status();
11260
11323
  }
11261
11324
  };
11262
- //#endregion
11263
- //#region src/frp-attach.ts
11325
+ // src/frp-attach.ts
11264
11326
  /** Default proof path used to confirm the public entry answered from this computer. */
11265
11327
  const FRP_ATTACH_DISCOVERY_PATH = "/mobile-access/discovery";
11266
11328
  /** Declaration printed above the VPS half of an attach template. */
@@ -11457,8 +11519,7 @@ function createFrpAttachTemplateParts(settings, options = {}) {
11457
11519
  function createFrpAttachTemplate(settings, options = {}) {
11458
11520
  return createFrpAttachTemplateParts(settings, options).text;
11459
11521
  }
11460
- //#endregion
11461
- //#region src/frp-attach-plan.ts
11522
+ // src/frp-attach-plan.ts
11462
11523
  const DEFAULT_PLAN_CONFIG_FILE = "<DSH Mobile 私有目录>/remote/frp/config/frpc.toml";
11463
11524
  function planConfigFile(options) {
11464
11525
  const file = options.configFile;
@@ -11596,8 +11657,7 @@ function createFrpAttachPlan(settings, options = {}) {
11596
11657
  ])
11597
11658
  });
11598
11659
  }
11599
- //#endregion
11600
- //#region src/cert-renewal.ts
11660
+ // src/cert-renewal.ts
11601
11661
  /** Days left below which the entry certificate is reported as expiring. */
11602
11662
  const CERT_EXPIRING_DAYS = 2;
11603
11663
  const DAY_MS = 864e5;
@@ -11703,8 +11763,7 @@ async function probeOriginCertificate(host, port, timeoutMs = 5e3) {
11703
11763
  });
11704
11764
  });
11705
11765
  }
11706
- //#endregion
11707
- //#region src/managed-setup.ts
11766
+ // src/managed-setup.ts
11708
11767
  const VIRTUAL_INTERFACE_MARKERS = [
11709
11768
  "bridge",
11710
11769
  "docker",
@@ -11829,6 +11888,11 @@ async function saveManagedTrustedNetworks(setupFile, values) {
11829
11888
  await restrictPrivateFile(setupFile);
11830
11889
  return next;
11831
11890
  }
11891
+ /** Persist an existing managed setup without resetting its identity, paths or extra trusted networks. */
11892
+ async function saveManagedSetup(setupFile, setup) {
11893
+ const validated = parseManagedSetup(setup);
11894
+ await atomicWrite(setupFile, `${JSON.stringify(validated, null, 2)}\n`);
11895
+ }
11832
11896
  function privateIpv4(value) {
11833
11897
  const parts = value.split(".").map(Number);
11834
11898
  return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
@@ -12197,8 +12261,7 @@ async function materializeManagedSetup(setup, table) {
12197
12261
  }
12198
12262
  };
12199
12263
  }
12200
- //#endregion
12201
- //#region src/frp-ingress.ts
12264
+ // src/frp-ingress.ts
12202
12265
  /** Private ingress directory, always a sibling of the remote device file. */
12203
12266
  function frpIngressPaths(stateFile) {
12204
12267
  const directory = join(dirname(stateFile), "ingress");
@@ -12418,8 +12481,7 @@ async function purgeFrpIngressCertificates(stateFile, ownedRoot = dirname(stateF
12418
12481
  throw error;
12419
12482
  }
12420
12483
  }
12421
- //#endregion
12422
- //#region src/remote.ts
12484
+ // src/remote.ts
12423
12485
  const REMOTE_PROVIDERS = Object.freeze([
12424
12486
  "tailscale",
12425
12487
  "cpolar",
@@ -12655,8 +12717,7 @@ function configuredRemoteProvider(environment) {
12655
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");
12656
12718
  return value;
12657
12719
  }
12658
- //#endregion
12659
- //#region src/frp.ts
12720
+ // src/frp.ts
12660
12721
  const START_TIMEOUT_MS$2 = 45e3;
12661
12722
  const DISCOVERY_REQUEST_TIMEOUT_MS = 5e3;
12662
12723
  const DISCOVERY_RETRY_MS = 1e3;
@@ -13266,8 +13327,7 @@ var FrpController = class {
13266
13327
  ], "FRP resource cleanup failed");
13267
13328
  }
13268
13329
  };
13269
- //#endregion
13270
- //#region src/origin-proxy-config.ts
13330
+ // src/origin-proxy-config.ts
13271
13331
  const DEFAULT_ORIGIN_LISTEN_PORT = 3444;
13272
13332
  const MAX_SETTINGS_BYTES = 8192;
13273
13333
  /**
@@ -13437,8 +13497,7 @@ var OriginConfigStore = class {
13437
13497
  return this.status();
13438
13498
  }
13439
13499
  };
13440
- //#endregion
13441
- //#region src/origin-proxy.ts
13500
+ // src/origin-proxy.ts
13442
13501
  /** Owns an authenticated HTTP gateway, with no tunnel process or public network probes. */
13443
13502
  var OriginController = class {
13444
13503
  options;
@@ -13588,8 +13647,7 @@ var OriginController = class {
13588
13647
  });
13589
13648
  }
13590
13649
  };
13591
- //#endregion
13592
- //#region src/cloudflared-component.ts
13650
+ // src/cloudflared-component.ts
13593
13651
  /** Upper bound for `tar -t` output; a pinned release listing is a few lines. */
13594
13652
  const MAX_ARCHIVE_LIST_BYTES = 1048576;
13595
13653
  const MAX_ARCHIVE_ENTRIES = 256;
@@ -13914,8 +13972,7 @@ var CloudflaredComponentManager = class {
13914
13972
  return task.then(() => this.status());
13915
13973
  }
13916
13974
  };
13917
- //#endregion
13918
- //#region src/cloudflared.ts
13975
+ // src/cloudflared.ts
13919
13976
  const MAX_LOG_BUFFER_BYTES$1 = 65536;
13920
13977
  /**
13921
13978
  * A quick tunnel has to be allocated and registered with the Cloudflare edge
@@ -14433,8 +14490,7 @@ var CloudflaredController = class {
14433
14490
  ], "cloudflared resource cleanup failed");
14434
14491
  }
14435
14492
  };
14436
- //#endregion
14437
- //#region src/cloudflared-tunnel.ts
14493
+ // src/cloudflared-tunnel.ts
14438
14494
  const MAX_CONFIG_BYTES$1 = 8192;
14439
14495
  const QUICK_TUNNEL_SUFFIX = ".trycloudflare.com";
14440
14496
  /** Cloudflare's own tunnel-routing domain; it can never be a public hostname. */
@@ -14640,8 +14696,7 @@ var CloudflaredTunnelStore = class {
14640
14696
  return this.status();
14641
14697
  }
14642
14698
  };
14643
- //#endregion
14644
- //#region src/iroh-files.ts
14699
+ // src/iroh-files.ts
14645
14700
  async function renameIrohPath(root, source, destination) {
14646
14701
  await renameManagedPath(root, source, destination, "iroh");
14647
14702
  }
@@ -14651,8 +14706,7 @@ async function ensureIrohDirectory(root, directory) {
14651
14706
  async function removeIrohTree(root, target) {
14652
14707
  await removeManagedTree(root, target, "iroh");
14653
14708
  }
14654
- //#endregion
14655
- //#region src/iroh-manifest.ts
14709
+ // src/iroh-manifest.ts
14656
14710
  /** Checksum-gated resolution of the iroh transport host binary.
14657
14711
  *
14658
14712
  * The sidecar is built and published by the independent `dsh-mobile-iroh`
@@ -14756,8 +14810,7 @@ async function resolveIrohSidecarAt(companionRoot, platform, arch) {
14756
14810
  if (process.platform !== "win32" && platform !== "win32" && (before.mode & 73) === 0) throw new Error("iroh_native_not_executable");
14757
14811
  return path;
14758
14812
  }
14759
- //#endregion
14760
- //#region src/iroh-component.ts
14813
+ // src/iroh-component.ts
14761
14814
  /** Managed view of the iroh transport host sidecar.
14762
14815
  *
14763
14816
  * Explicit installation verifies and promotes a pinned sidecar release into
@@ -15071,8 +15124,7 @@ var IrohComponentManager = class {
15071
15124
  return this.cached;
15072
15125
  }
15073
15126
  };
15074
- //#endregion
15075
- //#region src/iroh-sidecar.ts
15127
+ // src/iroh-sidecar.ts
15076
15128
  /** Versioned parent-to-sidecar protocol for the iroh transport host.
15077
15129
  *
15078
15130
  * Ported from the independent `dsh-mobile-iroh` companion's sidecar
@@ -15202,8 +15254,7 @@ function decodeIrohEvent(line) {
15202
15254
  }
15203
15255
  return Object.freeze(event);
15204
15256
  }
15205
- //#endregion
15206
- //#region src/iroh-origin.ts
15257
+ // src/iroh-origin.ts
15207
15258
  /** Identity-derived browser origins for the raw TLS-over-iroh carrier.
15208
15259
  *
15209
15260
  * Ported from the independent `dsh-mobile-iroh` companion's origin contract.
@@ -15234,8 +15285,7 @@ function validateIrohOrigin(value, endpointId, localPort = DEFAULT_IROH_LOCAL_PO
15234
15285
  if (value !== expected) throw new Error("iroh origin does not match the selected endpoint and local port");
15235
15286
  return expected;
15236
15287
  }
15237
- //#endregion
15238
- //#region src/iroh-pairing.ts
15288
+ // src/iroh-pairing.ts
15239
15289
  /** Short-lived iroh pairing descriptors for QR transport.
15240
15290
  *
15241
15291
  * Byte-compatible with the independent `dsh-mobile-iroh` companion's
@@ -15343,8 +15393,7 @@ function decodePairingDescriptor(encoded, options = {}) {
15343
15393
  }
15344
15394
  /** Phone-side listener port advertised in QR codes minted by this computer. */
15345
15395
  const PAIRING_DESCRIPTOR_LOCAL_PORT = DEFAULT_IROH_LOCAL_PORT;
15346
- //#endregion
15347
- //#region src/iroh.ts
15396
+ // src/iroh.ts
15348
15397
  /** Experimental iroh remote transport as a first-class remote provider.
15349
15398
  *
15350
15399
  * The sidecar is distributed by this plugin itself: an explicit install
@@ -15895,8 +15944,7 @@ var IrohController = class {
15895
15944
  });
15896
15945
  }
15897
15946
  };
15898
- //#endregion
15899
- //#region src/iroh-tls.ts
15947
+ // src/iroh-tls.ts
15900
15948
  /** Self-signed TLS identity for the experimental iroh gateway.
15901
15949
  *
15902
15950
  * Reuses this repository's managed CA path (`managed-setup.ts`) instead of
@@ -16063,8 +16111,7 @@ function irohGatewayConfig(template, endpointId, stateFile, tls, listenPort = 0)
16063
16111
  pairingTtlMs: 3e5
16064
16112
  });
16065
16113
  }
16066
- //#endregion
16067
- //#region src/task-events.ts
16114
+ // src/task-events.ts
16068
16115
  const TASK_EVENT_DEBOUNCE_MS = 1e3;
16069
16116
  function turnNumber(value) {
16070
16117
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
@@ -16141,8 +16188,99 @@ var TaskEventHub = class {
16141
16188
  return this.sinks.size;
16142
16189
  }
16143
16190
  };
16144
- //#endregion
16145
- //#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
16146
16284
  /** Recognize the active remote-web-ui boot hook without evaluating or modifying it. */
16147
16285
  function hasCompetingRemoteChannelBoot(rows) {
16148
16286
  return rows.some((row) => row.kind === "script" && row.text?.includes("__DSH_REMOTE_CHANNEL_BOOT__") === true);
@@ -16201,6 +16339,7 @@ function check(id, status, reason, label, detail, action, facts) {
16201
16339
  id,
16202
16340
  status,
16203
16341
  reason,
16342
+ scope: id === "versions" ? "configuration" : id === "phone-network" ? "phone" : "computer",
16204
16343
  ...facts === void 0 ? {} : { facts: Object.freeze(facts) },
16205
16344
  label,
16206
16345
  detail,
@@ -16445,28 +16584,6 @@ async function relayProbeThroughProxy(target, proxy, budgetMs = remoteDiagnostic
16445
16584
  hopRequest.end();
16446
16585
  });
16447
16586
  }
16448
- /** Bounded supplementary DNS context for a failed direct attempt. */
16449
- async function unreachableWithDnsContext(hostname, budgetMs) {
16450
- let fakeIp = false;
16451
- let timer;
16452
- try {
16453
- fakeIp = (await Promise.race([lookup(hostname, { all: true }), new Promise((_resolve, reject) => {
16454
- timer = setTimeout(() => {
16455
- reject(/* @__PURE__ */ new Error("dns_context_timeout"));
16456
- }, Math.max(1, budgetMs));
16457
- timer.unref();
16458
- })])).some(({ address }) => {
16459
- const [first, second] = address.split(".").map(Number);
16460
- return first === 198 && (second === 18 || second === 19);
16461
- });
16462
- } catch {} finally {
16463
- clearTimeout(timer);
16464
- }
16465
- return {
16466
- state: "unreachable",
16467
- ...fakeIp ? { fakeIp: true } : {}
16468
- };
16469
- }
16470
16587
  async function fetchRemoteHealth(target, budgetMs) {
16471
16588
  const started = performance.now();
16472
16589
  try {
@@ -16476,19 +16593,28 @@ async function fetchRemoteHealth(target, budgetMs) {
16476
16593
  signal: AbortSignal.timeout(Math.max(1, budgetMs - 500))
16477
16594
  });
16478
16595
  const latencyMs = Math.max(0, Math.round(performance.now() - started));
16479
- if (response.status === 429) return {
16480
- state: "rate-limited",
16481
- latencyMs
16482
- };
16483
- 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 ? {
16484
16604
  state: "ready",
16485
16605
  latencyMs
16486
16606
  } : {
16487
16607
  state: "unreachable",
16488
- 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)
16489
16617
  };
16490
- } catch {
16491
- return unreachableWithDnsContext(target.hostname, Math.max(1, budgetMs - (performance.now() - started)));
16492
16618
  }
16493
16619
  }
16494
16620
  /**
@@ -16511,7 +16637,14 @@ async function probeRemoteHealth(origin, options = {}) {
16511
16637
  return { state: "not-applicable" };
16512
16638
  }
16513
16639
  const budgetMs = remoteDiagnosticTimeoutMs(origin);
16514
- 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
+ };
16515
16648
  if (observation.state !== "unreachable") return observation;
16516
16649
  const relay = configuredProxyFor(origin, options.env ?? process.env);
16517
16650
  if (relay === void 0) return observation;
@@ -16521,12 +16654,14 @@ async function probeRemoteHealth(origin, options = {}) {
16521
16654
  if (status === 429) return {
16522
16655
  state: "rate-limited",
16523
16656
  latencyMs,
16524
- viaProxy: true
16657
+ viaProxy: true,
16658
+ ...context
16525
16659
  };
16526
16660
  return status >= 200 && status < 300 ? {
16527
16661
  state: "ready",
16528
16662
  latencyMs,
16529
- viaProxy: true
16663
+ viaProxy: true,
16664
+ ...context
16530
16665
  } : observation;
16531
16666
  } catch {
16532
16667
  return observation;
@@ -16536,28 +16671,38 @@ async function defaultRemoteProbe(origin) {
16536
16671
  return probeRemoteHealth(origin);
16537
16672
  }
16538
16673
  function reportLine(entry) {
16539
- 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}`}`;
16540
16675
  }
16541
16676
  /** Run bounded read-only checks and return a report safe to paste into an issue. */
16542
16677
  async function collectConnectionDiagnostics(snapshot, probes = {}) {
16543
16678
  const checks = [];
16544
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" });
16545
- const [firewall, remoteObservation] = await Promise.all([(probes.firewall ?? defaultFirewallProbe())(snapshot.lan.port), remoteProbe]);
16546
- 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}。`));
16547
16682
  if (snapshot.competingRemoteChannelBoot === true) checks.push(check("remote-plugin-conflict", "warning", "competing-remote-channel", "第三方远程插件", "检测到另一套远程请求通道脚本;手机页面可能进入该插件自己的配对流程。", "在电脑端关闭 dsh-remote-web-ui 的远程访问后刷新,再使用 DSH Mobile 当前二维码配对;不要混用两种配对码。"));
16548
- if (snapshot.lan.configured === false) checks.push(check("network", "error", "lan-setup-required", "局域网配置", "尚未完成局域网初始化。", "返回局域网页选择网卡并完成配置。"));
16549
- 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 ? "在局域网页重新确认网卡配置。" : "局域网未启用;此配置不用于判断远程入口。"));
16550
16686
  else if (snapshot.lan.configuredInterface !== void 0) {
16551
16687
  const interfaceName = snapshot.lan.interfaceName ?? snapshot.lan.configuredInterface;
16552
16688
  checks.push(check("network", "ok", "network-interface", "局域网网卡", `正在跟随 ${interfaceName}。`, void 0, { interfaceName }));
16553
16689
  } else checks.push(check("network", "info", "network-fixed", "局域网网卡", "当前使用固定网络配置。"));
16554
16690
  if (snapshot.lan.running && snapshot.lan.origin !== void 0) {
16555
16691
  const endpointSuffix = maskLanOrigin(snapshot.lan.origin);
16556
- checks.push(check("lan", "ok", "lan-ready", "局域网网关", `已监听 ${endpointSuffix},配对入口可用。`, void 0, { endpointSuffix }));
16692
+ checks.push(check("lan", "ok", "lan-ready", "局域网网关", `电脑网关已监听 ${endpointSuffix};尚未验证手机能否到达。`, void 0, { endpointSuffix }));
16557
16693
  } else checks.push(check("lan", "info", "lan-off", "局域网网关", "当前未开启。", "需要手机直连时开启局域网访问。"));
16558
- if (firewall.state === "ready") checks.push(check("firewall", "ok", "firewall-ready", "Windows 防火墙", "局域网 TCP 与发现规则已启用。"));
16559
- 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 ? "确认需要局域网连接,再检查相应规则;不要关闭整个防火墙。" : "局域网未启用,不据此判断远程入口失败。"));
16560
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
+ }
16561
16706
  if (!snapshot.remote.running || snapshot.remote.state === "off") checks.push(check("remote", "info", "remote-off", "远程通道", "当前未启用。", void 0, { provider: snapshot.remote.provider }));
16562
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" }));
16563
16708
  else if (snapshot.remote.provider === "iroh" && snapshot.remote.state === "ready") checks.push(check("remote", "info", "remote-iroh-ready", "远程通道", "iroh 通道已就绪;端点身份为回环 TLS,不从电脑端探测手机可达性。", "在手机上用配对二维码验证连接;二维码过期或通道报错时重新生成并重连。", { provider: "iroh" }));
@@ -16569,10 +16714,14 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
16569
16714
  ...remoteObservation.latencyMs === void 0 ? {} : { latencyMs: remoteObservation.latencyMs },
16570
16715
  ...remoteObservation.viaProxy === true ? { viaProxy: true } : {}
16571
16716
  };
16572
- 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));
16573
16718
  else if (remoteObservation.state === "rate-limited") checks.push(check("remote", "warning", "remote-rate-limited", "远程通道", remoteObservation.viaProxy === true ? "电脑经代理访问时观察到限流,尚未验证手机能否连接。" : "公共地址可达,但本次检查观察到服务限流。", "稍后重试;旧会话会按需加载以减少流量。", facts));
16574
- else if (snapshot.remote.provider === "tailscale" && remoteObservation.fakeIp === true) checks.push(check("remote", "error", "remote-fake-ip", "远程通道", "Tailscale 地址被当前 VPN 或 DNS 代理接管,但 TLS 链路未建立。", "切换 VPN 节点或代理模式;仍失败时改用 cpolar。", facts));
16575
- 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
+ }
16576
16725
  } else if (snapshot.remote.state === "starting" || snapshot.remote.state === "connecting" || snapshot.remote.state === "needs-login") {
16577
16726
  const needsLogin = snapshot.remote.state === "needs-login";
16578
16727
  checks.push(check("remote", "warning", needsLogin ? "remote-needs-login" : "remote-connecting", "远程通道", needsLogin ? "等待完成 Tailscale 登录。" : "仍在建立连接。", needsLogin ? "返回远程页继续登录。" : "等待片刻后重新检查。", { provider: snapshot.remote.provider }));
@@ -16583,15 +16732,16 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
16583
16732
  controllerCode
16584
16733
  }));
16585
16734
  }
16586
- checks.push(check("phone-network", "info", "phone-network-unknown", "手机网络", "电脑无法判断路由器是否隔离了手机。", "局域网仍失败时,确认手机与电脑在同一网络,并关闭访客网络或 AP 隔离。"));
16735
+ checks.push(check("phone-network", "info", "phone-network-unknown", "手机网络", "电脑没有验证手机网络、认证页面、资源加载或 WebSocket。", "请用手机当前网络实际连接,记录失败阶段;不要提供配对凭据或直接关闭安全隔离措施。"));
16587
16736
  const overall = checks.some((entry) => entry.status === "error") ? "error" : checks.some((entry) => entry.status === "warning") ? "attention" : "ok";
16588
- const summary = overall === "ok" ? "连接基础检查正常。" : overall === "attention" ? "发现需要留意的项目。" : "发现会影响连接的问题。";
16737
+ const summary = overall === "ok" ? "电脑端基础检查完成;手机、认证页面、资源与 WebSocket 尚未验证。" : overall === "attention" ? "电脑端检查有待确认项目;请区分测量路径与实际手机连接。" : "电脑端检查有失败项目;不据此推断所有网络均不可达。";
16589
16738
  const report = [
16590
16739
  "DSH Mobile 诊断报告",
16591
16740
  `生成时间: ${(/* @__PURE__ */ new Date()).toISOString()}`,
16592
16741
  `版本: plugin=${DSH_MOBILE_VERSION}; dsh=${snapshot.dshVersion}; min-app=${MINIMUM_ANDROID_APP_VERSION}`,
16593
16742
  `LAN: ${snapshot.lan.running ? "on" : "off"}; endpoint=${maskLanOrigin(snapshot.lan.origin)}`,
16594
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.",
16595
16745
  ...checks.map(reportLine)
16596
16746
  ].join("\n");
16597
16747
  return Object.freeze({
@@ -16605,11 +16755,19 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
16605
16755
  }),
16606
16756
  summary,
16607
16757
  checks: Object.freeze(checks),
16608
- 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
+ })
16609
16768
  });
16610
16769
  }
16611
- //#endregion
16612
- //#region src/mobile-guide.ts
16770
+ // src/mobile-guide.ts
16613
16771
  /** Compose the guide with the current customization state injected. */
16614
16772
  function buildMobileGuide(state) {
16615
16773
  const styleLine = state.hasCustomCss ? "存在(已检测到文件,需在已配对页面验证加载)" : "不存在(使用内置默认样式)";
@@ -16674,8 +16832,7 @@ const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手
16674
16832
  - 创建或修改扩展后,确认 extension.json 的 schemaVersion 为 1、id 与目录名一致、且 id 只含小写字母数字和连字符
16675
16833
  - 扩展的 host.mjs 若在完成前无法激活,先修正而不是留下损坏的扩展
16676
16834
  - 完成后检查自己实际写入了哪些文件,向用户简要说明改了什么、手机端会有什么变化`;
16677
- //#endregion
16678
- //#region src/vps-deploy.ts
16835
+ // src/vps-deploy.ts
16679
16836
  const FRP_VERSION = "0.70.1";
16680
16837
  const SSH_TIMEOUT_MS = 3e5;
16681
16838
  const LINUX_ARTIFACTS = Object.freeze({
@@ -17646,8 +17803,44 @@ async function uninstallVps(serverAddress, uninstall, input, options = {}) {
17646
17803
  checks
17647
17804
  });
17648
17805
  }
17649
- //#endregion
17650
- //#region src/funnel.ts
17806
+ // src/lan-rebind.ts
17807
+ /** An unreadable setup is rejected before stopping the listener or replacing any file. */
17808
+ var ManagedLanConfigurationError = class extends Error {
17809
+ constructor(cause) {
17810
+ super("managed LAN configuration is unreadable", { cause });
17811
+ this.name = "ManagedLanConfigurationError";
17812
+ }
17813
+ };
17814
+ /** Rebind an existing listener; callers serialize this call with all other setup-file writes. */
17815
+ async function rebindManagedLan(options) {
17816
+ let previous;
17817
+ try {
17818
+ previous = parseManagedSetup(JSON.parse(await readFile(options.setupFile, "utf8")));
17819
+ await readManagedCa(previous.tls);
17820
+ } catch (error) {
17821
+ throw new ManagedLanConfigurationError(error);
17822
+ }
17823
+ const result = await options.controller.reconfigure(async () => {
17824
+ const next = Object.freeze({
17825
+ ...previous,
17826
+ networkInterface: options.network.name
17827
+ });
17828
+ await refreshManagedServerCertificate(next, options.network.address);
17829
+ await saveManagedSetup(options.setupFile, next);
17830
+ options.publish(next);
17831
+ return next;
17832
+ }, async () => {
17833
+ await saveManagedSetup(options.setupFile, previous);
17834
+ options.publish(previous);
17835
+ const network = options.selectPrevious?.(previous) ?? selectLanNetwork(void 0, previous.networkInterface);
17836
+ await refreshManagedServerCertificate(previous, network.address);
17837
+ });
17838
+ return {
17839
+ setup: result.value,
17840
+ running: result.running
17841
+ };
17842
+ }
17843
+ // src/funnel.ts
17651
17844
  const MAX_PROTOCOL_LINE_BYTES = 16384;
17652
17845
  const FUNNEL_START_TIMEOUT_MS = 45e3;
17653
17846
  function publicStatus$1(status) {
@@ -18161,8 +18354,7 @@ function funnelExecutable(importMetaUrl, environment = process.env) {
18161
18354
  const file = `dsh-mobile-funnel-${process.platform}-${process.arch}${suffix}`;
18162
18355
  return resolve(fileURLToPath(new URL(`../bin/${file}`, importMetaUrl)));
18163
18356
  }
18164
- //#endregion
18165
- //#region src/cpolar.ts
18357
+ // src/cpolar.ts
18166
18358
  const MAX_LOG_BUFFER_BYTES = 65536;
18167
18359
  const START_TIMEOUT_MS = 45e3;
18168
18360
  const CPOLAR_HOST_SUFFIXES = Object.freeze([
@@ -18578,8 +18770,7 @@ var CpolarController = class {
18578
18770
  ], "cpolar resource cleanup failed");
18579
18771
  }
18580
18772
  };
18581
- //#endregion
18582
- //#region src/caddy-files.ts
18773
+ // src/caddy-files.ts
18583
18774
  /** Refuse link-shaped ancestors within Caddy's owned state root. */
18584
18775
  async function assertCaddyParents(root, target) {
18585
18776
  await assertManagedParents(root, target, "caddy");
@@ -18600,8 +18791,7 @@ async function writeCaddyPrivateFile(root, file, body) {
18600
18791
  async function removeCaddyTree(root, target) {
18601
18792
  await removeManagedTree(root, target, "caddy");
18602
18793
  }
18603
- //#endregion
18604
- //#region src/caddy-component.ts
18794
+ // src/caddy-component.ts
18605
18795
  const CADDY_VERSION = "2.11.7";
18606
18796
  const CADDY_DNS_PLUGIN_VERSION = "v0.4.3";
18607
18797
  const CADDY_DOWNLOAD_PAGE = "https://github.com/saya-ch/dsh-mobile/releases/tag/caddy-component-2.11.7-tencentcloud-0.4.3-r1";
@@ -19120,8 +19310,7 @@ var CaddyConfigStore = class {
19120
19310
  return task;
19121
19311
  }
19122
19312
  };
19123
- //#endregion
19124
- //#region src/caddy.ts
19313
+ // src/caddy.ts
19125
19314
  /** Private directories override Caddy's platform defaults; ambient API keys and admin/proxy overrides are omitted. */
19126
19315
  function caddyProcessEnvironment(config, settings, environment = process.env) {
19127
19316
  const credentials = config.credentials();
@@ -19451,8 +19640,7 @@ var CaddyController = class {
19451
19640
  }
19452
19641
  }
19453
19642
  };
19454
- //#endregion
19455
- //#region src/managed-origin.ts
19643
+ // src/managed-origin.ts
19456
19644
  /** Children never independently restore a durable enabled switch. */
19457
19645
  function createVolatileRemoteControlStore() {
19458
19646
  let value = {
@@ -19633,8 +19821,7 @@ var ManagedOriginController = class {
19633
19821
  return task;
19634
19822
  }
19635
19823
  };
19636
- //#endregion
19637
- //#region src/release-update.ts
19824
+ // src/release-update.ts
19638
19825
  const PACKAGE_NAME = "dsh-mobile";
19639
19826
  const NPM_LATEST_URL = "https://registry.npmjs.org/dsh-mobile/latest";
19640
19827
  const ANDROID_RELEASE_DESCRIPTOR_URL = "https://raw.githubusercontent.com/saya-ch/dsh-mobile/main/apps/mobile/release.json";
@@ -20097,8 +20284,7 @@ var PluginReleaseManager = class {
20097
20284
  });
20098
20285
  }
20099
20286
  };
20100
- //#endregion
20101
- //#region src/file-logger.ts
20287
+ // src/file-logger.ts
20102
20288
  const MAX_LOG_BYTES = 5242880;
20103
20289
  const MAX_QUEUED_BYTES = 262144;
20104
20290
  const PATH_PREFIX = "mobile_log";
@@ -20208,14 +20394,12 @@ async function installMobileFileLogger(ctx, stateDirectory, options = {}) {
20208
20394
  }
20209
20395
  return file;
20210
20396
  }
20211
- //#endregion
20212
- //#region src/mobile-push/contracts.ts
20397
+ // src/mobile-push/contracts.ts
20213
20398
  const OUTCOME_COALESCE_MS = 1e3;
20214
20399
  const ATTENTION_TTL_MS = 3e5;
20215
20400
  const OUTCOME_TTL_MS = 18e5;
20216
20401
  const ATTEMPT_TIMEOUT_MS = 1e4;
20217
- //#endregion
20218
- //#region src/mobile-push/requests.ts
20402
+ // src/mobile-push/requests.ts
20219
20403
  var RequestLifetimes = class {
20220
20404
  #records = /* @__PURE__ */ new Map();
20221
20405
  #byCall = /* @__PURE__ */ new Map();
@@ -20395,8 +20579,7 @@ var RequestLifetimes = class {
20395
20579
  }, 250);
20396
20580
  }
20397
20581
  };
20398
- //#endregion
20399
- //#region src/mobile-push/outcomes.ts
20582
+ // src/mobile-push/outcomes.ts
20400
20583
  const isCompletedGoalPhase = (phase) => phase === "complete";
20401
20584
  var OutcomeLifetimes = class {
20402
20585
  #ctx;
@@ -20576,8 +20759,7 @@ var OutcomeLifetimes = class {
20576
20759
  return current;
20577
20760
  }
20578
20761
  };
20579
- //#endregion
20580
- //#region src/mobile-push/reconcile.ts
20762
+ // src/mobile-push/reconcile.ts
20581
20763
  const TURN_REASONS = [
20582
20764
  "completed",
20583
20765
  "error",
@@ -20849,8 +21031,7 @@ var PushReconciler = class {
20849
21031
  return verifyOutcome(source, values, agent);
20850
21032
  }
20851
21033
  };
20852
- //#endregion
20853
- //#region src/mobile-push/host.ts
21034
+ // src/mobile-push/host.ts
20854
21035
  function hasContract(ctx) {
20855
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;
20856
21037
  }
@@ -21061,8 +21242,7 @@ function createPushHost(ctx) {
21061
21242
  }
21062
21243
  };
21063
21244
  }
21064
- //#endregion
21065
- //#region src/mobile-push/endpoint.ts
21245
+ // src/mobile-push/endpoint.ts
21066
21246
  const MAX_ENDPOINT_BYTES = 2048;
21067
21247
  const APPLE_SUFFIX = ".push.apple.com";
21068
21248
  const BASE64URL = /^[A-Za-z0-9_-]+$/u;
@@ -21179,8 +21359,7 @@ async function resolveAppleEndpoint(endpoint, resolver = systemResolver) {
21179
21359
  addresses: Object.freeze([...addresses])
21180
21360
  });
21181
21361
  }
21182
- //#endregion
21183
- //#region src/mobile-push/store.ts
21362
+ // src/mobile-push/store.ts
21184
21363
  const MAX_STATE_BYTES = 4194304;
21185
21364
  const emptySnapshot = () => ({
21186
21365
  version: 1,
@@ -21443,8 +21622,7 @@ var JsonPushStore = class {
21443
21622
  }
21444
21623
  }
21445
21624
  };
21446
- //#endregion
21447
- //#region src/mobile-push/registry.ts
21625
+ // src/mobile-push/registry.ts
21448
21626
  const empty = () => ({
21449
21627
  version: 1,
21450
21628
  ownerGenerations: [],
@@ -21832,8 +22010,7 @@ var PushRegistry = class {
21832
22010
  };
21833
22011
  }
21834
22012
  };
21835
- //#endregion
21836
- //#region src/mobile-push/payload.ts
22013
+ // src/mobile-push/payload.ts
21837
22014
  const messages = {
21838
22015
  en: {
21839
22016
  question: ["Input requested", "A response needs your attention."],
@@ -21885,8 +22062,7 @@ function buildDeclarativePayload(category, locale, navigate, tag) {
21885
22062
  if (Buffer.byteLength(payload, "utf8") > 3072) throw new TypeError("push_payload_too_large");
21886
22063
  return payload;
21887
22064
  }
21888
- //#endregion
21889
- //#region src/mobile-push/retention.ts
22065
+ // src/mobile-push/retention.ts
21890
22066
  /** Pure defer-membership retention helpers shared by delivery maintenance. */
21891
22067
  /** Removes deferred-recheck entries whose owning pending intent no longer exists; returns how many were removed. */
21892
22068
  function pruneOrphanedDeferrals(deferred, pending) {
@@ -21899,8 +22075,7 @@ function pruneOrphanedDeferrals(deferred, pending) {
21899
22075
  }
21900
22076
  return removed;
21901
22077
  }
21902
- //#endregion
21903
- //#region src/mobile-push/sender.ts
22078
+ // src/mobile-push/sender.ts
21904
22079
  const MAX_RESPONSE_BYTES = 8192;
21905
22080
  const MAX_TTL_SECONDS = 1800;
21906
22081
  const CONTENT_ENCODING = "aes128gcm";
@@ -22186,8 +22361,7 @@ var PushSender = class {
22186
22361
  }
22187
22362
  }
22188
22363
  };
22189
- //#endregion
22190
- //#region src/mobile-push/delivery.ts
22364
+ // src/mobile-push/delivery.ts
22191
22365
  const RECONCILE_INTERVAL_MS = 3e4;
22192
22366
  const RETRY_BACKOFF_MS = [1e3, 5e3];
22193
22367
  const MAX_TEST_QUEUE = 64;
@@ -22973,8 +23147,7 @@ var PushDelivery = class {
22973
23147
  }, delay);
22974
23148
  }
22975
23149
  };
22976
- //#endregion
22977
- //#region src/mobile-push/gateway.ts
23150
+ // src/mobile-push/gateway.ts
22978
23151
  /** One service shared by every Gateway while each Gateway owns a separate auth/origin attachment. */
22979
23152
  var PushGatewayCoordinator = class {
22980
23153
  host;
@@ -23097,8 +23270,7 @@ var PushGatewayCoordinator = class {
23097
23270
  return true;
23098
23271
  }
23099
23272
  };
23100
- //#endregion
23101
- //#region src/installation-id.ts
23273
+ // src/installation-id.ts
23102
23274
  const INSTALLATION_ID_PATTERN = /^[a-f0-9]{64}\n$/u;
23103
23275
  const INSTALLATION_ID_BYTES = 65;
23104
23276
  async function readInstallationId(file) {
@@ -23162,8 +23334,7 @@ async function loadOrCreateInstallationId(file) {
23162
23334
  }
23163
23335
  }
23164
23336
  }
23165
- //#endregion
23166
- //#region src/gateway-identity.ts
23337
+ // src/gateway-identity.ts
23167
23338
  /**
23168
23339
  * Attach the authenticated installation identity while retaining existing entry identity rules.
23169
23340
  * @param config - Validated gateway configuration containing the legacy identity.
@@ -23183,8 +23354,7 @@ function withInstallationIdentity(config, installationId, explicitIdentity) {
23183
23354
  legacyInstanceId: config.instanceId
23184
23355
  });
23185
23356
  }
23186
- //#endregion
23187
- //#region src/lan-setup.ts
23357
+ // src/lan-setup.ts
23188
23358
  const FIREWALL_TCP_RULE = "DSH Mobile HTTPS";
23189
23359
  const FIREWALL_UDP_RULE = "DSH Mobile Discovery";
23190
23360
  async function runElevatedPowerShell(script) {
@@ -23256,8 +23426,7 @@ async function prepareManagedLanSetup(options) {
23256
23426
  androidCertificate
23257
23427
  });
23258
23428
  }
23259
- //#endregion
23260
- //#region src/plugin.ts
23429
+ // src/plugin.ts
23261
23430
  /** Stable Cordis plugin name. */
23262
23431
  const name = "dsh-mobile";
23263
23432
  /** The stock WebServer serves the control card; Connection authenticates the loopback DSH origin. */
@@ -23614,6 +23783,9 @@ function locateIrohCompanionRoots(importMetaUrl, environment = process.env) {
23614
23783
  async function apply(ctx, config) {
23615
23784
  const dshVersion = installedDshVersion();
23616
23785
  const loaded = await loadSetup(config);
23786
+ /** Rebind overrides the managed setup at runtime; the file on disk is the source of truth on restart. */
23787
+ let managedSetupOverride;
23788
+ let lanRebindRecoveryRequired = false;
23617
23789
  const mobileAccess = createMobileAccessService(ctx);
23618
23790
  const baseTemplate = loopbackTemplate(loaded, ctx.webServer.port);
23619
23791
  const upstreamLoginUrl = upstreamAuthenticatedUrl(ctx, baseTemplate.upstreamOrigin);
@@ -23910,8 +24082,17 @@ async function apply(ctx, config) {
23910
24082
  let lanGateway;
23911
24083
  let preparedLanSetup;
23912
24084
  let trustedNetworksRestartRequired = false;
23913
- let trustedNetworksWrite = Promise.resolve();
24085
+ let lanConfigurationWork = Promise.resolve();
23914
24086
  let adminClosing = false;
24087
+ const mutateLan = (operation) => {
24088
+ if (adminClosing) return Promise.reject(new HttpError(503, "unavailable"));
24089
+ const run = lanConfigurationWork.then(() => {
24090
+ if (adminClosing) throw new HttpError(503, "unavailable");
24091
+ return operation();
24092
+ });
24093
+ lanConfigurationWork = run.then(() => {}, () => {});
24094
+ return run;
24095
+ };
23915
24096
  const startGateway = async (candidateConfig) => {
23916
24097
  const resolved = withInstallationIdentity(parseGatewayConfig({
23917
24098
  ...candidateConfig,
@@ -23941,12 +24122,13 @@ async function apply(ctx, config) {
23941
24122
  if (loaded.kind === "unconfigured") throw new Error(preparedLanSetup === void 0 ? "lan_setup_required" : "lan_setup_restart_required");
23942
24123
  if (loaded.kind === "fixed") return startGateway(loaded.config);
23943
24124
  const following = new FollowingMobileAccessRuntime(async () => {
23944
- const network = selectLanNetwork(void 0, loaded.setup.networkInterface);
24125
+ const setup = managedSetupOverride ?? loaded.setup;
24126
+ const network = selectLanNetwork(void 0, setup.networkInterface);
23945
24127
  return {
23946
24128
  key: `${network.name}\0${network.address}\0${network.cidr}`,
23947
24129
  start: async () => startGateway({
23948
24130
  ...loaded.config,
23949
- ...await materializeManagedSetup(loaded.setup)
24131
+ ...await materializeManagedSetup(setup)
23950
24132
  })
23951
24133
  };
23952
24134
  }, (error) => {
@@ -24194,6 +24376,8 @@ async function apply(ctx, config) {
24194
24376
  restartRequired: preparedLanSetup !== void 0
24195
24377
  } : {},
24196
24378
  running: lanController.isRunning(),
24379
+ rebindSupported: loaded.kind === "managed",
24380
+ ...lanRebindRecoveryRequired ? { recoveryRequired: true } : {},
24197
24381
  origin: lanGateway?.address().origin,
24198
24382
  ...preparedLanSetup === void 0 ? {} : { pendingOrigin: preparedLanSetup.origin },
24199
24383
  ...lanGateway === void 0 ? {} : { extensions: lanGateway.extensionStatus() }
@@ -24227,7 +24411,18 @@ async function apply(ctx, config) {
24227
24411
  windowsFirewall: process.platform === "win32"
24228
24412
  };
24229
24413
  };
24230
- 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;
24231
24426
  let interfaceName;
24232
24427
  let networkError;
24233
24428
  if (loaded.kind === "managed") try {
@@ -24257,7 +24452,8 @@ async function apply(ctx, config) {
24257
24452
  } : void 0;
24258
24453
  return collectConnectionDiagnostics({
24259
24454
  dshVersion,
24260
- competingRemoteChannelBoot: hasCompetingRemoteChannelBoot(ctx.webServer.collectIndexInjections?.() ?? []),
24455
+ competingRemoteChannelBoot: hasCompetingRemoteChannelBoot(indexInjections),
24456
+ indexInspectionUnavailable,
24261
24457
  lan: {
24262
24458
  configured: loaded.kind !== "unconfigured" || preparedLanSetup !== void 0,
24263
24459
  running: lanController.isRunning(),
@@ -24328,12 +24524,15 @@ async function apply(ctx, config) {
24328
24524
  }
24329
24525
  if (request.method !== "POST") throw new HttpError(405, "method_not_allowed");
24330
24526
  const body = await readJsonObject(request, template.maxBodyBytes);
24331
- 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");
24332
- 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);
24333
24532
  } catch (error) {
24334
24533
  if (!(error instanceof ClientModuleConflictError)) throw error;
24335
24534
  sendJson(response, 409, {
24336
- error: "excluded_client_modules_invalid",
24535
+ error: error.code,
24337
24536
  detail: error.detail
24338
24537
  }, false);
24339
24538
  } finally {
@@ -24356,18 +24555,15 @@ async function apply(ctx, config) {
24356
24555
  } catch {
24357
24556
  throw new HttpError(400, "lan_trusted_networks_invalid");
24358
24557
  }
24359
- const write = trustedNetworksWrite.then(async () => {
24360
- if (adminClosing) throw new HttpError(503, "unavailable");
24558
+ await mutateLan(async () => {
24361
24559
  await saveManagedTrustedNetworks(loaded.setupFile, values);
24362
24560
  trustedNetworksRestartRequired = true;
24363
24561
  });
24364
- trustedNetworksWrite = write.catch(() => void 0);
24365
- await write;
24366
24562
  sendJson(response, 200, await trustedNetworksPayload(), false);
24367
24563
  return;
24368
24564
  }
24369
24565
  if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/diagnostics`) {
24370
- sendJson(response, 200, await diagnosticsPayload(), false);
24566
+ sendJson(response, 200, await diagnosticsPayload(request), false);
24371
24567
  return;
24372
24568
  }
24373
24569
  if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/release`) {
@@ -24391,10 +24587,13 @@ async function apply(ctx, config) {
24391
24587
  return;
24392
24588
  }
24393
24589
  if (request.method === "POST" && lanControl) {
24394
- const body = await readJsonObject(request, 4096);
24395
- if (typeof body.running !== "boolean") throw new HttpError(400, "bad_request");
24396
- if (body.running && loaded.kind === "unconfigured") throw new HttpError(409, preparedLanSetup === void 0 ? "lan_setup_required" : "lan_setup_restart_required");
24397
- await lanController.setRunning(body.running);
24590
+ const running = (await readJsonObject(request, 4096)).running;
24591
+ if (typeof running !== "boolean") throw new HttpError(400, "bad_request");
24592
+ if (running && loaded.kind === "unconfigured") throw new HttpError(409, preparedLanSetup === void 0 ? "lan_setup_required" : "lan_setup_restart_required");
24593
+ await mutateLan(async () => {
24594
+ if (running && lanRebindRecoveryRequired) throw new HttpError(409, "lan_rebind_recovery_required");
24595
+ await lanController.setRunning(running);
24596
+ });
24398
24597
  sendJson(response, 200, lanPayload(), false);
24399
24598
  return;
24400
24599
  }
@@ -24424,6 +24623,74 @@ async function apply(ctx, config) {
24424
24623
  sendJson(response, 200, await lanSetupPayload(), false);
24425
24624
  return;
24426
24625
  }
24626
+ if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/lan/networks`) {
24627
+ await lanConfigurationWork;
24628
+ const networks = availableLanNetworks();
24629
+ const preferred = await preferredLanInterfaceNames();
24630
+ let recommendedAddress;
24631
+ try {
24632
+ recommendedAddress = selectLanNetwork(void 0, void 0, void 0, preferred).address;
24633
+ } catch {}
24634
+ const currentInterface = loaded.kind === "managed" ? (managedSetupOverride ?? loaded.setup).networkInterface : void 0;
24635
+ sendJson(response, 200, {
24636
+ supported: loaded.kind === "managed",
24637
+ currentInterface,
24638
+ listenPort: loaded.kind === "managed" ? loaded.setup.listenPort : 3443,
24639
+ platform: process.platform,
24640
+ networks: networks.map((network) => ({
24641
+ name: network.name,
24642
+ address: network.address,
24643
+ cidr: network.cidr,
24644
+ current: network.name === currentInterface,
24645
+ recommended: network.address === recommendedAddress
24646
+ }))
24647
+ }, false);
24648
+ return;
24649
+ }
24650
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/lan/rebind`) {
24651
+ if (loaded.kind !== "managed") throw new HttpError(409, "lan_rebind_unsupported");
24652
+ const body = await readJsonObject(request, 4096);
24653
+ const networkInterface = typeof body.networkInterface === "string" ? body.networkInterface : "";
24654
+ if (networkInterface === "" || Object.keys(body).some((key) => key !== "networkInterface")) throw new HttpError(400, "bad_request");
24655
+ sendJson(response, 200, await mutateLan(async () => {
24656
+ let network;
24657
+ try {
24658
+ network = selectLanNetwork(void 0, networkInterface);
24659
+ } catch {
24660
+ throw new HttpError(409, "lan_rebind_network_unavailable");
24661
+ }
24662
+ const current = managedSetupOverride ?? loaded.setup;
24663
+ if (!lanRebindRecoveryRequired && current.networkInterface === networkInterface) throw new HttpError(409, "lan_rebind_unchanged");
24664
+ const previousOrigin = lanGateway?.address().origin;
24665
+ try {
24666
+ const result = await rebindManagedLan({
24667
+ setupFile: loaded.setupFile,
24668
+ controller: lanController,
24669
+ network,
24670
+ publish: (setup) => {
24671
+ managedSetupOverride = setup;
24672
+ }
24673
+ });
24674
+ trustedNetworksRestartRequired = false;
24675
+ lanRebindRecoveryRequired = false;
24676
+ return {
24677
+ origin: lanGateway?.address().origin ?? `https://${network.address}:${String(result.setup.listenPort)}`,
24678
+ previousOrigin,
24679
+ restartRequired: false,
24680
+ disconnected: result.running,
24681
+ ready: lanGateway !== void 0
24682
+ };
24683
+ } catch (error) {
24684
+ if (error instanceof ManagedLanConfigurationError) throw new HttpError(409, "lan_rebind_configuration_invalid");
24685
+ if (!(error instanceof MobileAccessReconfigurationError)) throw new HttpError(503, "lan_rebind_unavailable");
24686
+ const recovered = error.recovered;
24687
+ if (!recovered) lanRebindRecoveryRequired = true;
24688
+ logger.error("managed LAN rebind failed; prior configuration restored: %s", String(recovered));
24689
+ throw new HttpError(500, recovered ? "lan_rebind_failed" : "lan_rebind_recovery_required");
24690
+ }
24691
+ }), false);
24692
+ return;
24693
+ }
24427
24694
  if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/control`) {
24428
24695
  sendJson(response, 200, remotePayload(), false);
24429
24696
  return;
@@ -24942,7 +25209,7 @@ async function apply(ctx, config) {
24942
25209
  stopAdmin,
24943
25210
  drainRemoteWork,
24944
25211
  () => releaseManager.dispose(),
24945
- () => trustedNetworksWrite,
25212
+ () => lanConfigurationWork,
24946
25213
  disposeMobileCommand,
24947
25214
  disposeTaskEvents,
24948
25215
  async () => {
@@ -24965,7 +25232,7 @@ async function apply(ctx, config) {
24965
25232
  stopAdmin,
24966
25233
  drainRemoteWork,
24967
25234
  () => releaseManager.dispose(),
24968
- () => trustedNetworksWrite,
25235
+ () => lanConfigurationWork,
24969
25236
  disposeMobileCommand,
24970
25237
  disposeTaskEvents,
24971
25238
  async () => {
@@ -24981,7 +25248,6 @@ async function apply(ctx, config) {
24981
25248
  };
24982
25249
  }, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
24983
25250
  }
24984
- //#endregion
24985
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 };
24986
25252
 
24987
25253
  //# sourceMappingURL=index.mjs.map