dsh-mobile 0.3.16 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs CHANGED
@@ -142,28 +142,28 @@ var AccessController = class {
142
142
  devices: Object.freeze([...devices])
143
143
  });
144
144
  }
145
- emitSessionEnded(session) {
145
+ emitSessionEnded(session, reason) {
146
146
  const authorization = Object.freeze({
147
147
  sessionKey: session.key,
148
148
  deviceId: session.deviceId,
149
149
  expiresAt: session.expiresAt
150
150
  });
151
- for (const listener of this.sessionEndedListeners) listener(authorization);
151
+ for (const listener of this.sessionEndedListeners) listener(authorization, reason);
152
152
  }
153
- removeSession(key) {
153
+ removeSession(key, reason) {
154
154
  const session = this.sessions.get(key);
155
155
  if (session === void 0) return;
156
156
  this.sessions.delete(key);
157
- this.emitSessionEnded(session);
157
+ this.emitSessionEnded(session, reason);
158
158
  }
159
159
  pruneSessions(now) {
160
- for (const [key, session] of this.sessions) if (session.expiresAt <= now) this.removeSession(key);
160
+ for (const [key, session] of this.sessions) if (session.expiresAt <= now) this.removeSession(key, "expired");
161
161
  }
162
162
  createSession(deviceId, now, deviceExpiresAt) {
163
163
  this.pruneSessions(now);
164
164
  if (this.sessions.size >= this.options.maxSessions) {
165
165
  const oldest = [...this.sessions.values()].sort((left, right) => left.createdAt - right.createdAt)[0];
166
- if (oldest !== void 0) this.removeSession(oldest.key);
166
+ if (oldest !== void 0) this.removeSession(oldest.key, "evicted");
167
167
  }
168
168
  const sessionToken = opaqueToken();
169
169
  const csrfToken = opaqueToken();
@@ -244,7 +244,9 @@ var AccessController = class {
244
244
  const tokenDigest = digest(deviceToken);
245
245
  const index = this.devices.findIndex((device) => timingSafeEqual(Buffer.from(device.tokenDigest, "hex"), tokenDigest));
246
246
  const device = this.devices[index];
247
- if (device === void 0 || device.revokedAt !== void 0 || device.expiresAt <= now) throw new AccessError(401, "authentication_failed");
247
+ if (device === void 0) throw new AccessError(401, "authentication_failed");
248
+ if (device.revokedAt !== void 0) throw new AccessError(401, "device_revoked");
249
+ if (device.expiresAt <= now) throw new AccessError(401, "device_expired");
248
250
  const updated = Object.freeze({
249
251
  ...device,
250
252
  lastSeenAt: now
@@ -256,6 +258,32 @@ var AccessController = class {
256
258
  return this.createSession(device.id, now, device.expiresAt);
257
259
  });
258
260
  }
261
+ /** Validate a persistent device credential without consuming a Session slot. */
262
+ async probe(deviceToken) {
263
+ this.requireInitialized();
264
+ if (deviceToken.length > 512) throw new AccessError(401, "authentication_failed");
265
+ return this.exclusive(async () => {
266
+ const now = this.now();
267
+ const tokenDigest = digest(deviceToken);
268
+ const index = this.devices.findIndex((device) => timingSafeEqual(Buffer.from(device.tokenDigest, "hex"), tokenDigest));
269
+ const device = this.devices[index];
270
+ if (device === void 0) throw new AccessError(401, "authentication_failed");
271
+ if (device.revokedAt !== void 0) throw new AccessError(401, "device_revoked");
272
+ if (device.expiresAt <= now) throw new AccessError(401, "device_expired");
273
+ const updated = Object.freeze({
274
+ ...device,
275
+ lastSeenAt: now
276
+ });
277
+ const next = [...this.devices];
278
+ next[index] = updated;
279
+ await this.store.save(this.snapshot(next));
280
+ this.devices = next;
281
+ return Object.freeze({
282
+ deviceId: device.id,
283
+ deviceExpiresAt: device.expiresAt
284
+ });
285
+ });
286
+ }
259
287
  /** Resolve a short Session Cookie without revealing whether device or Session failed. */
260
288
  authorizeSession(sessionToken) {
261
289
  this.requireInitialized();
@@ -266,7 +294,7 @@ var AccessController = class {
266
294
  const session = this.sessions.get(key);
267
295
  const device = session === void 0 ? void 0 : this.devices.find((candidate) => candidate.id === session.deviceId);
268
296
  if (session === void 0 || device === void 0 || device.revokedAt !== void 0 || device.expiresAt <= now) {
269
- if (session !== void 0) this.removeSession(session.key);
297
+ if (session !== void 0) this.removeSession(session.key, "expired");
270
298
  throw new AccessError(401, "authentication_failed");
271
299
  }
272
300
  return Object.freeze({
@@ -282,7 +310,7 @@ var AccessController = class {
282
310
  }
283
311
  /** End one short Session and notify the gateway to abort its attached work. */
284
312
  logout(authorization) {
285
- this.removeSession(authorization.sessionKey);
313
+ this.removeSession(authorization.sessionKey, "logout");
286
314
  }
287
315
  /** Persist revocation, then end every Session owned by that device. */
288
316
  async revokeDevice(deviceId) {
@@ -298,7 +326,7 @@ var AccessController = class {
298
326
  });
299
327
  await this.store.save(this.snapshot(next));
300
328
  this.devices = next;
301
- for (const [key, session] of this.sessions) if (session.deviceId === deviceId) this.removeSession(key);
329
+ for (const [key, session] of this.sessions) if (session.deviceId === deviceId) this.removeSession(key, "revoked");
302
330
  return true;
303
331
  });
304
332
  }
@@ -308,7 +336,7 @@ var AccessController = class {
308
336
  await this.exclusive(async () => {
309
337
  await this.store.save(this.snapshot([]));
310
338
  this.devices = [];
311
- for (const key of [...this.sessions.keys()]) this.removeSession(key);
339
+ for (const key of [...this.sessions.keys()]) this.removeSession(key, "revoked");
312
340
  this.pairingWindow = void 0;
313
341
  });
314
342
  }
@@ -347,7 +375,7 @@ var AccessController = class {
347
375
  async finishClose() {
348
376
  await this.mutation;
349
377
  this.pairingWindow = void 0;
350
- for (const key of [...this.sessions.keys()]) this.removeSession(key);
378
+ for (const key of [...this.sessions.keys()]) this.removeSession(key, "expired");
351
379
  this.sessionEndedListeners.clear();
352
380
  this.initialized = false;
353
381
  }
@@ -361,7 +389,7 @@ var AccessController = class {
361
389
  };
362
390
  //#endregion
363
391
  //#region src/network.ts
364
- function parseIpv4(address) {
392
+ function parseIpv4$1(address) {
365
393
  const parts = address.split(".");
366
394
  if (parts.length !== 4) throw new Error(`invalid IPv4 address ${JSON.stringify(address)}`);
367
395
  let value = 0n;
@@ -375,7 +403,7 @@ function parseIpv4(address) {
375
403
  }
376
404
  function parseIpv6Part(part, address) {
377
405
  if (part.includes(".")) {
378
- const ipv4 = parseIpv4(part);
406
+ const ipv4 = parseIpv4$1(part);
379
407
  return [Number(ipv4 >> 16n & 65535n), Number(ipv4 & 65535n)];
380
408
  }
381
409
  if (!/^[\da-f]{1,4}$/iu.test(part)) throw new Error(`invalid IPv6 address ${JSON.stringify(address)}`);
@@ -405,12 +433,12 @@ function parseIp(address) {
405
433
  const mapped = mappedIpv4(unwrapped);
406
434
  if (mapped !== void 0) return {
407
435
  bits: 32,
408
- value: parseIpv4(mapped)
436
+ value: parseIpv4$1(mapped)
409
437
  };
410
438
  const version = isIP(unwrapped.split("%", 1)[0] ?? unwrapped);
411
439
  if (version === 4) return {
412
440
  bits: 32,
413
- value: parseIpv4(unwrapped)
441
+ value: parseIpv4$1(unwrapped)
414
442
  };
415
443
  if (version === 6) return {
416
444
  bits: 128,
@@ -1051,6 +1079,46 @@ var MobileAccessGatewayController = class {
1051
1079
  }
1052
1080
  };
1053
1081
  //#endregion
1082
+ //#region src/local-admin-host.ts
1083
+ /** Browser-safe desktop-admin Host checks. Do not import Node APIs here. */
1084
+ function unwrapBrackets(hostname) {
1085
+ return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
1086
+ }
1087
+ function parseIpv4(hostname) {
1088
+ const parts = hostname.split(".");
1089
+ if (parts.length !== 4) return void 0;
1090
+ const octets = [];
1091
+ for (const part of parts) {
1092
+ if (!/^(?:0|[1-9]\d{0,2})$/u.test(part)) return void 0;
1093
+ const value = Number(part);
1094
+ if (value > 255) return void 0;
1095
+ octets.push(value);
1096
+ }
1097
+ return octets;
1098
+ }
1099
+ function isLoopbackIpv4(octets) {
1100
+ return octets[0] === 127;
1101
+ }
1102
+ function isPrivateIpv4(octets) {
1103
+ return octets[0] === 10 || octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31 || octets[0] === 192 && octets[1] === 168;
1104
+ }
1105
+ function isLinkLocalIpv4(octets) {
1106
+ return octets[0] === 169 && octets[1] === 254;
1107
+ }
1108
+ /**
1109
+ * Hostnames that may appear on the DSH desktop admin surface.
1110
+ * Loopback, RFC1918, and IPv4 link-local are accepted. Public IPs,
1111
+ * CGNAT, and arbitrary DNS names stay rejected so DNS rebinding
1112
+ * cannot reach `/api/mobile-access`.
1113
+ */
1114
+ function isLocalAdminHostname(hostname) {
1115
+ const host = unwrapBrackets(hostname).trim().toLowerCase();
1116
+ if (host === "localhost" || host === "::1") return true;
1117
+ const octets = parseIpv4(host);
1118
+ if (octets === void 0) return false;
1119
+ return isLoopbackIpv4(octets) || isPrivateIpv4(octets) || isLinkLocalIpv4(octets);
1120
+ }
1121
+ //#endregion
1054
1122
  //#region src/http-security.ts
1055
1123
  const DEVICE_COOKIE = "dsh_ma_device";
1056
1124
  const SESSION_COOKIE = "dsh_ma_session";
@@ -1094,26 +1162,43 @@ function parseRequestTarget(raw) {
1094
1162
  search: parsed.search
1095
1163
  });
1096
1164
  }
1097
- /** Set the gateway-owned browser protections and non-cacheability. */
1098
- function setSecurityHeaders(response, tls) {
1165
+ /**
1166
+ * The frame sources a proxied GUI document may embed: its own routes, blob:
1167
+ * (the PDF viewer's object URL) and external http(s) pages (the sidebar
1168
+ * browser tab). The framed document runs in the tab's existing sandbox
1169
+ * (opaque origin, no same-origin privileges), so this does not hand the
1170
+ * embedded page anything it did not already have.
1171
+ */
1172
+ const PROXIED_FRAME_SRC = "frame-src 'self' blob: https: http:";
1173
+ /**
1174
+ * Set the gateway browser protections and non-cacheability.
1175
+ * @param response - the response to decorate.
1176
+ * @param tls - whether the connection is TLS (adds HSTS).
1177
+ * @param framing - the framing policy; defaults to the strict gateway one, so
1178
+ * every existing call site keeps refusing to be framed. Only the two PROXIED
1179
+ * forwarders pass `'proxied'`.
1180
+ */
1181
+ function setSecurityHeaders(response, tls, framing = "gateway") {
1182
+ const proxied = framing === "proxied";
1099
1183
  response.setHeader("Cache-Control", "no-store");
1100
1184
  response.setHeader("Content-Security-Policy", [
1101
1185
  "default-src 'self'",
1102
1186
  "base-uri 'none'",
1103
1187
  "object-src 'none'",
1104
- "frame-ancestors 'none'",
1188
+ proxied ? "frame-ancestors 'self'" : "frame-ancestors 'none'",
1105
1189
  "form-action 'self'",
1106
1190
  "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
1107
1191
  "style-src 'self' 'unsafe-inline'",
1108
1192
  "img-src 'self' data: blob:",
1109
1193
  "font-src 'self' data:",
1110
1194
  "connect-src 'self'",
1111
- "worker-src 'self' blob:"
1195
+ "worker-src 'self' blob:",
1196
+ ...proxied ? [PROXIED_FRAME_SRC] : []
1112
1197
  ].join("; "));
1113
1198
  response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
1114
1199
  response.setHeader("Referrer-Policy", "no-referrer");
1115
1200
  response.setHeader("X-Content-Type-Options", "nosniff");
1116
- response.setHeader("X-Frame-Options", "DENY");
1201
+ response.setHeader("X-Frame-Options", proxied ? "SAMEORIGIN" : "DENY");
1117
1202
  response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
1118
1203
  if (tls) response.setHeader("Strict-Transport-Security", "max-age=31536000");
1119
1204
  }
@@ -1210,7 +1295,7 @@ function localAuthority(header) {
1210
1295
  function assertLocalAdminTrust(request, requireBrowserOrigin) {
1211
1296
  if (request.socket.remoteAddress === void 0 || !isLoopbackAddress(request.socket.remoteAddress)) throw new HttpError(403, "forbidden");
1212
1297
  const host = localAuthority(request.headers.host);
1213
- if (host === void 0 || host.hostname !== "localhost" && !isLoopbackAddress(host.hostname)) throw new HttpError(403, "forbidden");
1298
+ if (host === void 0 || !isLocalAdminHostname(host.hostname)) throw new HttpError(403, "forbidden");
1214
1299
  const site = request.headers["sec-fetch-site"];
1215
1300
  if (site !== void 0 && site !== "same-origin" && site !== "none") throw new HttpError(403, "forbidden");
1216
1301
  const origin = request.headers.origin;
@@ -1221,7 +1306,8 @@ function assertLocalAdminTrust(request, requireBrowserOrigin) {
1221
1306
  if (error instanceof HttpError) throw error;
1222
1307
  throw new HttpError(403, "forbidden");
1223
1308
  }
1224
- if (requireBrowserOrigin && site !== void 0 && (origin === void 0 || site !== "same-origin")) throw new HttpError(403, "forbidden");
1309
+ if (requireBrowserOrigin && origin === void 0) throw new HttpError(403, "forbidden");
1310
+ if (requireBrowserOrigin && site !== void 0 && site !== "same-origin") throw new HttpError(403, "forbidden");
1225
1311
  }
1226
1312
  //#endregion
1227
1313
  //#region src/version.ts
@@ -1613,6 +1699,12 @@ function combineSignalLifetime(first, second) {
1613
1699
  cleanup
1614
1700
  };
1615
1701
  }
1702
+ /** Validate and normalize an action input through either supported schema form. */
1703
+ function parseActionInput(schema, input) {
1704
+ if (schema === void 0) return input;
1705
+ if (typeof schema === "function") return schema(input);
1706
+ return schema.parse(input) ?? input;
1707
+ }
1616
1708
  /** Host registry and service consumed by both npm plugins and local extensions. */
1617
1709
  var MobileAccessService = class extends Service {
1618
1710
  registered = /* @__PURE__ */ new Map();
@@ -1744,9 +1836,9 @@ var MobileAccessService = class extends Service {
1744
1836
  if (extension === void 0) throw new MobileExtensionError("extension_not_found", "extension not found", 404);
1745
1837
  const action = ("host" in extension ? extension.host : extension).actions?.[actionName];
1746
1838
  if (action === void 0) throw new MobileExtensionError("action_not_found", "action not found", 404);
1747
- let parsed = input;
1839
+ let parsed;
1748
1840
  try {
1749
- parsed = action.input?.parse(input) ?? input;
1841
+ parsed = parseActionInput(action.input, input);
1750
1842
  } catch {
1751
1843
  throw new MobileExtensionError("invalid_action_input", "action input is invalid", 400);
1752
1844
  }
@@ -2094,6 +2186,188 @@ function createMobileAccessService(ctx) {
2094
2186
  return new MobileAccessService(ctx);
2095
2187
  }
2096
2188
  //#endregion
2189
+ //#region src/auth-pages.ts
2190
+ const COPY = {
2191
+ en: {
2192
+ lang: "en",
2193
+ pairTitle: "Pair DSH mobile access",
2194
+ pairHeading: "Pair this device",
2195
+ pairingCode: "Pairing code",
2196
+ deviceName: "Device name",
2197
+ pair: "Pair",
2198
+ pairing: "Pairing…",
2199
+ pairFailed: "Pairing failed",
2200
+ unavailable: "The computer is unavailable.",
2201
+ reconnectTitle: "Reconnect DSH mobile access",
2202
+ reconnectHeading: "Reconnect this device",
2203
+ restoring: "Restoring the secure Session…",
2204
+ noLongerPaired: "This device is no longer paired. Open pairing on the computer, then pair it again.",
2205
+ openPairing: "Open pairing"
2206
+ },
2207
+ zh: {
2208
+ lang: "zh-CN",
2209
+ pairTitle: "配对 DSH 移动访问",
2210
+ pairHeading: "配对此设备",
2211
+ pairingCode: "配对码",
2212
+ deviceName: "设备名称",
2213
+ pair: "配对",
2214
+ pairing: "正在配对…",
2215
+ pairFailed: "配对失败",
2216
+ unavailable: "电脑当前不可用。",
2217
+ reconnectTitle: "重新连接 DSH 移动访问",
2218
+ reconnectHeading: "重新连接此设备",
2219
+ restoring: "正在恢复安全会话…",
2220
+ noLongerPaired: "此设备已不在配对列表中。请在电脑端打开配对,然后重新配对。",
2221
+ openPairing: "打开配对页面"
2222
+ },
2223
+ it: {
2224
+ lang: "it-IT",
2225
+ pairTitle: "Abbina accesso mobile DSH",
2226
+ pairHeading: "Abbina questo dispositivo",
2227
+ pairingCode: "Codice di abbinamento",
2228
+ deviceName: "Nome dispositivo",
2229
+ pair: "Abbina",
2230
+ pairing: "Abbinamento…",
2231
+ pairFailed: "Abbinamento non riuscito",
2232
+ unavailable: "Il computer non è disponibile.",
2233
+ reconnectTitle: "Riconnetti accesso mobile DSH",
2234
+ reconnectHeading: "Riconnetti questo dispositivo",
2235
+ restoring: "Ripristino della sessione sicura…",
2236
+ noLongerPaired: "Questo dispositivo non è più abbinato. Apri l’abbinamento sul computer e ripeti la procedura.",
2237
+ openPairing: "Apri abbinamento"
2238
+ }
2239
+ };
2240
+ /** Choose Chinese, Italian, or English from an HTTP Accept-Language header. */
2241
+ function resolveAuthPageLocale(header) {
2242
+ const values = header?.split(",").map((value, index) => {
2243
+ const [language, ...parameters] = value.split(";");
2244
+ const quality = parameters.find((parameter) => parameter.trim().toLowerCase().startsWith("q="));
2245
+ const parsedQuality = quality === void 0 ? 1 : Number(quality.trim().slice(2));
2246
+ return {
2247
+ language: language?.trim().toLowerCase() ?? "",
2248
+ quality: Number.isFinite(parsedQuality) ? Math.max(0, Math.min(1, parsedQuality)) : 0,
2249
+ index
2250
+ };
2251
+ }).filter((value) => value.quality > 0).sort((left, right) => right.quality - left.quality || left.index - right.index).map((value) => value.language) ?? [];
2252
+ for (const value of values) {
2253
+ if (value.startsWith("zh")) return "zh";
2254
+ if (value.startsWith("it")) return "it";
2255
+ if (value.startsWith("en")) return "en";
2256
+ }
2257
+ return "en";
2258
+ }
2259
+ /** Render the browser pairing form without reflecting request data into HTML. */
2260
+ function renderPairPage(locale) {
2261
+ const copy = COPY[locale];
2262
+ return `<!doctype html>
2263
+ <html lang="${copy.lang}">
2264
+ <meta charset="utf-8">
2265
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
2266
+ <title>${copy.pairTitle}</title>
2267
+ <main>
2268
+ <h1>${copy.pairHeading}</h1>
2269
+ <form id="pair-form">
2270
+ <label>${copy.pairingCode} <input id="pair-token" autocomplete="one-time-code" required></label>
2271
+ <label>${copy.deviceName} <input id="device-label" maxlength="64" autocomplete="off"></label>
2272
+ <button type="submit">${copy.pair}</button>
2273
+ <output id="pair-status" aria-live="polite"></output>
2274
+ </form>
2275
+ </main>
2276
+ <script src="/mobile-access/pair.js" defer><\/script>
2277
+ </html>
2278
+ `;
2279
+ }
2280
+ /** Render the pairing form script with locale-owned status messages. */
2281
+ function renderPairScript(locale) {
2282
+ const copy = COPY[locale];
2283
+ return `(() => {
2284
+ const form = document.getElementById('pair-form')
2285
+ const token = document.getElementById('pair-token')
2286
+ const label = document.getElementById('device-label')
2287
+ const status = document.getElementById('pair-status')
2288
+ const fragment = new URLSearchParams(location.hash.slice(1))
2289
+ const supplied = fragment.get('token')
2290
+ history.replaceState(null, '', location.pathname)
2291
+ if (supplied) token.value = supplied
2292
+ form.addEventListener('submit', async (event) => {
2293
+ event.preventDefault()
2294
+ status.value = ${JSON.stringify(copy.pairing)}
2295
+ try {
2296
+ const response = await fetch('/mobile-access/auth/pair', {
2297
+ method: 'POST',
2298
+ credentials: 'same-origin',
2299
+ headers: { 'content-type': 'application/json' },
2300
+ body: JSON.stringify({ token: token.value, label: label.value || undefined }),
2301
+ })
2302
+ if (!response.ok) {
2303
+ status.value = ${JSON.stringify(copy.pairFailed)}
2304
+ return
2305
+ }
2306
+ location.replace('/')
2307
+ } catch {
2308
+ status.value = ${JSON.stringify(copy.unavailable)}
2309
+ }
2310
+ })
2311
+ })()
2312
+ `;
2313
+ }
2314
+ /** Render the browser reauthentication page. */
2315
+ function renderLoginPage(locale) {
2316
+ const copy = COPY[locale];
2317
+ return `<!doctype html>
2318
+ <html lang="${copy.lang}">
2319
+ <meta charset="utf-8">
2320
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
2321
+ <title>${copy.reconnectTitle}</title>
2322
+ <main>
2323
+ <h1>${copy.reconnectHeading}</h1>
2324
+ <p id="login-progress" aria-live="polite">${copy.restoring}</p>
2325
+ <section id="login-failed" hidden>
2326
+ <p>${copy.noLongerPaired}</p>
2327
+ <a href="/mobile-access/pair">${copy.openPairing}</a>
2328
+ </section>
2329
+ </main>
2330
+ <script src="/mobile-access/login.js" defer><\/script>
2331
+ </html>
2332
+ `;
2333
+ }
2334
+ /** Render the reauthentication script with locale-owned failure text. */
2335
+ function renderLoginScript(locale) {
2336
+ const copy = COPY[locale];
2337
+ return `(() => {
2338
+ const candidate = new URL(location.href).searchParams.get('return')
2339
+ let returnPath = '/'
2340
+ if (candidate && candidate.startsWith('/')) {
2341
+ try {
2342
+ const resolved = new URL(candidate, location.origin)
2343
+ const pathname = decodeURIComponent(resolved.pathname)
2344
+ if (resolved.origin === location.origin && pathname !== '/mobile-access'
2345
+ && !pathname.startsWith('/mobile-access/') && !pathname.includes('\\\\')) {
2346
+ returnPath = resolved.pathname + resolved.search + resolved.hash
2347
+ }
2348
+ } catch {
2349
+ // Malformed untrusted return targets keep the safe root default.
2350
+ }
2351
+ }
2352
+ fetch('/mobile-access/auth/renew', {
2353
+ method: 'POST',
2354
+ credentials: 'same-origin',
2355
+ headers: { 'content-type': 'application/json' },
2356
+ body: '{}',
2357
+ }).then((response) => {
2358
+ if (response.ok) {
2359
+ location.replace(returnPath)
2360
+ return
2361
+ }
2362
+ document.getElementById('login-progress').hidden = true
2363
+ document.getElementById('login-failed').hidden = false
2364
+ }).catch(() => {
2365
+ document.getElementById('login-progress').textContent = ${JSON.stringify(copy.unavailable)}
2366
+ })
2367
+ })()
2368
+ `;
2369
+ }
2370
+ //#endregion
2097
2371
  //#region src/gateway.ts
2098
2372
  const MAX_CONTROL_BODY_BYTES = 16384;
2099
2373
  const MAX_HEADER_BYTES = 16384;
@@ -2152,23 +2426,6 @@ const MOBILE_LAYOUT_DEPENDENCY_PROFILES = Object.freeze([Object.freeze({
2152
2426
  })]);
2153
2427
  const MOBILE_CSRF_FETCH_BOOTSTRAP = `(()=>{const nativeFetch=window.fetch.bind(window);window.fetch=(input,init)=>{const source=input instanceof Request?input:undefined;const method=String(init?.method??source?.method??'GET').toUpperCase();if(method==='GET'||method==='HEAD')return nativeFetch(input,init);const raw=typeof input==='string'?input:input instanceof URL?input.href:source?.url;if(raw===undefined||new URL(raw,location.href).origin!==location.origin)return nativeFetch(input,init);const headers=new Headers(init?.headers??source?.headers);if(!headers.has(${JSON.stringify(CSRF_HEADER)})){const prefix=${JSON.stringify(`${CSRF_COOKIE}=`)};const token=document.cookie.split(';').map(value=>value.trim()).find(value=>value.startsWith(prefix))?.slice(prefix.length);if(token!==undefined)headers.set(${JSON.stringify(CSRF_HEADER)},token)}return nativeFetch(input,{...init,headers})};})();`;
2154
2428
  const MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP = `(()=>{if(window.__DSH_TRANSPORT__!==undefined)throw new Error('DSH Mobile cannot replace an existing transport override');window.__DSH_TRANSPORT__={fetch:(input,init)=>window.fetch(input,init),ownsHost:true}})();`;
2155
- const PAIR_PAGE = `<!doctype html>
2156
- <html lang="en">
2157
- <meta charset="utf-8">
2158
- <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
2159
- <title>Pair DSH mobile access</title>
2160
- <main>
2161
- <h1>Pair this device</h1>
2162
- <form id="pair-form">
2163
- <label>Pairing code <input id="pair-token" autocomplete="one-time-code" required></label>
2164
- <label>Device name <input id="device-label" maxlength="64" autocomplete="off"></label>
2165
- <button type="submit">Pair</button>
2166
- <output id="pair-status"></output>
2167
- </form>
2168
- </main>
2169
- <script src="/mobile-access/pair.js" defer><\/script>
2170
- </html>
2171
- `;
2172
2429
  const gzipBuffer = promisify(gzip);
2173
2430
  function ensureMobileViewport(html) {
2174
2431
  const match = /<meta\b(?=[^>]*\bname\s*=\s*["']viewport["'])[^>]*>/iu.exec(html);
@@ -2272,80 +2529,6 @@ function rewriteMobileIndexWithBatch(html) {
2272
2529
  function rewriteMobileIndex(html) {
2273
2530
  return rewriteMobileIndexWithBatch(html).html;
2274
2531
  }
2275
- const PAIR_SCRIPT = `(() => {
2276
- const form = document.getElementById('pair-form')
2277
- const token = document.getElementById('pair-token')
2278
- const label = document.getElementById('device-label')
2279
- const status = document.getElementById('pair-status')
2280
- const fragment = new URLSearchParams(location.hash.slice(1))
2281
- const supplied = fragment.get('token')
2282
- history.replaceState(null, '', location.pathname)
2283
- if (supplied) token.value = supplied
2284
- form.addEventListener('submit', async (event) => {
2285
- event.preventDefault()
2286
- status.value = 'Pairing…'
2287
- const response = await fetch('/mobile-access/auth/pair', {
2288
- method: 'POST',
2289
- credentials: 'same-origin',
2290
- headers: { 'content-type': 'application/json' },
2291
- body: JSON.stringify({ token: token.value, label: label.value || undefined }),
2292
- })
2293
- if (!response.ok) {
2294
- status.value = 'Pairing failed'
2295
- return
2296
- }
2297
- location.replace('/')
2298
- })
2299
- })()
2300
- `;
2301
- const LOGIN_PAGE = `<!doctype html>
2302
- <html lang="en">
2303
- <meta charset="utf-8">
2304
- <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
2305
- <title>Reconnect DSH mobile access</title>
2306
- <main>
2307
- <h1>Reconnect this device</h1>
2308
- <p id="login-progress">Restoring the secure Session…</p>
2309
- <section id="login-failed" hidden>
2310
- <p>This device is no longer paired. Open pairing on the computer, then pair it again.</p>
2311
- <a href="/mobile-access/pair">Open pairing</a>
2312
- </section>
2313
- </main>
2314
- <script src="/mobile-access/login.js" defer><\/script>
2315
- </html>
2316
- `;
2317
- const LOGIN_SCRIPT = `(() => {
2318
- const candidate = new URL(location.href).searchParams.get('return')
2319
- let returnPath = '/'
2320
- if (candidate && candidate.startsWith('/')) {
2321
- try {
2322
- const resolved = new URL(candidate, location.origin)
2323
- const pathname = decodeURIComponent(resolved.pathname)
2324
- if (resolved.origin === location.origin && pathname !== '/mobile-access'
2325
- && !pathname.startsWith('/mobile-access/') && !pathname.includes('\\\\')) {
2326
- returnPath = resolved.pathname + resolved.search + resolved.hash
2327
- }
2328
- } catch {
2329
- // Malformed untrusted return targets keep the safe root default.
2330
- }
2331
- }
2332
- fetch('/mobile-access/auth/renew', {
2333
- method: 'POST',
2334
- credentials: 'same-origin',
2335
- headers: { 'content-type': 'application/json' },
2336
- body: '{}',
2337
- }).then((response) => {
2338
- if (response.ok) {
2339
- location.replace(returnPath)
2340
- return
2341
- }
2342
- document.getElementById('login-progress').hidden = true
2343
- document.getElementById('login-failed').hidden = false
2344
- }).catch(() => {
2345
- document.getElementById('login-progress').textContent = 'The computer is unavailable.'
2346
- })
2347
- })()
2348
- `;
2349
2532
  var ByteLimitTransform = class extends Transform {
2350
2533
  maximum;
2351
2534
  total = 0;
@@ -2772,6 +2955,9 @@ var MobileAccessGateway = class {
2772
2955
  mobileBootBatches = /* @__PURE__ */ new Map();
2773
2956
  extensionEventListeners = /* @__PURE__ */ new Set();
2774
2957
  extensionEventRevision = 0;
2958
+ taskEventListeners = /* @__PURE__ */ new Set();
2959
+ deviceEventListeners = /* @__PURE__ */ new Map();
2960
+ pendingDeviceRevocations = /* @__PURE__ */ new Set();
2775
2961
  extensionChangeTimer;
2776
2962
  extensionChangeTask;
2777
2963
  legacyCustomDigest = "";
@@ -2786,6 +2972,7 @@ var MobileAccessGateway = class {
2786
2972
  removeSessionListener;
2787
2973
  removeExtensionContentListener;
2788
2974
  renewLimiter;
2975
+ probeLimiter;
2789
2976
  constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog) {
2790
2977
  this.config = config;
2791
2978
  this.extensions = extensions;
@@ -2805,8 +2992,18 @@ var MobileAccessGateway = class {
2805
2992
  maxRateLimitKeys: config.maxRateLimitKeys
2806
2993
  });
2807
2994
  this.renewLimiter = new BoundedRateLimiter(Math.min(100, config.maxPairingAttempts * 4), config.rateLimitWindowMs, config.maxRateLimitKeys);
2808
- this.removeSessionListener = this.access.onSessionEnded((authorization) => {
2809
- this.abortSessionResources(authorization.sessionKey);
2995
+ this.probeLimiter = new BoundedRateLimiter(Math.min(1e3, config.maxDevices * 4), config.rateLimitWindowMs, config.maxRateLimitKeys);
2996
+ this.removeSessionListener = this.access.onSessionEnded((authorization, reason) => {
2997
+ if (reason === "revoked") {
2998
+ if (!this.pendingDeviceRevocations.has(authorization.deviceId)) {
2999
+ this.pendingDeviceRevocations.add(authorization.deviceId);
3000
+ this.broadcastDeviceRevoked(authorization.deviceId);
3001
+ }
3002
+ setImmediate(() => {
3003
+ this.abortSessionResources(authorization.sessionKey);
3004
+ this.pendingDeviceRevocations.delete(authorization.deviceId);
3005
+ });
3006
+ } else this.abortSessionResources(authorization.sessionKey);
2810
3007
  });
2811
3008
  this.removeExtensionContentListener = this.extensions?.onContentChanged(() => {
2812
3009
  this.broadcastExtensionChange();
@@ -3078,6 +3275,17 @@ var MobileAccessGateway = class {
3078
3275
  sessionExpiresAt: result.sessionExpiresAt
3079
3276
  }, this.tlsEnabled);
3080
3277
  }
3278
+ async handleNativeProbe(request, response) {
3279
+ if (!this.probeLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
3280
+ const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
3281
+ if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
3282
+ const result = await this.access.probe(body.deviceToken);
3283
+ sendJson(response, 200, {
3284
+ instanceId: this.config.instanceId,
3285
+ deviceId: result.deviceId,
3286
+ deviceExpiresAt: result.deviceExpiresAt
3287
+ }, this.tlsEnabled);
3288
+ }
3081
3289
  async handleLogout(request, response) {
3082
3290
  await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
3083
3291
  const authorization = this.authorize(request);
@@ -3140,8 +3348,11 @@ var MobileAccessGateway = class {
3140
3348
  }
3141
3349
  if (target.search === "" && request.method === "GET" && (target.decodedPathname === `/mobile-access/pair` || target.decodedPathname === `/mobile-access/pair.js`)) {
3142
3350
  if (!this.access.pairingStatus().open) throw new HttpError(404, "not_found");
3351
+ const languageHeader = request.headers["accept-language"];
3352
+ const locale = resolveAuthPageLocale(typeof languageHeader === "string" ? languageHeader : void 0);
3143
3353
  setSecurityHeaders(response, this.tlsEnabled);
3144
- const body = target.decodedPathname.endsWith(".js") ? PAIR_SCRIPT : PAIR_PAGE;
3354
+ response.setHeader("Vary", "Accept-Language");
3355
+ const body = target.decodedPathname.endsWith(".js") ? renderPairScript(locale) : renderPairPage(locale);
3145
3356
  response.writeHead(200, {
3146
3357
  "Content-Type": target.decodedPathname.endsWith(".js") ? "text/javascript; charset=utf-8" : "text/html; charset=utf-8",
3147
3358
  "Content-Length": Buffer.byteLength(body)
@@ -3151,8 +3362,11 @@ var MobileAccessGateway = class {
3151
3362
  }
3152
3363
  if (request.method === "GET" && (target.decodedPathname === `/mobile-access/login` || target.decodedPathname === `/mobile-access/login.js`)) {
3153
3364
  if (target.decodedPathname.endsWith(".js") && target.search !== "") throw new HttpError(400, "bad_request");
3365
+ const languageHeader = request.headers["accept-language"];
3366
+ const locale = resolveAuthPageLocale(typeof languageHeader === "string" ? languageHeader : void 0);
3154
3367
  setSecurityHeaders(response, this.tlsEnabled);
3155
- const body = target.decodedPathname.endsWith(".js") ? LOGIN_SCRIPT : LOGIN_PAGE;
3368
+ response.setHeader("Vary", "Accept-Language");
3369
+ const body = target.decodedPathname.endsWith(".js") ? renderLoginScript(locale) : renderLoginPage(locale);
3156
3370
  response.writeHead(200, {
3157
3371
  "Content-Type": target.decodedPathname.endsWith(".js") ? "text/javascript; charset=utf-8" : "text/html; charset=utf-8",
3158
3372
  "Content-Length": Buffer.byteLength(body)
@@ -3176,6 +3390,10 @@ var MobileAccessGateway = class {
3176
3390
  await this.handleNativeRenew(request, response);
3177
3391
  return;
3178
3392
  }
3393
+ if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/native-probe`) {
3394
+ await this.handleNativeProbe(request, response);
3395
+ return;
3396
+ }
3179
3397
  if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/logout`) {
3180
3398
  await this.handleLogout(request, response);
3181
3399
  return;
@@ -3612,7 +3830,7 @@ var MobileAccessGateway = class {
3612
3830
  delete headers["content-length"];
3613
3831
  delete headers["content-encoding"];
3614
3832
  delete headers.etag;
3615
- setSecurityHeaders(response, this.tlsEnabled);
3833
+ setSecurityHeaders(response, this.tlsEnabled, "proxied");
3616
3834
  response.writeHead(200, {
3617
3835
  ...headers,
3618
3836
  "Content-Type": "text/html; charset=utf-8",
@@ -3864,7 +4082,7 @@ var MobileAccessGateway = class {
3864
4082
  }
3865
4083
  bodyDone.catch(reject);
3866
4084
  });
3867
- setSecurityHeaders(response, this.tlsEnabled);
4085
+ setSecurityHeaders(response, this.tlsEnabled, "proxied");
3868
4086
  const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
3869
4087
  const cacheControl = revisionedStaticCacheControl(request$2);
3870
4088
  if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
@@ -3900,6 +4118,22 @@ var MobileAccessGateway = class {
3900
4118
  this.extensionEventRevision += 1;
3901
4119
  for (const listener of this.extensionEventListeners) listener(this.extensionEventRevision);
3902
4120
  }
4121
+ /** Fan a completed task to every phone holding this gateway's event stream. */
4122
+ broadcastTaskEvent(event) {
4123
+ if (this.closing) return;
4124
+ const payload = JSON.stringify({
4125
+ sessionId: String(event.sessionId),
4126
+ turn: Number(event.turn) || 0
4127
+ });
4128
+ for (const listener of this.taskEventListeners) listener(payload);
4129
+ }
4130
+ /** Notify only the device whose persistent credential was revoked by the Host. */
4131
+ broadcastDeviceRevoked(deviceId) {
4132
+ const listeners = this.deviceEventListeners.get(deviceId);
4133
+ if (listeners === void 0) return;
4134
+ const payload = JSON.stringify({ reason: "device_revoked" });
4135
+ for (const listener of [...listeners]) listener(payload);
4136
+ }
3903
4137
  pollLegacyCustomChanges() {
3904
4138
  if (this.extensionChangeTask !== void 0) return this.extensionChangeTask;
3905
4139
  const digestFile = async (path, fallback) => {
@@ -3931,6 +4165,10 @@ var MobileAccessGateway = class {
3931
4165
  closed = true;
3932
4166
  if (heartbeat !== void 0) clearInterval(heartbeat);
3933
4167
  this.extensionEventListeners.delete(send);
4168
+ this.taskEventListeners.delete(sendTask);
4169
+ const deviceListeners = this.deviceEventListeners.get(authorization.deviceId);
4170
+ deviceListeners?.delete(sendDevice);
4171
+ if (deviceListeners?.size === 0) this.deviceEventListeners.delete(authorization.deviceId);
3934
4172
  request.removeListener("aborted", close);
3935
4173
  response.removeListener("close", close);
3936
4174
  operation.release();
@@ -3939,6 +4177,14 @@ var MobileAccessGateway = class {
3939
4177
  if (closed || response.destroyed || response.writableEnded) return;
3940
4178
  response.write(`id: ${String(revision)}\nevent: extensions-changed\ndata: {\"revision\":${String(revision)}}\n\n`);
3941
4179
  };
4180
+ const sendTask = (payload) => {
4181
+ if (closed || response.destroyed || response.writableEnded) return;
4182
+ response.write(`event: task-notify\ndata: ${payload}\n\n`);
4183
+ };
4184
+ const sendDevice = (payload) => {
4185
+ if (closed || response.destroyed || response.writableEnded) return;
4186
+ response.write(`event: device-revoked\ndata: ${payload}\n\n`);
4187
+ };
3942
4188
  setSecurityHeaders(response, this.tlsEnabled);
3943
4189
  response.writeHead(200, {
3944
4190
  "Content-Type": "text/event-stream; charset=utf-8",
@@ -3948,6 +4194,10 @@ var MobileAccessGateway = class {
3948
4194
  });
3949
4195
  response.write("retry: 2000\n: ready\n\n");
3950
4196
  this.extensionEventListeners.add(send);
4197
+ this.taskEventListeners.add(sendTask);
4198
+ const deviceListeners = this.deviceEventListeners.get(authorization.deviceId) ?? /* @__PURE__ */ new Set();
4199
+ deviceListeners.add(sendDevice);
4200
+ this.deviceEventListeners.set(authorization.deviceId, deviceListeners);
3951
4201
  heartbeat = setInterval(() => {
3952
4202
  if (!closed && !response.destroyed && !response.writableEnded) response.write(": heartbeat\n\n");
3953
4203
  }, EXTENSION_EVENT_HEARTBEAT_MS);
@@ -4218,6 +4468,7 @@ var MobileAccessGateway = class {
4218
4468
  this.upstreamAuthRequest?.destroy();
4219
4469
  this.upstreamAuthRequest = void 0;
4220
4470
  this.removeSessionListener();
4471
+ this.pendingDeviceRevocations.clear();
4221
4472
  const accessClose = this.access.close();
4222
4473
  for (const stored of this.mobileBootBatches.values()) stored.assembly?.controller.abort();
4223
4474
  for (const request of this.activeRequests.values()) request.abort();
@@ -5766,6 +6017,84 @@ var FrpController = class {
5766
6017
  }
5767
6018
  };
5768
6019
  //#endregion
6020
+ //#region src/task-events.ts
6021
+ const TASK_EVENT_DEBOUNCE_MS = 1e3;
6022
+ function turnNumber(value) {
6023
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
6024
+ }
6025
+ function turnData(value) {
6026
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return {
6027
+ turn: 0,
6028
+ completed: false
6029
+ };
6030
+ const record = value;
6031
+ const reason = record.reason;
6032
+ const completed = typeof reason === "string" ? reason === "completed" : reason !== null && typeof reason === "object" && reason.kind === "completed";
6033
+ return {
6034
+ turn: turnNumber(record.turn),
6035
+ completed
6036
+ };
6037
+ }
6038
+ /**
6039
+ * Subscribe to completed root turns. Subagent turns are skipped so one task
6040
+ * announces once, and rapid turn boundaries collapse into a single event.
6041
+ * Returns a disposer that also drops pending debounces.
6042
+ */
6043
+ function watchTaskCompletions(ctx, options) {
6044
+ const debounceMs = options.debounceMs ?? 1e3;
6045
+ const pending = /* @__PURE__ */ new Map();
6046
+ const disposeListener = ctx.on("session/event", (session, event) => {
6047
+ if (event.type !== "turn/end") return;
6048
+ const { turn, completed } = turnData(event.data);
6049
+ if (!completed) return;
6050
+ if (session.header?.parentSession !== void 0 && session.header?.parentSession !== null) return;
6051
+ const sessionId = String(session.id);
6052
+ options.log?.("task-completed-observed", {
6053
+ sessionId,
6054
+ turn
6055
+ });
6056
+ const previous = pending.get(sessionId);
6057
+ if (previous !== void 0) clearTimeout(previous);
6058
+ pending.set(sessionId, setTimeout(() => {
6059
+ pending.delete(sessionId);
6060
+ options.log?.("task-completed-announced", {
6061
+ sessionId,
6062
+ turn
6063
+ });
6064
+ options.onTaskCompleted(Object.freeze({
6065
+ sessionId,
6066
+ turn
6067
+ }));
6068
+ }, debounceMs));
6069
+ });
6070
+ return () => {
6071
+ disposeListener();
6072
+ for (const timer of pending.values()) clearTimeout(timer);
6073
+ pending.clear();
6074
+ };
6075
+ }
6076
+ /** One subscription feeding every live gateway; gateways register on start. */
6077
+ var TaskEventHub = class {
6078
+ sinks = /* @__PURE__ */ new Set();
6079
+ /** Register a sink; returns its disposer. */
6080
+ add(sink) {
6081
+ this.sinks.add(sink);
6082
+ return () => {
6083
+ this.sinks.delete(sink);
6084
+ };
6085
+ }
6086
+ /** Fan out to a snapshot so a failing sink cannot break its siblings. */
6087
+ broadcast(event) {
6088
+ for (const sink of [...this.sinks]) try {
6089
+ sink.broadcastTaskEvent(event);
6090
+ } catch {}
6091
+ }
6092
+ /** Visible for tests. */
6093
+ get size() {
6094
+ return this.sinks.size;
6095
+ }
6096
+ };
6097
+ //#endregion
5769
6098
  //#region src/diagnostics.ts
5770
6099
  const REMOTE_ERROR_GUIDANCE = Object.freeze({
5771
6100
  component_missing: "重新安装完整插件包。",
@@ -5921,7 +6250,8 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
5921
6250
  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" });
5922
6251
  const [firewall, remoteObservation] = await Promise.all([(probes.firewall ?? defaultFirewallProbe())(snapshot.lan.port), remoteProbe]);
5923
6252
  checks.push(check("versions", "ok", "versions-current", "版本兼容", `插件 ${DSH_MOBILE_VERSION},DSH ${snapshot.dshVersion},Android App 最低 ${MINIMUM_ANDROID_APP_VERSION}。`));
5924
- if (snapshot.lan.networkError !== void 0) checks.push(check("network", "error", "network-unavailable", "局域网网卡", "已保存的网卡当前不可用。", "重新运行 dsh-mobile setup。"));
6253
+ if (snapshot.lan.configured === false) checks.push(check("network", "error", "lan-setup-required", "局域网配置", "尚未完成局域网初始化。", "返回局域网页选择网卡并完成配置。"));
6254
+ else if (snapshot.lan.networkError !== void 0) checks.push(check("network", "error", "network-unavailable", "局域网网卡", "已保存的网卡当前不可用。", "重新运行 dsh-mobile setup。"));
5925
6255
  else if (snapshot.lan.configuredInterface !== void 0) {
5926
6256
  const interfaceName = snapshot.lan.interfaceName ?? snapshot.lan.configuredInterface;
5927
6257
  checks.push(check("network", "ok", "network-interface", "局域网网卡", `正在跟随 ${interfaceName}。`, void 0, { interfaceName }));
@@ -6017,11 +6347,11 @@ const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手
6017
6347
  2. 电脑端能力 —— 手机需要读电脑文件、执行命令或访问硬件时,创建扩展:
6018
6348
  - 目录:$DSH_HOME/mobile-access/extensions/<id>/,id 用小写字母数字和连字符(如 media-remote)
6019
6349
  - extension.json:{"schemaVersion":1,"id":"<id>","name":"显示名","version":"0.1.0","description":"说明"}
6020
- - host.mjs:电脑端 Node.js 代码(可信本地代码,可读写文件、执行命令)。导出默认函数 (api) => { ... },用 api.action('名称', { input, run }) 注册动作、api.route({ method, path, handle }) 注册路由、api.effect(fn) 注册清理
6350
+ - host.mjs:电脑端 Node.js 代码(可信本地代码,可读写文件、执行命令)。导出默认函数 (api) => { ... },用 api.action('名称', { input, run }) 注册动作、api.route({ method, path, handle }) 注册路由、api.effect(fn) 注册清理。input 可以直接使用 api.schema.object(...) 等 Schemastery schema,也可以传入带 parse(value) 的适配器;两种形式都会在动作执行前校验并规范化输入。
6021
6351
  - mobile.js:手机端脚本,用 window.dshMobile.define({ apiVersion:1, id:'<id>', activate(api) { ... } }),activate 返回清理函数
6022
6352
  - mobile.css:手机端样式(可选)
6023
6353
  - assets/:手机端静态资源(可选)
6024
- - mobile.js 里用 api.host.invoke('动作名', 输入) 调 host.mjs 的 action,api.host.fetch('/路由路径') 调 route,api.host.assetUrl('相对路径') 生成与当前版本绑定的资源地址
6354
+ - mobile.js 里用 api.host.invoke('动作名', 输入) 调 host.mjs 的 action(请求会按 application/json 发送),api.host.fetch('/路由路径') 调 route,api.host.assetUrl('相对路径') 生成与当前版本绑定的资源地址
6025
6355
  - 也可以先用命令生成模板:dsh plugin --profile web exec dsh-mobile extension create <id> --name "<名称>",再在模板上改
6026
6356
 
6027
6357
  安全约束:
@@ -7646,7 +7976,7 @@ var CpolarController = class {
7646
7976
  const args = [
7647
7977
  "http",
7648
7978
  `-config=${resolve(this.options.configFile)}`,
7649
- `-region=${this.options.region ?? "cn"}`,
7979
+ ...this.options.region === void 0 ? [] : [`-region=${this.options.region}`],
7650
7980
  "-inspect-addr=false",
7651
7981
  "-redirect-https=true",
7652
7982
  "-log=stdout",
@@ -8537,6 +8867,8 @@ const VIRTUAL_INTERFACE_MARKERS = [
8537
8867
  "wsl",
8538
8868
  "zerotier"
8539
8869
  ];
8870
+ /** Route inspection is advisory; never let a system command hold setup open indefinitely. */
8871
+ const ROUTE_COMMAND_TIMEOUT_MS = 5e3;
8540
8872
  function requiredString(value, name) {
8541
8873
  if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
8542
8874
  return value;
@@ -8603,6 +8935,59 @@ function likelyVirtualInterface(name) {
8603
8935
  const normalized = name.toLowerCase().replaceAll(/[^a-z0-9]+/gu, " ");
8604
8936
  return VIRTUAL_INTERFACE_MARKERS.some((marker) => normalized.includes(marker.replaceAll("-", " "))) || /^(?:br|wg)\d*\b/u.test(normalized);
8605
8937
  }
8938
+ async function runRouteCommand(file, args) {
8939
+ return (await execFileText(file, [...args], {
8940
+ encoding: "utf8",
8941
+ timeout: ROUTE_COMMAND_TIMEOUT_MS
8942
+ })).stdout;
8943
+ }
8944
+ function uniqueLines(output) {
8945
+ return [...new Set(output.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean))];
8946
+ }
8947
+ /** Return operating-system default-route interfaces in routing preference order. */
8948
+ async function preferredLanInterfaceNames(platform = process.platform, run = runRouteCommand) {
8949
+ try {
8950
+ if (platform === "win32") return uniqueLines(await run("powershell.exe", [
8951
+ "-NoProfile",
8952
+ "-NonInteractive",
8953
+ "-Command",
8954
+ [
8955
+ "$routes = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop",
8956
+ "$ranked = $routes | Where-Object { $_.State -eq 'Alive' -and $_.NextHop -ne '0.0.0.0' } | ForEach-Object {",
8957
+ " $route = $_",
8958
+ " $adapter = Get-NetAdapter -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
8959
+ " $ip = Get-NetIPInterface -AddressFamily IPv4 -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
8960
+ " if ($adapter -and $ip -and $adapter.Status -eq 'Up' -and $adapter.HardwareInterface -eq $true -and $adapter.Virtual -ne $true) {",
8961
+ " [pscustomobject]@{ Name = $route.InterfaceAlias; Metric = [int]$route.RouteMetric + [int]$ip.InterfaceMetric }",
8962
+ " }",
8963
+ "}",
8964
+ "$ranked | Sort-Object Metric | Select-Object -ExpandProperty Name -Unique"
8965
+ ].join("; ")
8966
+ ]));
8967
+ if (platform === "linux") {
8968
+ const routes = uniqueLines(await run("ip", [
8969
+ "-o",
8970
+ "-4",
8971
+ "route",
8972
+ "show",
8973
+ "default"
8974
+ ])).map((line) => ({
8975
+ name: /(?:^|\s)dev\s+(\S+)/u.exec(line)?.[1],
8976
+ metric: Number(/(?:^|\s)metric\s+(\d+)/u.exec(line)?.[1] ?? 0)
8977
+ })).filter((route) => route.name !== void 0 && !likelyVirtualInterface(route.name)).sort((left, right) => left.metric - right.metric);
8978
+ return [...new Set(routes.map((route) => route.name))];
8979
+ }
8980
+ if (platform === "darwin") {
8981
+ const name = /^\s*interface:\s*(\S+)\s*$/mu.exec(await run("route", [
8982
+ "-n",
8983
+ "get",
8984
+ "default"
8985
+ ]))?.[1];
8986
+ return name === void 0 || likelyVirtualInterface(name) ? [] : [name];
8987
+ }
8988
+ } catch {}
8989
+ return [];
8990
+ }
8606
8991
  /** Select an active LAN, optionally by address or by a previously saved interface name. */
8607
8992
  function selectLanNetwork(requestedAddress, requestedInterface, table, preferredInterfaces = []) {
8608
8993
  const candidates = availableLanNetworks(table);
@@ -8653,6 +9038,57 @@ async function atomicWrite(file, contents) {
8653
9038
  await rename(temporary, file);
8654
9039
  await restrictPrivateFile(file);
8655
9040
  }
9041
+ /** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
9042
+ async function ensureManagedCa(setup, legacy) {
9043
+ let certPem;
9044
+ let keyPem;
9045
+ try {
9046
+ [certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
9047
+ } catch (error) {
9048
+ if (error.code !== "ENOENT") throw error;
9049
+ let migrated = false;
9050
+ if (legacy !== void 0) try {
9051
+ [certPem, keyPem] = await Promise.all([readFile(legacy.certFile, "utf8"), readFile(legacy.keyFile, "utf8")]);
9052
+ assertMatchingCa(certPem, keyPem);
9053
+ migrated = true;
9054
+ } catch (legacyError) {
9055
+ if (legacyError.code !== "ENOENT") throw legacyError;
9056
+ }
9057
+ if (!migrated) {
9058
+ const now = /* @__PURE__ */ new Date();
9059
+ const notAfter = new Date(now);
9060
+ notAfter.setFullYear(notAfter.getFullYear() + 5);
9061
+ const generated = await generate([{
9062
+ name: "commonName",
9063
+ value: "DeepSeek Harness Mobile CA"
9064
+ }], {
9065
+ keyType: "ec",
9066
+ curve: "P-256",
9067
+ algorithm: "sha256",
9068
+ notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
9069
+ notAfterDate: notAfter,
9070
+ extensions: [{
9071
+ name: "basicConstraints",
9072
+ cA: true,
9073
+ critical: true
9074
+ }, {
9075
+ name: "keyUsage",
9076
+ digitalSignature: true,
9077
+ keyCertSign: true,
9078
+ cRLSign: true,
9079
+ critical: true
9080
+ }]
9081
+ });
9082
+ certPem = generated.cert;
9083
+ keyPem = generated.private;
9084
+ }
9085
+ if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
9086
+ await Promise.all([atomicWrite(setup.caCertFile, certPem), atomicWrite(setup.caKeyFile, keyPem)]);
9087
+ }
9088
+ if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
9089
+ await Promise.all([restrictPrivateFile(setup.caCertFile), restrictPrivateFile(setup.caKeyFile)]);
9090
+ return assertMatchingCa(certPem, keyPem);
9091
+ }
8656
9092
  /** Sign and atomically install a server leaf for the interface's current address. */
8657
9093
  async function refreshManagedServerCertificate(setup, address) {
8658
9094
  await Promise.all([restrictPrivateFile(setup.tls.caCertFile), restrictPrivateFile(setup.tls.caKeyFile)]);
@@ -8719,6 +9155,79 @@ async function materializeManagedSetup(setup, table) {
8719
9155
  };
8720
9156
  }
8721
9157
  //#endregion
9158
+ //#region src/lan-setup.ts
9159
+ const FIREWALL_TCP_RULE = "DSH Mobile HTTPS";
9160
+ const FIREWALL_UDP_RULE = "DSH Mobile Discovery";
9161
+ async function runElevatedPowerShell(script) {
9162
+ await execFileText("powershell.exe", [
9163
+ "-NoProfile",
9164
+ "-NonInteractive",
9165
+ "-Command",
9166
+ [
9167
+ "$ErrorActionPreference = 'Stop'; $process = Start-Process -FilePath 'powershell.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru",
9168
+ ` -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','${Buffer.from(script, "utf16le").toString("base64")}')`,
9169
+ "; exit $process.ExitCode"
9170
+ ].join(" ")
9171
+ ], { windowsHide: true });
9172
+ }
9173
+ /** Install the two Windows inbound rules used by the managed LAN listener. */
9174
+ async function configureWindowsFirewall(port) {
9175
+ if (process.platform !== "win32") return;
9176
+ await runElevatedPowerShell([
9177
+ "$ErrorActionPreference = 'Stop'",
9178
+ `Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
9179
+ `Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
9180
+ `New-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`,
9181
+ `New-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -Direction Inbound -Action Allow -Protocol UDP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`
9182
+ ].join("; "));
9183
+ }
9184
+ /** Generate private TLS material and persist a managed setup selected by the user. */
9185
+ async function prepareManagedLanSetup(options) {
9186
+ if (!isAbsolute(options.setupFile) || !isAbsolute(options.controlFile)) throw new Error("managed LAN setup paths must be absolute");
9187
+ if (!Number.isSafeInteger(options.listenPort) || options.listenPort < 1024 || options.listenPort > 65535) throw new Error("managed LAN listenPort must be from 1024 through 65535");
9188
+ if (!Number.isSafeInteger(options.dshPort) || options.dshPort < 1024 || options.dshPort > 65535) throw new Error("managed LAN dshPort must be from 1024 through 65535");
9189
+ const directory = dirname(options.setupFile);
9190
+ const tlsDirectory = join(directory, "tls");
9191
+ await mkdir(tlsDirectory, {
9192
+ recursive: true,
9193
+ mode: 448
9194
+ });
9195
+ const managedTls = {
9196
+ mode: "managed",
9197
+ caCertFile: join(tlsDirectory, "ca.pem"),
9198
+ caKeyFile: join(tlsDirectory, "ca-key.pem"),
9199
+ certFile: join(tlsDirectory, "server-cert.pem"),
9200
+ keyFile: join(tlsDirectory, "server-key.pem")
9201
+ };
9202
+ const ca = await ensureManagedCa(managedTls, {
9203
+ certFile: join(tlsDirectory, "cert.pem"),
9204
+ keyFile: join(tlsDirectory, "key.pem")
9205
+ });
9206
+ const setup = {
9207
+ version: 2,
9208
+ networkInterface: options.network.name,
9209
+ listenPort: options.listenPort,
9210
+ upstreamOrigin: `http://127.0.0.1:${String(options.dshPort)}`,
9211
+ tls: managedTls
9212
+ };
9213
+ await refreshManagedServerCertificate(setup, options.network.address);
9214
+ const androidCertificate = join(tlsDirectory, "dsh-mobile-ca.cer");
9215
+ await writeFile(androidCertificate, ca.raw, { mode: 384 });
9216
+ await Promise.all([...Object.values(managedTls).filter((value) => value !== "managed").map((file) => restrictPrivateFile(file)), restrictPrivateFile(androidCertificate)]);
9217
+ if (options.configureFirewall) await configureWindowsFirewall(options.listenPort);
9218
+ await Promise.all([writeFile(options.setupFile, `${JSON.stringify({
9219
+ ...setup,
9220
+ tls: Object.fromEntries(Object.entries(setup.tls).map(([key, value]) => [key, typeof value === "string" ? value.replaceAll("\\", "/") : value]))
9221
+ }, null, 2)}\n`, { mode: 384 }), writeFile(options.controlFile, "{\"version\":1,\"enabled\":true}\n", { mode: 384 })]);
9222
+ await Promise.all([restrictPrivateFile(options.setupFile), restrictPrivateFile(options.controlFile)]);
9223
+ return Object.freeze({
9224
+ setup: Object.freeze(setup),
9225
+ network: Object.freeze({ ...options.network }),
9226
+ origin: `https://${options.network.address}:${String(options.listenPort)}`,
9227
+ androidCertificate
9228
+ });
9229
+ }
9230
+ //#endregion
8722
9231
  //#region src/plugin.ts
8723
9232
  /** Stable Cordis plugin name. */
8724
9233
  const name = "dsh-mobile";
@@ -8792,6 +9301,7 @@ function mapAdminError(error) {
8792
9301
  if (error instanceof Error && error.message.startsWith("vps_")) return new HttpError(409, error.message);
8793
9302
  if (error instanceof Error && error.message === "plugin_update_failed") return new HttpError(500, error.message);
8794
9303
  if (error instanceof Error && error.message.startsWith("plugin_update_")) return new HttpError(409, error.message);
9304
+ if (error instanceof Error && error.message.startsWith("lan_setup_")) return new HttpError(409, error.message);
8795
9305
  return new HttpError(500, "internal_error");
8796
9306
  }
8797
9307
  const SETUP_KEYS = /* @__PURE__ */ new Set([
@@ -8831,8 +9341,9 @@ async function loadSetup(config) {
8831
9341
  source = await readFile(resolve(config.setupFile), "utf8");
8832
9342
  } catch (error) {
8833
9343
  if (error.code === "ENOENT") return {
8834
- kind: "fixed",
8835
- config
9344
+ kind: "unconfigured",
9345
+ config,
9346
+ setupFile: resolve(config.setupFile)
8836
9347
  };
8837
9348
  throw error;
8838
9349
  }
@@ -8939,6 +9450,13 @@ async function apply(ctx, config) {
8939
9450
  const logFile = await installMobileFileLogger(ctx, stateDirectory);
8940
9451
  const logger = ctx.logger("dsh-mobile");
8941
9452
  logger.info("logging initialized file=%s", logFile);
9453
+ const taskEventHub = new TaskEventHub();
9454
+ const disposeTaskEvents = watchTaskCompletions(ctx, {
9455
+ onTaskCompleted: (event) => taskEventHub.broadcast(event),
9456
+ log(event, fields) {
9457
+ logger.info("task event=%s fields=%o", event, fields);
9458
+ }
9459
+ });
8942
9460
  const remoteDirectory = join(stateDirectory, "remote");
8943
9461
  const configuredDshHome = process.env.DSH_HOME?.trim();
8944
9462
  const releaseManager = new PluginReleaseManager({ profileDirectory: releaseProfileDirectory(ctx, configuredDshHome === void 0 || configuredDshHome === "" ? dirname(stateDirectory) : resolve(configuredDshHome), process.argv.slice(2)) });
@@ -8984,6 +9502,7 @@ async function apply(ctx, config) {
8984
9502
  await webSocketPaths.load();
8985
9503
  const blockedUpgradePaths = new BlockedUpgradePathLog();
8986
9504
  let lanGateway;
9505
+ let preparedLanSetup;
8987
9506
  const startGateway = async (candidateConfig) => {
8988
9507
  const resolved = parseGatewayConfig({
8989
9508
  ...candidateConfig,
@@ -8992,12 +9511,15 @@ async function apply(ctx, config) {
8992
9511
  const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
8993
9512
  await candidate.start();
8994
9513
  lanGateway = candidate;
9514
+ const removeTaskSink = taskEventHub.add(candidate);
8995
9515
  return { close: async () => {
8996
9516
  if (lanGateway === candidate) lanGateway = void 0;
9517
+ removeTaskSink();
8997
9518
  await candidate.close();
8998
9519
  } };
8999
9520
  };
9000
9521
  const startRuntime = async () => {
9522
+ if (loaded.kind === "unconfigured") throw new Error(preparedLanSetup === void 0 ? "lan_setup_required" : "lan_setup_restart_required");
9001
9523
  if (loaded.kind === "fixed") return startGateway(loaded.config);
9002
9524
  const following = new FollowingMobileAccessRuntime(async () => {
9003
9525
  const network = selectLanNetwork(void 0, loaded.setup.networkInterface);
@@ -9014,7 +9536,13 @@ async function apply(ctx, config) {
9014
9536
  await following.initialize(2e3);
9015
9537
  return following;
9016
9538
  };
9017
- const lanController = new MobileAccessGatewayController(new JsonMobileAccessControlStore(parseControlFile(config.controlFile), config.initiallyEnabled), startRuntime);
9539
+ const lanControlFile = parseControlFile(config.controlFile);
9540
+ const lanControlStore = new JsonMobileAccessControlStore(lanControlFile, config.initiallyEnabled);
9541
+ if (loaded.kind === "unconfigured" && (await lanControlStore.load()).enabled) await lanControlStore.save({
9542
+ version: 1,
9543
+ enabled: false
9544
+ });
9545
+ const lanController = new MobileAccessGatewayController(lanControlStore, startRuntime);
9018
9546
  const remoteDeviceFile = join(remoteDirectory, "devices.json");
9019
9547
  const legacyCpolarDeviceFile = join(remoteDirectory, "cpolar", "devices.json");
9020
9548
  if (initialRemoteProvider === "cpolar") try {
@@ -9048,7 +9576,6 @@ async function apply(ctx, config) {
9048
9576
  store: cpolarStore,
9049
9577
  executable: cpolarComponent.executable,
9050
9578
  configFile: cpolarComponent.configFile,
9051
- region: "cn",
9052
9579
  createGateway: createRemoteGateway
9053
9580
  }),
9054
9581
  frp: new FrpController({
@@ -9061,16 +9588,51 @@ async function apply(ctx, config) {
9061
9588
  };
9062
9589
  const remoteProviders = new RemoteProviderCoordinator(initialRemoteProvider, remoteControllers, remoteProviderStore);
9063
9590
  const remoteController = () => remoteProviders.controller();
9591
+ for (const provider of [
9592
+ "tailscale",
9593
+ "cpolar",
9594
+ "frp"
9595
+ ]) {
9596
+ const controller = remoteControllers[provider];
9597
+ taskEventHub.add({ broadcastTaskEvent: (event) => {
9598
+ controller.gateway()?.broadcastTaskEvent(event);
9599
+ } });
9600
+ }
9064
9601
  const remotePayload = () => remoteControlPayload(remoteProviders.selected, remoteController().status(), remoteController().gateway(), {
9065
9602
  tailscale: remoteControllers.tailscale.status(),
9066
9603
  cpolar: remoteControllers.cpolar.status(),
9067
9604
  frp: remoteControllers.frp.status()
9068
9605
  }, cpolarComponent.status(), frpComponent.status(), frpConfig.status());
9069
9606
  const lanPayload = () => ({
9607
+ ...loaded.kind === "unconfigured" ? {
9608
+ configured: preparedLanSetup !== void 0,
9609
+ restartRequired: preparedLanSetup !== void 0
9610
+ } : {},
9070
9611
  running: lanController.isRunning(),
9071
9612
  origin: lanGateway?.address().origin,
9613
+ ...preparedLanSetup === void 0 ? {} : { pendingOrigin: preparedLanSetup.origin },
9072
9614
  ...lanGateway === void 0 ? {} : { extensions: lanGateway.extensionStatus() }
9073
9615
  });
9616
+ const lanSetupPayload = async () => {
9617
+ if (loaded.kind !== "unconfigured" || preparedLanSetup !== void 0) return lanPayload();
9618
+ const networks = availableLanNetworks();
9619
+ const preferred = await preferredLanInterfaceNames();
9620
+ let recommendedAddress;
9621
+ try {
9622
+ recommendedAddress = selectLanNetwork(void 0, void 0, void 0, preferred).address;
9623
+ } catch {}
9624
+ return {
9625
+ ...lanPayload(),
9626
+ networks: networks.map((network) => ({
9627
+ name: network.name,
9628
+ address: network.address,
9629
+ cidr: network.cidr,
9630
+ recommended: network.address === recommendedAddress
9631
+ })),
9632
+ listenPort: 3443,
9633
+ windowsFirewall: process.platform === "win32"
9634
+ };
9635
+ };
9074
9636
  const diagnosticsPayload = async () => {
9075
9637
  let interfaceName;
9076
9638
  let networkError;
@@ -9083,6 +9645,7 @@ async function apply(ctx, config) {
9083
9645
  return collectConnectionDiagnostics({
9084
9646
  dshVersion,
9085
9647
  lan: {
9648
+ configured: loaded.kind !== "unconfigured" || preparedLanSetup !== void 0,
9086
9649
  running: lanController.isRunning(),
9087
9650
  ...lanGateway === void 0 ? {} : {
9088
9651
  origin: lanGateway.address().origin,
@@ -9117,6 +9680,10 @@ async function apply(ctx, config) {
9117
9680
  sendJson(response, 200, lanPayload(), false);
9118
9681
  return;
9119
9682
  }
9683
+ if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/lan/setup`) {
9684
+ sendJson(response, 200, await lanSetupPayload(), false);
9685
+ return;
9686
+ }
9120
9687
  if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/diagnostics`) {
9121
9688
  sendJson(response, 200, await diagnosticsPayload(), false);
9122
9689
  return;
@@ -9133,10 +9700,37 @@ async function apply(ctx, config) {
9133
9700
  if (request.method === "POST" && lanControl) {
9134
9701
  const body = await readJsonObject(request, 4096);
9135
9702
  if (typeof body.running !== "boolean") throw new HttpError(400, "bad_request");
9703
+ if (body.running && loaded.kind === "unconfigured") throw new HttpError(409, preparedLanSetup === void 0 ? "lan_setup_required" : "lan_setup_restart_required");
9136
9704
  await lanController.setRunning(body.running);
9137
9705
  sendJson(response, 200, lanPayload(), false);
9138
9706
  return;
9139
9707
  }
9708
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/lan/setup`) {
9709
+ const body = await readJsonObject(request, 4096);
9710
+ if (loaded.kind !== "unconfigured") throw new HttpError(409, "lan_setup_already_configured");
9711
+ if (preparedLanSetup !== void 0) {
9712
+ sendJson(response, 200, await lanSetupPayload(), false);
9713
+ return;
9714
+ }
9715
+ if (body.confirm !== true || typeof body.address !== "string") throw new HttpError(400, "bad_request");
9716
+ const network = availableLanNetworks().find((candidate) => candidate.address === body.address);
9717
+ if (network === void 0) throw new HttpError(409, "lan_setup_network_unavailable");
9718
+ try {
9719
+ preparedLanSetup = await prepareManagedLanSetup({
9720
+ setupFile: loaded.setupFile,
9721
+ controlFile: lanControlFile,
9722
+ network,
9723
+ listenPort: 3443,
9724
+ dshPort: ctx.webServer.port,
9725
+ configureFirewall: body.configureFirewall !== false
9726
+ });
9727
+ } catch (error) {
9728
+ logger.error("managed LAN setup failed: %s", error instanceof Error ? error.stack ?? error.message : String(error));
9729
+ throw new HttpError(409, "lan_setup_failed");
9730
+ }
9731
+ sendJson(response, 200, await lanSetupPayload(), false);
9732
+ return;
9733
+ }
9140
9734
  if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/control`) {
9141
9735
  sendJson(response, 200, remotePayload(), false);
9142
9736
  return;
@@ -9403,6 +9997,7 @@ async function apply(ctx, config) {
9403
9997
  await settleCleanupSteps([
9404
9998
  unregister,
9405
9999
  disposeMobileCommand,
10000
+ disposeTaskEvents,
9406
10001
  async () => {
9407
10002
  const failures = (await Promise.allSettled(Object.values(remoteControllers).map((controller) => controller.close()))).filter((result) => result.status === "rejected").map((result) => result.reason);
9408
10003
  if (failures.length > 0) throw new AggregateError(failures, "remote provider cleanup failed");
@@ -9420,6 +10015,7 @@ async function apply(ctx, config) {
9420
10015
  await settleCleanupSteps([
9421
10016
  unregister,
9422
10017
  disposeMobileCommand,
10018
+ disposeTaskEvents,
9423
10019
  async () => {
9424
10020
  const failures = (await Promise.allSettled(Object.values(remoteControllers).map((controller) => controller.close()))).filter((result) => result.status === "rejected").map((result) => result.reason);
9425
10021
  if (failures.length > 0) throw new AggregateError(failures, "remote provider cleanup failed");
@@ -9432,6 +10028,6 @@ async function apply(ctx, config) {
9432
10028
  }, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
9433
10029
  }
9434
10030
  //#endregion
9435
- export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CSRF_COOKIE, CSRF_HEADER, Config, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, RequestTrustPolicy, SESSION_COOKIE, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, inject, isGloballyRoutableIpv4, isLoopbackAddress, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, parseAuthority, parseCidr, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseRemoteProviderState, resolveAuthority, rewriteMobileIndex, validateFrpPublicOrigin, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateWebSocketPath };
10031
+ export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CSRF_COOKIE, CSRF_HEADER, Config, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, inject, isGloballyRoutableIpv4, isLoopbackAddress, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, parseAuthority, parseCidr, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseRemoteProviderState, resolveAuthority, rewriteMobileIndex, validateFrpPublicOrigin, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateWebSocketPath, watchTaskCompletions };
9436
10032
 
9437
10033
  //# sourceMappingURL=index.mjs.map