dsh-mobile 0.6.18 → 0.7.1

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 (42) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/CONTRIBUTORS.md +4 -1
  3. package/README.en.md +34 -20
  4. package/README.md +34 -20
  5. package/SECURITY.md +7 -0
  6. package/THIRD_PARTY_NOTICES.md +33 -0
  7. package/apps/mobile/README.md +28 -3
  8. package/apps/mobile/README.zh-CN.md +28 -3
  9. package/apps/mobile/release.json +3 -3
  10. package/docs/CADDY_MANAGED.en.md +1 -1
  11. package/docs/CADDY_MANAGED.md +1 -1
  12. package/docs/CLIENT_MODULES.en.md +2 -0
  13. package/docs/CLIENT_MODULES.md +2 -0
  14. package/docs/CLOUDFLARE_TUNNEL.en.md +7 -5
  15. package/docs/CLOUDFLARE_TUNNEL.md +7 -5
  16. package/docs/CPOLAR_SUBDOMAIN.en.md +2 -2
  17. package/docs/CPOLAR_SUBDOMAIN.md +2 -2
  18. package/docs/IROH.en.md +65 -0
  19. package/docs/IROH.md +65 -0
  20. package/docs/MOBILE_PLUGIN_ADAPTATION.en.md +8 -0
  21. package/docs/MOBILE_PLUGIN_ADAPTATION.md +8 -0
  22. package/docs/README.md +2 -1
  23. package/docs/SELF_HOSTED_ORIGIN.en.md +26 -1
  24. package/docs/SELF_HOSTED_ORIGIN.md +27 -2
  25. package/docs/SKIN_COMPATIBILITY.en.md +2 -0
  26. package/docs/SKIN_COMPATIBILITY.md +2 -0
  27. package/docs/SLOW_CONNECTIONS.en.md +8 -0
  28. package/docs/SLOW_CONNECTIONS.md +8 -0
  29. package/lib/cli.js.map +1 -1
  30. package/lib/client.js +1475 -240
  31. package/lib/client.js.map +1 -1
  32. package/lib/index.d.mts +270 -2
  33. package/lib/index.mjs +1934 -251
  34. package/lib/index.mjs.map +1 -1
  35. package/lib/mobile-layout.js +17 -6
  36. package/lib/mobile-layout.js.map +1 -1
  37. package/native/iroh-host/LICENSE.dsh-mobile-iroh +21 -0
  38. package/native/iroh-host/LICENSE.dsh-tether +21 -0
  39. package/native/iroh-host/NOTICE.dsh-mobile-iroh +13 -0
  40. package/native/iroh-host/README.md +66 -0
  41. package/package.json +14 -5
  42. package/packages/question-fixes/package.json +1 -1
package/lib/index.mjs CHANGED
@@ -3,7 +3,7 @@ import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomB
3
3
  import { basename, dirname, extname, isAbsolute, join, posix, relative, resolve, win32 } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import z from "@deepseek-ai/schemastery";
