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 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 LAN-only TCP/UDP firewall rules. Use `--no-firewall` only when those rules are managed separately. When DHCP changes that interface's IP, the plugin follows the new address and signs a matching server certificate from the same CA on the next DSH start.
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 会请求管理员批准两条仅限所选局域网的 TCP/UDP 防火墙规则;只有自行管理规则时才使用 `--no-firewall`。DHCP 改变该网卡的 IP 后,插件会在下次启动 DSH 时跟随新地址,并用同一 CA 自动签发匹配的服务器证书。
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, cidr) {
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 '${cidr}' -Profile Any | Out-Null`,
239
- `New-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -Direction Inbound -Action Allow -Protocol UDP -LocalPort ${String(port)} -RemoteAddress '${cidr}' -Profile Any | Out-Null`
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, network.cidr);
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 config;
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 config;
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
- const setup = await materializeManagedSetup(parseManagedSetup(record));
2318
- const merged = { ...config };
2319
- for (const key of SETUP_KEYS) if (key !== "version") delete merged[key];
2320
- return {
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
- ...merged,
2331
- ...setup
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 resolved = parseGatewayConfig(await loadSetup(config));
2398
+ const loaded = await loadSetup(config);
2337
2399
  let gateway;
2338
- const controller = new MobileAccessGatewayController(new JsonMobileAccessControlStore(parseControlFile(config.controlFile), config.initiallyEnabled), async () => {
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile",
3
- "version": "0.1.0-alpha.6",
3
+ "version": "0.1.0-alpha.7",
4
4
  "private": false,
5
5
  "description": "Authenticated Android and mobile-browser access for the stock DeepSeek Harness Web UI",
6
6
  "type": "module",