dsh-mobile 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs CHANGED
@@ -1246,7 +1246,10 @@ const EXTENSION_LIMITS = Object.freeze({
1246
1246
  manifest: 65536,
1247
1247
  script: 1048576,
1248
1248
  css: 524288,
1249
- asset: 8388608
1249
+ asset: 8388608,
1250
+ assetFiles: 256,
1251
+ assetBytes: 33554432,
1252
+ assetDepth: 8
1250
1253
  });
1251
1254
  /** A misbehaving host activation must not wedge the local watcher forever. */
1252
1255
  const HOST_ACTIVATION_TIMEOUT_MS = 5e3;
@@ -1331,7 +1334,7 @@ function normalizeRelativePath(value, field) {
1331
1334
  if (normalized.split("/").some((part) => part === "" || part === "." || part === "..")) throw new MobileExtensionError("invalid_extension_path", `${field} escapes extension directory`);
1332
1335
  return normalized;
1333
1336
  }
1334
- async function regularFile$1(path, maximum, field) {
1337
+ async function regularFile$2(path, maximum, field) {
1335
1338
  let info;
1336
1339
  try {
1337
1340
  info = await lstat(path);
@@ -1352,7 +1355,7 @@ async function containedPath(root, relativePath, maximum, field) {
1352
1355
  const targetReal = await realpath(target);
1353
1356
  const relation = relative(rootReal, targetReal);
1354
1357
  if (relation === "" || relation.startsWith("..") || isAbsolute(relation)) throw new MobileExtensionError("invalid_extension_path", `${field} escapes extension directory`);
1355
- return regularFile$1(targetReal, maximum, field);
1358
+ return regularFile$2(targetReal, maximum, field);
1356
1359
  }
1357
1360
  async function optionalFile(root, name, maximum, field) {
1358
1361
  try {
@@ -1390,7 +1393,9 @@ async function assetSnapshot(extensionRootReal) {
1390
1393
  const assetsReal = await realpath(assetsPath);
1391
1394
  assertRealPathWithin(extensionRootReal, assetsReal, "assets");
1392
1395
  const snapshots = /* @__PURE__ */ new Map();
1393
- const visit = async (directoryReal, prefix) => {
1396
+ let totalBytes = 0;
1397
+ const visit = async (directoryReal, prefix, depth) => {
1398
+ if (depth > EXTENSION_LIMITS.assetDepth) throw new MobileExtensionError("invalid_extension", "asset tree exceeds its depth limit");
1394
1399
  assertRealPathWithin(extensionRootReal, directoryReal, "asset directory");
1395
1400
  const handle = await opendir(directoryReal);
1396
1401
  const entries = [];
@@ -1408,11 +1413,13 @@ async function assetSnapshot(extensionRootReal) {
1408
1413
  assertRealPathWithin(extensionRootReal, targetReal, "asset");
1409
1414
  const key = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
1410
1415
  if (info.isDirectory()) {
1411
- await visit(targetReal, key);
1416
+ await visit(targetReal, key, depth + 1);
1412
1417
  continue;
1413
1418
  }
1414
1419
  if (!info.isFile() || info.size > EXTENSION_LIMITS.asset) throw new MobileExtensionError("invalid_extension", "asset must be a regular file within its size limit");
1415
1420
  const body = await readFile(targetReal);
1421
+ totalBytes += body.byteLength;
1422
+ if (snapshots.size >= EXTENSION_LIMITS.assetFiles || totalBytes > EXTENSION_LIMITS.assetBytes) throw new MobileExtensionError("invalid_extension", "asset tree exceeds its aggregate limit");
1416
1423
  snapshots.set(key, Object.freeze({
1417
1424
  body,
1418
1425
  digest: createHash("sha256").update(body).digest("hex"),
@@ -1420,12 +1427,12 @@ async function assetSnapshot(extensionRootReal) {
1420
1427
  }));
1421
1428
  }
1422
1429
  };
1423
- await visit(assetsReal, "");
1430
+ await visit(assetsReal, "", 0);
1424
1431
  return snapshots;
1425
1432
  }
1426
1433
  async function extensionFingerprint(directory) {
1427
1434
  const root = await realExtensionRoot(directory);
1428
- const manifestFile = await regularFile$1(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
1435
+ const manifestFile = await regularFile$2(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
1429
1436
  const manifestBody = await readFile(manifestFile.path);
1430
1437
  const manifest = parseExtensionManifest(JSON.parse(manifestBody.toString("utf8")));
1431
1438
  if (manifest.id !== basename(root)) throw new MobileExtensionError("invalid_manifest", "extension id must match its directory name");
@@ -1915,7 +1922,7 @@ async function abortAndDisposeLocal(entries) {
1915
1922
  }
1916
1923
  async function loadLocalExtension(directory, context, known, parentSignal) {
1917
1924
  const root = await realExtensionRoot(directory);
1918
- const manifestFile = await regularFile$1(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
1925
+ const manifestFile = await regularFile$2(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
1919
1926
  const manifest = known?.manifest ?? parseExtensionManifest(JSON.parse(await readFile(manifestFile.path, "utf8")));
1920
1927
  if (manifest.id !== basename(root)) throw new MobileExtensionError("invalid_manifest", "extension id must match its directory name");
1921
1928
  const scriptBody = known === void 0 ? await optionalFile(root, "mobile.js", EXTENSION_LIMITS.script, "mobile.js").then((path) => path === void 0 ? void 0 : readFile(path)) : known.scriptBody;
@@ -3309,8 +3316,10 @@ var MobileAccessGateway = class {
3309
3316
  }
3310
3317
  }
3311
3318
  async sendExtensionResponse(response, result, head) {
3319
+ const status = result.status ?? 200;
3320
+ if (!Number.isSafeInteger(status) || status < 200 || status > 599) throw new MobileExtensionError("invalid_route_response", "extension returned an invalid HTTP status", 500);
3312
3321
  const contentType = result.contentType ?? "application/octet-stream";
3313
- if (!/^[\w!#$&+.^-]+\/[\w!#$&+.^-]+(?:;[\s\S]*)?$/u.test(contentType)) throw new MobileExtensionError("invalid_route_response", "extension returned an invalid content type", 500);
3322
+ if (contentType.length > 1024 || !/^[\x20-\x7e]+$/u.test(contentType) || !/^[\w!#$&+.^-]+\/[\w!#$&+.^-]+(?:;[\x20-\x7e]*)?$/u.test(contentType)) throw new MobileExtensionError("invalid_route_response", "extension returned an invalid content type", 500);
3314
3323
  const safeHeaders = {};
3315
3324
  for (const [name, value] of Object.entries(result.headers ?? {})) {
3316
3325
  if (!/^(?:content-disposition|cache-control|etag)$/iu.test(name) || /[\r\n]/u.test(value)) continue;
@@ -3320,7 +3329,7 @@ var MobileAccessGateway = class {
3320
3329
  if (typeof result.body === "string" || result.body instanceof Uint8Array) {
3321
3330
  const body = typeof result.body === "string" ? Buffer.from(result.body) : Buffer.from(result.body);
3322
3331
  if (body.byteLength > 4194304) throw new MobileExtensionError("extension_result_too_large", "extension response is too large", 500);
3323
- response.writeHead(result.status ?? 200, {
3332
+ response.writeHead(status, {
3324
3333
  ...safeHeaders,
3325
3334
  "Content-Type": contentType,
3326
3335
  "Content-Length": body.byteLength
@@ -3329,7 +3338,7 @@ var MobileAccessGateway = class {
3329
3338
  else response.end(body);
3330
3339
  return;
3331
3340
  }
3332
- response.writeHead(result.status ?? 200, {
3341
+ response.writeHead(status, {
3333
3342
  ...safeHeaders,
3334
3343
  "Content-Type": contentType
3335
3344
  });
@@ -4160,6 +4169,1184 @@ var MemoryDeviceStore = class {
4160
4169
  }
4161
4170
  };
4162
4171
  //#endregion
4172
+ //#region src/frp-component.ts
4173
+ const FRP_VERSION = "0.70.1";
4174
+ const MAX_ARCHIVE_ENTRIES = 128;
4175
+ const MAX_ARCHIVE_LIST_BYTES = 262144;
4176
+ /** Pinned official FRP release metadata for supported desktop targets. */
4177
+ const FRP_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
4178
+ {
4179
+ platform: "win32",
4180
+ arch: "x64",
4181
+ archiveName: "frp.zip",
4182
+ executableName: "frpc.exe",
4183
+ downloadBytes: 13924309,
4184
+ downloadSha256: "531f3cd3cc41c0b4f077b54fe6b7dd83c0ff727e7f0bf412a4c78fa279165de5",
4185
+ downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/frp_${FRP_VERSION}_windows_amd64.zip`
4186
+ },
4187
+ {
4188
+ platform: "win32",
4189
+ arch: "arm64",
4190
+ archiveName: "frp.zip",
4191
+ executableName: "frpc.exe",
4192
+ downloadBytes: 12204751,
4193
+ downloadSha256: "74d3acaf0f03ee190dd0462f9b49861dca50b0559c5488af4b36572fc951fcca",
4194
+ downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/frp_${FRP_VERSION}_windows_arm64.zip`
4195
+ },
4196
+ {
4197
+ platform: "linux",
4198
+ arch: "x64",
4199
+ archiveName: "frp.tar.gz",
4200
+ executableName: "frpc",
4201
+ downloadBytes: 13924042,
4202
+ downloadSha256: "333da23d1b9009d7c01638e9ba38cf4600f7d37d393f854e96ee1396adefa9a6",
4203
+ downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/frp_${FRP_VERSION}_linux_amd64.tar.gz`
4204
+ },
4205
+ {
4206
+ platform: "linux",
4207
+ arch: "arm64",
4208
+ archiveName: "frp.tar.gz",
4209
+ executableName: "frpc",
4210
+ downloadBytes: 12371290,
4211
+ downloadSha256: "3990f396a9a490ee7f0e5f355287750ed41520064ed999eab443b5e9a78d773d",
4212
+ downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/frp_${FRP_VERSION}_linux_arm64.tar.gz`
4213
+ },
4214
+ {
4215
+ platform: "darwin",
4216
+ arch: "x64",
4217
+ archiveName: "frp.tar.gz",
4218
+ executableName: "frpc",
4219
+ downloadBytes: 13951979,
4220
+ downloadSha256: "cbf69cf26e5553e914e97d37f5d4367fa30f5f531d073a889465af4719281e25",
4221
+ downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/frp_${FRP_VERSION}_darwin_amd64.tar.gz`
4222
+ },
4223
+ {
4224
+ platform: "darwin",
4225
+ arch: "arm64",
4226
+ archiveName: "frp.tar.gz",
4227
+ executableName: "frpc",
4228
+ downloadBytes: 12670664,
4229
+ downloadSha256: "cfa733b5a261c1647edee3c1fc4133d2542989b28f5602e81d47fc821d25c55f",
4230
+ downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/frp_${FRP_VERSION}_darwin_arm64.tar.gz`
4231
+ }
4232
+ ].map((release) => [`${release.platform}-${release.arch}`, Object.freeze(release)])));
4233
+ function inside$1(parent, child) {
4234
+ const candidate = relative(parent, child);
4235
+ return candidate !== "" && !candidate.startsWith("..") && !isAbsolute(candidate);
4236
+ }
4237
+ async function regularFile$1(file) {
4238
+ try {
4239
+ const entry = await lstat(file);
4240
+ return entry.isFile() && !entry.isSymbolicLink();
4241
+ } catch (error) {
4242
+ if (error.code === "ENOENT") return false;
4243
+ throw error;
4244
+ }
4245
+ }
4246
+ async function replaceDirectory(target, candidate) {
4247
+ const backup = `${target}.previous-${randomBytes(12).toString("hex")}`;
4248
+ let previous = false;
4249
+ try {
4250
+ try {
4251
+ await rename(target, backup);
4252
+ previous = true;
4253
+ } catch (error) {
4254
+ if (error.code !== "ENOENT") throw error;
4255
+ }
4256
+ try {
4257
+ await rename(candidate, target);
4258
+ } catch (error) {
4259
+ if (previous) try {
4260
+ await rename(backup, target);
4261
+ } catch (restoreError) {
4262
+ throw new AggregateError([error, restoreError], "frp_component_replace_failed");
4263
+ }
4264
+ throw error;
4265
+ }
4266
+ if (previous) await rm(backup, {
4267
+ recursive: true,
4268
+ force: true
4269
+ });
4270
+ } finally {
4271
+ await rm(candidate, {
4272
+ recursive: true,
4273
+ force: true
4274
+ });
4275
+ }
4276
+ }
4277
+ function sha256$1(bytes) {
4278
+ return createHash("sha256").update(bytes).digest("hex");
4279
+ }
4280
+ async function runCapture(file, args) {
4281
+ return new Promise((resolveRun, reject) => {
4282
+ execFile(file, [...args], {
4283
+ windowsHide: true,
4284
+ timeout: 12e4,
4285
+ maxBuffer: MAX_ARCHIVE_LIST_BYTES,
4286
+ encoding: "utf8"
4287
+ }, (error, stdout) => {
4288
+ if (error === null) resolveRun(stdout);
4289
+ else reject(error);
4290
+ });
4291
+ });
4292
+ }
4293
+ function validatedArchiveEntry(rawEntry) {
4294
+ if (rawEntry.length === 0 || rawEntry.includes("\\") || rawEntry.includes("\0") || rawEntry.startsWith("/") || /^[a-zA-Z]:/u.test(rawEntry)) throw new Error("frp_archive_path_invalid");
4295
+ const segments = rawEntry.replace(/\/$/u, "").split("/");
4296
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) throw new Error("frp_archive_path_invalid");
4297
+ return segments;
4298
+ }
4299
+ /** Select exactly one nested frpc executable from a safe archive listing. */
4300
+ function selectFrpExecutableEntry(entries, executableName) {
4301
+ if (entries.length === 0 || entries.length > MAX_ARCHIVE_ENTRIES) throw new Error("frp_archive_entries_invalid");
4302
+ let executableEntry;
4303
+ for (const entry of entries) {
4304
+ const segments = validatedArchiveEntry(entry);
4305
+ if (segments.length >= 2 && segments.at(-1) === executableName) {
4306
+ if (executableEntry !== void 0) throw new Error("frp_archive_executable_ambiguous");
4307
+ executableEntry = entry.replace(/\/$/u, "");
4308
+ }
4309
+ }
4310
+ if (executableEntry === void 0) throw new Error("frp_archive_executable_missing");
4311
+ return executableEntry;
4312
+ }
4313
+ async function defaultExtractArtifact$1(archive, destination, executableName) {
4314
+ const tar = process.platform === "win32" ? "tar.exe" : "tar";
4315
+ const executableEntry = selectFrpExecutableEntry((await runCapture(tar, ["-tf", archive])).split(/\r?\n/u).filter((entry) => entry.length > 0), executableName);
4316
+ const unpacked = join(destination, "archive");
4317
+ await mkdir(unpacked, {
4318
+ recursive: true,
4319
+ mode: 448
4320
+ });
4321
+ await runCapture(tar, [
4322
+ "-xf",
4323
+ archive,
4324
+ "-C",
4325
+ unpacked,
4326
+ executableEntry
4327
+ ]);
4328
+ const extracted = join(unpacked, ...validatedArchiveEntry(executableEntry));
4329
+ if (!await regularFile$1(extracted)) throw new Error("frp_archive_executable_invalid");
4330
+ await copyFile(extracted, join(destination, executableName));
4331
+ }
4332
+ async function defaultFetchArtifact$1(artifact, signal) {
4333
+ const response = await fetch(artifact.downloadUrl, {
4334
+ redirect: "follow",
4335
+ signal
4336
+ });
4337
+ if (!response.ok) throw new Error(`frp_download_http_${String(response.status)}`);
4338
+ const finalUrl = new URL(response.url);
4339
+ const officialHost = finalUrl.hostname === "github.com" || finalUrl.hostname.endsWith(".githubusercontent.com");
4340
+ if (finalUrl.protocol !== "https:" || !officialHost) throw new Error("frp_download_origin_invalid");
4341
+ const lengthHeader = response.headers.get("content-length");
4342
+ const declaredLength = lengthHeader === null ? void 0 : Number(lengthHeader);
4343
+ if (declaredLength !== void 0 && (!Number.isFinite(declaredLength) || declaredLength !== artifact.downloadBytes)) throw new Error("frp_download_size_mismatch");
4344
+ if (response.body === null) throw new Error("frp_download_empty");
4345
+ const chunks = [];
4346
+ let received = 0;
4347
+ const reader = response.body.getReader();
4348
+ while (true) {
4349
+ const result = await reader.read();
4350
+ if (result.done) break;
4351
+ received += result.value.byteLength;
4352
+ if (received > artifact.downloadBytes) {
4353
+ await reader.cancel();
4354
+ throw new Error("frp_download_size_mismatch");
4355
+ }
4356
+ chunks.push(result.value);
4357
+ }
4358
+ if (received !== artifact.downloadBytes) throw new Error("frp_download_size_mismatch");
4359
+ const bytes = new Uint8Array(received);
4360
+ let offset = 0;
4361
+ for (const chunk of chunks) {
4362
+ bytes.set(chunk, offset);
4363
+ offset += chunk.byteLength;
4364
+ }
4365
+ return bytes;
4366
+ }
4367
+ async function defaultInspectExecutable(executable) {
4368
+ return (await runCapture(executable, ["--version"])).trim();
4369
+ }
4370
+ /** Owns the optional official frpc binary inside the DSH Mobile state directory. */
4371
+ var FrpComponentManager = class {
4372
+ executable;
4373
+ componentRoot;
4374
+ componentStorage;
4375
+ logRoot;
4376
+ stagingRoot;
4377
+ artifact;
4378
+ fetchArtifact;
4379
+ extractArtifact;
4380
+ inspectExecutable;
4381
+ installed = false;
4382
+ installedBytes = 0;
4383
+ errorCode;
4384
+ queue = Promise.resolve();
4385
+ constructor(options) {
4386
+ const stateDirectory = resolve(options.stateDirectory);
4387
+ if (!isAbsolute(stateDirectory)) throw new Error("frp state directory must be absolute");
4388
+ const platform = options.platform ?? process.platform;
4389
+ const arch = options.arch ?? process.arch;
4390
+ this.artifact = FRP_COMPONENT_RELEASES[`${platform}-${arch}`];
4391
+ this.componentRoot = join(stateDirectory, "components", "frp");
4392
+ this.componentStorage = join(this.componentRoot, FRP_VERSION);
4393
+ this.executable = join(this.componentStorage, platform === "win32" ? "frpc.exe" : "frpc");
4394
+ this.logRoot = join(stateDirectory, "logs", "frp");
4395
+ this.stagingRoot = join(stateDirectory, "staging", "frp");
4396
+ for (const child of [
4397
+ this.componentRoot,
4398
+ this.componentStorage,
4399
+ this.logRoot,
4400
+ this.stagingRoot
4401
+ ]) if (!inside$1(stateDirectory, child)) throw new Error("frp component path escaped its state directory");
4402
+ this.fetchArtifact = options.fetchArtifact ?? defaultFetchArtifact$1;
4403
+ this.extractArtifact = options.extractArtifact ?? defaultExtractArtifact$1;
4404
+ this.inspectExecutable = options.inspectExecutable ?? defaultInspectExecutable;
4405
+ }
4406
+ /** Inspect the managed executable without relying on global FRP installations. */
4407
+ async initialize() {
4408
+ this.installed = await regularFile$1(this.executable);
4409
+ this.installedBytes = this.installed ? (await stat(this.executable)).size : 0;
4410
+ if (this.installed) try {
4411
+ if (await this.inspectExecutable(this.executable) !== FRP_VERSION) throw new Error("frp_component_version_mismatch");
4412
+ this.errorCode = void 0;
4413
+ } catch {
4414
+ this.installed = false;
4415
+ this.errorCode = "frp_component_invalid";
4416
+ }
4417
+ }
4418
+ /** Return component metadata without exposing configuration or credentials. */
4419
+ status() {
4420
+ return Object.freeze({
4421
+ supported: this.artifact !== void 0,
4422
+ installed: this.installed,
4423
+ version: FRP_VERSION,
4424
+ downloadBytes: this.artifact?.downloadBytes ?? 0,
4425
+ installedBytes: this.installedBytes,
4426
+ sourceUrl: this.artifact?.downloadUrl ?? "https://github.com/fatedier/frp/releases",
4427
+ releasePage: `https://github.com/fatedier/frp/releases/tag/v${FRP_VERSION}`,
4428
+ storagePath: this.componentRoot,
4429
+ ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
4430
+ });
4431
+ }
4432
+ /** Download, verify, and extract only frpc after explicit confirmation. */
4433
+ install() {
4434
+ return this.enqueue(async () => {
4435
+ const artifact = this.artifact;
4436
+ if (artifact === void 0) throw new Error("frp_component_unsupported");
4437
+ await mkdir(this.stagingRoot, {
4438
+ recursive: true,
4439
+ mode: 448
4440
+ });
4441
+ const staging = await mkdtemp(join(this.stagingRoot, "install-"));
4442
+ try {
4443
+ const controller = new AbortController();
4444
+ const timeout = setTimeout(() => {
4445
+ controller.abort();
4446
+ }, 12e4);
4447
+ timeout.unref();
4448
+ let bytes;
4449
+ try {
4450
+ bytes = await this.fetchArtifact(artifact, controller.signal);
4451
+ } finally {
4452
+ clearTimeout(timeout);
4453
+ }
4454
+ if (bytes.byteLength !== artifact.downloadBytes) throw new Error("frp_download_size_mismatch");
4455
+ if (sha256$1(bytes) !== artifact.downloadSha256) throw new Error("frp_download_hash_mismatch");
4456
+ const archive = join(staging, artifact.archiveName);
4457
+ await writeFile(archive, bytes, {
4458
+ flag: "wx",
4459
+ mode: 384
4460
+ });
4461
+ await this.extractArtifact(archive, staging, artifact.executableName);
4462
+ const extracted = join(staging, artifact.executableName);
4463
+ if (!await regularFile$1(extracted)) throw new Error("frp_executable_missing");
4464
+ await chmod(extracted, 448);
4465
+ if (await this.inspectExecutable(extracted) !== FRP_VERSION) throw new Error("frp_component_version_mismatch");
4466
+ const candidate = join(this.componentRoot, `.install-${randomBytes(12).toString("hex")}`);
4467
+ await mkdir(candidate, {
4468
+ recursive: true,
4469
+ mode: 448
4470
+ });
4471
+ const candidateExecutable = join(candidate, artifact.executableName);
4472
+ await copyFile(extracted, candidateExecutable);
4473
+ await chmod(candidateExecutable, 448);
4474
+ await replaceDirectory(this.componentStorage, candidate);
4475
+ this.installed = true;
4476
+ this.installedBytes = (await stat(this.executable)).size;
4477
+ this.errorCode = void 0;
4478
+ } finally {
4479
+ await rm(staging, {
4480
+ recursive: true,
4481
+ force: true
4482
+ });
4483
+ }
4484
+ });
4485
+ }
4486
+ /** Remove all FRP executable, staging, and log files owned by DSH Mobile. */
4487
+ purge() {
4488
+ return this.enqueue(async () => {
4489
+ await Promise.all([
4490
+ rm(this.componentRoot, {
4491
+ recursive: true,
4492
+ force: true
4493
+ }),
4494
+ rm(this.logRoot, {
4495
+ recursive: true,
4496
+ force: true
4497
+ }),
4498
+ rm(this.stagingRoot, {
4499
+ recursive: true,
4500
+ force: true
4501
+ })
4502
+ ]);
4503
+ this.installed = false;
4504
+ this.installedBytes = 0;
4505
+ this.errorCode = void 0;
4506
+ });
4507
+ }
4508
+ enqueue(operation) {
4509
+ const task = this.queue.then(operation, operation);
4510
+ this.queue = task.then(() => void 0, () => void 0);
4511
+ return task.then(() => this.status());
4512
+ }
4513
+ };
4514
+ //#endregion
4515
+ //#region src/frp-template.ts
4516
+ /** Loopback-only HTTP vhost port used between Caddy and frps. */
4517
+ const FRP_VHOST_HTTP_PORT = 7080;
4518
+ function publicDnsHostname(value) {
4519
+ return value.length <= 253 && value.includes(".") && !/^[0-9.]+$/u.test(value) && !value.includes(":") && value.split(".").every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label));
4520
+ }
4521
+ /** Build the only supported frps and Caddy configuration from validated user inputs. */
4522
+ function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
4523
+ if (!Number.isSafeInteger(serverPort) || serverPort < 1 || serverPort > 65535 || token.length < 16 || token.length > 512 || /[\s\u0000-\u001f\u007f]/u.test(token)) throw new Error("frp_template_input_invalid");
4524
+ let url;
4525
+ try {
4526
+ url = new URL(publicOrigin);
4527
+ } catch {
4528
+ throw new Error("frp_template_input_invalid");
4529
+ }
4530
+ if (url.protocol !== "https:" || url.port !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "" || !publicDnsHostname(url.hostname)) throw new Error("frp_template_input_invalid");
4531
+ return [
4532
+ "# frps.toml",
4533
+ `bindPort = ${String(serverPort)}`,
4534
+ "proxyBindAddr = \"127.0.0.1\"",
4535
+ `vhostHTTPPort = ${String(FRP_VHOST_HTTP_PORT)}`,
4536
+ "auth.method = \"token\"",
4537
+ `auth.token = ${JSON.stringify(token)}`,
4538
+ "",
4539
+ "# Caddyfile",
4540
+ `${url.hostname} {`,
4541
+ ` reverse_proxy 127.0.0.1:${String(FRP_VHOST_HTTP_PORT)}`,
4542
+ "}",
4543
+ ""
4544
+ ].join("\n");
4545
+ }
4546
+ //#endregion
4547
+ //#region src/frp-config.ts
4548
+ const MAX_SETTINGS_BYTES = 8192;
4549
+ function hostname$1(value) {
4550
+ if (value.length > 253 || !value.includes(".")) return false;
4551
+ return value.split(".").every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label));
4552
+ }
4553
+ /** Validate the FRP server hostname or IP address. */
4554
+ function validateFrpServerAddress(value) {
4555
+ 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");
4556
+ const normalized = value.toLowerCase().replace(/\.$/u, "");
4557
+ if (isIP(normalized) === 0 && !hostname$1(normalized)) throw new Error("frp_server_address_invalid");
4558
+ return normalized;
4559
+ }
4560
+ /** Validate the FRP control port. */
4561
+ function validateFrpServerPort(value) {
4562
+ if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > 65535) throw new Error("frp_server_port_invalid");
4563
+ return Number(value);
4564
+ }
4565
+ /** Validate a high-entropy FRP token before durable storage. */
4566
+ function validateFrpToken(value) {
4567
+ if (typeof value !== "string" || value.length < 16 || value.length > 512 || /[\s\u0000-\u001f\u007f]/u.test(value)) throw new Error("frp_token_invalid");
4568
+ return value;
4569
+ }
4570
+ /** Validate the public HTTPS origin used by Caddy and Android pairing. */
4571
+ function validateFrpPublicOrigin(value) {
4572
+ if (typeof value !== "string" || value.length > 512) throw new Error("frp_public_origin_invalid");
4573
+ let url;
4574
+ try {
4575
+ url = new URL(value);
4576
+ } catch {
4577
+ throw new Error("frp_public_origin_invalid");
4578
+ }
4579
+ if (url.protocol !== "https:" || url.port !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "" || isIP(url.hostname) !== 0 || !hostname$1(url.hostname)) throw new Error("frp_public_origin_invalid");
4580
+ return url.origin;
4581
+ }
4582
+ /** Parse FRP settings at the loopback request and filesystem boundaries. */
4583
+ function parseFrpSettings(value) {
4584
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("frp_settings_invalid");
4585
+ const record = value;
4586
+ if (Reflect.ownKeys(record).some((key) => ![
4587
+ "version",
4588
+ "serverAddress",
4589
+ "serverPort",
4590
+ "token",
4591
+ "publicOrigin"
4592
+ ].includes(String(key)))) throw new Error("frp_settings_invalid");
4593
+ if (record.version !== void 0 && record.version !== 1) throw new Error("frp_settings_invalid");
4594
+ return Object.freeze({
4595
+ version: 1,
4596
+ serverAddress: validateFrpServerAddress(record.serverAddress),
4597
+ serverPort: validateFrpServerPort(record.serverPort),
4598
+ token: validateFrpToken(record.token),
4599
+ publicOrigin: validateFrpPublicOrigin(record.publicOrigin)
4600
+ });
4601
+ }
4602
+ function tomlString(value) {
4603
+ return JSON.stringify(value);
4604
+ }
4605
+ /** Build the single-purpose frpc configuration for the current loopback gateway. */
4606
+ function createFrpcToml(settings, localPort) {
4607
+ if (!Number.isSafeInteger(localPort) || localPort < 1 || localPort > 65535) throw new Error("frp_local_port_invalid");
4608
+ const hostnameValue = new URL(settings.publicOrigin).hostname;
4609
+ return [
4610
+ `serverAddr = ${tomlString(settings.serverAddress)}`,
4611
+ `serverPort = ${String(settings.serverPort)}`,
4612
+ "auth.method = \"token\"",
4613
+ `auth.token = ${tomlString(settings.token)}`,
4614
+ "transport.tls.enable = true",
4615
+ "",
4616
+ "[[proxies]]",
4617
+ "name = \"dsh-mobile\"",
4618
+ "type = \"http\"",
4619
+ "localIP = \"127.0.0.1\"",
4620
+ `localPort = ${String(localPort)}`,
4621
+ `customDomains = [${tomlString(hostnameValue)}]`,
4622
+ "transport.useEncryption = true",
4623
+ "transport.useCompression = true",
4624
+ ""
4625
+ ].join("\n");
4626
+ }
4627
+ /** Build the matching restricted frps and Caddy templates for one VPS. */
4628
+ function createFrpServerTemplate(settings) {
4629
+ return createRestrictedFrpServerTemplate(settings.serverPort, settings.token, settings.publicOrigin);
4630
+ }
4631
+ async function atomicPrivateWrite(file, body) {
4632
+ const directory = dirname(file);
4633
+ await mkdir(directory, {
4634
+ recursive: true,
4635
+ mode: 448
4636
+ });
4637
+ try {
4638
+ const current = await lstat(file);
4639
+ if (!current.isFile() || current.isSymbolicLink()) throw new Error("frp_config_target_invalid");
4640
+ } catch (error) {
4641
+ if (error.code !== "ENOENT") throw error;
4642
+ }
4643
+ const temporary = join(directory, `.${basename(file)}.${randomBytes(12).toString("hex")}.tmp`);
4644
+ try {
4645
+ await writeFile(temporary, body, {
4646
+ encoding: "utf8",
4647
+ flag: "wx",
4648
+ mode: 384
4649
+ });
4650
+ await rename(temporary, file);
4651
+ await restrictPrivateFile(file);
4652
+ } catch (error) {
4653
+ await rm(temporary, { force: true });
4654
+ throw error;
4655
+ }
4656
+ }
4657
+ /** Owns private FRP settings and generation-specific frpc configuration. */
4658
+ var FrpConfigStore = class {
4659
+ stateRoot;
4660
+ settingsFile;
4661
+ runtimeConfigFile;
4662
+ settingsValue;
4663
+ errorCode;
4664
+ constructor(stateDirectory) {
4665
+ if (!isAbsolute(stateDirectory)) throw new Error("frp config state directory must be absolute");
4666
+ this.stateRoot = resolve(stateDirectory);
4667
+ this.settingsFile = join(this.stateRoot, "settings.json");
4668
+ this.runtimeConfigFile = join(this.stateRoot, "frpc.toml");
4669
+ }
4670
+ /** Load private settings while rejecting links, oversized files, and unknown fields. */
4671
+ async initialize() {
4672
+ let entry;
4673
+ try {
4674
+ entry = await lstat(this.settingsFile);
4675
+ } catch (error) {
4676
+ if (error.code === "ENOENT") return;
4677
+ throw error;
4678
+ }
4679
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_SETTINGS_BYTES) {
4680
+ this.errorCode = "frp_config_invalid";
4681
+ return;
4682
+ }
4683
+ await restrictPrivateFile(this.settingsFile);
4684
+ try {
4685
+ this.settingsValue = parseFrpSettings(JSON.parse(await readFile(this.settingsFile, "utf8")));
4686
+ this.errorCode = void 0;
4687
+ } catch {
4688
+ this.settingsValue = void 0;
4689
+ this.errorCode = "frp_config_invalid";
4690
+ }
4691
+ }
4692
+ /** Return configuration metadata without exposing the FRP token. */
4693
+ status() {
4694
+ const settings = this.settingsValue;
4695
+ return Object.freeze({
4696
+ configured: settings !== void 0,
4697
+ ...settings === void 0 ? {} : {
4698
+ serverAddress: settings.serverAddress,
4699
+ serverPort: settings.serverPort,
4700
+ publicOrigin: settings.publicOrigin
4701
+ },
4702
+ vhostHttpPort: FRP_VHOST_HTTP_PORT,
4703
+ storagePath: this.stateRoot,
4704
+ ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
4705
+ });
4706
+ }
4707
+ /** Return private settings only to the provider lifecycle. */
4708
+ settings() {
4709
+ return this.settingsValue;
4710
+ }
4711
+ /** Atomically replace private FRP settings. */
4712
+ async configure(value) {
4713
+ const settings = parseFrpSettings(value);
4714
+ await atomicPrivateWrite(this.settingsFile, `${JSON.stringify(settings)}\n`);
4715
+ await rm(this.runtimeConfigFile, { force: true });
4716
+ this.settingsValue = settings;
4717
+ this.errorCode = void 0;
4718
+ return this.status();
4719
+ }
4720
+ /** Materialize the private generation-specific frpc configuration. */
4721
+ async writeRuntimeConfig(localPort) {
4722
+ const settings = this.settingsValue;
4723
+ if (settings === void 0) throw new Error("frp_config_missing");
4724
+ await atomicPrivateWrite(this.runtimeConfigFile, createFrpcToml(settings, localPort));
4725
+ return this.runtimeConfigFile;
4726
+ }
4727
+ /** Remove only configuration files owned by the FRP provider. */
4728
+ async purge() {
4729
+ await rm(this.stateRoot, {
4730
+ recursive: true,
4731
+ force: true
4732
+ });
4733
+ this.settingsValue = void 0;
4734
+ this.errorCode = void 0;
4735
+ return this.status();
4736
+ }
4737
+ };
4738
+ //#endregion
4739
+ //#region src/remote.ts
4740
+ const REMOTE_PROVIDERS = [
4741
+ "tailscale",
4742
+ "cpolar",
4743
+ "frp"
4744
+ ];
4745
+ function aggregateErrors(errors, message) {
4746
+ if (errors.length === 0) return void 0;
4747
+ if (errors.length === 1 && errors[0] instanceof Error) return errors[0];
4748
+ return new AggregateError(errors, message);
4749
+ }
4750
+ /** Settle independent remote cleanup work before reporting any collected failure. */
4751
+ async function settleRemoteResources(steps, message = "remote resource cleanup failed") {
4752
+ const failure = aggregateErrors((await Promise.allSettled(steps.map(async (step) => step()))).filter((result) => result.status === "rejected").map((result) => result.reason), message);
4753
+ if (failure !== void 0) throw failure;
4754
+ }
4755
+ /**
4756
+ * Serialize all provider mutations and preserve the single-provider invariant.
4757
+ * Operations read the selected controller only after reaching the front of the queue.
4758
+ */
4759
+ var RemoteProviderCoordinator = class {
4760
+ controllers;
4761
+ store;
4762
+ selectedValue;
4763
+ queue = Promise.resolve();
4764
+ constructor(selected, controllers, store) {
4765
+ this.controllers = controllers;
4766
+ this.store = store;
4767
+ this.selectedValue = selected;
4768
+ }
4769
+ /** Return the durable provider currently selected by the desktop UI. */
4770
+ get selected() {
4771
+ return this.selectedValue;
4772
+ }
4773
+ /** Return the controller selected when this method is called. */
4774
+ controller() {
4775
+ return this.controllers[this.selectedValue];
4776
+ }
4777
+ /** Run a provider-owned mutation after all earlier provider work settles. */
4778
+ mutate(operation) {
4779
+ return this.enqueue(() => operation(this.controller()));
4780
+ }
4781
+ /** Disable the previous provider, persist the new selection, and retain rollback on write failure. */
4782
+ select(provider) {
4783
+ return this.enqueue(async () => {
4784
+ if (provider === this.selectedValue) return;
4785
+ const previous = this.controllers[this.selectedValue];
4786
+ const restore = previous.status().enabled;
4787
+ if (restore) await previous.setEnabled(false);
4788
+ try {
4789
+ await this.store.save({
4790
+ version: 1,
4791
+ provider
4792
+ });
4793
+ this.selectedValue = provider;
4794
+ } catch (error) {
4795
+ if (restore) try {
4796
+ await previous.setEnabled(true);
4797
+ } catch (restoreError) {
4798
+ throw new AggregateError([error, restoreError], "remote provider selection rollback failed");
4799
+ }
4800
+ throw error;
4801
+ }
4802
+ });
4803
+ }
4804
+ enqueue(operation) {
4805
+ const task = this.queue.then(() => this.runAndEnforce(operation), () => this.runAndEnforce(operation));
4806
+ this.queue = task.then(() => void 0, () => void 0);
4807
+ return task;
4808
+ }
4809
+ async runAndEnforce(operation) {
4810
+ let value;
4811
+ let operationError;
4812
+ try {
4813
+ value = await operation();
4814
+ } catch (error) {
4815
+ operationError = error;
4816
+ }
4817
+ const results = await Promise.allSettled(REMOTE_PROVIDERS.filter((provider) => provider !== this.selectedValue).map((provider) => this.controllers[provider].setEnabled(false)));
4818
+ const failure = aggregateErrors([...operationError === void 0 ? [] : [operationError], ...results.filter((result) => result.status === "rejected").map((result) => result.reason)], "remote provider operation failed");
4819
+ if (failure !== void 0) throw failure;
4820
+ return value;
4821
+ }
4822
+ };
4823
+ /** Stop an owned provider process and do not report completion before its close event. */
4824
+ async function terminateRemoteProcess(child, gracefulTimeoutMs = 1500, forcedTimeoutMs = 1500) {
4825
+ if (child.exitCode !== null || child.signalCode !== null) return;
4826
+ await new Promise((resolveClose, rejectClose) => {
4827
+ let gracefulTimer;
4828
+ let forcedTimer;
4829
+ let settled = false;
4830
+ const finish = (error) => {
4831
+ if (settled) return;
4832
+ settled = true;
4833
+ if (gracefulTimer !== void 0) clearTimeout(gracefulTimer);
4834
+ if (forcedTimer !== void 0) clearTimeout(forcedTimer);
4835
+ child.off("close", onClose);
4836
+ if (error === void 0) resolveClose();
4837
+ else rejectClose(error);
4838
+ };
4839
+ const onClose = () => {
4840
+ finish();
4841
+ };
4842
+ child.once("close", onClose);
4843
+ try {
4844
+ child.kill("SIGTERM");
4845
+ } catch (error) {
4846
+ finish(error instanceof Error ? error : new Error(String(error)));
4847
+ return;
4848
+ }
4849
+ if (settled) return;
4850
+ gracefulTimer = setTimeout(() => {
4851
+ try {
4852
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
4853
+ } catch (error) {
4854
+ finish(error instanceof Error ? error : new Error(String(error)));
4855
+ return;
4856
+ }
4857
+ if (settled) return;
4858
+ forcedTimer = setTimeout(() => {
4859
+ finish(/* @__PURE__ */ new Error("remote_process_stop_timeout"));
4860
+ }, forcedTimeoutMs);
4861
+ forcedTimer.unref();
4862
+ }, gracefulTimeoutMs);
4863
+ gracefulTimer.unref();
4864
+ });
4865
+ }
4866
+ /** Validate the provider selection loaded across the filesystem boundary. */
4867
+ function parseRemoteProviderState(value) {
4868
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("remote provider state must be an object");
4869
+ const record = value;
4870
+ if (record.version !== 1 || record.provider !== "tailscale" && record.provider !== "cpolar" && record.provider !== "frp" || Reflect.ownKeys(record).some((key) => key !== "version" && key !== "provider")) throw new Error("remote provider state has an unsupported format");
4871
+ return Object.freeze({
4872
+ version: 1,
4873
+ provider: record.provider
4874
+ });
4875
+ }
4876
+ /** Atomic selection store whose absent-file state uses the configured default. */
4877
+ var JsonRemoteProviderStore = class {
4878
+ file;
4879
+ defaultProvider;
4880
+ constructor(file, defaultProvider) {
4881
+ this.file = file;
4882
+ this.defaultProvider = defaultProvider;
4883
+ }
4884
+ async load() {
4885
+ let stat;
4886
+ try {
4887
+ stat = await lstat(this.file);
4888
+ } catch (error) {
4889
+ if (error.code === "ENOENT") return Object.freeze({
4890
+ version: 1,
4891
+ provider: this.defaultProvider
4892
+ });
4893
+ throw error;
4894
+ }
4895
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) throw new Error("remote provider state must be a regular file no larger than 4 KiB");
4896
+ await restrictPrivateFile(this.file);
4897
+ let parsed;
4898
+ try {
4899
+ parsed = JSON.parse(await readFile(this.file, "utf8"));
4900
+ } catch (error) {
4901
+ throw new Error("remote provider state is not valid JSON", { cause: error });
4902
+ }
4903
+ return parseRemoteProviderState(parsed);
4904
+ }
4905
+ async save(state) {
4906
+ const validated = parseRemoteProviderState(state);
4907
+ const directory = dirname(this.file);
4908
+ await mkdir(directory, {
4909
+ recursive: true,
4910
+ mode: 448
4911
+ });
4912
+ try {
4913
+ const current = await lstat(this.file);
4914
+ if (!current.isFile() || current.isSymbolicLink()) throw new Error("remote provider state target must remain a regular file");
4915
+ } catch (error) {
4916
+ if (error.code !== "ENOENT") throw error;
4917
+ }
4918
+ const temporary = join(directory, `.${basename(this.file)}.${randomBytes(12).toString("hex")}.tmp`);
4919
+ try {
4920
+ await writeFile(temporary, `${JSON.stringify(validated)}\n`, {
4921
+ encoding: "utf8",
4922
+ flag: "wx",
4923
+ mode: 384
4924
+ });
4925
+ await rename(temporary, this.file);
4926
+ await restrictPrivateFile(this.file);
4927
+ } catch (error) {
4928
+ await rm(temporary, { force: true });
4929
+ throw error;
4930
+ }
4931
+ }
4932
+ };
4933
+ /** Resolve the first-run provider without letting environment values bypass validation. */
4934
+ function configuredRemoteProvider(environment) {
4935
+ const value = environment.DSH_MOBILE_REMOTE_PROVIDER ?? "tailscale";
4936
+ if (value !== "tailscale" && value !== "cpolar" && value !== "frp") throw new Error("DSH_MOBILE_REMOTE_PROVIDER must be tailscale, cpolar, or frp");
4937
+ return value;
4938
+ }
4939
+ //#endregion
4940
+ //#region src/frp.ts
4941
+ const START_TIMEOUT_MS$1 = 45e3;
4942
+ const DISCOVERY_REQUEST_TIMEOUT_MS = 5e3;
4943
+ const DISCOVERY_RETRY_MS = 1e3;
4944
+ const MAX_DISCOVERY_BYTES = 16384;
4945
+ const VHOST_PROBE_TIMEOUT_MS = 1500;
4946
+ function publicStatus$2(status) {
4947
+ return Object.freeze({
4948
+ enabled: status.enabled,
4949
+ state: status.state,
4950
+ ...status.origin === void 0 ? {} : { origin: status.origin },
4951
+ ...status.errorCode === void 0 ? {} : { errorCode: status.errorCode }
4952
+ });
4953
+ }
4954
+ async function defaultVerifyConfig(executable, configFile) {
4955
+ await new Promise((resolveRun, reject) => {
4956
+ execFile(executable, [
4957
+ "verify",
4958
+ "-c",
4959
+ configFile
4960
+ ], {
4961
+ windowsHide: true,
4962
+ timeout: 3e4,
4963
+ maxBuffer: 65536
4964
+ }, (error) => {
4965
+ if (error === null) resolveRun();
4966
+ else reject(error);
4967
+ });
4968
+ });
4969
+ }
4970
+ function defaultLaunchClient(executable, configFile) {
4971
+ return spawn(executable, ["-c", configFile], {
4972
+ shell: false,
4973
+ stdio: [
4974
+ "pipe",
4975
+ "pipe",
4976
+ "pipe"
4977
+ ],
4978
+ windowsHide: true
4979
+ });
4980
+ }
4981
+ async function defaultProbeVhostExposure(serverAddress, port) {
4982
+ return new Promise((resolveProbe) => {
4983
+ const socket = connect({
4984
+ host: serverAddress,
4985
+ port
4986
+ });
4987
+ let finished = false;
4988
+ const finish = (exposed) => {
4989
+ if (finished) return;
4990
+ finished = true;
4991
+ clearTimeout(timer);
4992
+ socket.destroy();
4993
+ resolveProbe(exposed);
4994
+ };
4995
+ const timer = setTimeout(() => {
4996
+ finish(false);
4997
+ }, VHOST_PROBE_TIMEOUT_MS);
4998
+ timer.unref();
4999
+ socket.once("connect", () => {
5000
+ finish(true);
5001
+ });
5002
+ socket.once("error", () => {
5003
+ finish(false);
5004
+ });
5005
+ });
5006
+ }
5007
+ async function boundedResponseBytes(response) {
5008
+ if (response.body === null) throw new Error("frp_discovery_invalid");
5009
+ const declaredLength = Number(response.headers.get("content-length"));
5010
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_DISCOVERY_BYTES) throw new Error("frp_discovery_invalid");
5011
+ const reader = response.body.getReader();
5012
+ const chunks = [];
5013
+ let received = 0;
5014
+ while (true) {
5015
+ const result = await reader.read();
5016
+ if (result.done) break;
5017
+ received += result.value.byteLength;
5018
+ if (received > MAX_DISCOVERY_BYTES) {
5019
+ await reader.cancel();
5020
+ throw new Error("frp_discovery_invalid");
5021
+ }
5022
+ chunks.push(result.value);
5023
+ }
5024
+ const bytes = new Uint8Array(received);
5025
+ let offset = 0;
5026
+ for (const chunk of chunks) {
5027
+ bytes.set(chunk, offset);
5028
+ offset += chunk.byteLength;
5029
+ }
5030
+ return bytes;
5031
+ }
5032
+ async function defaultProbeDiscovery(origin, expectedInstanceId, signal) {
5033
+ const requestController = new AbortController();
5034
+ const abort = () => {
5035
+ requestController.abort();
5036
+ };
5037
+ signal.addEventListener("abort", abort, { once: true });
5038
+ const timeout = setTimeout(abort, DISCOVERY_REQUEST_TIMEOUT_MS);
5039
+ timeout.unref();
5040
+ try {
5041
+ const response = await fetch(`${origin}/mobile-access/discovery`, {
5042
+ method: "GET",
5043
+ redirect: "error",
5044
+ cache: "no-store",
5045
+ signal: requestController.signal,
5046
+ headers: { accept: "application/json" }
5047
+ });
5048
+ if (!response.ok) return false;
5049
+ let value;
5050
+ try {
5051
+ value = JSON.parse(new TextDecoder().decode(await boundedResponseBytes(response)));
5052
+ } catch {
5053
+ throw new Error("frp_discovery_invalid");
5054
+ }
5055
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("frp_discovery_invalid");
5056
+ const actual = value.instanceId;
5057
+ if (typeof actual !== "string") throw new Error("frp_discovery_invalid");
5058
+ if (actual !== expectedInstanceId) throw new Error("frp_discovery_mismatch");
5059
+ return true;
5060
+ } finally {
5061
+ clearTimeout(timeout);
5062
+ signal.removeEventListener("abort", abort);
5063
+ }
5064
+ }
5065
+ /** Owns frpc, its generation-specific configuration, and the remote gateway. */
5066
+ var FrpController = class {
5067
+ options;
5068
+ enabled = false;
5069
+ initialized = false;
5070
+ disposed = false;
5071
+ child;
5072
+ gatewayValue;
5073
+ generation = 0;
5074
+ latest = publicStatus$2({
5075
+ enabled: false,
5076
+ state: "off"
5077
+ });
5078
+ queue = Promise.resolve();
5079
+ startupAbort;
5080
+ constructor(options) {
5081
+ this.options = options;
5082
+ if (!isAbsolute(options.executable)) throw new Error("frpc executable path must be absolute");
5083
+ if (!/^[a-f0-9]{64}$/u.test(options.instanceId)) throw new Error("FRP instance ID is invalid");
5084
+ }
5085
+ /** Restore the remembered FRP switch without changing LAN or other providers. */
5086
+ async initialize() {
5087
+ const state = await this.options.store.load();
5088
+ this.enabled = state.enabled;
5089
+ this.initialized = true;
5090
+ if (this.enabled) await this.start();
5091
+ else this.publish({
5092
+ enabled: false,
5093
+ state: "off"
5094
+ });
5095
+ }
5096
+ /** Return the active FRP-backed DSH gateway. */
5097
+ gateway() {
5098
+ return this.gatewayValue;
5099
+ }
5100
+ /** Return state safe for the desktop control UI. */
5101
+ status() {
5102
+ return publicStatus$2(this.latest);
5103
+ }
5104
+ /** Enable or disable FRP without changing LAN or another provider. */
5105
+ async setEnabled(enabled) {
5106
+ if (!this.initialized || this.disposed) throw new Error("FRP controller is unavailable");
5107
+ await this.enqueue(async () => {
5108
+ if (this.enabled === enabled && (enabled === false || this.child !== void 0)) return;
5109
+ if (!enabled) await this.stop();
5110
+ this.enabled = enabled;
5111
+ await this.options.store.save({
5112
+ version: 1,
5113
+ enabled
5114
+ });
5115
+ if (enabled) await this.start();
5116
+ else this.publish({
5117
+ enabled: false,
5118
+ state: "off"
5119
+ });
5120
+ });
5121
+ return this.status();
5122
+ }
5123
+ /** Restart FRP while retaining its private server settings and devices. */
5124
+ async reconnect() {
5125
+ if (!this.initialized || this.disposed) throw new Error("FRP controller is unavailable");
5126
+ await this.enqueue(async () => {
5127
+ if (!this.enabled) {
5128
+ this.enabled = true;
5129
+ await this.options.store.save({
5130
+ version: 1,
5131
+ enabled: true
5132
+ });
5133
+ }
5134
+ await this.stop();
5135
+ await this.start();
5136
+ });
5137
+ return this.status();
5138
+ }
5139
+ /** Disable FRP without deleting its explicitly managed component or settings. */
5140
+ async reset() {
5141
+ if (!this.initialized || this.disposed) throw new Error("FRP controller is unavailable");
5142
+ await this.enqueue(async () => {
5143
+ await this.stop();
5144
+ this.enabled = false;
5145
+ await this.options.store.save({
5146
+ version: 1,
5147
+ enabled: false
5148
+ });
5149
+ this.publish({
5150
+ enabled: false,
5151
+ state: "off"
5152
+ });
5153
+ });
5154
+ return this.status();
5155
+ }
5156
+ /** Stop all FRP resources without changing the remembered switch. */
5157
+ async close() {
5158
+ if (this.disposed) return;
5159
+ this.disposed = true;
5160
+ await this.enqueue(() => this.stop());
5161
+ }
5162
+ enqueue(operation) {
5163
+ const task = this.queue.then(operation, operation);
5164
+ this.queue = task.then(() => void 0, () => void 0);
5165
+ return task;
5166
+ }
5167
+ publish(status) {
5168
+ this.latest = publicStatus$2(status);
5169
+ try {
5170
+ this.options.onStatus?.(this.status());
5171
+ } catch {}
5172
+ }
5173
+ async start() {
5174
+ const generation = ++this.generation;
5175
+ let executableEntry;
5176
+ try {
5177
+ executableEntry = await lstat(this.options.executable);
5178
+ } catch {
5179
+ this.publish({
5180
+ enabled: true,
5181
+ state: "unavailable",
5182
+ errorCode: "frp_component_missing"
5183
+ });
5184
+ return;
5185
+ }
5186
+ if (!executableEntry.isFile() || executableEntry.isSymbolicLink()) {
5187
+ this.publish({
5188
+ enabled: true,
5189
+ state: "unavailable",
5190
+ errorCode: "frp_component_invalid"
5191
+ });
5192
+ return;
5193
+ }
5194
+ const settings = this.options.config.settings();
5195
+ if (settings === void 0) {
5196
+ this.publish({
5197
+ enabled: true,
5198
+ state: "unavailable",
5199
+ errorCode: "frp_config_missing"
5200
+ });
5201
+ return;
5202
+ }
5203
+ this.publish({
5204
+ enabled: true,
5205
+ state: "starting",
5206
+ origin: settings.publicOrigin
5207
+ });
5208
+ let exposed;
5209
+ try {
5210
+ exposed = await (this.options.probeVhostExposure ?? defaultProbeVhostExposure)(settings.serverAddress, FRP_VHOST_HTTP_PORT);
5211
+ } catch {
5212
+ this.publish({
5213
+ enabled: true,
5214
+ state: "error",
5215
+ origin: settings.publicOrigin,
5216
+ errorCode: "frp_vhost_probe_failed"
5217
+ });
5218
+ return;
5219
+ }
5220
+ if (exposed) {
5221
+ this.publish({
5222
+ enabled: true,
5223
+ state: "error",
5224
+ origin: settings.publicOrigin,
5225
+ errorCode: "frp_vhost_publicly_reachable"
5226
+ });
5227
+ return;
5228
+ }
5229
+ let gateway;
5230
+ try {
5231
+ gateway = await this.options.createGateway(settings.publicOrigin);
5232
+ } catch {
5233
+ this.publish({
5234
+ enabled: true,
5235
+ state: "error",
5236
+ origin: settings.publicOrigin,
5237
+ errorCode: "gateway_start_failed"
5238
+ });
5239
+ return;
5240
+ }
5241
+ if (generation !== this.generation || !this.enabled) {
5242
+ await gateway.close();
5243
+ return;
5244
+ }
5245
+ this.gatewayValue = gateway;
5246
+ let configFile;
5247
+ try {
5248
+ configFile = await this.options.config.writeRuntimeConfig(gateway.address().port);
5249
+ await (this.options.verifyConfig ?? defaultVerifyConfig)(this.options.executable, configFile);
5250
+ } catch {
5251
+ await this.failGeneration(generation, "frp_config_verify_failed");
5252
+ return;
5253
+ }
5254
+ if (generation !== this.generation || !this.enabled) return;
5255
+ let child;
5256
+ try {
5257
+ child = (this.options.launchClient ?? defaultLaunchClient)(this.options.executable, configFile);
5258
+ } catch {
5259
+ await this.failGeneration(generation, "frp_launch_failed");
5260
+ return;
5261
+ }
5262
+ this.child = child;
5263
+ child.stdout.resume();
5264
+ child.stderr.resume();
5265
+ child.once("error", () => {
5266
+ this.enqueue(() => this.failGeneration(generation, "frp_launch_failed"));
5267
+ });
5268
+ child.once("close", (code) => {
5269
+ if (generation !== this.generation || this.child !== child) return;
5270
+ this.child = void 0;
5271
+ if (this.enabled) this.enqueue(() => this.failGeneration(generation, code === 0 ? "frp_stopped" : "frp_exited"));
5272
+ });
5273
+ this.publish({
5274
+ enabled: true,
5275
+ state: "connecting",
5276
+ origin: settings.publicOrigin
5277
+ });
5278
+ const controller = new AbortController();
5279
+ this.startupAbort = controller;
5280
+ this.waitForDiscovery(generation, settings.publicOrigin, controller.signal);
5281
+ }
5282
+ async waitForDiscovery(generation, origin, signal) {
5283
+ const deadline = Date.now() + (this.options.startTimeoutMs ?? START_TIMEOUT_MS$1);
5284
+ const probe = this.options.probeDiscovery ?? defaultProbeDiscovery;
5285
+ while (!signal.aborted && Date.now() < deadline) {
5286
+ try {
5287
+ if (await probe(origin, this.options.instanceId, signal)) {
5288
+ await this.enqueue(async () => {
5289
+ if (generation !== this.generation || signal.aborted || !this.enabled) return;
5290
+ this.startupAbort = void 0;
5291
+ this.publish({
5292
+ enabled: true,
5293
+ state: "ready",
5294
+ origin
5295
+ });
5296
+ });
5297
+ return;
5298
+ }
5299
+ } catch (error) {
5300
+ if (signal.aborted) return;
5301
+ if (error instanceof Error && (error.message === "frp_discovery_mismatch" || error.message === "frp_discovery_invalid")) {
5302
+ await this.enqueue(() => this.failGeneration(generation, error.message));
5303
+ return;
5304
+ }
5305
+ }
5306
+ await new Promise((resolveWait) => {
5307
+ let finished = false;
5308
+ const finish = () => {
5309
+ if (finished) return;
5310
+ finished = true;
5311
+ clearTimeout(timer);
5312
+ signal.removeEventListener("abort", finish);
5313
+ resolveWait();
5314
+ };
5315
+ const timer = setTimeout(finish, this.options.retryIntervalMs ?? DISCOVERY_RETRY_MS);
5316
+ timer.unref();
5317
+ signal.addEventListener("abort", finish, { once: true });
5318
+ });
5319
+ }
5320
+ if (!signal.aborted) await this.enqueue(() => this.failGeneration(generation, "frp_start_timeout"));
5321
+ }
5322
+ async failGeneration(generation, code) {
5323
+ if (generation !== this.generation) return;
5324
+ await this.stopProcessAndGateway();
5325
+ if (this.enabled) this.publish({
5326
+ enabled: true,
5327
+ state: "error",
5328
+ errorCode: code
5329
+ });
5330
+ }
5331
+ async stop() {
5332
+ ++this.generation;
5333
+ await this.stopProcessAndGateway();
5334
+ }
5335
+ async stopProcessAndGateway() {
5336
+ this.startupAbort?.abort();
5337
+ this.startupAbort = void 0;
5338
+ const child = this.child;
5339
+ this.child = void 0;
5340
+ const gateway = this.gatewayValue;
5341
+ this.gatewayValue = void 0;
5342
+ await settleRemoteResources([
5343
+ () => child !== void 0 && child.exitCode === null ? terminateRemoteProcess(child) : void 0,
5344
+ () => gateway?.close(),
5345
+ () => rm(this.options.config.runtimeConfigFile, { force: true })
5346
+ ], "FRP resource cleanup failed");
5347
+ }
5348
+ };
5349
+ //#endregion
4163
5350
  //#region src/diagnostics.ts
4164
5351
  const execFile$2 = promisify(execFile);
4165
5352
  const REMOTE_ERROR_GUIDANCE = Object.freeze({
@@ -4180,6 +5367,18 @@ const REMOTE_ERROR_GUIDANCE = Object.freeze({
4180
5367
  cpolar_start_timeout: "检查网络后点击“重新连接”。",
4181
5368
  cpolar_stopped: "点击“重新连接”。",
4182
5369
  cpolar_exited: "点击“重新连接”;仍失败时复制诊断报告。",
5370
+ frp_component_missing: "先安装 FRP 官方组件。",
5371
+ frp_component_invalid: "彻底清理 FRP 组件后重新安装。",
5372
+ frp_config_missing: "先保存自建 FRP 连接配置。",
5373
+ frp_config_verify_failed: "检查服务器地址、端口、Token 和公开域名。",
5374
+ frp_vhost_publicly_reachable: "将 frps 的 HTTP vhost 监听限制到 127.0.0.1。",
5375
+ frp_vhost_probe_failed: "确认 VPS 地址可解析后重新连接。",
5376
+ frp_launch_failed: "重新安装 FRP 官方组件后重试。",
5377
+ frp_start_timeout: "确认 frps、Caddy 和域名解析正常后重新连接。",
5378
+ frp_discovery_mismatch: "公开域名连接到了另一台电脑,请核对 Caddy 与 frps 配置。",
5379
+ frp_discovery_invalid: "公开域名返回了非 DSH Mobile 响应。",
5380
+ frp_stopped: "点击“重新连接”。",
5381
+ frp_exited: "检查 VPS 配置后重新连接;仍失败时复制诊断报告。",
4183
5382
  gateway_start_failed: "确认 DSH 正在运行后重新连接。"
4184
5383
  });
4185
5384
  function check(id, status, reason, label, detail, action, facts) {
@@ -4257,7 +5456,7 @@ function defaultFirewallProbe(platform = process.platform) {
4257
5456
  function remoteDiagnosticTimeoutMs(origin) {
4258
5457
  const hostname = new URL(origin).hostname.toLowerCase();
4259
5458
  if (hostname.endsWith(".ts.net") || hostname.includes(".cpolar.")) return 1e4;
4260
- return 5e3;
5459
+ return 1e4;
4261
5460
  }
4262
5461
  async function defaultRemoteProbe(origin) {
4263
5462
  if (origin === void 0) return { state: "not-applicable" };
@@ -4748,28 +5947,12 @@ var FunnelController = class {
4748
5947
  this.clearStartTimer();
4749
5948
  const child = this.child;
4750
5949
  this.child = void 0;
4751
- child?.stdin.end();
4752
- if (child !== void 0 && child.exitCode === null) {
4753
- child.kill("SIGTERM");
4754
- await new Promise((resolveClose) => {
4755
- let completed = false;
4756
- const finish = () => {
4757
- if (completed) return;
4758
- completed = true;
4759
- clearTimeout(timer);
4760
- resolveClose();
4761
- };
4762
- const timer = setTimeout(() => {
4763
- if (child.exitCode === null) child.kill("SIGKILL");
4764
- finish();
4765
- }, 1500);
4766
- timer.unref();
4767
- child.once("close", finish);
4768
- });
4769
- }
4770
5950
  const gateway = this.gatewayValue;
4771
5951
  this.gatewayValue = void 0;
4772
- await gateway?.close();
5952
+ await settleRemoteResources([async () => {
5953
+ child?.stdin.end();
5954
+ if (child !== void 0 && child.exitCode === null) await terminateRemoteProcess(child);
5955
+ }, () => gateway?.close()], "Funnel resource cleanup failed");
4773
5956
  }
4774
5957
  clearStartTimer() {
4775
5958
  if (this.startTimer === void 0) return;
@@ -5140,30 +6323,15 @@ var CpolarController = class {
5140
6323
  this.startupTimer = void 0;
5141
6324
  const reservation = this.reservation;
5142
6325
  this.reservation = void 0;
5143
- await reservation?.release();
5144
6326
  const child = this.child;
5145
6327
  this.child = void 0;
5146
- if (child !== void 0 && child.exitCode === null) {
5147
- child.kill("SIGTERM");
5148
- await new Promise((resolveClose) => {
5149
- let completed = false;
5150
- const finish = () => {
5151
- if (completed) return;
5152
- completed = true;
5153
- clearTimeout(timer);
5154
- resolveClose();
5155
- };
5156
- const timer = setTimeout(() => {
5157
- if (child.exitCode === null) child.kill("SIGKILL");
5158
- finish();
5159
- }, 1500);
5160
- timer.unref();
5161
- child.once("close", finish);
5162
- });
5163
- }
5164
6328
  const gateway = this.gatewayValue;
5165
6329
  this.gatewayValue = void 0;
5166
- await gateway?.close();
6330
+ await settleRemoteResources([
6331
+ () => reservation?.release(),
6332
+ () => child !== void 0 && child.exitCode === null ? terminateRemoteProcess(child) : void 0,
6333
+ () => gateway?.close()
6334
+ ], "cpolar resource cleanup failed");
5167
6335
  }
5168
6336
  };
5169
6337
  //#endregion
@@ -5435,80 +6603,376 @@ var CpolarComponentManager = class {
5435
6603
  }
5436
6604
  };
5437
6605
  //#endregion
5438
- //#region src/remote.ts
5439
- /** Validate the provider selection loaded across the filesystem boundary. */
5440
- function parseRemoteProviderState(value) {
5441
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("remote provider state must be an object");
5442
- const record = value;
5443
- if (record.version !== 1 || record.provider !== "tailscale" && record.provider !== "cpolar" || Reflect.ownKeys(record).some((key) => key !== "version" && key !== "provider")) throw new Error("remote provider state has an unsupported format");
6606
+ //#region src/release-update.ts
6607
+ const PACKAGE_NAME = "dsh-mobile";
6608
+ const NPM_LATEST_URL = "https://registry.npmjs.org/dsh-mobile/latest";
6609
+ const GITHUB_LATEST_URL = "https://github.com/saya-ch/dsh-mobile/releases/latest";
6610
+ const GITHUB_RELEASES_URL = "https://github.com/saya-ch/dsh-mobile/releases";
6611
+ const STATUS_CACHE_MS = 6e5;
6612
+ const REQUEST_TIMEOUT_MS = 8e3;
6613
+ const UPDATE_TIMEOUT_MS = 12e4;
6614
+ const UPDATE_TERMINATION_GRACE_MS = 1500;
6615
+ const NUMERIC_VERSION_IDENTIFIER = "(?:0|[1-9]\\d*)";
6616
+ const WILDCARD_VERSION_IDENTIFIER = "(?:[xX*])";
6617
+ const RANGE_VERSION = `(?:${`${NUMERIC_VERSION_IDENTIFIER}\\.${NUMERIC_VERSION_IDENTIFIER}\\.${NUMERIC_VERSION_IDENTIFIER}(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?`}|${`(?:${WILDCARD_VERSION_IDENTIFIER}|${NUMERIC_VERSION_IDENTIFIER}(?:\\.(?:${WILDCARD_VERSION_IDENTIFIER}|${NUMERIC_VERSION_IDENTIFIER}(?:\\.(?:${WILDCARD_VERSION_IDENTIFIER}|${NUMERIC_VERSION_IDENTIFIER}))?))?)`})`;
6618
+ const COMPARATOR = new RegExp(`^(?:<=|>=|<|>|=|~|\\^)?${RANGE_VERSION}$`, "u");
6619
+ const HYPHEN_RANGE = new RegExp(`^${RANGE_VERSION} +[-] +${RANGE_VERSION}$`, "u");
6620
+ const DIST_TAG = /^[A-Za-z][A-Za-z0-9._-]{0,127}$/u;
6621
+ function parseSemver(value) {
6622
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(value);
6623
+ if (match === null) return void 0;
6624
+ const core = [
6625
+ Number(match[1]),
6626
+ Number(match[2]),
6627
+ Number(match[3])
6628
+ ];
6629
+ if (core.some((part) => !Number.isSafeInteger(part))) return void 0;
6630
+ const prerelease = match[4] === void 0 ? [] : match[4].split(".").map((part) => /^\d+$/u.test(part) ? Number(part) : part);
6631
+ if (prerelease.some((part) => typeof part === "number" && !Number.isSafeInteger(part))) return void 0;
5444
6632
  return Object.freeze({
5445
- version: 1,
5446
- provider: record.provider
6633
+ core,
6634
+ prerelease: Object.freeze(prerelease)
5447
6635
  });
5448
6636
  }
5449
- /** Atomic selection store whose absent-file state uses the configured default. */
5450
- var JsonRemoteProviderStore = class {
5451
- file;
5452
- defaultProvider;
5453
- constructor(file, defaultProvider) {
5454
- this.file = file;
5455
- this.defaultProvider = defaultProvider;
6637
+ /** Compare two strict SemVer strings, including prerelease precedence. */
6638
+ function comparePluginVersions(left, right) {
6639
+ const a = parseSemver(left);
6640
+ const b = parseSemver(right);
6641
+ if (a === void 0 || b === void 0) return void 0;
6642
+ for (let index = 0; index < a.core.length; index += 1) {
6643
+ const difference = a.core[index] - b.core[index];
6644
+ if (difference !== 0) return Math.sign(difference);
6645
+ }
6646
+ if (a.prerelease.length === 0 || b.prerelease.length === 0) return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length === 0 ? 1 : -1;
6647
+ const length = Math.max(a.prerelease.length, b.prerelease.length);
6648
+ for (let index = 0; index < length; index += 1) {
6649
+ const leftPart = a.prerelease[index];
6650
+ const rightPart = b.prerelease[index];
6651
+ if (leftPart === void 0 || rightPart === void 0) return leftPart === void 0 ? -1 : 1;
6652
+ if (leftPart === rightPart) continue;
6653
+ if (typeof leftPart === "number" && typeof rightPart === "number") return Math.sign(leftPart - rightPart);
6654
+ if (typeof leftPart === "number") return -1;
6655
+ if (typeof rightPart === "number") return 1;
6656
+ return leftPart < rightPart ? -1 : 1;
6657
+ }
6658
+ return 0;
6659
+ }
6660
+ function isComparatorSet(value) {
6661
+ if (HYPHEN_RANGE.test(value)) return true;
6662
+ const comparators = value.replace(/(<=|>=|<|>|=|~|\^) +/gu, "$1").split(/ +/u);
6663
+ return comparators.length > 0 && comparators.every((comparator) => COMPARATOR.test(comparator));
6664
+ }
6665
+ function isNpmVersionRange(value) {
6666
+ if (!/^[0-9xX*<>=~^|.+\- ]+$/u.test(value)) return false;
6667
+ const alternatives = value.split(/ *\|\| */u);
6668
+ return alternatives.length > 0 && alternatives.every((alternative) => alternative !== "" && isComparatorSet(alternative));
6669
+ }
6670
+ /** Return whether pnpm may safely replace this profile dependency from an npm version, range, or tag. */
6671
+ function isRegistryPluginSpec(value) {
6672
+ if (typeof value !== "string" || value.trim() !== value || value === "" || /[\u0000-\u001f\u007f]/u.test(value)) return false;
6673
+ if (/\.(?:tgz|tar(?:\.gz)?)$/iu.test(value)) return false;
6674
+ return parseSemver(value) !== void 0 || isNpmVersionRange(value) || DIST_TAG.test(value);
6675
+ }
6676
+ /** Resolve the DSH profile named by the current launcher arguments. */
6677
+ function launchedProfileName(argv) {
6678
+ for (let index = 0; index < argv.length; index += 1) {
6679
+ if (argv[index] === "--profile") {
6680
+ const candidate = argv[index + 1];
6681
+ if (candidate !== void 0 && /^[\w.-]+$/u.test(candidate)) return candidate;
6682
+ }
6683
+ const match = /^--profile=([\w.-]+)$/u.exec(argv[index] ?? "");
6684
+ if (match?.[1] !== void 0) return match[1];
5456
6685
  }
5457
- async load() {
5458
- let stat;
5459
- try {
5460
- stat = await lstat(this.file);
5461
- } catch (error) {
5462
- if (error.code === "ENOENT") return Object.freeze({
5463
- version: 1,
5464
- provider: this.defaultProvider
6686
+ return "web";
6687
+ }
6688
+ async function profileDependencySpec(profileDirectory) {
6689
+ try {
6690
+ const value = JSON.parse(await readFile(join(profileDirectory, "package.json"), "utf8")).dependencies?.[PACKAGE_NAME];
6691
+ return typeof value === "string" ? value : void 0;
6692
+ } catch {
6693
+ return;
6694
+ }
6695
+ }
6696
+ async function fetchNpmVersion(fetcher) {
6697
+ const response = await fetcher(NPM_LATEST_URL, {
6698
+ headers: {
6699
+ accept: "application/json",
6700
+ "user-agent": "dsh-mobile-release-check"
6701
+ },
6702
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
6703
+ });
6704
+ if (!response.ok) return void 0;
6705
+ const payload = await response.json();
6706
+ return typeof payload.version === "string" && parseSemver(payload.version) !== void 0 ? payload.version : void 0;
6707
+ }
6708
+ function githubReleaseVersion(location, responseUrl) {
6709
+ let url;
6710
+ try {
6711
+ url = new URL(location ?? responseUrl, GITHUB_LATEST_URL);
6712
+ } catch {
6713
+ return;
6714
+ }
6715
+ if (url.origin !== "https://github.com" || url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") return void 0;
6716
+ if (!url.pathname.startsWith("/saya-ch/dsh-mobile/releases/tag/v")) return void 0;
6717
+ let version;
6718
+ try {
6719
+ version = decodeURIComponent(url.pathname.slice(34));
6720
+ } catch {
6721
+ return;
6722
+ }
6723
+ return parseSemver(version) === void 0 ? void 0 : version;
6724
+ }
6725
+ function androidReleaseDownloadUrl(version) {
6726
+ if (version === void 0) return GITHUB_RELEASES_URL;
6727
+ const tag = `v${version}`;
6728
+ return `https://github.com/saya-ch/dsh-mobile/releases/download/${encodeURIComponent(tag)}/dsh-mobile-android-${encodeURIComponent(tag)}.apk`;
6729
+ }
6730
+ async function fetchAndroidVersion(fetcher) {
6731
+ const response = await fetcher(GITHUB_LATEST_URL, {
6732
+ method: "GET",
6733
+ redirect: "manual",
6734
+ headers: {
6735
+ accept: "text/html",
6736
+ "user-agent": "dsh-mobile-release-check"
6737
+ },
6738
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
6739
+ });
6740
+ return githubReleaseVersion(response.headers.get("location"), response.url);
6741
+ }
6742
+ async function readProfileInstalledVersion(profileDirectory) {
6743
+ try {
6744
+ const manifestPath = createRequire(join(profileDirectory, "package.json")).resolve(`${PACKAGE_NAME}/package.json`);
6745
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
6746
+ return typeof manifest.version === "string" ? manifest.version : void 0;
6747
+ } catch {
6748
+ return;
6749
+ }
6750
+ }
6751
+ function childCompletion(child) {
6752
+ return new Promise((resolveCompletion, rejectCompletion) => {
6753
+ child.once("error", rejectCompletion);
6754
+ child.once("close", (code, signal) => {
6755
+ resolveCompletion({
6756
+ code,
6757
+ signal
5465
6758
  });
5466
- throw error;
6759
+ });
6760
+ });
6761
+ }
6762
+ function createDeadline(timeoutMs) {
6763
+ let timer;
6764
+ return {
6765
+ promise: new Promise((resolveTimeout) => {
6766
+ timer = setTimeout(resolveTimeout, timeoutMs);
6767
+ timer.unref();
6768
+ }),
6769
+ cancel: () => {
6770
+ if (timer !== void 0) clearTimeout(timer);
6771
+ timer = void 0;
5467
6772
  }
5468
- if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) throw new Error("remote provider state must be a regular file no larger than 4 KiB");
5469
- await restrictPrivateFile(this.file);
5470
- let parsed;
6773
+ };
6774
+ }
6775
+ async function taskkillProcessTree(pid) {
6776
+ if ((await childCompletion(spawn("taskkill.exe", [
6777
+ "/PID",
6778
+ String(pid),
6779
+ "/T",
6780
+ "/F"
6781
+ ], {
6782
+ shell: false,
6783
+ windowsHide: true,
6784
+ stdio: "ignore"
6785
+ }))).code !== 0) throw new Error("plugin_update_tree_termination_failed");
6786
+ }
6787
+ async function completionWithin(completion, timeoutMs) {
6788
+ let timer;
6789
+ try {
6790
+ return await Promise.race([completion.then(() => true, () => true), new Promise((resolveTimeout) => {
6791
+ timer = setTimeout(() => {
6792
+ resolveTimeout(false);
6793
+ }, timeoutMs);
6794
+ timer.unref();
6795
+ })]);
6796
+ } finally {
6797
+ if (timer !== void 0) clearTimeout(timer);
6798
+ }
6799
+ }
6800
+ function processMissing(error) {
6801
+ return error.code === "ESRCH";
6802
+ }
6803
+ async function terminateProcessTree(child, completion, platform) {
6804
+ if (child.exitCode !== null || child.signalCode !== null) return;
6805
+ const pid = child.pid;
6806
+ if (pid === void 0) {
6807
+ child.kill("SIGKILL");
6808
+ if (!await completionWithin(completion, UPDATE_TERMINATION_GRACE_MS)) throw new Error("plugin_update_tree_termination_timeout");
6809
+ return;
6810
+ }
6811
+ if (platform === "win32") {
5471
6812
  try {
5472
- parsed = JSON.parse(await readFile(this.file, "utf8"));
6813
+ await taskkillProcessTree(pid);
5473
6814
  } catch (error) {
5474
- throw new Error("remote provider state is not valid JSON", { cause: error });
6815
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
6816
+ if (!await completionWithin(completion, UPDATE_TERMINATION_GRACE_MS)) throw new AggregateError([error, /* @__PURE__ */ new Error("plugin_update_tree_termination_timeout")], "plugin update tree termination failed");
6817
+ throw error;
5475
6818
  }
5476
- return parseRemoteProviderState(parsed);
6819
+ if (!await completionWithin(completion, UPDATE_TERMINATION_GRACE_MS)) {
6820
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
6821
+ if (!await completionWithin(completion, UPDATE_TERMINATION_GRACE_MS)) throw new Error("plugin_update_tree_termination_timeout");
6822
+ }
6823
+ return;
5477
6824
  }
5478
- async save(state) {
5479
- const validated = parseRemoteProviderState(state);
5480
- const directory = dirname(this.file);
5481
- await mkdir(directory, {
5482
- recursive: true,
5483
- mode: 448
6825
+ try {
6826
+ process.kill(-pid, "SIGTERM");
6827
+ } catch (error) {
6828
+ if (!processMissing(error)) throw error;
6829
+ if (!await completionWithin(completion, UPDATE_TERMINATION_GRACE_MS)) throw new Error("plugin_update_tree_termination_timeout");
6830
+ return;
6831
+ }
6832
+ if (await completionWithin(completion, UPDATE_TERMINATION_GRACE_MS)) return;
6833
+ try {
6834
+ process.kill(-pid, "SIGKILL");
6835
+ } catch (error) {
6836
+ if (!processMissing(error)) throw error;
6837
+ }
6838
+ if (!await completionWithin(completion, UPDATE_TERMINATION_GRACE_MS)) throw new Error("plugin_update_tree_termination_timeout");
6839
+ }
6840
+ function startUpdateProcess(request) {
6841
+ const child = spawn(request.command, [...request.args], {
6842
+ cwd: request.cwd,
6843
+ detached: request.detached,
6844
+ shell: request.shell,
6845
+ windowsHide: true,
6846
+ stdio: [
6847
+ "ignore",
6848
+ "ignore",
6849
+ "pipe"
6850
+ ]
6851
+ });
6852
+ const completion = childCompletion(child);
6853
+ return {
6854
+ completion,
6855
+ ...child.stderr === null ? {} : { stderr: child.stderr },
6856
+ terminateTree: async () => terminateProcessTree(child, completion, request.platform)
6857
+ };
6858
+ }
6859
+ function updateFailure(cause) {
6860
+ return cause === void 0 ? /* @__PURE__ */ new Error("plugin_update_failed") : new Error("plugin_update_failed", { cause });
6861
+ }
6862
+ async function runPnpmUpdate(profileDirectory, version, runtime = {}) {
6863
+ if (parseSemver(version) === void 0) throw new Error("plugin_update_unavailable");
6864
+ const platform = runtime.platform ?? process.platform;
6865
+ const packageSpec = `${PACKAGE_NAME}@${version}`;
6866
+ const managed = (runtime.start ?? startUpdateProcess)({
6867
+ command: platform === "win32" ? runtime.windowsCommandInterpreter ?? process.env.ComSpec ?? "cmd.exe" : "pnpm",
6868
+ args: platform === "win32" ? [
6869
+ "/d",
6870
+ "/s",
6871
+ "/c",
6872
+ "pnpm.cmd",
6873
+ "add",
6874
+ packageSpec
6875
+ ] : ["add", packageSpec],
6876
+ cwd: profileDirectory,
6877
+ detached: platform !== "win32",
6878
+ platform,
6879
+ shell: false
6880
+ });
6881
+ let diagnostics = "";
6882
+ managed.stderr?.on("data", (chunk) => {
6883
+ if (diagnostics.length < 4096) diagnostics += Buffer.from(chunk).toString("utf8").slice(0, 4096 - diagnostics.length);
6884
+ });
6885
+ const completion = managed.completion.then((result) => ({
6886
+ kind: "exit",
6887
+ result
6888
+ }), (error) => ({
6889
+ kind: "error",
6890
+ error
6891
+ }));
6892
+ const deadline = (runtime.deadline ?? createDeadline)(runtime.timeoutMs ?? UPDATE_TIMEOUT_MS);
6893
+ const first = await Promise.race([completion, deadline.promise.then(() => ({ kind: "timeout" }))]);
6894
+ deadline.cancel();
6895
+ if (first.kind === "error") throw updateFailure(first.error);
6896
+ if (first.kind === "exit") {
6897
+ if (first.result.code === 0) return;
6898
+ const detail = diagnostics.trim() || `pnpm exited with ${first.result.signal ?? String(first.result.code)}`;
6899
+ throw updateFailure(new Error(detail));
6900
+ }
6901
+ let terminationError;
6902
+ try {
6903
+ await managed.terminateTree();
6904
+ } catch (error) {
6905
+ terminationError = error;
6906
+ }
6907
+ if (terminationError !== void 0) throw updateFailure(terminationError);
6908
+ const stopped = await completion;
6909
+ if (stopped.kind === "error") throw updateFailure(stopped.error);
6910
+ throw updateFailure(/* @__PURE__ */ new Error("plugin update timed out"));
6911
+ }
6912
+ /** Cached npm/GitHub release lookup and guarded profile-local package update. */
6913
+ var PluginReleaseManager = class {
6914
+ profileDirectory;
6915
+ installedVersion;
6916
+ fetcher;
6917
+ runner;
6918
+ installedVersionReader;
6919
+ now;
6920
+ cache;
6921
+ activeUpdate;
6922
+ constructor(options) {
6923
+ this.profileDirectory = options.profileDirectory;
6924
+ this.installedVersion = options.installedVersion ?? DSH_MOBILE_VERSION;
6925
+ this.fetcher = options.fetch ?? globalThis.fetch;
6926
+ this.runner = options.runUpdate ?? ((profileDirectory, version) => runPnpmUpdate(profileDirectory, version, options.updateProcess));
6927
+ this.installedVersionReader = options.readInstalledVersion ?? readProfileInstalledVersion;
6928
+ this.now = options.now ?? Date.now;
6929
+ }
6930
+ /** Read cached release metadata and suppress external lookup failures. */
6931
+ async status(force = false) {
6932
+ if (!force && this.cache !== void 0 && this.cache.expiresAt > this.now()) return this.cache.status;
6933
+ const updateSupported = isRegistryPluginSpec(await profileDependencySpec(this.profileDirectory));
6934
+ const [npmResult, androidResult] = await Promise.allSettled([fetchNpmVersion(this.fetcher), fetchAndroidVersion(this.fetcher)]);
6935
+ const latestVersion = npmResult.status === "fulfilled" ? npmResult.value : void 0;
6936
+ const androidVersion = androidResult.status === "fulfilled" ? androidResult.value : void 0;
6937
+ const comparison = latestVersion === void 0 ? void 0 : comparePluginVersions(latestVersion, this.installedVersion);
6938
+ const status = Object.freeze({
6939
+ installedVersion: this.installedVersion,
6940
+ ...latestVersion === void 0 ? {} : { latestVersion },
6941
+ updateAvailable: updateSupported && comparison === 1,
6942
+ updateSupported,
6943
+ ...androidVersion === void 0 ? {} : { androidVersion },
6944
+ androidDownloadUrl: androidReleaseDownloadUrl(androidVersion)
5484
6945
  });
6946
+ this.cache = {
6947
+ expiresAt: this.now() + STATUS_CACHE_MS,
6948
+ status
6949
+ };
6950
+ return status;
6951
+ }
6952
+ /** Install the latest npm release into the active profile, then require a DSH restart. */
6953
+ async update() {
6954
+ if (this.activeUpdate !== void 0) return this.activeUpdate;
6955
+ this.activeUpdate = this.updateOnce();
5485
6956
  try {
5486
- const current = await lstat(this.file);
5487
- if (!current.isFile() || current.isSymbolicLink()) throw new Error("remote provider state target must remain a regular file");
5488
- } catch (error) {
5489
- if (error.code !== "ENOENT") throw error;
5490
- }
5491
- const temporary = join(directory, `.${basename(this.file)}.${randomBytes(12).toString("hex")}.tmp`);
5492
- try {
5493
- await writeFile(temporary, `${JSON.stringify(validated)}\n`, {
5494
- encoding: "utf8",
5495
- flag: "wx",
5496
- mode: 384
5497
- });
5498
- await rename(temporary, this.file);
5499
- await restrictPrivateFile(this.file);
5500
- } catch (error) {
5501
- await rm(temporary, { force: true });
5502
- throw error;
6957
+ return await this.activeUpdate;
6958
+ } finally {
6959
+ this.activeUpdate = void 0;
5503
6960
  }
5504
6961
  }
6962
+ async updateOnce() {
6963
+ const status = await this.status(true);
6964
+ if (!status.updateSupported) throw new Error("plugin_update_unsupported");
6965
+ if (!status.updateAvailable || status.latestVersion === void 0) throw new Error("plugin_update_unavailable");
6966
+ await this.runner(this.profileDirectory, status.latestVersion);
6967
+ const installed = await this.installedVersionReader(this.profileDirectory);
6968
+ if (installed !== status.latestVersion) throw new Error("plugin_update_failed");
6969
+ this.cache = void 0;
6970
+ return Object.freeze({
6971
+ installedVersion: installed,
6972
+ restartRequired: true
6973
+ });
6974
+ }
5505
6975
  };
5506
- /** Resolve the first-run provider without letting environment values bypass validation. */
5507
- function configuredRemoteProvider(environment) {
5508
- const value = environment.DSH_MOBILE_REMOTE_PROVIDER ?? "tailscale";
5509
- if (value !== "tailscale" && value !== "cpolar") throw new Error("DSH_MOBILE_REMOTE_PROVIDER must be tailscale or cpolar");
5510
- return value;
5511
- }
5512
6976
  promisify(execFile);
5513
6977
  const VIRTUAL_INTERFACE_MARKERS = [
5514
6978
  "bridge",
@@ -5721,6 +7185,17 @@ const inject = [
5721
7185
  "commands",
5722
7186
  "connection"
5723
7187
  ];
7188
+ /** Run cleanup steps in ownership order and report every failure after all steps settle. */
7189
+ async function settleCleanupSteps(steps) {
7190
+ const errors = [];
7191
+ for (const step of steps) try {
7192
+ await step();
7193
+ } catch (error) {
7194
+ errors.push(error);
7195
+ }
7196
+ if (errors.length === 1 && errors[0] instanceof Error) throw errors[0];
7197
+ if (errors.length > 0) throw new AggregateError(errors, "DSH Mobile cleanup failed");
7198
+ }
5724
7199
  function upstreamAuthenticatedUrl(ctx, upstreamOrigin) {
5725
7200
  const connection = ctx.connection;
5726
7201
  return typeof connection?.authenticatedUrl === "function" ? connection.authenticatedUrl(upstreamOrigin.origin) : void 0;
@@ -5738,6 +7213,16 @@ function mapAdminError(error) {
5738
7213
  if (error instanceof Error && error.message.startsWith("saved LAN interface ")) return new HttpError(409, "network_interface_unavailable");
5739
7214
  if (error instanceof Error && error.message === "cpolar_authtoken_invalid") return new HttpError(400, "cpolar_authtoken_invalid");
5740
7215
  if (error instanceof Error && error.message.startsWith("cpolar_")) return new HttpError(409, error.message);
7216
+ if (error instanceof Error && [
7217
+ "frp_server_address_invalid",
7218
+ "frp_server_port_invalid",
7219
+ "frp_token_invalid",
7220
+ "frp_public_origin_invalid",
7221
+ "frp_settings_invalid"
7222
+ ].includes(error.message)) return new HttpError(400, error.message);
7223
+ if (error instanceof Error && error.message.startsWith("frp_")) return new HttpError(409, error.message);
7224
+ if (error instanceof Error && error.message === "plugin_update_failed") return new HttpError(500, error.message);
7225
+ if (error instanceof Error && error.message.startsWith("plugin_update_")) return new HttpError(409, error.message);
5741
7226
  return new HttpError(500, "internal_error");
5742
7227
  }
5743
7228
  const SETUP_KEYS = /* @__PURE__ */ new Set([
@@ -5829,7 +7314,7 @@ function remoteGatewayConfig(template, publicOrigin, stateFile, instanceId, list
5829
7314
  discovery: false
5830
7315
  });
5831
7316
  }
5832
- function remoteControlPayload(provider, status, gateway, providerStatuses, cpolarComponent) {
7317
+ function remoteControlPayload(provider, status, gateway, providerStatuses, cpolarComponent, frpComponent, frpConfiguration) {
5833
7318
  return {
5834
7319
  provider,
5835
7320
  running: status.enabled,
@@ -5850,6 +7335,13 @@ function remoteControlPayload(provider, status, gateway, providerStatuses, cpola
5850
7335
  running: providerStatuses.cpolar.enabled,
5851
7336
  state: providerStatuses.cpolar.state,
5852
7337
  component: cpolarComponent
7338
+ },
7339
+ frp: {
7340
+ bundled: false,
7341
+ running: providerStatuses.frp.enabled,
7342
+ state: providerStatuses.frp.state,
7343
+ component: frpComponent,
7344
+ configuration: frpConfiguration
5853
7345
  }
5854
7346
  }
5855
7347
  };
@@ -5865,10 +7357,17 @@ async function apply(ctx, config) {
5865
7357
  const instanceId = await stableInstanceId(loaded, template);
5866
7358
  const stateDirectory = dirname(template.stateFile);
5867
7359
  const remoteDirectory = join(stateDirectory, "remote");
7360
+ const configuredDshHome = process.env.DSH_HOME?.trim();
7361
+ const dshHome = configuredDshHome === void 0 || configuredDshHome === "" ? dirname(stateDirectory) : resolve(configuredDshHome);
7362
+ const releaseManager = new PluginReleaseManager({ profileDirectory: join(dshHome, "profiles", launchedProfileName(process.argv.slice(2))) });
5868
7363
  const remoteProviderStore = new JsonRemoteProviderStore(join(remoteDirectory, "provider.json"), configuredRemoteProvider(process.env));
5869
- let remoteProvider = (await remoteProviderStore.load()).provider;
7364
+ const initialRemoteProvider = (await remoteProviderStore.load()).provider;
5870
7365
  const cpolarComponent = new CpolarComponentManager({ stateDirectory });
5871
7366
  await cpolarComponent.initialize();
7367
+ const frpComponent = new FrpComponentManager({ stateDirectory });
7368
+ await frpComponent.initialize();
7369
+ const frpConfig = new FrpConfigStore(join(remoteDirectory, "frp", "config"));
7370
+ await frpConfig.initialize();
5872
7371
  const unregisterBuiltin = mobileAccess.registerExtension({
5873
7372
  schemaVersion: 1,
5874
7373
  id: "computer-images",
@@ -5930,7 +7429,7 @@ async function apply(ctx, config) {
5930
7429
  const lanController = new MobileAccessGatewayController(new JsonMobileAccessControlStore(parseControlFile(config.controlFile), config.initiallyEnabled), startRuntime);
5931
7430
  const remoteDeviceFile = join(remoteDirectory, "devices.json");
5932
7431
  const legacyCpolarDeviceFile = join(remoteDirectory, "cpolar", "devices.json");
5933
- if (remoteProvider === "cpolar") try {
7432
+ if (initialRemoteProvider === "cpolar") try {
5934
7433
  await lstat(remoteDeviceFile);
5935
7434
  } catch (error) {
5936
7435
  if (error.code !== "ENOENT") throw error;
@@ -5948,6 +7447,7 @@ async function apply(ctx, config) {
5948
7447
  };
5949
7448
  const tailscaleStore = new JsonMobileAccessControlStore(join(remoteDirectory, "control.json"), false);
5950
7449
  const cpolarStore = new JsonMobileAccessControlStore(join(remoteDirectory, "cpolar", "control.json"), false);
7450
+ const frpStore = new JsonMobileAccessControlStore(join(remoteDirectory, "frp", "control.json"), false);
5951
7451
  const remoteControllers = {
5952
7452
  tailscale: new FunnelController({
5953
7453
  store: tailscaleStore,
@@ -5962,29 +7462,22 @@ async function apply(ctx, config) {
5962
7462
  configFile: cpolarComponent.configFile,
5963
7463
  region: "cn",
5964
7464
  createGateway: createRemoteGateway
7465
+ }),
7466
+ frp: new FrpController({
7467
+ store: frpStore,
7468
+ executable: frpComponent.executable,
7469
+ config: frpConfig,
7470
+ instanceId,
7471
+ createGateway: createRemoteGateway
5965
7472
  })
5966
7473
  };
5967
- const remoteController = () => remoteControllers[remoteProvider];
5968
- const remotePayload = () => remoteControlPayload(remoteProvider, remoteController().status(), remoteController().gateway(), {
7474
+ const remoteProviders = new RemoteProviderCoordinator(initialRemoteProvider, remoteControllers, remoteProviderStore);
7475
+ const remoteController = () => remoteProviders.controller();
7476
+ const remotePayload = () => remoteControlPayload(remoteProviders.selected, remoteController().status(), remoteController().gateway(), {
5969
7477
  tailscale: remoteControllers.tailscale.status(),
5970
- cpolar: remoteControllers.cpolar.status()
5971
- }, cpolarComponent.status());
5972
- const selectRemoteProvider = async (provider) => {
5973
- if (provider === remoteProvider) return;
5974
- const previous = remoteControllers[remoteProvider];
5975
- const restore = previous.status().enabled;
5976
- if (restore) await previous.setEnabled(false);
5977
- try {
5978
- await remoteProviderStore.save({
5979
- version: 1,
5980
- provider
5981
- });
5982
- remoteProvider = provider;
5983
- } catch (error) {
5984
- if (restore) await previous.setEnabled(true);
5985
- throw error;
5986
- }
5987
- };
7478
+ cpolar: remoteControllers.cpolar.status(),
7479
+ frp: remoteControllers.frp.status()
7480
+ }, cpolarComponent.status(), frpComponent.status(), frpConfig.status());
5988
7481
  const lanPayload = () => ({
5989
7482
  running: lanController.isRunning(),
5990
7483
  origin: lanGateway?.address().origin,
@@ -6015,7 +7508,7 @@ async function apply(ctx, config) {
6015
7508
  ...networkError === void 0 ? {} : { networkError }
6016
7509
  },
6017
7510
  remote: {
6018
- provider: remoteProvider,
7511
+ provider: remoteProviders.selected,
6019
7512
  running: remote.enabled,
6020
7513
  state: remote.state,
6021
7514
  ...remote.origin === void 0 ? {} : { origin: remote.origin },
@@ -6040,6 +7533,15 @@ async function apply(ctx, config) {
6040
7533
  sendJson(response, 200, await diagnosticsPayload(), false);
6041
7534
  return;
6042
7535
  }
7536
+ if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/release`) {
7537
+ sendJson(response, 200, await releaseManager.status(), false);
7538
+ return;
7539
+ }
7540
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/release/update`) {
7541
+ await readJsonObject(request, 4096);
7542
+ sendJson(response, 200, await releaseManager.update(), false);
7543
+ return;
7544
+ }
6043
7545
  if (request.method === "POST" && lanControl) {
6044
7546
  const body = await readJsonObject(request, 4096);
6045
7547
  if (typeof body.running !== "boolean") throw new HttpError(400, "bad_request");
@@ -6053,47 +7555,75 @@ async function apply(ctx, config) {
6053
7555
  }
6054
7556
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/provider`) {
6055
7557
  const body = await readJsonObject(request, 4096);
6056
- if (body.provider !== "tailscale" && body.provider !== "cpolar") throw new HttpError(400, "bad_request");
6057
- await selectRemoteProvider(body.provider);
7558
+ if (body.provider !== "tailscale" && body.provider !== "cpolar" && body.provider !== "frp") throw new HttpError(400, "bad_request");
7559
+ await remoteProviders.select(body.provider);
6058
7560
  sendJson(response, 200, remotePayload(), false);
6059
7561
  return;
6060
7562
  }
6061
7563
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cpolar/component/install`) {
6062
7564
  if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
6063
- await cpolarComponent.install();
7565
+ await remoteProviders.mutate(async () => cpolarComponent.install());
6064
7566
  sendJson(response, 200, remotePayload(), false);
6065
7567
  return;
6066
7568
  }
6067
7569
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cpolar/configure`) {
6068
7570
  const body = await readJsonObject(request, 4096);
6069
- await cpolarComponent.configure(body.authtoken);
7571
+ await remoteProviders.mutate(async () => cpolarComponent.configure(body.authtoken));
6070
7572
  sendJson(response, 200, remotePayload(), false);
6071
7573
  return;
6072
7574
  }
6073
7575
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cpolar/component/purge`) {
6074
7576
  if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
6075
- await remoteControllers.cpolar.setEnabled(false);
6076
- await cpolarComponent.purge();
7577
+ await remoteProviders.mutate(async () => {
7578
+ await remoteControllers.cpolar.setEnabled(false);
7579
+ await cpolarComponent.purge();
7580
+ });
6077
7581
  sendJson(response, 200, remotePayload(), false);
6078
7582
  return;
6079
7583
  }
6080
- if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/control`) {
7584
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/component/install`) {
7585
+ if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
7586
+ await remoteProviders.mutate(async () => frpComponent.install());
7587
+ sendJson(response, 200, remotePayload(), false);
7588
+ return;
7589
+ }
7590
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/configure`) {
6081
7591
  const body = await readJsonObject(request, 4096);
6082
- if (typeof body.running !== "boolean") throw new HttpError(400, "bad_request");
6083
- await remoteController().setEnabled(body.running);
7592
+ await remoteProviders.mutate(async () => {
7593
+ await frpConfig.configure(body);
7594
+ if (remoteControllers.frp.status().enabled) await remoteControllers.frp.reconnect();
7595
+ });
7596
+ sendJson(response, 200, remotePayload(), false);
7597
+ return;
7598
+ }
7599
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/component/purge`) {
7600
+ if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
7601
+ await remoteProviders.mutate(async () => {
7602
+ await remoteControllers.frp.setEnabled(false);
7603
+ await Promise.all([frpComponent.purge(), frpConfig.purge()]);
7604
+ });
7605
+ sendJson(response, 200, remotePayload(), false);
7606
+ return;
7607
+ }
7608
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/control`) {
7609
+ const running = (await readJsonObject(request, 4096)).running;
7610
+ if (typeof running !== "boolean") throw new HttpError(400, "bad_request");
7611
+ await remoteProviders.mutate(async (controller) => controller.setEnabled(running));
6084
7612
  sendJson(response, 200, remotePayload(), false);
6085
7613
  return;
6086
7614
  }
6087
7615
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/reconnect`) {
6088
7616
  await readJsonObject(request, 4096);
6089
- await remoteController().reconnect();
7617
+ await remoteProviders.mutate(async (controller) => controller.reconnect());
6090
7618
  sendJson(response, 200, remotePayload(), false);
6091
7619
  return;
6092
7620
  }
6093
7621
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/reset`) {
6094
7622
  if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
6095
- await remoteController().reset();
6096
- await rm(remoteDeviceFile, { force: true });
7623
+ await remoteProviders.mutate(async (controller) => {
7624
+ await controller.reset();
7625
+ await rm(remoteDeviceFile, { force: true });
7626
+ });
6097
7627
  sendJson(response, 200, remotePayload(), false);
6098
7628
  return;
6099
7629
  }
@@ -6152,36 +7682,54 @@ async function apply(ctx, config) {
6152
7682
  try {
6153
7683
  await mobileAccess.startLocal(template.extensionsDir, ctx);
6154
7684
  await lanController.initialize();
6155
- if (remoteProvider === "tailscale") await cpolarStore.save({
6156
- version: 1,
6157
- enabled: false
6158
- });
6159
- else await tailscaleStore.save({
7685
+ const stores = {
7686
+ tailscale: tailscaleStore,
7687
+ cpolar: cpolarStore,
7688
+ frp: frpStore
7689
+ };
7690
+ await Promise.all(Object.keys(stores).filter((provider) => provider !== remoteProviders.selected).map((provider) => stores[provider].save({
6160
7691
  version: 1,
6161
7692
  enabled: false
6162
- });
6163
- await remoteControllers.tailscale.initialize();
6164
- await remoteControllers.cpolar.initialize();
7693
+ })));
7694
+ for (const provider of [
7695
+ "tailscale",
7696
+ "cpolar",
7697
+ "frp"
7698
+ ]) await remoteControllers[provider].initialize();
6165
7699
  } catch (error) {
6166
- unregister();
6167
- disposeMobileCommand();
6168
- await Promise.all([remoteControllers.tailscale.close(), remoteControllers.cpolar.close()]);
6169
- await lanController.close();
6170
- await mobileAccess.stopLocal();
6171
- unregisterBuiltin();
7700
+ try {
7701
+ await settleCleanupSteps([
7702
+ unregister,
7703
+ disposeMobileCommand,
7704
+ async () => {
7705
+ const failures = (await Promise.allSettled(Object.values(remoteControllers).map((controller) => controller.close()))).filter((result) => result.status === "rejected").map((result) => result.reason);
7706
+ if (failures.length > 0) throw new AggregateError(failures, "remote provider cleanup failed");
7707
+ },
7708
+ () => lanController.close(),
7709
+ () => mobileAccess.stopLocal(),
7710
+ unregisterBuiltin
7711
+ ]);
7712
+ } catch (cleanupError) {
7713
+ throw new AggregateError([error, cleanupError], "DSH Mobile initialization and cleanup failed");
7714
+ }
6172
7715
  throw error;
6173
7716
  }
6174
7717
  return async () => {
6175
- unregister();
6176
- disposeMobileCommand();
6177
- await Promise.all([remoteControllers.tailscale.close(), remoteControllers.cpolar.close()]);
6178
- await lanController.close();
6179
- await mobileAccess.stopLocal();
6180
- unregisterBuiltin();
7718
+ await settleCleanupSteps([
7719
+ unregister,
7720
+ disposeMobileCommand,
7721
+ async () => {
7722
+ const failures = (await Promise.allSettled(Object.values(remoteControllers).map((controller) => controller.close()))).filter((result) => result.status === "rejected").map((result) => result.reason);
7723
+ if (failures.length > 0) throw new AggregateError(failures, "remote provider cleanup failed");
7724
+ },
7725
+ () => lanController.close(),
7726
+ () => mobileAccess.stopLocal(),
7727
+ unregisterBuiltin
7728
+ ]);
6181
7729
  };
6182
- }, "dsh-mobile: independent LAN and selectable remote access with /mobile command");
7730
+ }, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
6183
7731
  }
6184
7732
  //#endregion
6185
- export { AUTH_PREFIX, AccessController, AccessError, BoundedRateLimiter, CSRF_COOKIE, CSRF_HEADER, Config, DEVICE_COOKIE, EXTENSION_LIMITS, JsonDeviceStore, JsonMobileAccessControlStore, LOCAL_ADMIN_PREFIX, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, RequestTrustPolicy, SESSION_COOKIE, SUPPORTED_DSH_VERSIONS, WS_PATHS, addressAllowed, apply, assertExtensionId, assertSupportedDshVersion, createMobileAccessService, inject, isLoopbackAddress, name, parseAuthority, parseCidr, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseGatewayConfig, parseMobileAccessControlState, resolveAuthority, rewriteMobileIndex };
7733
+ export { AUTH_PREFIX, AccessController, AccessError, BoundedRateLimiter, CSRF_COOKIE, CSRF_HEADER, Config, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_COMPONENT_RELEASES, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, RequestTrustPolicy, SESSION_COOKIE, SUPPORTED_DSH_VERSIONS, WS_PATHS, addressAllowed, apply, assertExtensionId, assertSupportedDshVersion, configuredRemoteProvider, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, inject, isLoopbackAddress, name, parseAuthority, parseCidr, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseRemoteProviderState, resolveAuthority, rewriteMobileIndex, validateFrpPublicOrigin, validateFrpServerAddress, validateFrpServerPort, validateFrpToken };
6186
7734
 
6187
7735
  //# sourceMappingURL=index.mjs.map