dsh-mobile 0.1.0-alpha.6 → 0.1.0-alpha.7
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/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/cli.js +4 -4
- package/lib/index.mjs +99 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ pnpm dsh plugin --profile web exec dsh-mobile setup
|
|
|
61
61
|
pnpm dsh --profile web
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
Both paths install the public npm package into stock DSH without patching its source. Setup remembers the selected LAN interface, creates one local CA, stores plugin data under `$DSH_HOME/mobile-access/`, and on Windows requests administrator approval for
|
|
64
|
+
Both paths install the public npm package into stock DSH without patching its source. Setup remembers the selected LAN interface, creates one local CA, stores plugin data under `$DSH_HOME/mobile-access/`, and on Windows requests administrator approval once for TCP/UDP rules limited to Windows' dynamic `LocalSubnet`. Use `--no-firewall` only when those rules are managed separately. While DSH is running, the plugin follows DHCP, Wi-Fi, and phone-hotspot address changes on that interface, rebinds the gateway, and signs a matching server certificate from the same CA. Paired devices and the Android CA trust remain valid.
|
|
65
65
|
|
|
66
66
|
1. Copy the generated `dsh-mobile-ca.cer` to Android and install it once as a user CA certificate.
|
|
67
67
|
2. Open the Mobile card in the lower-left corner of the desktop DSH UI.
|
package/README.zh.md
CHANGED
|
@@ -61,7 +61,7 @@ pnpm dsh plugin --profile web exec dsh-mobile setup
|
|
|
61
61
|
pnpm dsh --profile web
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
两种方式都会把公开 npm 包安装到原生 DSH,不修改 DSH 源码。设置向导会记住所选局域网网卡、生成一份稳定的本地 CA,并把插件数据保存到 `$DSH_HOME/mobile-access/`。Windows
|
|
64
|
+
两种方式都会把公开 npm 包安装到原生 DSH,不修改 DSH 源码。设置向导会记住所选局域网网卡、生成一份稳定的本地 CA,并把插件数据保存到 `$DSH_HOME/mobile-access/`。Windows 只在首次设置时请求管理员批准两条受动态 `LocalSubnet` 限制的 TCP/UDP 规则;只有自行管理规则时才使用 `--no-firewall`。DSH 运行期间,如果该网卡因 DHCP、切换 Wi-Fi 或手机热点而改变地址,插件会自动重绑网关,并用同一 CA 签发匹配的新服务器证书;已有设备配对和 Android CA 信任不会失效。
|
|
65
65
|
|
|
66
66
|
1. 将向导输出的 `dsh-mobile-ca.cer` 复制到 Android,并在系统设置中一次性安装为用户 CA 证书。
|
|
67
67
|
2. 在电脑 DSH 左下角打开“移动端”卡片。
|
package/lib/cli.js
CHANGED
|
@@ -229,14 +229,14 @@ async function runElevatedPowerShell(script) {
|
|
|
229
229
|
launch
|
|
230
230
|
], { windowsHide: true });
|
|
231
231
|
}
|
|
232
|
-
async function configureWindowsFirewall(port
|
|
232
|
+
async function configureWindowsFirewall(port) {
|
|
233
233
|
if (process.platform !== "win32") return;
|
|
234
234
|
const script = [
|
|
235
235
|
"$ErrorActionPreference = 'Stop'",
|
|
236
236
|
`Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
|
|
237
237
|
`Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
|
|
238
|
-
`New-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${String(port)} -RemoteAddress
|
|
239
|
-
`New-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -Direction Inbound -Action Allow -Protocol UDP -LocalPort ${String(port)} -RemoteAddress
|
|
238
|
+
`New-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`,
|
|
239
|
+
`New-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -Direction Inbound -Action Allow -Protocol UDP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`
|
|
240
240
|
].join("; ");
|
|
241
241
|
console.log("Windows will request administrator approval for two LAN-only firewall rules.");
|
|
242
242
|
await runElevatedPowerShell(script);
|
|
@@ -293,7 +293,7 @@ async function setup(args) {
|
|
|
293
293
|
chmod(keyFile, 384),
|
|
294
294
|
chmod(androidCertificate, 384)
|
|
295
295
|
]);
|
|
296
|
-
if (options.configureFirewall) await configureWindowsFirewall(options.port
|
|
296
|
+
if (options.configureFirewall) await configureWindowsFirewall(options.port);
|
|
297
297
|
const customCss = join(directory, "mobile.css");
|
|
298
298
|
try {
|
|
299
299
|
await readFile(customCss);
|
package/lib/index.mjs
CHANGED
|
@@ -687,6 +687,60 @@ function parseGatewayConfig(raw) {
|
|
|
687
687
|
}
|
|
688
688
|
//#endregion
|
|
689
689
|
//#region src/control.ts
|
|
690
|
+
/** Keeps one runtime aligned with a changing network selection. */
|
|
691
|
+
var FollowingMobileAccessRuntime = class {
|
|
692
|
+
select;
|
|
693
|
+
onRefreshError;
|
|
694
|
+
runtime;
|
|
695
|
+
key;
|
|
696
|
+
queue = Promise.resolve();
|
|
697
|
+
closed = false;
|
|
698
|
+
timer;
|
|
699
|
+
constructor(select, onRefreshError) {
|
|
700
|
+
this.select = select;
|
|
701
|
+
this.onRefreshError = onRefreshError;
|
|
702
|
+
}
|
|
703
|
+
/** Start the current selection and optionally poll for later changes. */
|
|
704
|
+
async initialize(refreshIntervalMs) {
|
|
705
|
+
await this.refresh();
|
|
706
|
+
if (refreshIntervalMs === void 0) return;
|
|
707
|
+
this.timer = setInterval(() => {
|
|
708
|
+
this.refresh().catch(this.onRefreshError);
|
|
709
|
+
}, refreshIntervalMs);
|
|
710
|
+
this.timer.unref();
|
|
711
|
+
}
|
|
712
|
+
/** Reconcile the active runtime with the latest selection. */
|
|
713
|
+
refresh() {
|
|
714
|
+
return this.enqueue(async () => {
|
|
715
|
+
if (this.closed) return;
|
|
716
|
+
const selection = await this.select();
|
|
717
|
+
if (this.closed || this.runtime !== void 0 && this.key === selection.key) return;
|
|
718
|
+
const previous = this.runtime;
|
|
719
|
+
this.runtime = void 0;
|
|
720
|
+
this.key = void 0;
|
|
721
|
+
if (previous !== void 0) await previous.close();
|
|
722
|
+
this.runtime = await selection.start();
|
|
723
|
+
this.key = selection.key;
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
/** Stop polling and close the most recently selected runtime. */
|
|
727
|
+
close() {
|
|
728
|
+
if (this.closed) return this.queue;
|
|
729
|
+
this.closed = true;
|
|
730
|
+
if (this.timer !== void 0) clearInterval(this.timer);
|
|
731
|
+
return this.enqueue(async () => {
|
|
732
|
+
const runtime = this.runtime;
|
|
733
|
+
this.runtime = void 0;
|
|
734
|
+
this.key = void 0;
|
|
735
|
+
if (runtime !== void 0) await runtime.close();
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
enqueue(operation) {
|
|
739
|
+
const run = this.queue.then(operation, operation);
|
|
740
|
+
this.queue = run.then(() => {}, () => {});
|
|
741
|
+
return run;
|
|
742
|
+
}
|
|
743
|
+
};
|
|
690
744
|
/** Validate control state loaded across the filesystem boundary. */
|
|
691
745
|
function parseMobileAccessControlState(value) {
|
|
692
746
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("mobile-access control state must be an object");
|
|
@@ -2295,14 +2349,25 @@ const SETUP_KEYS = /* @__PURE__ */ new Set([
|
|
|
2295
2349
|
"instanceId",
|
|
2296
2350
|
"tls"
|
|
2297
2351
|
]);
|
|
2352
|
+
function withoutSetupKeys(config) {
|
|
2353
|
+
const merged = { ...config };
|
|
2354
|
+
for (const key of SETUP_KEYS) if (key !== "version") delete merged[key];
|
|
2355
|
+
return merged;
|
|
2356
|
+
}
|
|
2298
2357
|
async function loadSetup(config) {
|
|
2299
|
-
if (config.setupFile === void 0) return
|
|
2358
|
+
if (config.setupFile === void 0) return {
|
|
2359
|
+
kind: "fixed",
|
|
2360
|
+
config
|
|
2361
|
+
};
|
|
2300
2362
|
if (!isAbsolute(config.setupFile)) throw new Error("setupFile must be an absolute file path");
|
|
2301
2363
|
let source;
|
|
2302
2364
|
try {
|
|
2303
2365
|
source = await readFile(resolve(config.setupFile), "utf8");
|
|
2304
2366
|
} catch (error) {
|
|
2305
|
-
if (error.code === "ENOENT") return
|
|
2367
|
+
if (error.code === "ENOENT") return {
|
|
2368
|
+
kind: "fixed",
|
|
2369
|
+
config
|
|
2370
|
+
};
|
|
2306
2371
|
throw error;
|
|
2307
2372
|
}
|
|
2308
2373
|
let parsed;
|
|
@@ -2313,29 +2378,27 @@ async function loadSetup(config) {
|
|
|
2313
2378
|
}
|
|
2314
2379
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("mobile setup file must be an object");
|
|
2315
2380
|
const record = parsed;
|
|
2316
|
-
if (record.version === 2) {
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
...merged,
|
|
2322
|
-
...setup
|
|
2323
|
-
};
|
|
2324
|
-
}
|
|
2381
|
+
if (record.version === 2) return {
|
|
2382
|
+
kind: "managed",
|
|
2383
|
+
config: withoutSetupKeys(config),
|
|
2384
|
+
setup: parseManagedSetup(record)
|
|
2385
|
+
};
|
|
2325
2386
|
if (record.version !== 1 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || !SETUP_KEYS.has(key))) throw new Error("mobile setup file has an unsupported format");
|
|
2326
2387
|
const { version: _version, ...setup } = record;
|
|
2327
|
-
const merged = { ...config };
|
|
2328
|
-
for (const key of SETUP_KEYS) if (key !== "version") delete merged[key];
|
|
2329
2388
|
return {
|
|
2330
|
-
|
|
2331
|
-
|
|
2389
|
+
kind: "fixed",
|
|
2390
|
+
config: {
|
|
2391
|
+
...withoutSetupKeys(config),
|
|
2392
|
+
...setup
|
|
2393
|
+
}
|
|
2332
2394
|
};
|
|
2333
2395
|
}
|
|
2334
2396
|
/** Mount the resident control route and its optional authenticated LAN gateway. */
|
|
2335
2397
|
async function apply(ctx, config) {
|
|
2336
|
-
const
|
|
2398
|
+
const loaded = await loadSetup(config);
|
|
2337
2399
|
let gateway;
|
|
2338
|
-
const
|
|
2400
|
+
const startGateway = async (candidateConfig) => {
|
|
2401
|
+
const resolved = parseGatewayConfig(candidateConfig);
|
|
2339
2402
|
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices));
|
|
2340
2403
|
await candidate.start();
|
|
2341
2404
|
gateway = candidate;
|
|
@@ -2343,7 +2406,25 @@ async function apply(ctx, config) {
|
|
|
2343
2406
|
if (gateway === candidate) gateway = void 0;
|
|
2344
2407
|
await candidate.close();
|
|
2345
2408
|
} };
|
|
2346
|
-
}
|
|
2409
|
+
};
|
|
2410
|
+
const startRuntime = async () => {
|
|
2411
|
+
if (loaded.kind === "fixed") return startGateway(loaded.config);
|
|
2412
|
+
const following = new FollowingMobileAccessRuntime(async () => {
|
|
2413
|
+
const network = selectLanNetwork(void 0, loaded.setup.networkInterface);
|
|
2414
|
+
return {
|
|
2415
|
+
key: `${network.name}\0${network.address}\0${network.cidr}`,
|
|
2416
|
+
start: async () => startGateway({
|
|
2417
|
+
...loaded.config,
|
|
2418
|
+
...await materializeManagedSetup(loaded.setup)
|
|
2419
|
+
})
|
|
2420
|
+
};
|
|
2421
|
+
}, (error) => {
|
|
2422
|
+
process.emitWarning(`DSH Mobile could not follow the current LAN address: ${error instanceof Error ? error.message : String(error)}`, { code: "DSH_MOBILE_NETWORK_REFRESH" });
|
|
2423
|
+
});
|
|
2424
|
+
await following.initialize(2e3);
|
|
2425
|
+
return following;
|
|
2426
|
+
};
|
|
2427
|
+
const controller = new MobileAccessGatewayController(new JsonMobileAccessControlStore(parseControlFile(config.controlFile), config.initiallyEnabled), startRuntime);
|
|
2347
2428
|
const adminRoute = {
|
|
2348
2429
|
kind: "prefix",
|
|
2349
2430
|
path: LOCAL_ADMIN_PREFIX,
|