dsh-mobile 0.3.1 → 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/CHANGELOG.md +21 -0
- package/FUNNEL_THIRD_PARTY_LICENSES.txt +2551 -0
- package/README.en.md +26 -13
- package/README.md +24 -11
- package/SECURITY.md +6 -3
- package/THIRD_PARTY_NOTICES.md +7 -1
- package/bin/dsh-mobile-funnel-win32-x64.exe +0 -0
- package/lib/cli.js +4 -1
- package/lib/cli.js.map +1 -0
- package/lib/client.js +2997 -450
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +259 -11
- package/lib/index.mjs +2341 -407
- package/lib/index.mjs.map +1 -0
- package/lib/mobile-layout.js +54 -10
- package/lib/mobile-layout.js.map +1 -1
- package/package.json +10 -6
- package/assets/brand/app-icon-master.png +0 -0
- package/assets/brand/repository-hero.png +0 -0
package/lib/index.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { createSocket } from "node:dgram";
|
|
|
11
11
|
import { homedir, hostname, networkInterfaces } from "node:os";
|
|
12
12
|
import { createServer as createServer$1, request } from "node:http";
|
|
13
13
|
import { createServer as createServer$2 } from "node:https";
|
|
14
|
-
import { Transform } from "node:stream";
|
|
14
|
+
import { Transform, finished } from "node:stream";
|
|
15
15
|
import { pipeline } from "node:stream/promises";
|
|
16
16
|
import { createGzip, gzip } from "node:zlib";
|
|
17
17
|
import Bonjour from "bonjour-service";
|
|
@@ -1246,18 +1246,40 @@ 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;
|
|
1253
|
-
|
|
1256
|
+
/** The previous Host outlives the hidden-page refresh interval and one timed refresh. */
|
|
1257
|
+
const RETIRED_GENERATION_TTL_MS = 6e5;
|
|
1258
|
+
/** Extension teardown is advisory and must never stop watcher progress. */
|
|
1259
|
+
const HOST_TEARDOWN_TIMEOUT_MS = 2e3;
|
|
1260
|
+
async function withActivationTimeout(promise, id, signal) {
|
|
1254
1261
|
let timer;
|
|
1262
|
+
let onAbort;
|
|
1255
1263
|
try {
|
|
1256
|
-
return await Promise.race([
|
|
1257
|
-
|
|
1258
|
-
|
|
1264
|
+
return await Promise.race([
|
|
1265
|
+
promise,
|
|
1266
|
+
new Promise((_, reject) => {
|
|
1267
|
+
timer = setTimeout(() => reject(new MobileExtensionError("host_load_timeout", `extension ${id} activation timed out`, 500)), HOST_ACTIVATION_TIMEOUT_MS);
|
|
1268
|
+
}),
|
|
1269
|
+
new Promise((_, reject) => {
|
|
1270
|
+
const abort = () => {
|
|
1271
|
+
reject(new MobileExtensionError("host_activation_closed", `extension ${id} activation is closed`, 409));
|
|
1272
|
+
};
|
|
1273
|
+
if (signal.aborted) abort();
|
|
1274
|
+
else {
|
|
1275
|
+
onAbort = abort;
|
|
1276
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
1277
|
+
}
|
|
1278
|
+
})
|
|
1279
|
+
]);
|
|
1259
1280
|
} finally {
|
|
1260
1281
|
if (timer !== void 0) clearTimeout(timer);
|
|
1282
|
+
if (onAbort !== void 0) signal.removeEventListener("abort", onAbort);
|
|
1261
1283
|
}
|
|
1262
1284
|
}
|
|
1263
1285
|
/** A controlled business failure returned by an extension action or route. */
|
|
@@ -1312,7 +1334,7 @@ function normalizeRelativePath(value, field) {
|
|
|
1312
1334
|
if (normalized.split("/").some((part) => part === "" || part === "." || part === "..")) throw new MobileExtensionError("invalid_extension_path", `${field} escapes extension directory`);
|
|
1313
1335
|
return normalized;
|
|
1314
1336
|
}
|
|
1315
|
-
async function regularFile$
|
|
1337
|
+
async function regularFile$2(path, maximum, field) {
|
|
1316
1338
|
let info;
|
|
1317
1339
|
try {
|
|
1318
1340
|
info = await lstat(path);
|
|
@@ -1333,7 +1355,7 @@ async function containedPath(root, relativePath, maximum, field) {
|
|
|
1333
1355
|
const targetReal = await realpath(target);
|
|
1334
1356
|
const relation = relative(rootReal, targetReal);
|
|
1335
1357
|
if (relation === "" || relation.startsWith("..") || isAbsolute(relation)) throw new MobileExtensionError("invalid_extension_path", `${field} escapes extension directory`);
|
|
1336
|
-
return regularFile$
|
|
1358
|
+
return regularFile$2(targetReal, maximum, field);
|
|
1337
1359
|
}
|
|
1338
1360
|
async function optionalFile(root, name, maximum, field) {
|
|
1339
1361
|
try {
|
|
@@ -1346,21 +1368,96 @@ async function optionalFile(root, name, maximum, field) {
|
|
|
1346
1368
|
}
|
|
1347
1369
|
async function optionalBytes(root, name, maximum, field) {
|
|
1348
1370
|
const path = await optionalFile(root, name, maximum, field);
|
|
1349
|
-
return path === void 0 ?
|
|
1371
|
+
return path === void 0 ? void 0 : readFile(path);
|
|
1372
|
+
}
|
|
1373
|
+
function assertRealPathWithin(rootReal, targetReal, field) {
|
|
1374
|
+
const relation = relative(rootReal, targetReal);
|
|
1375
|
+
if (relation === "" || relation.startsWith("..") || isAbsolute(relation)) throw new MobileExtensionError("invalid_extension_path", `${field} escapes extension directory`);
|
|
1376
|
+
}
|
|
1377
|
+
async function realExtensionRoot(directory) {
|
|
1378
|
+
const root = resolve(directory);
|
|
1379
|
+
const info = await lstat(root);
|
|
1380
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new MobileExtensionError("invalid_extension", "extension directory must be real");
|
|
1381
|
+
return realpath(root);
|
|
1382
|
+
}
|
|
1383
|
+
async function assetSnapshot(extensionRootReal) {
|
|
1384
|
+
const assetsPath = join(extensionRootReal, "assets");
|
|
1385
|
+
let assetsInfo;
|
|
1386
|
+
try {
|
|
1387
|
+
assetsInfo = await lstat(assetsPath);
|
|
1388
|
+
} catch (error) {
|
|
1389
|
+
if (error.code === "ENOENT") return /* @__PURE__ */ new Map();
|
|
1390
|
+
throw error;
|
|
1391
|
+
}
|
|
1392
|
+
if (!assetsInfo.isDirectory() || assetsInfo.isSymbolicLink()) throw new MobileExtensionError("invalid_extension", "assets must be a real directory");
|
|
1393
|
+
const assetsReal = await realpath(assetsPath);
|
|
1394
|
+
assertRealPathWithin(extensionRootReal, assetsReal, "assets");
|
|
1395
|
+
const snapshots = /* @__PURE__ */ new Map();
|
|
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");
|
|
1399
|
+
assertRealPathWithin(extensionRootReal, directoryReal, "asset directory");
|
|
1400
|
+
const handle = await opendir(directoryReal);
|
|
1401
|
+
const entries = [];
|
|
1402
|
+
try {
|
|
1403
|
+
for await (const entry of handle) entries.push(entry);
|
|
1404
|
+
} finally {
|
|
1405
|
+
await handle.close().catch(() => void 0);
|
|
1406
|
+
}
|
|
1407
|
+
entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
|
1408
|
+
for (const entry of entries) {
|
|
1409
|
+
const path = join(directoryReal, entry.name);
|
|
1410
|
+
const info = await lstat(path);
|
|
1411
|
+
if (info.isSymbolicLink()) throw new MobileExtensionError("invalid_extension_path", "asset escapes extension directory");
|
|
1412
|
+
const targetReal = await realpath(path);
|
|
1413
|
+
assertRealPathWithin(extensionRootReal, targetReal, "asset");
|
|
1414
|
+
const key = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
1415
|
+
if (info.isDirectory()) {
|
|
1416
|
+
await visit(targetReal, key, depth + 1);
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
if (!info.isFile() || info.size > EXTENSION_LIMITS.asset) throw new MobileExtensionError("invalid_extension", "asset must be a regular file within its size limit");
|
|
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");
|
|
1423
|
+
snapshots.set(key, Object.freeze({
|
|
1424
|
+
body,
|
|
1425
|
+
digest: createHash("sha256").update(body).digest("hex"),
|
|
1426
|
+
name: entry.name
|
|
1427
|
+
}));
|
|
1428
|
+
}
|
|
1429
|
+
};
|
|
1430
|
+
await visit(assetsReal, "", 0);
|
|
1431
|
+
return snapshots;
|
|
1350
1432
|
}
|
|
1351
1433
|
async function extensionFingerprint(directory) {
|
|
1352
|
-
const
|
|
1434
|
+
const root = await realExtensionRoot(directory);
|
|
1435
|
+
const manifestFile = await regularFile$2(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
|
|
1353
1436
|
const manifestBody = await readFile(manifestFile.path);
|
|
1354
1437
|
const manifest = parseExtensionManifest(JSON.parse(manifestBody.toString("utf8")));
|
|
1355
|
-
if (manifest.id !== basename(
|
|
1356
|
-
const [host, script, style] = await Promise.all([
|
|
1357
|
-
optionalBytes(
|
|
1358
|
-
optionalBytes(
|
|
1359
|
-
optionalBytes(
|
|
1438
|
+
if (manifest.id !== basename(root)) throw new MobileExtensionError("invalid_manifest", "extension id must match its directory name");
|
|
1439
|
+
const [host, script, style, assets] = await Promise.all([
|
|
1440
|
+
optionalBytes(root, "host.mjs", EXTENSION_LIMITS.script, "host.mjs"),
|
|
1441
|
+
optionalBytes(root, "mobile.js", EXTENSION_LIMITS.script, "mobile.js"),
|
|
1442
|
+
optionalBytes(root, "mobile.css", EXTENSION_LIMITS.css, "mobile.css"),
|
|
1443
|
+
assetSnapshot(root)
|
|
1360
1444
|
]);
|
|
1445
|
+
const digest = createHash("sha256").update(`manifest:${manifestBody.byteLength}:`).update(createHash("sha256").update(manifestBody).digest());
|
|
1446
|
+
for (const [name, body] of [
|
|
1447
|
+
["host", host],
|
|
1448
|
+
["script", script],
|
|
1449
|
+
["style", style]
|
|
1450
|
+
]) {
|
|
1451
|
+
digest.update(`\0${name}:${body?.byteLength ?? -1}:`);
|
|
1452
|
+
if (body !== void 0) digest.update(createHash("sha256").update(body).digest());
|
|
1453
|
+
}
|
|
1454
|
+
for (const [name, asset] of assets) digest.update(`\0asset:${Buffer.byteLength(name)}:${name}:${asset.body.byteLength}:${asset.digest}`);
|
|
1361
1455
|
return {
|
|
1362
1456
|
manifest,
|
|
1363
|
-
digest:
|
|
1457
|
+
digest: digest.digest("hex"),
|
|
1458
|
+
assets,
|
|
1459
|
+
...script === void 0 ? {} : { scriptBody: script },
|
|
1460
|
+
...style === void 0 ? {} : { styleBody: style }
|
|
1364
1461
|
};
|
|
1365
1462
|
}
|
|
1366
1463
|
function routeKey(route) {
|
|
@@ -1415,20 +1512,49 @@ function validateDefinition(definition) {
|
|
|
1415
1512
|
...routes.length === 0 ? {} : { routes: Object.freeze(routes) }
|
|
1416
1513
|
});
|
|
1417
1514
|
}
|
|
1418
|
-
function
|
|
1419
|
-
if (first.aborted || second.aborted)
|
|
1420
|
-
|
|
1515
|
+
function combineSignalLifetime(first, second) {
|
|
1516
|
+
if (first.aborted || second.aborted) {
|
|
1517
|
+
const aborted = new AbortController();
|
|
1518
|
+
aborted.abort(first.aborted ? first.reason : second.reason);
|
|
1519
|
+
return {
|
|
1520
|
+
signal: aborted.signal,
|
|
1521
|
+
cleanup: () => void 0
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
const controller = new AbortController();
|
|
1525
|
+
const cleanup = () => {
|
|
1526
|
+
first.removeEventListener("abort", abortFirst);
|
|
1527
|
+
second.removeEventListener("abort", abortSecond);
|
|
1528
|
+
};
|
|
1529
|
+
const abortFirst = () => {
|
|
1530
|
+
cleanup();
|
|
1531
|
+
controller.abort(first.reason);
|
|
1532
|
+
};
|
|
1533
|
+
const abortSecond = () => {
|
|
1534
|
+
cleanup();
|
|
1535
|
+
controller.abort(second.reason);
|
|
1536
|
+
};
|
|
1537
|
+
first.addEventListener("abort", abortFirst, { once: true });
|
|
1538
|
+
second.addEventListener("abort", abortSecond, { once: true });
|
|
1539
|
+
return {
|
|
1540
|
+
signal: controller.signal,
|
|
1541
|
+
cleanup
|
|
1542
|
+
};
|
|
1421
1543
|
}
|
|
1422
1544
|
/** Host registry and service consumed by both npm plugins and local extensions. */
|
|
1423
1545
|
var MobileAccessService = class extends Service {
|
|
1424
1546
|
registered = /* @__PURE__ */ new Map();
|
|
1425
1547
|
local = /* @__PURE__ */ new Map();
|
|
1548
|
+
retired = /* @__PURE__ */ new Map();
|
|
1426
1549
|
failures = /* @__PURE__ */ new Map();
|
|
1427
|
-
|
|
1550
|
+
contentListeners = /* @__PURE__ */ new Set();
|
|
1551
|
+
contentHash = createHash("sha256").update("").digest("hex");
|
|
1428
1552
|
localRoot;
|
|
1429
1553
|
localContext;
|
|
1430
1554
|
localTimer;
|
|
1431
1555
|
localRefreshing;
|
|
1556
|
+
localRefreshAbort;
|
|
1557
|
+
localLifecycle = 0;
|
|
1432
1558
|
localClosed = true;
|
|
1433
1559
|
constructor(ctx) {
|
|
1434
1560
|
super(ctx, "mobileAccess");
|
|
@@ -1454,9 +1580,21 @@ var MobileAccessService = class extends Service {
|
|
|
1454
1580
|
contentDigest() {
|
|
1455
1581
|
return this.contentHash;
|
|
1456
1582
|
}
|
|
1583
|
+
/** Subscribe to committed extension generation changes. */
|
|
1584
|
+
onContentChanged(listener) {
|
|
1585
|
+
this.contentListeners.add(listener);
|
|
1586
|
+
return () => {
|
|
1587
|
+
this.contentListeners.delete(listener);
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1457
1590
|
updateContentHash() {
|
|
1458
1591
|
const parts = [...[...this.registered.values()].map((entry) => entry.definition.id), ...[...this.local.values()].map((active) => `${active.manifest.id}:${active.digest}`)];
|
|
1459
|
-
|
|
1592
|
+
const next = createHash("sha256").update(parts.sort().join("|")).digest("hex");
|
|
1593
|
+
if (next === this.contentHash) return;
|
|
1594
|
+
this.contentHash = next;
|
|
1595
|
+
for (const listener of this.contentListeners) try {
|
|
1596
|
+
listener();
|
|
1597
|
+
} catch {}
|
|
1460
1598
|
}
|
|
1461
1599
|
/** Return the current client-facing manifest, deterministically sorted by id. */
|
|
1462
1600
|
manifest() {
|
|
@@ -1470,8 +1608,9 @@ var MobileAccessService = class extends Service {
|
|
|
1470
1608
|
});
|
|
1471
1609
|
for (const active of this.local.values()) entries.set(active.manifest.id, {
|
|
1472
1610
|
...active.manifest,
|
|
1473
|
-
|
|
1474
|
-
...active.
|
|
1611
|
+
generation: active.digest,
|
|
1612
|
+
...active.scriptBody === void 0 ? {} : { scriptUrl: `/mobile-access/extensions/${active.manifest.id}/mobile.js?generation=${active.digest}` },
|
|
1613
|
+
...active.styleBody === void 0 ? {} : { styleUrl: `/mobile-access/extensions/${active.manifest.id}/mobile.css?generation=${active.digest}` },
|
|
1475
1614
|
assetsUrl: `/mobile-access/extensions/${active.manifest.id}/assets/`
|
|
1476
1615
|
});
|
|
1477
1616
|
return [...entries.values()].sort((left, right) => left.id.localeCompare(right.id));
|
|
@@ -1484,49 +1623,52 @@ var MobileAccessService = class extends Service {
|
|
|
1484
1623
|
});
|
|
1485
1624
|
}
|
|
1486
1625
|
/** Locate one active extension. */
|
|
1487
|
-
extension(id) {
|
|
1626
|
+
extension(id, generation) {
|
|
1627
|
+
if (generation !== void 0) {
|
|
1628
|
+
const current = this.local.get(id);
|
|
1629
|
+
if (current?.digest === generation) return current;
|
|
1630
|
+
const previous = this.retired.get(id)?.active;
|
|
1631
|
+
return previous?.digest === generation ? previous : void 0;
|
|
1632
|
+
}
|
|
1488
1633
|
return this.local.get(id) ?? this.registered.get(id)?.definition;
|
|
1489
1634
|
}
|
|
1490
1635
|
/** Return the active local generation signal for gateway cancellation wiring. */
|
|
1491
|
-
signal(id) {
|
|
1492
|
-
|
|
1636
|
+
signal(id, generation) {
|
|
1637
|
+
const extension = this.extension(id, generation);
|
|
1638
|
+
return extension !== void 0 && "host" in extension ? extension.controller.signal : void 0;
|
|
1493
1639
|
}
|
|
1494
1640
|
/** Read a local client entry after validating that it remains inside its directory. */
|
|
1495
|
-
async readClientFile(id, kind, signal) {
|
|
1641
|
+
async readClientFile(id, kind, signal, generation) {
|
|
1496
1642
|
signal?.throwIfAborted();
|
|
1497
|
-
const
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
const body =
|
|
1643
|
+
const selected = this.extension(id, generation);
|
|
1644
|
+
const active = selected !== void 0 && "host" in selected ? selected : void 0;
|
|
1645
|
+
if (active === void 0) throw new MobileExtensionError("extension_generation_not_found", "extension generation not found", 404);
|
|
1646
|
+
const snapshot = kind === "script" ? active.scriptBody : active.styleBody;
|
|
1647
|
+
if (snapshot === void 0) throw new MobileExtensionError("extension_asset_not_found", "extension asset not found", 404);
|
|
1648
|
+
const body = Buffer.from(snapshot);
|
|
1503
1649
|
return {
|
|
1504
1650
|
body,
|
|
1505
1651
|
digest: createHash("sha256").update(body).digest("hex")
|
|
1506
1652
|
};
|
|
1507
1653
|
}
|
|
1508
|
-
/** Read a
|
|
1509
|
-
async readAsset(id, assetPath, signal) {
|
|
1654
|
+
/** Read a generation-pinned static asset from its validated snapshot. */
|
|
1655
|
+
async readAsset(id, assetPath, signal, generation) {
|
|
1510
1656
|
signal?.throwIfAborted();
|
|
1511
|
-
const
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
if (error.code === "ENOENT") throw new MobileExtensionError("extension_asset_not_found", "extension asset not found", 404);
|
|
1518
|
-
throw error;
|
|
1519
|
-
}
|
|
1520
|
-
const body = await readFile(file.path, { signal });
|
|
1657
|
+
const selected = this.extension(id, generation);
|
|
1658
|
+
const active = selected !== void 0 && "host" in selected ? selected : void 0;
|
|
1659
|
+
if (active === void 0) throw new MobileExtensionError("extension_generation_not_found", "extension generation not found", 404);
|
|
1660
|
+
const normalized = normalizeRelativePath(assetPath, "asset");
|
|
1661
|
+
const asset = active.assets.get(normalized);
|
|
1662
|
+
if (asset === void 0) throw new MobileExtensionError("extension_asset_not_found", "extension asset not found", 404);
|
|
1521
1663
|
return {
|
|
1522
|
-
body,
|
|
1523
|
-
digest:
|
|
1524
|
-
name:
|
|
1664
|
+
body: Buffer.from(asset.body),
|
|
1665
|
+
digest: asset.digest,
|
|
1666
|
+
name: asset.name
|
|
1525
1667
|
};
|
|
1526
1668
|
}
|
|
1527
1669
|
/** Invoke one action after parsing its input and binding the request lifetime. */
|
|
1528
|
-
async invoke(id, actionName, input, context) {
|
|
1529
|
-
const extension = this.extension(id);
|
|
1670
|
+
async invoke(id, actionName, input, context, generation) {
|
|
1671
|
+
const extension = this.extension(id, generation);
|
|
1530
1672
|
if (extension === void 0) throw new MobileExtensionError("extension_not_found", "extension not found", 404);
|
|
1531
1673
|
const action = ("host" in extension ? extension.host : extension).actions?.[actionName];
|
|
1532
1674
|
if (action === void 0) throw new MobileExtensionError("action_not_found", "action not found", 404);
|
|
@@ -1536,7 +1678,8 @@ var MobileAccessService = class extends Service {
|
|
|
1536
1678
|
} catch {
|
|
1537
1679
|
throw new MobileExtensionError("invalid_action_input", "action input is invalid", 400);
|
|
1538
1680
|
}
|
|
1539
|
-
const
|
|
1681
|
+
const lifetime = "host" in extension ? combineSignalLifetime(extension.controller.signal, context.signal) : void 0;
|
|
1682
|
+
const signal = lifetime?.signal ?? context.signal;
|
|
1540
1683
|
try {
|
|
1541
1684
|
return await action.run({
|
|
1542
1685
|
...context,
|
|
@@ -1545,39 +1688,53 @@ var MobileAccessService = class extends Service {
|
|
|
1545
1688
|
} catch (error) {
|
|
1546
1689
|
if (error instanceof MobileExtensionError) throw error;
|
|
1547
1690
|
throw new MobileExtensionError("extension_failed", "extension action failed", 500);
|
|
1691
|
+
} finally {
|
|
1692
|
+
lifetime?.cleanup();
|
|
1548
1693
|
}
|
|
1549
1694
|
}
|
|
1550
1695
|
/** Match one route and invoke it with a generation-bound abort signal. */
|
|
1551
|
-
async route(id, method, pathname, request) {
|
|
1552
|
-
const extension = this.extension(id);
|
|
1696
|
+
async route(id, method, pathname, request, generation) {
|
|
1697
|
+
const extension = this.extension(id, generation);
|
|
1553
1698
|
if (extension === void 0) throw new MobileExtensionError("extension_not_found", "extension not found", 404);
|
|
1554
1699
|
const route = ("host" in extension ? extension.host : extension).routes?.find((candidate) => {
|
|
1555
1700
|
if (candidate.method !== method) return false;
|
|
1556
1701
|
return (candidate.kind ?? "exact") === "exact" ? candidate.path === pathname : pathname === candidate.path || pathname.startsWith(`${candidate.path}/`);
|
|
1557
1702
|
});
|
|
1558
1703
|
if (route === void 0) throw new MobileExtensionError("route_not_found", "route not found", 404);
|
|
1704
|
+
const lifetime = "host" in extension ? combineSignalLifetime(extension.controller.signal, request.signal) : void 0;
|
|
1705
|
+
let releaseLifetime = true;
|
|
1559
1706
|
try {
|
|
1560
|
-
const routeRequest =
|
|
1707
|
+
const routeRequest = lifetime === void 0 ? request : {
|
|
1561
1708
|
...request,
|
|
1562
|
-
signal:
|
|
1563
|
-
}
|
|
1709
|
+
signal: lifetime.signal
|
|
1710
|
+
};
|
|
1564
1711
|
const result = await route.handle(routeRequest);
|
|
1565
1712
|
if (result === null || typeof result !== "object" || typeof result.body !== "string" && !(result.body instanceof Uint8Array) && !isReadable(result.body)) throw new MobileExtensionError("invalid_route_response", "extension returned an invalid response", 500);
|
|
1713
|
+
if (lifetime !== void 0 && isReadable(result.body)) {
|
|
1714
|
+
releaseLifetime = false;
|
|
1715
|
+
releaseSignalLifetimeWhenStreamSettles(result.body, lifetime.cleanup);
|
|
1716
|
+
}
|
|
1566
1717
|
return result;
|
|
1567
1718
|
} catch (error) {
|
|
1568
1719
|
if (error instanceof MobileExtensionError) throw error;
|
|
1569
1720
|
throw new MobileExtensionError("extension_failed", "extension route failed", 500);
|
|
1721
|
+
} finally {
|
|
1722
|
+
if (releaseLifetime) lifetime?.cleanup();
|
|
1570
1723
|
}
|
|
1571
1724
|
}
|
|
1572
1725
|
/** Start the local directory watcher; an absent directory is intentionally inert. */
|
|
1573
1726
|
async startLocal(root, context) {
|
|
1574
|
-
|
|
1727
|
+
const targetRoot = resolve(root);
|
|
1728
|
+
if (this.localRoot !== void 0 && resolve(this.localRoot) !== targetRoot) await this.stopLocal();
|
|
1575
1729
|
if (this.localTimer !== void 0) clearInterval(this.localTimer);
|
|
1576
|
-
|
|
1730
|
+
const lifecycle = ++this.localLifecycle;
|
|
1731
|
+
this.localRoot = targetRoot;
|
|
1577
1732
|
this.localContext = context;
|
|
1578
1733
|
this.localClosed = false;
|
|
1579
1734
|
await mkdir(this.localRoot, { recursive: true });
|
|
1735
|
+
if (this.localClosed || this.localLifecycle !== lifecycle || this.localRoot !== targetRoot || this.localContext !== context) return;
|
|
1580
1736
|
await this.refreshLocal();
|
|
1737
|
+
if (this.localClosed || this.localLifecycle !== lifecycle || this.localRoot !== targetRoot || this.localContext !== context) return;
|
|
1581
1738
|
this.localTimer = setInterval(() => {
|
|
1582
1739
|
this.refreshLocal();
|
|
1583
1740
|
}, 2e3);
|
|
@@ -1586,23 +1743,43 @@ var MobileAccessService = class extends Service {
|
|
|
1586
1743
|
/** Stop the watcher and abort every local host generation. */
|
|
1587
1744
|
async stopLocal() {
|
|
1588
1745
|
this.localClosed = true;
|
|
1746
|
+
const lifecycle = ++this.localLifecycle;
|
|
1589
1747
|
if (this.localTimer !== void 0) clearInterval(this.localTimer);
|
|
1590
1748
|
this.localTimer = void 0;
|
|
1591
|
-
const
|
|
1749
|
+
const refreshing = this.localRefreshing;
|
|
1750
|
+
this.localRefreshAbort?.abort();
|
|
1751
|
+
const previous = [...this.local.values(), ...[...this.retired.values()].map((entry) => entry.active)];
|
|
1752
|
+
this.local.clear();
|
|
1753
|
+
for (const entry of this.retired.values()) clearTimeout(entry.timer);
|
|
1754
|
+
this.retired.clear();
|
|
1755
|
+
this.failures.clear();
|
|
1756
|
+
this.updateContentHash();
|
|
1757
|
+
await Promise.allSettled([abortAndDisposeLocal(previous), ...refreshing === void 0 ? [] : [refreshing]]);
|
|
1758
|
+
if (this.localLifecycle !== lifecycle) return;
|
|
1759
|
+
const late = [...this.local.values(), ...[...this.retired.values()].map((entry) => entry.active)];
|
|
1592
1760
|
this.local.clear();
|
|
1761
|
+
for (const entry of this.retired.values()) clearTimeout(entry.timer);
|
|
1762
|
+
this.retired.clear();
|
|
1593
1763
|
this.failures.clear();
|
|
1594
|
-
|
|
1764
|
+
this.updateContentHash();
|
|
1765
|
+
await abortAndDisposeLocal(late);
|
|
1766
|
+
if (this.localTimer !== void 0) clearInterval(this.localTimer);
|
|
1767
|
+
this.localTimer = void 0;
|
|
1595
1768
|
}
|
|
1596
1769
|
/** Refresh all local extensions atomically; failures keep the previous snapshot. */
|
|
1597
1770
|
refreshLocal() {
|
|
1598
1771
|
if (this.localRefreshing !== void 0) return this.localRefreshing;
|
|
1599
|
-
|
|
1600
|
-
|
|
1772
|
+
const controller = new AbortController();
|
|
1773
|
+
this.localRefreshAbort = controller;
|
|
1774
|
+
const refreshing = this.stageAndCommit(controller.signal).finally(() => {
|
|
1775
|
+
if (this.localRefreshing === refreshing) this.localRefreshing = void 0;
|
|
1776
|
+
if (this.localRefreshAbort === controller) this.localRefreshAbort = void 0;
|
|
1601
1777
|
});
|
|
1602
|
-
|
|
1778
|
+
this.localRefreshing = refreshing;
|
|
1779
|
+
return refreshing;
|
|
1603
1780
|
}
|
|
1604
|
-
async stageAndCommit() {
|
|
1605
|
-
if (this.localClosed || this.localRoot === void 0 || this.localContext === void 0) return;
|
|
1781
|
+
async stageAndCommit(signal) {
|
|
1782
|
+
if (this.localClosed || signal.aborted || this.localRoot === void 0 || this.localContext === void 0) return;
|
|
1606
1783
|
let names = [];
|
|
1607
1784
|
try {
|
|
1608
1785
|
const directory = await opendir(this.localRoot);
|
|
@@ -1620,19 +1797,29 @@ var MobileAccessService = class extends Service {
|
|
|
1620
1797
|
let failingName = "local";
|
|
1621
1798
|
try {
|
|
1622
1799
|
for (const name of names) {
|
|
1800
|
+
signal.throwIfAborted();
|
|
1623
1801
|
failingName = name;
|
|
1624
1802
|
const directory = join(this.localRoot, name);
|
|
1625
1803
|
const fingerprint = await extensionFingerprint(directory);
|
|
1626
|
-
const
|
|
1627
|
-
|
|
1804
|
+
const current = this.local.get(fingerprint.manifest.id);
|
|
1805
|
+
const retired = this.retired.get(fingerprint.manifest.id)?.active;
|
|
1806
|
+
const previous = current?.digest === fingerprint.digest ? current : retired?.digest === fingerprint.digest ? retired : void 0;
|
|
1807
|
+
if (previous?.digest === fingerprint.digest) staged.push(previous);
|
|
1628
1808
|
else {
|
|
1629
|
-
const fresh = await loadLocalExtension(directory, this.localContext, fingerprint);
|
|
1809
|
+
const fresh = await loadLocalExtension(directory, this.localContext, fingerprint, signal);
|
|
1810
|
+
try {
|
|
1811
|
+
signal.throwIfAborted();
|
|
1812
|
+
if ((await extensionFingerprint(directory)).digest !== fingerprint.digest) throw new MobileExtensionError("extension_changed_during_activation", `extension ${fingerprint.manifest.id} changed during activation`, 409);
|
|
1813
|
+
} catch (error) {
|
|
1814
|
+
await abortAndDisposeLocal([fresh]);
|
|
1815
|
+
throw error;
|
|
1816
|
+
}
|
|
1630
1817
|
staged.push(fresh);
|
|
1631
1818
|
stagedFresh.push(fresh);
|
|
1632
1819
|
}
|
|
1633
1820
|
}
|
|
1634
|
-
if (this.localClosed || this.localRoot === void 0 || this.localContext === void 0) {
|
|
1635
|
-
await
|
|
1821
|
+
if (this.localClosed || signal.aborted || this.localRoot === void 0 || this.localContext === void 0) {
|
|
1822
|
+
await abortAndDisposeLocal(stagedFresh);
|
|
1636
1823
|
return;
|
|
1637
1824
|
}
|
|
1638
1825
|
const duplicate = /* @__PURE__ */ new Set();
|
|
@@ -1641,42 +1828,106 @@ var MobileAccessService = class extends Service {
|
|
|
1641
1828
|
duplicate.add(entry.manifest.id);
|
|
1642
1829
|
}
|
|
1643
1830
|
const previous = [...this.local.values()];
|
|
1831
|
+
for (const entry of staged) {
|
|
1832
|
+
const retired = this.retired.get(entry.manifest.id);
|
|
1833
|
+
if (retired?.active === entry) {
|
|
1834
|
+
clearTimeout(retired.timer);
|
|
1835
|
+
this.retired.delete(entry.manifest.id);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
const stagedIds = new Set(staged.map((entry) => entry.manifest.id));
|
|
1839
|
+
const removed = [];
|
|
1840
|
+
for (const entry of previous) {
|
|
1841
|
+
if (staged.includes(entry)) continue;
|
|
1842
|
+
if (stagedIds.has(entry.manifest.id)) {
|
|
1843
|
+
this.retire(entry);
|
|
1844
|
+
continue;
|
|
1845
|
+
}
|
|
1846
|
+
removed.push(entry);
|
|
1847
|
+
const retired = this.retired.get(entry.manifest.id);
|
|
1848
|
+
if (retired !== void 0) {
|
|
1849
|
+
clearTimeout(retired.timer);
|
|
1850
|
+
this.retired.delete(entry.manifest.id);
|
|
1851
|
+
removed.push(retired.active);
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1644
1854
|
this.local.clear();
|
|
1645
1855
|
for (const entry of staged) this.local.set(entry.manifest.id, entry);
|
|
1646
1856
|
for (const entry of staged) this.failures.delete(entry.manifest.id);
|
|
1647
1857
|
for (const name of names) this.failures.delete(name);
|
|
1648
1858
|
for (const failure of this.failures.keys()) if (failure !== "local" && !names.includes(failure)) this.failures.delete(failure);
|
|
1649
1859
|
this.failures.delete("local");
|
|
1650
|
-
|
|
1860
|
+
if (removed.length > 0) abortAndDisposeLocal(removed);
|
|
1651
1861
|
this.updateContentHash();
|
|
1652
1862
|
} catch (error) {
|
|
1653
|
-
await
|
|
1863
|
+
await abortAndDisposeLocal(stagedFresh);
|
|
1864
|
+
if (this.localClosed || signal.aborted) return;
|
|
1654
1865
|
const message = error instanceof Error ? error.message : String(error);
|
|
1655
1866
|
this.failures.set(failingName, message);
|
|
1656
1867
|
if (!(error instanceof MobileExtensionError)) this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
|
|
1657
1868
|
}
|
|
1658
1869
|
}
|
|
1870
|
+
retire(active) {
|
|
1871
|
+
const previous = this.retired.get(active.manifest.id);
|
|
1872
|
+
if (previous?.active === active) return;
|
|
1873
|
+
if (previous !== void 0) {
|
|
1874
|
+
clearTimeout(previous.timer);
|
|
1875
|
+
this.retired.delete(active.manifest.id);
|
|
1876
|
+
abortAndDisposeLocal([previous.active]);
|
|
1877
|
+
}
|
|
1878
|
+
const timer = setTimeout(() => {
|
|
1879
|
+
if (this.retired.get(active.manifest.id)?.active !== active) return;
|
|
1880
|
+
this.retired.delete(active.manifest.id);
|
|
1881
|
+
abortAndDisposeLocal([active]);
|
|
1882
|
+
}, RETIRED_GENERATION_TTL_MS);
|
|
1883
|
+
timer.unref();
|
|
1884
|
+
this.retired.set(active.manifest.id, {
|
|
1885
|
+
active,
|
|
1886
|
+
timer
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1659
1889
|
};
|
|
1660
1890
|
function isReadable(value) {
|
|
1661
1891
|
return value !== null && typeof value === "object" && typeof value.pipe === "function";
|
|
1662
1892
|
}
|
|
1663
|
-
|
|
1893
|
+
function releaseSignalLifetimeWhenStreamSettles(stream, cleanup) {
|
|
1894
|
+
let stopObserving;
|
|
1895
|
+
stopObserving = finished(stream, () => {
|
|
1896
|
+
stopObserving?.();
|
|
1897
|
+
cleanup();
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
function invokeCleanups(cleanups) {
|
|
1901
|
+
const pending = [];
|
|
1902
|
+
for (const cleanup of [...cleanups].reverse()) try {
|
|
1903
|
+
pending.push(Promise.resolve(cleanup()));
|
|
1904
|
+
} catch {}
|
|
1905
|
+
return pending;
|
|
1906
|
+
}
|
|
1907
|
+
async function settleBounded(pending, timeoutMs) {
|
|
1908
|
+
if (pending.length === 0) return;
|
|
1909
|
+
let timer;
|
|
1910
|
+
await Promise.race([Promise.allSettled(pending), new Promise((resolveTimeout) => {
|
|
1911
|
+
timer = setTimeout(resolveTimeout, timeoutMs);
|
|
1912
|
+
})]);
|
|
1913
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1914
|
+
}
|
|
1915
|
+
async function abortAndDisposeLocal(entries) {
|
|
1916
|
+
const pending = [];
|
|
1664
1917
|
for (const entry of entries) {
|
|
1665
1918
|
entry.controller.abort();
|
|
1666
|
-
|
|
1667
|
-
await cleanup();
|
|
1668
|
-
} catch {}
|
|
1919
|
+
pending.push(...invokeCleanups(entry.cleanups));
|
|
1669
1920
|
}
|
|
1921
|
+
await settleBounded(pending, HOST_TEARDOWN_TIMEOUT_MS);
|
|
1670
1922
|
}
|
|
1671
|
-
async function loadLocalExtension(directory, context, known) {
|
|
1672
|
-
const root =
|
|
1673
|
-
const
|
|
1674
|
-
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new MobileExtensionError("invalid_extension", "extension directory must be real");
|
|
1675
|
-
const manifestFile = await regularFile$1(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
|
|
1923
|
+
async function loadLocalExtension(directory, context, known, parentSignal) {
|
|
1924
|
+
const root = await realExtensionRoot(directory);
|
|
1925
|
+
const manifestFile = await regularFile$2(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
|
|
1676
1926
|
const manifest = known?.manifest ?? parseExtensionManifest(JSON.parse(await readFile(manifestFile.path, "utf8")));
|
|
1677
1927
|
if (manifest.id !== basename(root)) throw new MobileExtensionError("invalid_manifest", "extension id must match its directory name");
|
|
1678
|
-
const
|
|
1679
|
-
const
|
|
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;
|
|
1929
|
+
const styleBody = known === void 0 ? await optionalFile(root, "mobile.css", EXTENSION_LIMITS.css, "mobile.css").then((path) => path === void 0 ? void 0 : readFile(path)) : known.styleBody;
|
|
1930
|
+
const assets = known?.assets ?? await assetSnapshot(root);
|
|
1680
1931
|
const hostFile = await optionalFile(root, "host.mjs", EXTENSION_LIMITS.script, "host.mjs");
|
|
1681
1932
|
const controller = new AbortController();
|
|
1682
1933
|
const actions = {};
|
|
@@ -1684,43 +1935,60 @@ async function loadLocalExtension(directory, context, known) {
|
|
|
1684
1935
|
const cleanups = [];
|
|
1685
1936
|
const pendingEffects = [];
|
|
1686
1937
|
let activationOpen = true;
|
|
1938
|
+
const onParentAbort = () => {
|
|
1939
|
+
controller.abort(parentSignal?.reason);
|
|
1940
|
+
};
|
|
1941
|
+
if (parentSignal?.aborted === true) onParentAbort();
|
|
1942
|
+
else parentSignal?.addEventListener("abort", onParentAbort, { once: true });
|
|
1943
|
+
const ensureActivationOpen = () => {
|
|
1944
|
+
if (!activationOpen || controller.signal.aborted) throw new MobileExtensionError("host_activation_closed", `extension ${manifest.id} activation is closed`, 409);
|
|
1945
|
+
};
|
|
1687
1946
|
const api = {
|
|
1688
1947
|
manifest,
|
|
1689
1948
|
context,
|
|
1690
1949
|
schema: z,
|
|
1691
1950
|
signal: controller.signal,
|
|
1692
1951
|
action(name, spec) {
|
|
1952
|
+
ensureActivationOpen();
|
|
1693
1953
|
if (actions[name] !== void 0) throw new MobileExtensionError("duplicate_action", `duplicate action ${name}`);
|
|
1694
1954
|
actions[name] = spec;
|
|
1695
1955
|
},
|
|
1696
1956
|
route(spec) {
|
|
1957
|
+
ensureActivationOpen();
|
|
1697
1958
|
routes.push(spec);
|
|
1698
1959
|
},
|
|
1699
1960
|
effect(setup) {
|
|
1961
|
+
ensureActivationOpen();
|
|
1700
1962
|
const result = setup();
|
|
1701
1963
|
if (result instanceof Promise) pendingEffects.push(result.then(async (cleanup) => {
|
|
1702
1964
|
if (typeof cleanup !== "function") return;
|
|
1703
1965
|
if (activationOpen) cleanups.push(cleanup);
|
|
1704
1966
|
else await cleanup();
|
|
1705
1967
|
}));
|
|
1706
|
-
else if (typeof result === "function")
|
|
1968
|
+
else if (typeof result === "function") {
|
|
1969
|
+
if (activationOpen) cleanups.push(result);
|
|
1970
|
+
else Promise.resolve(result()).catch(() => void 0);
|
|
1971
|
+
}
|
|
1707
1972
|
}
|
|
1708
1973
|
};
|
|
1709
1974
|
try {
|
|
1710
1975
|
const activate = async () => {
|
|
1976
|
+
controller.signal.throwIfAborted();
|
|
1711
1977
|
if (hostFile !== void 0) {
|
|
1712
1978
|
const digest = createHash("sha256").update(await readFile(hostFile)).digest("hex");
|
|
1979
|
+
controller.signal.throwIfAborted();
|
|
1713
1980
|
let imported;
|
|
1714
1981
|
try {
|
|
1715
1982
|
imported = await import(`${pathToFileURL(hostFile).href}?dsh_generation=${digest}`);
|
|
1716
1983
|
} catch {
|
|
1717
1984
|
throw new MobileExtensionError("host_load_failed", `could not load ${manifest.id}/host.mjs`, 500);
|
|
1718
1985
|
}
|
|
1986
|
+
controller.signal.throwIfAborted();
|
|
1719
1987
|
if (imported.default !== void 0) await imported.default(api);
|
|
1720
1988
|
}
|
|
1721
1989
|
await Promise.all(pendingEffects);
|
|
1722
1990
|
};
|
|
1723
|
-
await withActivationTimeout(activate(), manifest.id);
|
|
1991
|
+
await withActivationTimeout(activate(), manifest.id, controller.signal);
|
|
1724
1992
|
const host = validateDefinition({
|
|
1725
1993
|
...manifest,
|
|
1726
1994
|
actions,
|
|
@@ -1731,8 +1999,9 @@ async function loadLocalExtension(directory, context, known) {
|
|
|
1731
1999
|
return Object.freeze({
|
|
1732
2000
|
manifest,
|
|
1733
2001
|
directory: root,
|
|
1734
|
-
...
|
|
1735
|
-
...
|
|
2002
|
+
...scriptBody === void 0 ? {} : { scriptBody },
|
|
2003
|
+
...styleBody === void 0 ? {} : { styleBody },
|
|
2004
|
+
assets,
|
|
1736
2005
|
host,
|
|
1737
2006
|
controller,
|
|
1738
2007
|
cleanups: Object.freeze(cleanups),
|
|
@@ -1740,12 +2009,12 @@ async function loadLocalExtension(directory, context, known) {
|
|
|
1740
2009
|
});
|
|
1741
2010
|
} catch (error) {
|
|
1742
2011
|
activationOpen = false;
|
|
1743
|
-
await withActivationTimeout(Promise.allSettled(pendingEffects), manifest.id).catch(() => void 0);
|
|
1744
2012
|
controller.abort();
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
} catch {}
|
|
2013
|
+
const cleanupPromises = invokeCleanups(cleanups.splice(0));
|
|
2014
|
+
await settleBounded([...pendingEffects, ...cleanupPromises], HOST_TEARDOWN_TIMEOUT_MS);
|
|
1748
2015
|
throw error;
|
|
2016
|
+
} finally {
|
|
2017
|
+
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
1749
2018
|
}
|
|
1750
2019
|
}
|
|
1751
2020
|
/** Construct the service in a Cordis plugin without importing DSH internals. */
|
|
@@ -1772,6 +2041,8 @@ const UPSTREAM_AUTH_REFRESH_MARGIN_MS = 6e4;
|
|
|
1772
2041
|
const UPSTREAM_COOKIE_PAIR = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+=[\x21-\x3A\x3C-\x7E]*$/u;
|
|
1773
2042
|
const CUSTOM_STYLE_FALLBACK = "/* Add mobile overrides in the DSH home mobile-access/mobile.css file. */\n";
|
|
1774
2043
|
const CUSTOM_SCRIPT_FALLBACK = "window.dshMobile?.register(() => undefined)\n";
|
|
2044
|
+
const EXTENSION_CHANGE_POLL_MS = 2e3;
|
|
2045
|
+
const EXTENSION_EVENT_HEARTBEAT_MS = 15e3;
|
|
1775
2046
|
const MOBILE_CLIENT_MODULE = "dsh-mobile";
|
|
1776
2047
|
const CONNECTION_MODULE = "@deepseek-ai/dsh-client-connection";
|
|
1777
2048
|
const RUNTIME_MODULE = "@deepseek-ai/dsh-client-runtime";
|
|
@@ -2247,6 +2518,7 @@ function discoveryBroadcastTargets(cidrs) {
|
|
|
2247
2518
|
function extensionTarget(pathname) {
|
|
2248
2519
|
const prefix = `${AUTH_PREFIX}/extensions`;
|
|
2249
2520
|
if (pathname === prefix || pathname === `${prefix}/` || pathname === `${prefix}/manifest`) return { kind: "manifest" };
|
|
2521
|
+
if (pathname === `${prefix}/events`) return { kind: "events" };
|
|
2250
2522
|
if (!pathname.startsWith(`${prefix}/`)) return void 0;
|
|
2251
2523
|
const parts = pathname.slice(prefix.length + 1).split("/");
|
|
2252
2524
|
const id = parts.shift();
|
|
@@ -2276,12 +2548,21 @@ function extensionTarget(pathname) {
|
|
|
2276
2548
|
path: `/${parts.join("/")}`.replace(/\/{2,}/gu, "/")
|
|
2277
2549
|
};
|
|
2278
2550
|
}
|
|
2551
|
+
const EXTENSION_GENERATION_HEADER = "x-dsh-mobile-extension-generation";
|
|
2552
|
+
function extensionGeneration(value) {
|
|
2553
|
+
if (value === void 0) return void 0;
|
|
2554
|
+
if (!/^[a-f\d]{64}$/u.test(value)) throw new HttpError(400, "invalid_extension_generation");
|
|
2555
|
+
return value;
|
|
2556
|
+
}
|
|
2279
2557
|
function mobileBootBatchKey(pathname) {
|
|
2280
2558
|
return new RegExp(`^${MOBILE_BOOT_BATCH_PREFIX.replaceAll("/", "\\/")}([a-f\\d]{64})\\.js$`, "u").exec(pathname)?.[1];
|
|
2281
2559
|
}
|
|
2282
|
-
|
|
2560
|
+
function assertBoundedContentLength(request, maximum) {
|
|
2283
2561
|
const declared = request.headers["content-length"];
|
|
2284
2562
|
if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > maximum)) throw new HttpError(413, "payload_too_large");
|
|
2563
|
+
}
|
|
2564
|
+
async function readBoundedBody(request, maximum) {
|
|
2565
|
+
assertBoundedContentLength(request, maximum);
|
|
2285
2566
|
const chunks = [];
|
|
2286
2567
|
let total = 0;
|
|
2287
2568
|
for await (const chunk of request) {
|
|
@@ -2343,6 +2624,11 @@ var MobileAccessGateway = class {
|
|
|
2343
2624
|
activeRequests = /* @__PURE__ */ new Map();
|
|
2344
2625
|
activeWebSockets = /* @__PURE__ */ new Map();
|
|
2345
2626
|
mobileBootBatches = /* @__PURE__ */ new Map();
|
|
2627
|
+
extensionEventListeners = /* @__PURE__ */ new Set();
|
|
2628
|
+
extensionEventRevision = 0;
|
|
2629
|
+
extensionChangeTimer;
|
|
2630
|
+
extensionChangeTask;
|
|
2631
|
+
legacyCustomDigest = "";
|
|
2346
2632
|
upstreamCookie;
|
|
2347
2633
|
upstreamCookieExpiresAt = 0;
|
|
2348
2634
|
upstreamCookieTask;
|
|
@@ -2352,6 +2638,7 @@ var MobileAccessGateway = class {
|
|
|
2352
2638
|
started = false;
|
|
2353
2639
|
closeTask;
|
|
2354
2640
|
removeSessionListener;
|
|
2641
|
+
removeExtensionContentListener;
|
|
2355
2642
|
renewLimiter;
|
|
2356
2643
|
constructor(config, store, extensions, upstreamAuthenticatedUrl) {
|
|
2357
2644
|
this.config = config;
|
|
@@ -2373,6 +2660,9 @@ var MobileAccessGateway = class {
|
|
|
2373
2660
|
this.removeSessionListener = this.access.onSessionEnded((authorization) => {
|
|
2374
2661
|
this.abortSessionResources(authorization.sessionKey);
|
|
2375
2662
|
});
|
|
2663
|
+
this.removeExtensionContentListener = this.extensions?.onContentChanged(() => {
|
|
2664
|
+
this.broadcastExtensionChange();
|
|
2665
|
+
}) ?? (() => void 0);
|
|
2376
2666
|
}
|
|
2377
2667
|
/** Initialize durable state, validate TLS, and bind the externally reachable listener. */
|
|
2378
2668
|
async start() {
|
|
@@ -2440,6 +2730,11 @@ var MobileAccessGateway = class {
|
|
|
2440
2730
|
this.listenerPort = address.port;
|
|
2441
2731
|
this.policy = new RequestTrustPolicy(this.config.authorities, address.port, this.config.allowedCidrs, this.tlsEnabled);
|
|
2442
2732
|
if (this.config.discovery) await this.startDiscovery(address.port);
|
|
2733
|
+
await this.pollLegacyCustomChanges();
|
|
2734
|
+
this.extensionChangeTimer = setInterval(() => {
|
|
2735
|
+
this.pollLegacyCustomChanges();
|
|
2736
|
+
}, EXTENSION_CHANGE_POLL_MS);
|
|
2737
|
+
this.extensionChangeTimer.unref();
|
|
2443
2738
|
} catch (error) {
|
|
2444
2739
|
await this.closeFailedStart();
|
|
2445
2740
|
throw error;
|
|
@@ -2499,6 +2794,9 @@ var MobileAccessGateway = class {
|
|
|
2499
2794
|
}), "utf8");
|
|
2500
2795
|
}
|
|
2501
2796
|
async closeFailedStart() {
|
|
2797
|
+
if (this.extensionChangeTimer !== void 0) clearInterval(this.extensionChangeTimer);
|
|
2798
|
+
this.extensionChangeTimer = void 0;
|
|
2799
|
+
this.removeExtensionContentListener();
|
|
2502
2800
|
if (this.discoveryTimer !== void 0) clearInterval(this.discoveryTimer);
|
|
2503
2801
|
this.discoveryTimer = void 0;
|
|
2504
2802
|
await this.closeBonjour();
|
|
@@ -2859,6 +3157,11 @@ var MobileAccessGateway = class {
|
|
|
2859
3157
|
async handleExtensionRequest(targetInfo, target, request, response, authorization) {
|
|
2860
3158
|
const extensions = this.extensions;
|
|
2861
3159
|
if (extensions === void 0) throw new HttpError(404, "not_found");
|
|
3160
|
+
if (targetInfo.kind === "events") {
|
|
3161
|
+
if (request.method !== "GET" || target.search !== "") throw new HttpError(request.method === "GET" ? 400 : 405, request.method === "GET" ? "bad_request" : "method_not_allowed");
|
|
3162
|
+
this.openExtensionEventStream(request, response, authorization);
|
|
3163
|
+
return;
|
|
3164
|
+
}
|
|
2862
3165
|
if (targetInfo.kind === "manifest") {
|
|
2863
3166
|
if (request.method !== "GET" && request.method !== "HEAD") throw new HttpError(405, "method_not_allowed");
|
|
2864
3167
|
const operation = this.allocateRequest(authorization, response, {});
|
|
@@ -2906,9 +3209,10 @@ var MobileAccessGateway = class {
|
|
|
2906
3209
|
}
|
|
2907
3210
|
if (targetInfo.kind === "script" || targetInfo.kind === "style" || targetInfo.kind === "asset") {
|
|
2908
3211
|
if (request.method !== "GET" && request.method !== "HEAD") throw new HttpError(405, "method_not_allowed");
|
|
3212
|
+
const generation = extensionGeneration(new URLSearchParams(target.search).get("generation") ?? void 0);
|
|
2909
3213
|
const operation = this.allocateRequest(authorization, response, {});
|
|
2910
3214
|
try {
|
|
2911
|
-
const file = targetInfo.kind === "script" ? await extensions.readClientFile(targetInfo.id, "script", operation.signal) : targetInfo.kind === "style" ? await extensions.readClientFile(targetInfo.id, "style", operation.signal) : await extensions.readAsset(targetInfo.id, targetInfo.path ?? "", operation.signal);
|
|
3215
|
+
const file = targetInfo.kind === "script" ? await extensions.readClientFile(targetInfo.id, "script", operation.signal, generation) : targetInfo.kind === "style" ? await extensions.readClientFile(targetInfo.id, "style", operation.signal, generation) : await extensions.readAsset(targetInfo.id, targetInfo.path ?? "", operation.signal, generation);
|
|
2912
3216
|
if (headerValue(request.headers, "if-none-match") === file.digest) {
|
|
2913
3217
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
2914
3218
|
response.writeHead(304);
|
|
@@ -2931,23 +3235,26 @@ var MobileAccessGateway = class {
|
|
|
2931
3235
|
}
|
|
2932
3236
|
if (targetInfo.kind === "action") {
|
|
2933
3237
|
if (request.method !== "POST") throw new HttpError(405, "method_not_allowed");
|
|
2934
|
-
const
|
|
3238
|
+
const maximum = 1048576;
|
|
3239
|
+
assertBoundedContentLength(request, maximum);
|
|
3240
|
+
const generation = extensionGeneration(headerValue(request.headers, EXTENSION_GENERATION_HEADER));
|
|
2935
3241
|
const operation = this.allocateRequest(authorization, response, {});
|
|
2936
3242
|
const abort = new AbortController();
|
|
2937
3243
|
response.once("close", () => {
|
|
2938
3244
|
abort.abort();
|
|
2939
3245
|
});
|
|
2940
|
-
const generationSignal = extensions.signal(targetInfo.id);
|
|
3246
|
+
const generationSignal = extensions.signal(targetInfo.id, generation);
|
|
2941
3247
|
const onGenerationAbort = () => {
|
|
2942
3248
|
abort.abort();
|
|
2943
3249
|
if (!response.destroyed) response.destroy();
|
|
2944
3250
|
};
|
|
2945
3251
|
generationSignal?.addEventListener("abort", onGenerationAbort, { once: true });
|
|
2946
3252
|
try {
|
|
3253
|
+
const body = await readJsonObject(request, maximum);
|
|
2947
3254
|
const result = await extensions.invoke(targetInfo.id, targetInfo.action, body, {
|
|
2948
3255
|
signal: abort.signal,
|
|
2949
3256
|
deviceId: authorization.deviceId
|
|
2950
|
-
});
|
|
3257
|
+
}, generation);
|
|
2951
3258
|
let serialized;
|
|
2952
3259
|
try {
|
|
2953
3260
|
serialized = Buffer.from(JSON.stringify(result));
|
|
@@ -2973,19 +3280,22 @@ var MobileAccessGateway = class {
|
|
|
2973
3280
|
"PATCH",
|
|
2974
3281
|
"DELETE"
|
|
2975
3282
|
].includes(method)) throw new HttpError(405, "method_not_allowed");
|
|
2976
|
-
const
|
|
3283
|
+
const hasBody = method !== "GET" && method !== "HEAD";
|
|
3284
|
+
if (hasBody) assertBoundedContentLength(request, this.config.maxBodyBytes);
|
|
3285
|
+
const generation = extensionGeneration(headerValue(request.headers, EXTENSION_GENERATION_HEADER));
|
|
2977
3286
|
const operation = this.allocateRequest(authorization, response, {});
|
|
2978
3287
|
const abort = new AbortController();
|
|
2979
3288
|
response.once("close", () => {
|
|
2980
3289
|
abort.abort();
|
|
2981
3290
|
});
|
|
2982
|
-
const generationSignal = extensions.signal(targetInfo.id);
|
|
3291
|
+
const generationSignal = extensions.signal(targetInfo.id, generation);
|
|
2983
3292
|
const onGenerationAbort = () => {
|
|
2984
3293
|
abort.abort();
|
|
2985
3294
|
if (!response.destroyed) response.destroy();
|
|
2986
3295
|
};
|
|
2987
3296
|
generationSignal?.addEventListener("abort", onGenerationAbort, { once: true });
|
|
2988
3297
|
try {
|
|
3298
|
+
const body = hasBody ? await readBoundedBody(request, this.config.maxBodyBytes) : Buffer.alloc(0);
|
|
2989
3299
|
const parsed = new URL(target.raw, this.address().origin);
|
|
2990
3300
|
const routeRequest = {
|
|
2991
3301
|
method,
|
|
@@ -2996,7 +3306,7 @@ var MobileAccessGateway = class {
|
|
|
2996
3306
|
signal: abort.signal,
|
|
2997
3307
|
deviceId: authorization.deviceId
|
|
2998
3308
|
};
|
|
2999
|
-
const result = await extensions.route(targetInfo.id, method, targetInfo.path, routeRequest);
|
|
3309
|
+
const result = await extensions.route(targetInfo.id, method, targetInfo.path, routeRequest, generation);
|
|
3000
3310
|
await this.sendExtensionResponse(response, result, request.method === "HEAD");
|
|
3001
3311
|
} finally {
|
|
3002
3312
|
generationSignal?.removeEventListener("abort", onGenerationAbort);
|
|
@@ -3006,8 +3316,10 @@ var MobileAccessGateway = class {
|
|
|
3006
3316
|
}
|
|
3007
3317
|
}
|
|
3008
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);
|
|
3009
3321
|
const contentType = result.contentType ?? "application/octet-stream";
|
|
3010
|
-
if (!/^[\w!#$&+.^-]+\/[\w!#$&+.^-]+(?:;[\
|
|
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);
|
|
3011
3323
|
const safeHeaders = {};
|
|
3012
3324
|
for (const [name, value] of Object.entries(result.headers ?? {})) {
|
|
3013
3325
|
if (!/^(?:content-disposition|cache-control|etag)$/iu.test(name) || /[\r\n]/u.test(value)) continue;
|
|
@@ -3017,7 +3329,7 @@ var MobileAccessGateway = class {
|
|
|
3017
3329
|
if (typeof result.body === "string" || result.body instanceof Uint8Array) {
|
|
3018
3330
|
const body = typeof result.body === "string" ? Buffer.from(result.body) : Buffer.from(result.body);
|
|
3019
3331
|
if (body.byteLength > 4194304) throw new MobileExtensionError("extension_result_too_large", "extension response is too large", 500);
|
|
3020
|
-
response.writeHead(
|
|
3332
|
+
response.writeHead(status, {
|
|
3021
3333
|
...safeHeaders,
|
|
3022
3334
|
"Content-Type": contentType,
|
|
3023
3335
|
"Content-Length": body.byteLength
|
|
@@ -3026,7 +3338,7 @@ var MobileAccessGateway = class {
|
|
|
3026
3338
|
else response.end(body);
|
|
3027
3339
|
return;
|
|
3028
3340
|
}
|
|
3029
|
-
response.writeHead(
|
|
3341
|
+
response.writeHead(status, {
|
|
3030
3342
|
...safeHeaders,
|
|
3031
3343
|
"Content-Type": contentType
|
|
3032
3344
|
});
|
|
@@ -3382,6 +3694,66 @@ var MobileAccessGateway = class {
|
|
|
3382
3694
|
socket.upstream.destroy();
|
|
3383
3695
|
}
|
|
3384
3696
|
}
|
|
3697
|
+
broadcastExtensionChange() {
|
|
3698
|
+
if (this.closing) return;
|
|
3699
|
+
this.extensionEventRevision += 1;
|
|
3700
|
+
for (const listener of this.extensionEventListeners) listener(this.extensionEventRevision);
|
|
3701
|
+
}
|
|
3702
|
+
pollLegacyCustomChanges() {
|
|
3703
|
+
if (this.extensionChangeTask !== void 0) return this.extensionChangeTask;
|
|
3704
|
+
const digestFile = async (path, fallback) => {
|
|
3705
|
+
try {
|
|
3706
|
+
const info = await stat(path);
|
|
3707
|
+
if (!info.isFile() || info.size > 262144) return `invalid:${String(info.size)}:${String(info.mtimeMs)}`;
|
|
3708
|
+
return createHash("sha256").update(await readFile(path)).digest("hex");
|
|
3709
|
+
} catch (error) {
|
|
3710
|
+
if (error.code === "ENOENT") return createHash("sha256").update(fallback).digest("hex");
|
|
3711
|
+
return `error:${String(error.code ?? "unknown")}`;
|
|
3712
|
+
}
|
|
3713
|
+
};
|
|
3714
|
+
const task = Promise.all([digestFile(this.config.customScriptFile, CUSTOM_SCRIPT_FALLBACK), digestFile(this.config.customCssFile, CUSTOM_STYLE_FALLBACK)]).then((parts) => {
|
|
3715
|
+
const next = createHash("sha256").update(parts.join("|")).digest("hex");
|
|
3716
|
+
if (this.legacyCustomDigest !== "" && next !== this.legacyCustomDigest) this.broadcastExtensionChange();
|
|
3717
|
+
this.legacyCustomDigest = next;
|
|
3718
|
+
}).finally(() => {
|
|
3719
|
+
if (this.extensionChangeTask === task) this.extensionChangeTask = void 0;
|
|
3720
|
+
});
|
|
3721
|
+
this.extensionChangeTask = task;
|
|
3722
|
+
return task;
|
|
3723
|
+
}
|
|
3724
|
+
openExtensionEventStream(request, response, authorization) {
|
|
3725
|
+
const operation = this.allocateRequest(authorization, response, {});
|
|
3726
|
+
let closed = false;
|
|
3727
|
+
let heartbeat;
|
|
3728
|
+
const close = () => {
|
|
3729
|
+
if (closed) return;
|
|
3730
|
+
closed = true;
|
|
3731
|
+
if (heartbeat !== void 0) clearInterval(heartbeat);
|
|
3732
|
+
this.extensionEventListeners.delete(send);
|
|
3733
|
+
request.removeListener("aborted", close);
|
|
3734
|
+
response.removeListener("close", close);
|
|
3735
|
+
operation.release();
|
|
3736
|
+
};
|
|
3737
|
+
const send = (revision) => {
|
|
3738
|
+
if (closed || response.destroyed || response.writableEnded) return;
|
|
3739
|
+
response.write(`id: ${String(revision)}\nevent: extensions-changed\ndata: {\"revision\":${String(revision)}}\n\n`);
|
|
3740
|
+
};
|
|
3741
|
+
setSecurityHeaders(response, this.tlsEnabled);
|
|
3742
|
+
response.writeHead(200, {
|
|
3743
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
3744
|
+
"Cache-Control": "no-store",
|
|
3745
|
+
Connection: "keep-alive",
|
|
3746
|
+
"X-Accel-Buffering": "no"
|
|
3747
|
+
});
|
|
3748
|
+
response.write("retry: 2000\n: ready\n\n");
|
|
3749
|
+
this.extensionEventListeners.add(send);
|
|
3750
|
+
heartbeat = setInterval(() => {
|
|
3751
|
+
if (!closed && !response.destroyed && !response.writableEnded) response.write(": heartbeat\n\n");
|
|
3752
|
+
}, EXTENSION_EVENT_HEARTBEAT_MS);
|
|
3753
|
+
heartbeat.unref();
|
|
3754
|
+
request.once("aborted", close);
|
|
3755
|
+
response.once("close", close);
|
|
3756
|
+
}
|
|
3385
3757
|
async readUpgradeResponse(upstream, expectedAccept) {
|
|
3386
3758
|
return new Promise((resolve, reject) => {
|
|
3387
3759
|
let buffer = Buffer.alloc(0);
|
|
@@ -3629,6 +4001,9 @@ var MobileAccessGateway = class {
|
|
|
3629
4001
|
}
|
|
3630
4002
|
async performClose() {
|
|
3631
4003
|
this.closing = true;
|
|
4004
|
+
if (this.extensionChangeTimer !== void 0) clearInterval(this.extensionChangeTimer);
|
|
4005
|
+
this.extensionChangeTimer = void 0;
|
|
4006
|
+
this.removeExtensionContentListener();
|
|
3632
4007
|
this.upstreamAuthRequest?.destroy();
|
|
3633
4008
|
this.upstreamAuthRequest = void 0;
|
|
3634
4009
|
this.removeSessionListener();
|
|
@@ -3794,165 +4169,1375 @@ var MemoryDeviceStore = class {
|
|
|
3794
4169
|
}
|
|
3795
4170
|
};
|
|
3796
4171
|
//#endregion
|
|
3797
|
-
//#region src/
|
|
3798
|
-
const
|
|
3799
|
-
const
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
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);
|
|
3827
4236
|
}
|
|
3828
|
-
function
|
|
3829
|
-
if (origin === void 0) return "未分配";
|
|
4237
|
+
async function regularFile$1(file) {
|
|
3830
4238
|
try {
|
|
3831
|
-
const
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
return "地址格式无效";
|
|
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;
|
|
3837
4244
|
}
|
|
3838
4245
|
}
|
|
3839
|
-
function
|
|
3840
|
-
|
|
4246
|
+
async function replaceDirectory(target, candidate) {
|
|
4247
|
+
const backup = `${target}.previous-${randomBytes(12).toString("hex")}`;
|
|
4248
|
+
let previous = false;
|
|
3841
4249
|
try {
|
|
3842
|
-
const hostname = new URL(origin).hostname;
|
|
3843
|
-
if (hostname.endsWith(".ts.net")) return "*.ts.net";
|
|
3844
|
-
for (const suffix of [
|
|
3845
|
-
".cpolar.cn",
|
|
3846
|
-
".cpolar.io",
|
|
3847
|
-
".cpolar.top",
|
|
3848
|
-
".cpolar.com"
|
|
3849
|
-
]) if (hostname.endsWith(suffix)) return `*${suffix}`;
|
|
3850
|
-
return "公共 HTTPS 地址";
|
|
3851
|
-
} catch {
|
|
3852
|
-
return "地址格式无效";
|
|
3853
|
-
}
|
|
3854
|
-
}
|
|
3855
|
-
function defaultFirewallProbe(platform = process.platform) {
|
|
3856
|
-
return async (port) => {
|
|
3857
|
-
if (platform !== "win32") return { state: "not-applicable" };
|
|
3858
|
-
if (port === void 0) return { state: "unknown" };
|
|
3859
|
-
const script = [
|
|
3860
|
-
"$specs = @(@{ Name = 'DSH Mobile HTTPS'; Protocol = 'TCP' }, @{ Name = 'DSH Mobile Discovery'; Protocol = 'UDP' })",
|
|
3861
|
-
"$ready = $true",
|
|
3862
|
-
"$specs | ForEach-Object {",
|
|
3863
|
-
" $spec = $_",
|
|
3864
|
-
" $rule = Get-NetFirewallRule -DisplayName $spec.Name -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' } | Select-Object -First 1",
|
|
3865
|
-
" if ($null -eq $rule) { $ready = $false; return }",
|
|
3866
|
-
" $filters = @($rule | Get-NetFirewallPortFilter -ErrorAction SilentlyContinue)",
|
|
3867
|
-
` $matching = @($filters | Where-Object { $_.Protocol -eq $spec.Protocol -and ($_.LocalPort -eq 'Any' -or $_.LocalPort -eq '${String(port)}') })`,
|
|
3868
|
-
" if ($matching.Count -eq 0) { $ready = $false }",
|
|
3869
|
-
"}",
|
|
3870
|
-
"if ($ready) { 'ready' } else { 'missing' }"
|
|
3871
|
-
].join("; ");
|
|
3872
4250
|
try {
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
script
|
|
3878
|
-
], {
|
|
3879
|
-
encoding: "utf8",
|
|
3880
|
-
timeout: 3e3,
|
|
3881
|
-
windowsHide: true
|
|
3882
|
-
})).stdout.trim() === "ready" ? "ready" : "missing" };
|
|
3883
|
-
} catch {
|
|
3884
|
-
return { state: "unknown" };
|
|
4251
|
+
await rename(target, backup);
|
|
4252
|
+
previous = true;
|
|
4253
|
+
} catch (error) {
|
|
4254
|
+
if (error.code !== "ENOENT") throw error;
|
|
3885
4255
|
}
|
|
3886
|
-
|
|
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
|
+
}
|
|
3887
4276
|
}
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
const hostname = new URL(origin).hostname.toLowerCase();
|
|
3891
|
-
if (hostname.endsWith(".ts.net") || hostname.includes(".cpolar.")) return 1e4;
|
|
3892
|
-
return 5e3;
|
|
4277
|
+
function sha256$1(bytes) {
|
|
4278
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
3893
4279
|
}
|
|
3894
|
-
async function
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
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);
|
|
3903
4290
|
});
|
|
3904
|
-
|
|
3905
|
-
if (response.status === 429) return {
|
|
3906
|
-
state: "rate-limited",
|
|
3907
|
-
latencyMs
|
|
3908
|
-
};
|
|
3909
|
-
return response.ok ? {
|
|
3910
|
-
state: "ready",
|
|
3911
|
-
latencyMs
|
|
3912
|
-
} : {
|
|
3913
|
-
state: "unreachable",
|
|
3914
|
-
latencyMs
|
|
3915
|
-
};
|
|
3916
|
-
} catch {
|
|
3917
|
-
let fakeIp = false;
|
|
3918
|
-
try {
|
|
3919
|
-
fakeIp = (await lookup(hostname, { all: true })).some(({ address }) => {
|
|
3920
|
-
const [first, second] = address.split(".").map(Number);
|
|
3921
|
-
return first === 198 && (second === 18 || second === 19);
|
|
3922
|
-
});
|
|
3923
|
-
} catch {}
|
|
3924
|
-
return {
|
|
3925
|
-
state: "unreachable",
|
|
3926
|
-
...fakeIp ? { fakeIp: true } : {}
|
|
3927
|
-
};
|
|
3928
|
-
}
|
|
4291
|
+
});
|
|
3929
4292
|
}
|
|
3930
|
-
function
|
|
3931
|
-
|
|
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;
|
|
3932
4298
|
}
|
|
3933
|
-
/**
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
const
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
if (
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
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
|
|
5350
|
+
//#region src/diagnostics.ts
|
|
5351
|
+
const execFile$2 = promisify(execFile);
|
|
5352
|
+
const REMOTE_ERROR_GUIDANCE = Object.freeze({
|
|
5353
|
+
component_missing: "重新安装完整插件包。",
|
|
5354
|
+
funnel_permission_required: "继续完成 Tailscale Funnel 授权。",
|
|
5355
|
+
funnel_https_required: "继续完成 Tailscale HTTPS 授权。",
|
|
5356
|
+
funnel_start_failed: "重新打开授权页并允许 Funnel。",
|
|
5357
|
+
funnel_start_timeout: "检查网络后点击“重新连接”。",
|
|
5358
|
+
tailscale_dns_missing: "确认 Tailscale 登录仍有效后重新连接。",
|
|
5359
|
+
sidecar_launch_failed: "重新安装完整插件包后重试。",
|
|
5360
|
+
sidecar_stopped: "点击“重新连接”。",
|
|
5361
|
+
sidecar_exited: "点击“重新连接”;仍失败时复制诊断报告。",
|
|
5362
|
+
control_channel_failed: "点击“重新连接”。",
|
|
5363
|
+
cpolar_component_missing: "先安装 cpolar 官方组件。",
|
|
5364
|
+
cpolar_component_invalid: "彻底移除 cpolar 组件后重新安装。",
|
|
5365
|
+
cpolar_config_missing: "保存 cpolar Authtoken 后重试。",
|
|
5366
|
+
cpolar_config_invalid: "重新保存 cpolar Authtoken。",
|
|
5367
|
+
cpolar_start_timeout: "检查网络后点击“重新连接”。",
|
|
5368
|
+
cpolar_stopped: "点击“重新连接”。",
|
|
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 配置后重新连接;仍失败时复制诊断报告。",
|
|
5382
|
+
gateway_start_failed: "确认 DSH 正在运行后重新连接。"
|
|
5383
|
+
});
|
|
5384
|
+
function check(id, status, reason, label, detail, action, facts) {
|
|
5385
|
+
return Object.freeze({
|
|
5386
|
+
id,
|
|
5387
|
+
status,
|
|
5388
|
+
reason,
|
|
5389
|
+
...facts === void 0 ? {} : { facts: Object.freeze(facts) },
|
|
5390
|
+
label,
|
|
5391
|
+
detail,
|
|
5392
|
+
...action === void 0 ? {} : { action }
|
|
5393
|
+
});
|
|
5394
|
+
}
|
|
5395
|
+
function maskLanOrigin(origin) {
|
|
5396
|
+
if (origin === void 0) return "未分配";
|
|
5397
|
+
try {
|
|
5398
|
+
const url = new URL(origin);
|
|
5399
|
+
const octets = url.hostname.split(".");
|
|
5400
|
+
const host = octets.length === 4 ? `${octets[0]}.${octets[1]}.${octets[2]}.x` : "局域网地址";
|
|
5401
|
+
return `${url.protocol}//${host}${url.port === "" ? "" : `:${url.port}`}`;
|
|
5402
|
+
} catch {
|
|
5403
|
+
return "地址格式无效";
|
|
5404
|
+
}
|
|
5405
|
+
}
|
|
5406
|
+
function remoteSuffix(origin) {
|
|
5407
|
+
if (origin === void 0) return "未分配";
|
|
5408
|
+
try {
|
|
5409
|
+
const hostname = new URL(origin).hostname;
|
|
5410
|
+
if (hostname.endsWith(".ts.net")) return "*.ts.net";
|
|
5411
|
+
for (const suffix of [
|
|
5412
|
+
".cpolar.cn",
|
|
5413
|
+
".cpolar.io",
|
|
5414
|
+
".cpolar.top",
|
|
5415
|
+
".cpolar.com"
|
|
5416
|
+
]) if (hostname.endsWith(suffix)) return `*${suffix}`;
|
|
5417
|
+
return "公共 HTTPS 地址";
|
|
5418
|
+
} catch {
|
|
5419
|
+
return "地址格式无效";
|
|
5420
|
+
}
|
|
5421
|
+
}
|
|
5422
|
+
function defaultFirewallProbe(platform = process.platform) {
|
|
5423
|
+
return async (port) => {
|
|
5424
|
+
if (platform !== "win32") return { state: "not-applicable" };
|
|
5425
|
+
if (port === void 0) return { state: "unknown" };
|
|
5426
|
+
const script = [
|
|
5427
|
+
"$specs = @(@{ Name = 'DSH Mobile HTTPS'; Protocol = 'TCP' }, @{ Name = 'DSH Mobile Discovery'; Protocol = 'UDP' })",
|
|
5428
|
+
"$ready = $true",
|
|
5429
|
+
"$specs | ForEach-Object {",
|
|
5430
|
+
" $spec = $_",
|
|
5431
|
+
" $rule = Get-NetFirewallRule -DisplayName $spec.Name -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' } | Select-Object -First 1",
|
|
5432
|
+
" if ($null -eq $rule) { $ready = $false; return }",
|
|
5433
|
+
" $filters = @($rule | Get-NetFirewallPortFilter -ErrorAction SilentlyContinue)",
|
|
5434
|
+
` $matching = @($filters | Where-Object { $_.Protocol -eq $spec.Protocol -and ($_.LocalPort -eq 'Any' -or $_.LocalPort -eq '${String(port)}') })`,
|
|
5435
|
+
" if ($matching.Count -eq 0) { $ready = $false }",
|
|
5436
|
+
"}",
|
|
5437
|
+
"if ($ready) { 'ready' } else { 'missing' }"
|
|
5438
|
+
].join("; ");
|
|
5439
|
+
try {
|
|
5440
|
+
return { state: (await execFile$2("powershell.exe", [
|
|
5441
|
+
"-NoProfile",
|
|
5442
|
+
"-NonInteractive",
|
|
5443
|
+
"-Command",
|
|
5444
|
+
script
|
|
5445
|
+
], {
|
|
5446
|
+
encoding: "utf8",
|
|
5447
|
+
timeout: 3e3,
|
|
5448
|
+
windowsHide: true
|
|
5449
|
+
})).stdout.trim() === "ready" ? "ready" : "missing" };
|
|
5450
|
+
} catch {
|
|
5451
|
+
return { state: "unknown" };
|
|
5452
|
+
}
|
|
5453
|
+
};
|
|
5454
|
+
}
|
|
5455
|
+
/** Allow remote relays enough time to answer without making diagnostics unbounded. */
|
|
5456
|
+
function remoteDiagnosticTimeoutMs(origin) {
|
|
5457
|
+
const hostname = new URL(origin).hostname.toLowerCase();
|
|
5458
|
+
if (hostname.endsWith(".ts.net") || hostname.includes(".cpolar.")) return 1e4;
|
|
5459
|
+
return 1e4;
|
|
5460
|
+
}
|
|
5461
|
+
async function defaultRemoteProbe(origin) {
|
|
5462
|
+
if (origin === void 0) return { state: "not-applicable" };
|
|
5463
|
+
const hostname = new URL(origin).hostname;
|
|
5464
|
+
const started = performance.now();
|
|
5465
|
+
try {
|
|
5466
|
+
const response = await fetch(new URL("/mobile-access/health", origin), {
|
|
5467
|
+
cache: "no-store",
|
|
5468
|
+
redirect: "error",
|
|
5469
|
+
signal: AbortSignal.timeout(remoteDiagnosticTimeoutMs(origin))
|
|
5470
|
+
});
|
|
5471
|
+
const latencyMs = Math.max(0, Math.round(performance.now() - started));
|
|
5472
|
+
if (response.status === 429) return {
|
|
5473
|
+
state: "rate-limited",
|
|
5474
|
+
latencyMs
|
|
5475
|
+
};
|
|
5476
|
+
return response.ok ? {
|
|
5477
|
+
state: "ready",
|
|
5478
|
+
latencyMs
|
|
5479
|
+
} : {
|
|
5480
|
+
state: "unreachable",
|
|
5481
|
+
latencyMs
|
|
5482
|
+
};
|
|
5483
|
+
} catch {
|
|
5484
|
+
let fakeIp = false;
|
|
5485
|
+
try {
|
|
5486
|
+
fakeIp = (await lookup(hostname, { all: true })).some(({ address }) => {
|
|
5487
|
+
const [first, second] = address.split(".").map(Number);
|
|
5488
|
+
return first === 198 && (second === 18 || second === 19);
|
|
5489
|
+
});
|
|
5490
|
+
} catch {}
|
|
5491
|
+
return {
|
|
5492
|
+
state: "unreachable",
|
|
5493
|
+
...fakeIp ? { fakeIp: true } : {}
|
|
5494
|
+
};
|
|
5495
|
+
}
|
|
5496
|
+
}
|
|
5497
|
+
function reportLine(entry) {
|
|
5498
|
+
return `[${entry.status.toUpperCase()}] ${entry.label}: ${entry.detail}${entry.action === void 0 ? "" : ` ${entry.action}`}`;
|
|
5499
|
+
}
|
|
5500
|
+
/** Run bounded read-only checks and return a report safe to paste into an issue. */
|
|
5501
|
+
async function collectConnectionDiagnostics(snapshot, probes = {}) {
|
|
5502
|
+
const checks = [];
|
|
5503
|
+
const remoteProbe = snapshot.remote.running && snapshot.remote.state === "ready" && snapshot.remote.origin !== void 0 ? (probes.remote ?? defaultRemoteProbe)(snapshot.remote.origin) : Promise.resolve({ state: "not-applicable" });
|
|
5504
|
+
const [firewall, remoteObservation] = await Promise.all([(probes.firewall ?? defaultFirewallProbe())(snapshot.lan.port), remoteProbe]);
|
|
5505
|
+
checks.push(check("versions", "ok", "versions-current", "版本兼容", `插件 ${DSH_MOBILE_VERSION},DSH ${snapshot.dshVersion},Android App 最低 ${MINIMUM_ANDROID_APP_VERSION}。`));
|
|
5506
|
+
if (snapshot.lan.networkError !== void 0) checks.push(check("network", "error", "network-unavailable", "局域网网卡", "已保存的网卡当前不可用。", "重新运行 dsh-mobile setup。"));
|
|
5507
|
+
else if (snapshot.lan.configuredInterface !== void 0) {
|
|
5508
|
+
const interfaceName = snapshot.lan.interfaceName ?? snapshot.lan.configuredInterface;
|
|
5509
|
+
checks.push(check("network", "ok", "network-interface", "局域网网卡", `正在跟随 ${interfaceName}。`, void 0, { interfaceName }));
|
|
5510
|
+
} else checks.push(check("network", "info", "network-fixed", "局域网网卡", "当前使用固定网络配置。"));
|
|
5511
|
+
if (snapshot.lan.running && snapshot.lan.origin !== void 0) {
|
|
5512
|
+
const endpointSuffix = maskLanOrigin(snapshot.lan.origin);
|
|
5513
|
+
checks.push(check("lan", "ok", "lan-ready", "局域网网关", `已监听 ${endpointSuffix},配对入口可用。`, void 0, { endpointSuffix }));
|
|
5514
|
+
} else checks.push(check("lan", "info", "lan-off", "局域网网关", "当前未开启。", "需要手机直连时开启局域网访问。"));
|
|
5515
|
+
if (firewall.state === "ready") checks.push(check("firewall", "ok", "firewall-ready", "Windows 防火墙", "局域网 TCP 与发现规则已启用。"));
|
|
5516
|
+
else if (firewall.state === "missing") checks.push(check("firewall", "warning", "firewall-missing", "Windows 防火墙", "未找到完整的局域网放行规则。", "以管理员身份重新运行 dsh-mobile setup。"));
|
|
5517
|
+
else if (firewall.state === "unknown") checks.push(check("firewall", "info", "firewall-unknown", "Windows 防火墙", "系统未允许插件读取防火墙状态。", "若手机找不到电脑,以管理员身份重新运行 setup。"));
|
|
5518
|
+
if (!snapshot.remote.running || snapshot.remote.state === "off") checks.push(check("remote", "info", "remote-off", "远程通道", "当前未启用。", void 0, { provider: snapshot.remote.provider }));
|
|
5519
|
+
else if (snapshot.remote.state === "ready" && snapshot.remote.origin !== void 0) {
|
|
5520
|
+
const endpointSuffix = remoteSuffix(snapshot.remote.origin);
|
|
5521
|
+
const facts = {
|
|
5522
|
+
provider: snapshot.remote.provider,
|
|
5523
|
+
endpointSuffix,
|
|
5524
|
+
...remoteObservation.latencyMs === void 0 ? {} : { latencyMs: remoteObservation.latencyMs }
|
|
5525
|
+
};
|
|
5526
|
+
if (remoteObservation.state === "ready") checks.push(check("remote", "ok", "remote-ready", "远程通道", `${snapshot.remote.provider} 公共地址 ${endpointSuffix} 可达,往返约 ${String(remoteObservation.latencyMs ?? 0)} ms。`, void 0, facts));
|
|
5527
|
+
else if (remoteObservation.state === "rate-limited") checks.push(check("remote", "warning", "remote-rate-limited", "远程通道", "公共地址可达,但本次检查观察到服务限流。", "稍后重试;旧会话会按需加载以减少流量。", facts));
|
|
5528
|
+
else if (snapshot.remote.provider === "tailscale" && remoteObservation.fakeIp === true) checks.push(check("remote", "error", "remote-fake-ip", "远程通道", "Tailscale 地址被当前 VPN 或 DNS 代理接管,但 TLS 链路未建立。", "切换 VPN 节点或代理模式;仍失败时改用 cpolar。", facts));
|
|
5529
|
+
else checks.push(check("remote", "error", "remote-unreachable", "远程通道", "提供方显示已就绪,但公共地址暂不可达。", "点击“重新连接”;仍失败时检查提供方状态。", facts));
|
|
5530
|
+
} else if (snapshot.remote.state === "starting" || snapshot.remote.state === "connecting" || snapshot.remote.state === "needs-login") {
|
|
5531
|
+
const needsLogin = snapshot.remote.state === "needs-login";
|
|
5532
|
+
checks.push(check("remote", "warning", needsLogin ? "remote-needs-login" : "remote-connecting", "远程通道", needsLogin ? "等待完成 Tailscale 登录。" : "仍在建立连接。", needsLogin ? "返回远程页继续登录。" : "等待片刻后重新检查。", { provider: snapshot.remote.provider }));
|
|
5533
|
+
} else {
|
|
5534
|
+
const controllerCode = snapshot.remote.errorCode ?? snapshot.remote.state;
|
|
5535
|
+
checks.push(check("remote", "error", "remote-controller-error", "远程通道", `连接未建立(${controllerCode})。`, REMOTE_ERROR_GUIDANCE[controllerCode] ?? "返回远程页点击“重新连接”。", {
|
|
5536
|
+
provider: snapshot.remote.provider,
|
|
5537
|
+
controllerCode
|
|
5538
|
+
}));
|
|
5539
|
+
}
|
|
5540
|
+
checks.push(check("phone-network", "info", "phone-network-unknown", "手机网络", "电脑无法判断路由器是否隔离了手机。", "局域网仍失败时,确认手机与电脑在同一网络,并关闭访客网络或 AP 隔离。"));
|
|
3956
5541
|
const overall = checks.some((entry) => entry.status === "error") ? "error" : checks.some((entry) => entry.status === "warning") ? "attention" : "ok";
|
|
3957
5542
|
const summary = overall === "ok" ? "连接基础检查正常。" : overall === "attention" ? "发现需要留意的项目。" : "发现会影响连接的问题。";
|
|
3958
5543
|
const report = [
|
|
@@ -4002,7 +5587,7 @@ const MOBILE_CUSTOMIZATION_GUIDE = `你在为用户定制 DSH Mobile 的手机
|
|
|
4002
5587
|
- mobile.js:手机端脚本,用 window.dshMobile.define({ apiVersion:1, id:'<id>', activate(api) { ... } }),activate 返回清理函数
|
|
4003
5588
|
- mobile.css:手机端样式(可选)
|
|
4004
5589
|
- assets/:手机端静态资源(可选)
|
|
4005
|
-
- mobile.js 里用 api.host.invoke('动作名', 输入) 调 host.mjs 的 action,api.host.fetch('/路由路径') 调 route
|
|
5590
|
+
- mobile.js 里用 api.host.invoke('动作名', 输入) 调 host.mjs 的 action,api.host.fetch('/路由路径') 调 route,api.host.assetUrl('相对路径') 生成与当前版本绑定的资源地址
|
|
4006
5591
|
- 也可以先用命令生成模板:dsh plugin --profile web exec dsh-mobile extension create <id> --name "<名称>",再在模板上改
|
|
4007
5592
|
|
|
4008
5593
|
安全约束:
|
|
@@ -4362,28 +5947,12 @@ var FunnelController = class {
|
|
|
4362
5947
|
this.clearStartTimer();
|
|
4363
5948
|
const child = this.child;
|
|
4364
5949
|
this.child = void 0;
|
|
4365
|
-
child?.stdin.end();
|
|
4366
|
-
if (child !== void 0 && child.exitCode === null) {
|
|
4367
|
-
child.kill("SIGTERM");
|
|
4368
|
-
await new Promise((resolveClose) => {
|
|
4369
|
-
let completed = false;
|
|
4370
|
-
const finish = () => {
|
|
4371
|
-
if (completed) return;
|
|
4372
|
-
completed = true;
|
|
4373
|
-
clearTimeout(timer);
|
|
4374
|
-
resolveClose();
|
|
4375
|
-
};
|
|
4376
|
-
const timer = setTimeout(() => {
|
|
4377
|
-
if (child.exitCode === null) child.kill("SIGKILL");
|
|
4378
|
-
finish();
|
|
4379
|
-
}, 1500);
|
|
4380
|
-
timer.unref();
|
|
4381
|
-
child.once("close", finish);
|
|
4382
|
-
});
|
|
4383
|
-
}
|
|
4384
5950
|
const gateway = this.gatewayValue;
|
|
4385
5951
|
this.gatewayValue = void 0;
|
|
4386
|
-
await
|
|
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");
|
|
4387
5956
|
}
|
|
4388
5957
|
clearStartTimer() {
|
|
4389
5958
|
if (this.startTimer === void 0) return;
|
|
@@ -4754,30 +6323,15 @@ var CpolarController = class {
|
|
|
4754
6323
|
this.startupTimer = void 0;
|
|
4755
6324
|
const reservation = this.reservation;
|
|
4756
6325
|
this.reservation = void 0;
|
|
4757
|
-
await reservation?.release();
|
|
4758
6326
|
const child = this.child;
|
|
4759
6327
|
this.child = void 0;
|
|
4760
|
-
if (child !== void 0 && child.exitCode === null) {
|
|
4761
|
-
child.kill("SIGTERM");
|
|
4762
|
-
await new Promise((resolveClose) => {
|
|
4763
|
-
let completed = false;
|
|
4764
|
-
const finish = () => {
|
|
4765
|
-
if (completed) return;
|
|
4766
|
-
completed = true;
|
|
4767
|
-
clearTimeout(timer);
|
|
4768
|
-
resolveClose();
|
|
4769
|
-
};
|
|
4770
|
-
const timer = setTimeout(() => {
|
|
4771
|
-
if (child.exitCode === null) child.kill("SIGKILL");
|
|
4772
|
-
finish();
|
|
4773
|
-
}, 1500);
|
|
4774
|
-
timer.unref();
|
|
4775
|
-
child.once("close", finish);
|
|
4776
|
-
});
|
|
4777
|
-
}
|
|
4778
6328
|
const gateway = this.gatewayValue;
|
|
4779
6329
|
this.gatewayValue = void 0;
|
|
4780
|
-
await
|
|
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");
|
|
4781
6335
|
}
|
|
4782
6336
|
};
|
|
4783
6337
|
//#endregion
|
|
@@ -5049,80 +6603,376 @@ var CpolarComponentManager = class {
|
|
|
5049
6603
|
}
|
|
5050
6604
|
};
|
|
5051
6605
|
//#endregion
|
|
5052
|
-
//#region src/
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5057
|
-
|
|
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;
|
|
5058
6632
|
return Object.freeze({
|
|
5059
|
-
|
|
5060
|
-
|
|
6633
|
+
core,
|
|
6634
|
+
prerelease: Object.freeze(prerelease)
|
|
5061
6635
|
});
|
|
5062
6636
|
}
|
|
5063
|
-
/**
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
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];
|
|
5070
6685
|
}
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
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
|
|
5079
6758
|
});
|
|
5080
|
-
|
|
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;
|
|
5081
6772
|
}
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
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") {
|
|
5085
6812
|
try {
|
|
5086
|
-
|
|
6813
|
+
await taskkillProcessTree(pid);
|
|
5087
6814
|
} catch (error) {
|
|
5088
|
-
|
|
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;
|
|
5089
6818
|
}
|
|
5090
|
-
|
|
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;
|
|
5091
6824
|
}
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
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)
|
|
5098
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();
|
|
5099
6956
|
try {
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
if (error.code !== "ENOENT") throw error;
|
|
5104
|
-
}
|
|
5105
|
-
const temporary = join(directory, `.${basename(this.file)}.${randomBytes(12).toString("hex")}.tmp`);
|
|
5106
|
-
try {
|
|
5107
|
-
await writeFile(temporary, `${JSON.stringify(validated)}\n`, {
|
|
5108
|
-
encoding: "utf8",
|
|
5109
|
-
flag: "wx",
|
|
5110
|
-
mode: 384
|
|
5111
|
-
});
|
|
5112
|
-
await rename(temporary, this.file);
|
|
5113
|
-
await restrictPrivateFile(this.file);
|
|
5114
|
-
} catch (error) {
|
|
5115
|
-
await rm(temporary, { force: true });
|
|
5116
|
-
throw error;
|
|
6957
|
+
return await this.activeUpdate;
|
|
6958
|
+
} finally {
|
|
6959
|
+
this.activeUpdate = void 0;
|
|
5117
6960
|
}
|
|
5118
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
|
+
}
|
|
5119
6975
|
};
|
|
5120
|
-
/** Resolve the first-run provider without letting environment values bypass validation. */
|
|
5121
|
-
function configuredRemoteProvider(environment) {
|
|
5122
|
-
const value = environment.DSH_MOBILE_REMOTE_PROVIDER ?? "tailscale";
|
|
5123
|
-
if (value !== "tailscale" && value !== "cpolar") throw new Error("DSH_MOBILE_REMOTE_PROVIDER must be tailscale or cpolar");
|
|
5124
|
-
return value;
|
|
5125
|
-
}
|
|
5126
6976
|
promisify(execFile);
|
|
5127
6977
|
const VIRTUAL_INTERFACE_MARKERS = [
|
|
5128
6978
|
"bridge",
|
|
@@ -5335,6 +7185,17 @@ const inject = [
|
|
|
5335
7185
|
"commands",
|
|
5336
7186
|
"connection"
|
|
5337
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
|
+
}
|
|
5338
7199
|
function upstreamAuthenticatedUrl(ctx, upstreamOrigin) {
|
|
5339
7200
|
const connection = ctx.connection;
|
|
5340
7201
|
return typeof connection?.authenticatedUrl === "function" ? connection.authenticatedUrl(upstreamOrigin.origin) : void 0;
|
|
@@ -5352,6 +7213,16 @@ function mapAdminError(error) {
|
|
|
5352
7213
|
if (error instanceof Error && error.message.startsWith("saved LAN interface ")) return new HttpError(409, "network_interface_unavailable");
|
|
5353
7214
|
if (error instanceof Error && error.message === "cpolar_authtoken_invalid") return new HttpError(400, "cpolar_authtoken_invalid");
|
|
5354
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);
|
|
5355
7226
|
return new HttpError(500, "internal_error");
|
|
5356
7227
|
}
|
|
5357
7228
|
const SETUP_KEYS = /* @__PURE__ */ new Set([
|
|
@@ -5443,7 +7314,7 @@ function remoteGatewayConfig(template, publicOrigin, stateFile, instanceId, list
|
|
|
5443
7314
|
discovery: false
|
|
5444
7315
|
});
|
|
5445
7316
|
}
|
|
5446
|
-
function remoteControlPayload(provider, status, gateway, providerStatuses, cpolarComponent) {
|
|
7317
|
+
function remoteControlPayload(provider, status, gateway, providerStatuses, cpolarComponent, frpComponent, frpConfiguration) {
|
|
5447
7318
|
return {
|
|
5448
7319
|
provider,
|
|
5449
7320
|
running: status.enabled,
|
|
@@ -5464,6 +7335,13 @@ function remoteControlPayload(provider, status, gateway, providerStatuses, cpola
|
|
|
5464
7335
|
running: providerStatuses.cpolar.enabled,
|
|
5465
7336
|
state: providerStatuses.cpolar.state,
|
|
5466
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
|
|
5467
7345
|
}
|
|
5468
7346
|
}
|
|
5469
7347
|
};
|
|
@@ -5479,10 +7357,17 @@ async function apply(ctx, config) {
|
|
|
5479
7357
|
const instanceId = await stableInstanceId(loaded, template);
|
|
5480
7358
|
const stateDirectory = dirname(template.stateFile);
|
|
5481
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))) });
|
|
5482
7363
|
const remoteProviderStore = new JsonRemoteProviderStore(join(remoteDirectory, "provider.json"), configuredRemoteProvider(process.env));
|
|
5483
|
-
|
|
7364
|
+
const initialRemoteProvider = (await remoteProviderStore.load()).provider;
|
|
5484
7365
|
const cpolarComponent = new CpolarComponentManager({ stateDirectory });
|
|
5485
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();
|
|
5486
7371
|
const unregisterBuiltin = mobileAccess.registerExtension({
|
|
5487
7372
|
schemaVersion: 1,
|
|
5488
7373
|
id: "computer-images",
|
|
@@ -5544,7 +7429,7 @@ async function apply(ctx, config) {
|
|
|
5544
7429
|
const lanController = new MobileAccessGatewayController(new JsonMobileAccessControlStore(parseControlFile(config.controlFile), config.initiallyEnabled), startRuntime);
|
|
5545
7430
|
const remoteDeviceFile = join(remoteDirectory, "devices.json");
|
|
5546
7431
|
const legacyCpolarDeviceFile = join(remoteDirectory, "cpolar", "devices.json");
|
|
5547
|
-
if (
|
|
7432
|
+
if (initialRemoteProvider === "cpolar") try {
|
|
5548
7433
|
await lstat(remoteDeviceFile);
|
|
5549
7434
|
} catch (error) {
|
|
5550
7435
|
if (error.code !== "ENOENT") throw error;
|
|
@@ -5562,6 +7447,7 @@ async function apply(ctx, config) {
|
|
|
5562
7447
|
};
|
|
5563
7448
|
const tailscaleStore = new JsonMobileAccessControlStore(join(remoteDirectory, "control.json"), false);
|
|
5564
7449
|
const cpolarStore = new JsonMobileAccessControlStore(join(remoteDirectory, "cpolar", "control.json"), false);
|
|
7450
|
+
const frpStore = new JsonMobileAccessControlStore(join(remoteDirectory, "frp", "control.json"), false);
|
|
5565
7451
|
const remoteControllers = {
|
|
5566
7452
|
tailscale: new FunnelController({
|
|
5567
7453
|
store: tailscaleStore,
|
|
@@ -5576,29 +7462,22 @@ async function apply(ctx, config) {
|
|
|
5576
7462
|
configFile: cpolarComponent.configFile,
|
|
5577
7463
|
region: "cn",
|
|
5578
7464
|
createGateway: createRemoteGateway
|
|
7465
|
+
}),
|
|
7466
|
+
frp: new FrpController({
|
|
7467
|
+
store: frpStore,
|
|
7468
|
+
executable: frpComponent.executable,
|
|
7469
|
+
config: frpConfig,
|
|
7470
|
+
instanceId,
|
|
7471
|
+
createGateway: createRemoteGateway
|
|
5579
7472
|
})
|
|
5580
7473
|
};
|
|
5581
|
-
const
|
|
5582
|
-
const
|
|
7474
|
+
const remoteProviders = new RemoteProviderCoordinator(initialRemoteProvider, remoteControllers, remoteProviderStore);
|
|
7475
|
+
const remoteController = () => remoteProviders.controller();
|
|
7476
|
+
const remotePayload = () => remoteControlPayload(remoteProviders.selected, remoteController().status(), remoteController().gateway(), {
|
|
5583
7477
|
tailscale: remoteControllers.tailscale.status(),
|
|
5584
|
-
cpolar: remoteControllers.cpolar.status()
|
|
5585
|
-
|
|
5586
|
-
|
|
5587
|
-
if (provider === remoteProvider) return;
|
|
5588
|
-
const previous = remoteControllers[remoteProvider];
|
|
5589
|
-
const restore = previous.status().enabled;
|
|
5590
|
-
if (restore) await previous.setEnabled(false);
|
|
5591
|
-
try {
|
|
5592
|
-
await remoteProviderStore.save({
|
|
5593
|
-
version: 1,
|
|
5594
|
-
provider
|
|
5595
|
-
});
|
|
5596
|
-
remoteProvider = provider;
|
|
5597
|
-
} catch (error) {
|
|
5598
|
-
if (restore) await previous.setEnabled(true);
|
|
5599
|
-
throw error;
|
|
5600
|
-
}
|
|
5601
|
-
};
|
|
7478
|
+
cpolar: remoteControllers.cpolar.status(),
|
|
7479
|
+
frp: remoteControllers.frp.status()
|
|
7480
|
+
}, cpolarComponent.status(), frpComponent.status(), frpConfig.status());
|
|
5602
7481
|
const lanPayload = () => ({
|
|
5603
7482
|
running: lanController.isRunning(),
|
|
5604
7483
|
origin: lanGateway?.address().origin,
|
|
@@ -5629,7 +7508,7 @@ async function apply(ctx, config) {
|
|
|
5629
7508
|
...networkError === void 0 ? {} : { networkError }
|
|
5630
7509
|
},
|
|
5631
7510
|
remote: {
|
|
5632
|
-
provider:
|
|
7511
|
+
provider: remoteProviders.selected,
|
|
5633
7512
|
running: remote.enabled,
|
|
5634
7513
|
state: remote.state,
|
|
5635
7514
|
...remote.origin === void 0 ? {} : { origin: remote.origin },
|
|
@@ -5654,6 +7533,15 @@ async function apply(ctx, config) {
|
|
|
5654
7533
|
sendJson(response, 200, await diagnosticsPayload(), false);
|
|
5655
7534
|
return;
|
|
5656
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
|
+
}
|
|
5657
7545
|
if (request.method === "POST" && lanControl) {
|
|
5658
7546
|
const body = await readJsonObject(request, 4096);
|
|
5659
7547
|
if (typeof body.running !== "boolean") throw new HttpError(400, "bad_request");
|
|
@@ -5667,47 +7555,75 @@ async function apply(ctx, config) {
|
|
|
5667
7555
|
}
|
|
5668
7556
|
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/provider`) {
|
|
5669
7557
|
const body = await readJsonObject(request, 4096);
|
|
5670
|
-
if (body.provider !== "tailscale" && body.provider !== "cpolar") throw new HttpError(400, "bad_request");
|
|
5671
|
-
await
|
|
7558
|
+
if (body.provider !== "tailscale" && body.provider !== "cpolar" && body.provider !== "frp") throw new HttpError(400, "bad_request");
|
|
7559
|
+
await remoteProviders.select(body.provider);
|
|
5672
7560
|
sendJson(response, 200, remotePayload(), false);
|
|
5673
7561
|
return;
|
|
5674
7562
|
}
|
|
5675
7563
|
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cpolar/component/install`) {
|
|
5676
7564
|
if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
5677
|
-
await cpolarComponent.install();
|
|
7565
|
+
await remoteProviders.mutate(async () => cpolarComponent.install());
|
|
5678
7566
|
sendJson(response, 200, remotePayload(), false);
|
|
5679
7567
|
return;
|
|
5680
7568
|
}
|
|
5681
7569
|
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cpolar/configure`) {
|
|
5682
7570
|
const body = await readJsonObject(request, 4096);
|
|
5683
|
-
await cpolarComponent.configure(body.authtoken);
|
|
7571
|
+
await remoteProviders.mutate(async () => cpolarComponent.configure(body.authtoken));
|
|
5684
7572
|
sendJson(response, 200, remotePayload(), false);
|
|
5685
7573
|
return;
|
|
5686
7574
|
}
|
|
5687
7575
|
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cpolar/component/purge`) {
|
|
5688
7576
|
if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
5689
|
-
await
|
|
5690
|
-
|
|
7577
|
+
await remoteProviders.mutate(async () => {
|
|
7578
|
+
await remoteControllers.cpolar.setEnabled(false);
|
|
7579
|
+
await cpolarComponent.purge();
|
|
7580
|
+
});
|
|
5691
7581
|
sendJson(response, 200, remotePayload(), false);
|
|
5692
7582
|
return;
|
|
5693
7583
|
}
|
|
5694
|
-
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/
|
|
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`) {
|
|
5695
7591
|
const body = await readJsonObject(request, 4096);
|
|
5696
|
-
|
|
5697
|
-
|
|
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));
|
|
5698
7612
|
sendJson(response, 200, remotePayload(), false);
|
|
5699
7613
|
return;
|
|
5700
7614
|
}
|
|
5701
7615
|
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/reconnect`) {
|
|
5702
7616
|
await readJsonObject(request, 4096);
|
|
5703
|
-
await
|
|
7617
|
+
await remoteProviders.mutate(async (controller) => controller.reconnect());
|
|
5704
7618
|
sendJson(response, 200, remotePayload(), false);
|
|
5705
7619
|
return;
|
|
5706
7620
|
}
|
|
5707
7621
|
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/reset`) {
|
|
5708
7622
|
if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
5709
|
-
await
|
|
5710
|
-
|
|
7623
|
+
await remoteProviders.mutate(async (controller) => {
|
|
7624
|
+
await controller.reset();
|
|
7625
|
+
await rm(remoteDeviceFile, { force: true });
|
|
7626
|
+
});
|
|
5711
7627
|
sendJson(response, 200, remotePayload(), false);
|
|
5712
7628
|
return;
|
|
5713
7629
|
}
|
|
@@ -5766,36 +7682,54 @@ async function apply(ctx, config) {
|
|
|
5766
7682
|
try {
|
|
5767
7683
|
await mobileAccess.startLocal(template.extensionsDir, ctx);
|
|
5768
7684
|
await lanController.initialize();
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
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({
|
|
5774
7691
|
version: 1,
|
|
5775
7692
|
enabled: false
|
|
5776
|
-
});
|
|
5777
|
-
|
|
5778
|
-
|
|
7693
|
+
})));
|
|
7694
|
+
for (const provider of [
|
|
7695
|
+
"tailscale",
|
|
7696
|
+
"cpolar",
|
|
7697
|
+
"frp"
|
|
7698
|
+
]) await remoteControllers[provider].initialize();
|
|
5779
7699
|
} catch (error) {
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
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
|
+
}
|
|
5786
7715
|
throw error;
|
|
5787
7716
|
}
|
|
5788
7717
|
return async () => {
|
|
5789
|
-
|
|
5790
|
-
|
|
5791
|
-
|
|
5792
|
-
|
|
5793
|
-
|
|
5794
|
-
|
|
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
|
+
]);
|
|
5795
7729
|
};
|
|
5796
|
-
}, "dsh-mobile: independent LAN and selectable remote
|
|
7730
|
+
}, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
|
|
5797
7731
|
}
|
|
5798
7732
|
//#endregion
|
|
5799
|
-
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 };
|
|
5800
7734
|
|
|
5801
7735
|
//# sourceMappingURL=index.mjs.map
|