6
- import { connect, createServer } from "node:net";
6
+ import { connect, createServer, isIP } from "node:net";
7
7
  import { chmod, copyFile, link, lstat, mkdir, mkdtemp, open, opendir, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
8
8
  import { performance as performance$1 } from "node:perf_hooks";
9
9
  import { execFile, spawn } from "node:child_process";
@@ -77,14 +77,14 @@ var BoundedRateLimiter = class {
77
77
  function opaqueToken() {
78
78
  return randomBytes(32).toString("base64url");
79
79
  }
80
- function digest$1(value) {
80
+ function digest$2(value) {
81
81
  return createHash("sha256").update(value, "utf8").digest();
82
82
  }
83
83
  function digestHex(value) {
84
- return digest$1(value).toString("hex");
84
+ return digest$2(value).toString("hex");
85
85
  }
86
86
  function matchesDigest(value, expected) {
87
- return timingSafeEqual(digest$1(value), expected);
87
+ return timingSafeEqual(digest$2(value), expected);
88
88
  }
89
89
  function normalizeLabel(value) {
90
90
  const label = (value ?? "Mobile device").normalize("NFC").trim();
@@ -187,7 +187,7 @@ var AccessController = class {
187
187
  const record = Object.freeze({
188
188
  key,
189
189
  deviceId: device.id,
190
- csrfDigest: digest$1(csrfToken),
190
+ csrfDigest: digest$2(csrfToken),
191
191
  createdAt: now,
192
192
  expiresAt: Math.min(now + this.options.sessionTtlMs, device.expiresAt)
193
193
  });
@@ -209,7 +209,7 @@ var AccessController = class {
209
209
  const token = opaqueToken();
210
210
  const expiresAt = this.now() + ttl;
211
211
  this.pairingWindow = Object.freeze({
212
- digest: digest$1(token),
212
+ digest: digest$2(token),
213
213
  expiresAt
214
214
  });
215
215
  return Object.freeze({
@@ -317,7 +317,7 @@ var AccessController = class {
317
317
  }
318
318
  requireDevice(deviceToken, now) {
319
319
  if (deviceToken.length > 512) throw new AccessError(401, "authentication_failed");
320
- const tokenDigest = digest$1(deviceToken);
320
+ const tokenDigest = digest$2(deviceToken);
321
321
  const device = this.devices.find((candidate) => timingSafeEqual(Buffer.from(candidate.tokenDigest, "hex"), tokenDigest));
322
322
  if (device === void 0) throw new AccessError(401, "authentication_failed");
323
323
  if (device.revokedAt !== void 0) throw new AccessError(401, "device_revoked");
@@ -480,7 +480,7 @@ function countIpv6Groups(labels, allowDottedQuad) {
480
480
  * @param value - candidate address; non-strings are never an IP.
481
481
  * @returns `4` for IPv4, `6` for IPv6, `0` when the value is neither.
482
482
  */
483
- function isIP(value) {
483
+ function isIP$1(value) {
484
484
  if (typeof value !== "string") return 0;
485
485
  if (isStrictIpv4(value)) return 4;
486
486
  let text = value;
@@ -551,7 +551,7 @@ function parseIp(address) {
551
551
  bits: 32,
552
552
  value: parseIpv4$1(mapped)
553
553
  };
554
- const version = isIP(unwrapped.split("%", 1)[0] ?? unwrapped);
554
+ const version = isIP$1(unwrapped.split("%", 1)[0] ?? unwrapped);
555
555
  if (version === 4) return {
556
556
  bits: 32,
557
557
  value: parseIpv4$1(unwrapped)
@@ -1090,7 +1090,7 @@ function parseGatewayConfig(raw) {
1090
1090
  if (publicOrigin !== void 0 && value.listenPort !== void 0) throw new Error("publicOrigin cannot be combined with listenPort");
1091
1091
  if (publicOrigin !== void 0 && value.publicAuthorities !== void 0) throw new Error("publicOrigin cannot be combined with publicAuthorities");
1092
1092
  const listenHost = value.listenHost ?? (publicOrigin === void 0 ? "127.0.0.1" : "0.0.0.0");
1093
- if (isIP(listenHost) === 0) throw new Error("listenHost must be an IP literal");
1093
+ if (isIP$1(listenHost) === 0) throw new Error("listenHost must be an IP literal");
1094
1094
  const listenPort = publicOrigin?.port ?? integer(value.listenPort, "listenPort", 3443, 0, 65535);
1095
1095
  const upstreamOrigin = parseUpstream(value.upstreamOrigin);
1096
1096
  const tls = parseTls(value.tls, listenHost);
@@ -1285,6 +1285,45 @@ async function restrictPrivateFile(file, mode = 384) {
1285
1285
  timeout: 1e4
1286
1286
  }));
1287
1287
  }
1288
+ /**
1289
+ * Restrict a plugin-owned private directory (and what already sits inside it)
1290
+ * so a strict native consumer can verify every ancestor and the leaf itself.
1291
+ * Inheritance is disabled and re-granted to the current user, SYSTEM and
1292
+ * administrators only; future children inherit the same compliant entries.
1293
+ * Reparse points are never followed: a linked child is left alone and fails
1294
+ * closed downstream instead of being repaired here.
1295
+ */
1296
+ async function restrictPrivateDirectory(directory) {
1297
+ await chmod(directory, 448);
1298
+ const entry = await lstat(directory);
1299
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw Object.assign(/* @__PURE__ */ new Error("private directory is not a real directory"), { code: "PRIVATE_DIRECTORY_INVALID" });
1300
+ if (process.platform !== "win32") return;
1301
+ const userSid = await currentWindowsUserSid();
1302
+ const apply = (target) => permissionOperation("acl-apply", () => execFileText(windowsSystemTool("icacls.exe"), [
1303
+ target,
1304
+ "/inheritance:r",
1305
+ "/grant:r",
1306
+ `*${userSid}:(OI)(CI)(F)`,
1307
+ "*S-1-5-18:(OI)(CI)(F)",
1308
+ "*S-1-5-32-544:(OI)(CI)(F)",
1309
+ "/remove:g",
1310
+ "*S-1-1-0",
1311
+ "*S-1-5-11",
1312
+ "*S-1-5-32-545"
1313
+ ], {
1314
+ encoding: "utf8",
1315
+ windowsHide: true,
1316
+ timeout: 1e4
1317
+ }));
1318
+ await apply(directory);
1319
+ for (const name of await readdir(directory)) {
1320
+ const child = join(directory, name);
1321
+ const info = await lstat(child);
1322
+ if (info.isSymbolicLink()) continue;
1323
+ if (info.isDirectory()) await restrictPrivateDirectory(child);
1324
+ else if (info.isFile()) await restrictPrivateFile(child);
1325
+ }
1326
+ }
1288
1327
  //#endregion
1289
1328
  //#region src/client-module-preferences.ts
1290
1329
  const MAX_RECORD_BYTES = 1048576;
@@ -5743,7 +5782,7 @@ async function tlsOptions(config) {
5743
5782
  const leaf = chain[0].certificate;
5744
5783
  for (const authority of config.authorities) {
5745
5784
  const hostname = stripIpv6Brackets(authority.hostname);
5746
- if ((isIP(hostname) === 0 ? leaf.checkHost(hostname) : leaf.checkIP(hostname)) === void 0) throw new Error(`TLS certificate does not cover configured authority ${hostname}`);
5785
+ if ((isIP$1(hostname) === 0 ? leaf.checkHost(hostname) : leaf.checkIP(hostname)) === void 0) throw new Error(`TLS certificate does not cover configured authority ${hostname}`);
5747
5786
  }
5748
5787
  return {
5749
5788
  cert: chain.map((entry) => entry.pem).join(""),
@@ -6337,7 +6376,7 @@ var MobileAccessGateway = class {
6337
6376
  if (this.closing || this.discoveryError !== void 0 || !message.equals(DISCOVERY_QUERY) || !addressAllowed(remote.address, this.config.allowedCidrs)) return;
6338
6377
  sendAnnouncement(remote.port, remote.address);
6339
6378
  });
6340
- const bindHost = isIP(this.config.listenHost) === 4 && isLoopbackAddress(this.config.listenHost) ? this.config.listenHost : "0.0.0.0";
6379
+ const bindHost = isIP$1(this.config.listenHost) === 4 && isLoopbackAddress(this.config.listenHost) ? this.config.listenHost : "0.0.0.0";
6341
6380
  if (await new Promise((resolve) => {
6342
6381
  const onBindError = (error) => {
6343
6382
  socket.off("error", onBindError);
@@ -10424,7 +10463,7 @@ function hostname$2(value) {
10424
10463
  function validateFrpServerAddress(value) {
10425
10464
  if (typeof value !== "string" || value !== value.trim() || value.length === 0 || value.length > 253 || /[\s\u0000-\u001f\u007f/\\@?#]/u.test(value)) throw new Error("frp_server_address_invalid");
10426
10465
  const normalized = value.toLowerCase().replace(/\.$/u, "");
10427
- if (isIP(normalized) === 0 && !hostname$2(normalized)) throw new Error("frp_server_address_invalid");
10466
+ if (isIP$1(normalized) === 0 && !hostname$2(normalized)) throw new Error("frp_server_address_invalid");
10428
10467
  return normalized;
10429
10468
  }
10430
10469
  /** Validate the FRP control port. */
@@ -10447,8 +10486,8 @@ function validateFrpPublicOrigin(value) {
10447
10486
  throw new Error("frp_public_origin_invalid");
10448
10487
  }
10449
10488
  const publicHost = url.hostname;
10450
- if (url.protocol !== "https:" || url.port !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "" || isIP(publicHost) !== 4 && !hostname$2(publicHost)) throw new Error("frp_public_origin_invalid");
10451
- if (isIP(publicHost) === 4 && !isGloballyRoutableIpv4(publicHost)) throw new Error("frp_public_origin_invalid");
10489
+ if (url.protocol !== "https:" || url.port !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "" || isIP$1(publicHost) !== 4 && !hostname$2(publicHost)) throw new Error("frp_public_origin_invalid");
10490
+ if (isIP$1(publicHost) === 4 && !isGloballyRoutableIpv4(publicHost)) throw new Error("frp_public_origin_invalid");
10452
10491
  return url.origin;
10453
10492
  }
10454
10493
  /** Validate the provisioning mode. */
@@ -10532,7 +10571,7 @@ function parseFrpSettings(value) {
10532
10571
  if (resolvedEntryTls === "self-signed" && resolvedMode !== "attach") throw new Error("frp_entry_tls_invalid");
10533
10572
  if (resolvedMode === "attach" && resolvedEntryTls === "public-ip-cert" && vhostHttpPort === void 0) throw new Error("frp_attach_mode_requires_vhost_port");
10534
10573
  const publicOrigin = validateFrpPublicOrigin(record.publicOrigin);
10535
- if (resolvedEntryTls === "self-signed" && isIP(new URL(publicOrigin).hostname) !== 4) throw new Error("frp_self_signed_requires_public_ipv4");
10574
+ if (resolvedEntryTls === "self-signed" && isIP$1(new URL(publicOrigin).hostname) !== 4) throw new Error("frp_self_signed_requires_public_ipv4");
10536
10575
  return Object.freeze({
10537
10576
  version: 1,
10538
10577
  serverAddress: validateFrpServerAddress(record.serverAddress),
@@ -10886,7 +10925,7 @@ function frpAttachVpsParts(settings, options = {}) {
10886
10925
  selfSigned: false,
10887
10926
  vhostHttpPort,
10888
10927
  snippet: `${FRP_CADDY_SNIPPET_MARKER}\n${site.trimEnd()}\n`,
10889
- certGuide: Object.freeze(isIP(publicHost) === 4 ? guideCommands(manualIpCertificateGuide(publicHost)) : [])
10928
+ certGuide: Object.freeze(isIP$1(publicHost) === 4 ? guideCommands(manualIpCertificateGuide(publicHost)) : [])
10890
10929
  });
10891
10930
  }
10892
10931
  /**
@@ -11734,7 +11773,7 @@ function frpIngressPaths(stateFile) {
11734
11773
  statusFile: join(dirname(stateFile), "ingress-identity.json")
11735
11774
  });
11736
11775
  }
11737
- async function regularFileExists(file) {
11776
+ async function regularFileExists$1(file) {
11738
11777
  try {
11739
11778
  const entry = await lstat(file);
11740
11779
  if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("frp_ingress_file_invalid");
@@ -11745,7 +11784,7 @@ async function regularFileExists(file) {
11745
11784
  }
11746
11785
  }
11747
11786
  async function readIngressIdentity(file) {
11748
- if (!await regularFileExists(file)) return void 0;
11787
+ if (!await regularFileExists$1(file)) return void 0;
11749
11788
  try {
11750
11789
  const parsed = JSON.parse(await readFile(file, "utf8"));
11751
11790
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("invalid identity");
@@ -11785,10 +11824,10 @@ async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now()
11785
11824
  }
11786
11825
  const previousFingerprint = await readIngressIdentity(paths.statusFile);
11787
11826
  const [caCertExists, caKeyExists, leafCertExists, leafKeyExists] = await Promise.all([
11788
- regularFileExists(paths.caCertFile),
11789
- regularFileExists(paths.caKeyFile),
11790
- regularFileExists(paths.certFile),
11791
- regularFileExists(paths.keyFile)
11827
+ regularFileExists$1(paths.caCertFile),
11828
+ regularFileExists$1(paths.caKeyFile),
11829
+ regularFileExists$1(paths.certFile),
11830
+ regularFileExists$1(paths.keyFile)
11792
11831
  ]);
11793
11832
  if (caCertExists !== caKeyExists || !caCertExists && (previousFingerprint !== void 0 || leafCertExists || leafKeyExists)) throw new Error("frp_ingress_ca_invalid");
11794
11833
  if (caCertExists && (await readCertificateRenewal(paths.caCertFile, now)).state === "expired") throw new Error("frp_ingress_ca_expired");
@@ -11948,7 +11987,8 @@ const REMOTE_PROVIDERS = Object.freeze([
11948
11987
  "cpolar",
11949
11988
  "cloudflared",
11950
11989
  "frp",
11951
- "origin"
11990
+ "origin",
11991
+ "iroh"
11952
11992
  ]);
11953
11993
  function aggregateErrors(errors, message) {
11954
11994
  if (errors.length === 0) return void 0;
@@ -12108,7 +12148,7 @@ async function terminateRemoteProcess(child, gracefulTimeoutMs = 1500, forcedTim
12108
12148
  function parseRemoteProviderState(value) {
12109
12149
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("remote provider state must be an object");
12110
12150
  const record = value;
12111
- if (record.version !== 1 || record.provider !== "tailscale" && record.provider !== "cpolar" && record.provider !== "cloudflared" && record.provider !== "frp" && record.provider !== "origin" || Reflect.ownKeys(record).some((key) => key !== "version" && key !== "provider")) throw new Error("remote provider state has an unsupported format");
12151
+ if (record.version !== 1 || record.provider !== "tailscale" && record.provider !== "cpolar" && record.provider !== "cloudflared" && record.provider !== "frp" && record.provider !== "origin" && record.provider !== "iroh" || Reflect.ownKeys(record).some((key) => key !== "version" && key !== "provider")) throw new Error("remote provider state has an unsupported format");
12112
12152
  return Object.freeze({
12113
12153
  version: 1,
12114
12154
  provider: record.provider
@@ -12174,7 +12214,7 @@ var JsonRemoteProviderStore = class {
12174
12214
  /** Resolve the first-run provider without letting environment values bypass validation. */
12175
12215
  function configuredRemoteProvider(environment) {
12176
12216
  const value = environment.DSH_MOBILE_REMOTE_PROVIDER ?? "tailscale";
12177
- if (value !== "tailscale" && value !== "cpolar" && value !== "cloudflared" && value !== "frp" && value !== "origin") throw new Error("DSH_MOBILE_REMOTE_PROVIDER must be tailscale, cpolar, cloudflared, frp, or origin");
12217
+ 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");
12178
12218
  return value;
12179
12219
  }
12180
12220
  //#endregion
@@ -12209,7 +12249,7 @@ const CLIENT_FAILURE_CODES = Object.freeze([
12209
12249
  function frpcDiagnosisCode(output) {
12210
12250
  return CLIENT_FAILURE_CODES.find(([pattern]) => pattern.test(output))?.[1];
12211
12251
  }
12212
- function publicStatus$3(status) {
12252
+ function publicStatus$4(status) {
12213
12253
  return Object.freeze({
12214
12254
  enabled: status.enabled,
12215
12255
  state: status.state,
@@ -12415,7 +12455,7 @@ var FrpController = class {
12415
12455
  child;
12416
12456
  gatewayValue;
12417
12457
  generation = 0;
12418
- latest = publicStatus$3({
12458
+ latest = publicStatus$4({
12419
12459
  enabled: false,
12420
12460
  state: "off"
12421
12461
  });
@@ -12445,7 +12485,7 @@ var FrpController = class {
12445
12485
  }
12446
12486
  /** Return state safe for the desktop control UI. */
12447
12487
  status() {
12448
- return publicStatus$3(this.latest);
12488
+ return publicStatus$4(this.latest);
12449
12489
  }
12450
12490
  /** Enable or disable FRP without changing LAN or another provider. */
12451
12491
  async setEnabled(enabled) {
@@ -12511,7 +12551,7 @@ var FrpController = class {
12511
12551
  return task;
12512
12552
  }
12513
12553
  publish(status) {
12514
- this.latest = publicStatus$3(status);
12554
+ this.latest = publicStatus$4(status);
12515
12555
  try {
12516
12556
  this.options.onStatus?.(this.status());
12517
12557
  } catch {}
@@ -12830,18 +12870,18 @@ function validateOriginPublicOrigin(value) {
12830
12870
  "internal",
12831
12871
  "home.arpa"
12832
12872
  ].some((suffix) => host === suffix || host.endsWith("." + suffix));
12833
- if (url.protocol !== "https:" || url.pathname !== "/" || url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "" || url.port !== "" && Number(url.port) < 1 || (isIP(host) === 4 ? !isGloballyRoutableIpv4(host) : isIP(host) !== 0 || !validHostname)) throw new Error("origin_public_origin_invalid");
12873
+ if (url.protocol !== "https:" || url.pathname !== "/" || url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "" || url.port !== "" && Number(url.port) < 1 || (isIP$1(host) === 4 ? !isGloballyRoutableIpv4(host) : isIP$1(host) !== 0 || !validHostname)) throw new Error("origin_public_origin_invalid");
12834
12874
  return url.origin;
12835
12875
  }
12836
12876
  /** Bind one explicit loopback, RFC1918 IPv4 or IPv6 ULA interface; zone IDs are unsupported. */
12837
12877
  function validateOriginListenHost(value) {
12838
- const addressFamily = isIP(value);
12878
+ const addressFamily = isIP$1(value);
12839
12879
  if (typeof value !== "string" || value.includes("%") || addressFamily === 0 || !addressAllowed(value, TRUSTED_NETWORKS.filter((range) => range.bits === (addressFamily === 6 ? 128 : 32)))) throw new Error("origin_listen_host_invalid");
12840
12880
  return addressFamily === 6 ? new URL("http://[" + value + "]").hostname.slice(1, -1) : value;
12841
12881
  }
12842
12882
  /** Format a validated backend listener, including the brackets required by IPv6 URLs. */
12843
12883
  function formatOriginBackendOrigin(listenHost, listenPort) {
12844
- return "http://" + (isIP(listenHost) === 6 ? "[" + listenHost + "]" : listenHost) + ":" + String(listenPort);
12884
+ return "http://" + (isIP$1(listenHost) === 6 ? "[" + listenHost + "]" : listenHost) + ":" + String(listenPort);
12845
12885
  }
12846
12886
  function validateOriginListenPort(value) {
12847
12887
  if (!Number.isSafeInteger(value) || Number(value) < MIN_ORIGIN_LISTEN_PORT || Number(value) > 65535) throw new Error("origin_listen_port_invalid");
@@ -12850,12 +12890,12 @@ function validateOriginListenPort(value) {
12850
12890
  }
12851
12891
  /** Authorize direct proxy socket peers, not untrusted forwarded client headers. */
12852
12892
  function validateOriginAllowedCidrs(value, listenHost) {
12853
- const addressFamily = isIP(listenHost);
12893
+ const addressFamily = isIP$1(listenHost);
12854
12894
  const input = value === void 0 && isLoopbackAddress(listenHost) ? [addressFamily === 6 ? "::1/128" : "127.0.0.0/8"] : value;
12855
12895
  if (!Array.isArray(input) || input.length === 0 || input.length > 16) throw new Error("origin_allowed_cidrs_invalid");
12856
12896
  const cidrs = [];
12857
12897
  for (const entry of input) {
12858
- if (typeof entry !== "string" || entry.length > 64 || entry.includes("%") || isIP(entry.split("/")[0] ?? "") !== addressFamily) throw new Error("origin_allowed_cidrs_invalid");
12898
+ if (typeof entry !== "string" || entry.length > 64 || entry.includes("%") || isIP$1(entry.split("/")[0] ?? "") !== addressFamily) throw new Error("origin_allowed_cidrs_invalid");
12859
12899
  try {
12860
12900
  const cidr = parseCidr(entry);
12861
12901
  if (cidr.bits !== (addressFamily === 6 ? 128 : 32) || !TRUSTED_NETWORKS.some((range) => cidr.bits === range.bits && cidr.prefix >= range.prefix && addressAllowed(entry.split("/")[0], [range]))) throw new Error("untrusted CIDR");
@@ -13471,7 +13511,7 @@ const RESERVED_CLOUDFLARED_LABELS = Object.freeze([
13471
13511
  "update",
13472
13512
  "login"
13473
13513
  ]);
13474
- function publicStatus$2(status) {
13514
+ function publicStatus$3(status) {
13475
13515
  return Object.freeze({
13476
13516
  enabled: status.enabled,
13477
13517
  state: status.state,
@@ -13591,7 +13631,7 @@ var CloudflaredController = class {
13591
13631
  buffer = "";
13592
13632
  mode = "quick";
13593
13633
  namedOrigin;
13594
- latest = publicStatus$2({
13634
+ latest = publicStatus$3({
13595
13635
  enabled: false,
13596
13636
  state: "off"
13597
13637
  });
@@ -13619,7 +13659,7 @@ var CloudflaredController = class {
13619
13659
  }
13620
13660
  /** Return state safe for the desktop control UI. */
13621
13661
  status() {
13622
- return publicStatus$2(this.latest);
13662
+ return publicStatus$3(this.latest);
13623
13663
  }
13624
13664
  /** Enable or disable cloudflared without changing LAN or other provider state. */
13625
13665
  async setEnabled(enabled) {
@@ -13685,7 +13725,7 @@ var CloudflaredController = class {
13685
13725
  return task;
13686
13726
  }
13687
13727
  publish(status) {
13688
- this.latest = publicStatus$2(status);
13728
+ this.latest = publicStatus$3(status);
13689
13729
  try {
13690
13730
  this.options.onStatus?.(this.status());
13691
13731
  } catch {}
@@ -13992,7 +14032,7 @@ function hostname$1(value) {
13992
14032
  function validateCloudflaredTunnelHostname(value) {
13993
14033
  if (typeof value !== "string" || value !== value.trim() || value.length === 0 || value.length > 253) throw new Error("cloudflared_tunnel_hostname_invalid");
13994
14034
  const normalized = value.toLowerCase().replace(/\.$/u, "");
13995
- if (isIP(normalized) !== 0 || !hostname$1(normalized) || normalized.includes("*") || RESERVED_APEX_NAMES.includes(normalized) || normalized.endsWith(QUICK_TUNNEL_SUFFIX) || normalized.endsWith(TUNNEL_ROUTING_SUFFIX)) throw new Error("cloudflared_tunnel_hostname_invalid");
14035
+ if (isIP$1(normalized) !== 0 || !hostname$1(normalized) || normalized.includes("*") || RESERVED_APEX_NAMES.includes(normalized) || normalized.endsWith(QUICK_TUNNEL_SUFFIX) || normalized.endsWith(TUNNEL_ROUTING_SUFFIX)) throw new Error("cloudflared_tunnel_hostname_invalid");
13996
14036
  return normalized;
13997
14037
  }
13998
14038
  /** Validate the stable loopback port Cloudflare's ingress forwards to. */
@@ -14163,215 +14203,1638 @@ var CloudflaredTunnelStore = class {
14163
14203
  }
14164
14204
  };
14165
14205
  //#endregion
14166
- //#region src/task-events.ts
14167
- const TASK_EVENT_DEBOUNCE_MS = 1e3;
14168
- function turnNumber(value) {
14169
- return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
14206
+ //#region src/iroh-files.ts
14207
+ async function renameIrohPath(root, source, destination) {
14208
+ await renameManagedPath(root, source, destination, "iroh");
14170
14209
  }
14171
- function turnData(value) {
14172
- if (value === null || typeof value !== "object" || Array.isArray(value)) return {
14173
- turn: 0,
14174
- completed: false
14175
- };
14176
- const record = value;
14177
- const reason = record.reason;
14178
- const completed = typeof reason === "string" ? reason === "completed" : reason !== null && typeof reason === "object" && reason.kind === "completed";
14179
- return {
14180
- turn: turnNumber(record.turn),
14181
- completed
14182
- };
14210
+ async function ensureIrohDirectory(root, directory) {
14211
+ await ensureManagedDirectory(root, directory, "iroh");
14183
14212
  }
14184
- /**
14185
- * Subscribe to completed root turns. Subagent turns are skipped so one task
14186
- * announces once, and rapid turn boundaries collapse into a single event.
14187
- * Returns a disposer that also drops pending debounces.
14188
- */
14189
- function watchTaskCompletions(ctx, options) {
14190
- const debounceMs = options.debounceMs ?? 1e3;
14191
- const pending = /* @__PURE__ */ new Map();
14192
- const disposeListener = ctx.on("session/event", (session, event) => {
14193
- if (event.type !== "turn/end") return;
14194
- const { turn, completed } = turnData(event.data);
14195
- if (!completed) return;
14196
- if (session.header?.parentSession !== void 0 && session.header?.parentSession !== null) return;
14197
- const sessionId = String(session.id);
14198
- options.log?.("task-completed-observed", {
14199
- sessionId,
14200
- turn
14201
- });
14202
- const previous = pending.get(sessionId);
14203
- if (previous !== void 0) clearTimeout(previous);
14204
- pending.set(sessionId, setTimeout(() => {
14205
- pending.delete(sessionId);
14206
- options.log?.("task-completed-announced", {
14207
- sessionId,
14208
- turn
14209
- });
14210
- options.onTaskCompleted(Object.freeze({
14211
- sessionId,
14212
- turn
14213
- }));
14214
- }, debounceMs));
14215
- });
14216
- return () => {
14217
- disposeListener();
14218
- for (const timer of pending.values()) clearTimeout(timer);
14219
- pending.clear();
14220
- };
14213
+ async function removeIrohTree(root, target) {
14214
+ await removeManagedTree(root, target, "iroh");
14221
14215
  }
14222
- /** One subscription feeding every live gateway; gateways register on start. */
14223
- var TaskEventHub = class {
14224
- sinks = /* @__PURE__ */ new Set();
14225
- /** Register a sink; returns its disposer. */
14226
- add(sink) {
14227
- this.sinks.add(sink);
14228
- return () => {
14229
- this.sinks.delete(sink);
14230
- };
14231
- }
14232
- /** Fan out to a snapshot so a failing sink cannot break its siblings. */
14233
- broadcast(event) {
14234
- for (const sink of [...this.sinks]) try {
14235
- sink.broadcastTaskEvent(event);
14236
- } catch {}
14237
- }
14238
- /** Visible for tests. */
14239
- get size() {
14240
- return this.sinks.size;
14241
- }
14242
- };
14243
14216
  //#endregion
14244
- //#region src/diagnostics.ts
14245
- /** Recognize the active remote-web-ui boot hook without evaluating or modifying it. */
14246
- function hasCompetingRemoteChannelBoot(rows) {
14247
- return rows.some((row) => row.kind === "script" && row.text?.includes("__DSH_REMOTE_CHANNEL_BOOT__") === true);
14248
- }
14249
- const REMOTE_ERROR_GUIDANCE = Object.freeze({
14250
- component_missing: "重新安装完整插件包。",
14251
- funnel_permission_required: "继续完成 Tailscale Funnel 授权。",
14252
- funnel_https_required: "继续完成 Tailscale HTTPS 授权。",
14253
- funnel_start_failed: "重新打开授权页并允许 Funnel。",
14254
- funnel_start_timeout: "检查网络后点击“重新连接”。",
14255
- tailscale_dns_missing: "确认 Tailscale 登录仍有效后重新连接。",
14256
- sidecar_launch_failed: "重新安装完整插件包后重试。",
14257
- sidecar_stopped: "点击“重新连接”。",
14258
- sidecar_exited: "点击“重新连接”;仍失败时复制诊断报告。",
14259
- control_channel_failed: "点击“重新连接”。",
14260
- cpolar_component_missing: "先安装 cpolar 官方组件。",
14261
- cpolar_component_invalid: "彻底移除 cpolar 组件后重新安装。",
14262
- cpolar_config_missing: "保存 cpolar Authtoken 后重试。",
14263
- cpolar_config_invalid: "重新保存 cpolar Authtoken。",
14264
- cpolar_start_timeout: "检查网络后点击“重新连接”。",
14265
- cpolar_stopped: "点击“重新连接”。",
14266
- cpolar_exited: "点击“重新连接”;仍失败时复制诊断报告。",
14267
- cloudflared_component_missing: "先安装 cloudflared 官方组件。",
14268
- cloudflared_component_invalid: "彻底移除 cloudflared 组件后重新安装。",
14269
- cloudflared_port_unavailable: "无法分配本机远程网关端口,请重试。",
14270
- cloudflared_launch_failed: "重新安装 cloudflared 官方组件后重试。",
14271
- cloudflared_start_timeout: "检查网络后点击“重新连接”。",
14272
- cloudflared_stopped: "点击“重新连接”。",
14273
- cloudflared_exited: "点击“重新连接”;仍失败时复制诊断报告。",
14274
- cloudflared_invalid_output: "cloudflared 返回了无法识别的状态。",
14275
- cloudflared_invalid_origin: "cloudflared 返回的公网地址未通过校验。",
14276
- origin_config_missing: "先保存自有反向代理配置。",
14277
- origin_config_invalid: "重新保存自有反向代理配置。",
14278
- origin_listen_port_in_use: "更换 HTTP 后端端口;不要使用局域网的 3443、DSH 自身的 3080,或已被 cloudflared 隧道占用的 3444。",
14279
- origin_listen_address_unavailable: "监听地址不属于当前电脑,请重新选择本机的私网 IPv4 地址。",
14280
- origin_gateway_start_failed: "检查 HTTP 后端监听地址和端口后重新连接。",
14281
- frp_component_missing: "先安装 FRP 官方组件。",
14282
- frp_component_invalid: "彻底清理 FRP 组件后重新安装。",
14283
- frp_config_missing: "先保存自建 FRP 连接配置。",
14284
- frp_config_verify_failed: "检查服务器地址、端口、Token 和公开域名。",
14285
- frp_vhost_publicly_reachable: "将 frps 的 HTTP vhost 监听限制到 127.0.0.1。",
14286
- frp_vhost_probe_failed: "确认 VPS 地址可解析后重新连接。",
14287
- frp_launch_failed: "重新安装 FRP 官方组件后重试。",
14288
- frp_start_timeout: "确认 frps、Caddy 和域名解析正常后重新连接。",
14289
- frp_proxy_name_in_use: "该 frps 上已有同名代理:在另一台 DSH 电脑上关闭自建 FRP,或在 frps 上清掉该代理后重新连接。",
14290
- frp_remote_port_in_use: "公网入口端口被其他代理占用:改用其他入口端口后重新连接。",
14291
- frp_proxy_start_failed: "查看 frps 日志与 allowPorts,确认它能接受该代理名和入口端口。",
14292
- frp_discovery_mismatch: "公开域名连接到了另一台电脑,请核对 Caddy 与 frps 配置。",
14293
- frp_discovery_invalid: "公开域名返回了非 DSH Mobile 响应。",
14294
- frp_stopped: "点击“重新连接”。",
14295
- frp_exited: "检查 VPS 配置后重新连接;仍失败时复制诊断报告。",
14296
- gateway_start_failed: "确认 DSH 正在运行后重新连接。"
14217
+ //#region src/iroh-manifest.ts
14218
+ /** Checksum-gated resolution of the iroh transport host binary.
14219
+ *
14220
+ * The sidecar is built and published by the independent `dsh-mobile-iroh`
14221
+ * companion (npm alpha channel); this stable plugin never bundles, downloads
14222
+ * or rebuilds it. Resolution points at an installed companion directory,
14223
+ * validates its manifest against that package's own version, then verifies
14224
+ * the platform binary byte-for-byte. Anything unexpected fails closed.
14225
+ */
14226
+ const IROH_NATIVE_TARGETS = Object.freeze({
14227
+ "win32-x64": "dsh-mobile-iroh-host-win32-x64.exe",
14228
+ "linux-x64": "dsh-mobile-iroh-host-linux-x64"
14297
14229
  });
14298
- function check(id, status, reason, label, detail, action, facts) {
14230
+ const MAX_BINARY_BYTES = 134217728;
14231
+ const MAX_MANIFEST_BYTES = 4096;
14232
+ function exactKeys(value, keys) {
14233
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
14234
+ return Object.keys(value).sort().join(",") === [...keys].sort().join(",");
14235
+ }
14236
+ /** Validate the complete package-bound inventory; artifact paths are never supplied by metadata. */
14237
+ function validateIrohNativeManifest(value, packageVersion) {
14238
+ if (!exactKeys(value, [
14239
+ "schemaVersion",
14240
+ "packageVersion",
14241
+ "artifacts"
14242
+ ]) || value.schemaVersion !== 1 || value.packageVersion !== packageVersion || !exactKeys(value.artifacts, Object.keys(IROH_NATIVE_TARGETS))) throw new Error("iroh_native_manifest_invalid");
14243
+ const artifacts = {};
14244
+ for (const target of Object.keys(IROH_NATIVE_TARGETS)) {
14245
+ const entry = value.artifacts[target];
14246
+ if (!exactKeys(entry, [
14247
+ "file",
14248
+ "bytes",
14249
+ "sha256"
14250
+ ]) || entry.file !== IROH_NATIVE_TARGETS[target] || !Number.isSafeInteger(entry.bytes) || entry.bytes < 64 || entry.bytes > MAX_BINARY_BYTES || typeof entry.sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(entry.sha256)) throw new Error("iroh_native_manifest_invalid");
14251
+ artifacts[target] = Object.freeze({
14252
+ file: entry.file,
14253
+ bytes: entry.bytes,
14254
+ sha256: entry.sha256
14255
+ });
14256
+ }
14299
14257
  return Object.freeze({
14300
- id,
14301
- status,
14302
- reason,
14303
- ...facts === void 0 ? {} : { facts: Object.freeze(facts) },
14304
- label,
14305
- detail,
14306
- ...action === void 0 ? {} : { action }
14258
+ schemaVersion: 1,
14259
+ packageVersion: value.packageVersion,
14260
+ artifacts: Object.freeze(artifacts)
14307
14261
  });
14308
14262
  }
14309
- function maskLanOrigin(origin) {
14310
- if (origin === void 0) return "未分配";
14263
+ /**
14264
+ * Return the checksum-verified sidecar inside an installed companion package.
14265
+ * @param companionRoot - Companion package directory (contains `package.json` + `native/`).
14266
+ */
14267
+ async function resolveIrohSidecarAt(companionRoot, platform, arch) {
14268
+ const target = `${platform}-${arch}`;
14269
+ if (!Object.hasOwn(IROH_NATIVE_TARGETS, target)) throw new Error("iroh_native_platform_unsupported");
14270
+ const root = await realpath(resolve(companionRoot)).catch(() => {
14271
+ throw new Error("iroh_native_missing");
14272
+ });
14273
+ const nativeDirectory = join(root, "native");
14274
+ const directory = await lstat(nativeDirectory).catch(() => {
14275
+ throw new Error("iroh_native_missing");
14276
+ });
14277
+ if (!directory.isDirectory() || directory.isSymbolicLink()) throw new Error("iroh_native_file_invalid");
14278
+ const manifestPath = join(nativeDirectory, "manifest.json");
14279
+ const manifestStat = await lstat(manifestPath).catch(() => {
14280
+ throw new Error("iroh_native_missing");
14281
+ });
14282
+ if (!manifestStat.isFile() || manifestStat.isSymbolicLink() || manifestStat.size > MAX_MANIFEST_BYTES) throw new Error("iroh_native_manifest_invalid");
14283
+ let manifest;
14311
14284
  try {
14312
- const url = new URL(origin);
14313
- const octets = url.hostname.split(".");
14314
- const host = octets.length === 4 ? `${octets[0]}.${octets[1]}.${octets[2]}.x` : "局域网地址";
14315
- return `${url.protocol}//${host}${url.port === "" ? "" : `:${url.port}`}`;
14316
- } catch {
14317
- return "地址格式无效";
14285
+ const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
14286
+ if (typeof pkg.version !== "string") throw new Error("iroh_native_manifest_invalid");
14287
+ manifest = validateIrohNativeManifest(JSON.parse(await readFile(manifestPath, "utf8")), pkg.version);
14288
+ } catch (error) {
14289
+ if (error instanceof Error && error.message === "iroh_native_manifest_invalid") throw error;
14290
+ throw new Error("iroh_native_manifest_invalid");
14318
14291
  }
14319
- }
14320
- function remoteSuffix(origin) {
14321
- if (origin === void 0) return "未分配";
14292
+ const expected = manifest.artifacts[target];
14293
+ const path = join(nativeDirectory, IROH_NATIVE_TARGETS[target]);
14294
+ const before = await lstat(path).catch(() => {
14295
+ throw new Error("iroh_native_missing");
14296
+ });
14297
+ if (!before.isFile() || before.isSymbolicLink() || before.size !== expected.bytes) throw new Error("iroh_native_file_invalid");
14298
+ const file = await open(path, "r").catch(() => {
14299
+ throw new Error("iroh_native_missing");
14300
+ });
14322
14301
  try {
14323
- const hostname = new URL(origin).hostname;
14324
- if (hostname.endsWith(".ts.net")) return "*.ts.net";
14325
- if (hostname.endsWith(".trycloudflare.com")) return "*.trycloudflare.com";
14326
- for (const suffix of [
14327
- ".cpolar.cn",
14328
- ".cpolar.io",
14329
- ".cpolar.top",
14330
- ".cpolar.com"
14331
- ]) if (hostname.endsWith(suffix)) return `*${suffix}`;
14332
- return "公共 HTTPS 地址";
14333
- } catch {
14334
- return "地址格式无效";
14302
+ const opened = await file.stat();
14303
+ if (!opened.isFile() || opened.size !== expected.bytes || opened.ino !== before.ino || opened.dev !== before.dev) throw new Error("iroh_native_file_invalid");
14304
+ const hash = createHash("sha256");
14305
+ let bytes = 0;
14306
+ for await (const chunk of file.createReadStream({ autoClose: false })) {
14307
+ const buffer = chunk;
14308
+ bytes += buffer.length;
14309
+ if (bytes > expected.bytes) throw new Error("iroh_native_file_changed");
14310
+ hash.update(buffer);
14311
+ }
14312
+ if (hash.digest("hex") !== expected.sha256) throw new Error("iroh_native_hash_mismatch");
14313
+ const after = await lstat(path);
14314
+ if (after.isSymbolicLink() || after.ino !== opened.ino || after.dev !== opened.dev || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) throw new Error("iroh_native_file_changed");
14315
+ } finally {
14316
+ await file.close();
14335
14317
  }
14318
+ if (process.platform !== "win32" && platform !== "win32" && (before.mode & 73) === 0) throw new Error("iroh_native_not_executable");
14319
+ return path;
14336
14320
  }
14337
- function defaultFirewallProbe(platform = process.platform) {
14338
- return async (port) => {
14339
- if (platform !== "win32") return { state: "not-applicable" };
14340
- if (port === void 0) return { state: "unknown" };
14341
- const script = [
14342
- "$specs = @(@{ Name = 'DSH Mobile HTTPS'; Protocol = 'TCP' }, @{ Name = 'DSH Mobile Discovery'; Protocol = 'UDP' })",
14343
- "$ready = $true",
14344
- "$specs | ForEach-Object {",
14345
- " $spec = $_",
14346
- " $rule = Get-NetFirewallRule -DisplayName $spec.Name -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' } | Select-Object -First 1",
14347
- " if ($null -eq $rule) { $ready = $false; return }",
14348
- " $filters = @($rule | Get-NetFirewallPortFilter -ErrorAction SilentlyContinue)",
14349
- ` $matching = @($filters | Where-Object { $_.Protocol -eq $spec.Protocol -and ($_.LocalPort -eq 'Any' -or $_.LocalPort -eq '${String(port)}') })`,
14350
- " if ($matching.Count -eq 0) { $ready = $false }",
14351
- "}",
14352
- "if ($ready) { 'ready' } else { 'missing' }"
14353
- ].join("; ");
14354
- try {
14355
- return { state: (await execFileText("powershell.exe", [
14356
- "-NoProfile",
14357
- "-NonInteractive",
14358
- "-Command",
14359
- script
14360
- ], {
14361
- encoding: "utf8",
14362
- timeout: 3e3,
14363
- windowsHide: true
14364
- })).stdout.trim() === "ready" ? "ready" : "missing" };
14365
- } catch {
14366
- return { state: "unknown" };
14367
- }
14368
- };
14321
+ //#endregion
14322
+ //#region src/iroh-component.ts
14323
+ /** Managed view of the iroh transport host sidecar.
14324
+ *
14325
+ * Explicit installation verifies and promotes a pinned sidecar release into
14326
+ * private storage. A verified independent companion copy can be reused without
14327
+ * downloading. Enabling the channel alone never downloads a component.
14328
+ */
14329
+ const IROH_VERSION = "0.1.0-alpha.1";
14330
+ const IROH_RELEASE_PAGE = "https://github.com/saya-ch/dsh-mobile-iroh/releases";
14331
+ const IROH_COMPONENT_RELEASES = Object.freeze({
14332
+ "linux-x64": Object.freeze({
14333
+ version: IROH_VERSION,
14334
+ platform: "linux",
14335
+ arch: "x64",
14336
+ executableName: "dsh-mobile-iroh-host-linux-x64",
14337
+ downloadUrl: "https://unpkg.com/dsh-mobile-iroh@0.1.0-alpha.1/native/dsh-mobile-iroh-host-linux-x64",
14338
+ downloadBytes: 24451352,
14339
+ executableBytes: 24451352,
14340
+ downloadSha256: "be391dfba3f0f1323d4fe9989e56a0554ceaf74e00a44acdf4a3bfd163bdcab5",
14341
+ executableSha256: "be391dfba3f0f1323d4fe9989e56a0554ceaf74e00a44acdf4a3bfd163bdcab5"
14342
+ }),
14343
+ "win32-x64": Object.freeze({
14344
+ version: IROH_VERSION,
14345
+ platform: "win32",
14346
+ arch: "x64",
14347
+ executableName: "dsh-mobile-iroh-host-win32-x64.exe",
14348
+ downloadUrl: "https://unpkg.com/dsh-mobile-iroh@0.1.0-alpha.1/native/dsh-mobile-iroh-host-win32-x64.exe",
14349
+ downloadBytes: 14865920,
14350
+ executableBytes: 14865920,
14351
+ downloadSha256: "883987c636a379dc999a7f15fd1f9addbb7d28f430ed1f952ad5a610c37cc032",
14352
+ executableSha256: "883987c636a379dc999a7f15fd1f9addbb7d28f430ed1f952ad5a610c37cc032"
14353
+ })
14354
+ });
14355
+ async function digest$1(file) {
14356
+ return createHash("sha256").update(await readFile(file)).digest("hex");
14369
14357
  }
14370
- /** Reserve one six-second window per egress so the desktop's 15-second request can finish. */
14371
- function remoteDiagnosticTimeoutMs(_origin) {
14372
- return 6e3;
14358
+ function digestBytes(bytes) {
14359
+ return createHash("sha256").update(bytes).digest("hex");
14373
14360
  }
14374
- const TUNNEL_HEADER_LIMIT_BYTES = 65536;
14361
+ var IrohComponentManager = class {
14362
+ stateDirectory;
14363
+ platform;
14364
+ arch;
14365
+ locateCompanionRoots;
14366
+ resolveSidecar;
14367
+ artifact;
14368
+ fetchArtifact;
14369
+ componentRoot;
14370
+ componentStorage;
14371
+ executablePath;
14372
+ stagingRoot;
14373
+ cached;
14374
+ cachedExecutable;
14375
+ origin;
14376
+ queue = Promise.resolve();
14377
+ constructor(options) {
14378
+ if (typeof options.stateDirectory !== "string" || options.stateDirectory === "" || !isAbsolute(options.stateDirectory)) throw new Error("iroh component state directory invalid");
14379
+ this.stateDirectory = resolve(options.stateDirectory);
14380
+ this.platform = options.platform ?? process.platform;
14381
+ this.arch = options.arch ?? process.arch;
14382
+ const artifact = options.artifact ?? IROH_COMPONENT_RELEASES[`${this.platform}-${this.arch}`];
14383
+ if (artifact !== void 0 && (artifact.platform !== this.platform || artifact.arch !== this.arch || !/^https:\/\//u.test(artifact.downloadUrl) || !/^[a-f0-9]{64}$/u.test(artifact.downloadSha256) || !/^[a-f0-9]{64}$/u.test(artifact.executableSha256) || artifact.downloadBytes < 1 || artifact.executableBytes < 1 || !/^[a-z0-9.-]+$/u.test(artifact.executableName))) throw new Error("iroh_artifact_invalid");
14384
+ this.artifact = artifact;
14385
+ this.locateCompanionRoots = options.locateCompanionRoots ?? (() => []);
14386
+ this.resolveSidecar = options.resolveSidecar ?? resolveIrohSidecarAt;
14387
+ this.fetchArtifact = options.fetchArtifact ?? ((url, signal) => downloadPinnedArtifact({
14388
+ url,
14389
+ signal,
14390
+ expectedBytes: artifact?.downloadBytes ?? 0,
14391
+ errorPrefix: "iroh",
14392
+ redirectHosts: ["unpkg.com"]
14393
+ }));
14394
+ this.componentRoot = join(this.stateDirectory, "components", "iroh");
14395
+ this.componentStorage = join(this.componentRoot, artifact?.version ?? "0.1.0-alpha.1");
14396
+ this.executablePath = join(this.componentStorage, artifact?.executableName ?? (this.platform === "win32" ? "dsh-mobile-iroh-host-win32-x64.exe" : "dsh-mobile-iroh-host-linux-x64"));
14397
+ this.stagingRoot = join(this.stateDirectory, "staging", "iroh");
14398
+ this.cached = this.baseStatus();
14399
+ }
14400
+ /** Resolved, verified sidecar executable, or undefined when unavailable. */
14401
+ get executable() {
14402
+ return this.cachedExecutable;
14403
+ }
14404
+ /** Cached component state; refresh with {@link initialize} or {@link install}. */
14405
+ status() {
14406
+ return this.cached;
14407
+ }
14408
+ /** Verify the sidecar once at startup; safe to call before any status read. */
14409
+ async initialize() {
14410
+ await this.enqueue(() => this.refresh());
14411
+ }
14412
+ /**
14413
+ * Confirm a usable sidecar without downloading anything.
14414
+ *
14415
+ * Enabling the channel calls this: starting must never trigger a download
14416
+ * on its own. Only the explicit install button below may fetch bytes.
14417
+ */
14418
+ async ensureAvailable() {
14419
+ return this.enqueue(async () => {
14420
+ const current = await this.refresh();
14421
+ if (!(this.artifact !== void 0)) throw new Error("iroh_platform_unsupported");
14422
+ if (!current.installed) throw new Error("iroh_companion_missing");
14423
+ return current;
14424
+ });
14425
+ }
14426
+ /**
14427
+ * Ensure a verified sidecar in the plugin private directory.
14428
+ *
14429
+ * Prefers an already installed companion copy (no network), otherwise
14430
+ * downloads the pinned release and verifies size and SHA-256 before
14431
+ * promoting it. A companion that exists but fails verification is reported
14432
+ * instead of being silently replaced by a download.
14433
+ */
14434
+ async install() {
14435
+ return this.enqueue(async () => {
14436
+ const artifact = this.artifact;
14437
+ if (artifact === void 0) throw new Error("iroh_platform_unsupported");
14438
+ const stateValid = await this.checkStateExecutable(artifact).catch(() => void 0);
14439
+ if (stateValid !== void 0) {
14440
+ this.publishInstalled(artifact, this.executablePath, stateValid, "state");
14441
+ return this.cached;
14442
+ }
14443
+ const companion = await this.findCompanionExecutable().catch(() => void 0);
14444
+ if (companion !== void 0) {
14445
+ if (companion.valid) {
14446
+ await this.publishBytes(artifact, await readFile(companion.executable));
14447
+ return this.cached;
14448
+ }
14449
+ this.origin = void 0;
14450
+ this.cachedExecutable = void 0;
14451
+ this.cached = Object.freeze({
14452
+ ...this.baseStatus(),
14453
+ installed: false,
14454
+ errorCode: "irohSidecarInvalid"
14455
+ });
14456
+ throw new Error("iroh_sidecar_invalid");
14457
+ }
14458
+ let bytes;
14459
+ try {
14460
+ bytes = await this.downloadArtifact(artifact);
14461
+ } catch (error) {
14462
+ if (error instanceof Error && error.message.startsWith("iroh_")) throw error;
14463
+ throw new Error("iroh_download_failed", { cause: error });
14464
+ }
14465
+ await this.publishBytes(artifact, bytes);
14466
+ return this.cached;
14467
+ });
14468
+ }
14469
+ enqueue(operation) {
14470
+ const task = this.queue.then(operation, operation);
14471
+ this.queue = task.then(() => void 0, () => void 0);
14472
+ return task;
14473
+ }
14474
+ baseStatus() {
14475
+ const supported = this.artifact !== void 0;
14476
+ return Object.freeze({
14477
+ supported,
14478
+ installed: false,
14479
+ version: "",
14480
+ downloadBytes: this.artifact?.downloadBytes ?? 0,
14481
+ installedBytes: 0,
14482
+ sourceUrl: this.artifact?.downloadUrl ?? "https://www.npmjs.com/package/dsh-mobile-iroh",
14483
+ releasePage: IROH_RELEASE_PAGE,
14484
+ storagePath: this.stateDirectory,
14485
+ ...supported ? {} : { errorCode: "irohPlatformUnsupported" }
14486
+ });
14487
+ }
14488
+ publishInstalled(artifact, executable, version, origin) {
14489
+ this.origin = origin;
14490
+ this.cachedExecutable = executable;
14491
+ this.cached = Object.freeze({
14492
+ supported: true,
14493
+ installed: true,
14494
+ version,
14495
+ downloadBytes: artifact.downloadBytes,
14496
+ installedBytes: artifact.executableBytes,
14497
+ sourceUrl: artifact.downloadUrl,
14498
+ releasePage: IROH_RELEASE_PAGE,
14499
+ storagePath: executable
14500
+ });
14501
+ }
14502
+ async checkStateExecutable(artifact) {
14503
+ const entry = await lstat(this.executablePath);
14504
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size !== artifact.executableBytes) throw new Error("iroh_sidecar_invalid");
14505
+ if (this.platform !== "win32" && (entry.mode & 73) === 0) throw new Error("iroh_sidecar_invalid");
14506
+ if (await digest$1(this.executablePath) !== artifact.executableSha256) throw new Error("iroh_sidecar_invalid");
14507
+ return artifact.version;
14508
+ }
14509
+ async findCompanionExecutable() {
14510
+ for (const root of this.locateCompanionRoots()) try {
14511
+ const executable = await this.resolveSidecar(root, this.platform, this.arch);
14512
+ await lstat(executable);
14513
+ let version = "";
14514
+ try {
14515
+ const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
14516
+ if (pkg.name === "dsh-mobile-iroh" && typeof pkg.version === "string") version = pkg.version;
14517
+ } catch {}
14518
+ return {
14519
+ executable,
14520
+ version,
14521
+ valid: true
14522
+ };
14523
+ } catch (error) {
14524
+ if (error instanceof Error && error.message === "iroh_native_platform_unsupported") continue;
14525
+ if (error instanceof Error && (error.message === "iroh_native_hash_mismatch" || error.message === "iroh_native_file_changed" || error.message === "iroh_native_file_invalid" || error.message === "iroh_native_manifest_invalid" || error.message === "iroh_native_not_executable")) return {
14526
+ executable: "",
14527
+ version: "",
14528
+ valid: false
14529
+ };
14530
+ }
14531
+ }
14532
+ async downloadArtifact(artifact) {
14533
+ const controller = new AbortController();
14534
+ const timeout = setTimeout(() => {
14535
+ controller.abort(/* @__PURE__ */ new Error("iroh_download_timeout"));
14536
+ }, 6e5);
14537
+ timeout.unref();
14538
+ try {
14539
+ const bytes = await this.fetchArtifact(artifact.downloadUrl, controller.signal);
14540
+ if (bytes.byteLength !== artifact.downloadBytes) throw new Error("iroh_download_size_mismatch");
14541
+ if (digestBytes(bytes) !== artifact.downloadSha256) throw new Error("iroh_download_hash_mismatch");
14542
+ return bytes;
14543
+ } finally {
14544
+ clearTimeout(timeout);
14545
+ }
14546
+ }
14547
+ async publishBytes(artifact, bytes) {
14548
+ if (bytes.byteLength !== artifact.executableBytes || digestBytes(bytes) !== artifact.executableSha256) throw new Error("iroh_download_hash_mismatch");
14549
+ await ensureIrohDirectory(this.stateDirectory, this.stagingRoot);
14550
+ await ensureIrohDirectory(this.stateDirectory, this.componentRoot);
14551
+ const staging = await mkdtemp(join(this.stagingRoot, "install-"));
14552
+ const staged = join(staging, artifact.executableName);
14553
+ const backup = join(this.componentRoot, `.previous-${Date.now().toString(36)}`);
14554
+ let movedPrevious = false;
14555
+ try {
14556
+ await writeFile(staged, bytes, {
14557
+ flag: "wx",
14558
+ mode: 448
14559
+ });
14560
+ await chmod(staged, 448);
14561
+ if (digestBytes(await readFile(staged)) !== artifact.executableSha256) throw new Error("iroh_download_hash_mismatch");
14562
+ await ensureIrohDirectory(this.stateDirectory, this.componentStorage);
14563
+ try {
14564
+ await lstat(this.executablePath);
14565
+ await renameIrohPath(this.stateDirectory, this.componentStorage, backup);
14566
+ movedPrevious = true;
14567
+ await ensureIrohDirectory(this.stateDirectory, this.componentStorage);
14568
+ } catch (error) {
14569
+ if (error.code !== "ENOENT") throw error;
14570
+ }
14571
+ try {
14572
+ await rename(staged, this.executablePath);
14573
+ } catch (error) {
14574
+ if (movedPrevious) try {
14575
+ await renameIrohPath(this.stateDirectory, backup, this.componentStorage);
14576
+ movedPrevious = false;
14577
+ } catch {}
14578
+ throw error;
14579
+ }
14580
+ this.publishInstalled(artifact, this.executablePath, artifact.version, "state");
14581
+ } finally {
14582
+ const leftovers = movedPrevious ? [staging, backup] : [staging];
14583
+ const failures = [];
14584
+ for (const path of leftovers) try {
14585
+ await removeIrohTree(this.stateDirectory, path);
14586
+ } catch (error) {
14587
+ failures.push(error);
14588
+ }
14589
+ if (failures.length > 0) throw new AggregateError(failures, "iroh_component_cleanup_failed");
14590
+ }
14591
+ }
14592
+ /** Remove the installed sidecar component from the plugin private directory. */
14593
+ async purge() {
14594
+ return this.enqueue(async () => {
14595
+ const failures = [];
14596
+ try {
14597
+ await removeIrohTree(this.stateDirectory, this.componentStorage);
14598
+ } catch (error) {
14599
+ if (error.code !== "ENOENT") failures.push(error);
14600
+ }
14601
+ this.cachedExecutable = void 0;
14602
+ this.origin = void 0;
14603
+ this.cached = this.baseStatus();
14604
+ if (failures.length > 0) throw new AggregateError(failures, "iroh_component_cleanup_failed");
14605
+ return this.cached;
14606
+ });
14607
+ }
14608
+ async refresh() {
14609
+ if (this.artifact === void 0) {
14610
+ this.origin = void 0;
14611
+ this.cachedExecutable = void 0;
14612
+ this.cached = this.baseStatus();
14613
+ return this.cached;
14614
+ }
14615
+ const stateVersion = await this.checkStateExecutable(this.artifact).catch(() => void 0);
14616
+ if (stateVersion !== void 0) {
14617
+ this.publishInstalled(this.artifact, this.executablePath, stateVersion, "state");
14618
+ return this.cached;
14619
+ }
14620
+ const companion = await this.findCompanionExecutable().catch(() => void 0);
14621
+ if (companion !== void 0 && companion.valid) {
14622
+ this.publishInstalled(this.artifact, companion.executable, companion.version, "companion");
14623
+ return this.cached;
14624
+ }
14625
+ const stateExists = await lstat(this.executablePath).then((entry) => entry.isFile() && !entry.isSymbolicLink()).catch(() => false);
14626
+ this.origin = void 0;
14627
+ this.cachedExecutable = void 0;
14628
+ this.cached = Object.freeze({
14629
+ ...this.baseStatus(),
14630
+ installed: false,
14631
+ ...stateExists || companion !== void 0 && !companion.valid ? { errorCode: "irohSidecarInvalid" } : {}
14632
+ });
14633
+ return this.cached;
14634
+ }
14635
+ };
14636
+ //#endregion
14637
+ //#region src/iroh-sidecar.ts
14638
+ /** Versioned parent-to-sidecar protocol for the iroh transport host.
14639
+ *
14640
+ * Ported from the independent `dsh-mobile-iroh` companion's sidecar
14641
+ * controller. Only the pure command/event codec lives here; child-process
14642
+ * ownership stays with the provider controller so lifecycle, timeouts and
14643
+ * test doubles follow this repository's existing controller conventions.
14644
+ * Every message is versioned, bounded and rejected closed on unknown fields.
14645
+ */
14646
+ const MAX_LINE_BYTES$1 = 16384;
14647
+ const ENDPOINT_ID_PATTERN$1 = /^[0-9a-f]{64}$/u;
14648
+ function isPlainText(value, maximum) {
14649
+ return typeof value === "string" && value.length > 0 && value.length <= maximum && !/[\u0000-\u001f\u007f]/u.test(value);
14650
+ }
14651
+ function hasExactFields(value, fields) {
14652
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
14653
+ return Object.keys(value).sort().join(",") === [...fields].sort().join(",");
14654
+ }
14655
+ function stripBrackets(hostname) {
14656
+ return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
14657
+ }
14658
+ function isEndpointId(value) {
14659
+ return typeof value === "string" && ENDPOINT_ID_PATTERN$1.test(value);
14660
+ }
14661
+ /** Validate the loopback socket destination; TLS remains end to end. */
14662
+ function validateIrohProxyTarget(value) {
14663
+ const target = new URL(value);
14664
+ if (!["http:", "https:"].includes(target.protocol) || !["127.0.0.1", "::1"].includes(stripBrackets(target.hostname)) || target.username !== "" || target.password !== "" || target.pathname !== "/" || target.search !== "" || target.hash !== "") throw new Error("sidecar proxy target must be an explicit HTTP(S) loopback origin");
14665
+ return target.origin;
14666
+ }
14667
+ /** Strict parent-to-sidecar v1 command encoder. */
14668
+ function encodeIrohCommand(message) {
14669
+ if (message?.version !== 1) throw new Error("invalid sidecar command version");
14670
+ if (!hasExactFields(message, message.type === "device-forget" ? [
14671
+ "version",
14672
+ "type",
14673
+ "id"
14674
+ ] : ["version", "type"]) || ![
14675
+ "pairing-begin",
14676
+ "device-list",
14677
+ "device-forget",
14678
+ "shutdown"
14679
+ ].includes(message.type) || message.type === "device-forget" && !isEndpointId(message.id)) throw new Error("invalid sidecar command");
14680
+ const line = JSON.stringify(message);
14681
+ if (Buffer.byteLength(line) > MAX_LINE_BYTES$1) throw new Error("sidecar command too large");
14682
+ return `${line}\n`;
14683
+ }
14684
+ function isDirectAddress(value) {
14685
+ if (!isPlainText(value, 128)) return false;
14686
+ try {
14687
+ const url = new URL(`http://${value}`);
14688
+ return isIP(stripBrackets(url.hostname)) !== 0 && url.port !== "" && url.pathname === "/" && url.username === "" && url.password === "" && url.search === "" && url.hash === "";
14689
+ } catch {
14690
+ return false;
14691
+ }
14692
+ }
14693
+ /** Decode exact first-party stdout events; no extension fields grant authority. */
14694
+ function decodeIrohEvent(line) {
14695
+ if (typeof line !== "string" || Buffer.byteLength(line) > MAX_LINE_BYTES$1) throw new Error("sidecar event too large");
14696
+ let event;
14697
+ try {
14698
+ event = JSON.parse(line);
14699
+ } catch {
14700
+ throw new Error("invalid sidecar event JSON");
14701
+ }
14702
+ if (event?.version !== 1 || typeof event.type !== "string") throw new Error("invalid sidecar event version");
14703
+ const base = ["version", "type"];
14704
+ const fields = {
14705
+ ready: [
14706
+ "endpoint_id",
14707
+ "direct_addresses",
14708
+ "local_only"
14709
+ ],
14710
+ pairing: ["code", "expires_in_sec"],
14711
+ "pairing-closed": ["reason"],
14712
+ "pairing-done": ["peer", "name"],
14713
+ "peer-connected": ["peer", "name"],
14714
+ "peer-path": [
14715
+ "peer",
14716
+ "kind",
14717
+ "remote"
14718
+ ],
14719
+ "peer-disconnected": ["peer"],
14720
+ "proxy-opened": ["peer"],
14721
+ devices: ["devices"],
14722
+ error: ["code", "message"],
14723
+ stopped: []
14724
+ }[event.type];
14725
+ if (fields === void 0 || !hasExactFields(event, [...base, ...fields])) throw new Error("invalid sidecar event fields");
14726
+ if ("peer" in event && !isEndpointId(event.peer)) throw new Error("invalid sidecar event peer");
14727
+ if ("name" in event && !isPlainText(event.name, 128)) throw new Error("invalid sidecar event name");
14728
+ switch (event.type) {
14729
+ case "ready": {
14730
+ const addresses = event.direct_addresses;
14731
+ if (!isEndpointId(event.endpoint_id) || typeof event.local_only !== "boolean" || !Array.isArray(addresses) || addresses.length > 64 || !addresses.every(isDirectAddress)) throw new Error("invalid sidecar ready event");
14732
+ return Object.freeze({
14733
+ version: 1,
14734
+ type: "ready",
14735
+ endpoint_id: event.endpoint_id,
14736
+ direct_addresses: Object.freeze([...addresses]),
14737
+ local_only: event.local_only
14738
+ });
14739
+ }
14740
+ case "pairing":
14741
+ if (!/^[0-9]{6}$/u.test(String(event.code)) || !Number.isSafeInteger(event.expires_in_sec) || event.expires_in_sec < 1 || event.expires_in_sec > 3600) throw new Error("invalid sidecar pairing event");
14742
+ break;
14743
+ case "pairing-closed":
14744
+ if (!isPlainText(event.reason, 128)) throw new Error("invalid sidecar reason");
14745
+ break;
14746
+ case "peer-path":
14747
+ if (event.kind !== "direct" && event.kind !== "relay" || !isPlainText(event.remote, 512)) throw new Error("invalid sidecar path event");
14748
+ break;
14749
+ case "devices": {
14750
+ const devices = event.devices;
14751
+ if (!Array.isArray(devices) || devices.length > 256 || !devices.every((device) => hasExactFields(device, [
14752
+ "id",
14753
+ "name",
14754
+ "paired_at",
14755
+ "online"
14756
+ ]) && isEndpointId(device.id) && isPlainText(device.name, 128) && isPlainText(device.paired_at, 64) && typeof device.online === "boolean")) throw new Error("invalid sidecar device event");
14757
+ return Object.freeze({
14758
+ version: 1,
14759
+ type: "devices",
14760
+ devices: Object.freeze(devices.map((device) => Object.freeze({ ...device })))
14761
+ });
14762
+ }
14763
+ case "error": if (!isPlainText(event.code, 128) || !isPlainText(event.message, 1024)) throw new Error("invalid sidecar error event");
14764
+ }
14765
+ return Object.freeze(event);
14766
+ }
14767
+ //#endregion
14768
+ //#region src/iroh-origin.ts
14769
+ /** Identity-derived browser origins for the raw TLS-over-iroh carrier.
14770
+ *
14771
+ * Ported from the independent `dsh-mobile-iroh` companion's origin contract.
14772
+ * The local port is a deployment choice; changing it changes browser storage
14773
+ * identity, so the selected port is part of every persisted record.
14774
+ */
14775
+ const DEFAULT_IROH_LOCAL_PORT = 25443;
14776
+ const ENDPOINT_ID_PATTERN = /^[a-f0-9]{64}$/u;
14777
+ function checkEndpointId(endpointId) {
14778
+ if (typeof endpointId !== "string" || !ENDPOINT_ID_PATTERN.test(endpointId)) throw new Error("iroh endpoint id must be a lowercase 32-byte public key");
14779
+ }
14780
+ function checkLocalPort(localPort) {
14781
+ if (!Number.isSafeInteger(localPort) || localPort < 1024 || localPort > 65535) throw new Error("iroh local port must be from 1024 through 65535");
14782
+ }
14783
+ /** Keep the full endpoint key in DNS-safe labels rather than a colliding port hash. */
14784
+ function irohHostname(endpointId) {
14785
+ checkEndpointId(endpointId);
14786
+ return `${endpointId.slice(0, 32)}.${endpointId.slice(32)}.iroh.localhost`;
14787
+ }
14788
+ /** HTTPS origin the phone's local transport listener serves for one endpoint. */
14789
+ function irohOrigin(endpointId, localPort = DEFAULT_IROH_LOCAL_PORT) {
14790
+ checkLocalPort(localPort);
14791
+ return `https://${irohHostname(endpointId)}:${localPort}`;
14792
+ }
14793
+ /** Reject an Origin that does not belong to the selected cryptographic host identity. */
14794
+ function validateIrohOrigin(value, endpointId, localPort = DEFAULT_IROH_LOCAL_PORT) {
14795
+ const expected = irohOrigin(endpointId, localPort);
14796
+ if (value !== expected) throw new Error("iroh origin does not match the selected endpoint and local port");
14797
+ return expected;
14798
+ }
14799
+ //#endregion
14800
+ //#region src/iroh-pairing.ts
14801
+ /** Short-lived iroh pairing descriptors for QR transport.
14802
+ *
14803
+ * Byte-compatible with the independent `dsh-mobile-iroh` companion's
14804
+ * descriptor v1: the same field order, byte budget, CA checks and
14805
+ * duplicate-key rejection, so a future Phase 2 phone accepts QR codes from
14806
+ * either side. Error messages never contain the descriptor, pairing code,
14807
+ * or gateway token.
14808
+ */
14809
+ const PAIRING_DESCRIPTOR_VERSION = 1;
14810
+ const PAIRING_DESCRIPTOR_TYPE = "dsh-mobile-iroh-pairing";
14811
+ const MAX_PAIRING_DESCRIPTOR_BYTES = 2200;
14812
+ const MAX_PAIRING_LIFETIME_MS = 6e5;
14813
+ const FIELDS = Object.freeze([
14814
+ "version",
14815
+ "type",
14816
+ "endpointId",
14817
+ "origin",
14818
+ "localPort",
14819
+ "irohCode",
14820
+ "gatewayToken",
14821
+ "caCertificate",
14822
+ "instanceId",
14823
+ "expiresAt"
14824
+ ]);
14825
+ const HEX_ID = /^[a-f0-9]{64}$/u;
14826
+ const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
14827
+ /** Error messages never contain the descriptor, pairing code, or gateway token. */
14828
+ var PairingDescriptorError = class extends Error {
14829
+ code;
14830
+ constructor(code) {
14831
+ super(code);
14832
+ this.name = "PairingDescriptorError";
14833
+ this.code = code;
14834
+ }
14835
+ };
14836
+ function reject(code) {
14837
+ throw new PairingDescriptorError(code);
14838
+ }
14839
+ function validateCertificate(value, instanceId, now, expiresAt) {
14840
+ if (typeof value !== "string" || value.length === 0 || value.length > Math.ceil(512) * 4 || !BASE64.test(value)) reject("invalid_pairing_ca_encoding");
14841
+ const encoded = value;
14842
+ const der = Buffer.from(encoded, "base64");
14843
+ if (der.length === 0 || der.length > 1536 || der.toString("base64") !== encoded) reject("invalid_pairing_ca_encoding");
14844
+ let ca;
14845
+ try {
14846
+ ca = new X509Certificate(der);
14847
+ } catch {
14848
+ reject("invalid_pairing_ca_certificate");
14849
+ }
14850
+ if (!ca.raw.equals(der)) reject("invalid_pairing_ca_certificate");
14851
+ try {
14852
+ if (!ca.ca || ca.subject !== ca.issuer || !ca.verify(ca.publicKey)) reject("pairing_ca_must_be_self_signed");
14853
+ } catch (error) {
14854
+ if (error instanceof PairingDescriptorError) throw error;
14855
+ reject("pairing_ca_must_be_self_signed");
14856
+ }
14857
+ const notBefore = Date.parse(ca.validFrom);
14858
+ const notAfter = Date.parse(ca.validTo);
14859
+ if (!Number.isFinite(notBefore) || !Number.isFinite(notAfter) || now < notBefore || expiresAt > notAfter) reject("pairing_ca_outside_validity");
14860
+ if (createHash("sha256").update(der).digest("hex") !== instanceId) reject("pairing_ca_fingerprint_mismatch");
14861
+ }
14862
+ /**
14863
+ * Validate QR transport material, not a DSH Session or persistent credential.
14864
+ * localPort is mandatory on the wire and must match the identity-derived HTTPS
14865
+ * Origin. The QR is sensitive: only the public CA may be retained or logged on
14866
+ * its own; neither pairing secret may be copied into logs or device-list text.
14867
+ *
14868
+ * Scanning this descriptor is the user's out-of-band trust action. A self-signed
14869
+ * CA does not independently authenticate a QR received from an untrusted party.
14870
+ */
14871
+ function validatePairingDescriptor(value, options = {}) {
14872
+ const now = options.now ?? Date.now();
14873
+ if (!Number.isSafeInteger(now) || now < 0) reject("invalid_pairing_clock");
14874
+ if (value === null || typeof value !== "object" || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value)) || Object.keys(value).sort().join(",") !== [...FIELDS].sort().join(",")) reject("invalid_pairing_fields");
14875
+ const record = value;
14876
+ if (record.version !== 1 || record.type !== "dsh-mobile-iroh-pairing") reject("unsupported_pairing_descriptor");
14877
+ if (typeof record.endpointId !== "string" || !HEX_ID.test(record.endpointId)) reject("invalid_pairing_endpoint");
14878
+ if (!Number.isSafeInteger(record.localPort) || record.localPort < 1024 || record.localPort > 65535) reject("invalid_pairing_local_port");
14879
+ if (record.origin !== irohOrigin(record.endpointId, record.localPort)) reject("invalid_pairing_origin");
14880
+ if (typeof record.irohCode !== "string" || !/^[0-9]{6}$/u.test(record.irohCode)) reject("invalid_iroh_pairing_code");
14881
+ if (typeof record.gatewayToken !== "string" || !/^[A-Za-z0-9_-]{43}$/u.test(record.gatewayToken) || Buffer.from(record.gatewayToken, "base64url").toString("base64url") !== record.gatewayToken) reject("invalid_gateway_pairing_token");
14882
+ if (typeof record.instanceId !== "string" || !HEX_ID.test(record.instanceId)) reject("invalid_pairing_instance_id");
14883
+ if (!Number.isSafeInteger(record.expiresAt) || record.expiresAt <= now || record.expiresAt - now > 6e5) reject("invalid_pairing_expiry");
14884
+ validateCertificate(record.caCertificate, record.instanceId, now, record.expiresAt);
14885
+ const descriptor = Object.fromEntries(FIELDS.map((field) => [field, record[field]]));
14886
+ if (Buffer.byteLength(JSON.stringify(descriptor), "utf8") > 2200) reject("pairing_descriptor_too_large");
14887
+ return Object.freeze(descriptor);
14888
+ }
14889
+ /** Encode a bounded canonical-field-order QR payload without implicit defaults. */
14890
+ function encodePairingDescriptor(value, options = {}) {
14891
+ return JSON.stringify(validatePairingDescriptor(value, options));
14892
+ }
14893
+ /** Decode untrusted QR text, validating its expiry and public CA before use. */
14894
+ function decodePairingDescriptor(encoded, options = {}) {
14895
+ if (typeof encoded !== "string" || Buffer.byteLength(encoded, "utf8") > 2200) reject("pairing_descriptor_too_large");
14896
+ let value;
14897
+ try {
14898
+ value = JSON.parse(encoded);
14899
+ } catch {
14900
+ reject("invalid_pairing_json");
14901
+ }
14902
+ const descriptor = validatePairingDescriptor(value, options);
14903
+ if ((encoded.match(/"(?:[^"\\]|\\[\s\S])*"\s*:/gu) ?? []).length !== FIELDS.length) reject("duplicate_pairing_fields");
14904
+ return descriptor;
14905
+ }
14906
+ /** Phone-side listener port advertised in QR codes minted by this computer. */
14907
+ const PAIRING_DESCRIPTOR_LOCAL_PORT = DEFAULT_IROH_LOCAL_PORT;
14908
+ //#endregion
14909
+ //#region src/iroh.ts
14910
+ /** Experimental iroh remote transport as a first-class remote provider.
14911
+ *
14912
+ * The sidecar is distributed by this plugin itself: an explicit install
14913
+ * downloads a pinned release into the plugin private directory (an installed
14914
+ * companion copy is reused without downloading during the transition). The
14915
+ * controller owns exactly one sidecar child process plus one dedicated
14916
+ * loopback gateway, and status flows through the same coordinator, control
14917
+ * routes and task-event fan-out as every other remote provider. Enabling the
14918
+ * channel never downloads anything; only the install button may fetch bytes.
14919
+ * `ready` therefore means the sidecar is connected and the gateway serves
14920
+ * its loopback socket.
14921
+ */
14922
+ const READY_TIMEOUT_MS = 45e3;
14923
+ const IDENTITY_TIMEOUT_MS = 15e3;
14924
+ const IDENTITY_METADATA_MAX_BYTES = 8192;
14925
+ const PAIRING_TIMEOUT_MS = 15e3;
14926
+ const MAX_LINE_BYTES = 16384;
14927
+ /** Gateway pairing windows default to five minutes; the descriptor caps at ten. */
14928
+ const MAX_PAIRING_WINDOW_MS = 3e5;
14929
+ const MIN_PAIRING_WINDOW_MS = 1e4;
14930
+ function publicStatus$2(status) {
14931
+ return Object.freeze({ ...status });
14932
+ }
14933
+ /**
14934
+ * Prepare the sidecar's private identity/TLS state and resolve its endpoint.
14935
+ *
14936
+ * Failed termination is reported separately; the controller retains the child
14937
+ * until close so shutdown/reconnect can retry cleanup before starting a successor.
14938
+ * @param executable - verified sidecar path.
14939
+ * @param dataDir - private directory owning the endpoint identity.
14940
+ * @param options - child ownership callback and optional launcher/deadline overrides.
14941
+ * @returns the validated endpoint identity after the probe closes.
14942
+ */
14943
+ async function defaultResolveIdentity(executable, dataDir, options = {}) {
14944
+ const args = [
14945
+ "--lang",
14946
+ "en",
14947
+ "tls-prepare",
14948
+ "--data-dir",
14949
+ dataDir
14950
+ ];
14951
+ const child = options.launch === void 0 ? spawn(executable, args, {
14952
+ stdio: [
14953
+ "ignore",
14954
+ "pipe",
14955
+ "pipe"
14956
+ ],
14957
+ windowsHide: true
14958
+ }) : options.launch(executable, args);
14959
+ options.onSpawn?.(child);
14960
+ let output = Buffer.alloc(0);
14961
+ let settled = false;
14962
+ return await new Promise((resolve, reject) => {
14963
+ const fail = (code) => {
14964
+ if (settled) return;
14965
+ settled = true;
14966
+ clearTimeout(deadline);
14967
+ terminateRemoteProcess(child).then(() => {
14968
+ reject(new Error(code));
14969
+ }, () => {
14970
+ reject(new AggregateError([new Error(code), /* @__PURE__ */ new Error("iroh_probe_stop_failed")], "iroh_identity_cleanup_failed"));
14971
+ });
14972
+ };
14973
+ const deadline = setTimeout(() => {
14974
+ fail("iroh_identity_timeout");
14975
+ }, options.timeoutMs ?? IDENTITY_TIMEOUT_MS);
14976
+ deadline.unref?.();
14977
+ child.stdout.on("data", (chunk) => {
14978
+ if (settled) return;
14979
+ if (output.length + chunk.length > IDENTITY_METADATA_MAX_BYTES) {
14980
+ fail("iroh_identity_output_too_large");
14981
+ return;
14982
+ }
14983
+ output = Buffer.concat([output, chunk]);
14984
+ });
14985
+ child.stderr?.resume();
14986
+ child.once("error", () => {
14987
+ fail("iroh_identity_failed");
14988
+ });
14989
+ child.once("close", (code) => {
14990
+ clearTimeout(deadline);
14991
+ if (settled) return;
14992
+ settled = true;
14993
+ let metadata;
14994
+ try {
14995
+ metadata = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(output));
14996
+ } catch {
14997
+ reject(/* @__PURE__ */ new Error("iroh_identity_invalid"));
14998
+ return;
14999
+ }
15000
+ if (code !== 0 || typeof metadata !== "object" || metadata === null || !("version" in metadata) || metadata.version !== 1 || !("endpointId" in metadata) || typeof metadata.endpointId !== "string" || !/^[0-9a-f]{64}$/u.test(metadata.endpointId)) reject(/* @__PURE__ */ new Error("iroh_identity_invalid"));
15001
+ else resolve(metadata.endpointId);
15002
+ });
15003
+ });
15004
+ }
15005
+ var IrohController = class {
15006
+ options;
15007
+ enabled = false;
15008
+ initialized = false;
15009
+ disposed = false;
15010
+ generation = 0;
15011
+ queue = Promise.resolve();
15012
+ latest = publicStatus$2({
15013
+ enabled: false,
15014
+ state: "off"
15015
+ });
15016
+ gatewayValue;
15017
+ child;
15018
+ identityChild;
15019
+ closeTask;
15020
+ endpointId;
15021
+ constructor(options) {
15022
+ this.options = options;
15023
+ if (typeof options.dataDirectory !== "string" || options.dataDirectory === "") throw new Error("iroh data directory invalid");
15024
+ if (typeof options.createGateway !== "function") throw new Error("iroh gateway factory invalid");
15025
+ }
15026
+ async initialize(initialState) {
15027
+ await this.enqueue(async () => {
15028
+ const state = initialState ?? await this.options.store.load();
15029
+ if (this.disposed) return;
15030
+ this.enabled = state.enabled;
15031
+ this.initialized = true;
15032
+ if (this.enabled) await this.start();
15033
+ else this.publish({
15034
+ enabled: false,
15035
+ state: "off"
15036
+ });
15037
+ });
15038
+ }
15039
+ /** Return the active iroh-backed DSH gateway. */
15040
+ gateway() {
15041
+ return this.gatewayValue;
15042
+ }
15043
+ /** Return state safe for the desktop control UI. */
15044
+ status() {
15045
+ return this.latest;
15046
+ }
15047
+ async setEnabled(enabled) {
15048
+ if (!this.initialized || this.disposed) throw new Error("iroh controller is unavailable");
15049
+ await this.enqueue(async () => {
15050
+ if (this.enabled === enabled && (enabled ? this.latest.state === "ready" : this.child === void 0 && this.identityChild === void 0 && this.gatewayValue === void 0)) return;
15051
+ await this.options.store.save({
15052
+ version: 1,
15053
+ enabled
15054
+ });
15055
+ this.enabled = enabled;
15056
+ if (!enabled) await this.stop();
15057
+ if (enabled) await this.start();
15058
+ else this.publish({
15059
+ enabled: false,
15060
+ state: "off"
15061
+ });
15062
+ });
15063
+ return this.status();
15064
+ }
15065
+ /** Restart iroh while retaining its sidecar identity and devices. */
15066
+ async reconnect() {
15067
+ if (!this.initialized || this.disposed) throw new Error("iroh controller is unavailable");
15068
+ await this.enqueue(async () => {
15069
+ if (!this.enabled) {
15070
+ await this.options.store.save({
15071
+ version: 1,
15072
+ enabled: true
15073
+ });
15074
+ this.enabled = true;
15075
+ }
15076
+ await this.stop();
15077
+ await this.start();
15078
+ });
15079
+ return this.status();
15080
+ }
15081
+ /** Disable iroh without deleting its sidecar identity or companion files. */
15082
+ async reset() {
15083
+ if (!this.initialized || this.disposed) throw new Error("iroh controller is unavailable");
15084
+ await this.enqueue(async () => {
15085
+ await this.options.store.save({
15086
+ version: 1,
15087
+ enabled: false
15088
+ });
15089
+ this.enabled = false;
15090
+ await this.stop();
15091
+ this.publish({
15092
+ enabled: false,
15093
+ state: "off"
15094
+ });
15095
+ });
15096
+ return this.status();
15097
+ }
15098
+ /**
15099
+ * Mint one short-lived pairing descriptor for a QR code.
15100
+ *
15101
+ * Requires a live channel: the sidecar issues its six-digit code while the
15102
+ * gateway opens a one-time pairing window, and the descriptor binds both to
15103
+ * the pinned CA. Opening again replaces both windows without returning the
15104
+ * replaced secrets. The Phase 2 phone redeems the gateway token through the
15105
+ * ordinary pair endpoint over the iroh transport.
15106
+ */
15107
+ async beginPairing() {
15108
+ if (!this.initialized || this.disposed) throw new Error("iroh controller is unavailable");
15109
+ return this.enqueue(async () => {
15110
+ const generation = this.generation;
15111
+ const child = this.child;
15112
+ const gateway = this.gatewayValue;
15113
+ const endpointId = this.endpointId;
15114
+ if (!this.enabled || child === void 0 || gateway === void 0 || endpointId === void 0) throw new Error("iroh_not_ready");
15115
+ let buffered;
15116
+ do
15117
+ buffered = child.stdout.read();
15118
+ while (buffered !== null);
15119
+ try {
15120
+ child.stdin.write(encodeIrohCommand({
15121
+ version: 1,
15122
+ type: "pairing-begin"
15123
+ }));
15124
+ } catch {
15125
+ throw new Error("iroh_not_ready");
15126
+ }
15127
+ const pairing = await this.waitForPairingCode(child, this.options.pairingTimeoutMs ?? PAIRING_TIMEOUT_MS);
15128
+ if (generation !== this.generation || !this.enabled || this.child !== child || this.gatewayValue !== gateway) throw new Error("iroh_not_ready");
15129
+ if (pairing === void 0) throw new Error("iroh_pairing_failed");
15130
+ const ttlMs = Math.min(Math.max(pairing.expiresInSec * 1e3, MIN_PAIRING_WINDOW_MS), MAX_PAIRING_WINDOW_MS);
15131
+ const opened = await gateway.access.openPairing(ttlMs).catch((error) => {
15132
+ throw new Error("iroh_pairing_failed", { cause: error });
15133
+ });
15134
+ const expiresAt = Math.min(Date.now() + pairing.expiresInSec * 1e3, opened.expiresAt);
15135
+ if (expiresAt - Date.now() > 6e5) throw new Error("iroh_pairing_failed");
15136
+ const resolvePairingCa = this.options.resolvePairingCa;
15137
+ if (resolvePairingCa === void 0) throw new Error("iroh_pairing_failed");
15138
+ const ca = await resolvePairingCa().catch((error) => {
15139
+ throw new Error("iroh_pairing_failed", { cause: error });
15140
+ });
15141
+ const encoded = encodePairingDescriptor({
15142
+ version: 1,
15143
+ type: "dsh-mobile-iroh-pairing",
15144
+ endpointId,
15145
+ origin: irohOrigin(endpointId, DEFAULT_IROH_LOCAL_PORT),
15146
+ localPort: DEFAULT_IROH_LOCAL_PORT,
15147
+ irohCode: pairing.code,
15148
+ gatewayToken: opened.token,
15149
+ caCertificate: ca.der.toString("base64"),
15150
+ instanceId: ca.fingerprint,
15151
+ expiresAt
15152
+ });
15153
+ return {
15154
+ descriptor: JSON.parse(encoded),
15155
+ encoded,
15156
+ code: pairing.code,
15157
+ expiresAt
15158
+ };
15159
+ });
15160
+ }
15161
+ /** Stop all iroh resources without changing the remembered switch. */
15162
+ async close() {
15163
+ if (this.closeTask !== void 0) return this.closeTask;
15164
+ this.disposed = true;
15165
+ this.generation += 1;
15166
+ const close = this.queue.then(() => this.stop(), () => this.stop());
15167
+ this.closeTask = close;
15168
+ try {
15169
+ await close;
15170
+ } catch (error) {
15171
+ this.closeTask = void 0;
15172
+ throw error;
15173
+ }
15174
+ }
15175
+ enqueue(operation) {
15176
+ const run = () => this.disposed ? Promise.reject(/* @__PURE__ */ new Error("iroh controller is unavailable")) : operation();
15177
+ const task = this.queue.then(run, run);
15178
+ this.queue = task.then(() => void 0, () => void 0);
15179
+ return task;
15180
+ }
15181
+ publish(status) {
15182
+ this.latest = publicStatus$2(status);
15183
+ try {
15184
+ this.options.onStatus?.(this.status());
15185
+ } catch {}
15186
+ }
15187
+ async failGeneration(generation, errorCode, origin) {
15188
+ if (generation !== this.generation) return;
15189
+ this.generation += 1;
15190
+ this.publish({
15191
+ enabled: this.enabled,
15192
+ state: "error",
15193
+ ...origin === void 0 ? {} : { origin },
15194
+ errorCode
15195
+ });
15196
+ await this.stopResources();
15197
+ }
15198
+ async stop() {
15199
+ this.generation += 1;
15200
+ await this.stopResources();
15201
+ }
15202
+ async stopResources() {
15203
+ const child = this.child;
15204
+ const identityChild = this.identityChild;
15205
+ const gateway = this.gatewayValue;
15206
+ this.endpointId = void 0;
15207
+ await settleRemoteResources([
15208
+ async () => {
15209
+ if (child === void 0) return;
15210
+ try {
15211
+ child.stdin.write(encodeIrohCommand({
15212
+ version: 1,
15213
+ type: "shutdown"
15214
+ }));
15215
+ } catch {}
15216
+ await terminateRemoteProcess(child);
15217
+ if (this.child === child) this.child = void 0;
15218
+ },
15219
+ async () => {
15220
+ if (identityChild === void 0) return;
15221
+ await terminateRemoteProcess(identityChild);
15222
+ if (this.identityChild === identityChild) this.identityChild = void 0;
15223
+ },
15224
+ async () => {
15225
+ await gateway?.close();
15226
+ if (this.gatewayValue === gateway) this.gatewayValue = void 0;
15227
+ }
15228
+ ], "iroh resource cleanup failed");
15229
+ }
15230
+ async start() {
15231
+ await this.stopResources();
15232
+ if (this.disposed || !this.enabled) return;
15233
+ const generation = ++this.generation;
15234
+ let executable;
15235
+ try {
15236
+ const available = await this.options.component.ensureAvailable();
15237
+ executable = this.options.component.executable ?? available.storagePath;
15238
+ } catch {
15239
+ this.publish({
15240
+ enabled: true,
15241
+ state: "unavailable",
15242
+ errorCode: "iroh_companion_missing"
15243
+ });
15244
+ return;
15245
+ }
15246
+ if (generation !== this.generation || !this.enabled) return;
15247
+ if (executable === void 0) {
15248
+ this.publish({
15249
+ enabled: true,
15250
+ state: "unavailable",
15251
+ errorCode: "iroh_companion_missing"
15252
+ });
15253
+ return;
15254
+ }
15255
+ await mkdir(this.options.dataDirectory, {
15256
+ recursive: true,
15257
+ mode: 448
15258
+ }).catch(() => {
15259
+ throw new Error("iroh_data_directory_invalid");
15260
+ });
15261
+ await restrictPrivateDirectory(this.options.dataDirectory).catch(() => {
15262
+ throw new Error("iroh_data_directory_invalid");
15263
+ });
15264
+ if (generation !== this.generation || !this.enabled) return;
15265
+ let endpointId;
15266
+ try {
15267
+ endpointId = this.options.resolveIdentity === void 0 ? await defaultResolveIdentity(executable, this.options.dataDirectory, { onSpawn: (child) => {
15268
+ this.identityChild = child;
15269
+ child.once("close", () => {
15270
+ if (this.identityChild === child) this.identityChild = void 0;
15271
+ });
15272
+ } }) : await this.options.resolveIdentity(executable, this.options.dataDirectory);
15273
+ } catch (error) {
15274
+ await this.failGeneration(generation, error instanceof AggregateError ? "iroh_identity_cleanup_failed" : "iroh_identity_failed");
15275
+ return;
15276
+ }
15277
+ if (generation !== this.generation || !this.enabled) return;
15278
+ const origin = irohOrigin(endpointId);
15279
+ this.publish({
15280
+ enabled: true,
15281
+ state: "starting",
15282
+ origin
15283
+ });
15284
+ let gateway;
15285
+ try {
15286
+ gateway = await this.options.createGateway(endpointId);
15287
+ } catch {
15288
+ await this.failGeneration(generation, "gateway_start_failed", origin);
15289
+ return;
15290
+ }
15291
+ if (generation !== this.generation || !this.enabled) {
15292
+ await gateway.close();
15293
+ return;
15294
+ }
15295
+ this.gatewayValue = gateway;
15296
+ try {
15297
+ validateIrohProxyTarget(`http://127.0.0.1:${gateway.address().port}/`);
15298
+ } catch {
15299
+ await this.failGeneration(generation, "gateway_start_failed", origin);
15300
+ return;
15301
+ }
15302
+ if (generation !== this.generation || !this.enabled) return;
15303
+ let child;
15304
+ try {
15305
+ child = (this.options.launchSidecar ?? ((file, args) => spawn(file, [...args], {
15306
+ stdio: [
15307
+ "pipe",
15308
+ "pipe",
15309
+ "pipe"
15310
+ ],
15311
+ windowsHide: true
15312
+ })))(executable, [
15313
+ "--lang",
15314
+ "en",
15315
+ "host",
15316
+ "--data-dir",
15317
+ this.options.dataDirectory,
15318
+ "--proxy-target",
15319
+ `127.0.0.1:${gateway.address().port}`
15320
+ ]);
15321
+ } catch {
15322
+ await this.failGeneration(generation, "iroh_launch_failed", origin);
15323
+ return;
15324
+ }
15325
+ this.child = child;
15326
+ const onFailure = () => {
15327
+ if (this.disposed || generation !== this.generation) return;
15328
+ this.enqueue(() => this.failGeneration(generation, "iroh_exited", origin)).catch(() => {
15329
+ if (!this.disposed && this.generation === generation + 1) this.publish({
15330
+ enabled: this.enabled,
15331
+ state: "error",
15332
+ origin,
15333
+ errorCode: "iroh_cleanup_failed"
15334
+ });
15335
+ });
15336
+ };
15337
+ child.once("error", onFailure);
15338
+ child.once("close", onFailure);
15339
+ child.stdin.on("error", onFailure);
15340
+ this.publish({
15341
+ enabled: true,
15342
+ state: "connecting",
15343
+ origin
15344
+ });
15345
+ try {
15346
+ const ready = await this.waitForReady(child, endpointId, this.options.readyTimeoutMs ?? READY_TIMEOUT_MS);
15347
+ if (generation !== this.generation || !this.enabled) return;
15348
+ if (!ready) {
15349
+ await this.failGeneration(generation, "iroh_start_timeout", origin);
15350
+ return;
15351
+ }
15352
+ this.endpointId = endpointId;
15353
+ this.publish({
15354
+ enabled: true,
15355
+ state: "ready",
15356
+ origin
15357
+ });
15358
+ } catch {
15359
+ await this.failGeneration(generation, "iroh_start_failed", origin);
15360
+ }
15361
+ }
15362
+ waitForReady(child, endpointId, timeoutMs) {
15363
+ return new Promise((resolve) => {
15364
+ let pending = Buffer.alloc(0);
15365
+ let settled = false;
15366
+ const finish = (ready) => {
15367
+ if (settled) return;
15368
+ settled = true;
15369
+ clearTimeout(deadline);
15370
+ child.stdout.off("data", onData);
15371
+ resolve(ready);
15372
+ };
15373
+ const deadline = setTimeout(() => finish(false), timeoutMs);
15374
+ deadline.unref?.();
15375
+ const onData = (chunk) => {
15376
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
15377
+ if (pending.length + buffer.length > 64 * MAX_LINE_BYTES) {
15378
+ finish(false);
15379
+ return;
15380
+ }
15381
+ pending = Buffer.concat([pending, buffer]);
15382
+ let index = pending.indexOf(10);
15383
+ while (index !== -1) {
15384
+ const line = pending.subarray(0, index).toString("utf8");
15385
+ pending = pending.subarray(index + 1);
15386
+ let event;
15387
+ try {
15388
+ event = decodeIrohEvent(line);
15389
+ } catch {
15390
+ finish(false);
15391
+ return;
15392
+ }
15393
+ if (event.type === "ready") {
15394
+ finish(event.endpoint_id === endpointId);
15395
+ return;
15396
+ }
15397
+ if (event.type === "error") {
15398
+ finish(false);
15399
+ return;
15400
+ }
15401
+ index = pending.indexOf(10);
15402
+ }
15403
+ };
15404
+ child.stdout.on("data", onData);
15405
+ child.once("error", () => finish(false));
15406
+ child.once("close", () => finish(false));
15407
+ });
15408
+ }
15409
+ waitForPairingCode(child, timeoutMs) {
15410
+ return new Promise((resolve) => {
15411
+ let pending = Buffer.alloc(0);
15412
+ let settled = false;
15413
+ const finish = (pairing) => {
15414
+ if (settled) return;
15415
+ settled = true;
15416
+ clearTimeout(deadline);
15417
+ child.stdout.off("data", onData);
15418
+ resolve(pairing);
15419
+ };
15420
+ const deadline = setTimeout(() => finish(void 0), timeoutMs);
15421
+ deadline.unref?.();
15422
+ const onData = (chunk) => {
15423
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
15424
+ if (pending.length + buffer.length > 64 * MAX_LINE_BYTES) {
15425
+ finish(void 0);
15426
+ return;
15427
+ }
15428
+ pending = Buffer.concat([pending, buffer]);
15429
+ let index = pending.indexOf(10);
15430
+ while (index !== -1) {
15431
+ const line = pending.subarray(0, index).toString("utf8");
15432
+ pending = pending.subarray(index + 1);
15433
+ let event;
15434
+ try {
15435
+ event = decodeIrohEvent(line);
15436
+ } catch {
15437
+ finish(void 0);
15438
+ return;
15439
+ }
15440
+ if (event.type === "pairing") {
15441
+ finish({
15442
+ code: event.code,
15443
+ expiresInSec: event.expires_in_sec
15444
+ });
15445
+ return;
15446
+ }
15447
+ if (event.type === "error") {
15448
+ finish(void 0);
15449
+ return;
15450
+ }
15451
+ index = pending.indexOf(10);
15452
+ }
15453
+ };
15454
+ child.stdout.on("data", onData);
15455
+ child.once("error", () => finish(void 0));
15456
+ child.once("close", () => finish(void 0));
15457
+ });
15458
+ }
15459
+ };
15460
+ //#endregion
15461
+ //#region src/iroh-tls.ts
15462
+ /** Self-signed TLS identity for the experimental iroh gateway.
15463
+ *
15464
+ * Reuses this repository's managed CA path (`managed-setup.ts`) instead of
15465
+ * the companion's native TLS manager: the CA persists under the iroh state
15466
+ * directory, one leaf carries the identity-derived DNS name, and corrupt or
15467
+ * foreign material fails closed rather than regenerating silently. The
15468
+ * gateway terminates TLS with this leaf (see `irohGatewayConfig`), so the
15469
+ * sidecar only proxies opaque bytes and the app pins the CA from
15470
+ * `GET /mobile-access/ca.cer` in Phase 2.
15471
+ */
15472
+ /** Leaf lifetime and renewal headroom, matching the companion's native policy. */
15473
+ const IROH_LEAF_VALIDITY_DAYS = 90;
15474
+ const IROH_LEAF_RENEW_BEFORE_DAYS = 30;
15475
+ /** Private TLS directory, always a sibling of the remote device file. */
15476
+ function irohTlsPaths(stateFile, ownedRoot) {
15477
+ const directory = join(ownedRoot ?? dirname(stateFile), "iroh-tls");
15478
+ return Object.freeze({
15479
+ directory,
15480
+ caCertFile: join(directory, "ca.pem"),
15481
+ caKeyFile: join(directory, "ca-key.pem"),
15482
+ certFile: join(directory, "server.pem"),
15483
+ keyFile: join(directory, "server-key.pem"),
15484
+ statusFile: join(dirname(stateFile), "iroh-identity.json")
15485
+ });
15486
+ }
15487
+ async function regularFileExists(file) {
15488
+ try {
15489
+ const entry = await lstat(file);
15490
+ if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("iroh_tls_file_invalid");
15491
+ return true;
15492
+ } catch (error) {
15493
+ if (error.code !== "ENOENT") throw error;
15494
+ return false;
15495
+ }
15496
+ }
15497
+ async function readIrohIdentity(file) {
15498
+ if (!await regularFileExists(file)) return void 0;
15499
+ try {
15500
+ const parsed = JSON.parse(await readFile(file, "utf8"));
15501
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("invalid identity");
15502
+ const record = parsed;
15503
+ if (record.version !== 1 || typeof record.endpointId !== "string" || !/^[a-f0-9]{64}$/u.test(record.endpointId) || typeof record.caFingerprint !== "string" || !/^[a-f0-9]{64}$/u.test(record.caFingerprint)) throw new Error("invalid identity");
15504
+ return Object.freeze({
15505
+ endpointId: record.endpointId,
15506
+ caFingerprint: record.caFingerprint
15507
+ });
15508
+ } catch (error) {
15509
+ throw new Error("iroh_tls_ca_invalid", { cause: error });
15510
+ }
15511
+ }
15512
+ async function writeIrohIdentity(file, identity, ownedRoot) {
15513
+ await writeManagedPrivateFile(ownedRoot, file, `${JSON.stringify({
15514
+ version: 1,
15515
+ endpointId: identity.endpointId,
15516
+ caFingerprint: identity.caFingerprint
15517
+ })}\n`, "iroh_tls");
15518
+ }
15519
+ function fingerprintOf(certificate) {
15520
+ return certificate.fingerprint256.replaceAll(":", "").toLowerCase();
15521
+ }
15522
+ function leafCoversEndpoint(leaf, endpointId, now) {
15523
+ const hostname = irohHostname(endpointId);
15524
+ if (!(leaf.subjectAltName ?? "").split(",").map((entry) => entry.trim()).includes(`DNS:${hostname}`)) return false;
15525
+ const notAfter = Date.parse(leaf.validTo);
15526
+ if (!Number.isFinite(notAfter)) return false;
15527
+ return notAfter - now > 2592e6 && Date.parse(leaf.validFrom) <= now;
15528
+ }
15529
+ /**
15530
+ * Materialize the CA and endpoint leaf the iroh gateway terminates TLS with.
15531
+ *
15532
+ * A recorded CA fingerprint that no longer matches fails closed: deleting or
15533
+ * replacing the CA is an explicit operator act, never an automatic recovery.
15534
+ * The endpoint may change under a stable CA; only the leaf is re-issued then.
15535
+ */
15536
+ async function ensureIrohTls(endpointId, stateFile, ownedRoot, now = Date.now()) {
15537
+ if (!/^[a-f0-9]{64}$/u.test(endpointId)) throw new Error("iroh endpoint id must be a lowercase 32-byte public key");
15538
+ await assertManagedParents(ownedRoot, stateFile, "iroh_tls");
15539
+ const paths = irohTlsPaths(stateFile, ownedRoot);
15540
+ const identity = await readIrohIdentity(paths.statusFile);
15541
+ const ca = await ensureManagedCa({
15542
+ mode: "managed",
15543
+ caCertFile: paths.caCertFile,
15544
+ caKeyFile: paths.caKeyFile,
15545
+ certFile: paths.certFile,
15546
+ keyFile: paths.keyFile
15547
+ }).catch((error) => {
15548
+ throw new Error("iroh_tls_ca_invalid", { cause: error });
15549
+ });
15550
+ const caFingerprint = fingerprintOf(ca);
15551
+ if (identity !== void 0 && identity.caFingerprint !== caFingerprint) throw new Error("iroh_tls_ca_invalid");
15552
+ let leaf;
15553
+ if (await regularFileExists(paths.certFile) && await regularFileExists(paths.keyFile)) try {
15554
+ const [certPem, keyPem] = await Promise.all([readFile(paths.certFile, "utf8"), readFile(paths.keyFile, "utf8")]);
15555
+ const candidate = new X509Certificate(certPem);
15556
+ const key = createPrivateKey(keyPem);
15557
+ if (!candidate.ca && candidate.verify(ca.publicKey) && candidate.checkPrivateKey(key) && leafCoversEndpoint(candidate, endpointId, now)) leaf = candidate;
15558
+ } catch {
15559
+ throw new Error("iroh_tls_ca_invalid");
15560
+ }
15561
+ if (leaf === void 0) {
15562
+ await issueServerCertificate({
15563
+ caCertFile: paths.caCertFile,
15564
+ caKeyFile: paths.caKeyFile
15565
+ }, {
15566
+ commonName: "DeepSeek Harness Mobile iroh",
15567
+ dnsNames: [irohHostname(endpointId)]
15568
+ }, {
15569
+ certFile: paths.certFile,
15570
+ keyFile: paths.keyFile
15571
+ }, 90).catch((error) => {
15572
+ throw new Error("iroh_tls_leaf_invalid", { cause: error });
15573
+ });
15574
+ try {
15575
+ const [certPem, keyPem] = await Promise.all([readFile(paths.certFile, "utf8"), readFile(paths.keyFile, "utf8")]);
15576
+ const candidate = new X509Certificate(certPem);
15577
+ const key = createPrivateKey(keyPem);
15578
+ if (candidate.ca || !candidate.verify(ca.publicKey) || !candidate.checkPrivateKey(key) || !leafCoversEndpoint(candidate, endpointId, Date.now())) throw new Error("iroh_tls_leaf_invalid");
15579
+ leaf = candidate;
15580
+ } catch (error) {
15581
+ if (error instanceof Error && (error.message === "iroh_tls_leaf_invalid" || error.message === "iroh_tls_ca_invalid")) throw error;
15582
+ throw new Error("iroh_tls_leaf_invalid", { cause: error });
15583
+ }
15584
+ }
15585
+ await writeIrohIdentity(paths.statusFile, {
15586
+ endpointId,
15587
+ caFingerprint
15588
+ }, ownedRoot);
15589
+ return Object.freeze({
15590
+ paths,
15591
+ ca,
15592
+ leaf,
15593
+ caFingerprint,
15594
+ endpointId
15595
+ });
15596
+ }
15597
+ /**
15598
+ * Gateway config for the iroh endpoint-pinned entry.
15599
+ *
15600
+ * Like the self-signed FRP ingress, the gateway itself terminates TLS: the
15601
+ * sidecar only forwards raw TCP, so the listener speaks HTTPS with the leaf
15602
+ * the iroh CA signed, and the pairing CA is retained so the app can pin it
15603
+ * from `GET /mobile-access/ca.cer`. The listener stays on loopback because
15604
+ * the public path is the iroh transport, never a direct bind.
15605
+ */
15606
+ function irohGatewayConfig(template, endpointId, stateFile, tls, listenPort = 0) {
15607
+ const { pairingCaFile: _templateCaFile, ...shared } = template;
15608
+ const authority = new URL(irohOrigin(endpointId, DEFAULT_IROH_LOCAL_PORT)).host;
15609
+ return Object.freeze({
15610
+ ...shared,
15611
+ listenHost: "127.0.0.1",
15612
+ listenPort,
15613
+ authorities: Object.freeze([parseAuthority(authority)]),
15614
+ allowedCidrs: Object.freeze([parseCidr("127.0.0.0/8")]),
15615
+ stateFile,
15616
+ instanceId: tls.caFingerprint,
15617
+ pairingCaFile: tls.paths.caCertFile,
15618
+ tls: Object.freeze({
15619
+ mode: "provided",
15620
+ certFile: tls.paths.certFile,
15621
+ keyFile: tls.paths.keyFile
15622
+ }),
15623
+ publicTls: true,
15624
+ discovery: false,
15625
+ pairingTtlMs: 3e5
15626
+ });
15627
+ }
15628
+ //#endregion
15629
+ //#region src/task-events.ts
15630
+ const TASK_EVENT_DEBOUNCE_MS = 1e3;
15631
+ function turnNumber(value) {
15632
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
15633
+ }
15634
+ function turnData(value) {
15635
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return {
15636
+ turn: 0,
15637
+ completed: false
15638
+ };
15639
+ const record = value;
15640
+ const reason = record.reason;
15641
+ const completed = typeof reason === "string" ? reason === "completed" : reason !== null && typeof reason === "object" && reason.kind === "completed";
15642
+ return {
15643
+ turn: turnNumber(record.turn),
15644
+ completed
15645
+ };
15646
+ }
15647
+ /**
15648
+ * Subscribe to completed root turns. Subagent turns are skipped so one task
15649
+ * announces once, and rapid turn boundaries collapse into a single event.
15650
+ * Returns a disposer that also drops pending debounces.
15651
+ */
15652
+ function watchTaskCompletions(ctx, options) {
15653
+ const debounceMs = options.debounceMs ?? 1e3;
15654
+ const pending = /* @__PURE__ */ new Map();
15655
+ const disposeListener = ctx.on("session/event", (session, event) => {
15656
+ if (event.type !== "turn/end") return;
15657
+ const { turn, completed } = turnData(event.data);
15658
+ if (!completed) return;
15659
+ if (session.header?.parentSession !== void 0 && session.header?.parentSession !== null) return;
15660
+ const sessionId = String(session.id);
15661
+ options.log?.("task-completed-observed", {
15662
+ sessionId,
15663
+ turn
15664
+ });
15665
+ const previous = pending.get(sessionId);
15666
+ if (previous !== void 0) clearTimeout(previous);
15667
+ pending.set(sessionId, setTimeout(() => {
15668
+ pending.delete(sessionId);
15669
+ options.log?.("task-completed-announced", {
15670
+ sessionId,
15671
+ turn
15672
+ });
15673
+ options.onTaskCompleted(Object.freeze({
15674
+ sessionId,
15675
+ turn
15676
+ }));
15677
+ }, debounceMs));
15678
+ });
15679
+ return () => {
15680
+ disposeListener();
15681
+ for (const timer of pending.values()) clearTimeout(timer);
15682
+ pending.clear();
15683
+ };
15684
+ }
15685
+ /** One subscription feeding every live gateway; gateways register on start. */
15686
+ var TaskEventHub = class {
15687
+ sinks = /* @__PURE__ */ new Set();
15688
+ /** Register a sink; returns its disposer. */
15689
+ add(sink) {
15690
+ this.sinks.add(sink);
15691
+ return () => {
15692
+ this.sinks.delete(sink);
15693
+ };
15694
+ }
15695
+ /** Fan out to a snapshot so a failing sink cannot break its siblings. */
15696
+ broadcast(event) {
15697
+ for (const sink of [...this.sinks]) try {
15698
+ sink.broadcastTaskEvent(event);
15699
+ } catch {}
15700
+ }
15701
+ /** Visible for tests. */
15702
+ get size() {
15703
+ return this.sinks.size;
15704
+ }
15705
+ };
15706
+ //#endregion
15707
+ //#region src/diagnostics.ts
15708
+ /** Recognize the active remote-web-ui boot hook without evaluating or modifying it. */
15709
+ function hasCompetingRemoteChannelBoot(rows) {
15710
+ return rows.some((row) => row.kind === "script" && row.text?.includes("__DSH_REMOTE_CHANNEL_BOOT__") === true);
15711
+ }
15712
+ const REMOTE_ERROR_GUIDANCE = Object.freeze({
15713
+ component_missing: "重新安装完整插件包。",
15714
+ funnel_permission_required: "继续完成 Tailscale Funnel 授权。",
15715
+ funnel_https_required: "继续完成 Tailscale HTTPS 授权。",
15716
+ funnel_start_failed: "重新打开授权页并允许 Funnel。",
15717
+ funnel_start_timeout: "检查网络后点击“重新连接”。",
15718
+ tailscale_dns_missing: "确认 Tailscale 登录仍有效后重新连接。",
15719
+ sidecar_launch_failed: "重新安装完整插件包后重试。",
15720
+ sidecar_stopped: "点击“重新连接”。",
15721
+ sidecar_exited: "点击“重新连接”;仍失败时复制诊断报告。",
15722
+ control_channel_failed: "点击“重新连接”。",
15723
+ cpolar_component_missing: "先安装 cpolar 官方组件。",
15724
+ cpolar_component_invalid: "彻底移除 cpolar 组件后重新安装。",
15725
+ cpolar_config_missing: "保存 cpolar Authtoken 后重试。",
15726
+ cpolar_config_invalid: "重新保存 cpolar Authtoken。",
15727
+ cpolar_start_timeout: "检查网络后点击“重新连接”。",
15728
+ cpolar_stopped: "点击“重新连接”。",
15729
+ cpolar_exited: "点击“重新连接”;仍失败时复制诊断报告。",
15730
+ cloudflared_component_missing: "先安装 cloudflared 官方组件。",
15731
+ cloudflared_component_invalid: "彻底移除 cloudflared 组件后重新安装。",
15732
+ cloudflared_port_unavailable: "无法分配本机远程网关端口,请重试。",
15733
+ cloudflared_launch_failed: "重新安装 cloudflared 官方组件后重试。",
15734
+ cloudflared_start_timeout: "检查网络后点击“重新连接”。",
15735
+ cloudflared_stopped: "点击“重新连接”。",
15736
+ cloudflared_exited: "点击“重新连接”;仍失败时复制诊断报告。",
15737
+ cloudflared_invalid_output: "cloudflared 返回了无法识别的状态。",
15738
+ cloudflared_invalid_origin: "cloudflared 返回的公网地址未通过校验。",
15739
+ origin_config_missing: "先保存自有反向代理配置。",
15740
+ origin_config_invalid: "重新保存自有反向代理配置。",
15741
+ origin_listen_port_in_use: "更换 HTTP 后端端口;不要使用局域网的 3443、DSH 自身的 3080,或已被 cloudflared 隧道占用的 3444。",
15742
+ origin_listen_address_unavailable: "监听地址不属于当前电脑,请重新选择本机的私网 IPv4 地址。",
15743
+ origin_gateway_start_failed: "检查 HTTP 后端监听地址和端口后重新连接。",
15744
+ frp_component_missing: "先安装 FRP 官方组件。",
15745
+ frp_component_invalid: "彻底清理 FRP 组件后重新安装。",
15746
+ frp_config_missing: "先保存自建 FRP 连接配置。",
15747
+ frp_config_verify_failed: "检查服务器地址、端口、Token 和公开域名。",
15748
+ frp_vhost_publicly_reachable: "将 frps 的 HTTP vhost 监听限制到 127.0.0.1。",
15749
+ frp_vhost_probe_failed: "确认 VPS 地址可解析后重新连接。",
15750
+ frp_launch_failed: "重新安装 FRP 官方组件后重试。",
15751
+ frp_start_timeout: "确认 frps、Caddy 和域名解析正常后重新连接。",
15752
+ frp_proxy_name_in_use: "该 frps 上已有同名代理:在另一台 DSH 电脑上关闭自建 FRP,或在 frps 上清掉该代理后重新连接。",
15753
+ frp_remote_port_in_use: "公网入口端口被其他代理占用:改用其他入口端口后重新连接。",
15754
+ frp_proxy_start_failed: "查看 frps 日志与 allowPorts,确认它能接受该代理名和入口端口。",
15755
+ frp_discovery_mismatch: "公开域名连接到了另一台电脑,请核对 Caddy 与 frps 配置。",
15756
+ frp_discovery_invalid: "公开域名返回了非 DSH Mobile 响应。",
15757
+ frp_stopped: "点击“重新连接”。",
15758
+ frp_exited: "检查 VPS 配置后重新连接;仍失败时复制诊断报告。",
15759
+ gateway_start_failed: "确认 DSH 正在运行后重新连接。"
15760
+ });
15761
+ function check(id, status, reason, label, detail, action, facts) {
15762
+ return Object.freeze({
15763
+ id,
15764
+ status,
15765
+ reason,
15766
+ ...facts === void 0 ? {} : { facts: Object.freeze(facts) },
15767
+ label,
15768
+ detail,
15769
+ ...action === void 0 ? {} : { action }
15770
+ });
15771
+ }
15772
+ function maskLanOrigin(origin) {
15773
+ if (origin === void 0) return "未分配";
15774
+ try {
15775
+ const url = new URL(origin);
15776
+ const octets = url.hostname.split(".");
15777
+ const host = octets.length === 4 ? `${octets[0]}.${octets[1]}.${octets[2]}.x` : "局域网地址";
15778
+ return `${url.protocol}//${host}${url.port === "" ? "" : `:${url.port}`}`;
15779
+ } catch {
15780
+ return "地址格式无效";
15781
+ }
15782
+ }
15783
+ function remoteSuffix(origin) {
15784
+ if (origin === void 0) return "未分配";
15785
+ try {
15786
+ const hostname = new URL(origin).hostname;
15787
+ if (hostname.endsWith(".ts.net")) return "*.ts.net";
15788
+ if (hostname.endsWith(".trycloudflare.com")) return "*.trycloudflare.com";
15789
+ for (const suffix of [
15790
+ ".cpolar.cn",
15791
+ ".cpolar.io",
15792
+ ".cpolar.top",
15793
+ ".cpolar.com"
15794
+ ]) if (hostname.endsWith(suffix)) return `*${suffix}`;
15795
+ return "公共 HTTPS 地址";
15796
+ } catch {
15797
+ return "地址格式无效";
15798
+ }
15799
+ }
15800
+ function defaultFirewallProbe(platform = process.platform) {
15801
+ return async (port) => {
15802
+ if (platform !== "win32") return { state: "not-applicable" };
15803
+ if (port === void 0) return { state: "unknown" };
15804
+ const script = [
15805
+ "$specs = @(@{ Name = 'DSH Mobile HTTPS'; Protocol = 'TCP' }, @{ Name = 'DSH Mobile Discovery'; Protocol = 'UDP' })",
15806
+ "$ready = $true",
15807
+ "$specs | ForEach-Object {",
15808
+ " $spec = $_",
15809
+ " $rule = Get-NetFirewallRule -DisplayName $spec.Name -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' } | Select-Object -First 1",
15810
+ " if ($null -eq $rule) { $ready = $false; return }",
15811
+ " $filters = @($rule | Get-NetFirewallPortFilter -ErrorAction SilentlyContinue)",
15812
+ ` $matching = @($filters | Where-Object { $_.Protocol -eq $spec.Protocol -and ($_.LocalPort -eq 'Any' -or $_.LocalPort -eq '${String(port)}') })`,
15813
+ " if ($matching.Count -eq 0) { $ready = $false }",
15814
+ "}",
15815
+ "if ($ready) { 'ready' } else { 'missing' }"
15816
+ ].join("; ");
15817
+ try {
15818
+ return { state: (await execFileText("powershell.exe", [
15819
+ "-NoProfile",
15820
+ "-NonInteractive",
15821
+ "-Command",
15822
+ script
15823
+ ], {
15824
+ encoding: "utf8",
15825
+ timeout: 3e3,
15826
+ windowsHide: true
15827
+ })).stdout.trim() === "ready" ? "ready" : "missing" };
15828
+ } catch {
15829
+ return { state: "unknown" };
15830
+ }
15831
+ };
15832
+ }
15833
+ /** Reserve one six-second window per egress so the desktop's 15-second request can finish. */
15834
+ function remoteDiagnosticTimeoutMs(_origin) {
15835
+ return 6e3;
15836
+ }
15837
+ const TUNNEL_HEADER_LIMIT_BYTES = 65536;
14375
15838
  function proxyExcludedByNoProxy(target, raw) {
14376
15839
  const text = raw.trim().toLowerCase();
14377
15840
  if (text === "") return false;
@@ -14640,7 +16103,7 @@ function reportLine(entry) {
14640
16103
  /** Run bounded read-only checks and return a report safe to paste into an issue. */
14641
16104
  async function collectConnectionDiagnostics(snapshot, probes = {}) {
14642
16105
  const checks = [];
14643
- const remoteProbe = snapshot.remote.provider !== "origin" && snapshot.remote.running && snapshot.remote.state === "ready" && snapshot.remote.origin !== void 0 ? (probes.remote ?? defaultRemoteProbe)(snapshot.remote.origin) : Promise.resolve({ state: "not-applicable" });
16106
+ 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" });
14644
16107
  const [firewall, remoteObservation] = await Promise.all([(probes.firewall ?? defaultFirewallProbe())(snapshot.lan.port), remoteProbe]);
14645
16108
  checks.push(check("versions", "ok", "versions-current", "版本兼容", `插件 ${DSH_MOBILE_VERSION},DSH ${snapshot.dshVersion},Android App 最低 ${MINIMUM_ANDROID_APP_VERSION}。`));
14646
16109
  if (snapshot.competingRemoteChannelBoot === true) checks.push(check("remote-plugin-conflict", "warning", "competing-remote-channel", "第三方远程插件", "检测到另一套远程请求通道脚本;手机页面可能进入该插件自己的配对流程。", "在电脑端关闭 dsh-remote-web-ui 的远程访问后刷新,再使用 DSH Mobile 当前二维码配对;不要混用两种配对码。"));
@@ -14659,6 +16122,7 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
14659
16122
  else if (firewall.state === "unknown") checks.push(check("firewall", "info", "firewall-unknown", "Windows 防火墙", "系统未允许插件读取防火墙状态。", "若手机找不到电脑,以管理员身份重新运行 setup。"));
14660
16123
  if (!snapshot.remote.running || snapshot.remote.state === "off") checks.push(check("remote", "info", "remote-off", "远程通道", "当前未启用。", void 0, { provider: snapshot.remote.provider }));
14661
16124
  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" }));
16125
+ else if (snapshot.remote.provider === "iroh" && snapshot.remote.state === "ready") checks.push(check("remote", "info", "remote-iroh-ready", "远程通道", "iroh 通道已就绪;端点身份为回环 TLS,不从电脑端探测手机可达性。", "在手机上用配对二维码验证连接;二维码过期或通道报错时重新生成并重连。", { provider: "iroh" }));
14662
16126
  else if (snapshot.remote.state === "ready" && snapshot.remote.origin !== void 0) {
14663
16127
  const endpointSuffix = remoteSuffix(snapshot.remote.origin);
14664
16128
  const facts = {
@@ -14812,7 +16276,7 @@ function shellQuote(value) {
14812
16276
  * shared frpc settings stay permissive so local loopback test rigs keep working.
14813
16277
  */
14814
16278
  function assertPublicSshTarget(serverAddress) {
14815
- if (isIP(serverAddress) === 4 && !isGloballyRoutableIpv4(serverAddress)) throw new Error("vps_server_not_public");
16279
+ if (isIP$1(serverAddress) === 4 && !isGloballyRoutableIpv4(serverAddress)) throw new Error("vps_server_not_public");
14816
16280
  }
14817
16281
  /**
14818
16282
  * Validate a VPS address for every operation that opens a network connection
@@ -14821,7 +16285,7 @@ function assertPublicSshTarget(serverAddress) {
14821
16285
  */
14822
16286
  function validateVpsServerTarget(serverAddress) {
14823
16287
  const address = validateFrpServerAddress(serverAddress);
14824
- if (isIP(address) !== 0 && address.includes(":")) throw new Error("vps_ipv6_ssh_not_supported");
16288
+ if (isIP$1(address) !== 0 && address.includes(":")) throw new Error("vps_ipv6_ssh_not_supported");
14825
16289
  assertPublicSshTarget(address);
14826
16290
  return address;
14827
16291
  }
@@ -17039,7 +18503,7 @@ function parseCaddySettings(value) {
17039
18503
  3080
17040
18504
  ].includes(Number(listenPort))) throw new Error("caddy_listen_port_reserved");
17041
18505
  const publicOrigin = validateOriginPublicOrigin(record.publicOrigin);
17042
- if (isIP(new URL(publicOrigin).hostname) !== 0) throw new Error("caddy_domain_required");
18506
+ if (isIP$1(new URL(publicOrigin).hostname) !== 0) throw new Error("caddy_domain_required");
17043
18507
  return Object.freeze({
17044
18508
  version: 1,
17045
18509
  publicOrigin,
@@ -17849,9 +19313,37 @@ function androidRelease(version, tag) {
17849
19313
  if (typeof version !== "string" || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u.test(version) || parseSemver(version) === void 0 || tag !== `v${version}` && tag !== `android-v${version}`) return void 0;
17850
19314
  return Object.freeze({
17851
19315
  version,
19316
+ tag,
17852
19317
  downloadUrl: `https://github.com/saya-ch/dsh-mobile/releases/download/${tag}/dsh-mobile-android-v${version}.apk`
17853
19318
  });
17854
19319
  }
19320
+ /** Read the published APK byte sizes from the GitHub release assets (best effort). */
19321
+ async function fetchAndroidAssetSizes(fetcher, version, tag, signal) {
19322
+ try {
19323
+ const response = await fetcher(`${GITHUB_RELEASE_API}${encodeURIComponent(tag)}`, {
19324
+ headers: {
19325
+ accept: "application/vnd.github+json",
19326
+ "user-agent": "dsh-mobile-release-check"
19327
+ },
19328
+ signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
19329
+ });
19330
+ if (!response.ok) return {};
19331
+ const payload = await response.json();
19332
+ if (payload === null || typeof payload !== "object" || !("assets" in payload) || !Array.isArray(payload.assets)) return {};
19333
+ const assets = payload.assets;
19334
+ const sizeOf = (name) => {
19335
+ for (const asset of assets) if (asset !== null && typeof asset === "object" && "name" in asset && asset.name === name && "size" in asset && typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size > 0) return asset.size;
19336
+ };
19337
+ const compatBytes = sizeOf(`dsh-mobile-android-v${version}.apk`);
19338
+ const irohBytes = sizeOf(`dsh-mobile-android-v${version}-iroh.apk`);
19339
+ return {
19340
+ ...compatBytes === void 0 ? {} : { compatBytes },
19341
+ ...irohBytes === void 0 ? {} : { irohBytes }
19342
+ };
19343
+ } catch {
19344
+ return {};
19345
+ }
19346
+ }
17855
19347
  function androidReleaseDescriptor(payload) {
17856
19348
  if (typeof payload !== "object" || payload === null) return void 0;
17857
19349
  const fields = payload;
@@ -18070,6 +19562,9 @@ var PluginReleaseManager = class {
18070
19562
  now;
18071
19563
  cache;
18072
19564
  activeUpdate;
19565
+ assetLookupOwner = new AbortController();
19566
+ assetLookup;
19567
+ assetSizes;
18073
19568
  constructor(options) {
18074
19569
  this.profileDirectory = options.profileDirectory;
18075
19570
  this.installedVersion = options.installedVersion ?? DSH_MOBILE_VERSION;
@@ -18085,12 +19580,16 @@ var PluginReleaseManager = class {
18085
19580
  const npmVersion = fetchNpmVersion(this.fetcher);
18086
19581
  const [npmResult, androidResult, notesResult] = await Promise.allSettled([
18087
19582
  npmVersion,
18088
- fetchAndroidRelease(this.fetcher),
19583
+ fetchAndroidRelease(this.fetcher).then((android) => {
19584
+ if (android !== void 0) this.startAssetLookup(android);
19585
+ return android;
19586
+ }),
18089
19587
  npmVersion.then((version) => version === void 0 ? void 0 : fetchReleaseNotes(this.fetcher, version))
18090
19588
  ]);
18091
19589
  const latestVersion = npmResult.status === "fulfilled" ? npmResult.value : void 0;
18092
19590
  const android = androidResult.status === "fulfilled" ? androidResult.value : void 0;
18093
19591
  const releaseNotes = notesResult.status === "fulfilled" ? notesResult.value : void 0;
19592
+ const androidSizes = this.assetSizes?.downloadUrl === android?.downloadUrl ? this.assetSizes : void 0;
18094
19593
  const comparison = latestVersion === void 0 ? void 0 : comparePluginVersions(latestVersion, this.installedVersion);
18095
19594
  const status = Object.freeze({
18096
19595
  installedVersion: this.installedVersion,
@@ -18099,6 +19598,8 @@ var PluginReleaseManager = class {
18099
19598
  updateSupported,
18100
19599
  ...android === void 0 ? {} : { androidVersion: android.version },
18101
19600
  androidDownloadUrl: android?.downloadUrl ?? GITHUB_RELEASES_URL,
19601
+ ...androidSizes?.compatBytes === void 0 ? {} : { androidCompatBytes: androidSizes.compatBytes },
19602
+ ...androidSizes?.irohBytes === void 0 ? {} : { androidIrohBytes: androidSizes.irohBytes },
18102
19603
  ...releaseNotes === void 0 ? {} : { releaseNotes }
18103
19604
  });
18104
19605
  this.cache = {
@@ -18107,6 +19608,31 @@ var PluginReleaseManager = class {
18107
19608
  };
18108
19609
  return status;
18109
19610
  }
19611
+ /** Cancel and drain the optional APK-size lookup without delaying provider cleanup. */
19612
+ async dispose() {
19613
+ this.assetLookupOwner.abort();
19614
+ await this.assetLookup;
19615
+ }
19616
+ startAssetLookup(android) {
19617
+ if (this.assetLookupOwner.signal.aborted || this.assetLookup !== void 0 || this.assetSizes?.downloadUrl === android.downloadUrl) return;
19618
+ this.assetLookup = fetchAndroidAssetSizes(this.fetcher, android.version, android.tag, this.assetLookupOwner.signal).then((sizes) => {
19619
+ if (this.assetLookupOwner.signal.aborted) return;
19620
+ this.assetSizes = {
19621
+ downloadUrl: android.downloadUrl,
19622
+ ...sizes
19623
+ };
19624
+ if (this.cache?.status.androidDownloadUrl === android.downloadUrl) this.cache = {
19625
+ ...this.cache,
19626
+ status: Object.freeze({
19627
+ ...this.cache.status,
19628
+ ...sizes.compatBytes === void 0 ? {} : { androidCompatBytes: sizes.compatBytes },
19629
+ ...sizes.irohBytes === void 0 ? {} : { androidIrohBytes: sizes.irohBytes }
19630
+ })
19631
+ };
19632
+ }).finally(() => {
19633
+ this.assetLookup = void 0;
19634
+ });
19635
+ }
18110
19636
  /** Install the latest npm release into the active profile, then require a DSH restart. */
18111
19637
  async update() {
18112
19638
  if (this.activeUpdate !== void 0) return this.activeUpdate;
@@ -18460,6 +19986,7 @@ function installedDshVersion() {
18460
19986
  function mapAdminError(error) {
18461
19987
  if (error instanceof HttpError) return error;
18462
19988
  if (error instanceof MobileExtensionError) return new HttpError(error.status, error.code);
19989
+ if (error instanceof Error && error.message === "remote_provider_changed") return new HttpError(409, "remote_provider_changed");
18463
19990
  const code = error.code;
18464
19991
  if (error instanceof Error && error.message.includes("spawn UNKNOWN")) return new HttpError(409, "frp_component_launch_failed");
18465
19992
  if (code === "EADDRNOTAVAIL") return new HttpError(409, "network_address_changed");
@@ -18476,6 +20003,7 @@ function mapAdminError(error) {
18476
20003
  "frp_settings_invalid"
18477
20004
  ].includes(error.message)) return new HttpError(400, error.message);
18478
20005
  if (error instanceof Error && error.message.startsWith("frp_")) return new HttpError(409, error.message);
20006
+ if (error instanceof Error && error.message.startsWith("iroh_")) return new HttpError(409, error.message);
18479
20007
  if (error instanceof Error && [
18480
20008
  "origin_settings_invalid",
18481
20009
  "origin_public_origin_invalid",
@@ -18659,7 +20187,7 @@ function originGatewayConfig(template, settings, stateFile, instanceId, listenPo
18659
20187
  allowedCidrs: Object.freeze(validated.allowedCidrs.map(parseCidr))
18660
20188
  });
18661
20189
  }
18662
- function remoteControlPayload(provider, status, gateway, providerStatuses, cpolarComponent, cloudflaredComponent, cloudflaredConfiguration, frpComponent, frpConfiguration, originConfiguration, caddyComponentStatus) {
20190
+ function remoteControlPayload(provider, status, gateway, providerStatuses, cpolarComponent, cloudflaredComponent, cloudflaredConfiguration, frpComponent, frpConfiguration, originConfiguration, caddyComponentStatus, irohComponent) {
18663
20191
  return {
18664
20192
  provider,
18665
20193
  running: status.enabled,
@@ -18702,10 +20230,34 @@ function remoteControlPayload(provider, status, gateway, providerStatuses, cpola
18702
20230
  state: providerStatuses.origin.state,
18703
20231
  configuration: originConfiguration,
18704
20232
  component: caddyComponentStatus
20233
+ },
20234
+ iroh: {
20235
+ bundled: false,
20236
+ running: providerStatuses.iroh.enabled,
20237
+ state: providerStatuses.iroh.state,
20238
+ component: irohComponent
18705
20239
  }
18706
20240
  }
18707
20241
  };
18708
20242
  }
20243
+ /**
20244
+ * Candidate directories for an installed `dsh-mobile-iroh` companion package.
20245
+ *
20246
+ * A verified companion copy is reused without downloading during the
20247
+ * transition away from the experimental companion repository. An explicit
20248
+ * absolute override wins for tests and unusual layouts, otherwise the
20249
+ * sibling of this package covers the hoisted npm layout while realpath
20250
+ * (applied at verification) follows pnpm links.
20251
+ */
20252
+ function locateIrohCompanionRoots(importMetaUrl, environment = process.env) {
20253
+ const override = environment.DSH_MOBILE_IROH_COMPANION?.trim();
20254
+ if (override !== void 0 && override !== "") {
20255
+ if (!isAbsolute(override)) throw new Error("DSH_MOBILE_IROH_COMPANION must be an absolute path");
20256
+ return Object.freeze([resolve(override)]);
20257
+ }
20258
+ const ownPackageRoot = dirname(dirname(fileURLToPath(importMetaUrl)));
20259
+ return Object.freeze([join(dirname(ownPackageRoot), "dsh-mobile-iroh")]);
20260
+ }
18709
20261
  /** Mount the resident control route and its optional authenticated LAN gateway. */
18710
20262
  async function apply(ctx, config) {
18711
20263
  const dshVersion = installedDshVersion();
@@ -18743,6 +20295,11 @@ async function apply(ctx, config) {
18743
20295
  await cloudflaredComponent.initialize();
18744
20296
  const frpComponent = new FrpComponentManager({ stateDirectory });
18745
20297
  await frpComponent.initialize();
20298
+ const irohComponent = new IrohComponentManager({
20299
+ stateDirectory,
20300
+ locateCompanionRoots: () => locateIrohCompanionRoots(import.meta.url)
20301
+ });
20302
+ await irohComponent.initialize();
18746
20303
  const frpIdentity = await loadOrCreateInstallationId(join(stateDirectory, "installation-id"));
18747
20304
  const template = Object.freeze({
18748
20305
  ...baseTemplate,
@@ -18883,6 +20440,34 @@ async function apply(ctx, config) {
18883
20440
  return candidate;
18884
20441
  };
18885
20442
  /**
20443
+ * Gateway factory for the experimental iroh provider.
20444
+ *
20445
+ * The endpoint identity resolves inside the controller start, so TLS is
20446
+ * ensured here per endpoint: a stable CA with a leaf for the exact
20447
+ * identity-derived DNS name. The gateway terminates TLS itself (provided
20448
+ * mode) and retains the pairing CA for the app to pin, exactly like the
20449
+ * self-signed FRP ingress. Device pairings share the remote device file
20450
+ * with the other remote providers.
20451
+ */
20452
+ const createIrohGateway = async (endpointId) => {
20453
+ const tls = await ensureIrohTls(endpointId, remoteDeviceFile, stateDirectory);
20454
+ const resolved = irohGatewayConfig(template, endpointId, remoteDeviceFile, tls);
20455
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences, (entry) => {
20456
+ logger.warn("auth failure %s", JSON.stringify(entry));
20457
+ }, (entry) => {
20458
+ logger.warn("http failure %s", JSON.stringify(entry));
20459
+ }, (entry) => {
20460
+ logger.info("upstream auth %s", JSON.stringify(entry));
20461
+ });
20462
+ try {
20463
+ await candidate.start();
20464
+ } catch (error) {
20465
+ await candidate.close();
20466
+ throw error;
20467
+ }
20468
+ return candidate;
20469
+ };
20470
+ /**
18886
20471
  * Gateway factory for the FRP provider.
18887
20472
  *
18888
20473
  * The public-CA entry keeps the ordinary Caddy-fronted remote gateway. The
@@ -18935,6 +20520,7 @@ async function apply(ctx, config) {
18935
20520
  const cloudflaredStore = new JsonMobileAccessControlStore(join(remoteDirectory, "cloudflared", "control.json"), false);
18936
20521
  const frpStore = new JsonMobileAccessControlStore(join(remoteDirectory, "frp", "control.json"), false);
18937
20522
  const originStore = new JsonMobileAccessControlStore(join(remoteDirectory, "origin", "control.json"), false);
20523
+ const irohStore = new JsonMobileAccessControlStore(join(remoteDirectory, "iroh", "control.json"), false);
18938
20524
  const caddyController = new CaddyController({
18939
20525
  store: createVolatileRemoteControlStore(),
18940
20526
  component: caddyComponent,
@@ -18952,6 +20538,35 @@ async function apply(ctx, config) {
18952
20538
  }),
18953
20539
  managed: caddyController
18954
20540
  });
20541
+ const irohController = new IrohController({
20542
+ store: irohStore,
20543
+ component: irohComponent,
20544
+ dataDirectory: join(remoteDirectory, "iroh", "sidecar"),
20545
+ createGateway: (endpointId) => createIrohGateway(endpointId),
20546
+ /**
20547
+ * Read the pairing CA the live gateway advertises. The fingerprint must
20548
+ * match the gateway instance id, otherwise a rotated CA would mint QR
20549
+ * codes no phone can pin.
20550
+ */
20551
+ resolvePairingCa: async () => {
20552
+ const gateway = irohController.gateway();
20553
+ if (gateway === void 0) throw new Error("iroh_not_ready");
20554
+ const paths = irohTlsPaths(remoteDeviceFile, stateDirectory);
20555
+ let pem;
20556
+ try {
20557
+ pem = await readFile(paths.caCertFile, "utf8");
20558
+ } catch (error) {
20559
+ throw new Error("iroh_pairing_failed", { cause: error });
20560
+ }
20561
+ const ca = new X509Certificate(pem);
20562
+ const fingerprint = ca.fingerprint256.replaceAll(":", "").toLowerCase();
20563
+ if (!ca.ca || fingerprint !== gateway.config.instanceId) throw new Error("iroh_pairing_failed");
20564
+ return {
20565
+ der: ca.raw,
20566
+ fingerprint
20567
+ };
20568
+ }
20569
+ });
18955
20570
  const remoteControllers = {
18956
20571
  tailscale: new FunnelController({
18957
20572
  store: tailscaleStore,
@@ -18993,7 +20608,8 @@ async function apply(ctx, config) {
18993
20608
  await gateway.refreshProvidedTls();
18994
20609
  }
18995
20610
  }),
18996
- origin: originController
20611
+ origin: originController,
20612
+ iroh: irohController
18997
20613
  };
18998
20614
  const remoteProviders = new RemoteProviderCoordinator(initialRemoteProvider, remoteControllers, remoteProviderStore);
18999
20615
  const remoteController = () => remoteProviders.controller();
@@ -19009,8 +20625,9 @@ async function apply(ctx, config) {
19009
20625
  cpolar: remoteControllers.cpolar.status(),
19010
20626
  cloudflared: remoteControllers.cloudflared.status(),
19011
20627
  frp: remoteControllers.frp.status(),
19012
- origin: remoteControllers.origin.status()
19013
- }, cpolarComponent.status(), cloudflaredComponent.status(), cloudflaredTunnel.status(), frpComponent.status(), frpConfig.status(), originConfig.status(), caddyComponent.status()),
20628
+ origin: remoteControllers.origin.status(),
20629
+ iroh: remoteControllers.iroh.status()
20630
+ }, cpolarComponent.status(), cloudflaredComponent.status(), cloudflaredTunnel.status(), frpComponent.status(), frpConfig.status(), originConfig.status(), caddyComponent.status(), irohComponent.status()),
19014
20631
  originMode: originController.mode(),
19015
20632
  caddyConfiguration: caddyConfig.status(),
19016
20633
  caddyState: caddyController.status()
@@ -19247,7 +20864,7 @@ async function apply(ctx, config) {
19247
20864
  }
19248
20865
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/provider`) {
19249
20866
  const body = await readJsonObject(request, 4096);
19250
- if (body.provider !== "tailscale" && body.provider !== "cpolar" && body.provider !== "cloudflared" && body.provider !== "frp" && body.provider !== "origin") throw new HttpError(400, "bad_request");
20867
+ if (body.provider !== "tailscale" && body.provider !== "cpolar" && body.provider !== "cloudflared" && body.provider !== "frp" && body.provider !== "origin" && body.provider !== "iroh") throw new HttpError(400, "bad_request");
19251
20868
  await remoteProviders.select(body.provider);
19252
20869
  sendJson(response, 200, remotePayload(), false);
19253
20870
  return;
@@ -19380,10 +20997,14 @@ async function apply(ctx, config) {
19380
20997
  }
19381
20998
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cloudflared/tunnel`) {
19382
20999
  const body = await readJsonObject(request, 8192);
19383
- await remoteProviders.mutate(async () => {
21000
+ if (body.connect !== void 0 && typeof body.connect !== "boolean") throw new HttpError(400, "bad_request");
21001
+ const configure = async () => {
19384
21002
  await cloudflaredTunnel.configure(mergeSavedCloudflaredTunnelSettings(body, cloudflaredTunnel.settings()));
19385
- if (remoteControllers.cloudflared.status().enabled) await remoteControllers.cloudflared.reconnect();
19386
- });
21003
+ if (body.connect === true && !remoteControllers.cloudflared.status().enabled) await remoteControllers.cloudflared.setEnabled(true);
21004
+ else if (remoteControllers.cloudflared.status().enabled) await remoteControllers.cloudflared.reconnect();
21005
+ };
21006
+ if (body.connect === true) await remoteProviders.mutateSelected("cloudflared", configure);
21007
+ else await remoteProviders.mutate(configure);
19387
21008
  sendJson(response, 200, remotePayload(), false);
19388
21009
  return;
19389
21010
  }
@@ -19418,6 +21039,59 @@ async function apply(ctx, config) {
19418
21039
  sendJson(response, 200, remotePayload(), false);
19419
21040
  return;
19420
21041
  }
21042
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/iroh/component/install`) {
21043
+ if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
21044
+ logger.info("iroh component install started");
21045
+ try {
21046
+ await remoteProviders.mutate(async () => irohComponent.install());
21047
+ logger.info("iroh component install completed");
21048
+ } catch (error) {
21049
+ logger.error("iroh component install failed: %s", error instanceof Error ? error.stack ?? error.message : String(error));
21050
+ throw error;
21051
+ }
21052
+ sendJson(response, 200, remotePayload(), false);
21053
+ return;
21054
+ }
21055
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/iroh/component/purge`) {
21056
+ if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
21057
+ logger.info("iroh component purge started");
21058
+ try {
21059
+ await remoteProviders.mutate(async () => {
21060
+ if (remoteControllers.iroh.status().enabled) throw new HttpError(409, "iroh_component_running");
21061
+ await remoteControllers.iroh.setEnabled(false);
21062
+ await irohComponent.purge();
21063
+ });
21064
+ logger.info("iroh component purge completed");
21065
+ } catch (error) {
21066
+ logger.error("iroh component purge failed: %s", error instanceof Error ? error.stack ?? error.message : String(error));
21067
+ throw error;
21068
+ }
21069
+ sendJson(response, 200, remotePayload(), false);
21070
+ return;
21071
+ }
21072
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/iroh/pairing/begin`) {
21073
+ await readJsonObject(request, 4096);
21074
+ logger.info("iroh pairing window requested");
21075
+ const pairing = await remoteProviders.mutateSelected("iroh", async () => irohController.beginPairing()).catch((error) => {
21076
+ const code = error instanceof Error ? error.message : String(error);
21077
+ logger.error("iroh pairing failed: %s", code);
21078
+ if (error instanceof HttpError) throw error;
21079
+ throw new HttpError(409, code === "iroh_not_ready" ? "iroh_not_ready" : "iroh_pairing_failed");
21080
+ });
21081
+ let qrSvg = "";
21082
+ try {
21083
+ qrSvg = await QRCode.toString(pairing.encoded, {
21084
+ type: "svg",
21085
+ margin: 1
21086
+ });
21087
+ } catch {}
21088
+ logger.info("iroh pairing window opened");
21089
+ sendJson(response, 201, {
21090
+ ...pairing.descriptor,
21091
+ qrSvg
21092
+ }, false);
21093
+ return;
21094
+ }
19421
21095
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/attach-plan`) {
19422
21096
  const settings = mergeSavedFrpSettings(await readJsonObject(request, 8192), frpConfig.settings());
19423
21097
  const options = {
@@ -19560,9 +21234,15 @@ async function apply(ctx, config) {
19560
21234
  return;
19561
21235
  }
19562
21236
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/control`) {
19563
- const running = (await readJsonObject(request, 4096)).running;
21237
+ const body = await readJsonObject(request, 4096);
21238
+ const running = body.running;
19564
21239
  if (typeof running !== "boolean") throw new HttpError(400, "bad_request");
19565
- await remoteProviders.mutate(async (controller) => controller.setEnabled(running));
21240
+ if (body.provider !== void 0 && body.provider !== "tailscale" && body.provider !== "cpolar" && body.provider !== "cloudflared" && body.provider !== "frp" && body.provider !== "origin" && body.provider !== "iroh") throw new HttpError(400, "bad_request");
21241
+ const control = async (controller) => {
21242
+ await controller.setEnabled(running);
21243
+ };
21244
+ if (body.provider !== void 0) await remoteProviders.mutateSelected(body.provider, control);
21245
+ else await remoteProviders.mutate(control);
19566
21246
  sendJson(response, 200, remotePayload(), false);
19567
21247
  return;
19568
21248
  }
@@ -19668,7 +21348,8 @@ async function apply(ctx, config) {
19668
21348
  cpolar: cpolarStore,
19669
21349
  cloudflared: cloudflaredStore,
19670
21350
  frp: frpStore,
19671
- origin: originStore
21351
+ origin: originStore,
21352
+ iroh: irohStore
19672
21353
  };
19673
21354
  const prepared = await prepareDisabledRemoteProviders(remoteProviders.selected, stores);
19674
21355
  for (const provider of REMOTE_PROVIDERS) if (provider === remoteProviders.selected) await remoteControllers[provider].initialize();
@@ -19681,6 +21362,7 @@ async function apply(ctx, config) {
19681
21362
  await settleCleanupSteps([
19682
21363
  stopAdmin,
19683
21364
  drainRemoteWork,
21365
+ () => releaseManager.dispose(),
19684
21366
  () => trustedNetworksWrite,
19685
21367
  disposeMobileCommand,
19686
21368
  disposeTaskEvents,
@@ -19701,6 +21383,7 @@ async function apply(ctx, config) {
19701
21383
  await settleCleanupSteps([
19702
21384
  stopAdmin,
19703
21385
  drainRemoteWork,
21386
+ () => releaseManager.dispose(),
19704
21387
  () => trustedNetworksWrite,
19705
21388
  disposeMobileCommand,
19706
21389
  disposeTaskEvents,
@@ -19716,6 +21399,6 @@ async function apply(ctx, config) {
19716
21399
  }, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
19717
21400
  }
19718
21401
  //#endregion
19719
- 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_ORIGIN_LISTEN_PORT, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_ATTACH_DISCOVERY_PATH, FRP_ATTACH_LOCAL_DECLARATION, FRP_ATTACH_SELF_SIGNED_DECLARATION, FRP_ATTACH_VPS_DECLARATION, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FRP_DEFAULT_PROXY_NAME, FRP_DEFAULT_PUBLIC_PORT, FRP_RESERVED_PORTS, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, OriginConfigStore, OriginController, REMOTE_PROVIDERS, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpAttachPlan, createFrpAttachTemplate, createFrpAttachTemplateParts, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, ensureFrpIngressCertificate, evaluateCertificateLifetime, frpAttachVpsParts, frpIngressPaths, frpIngressSelfCheck, frpProxyName, inject, isCloudflaredRegistration, isFrpSelfSignedIngress, isGloballyRoutableIpv4, isLoopbackAddress, issueServerCertificate, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, probeOriginCertificate, readCertificateRenewal, resolveAuthority, resolveFrpEntryTls, resolveFrpMode, resolveFrpPublicPort, resolveFrpVhostHttpPort, resolveHostExecution, rewriteMobileIndex, validateAttachSettings, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpEntryTls, validateFrpMode, validateFrpProxyName, validateFrpPublicOrigin, validateFrpPublicPort, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateFrpVhostHttpPort, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validateWebSocketPath, watchTaskCompletions };
21402
+ 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 };
19720
21403
 
19721
21404
  //# sourceMappingURL=index.mjs.map