dsh-mobile 0.2.2 → 0.3.0

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
@@ -13,11 +13,12 @@ import { createServer as createServer$1, request } from "node:http";
13
13
  import { createServer as createServer$2 } from "node:https";
14
14
  import { Transform } from "node:stream";
15
15
  import { pipeline } from "node:stream/promises";
16
- import { createGzip } from "node:zlib";
16
+ import { createGzip, gzip } from "node:zlib";
17
17
  import Bonjour from "bonjour-service";
18
18
  import * as QRCode from "qrcode";
19
19
  import { Service } from "@deepseek-ai/cordis";
20
20
  import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm/message";
21
+ import { lookup } from "node:dns/promises";
21
22
  import { generate } from "selfsigned";
22
23
  //#region src/access.ts
23
24
  /** Stable error categories converted to deliberately terse HTTP responses. */
@@ -708,7 +709,8 @@ const SUPPORTED_DSH_VERSIONS = Object.freeze([
708
709
  "0.1.0-rc.5",
709
710
  "0.1.0-rc.6",
710
711
  "0.1.0-rc.7",
711
- "0.1.1-rc.2"
712
+ "0.1.1-rc.2",
713
+ "0.1.2-alpha.1"
712
714
  ]);
713
715
  /**
714
716
  * Reject an unverified DeepSeek Harness Host before opening the LAN listener.
@@ -720,10 +722,10 @@ function assertSupportedDshVersion(version) {
720
722
  }
721
723
  //#endregion
722
724
  //#region src/private-file.ts
723
- const execFile$2 = promisify(execFile);
725
+ const execFile$3 = promisify(execFile);
724
726
  let userSidTask;
725
727
  async function currentWindowsUserSid() {
726
- userSidTask ??= execFile$2("whoami.exe", [
728
+ userSidTask ??= execFile$3("whoami.exe", [
727
729
  "/user",
728
730
  "/fo",
729
731
  "csv",
@@ -743,7 +745,7 @@ async function restrictPrivateFile(file, mode = 384) {
743
745
  await chmod(file, mode);
744
746
  if (process.platform !== "win32") return;
745
747
  const userSid = await currentWindowsUserSid();
746
- await execFile$2("icacls.exe", [
748
+ await execFile$3("icacls.exe", [
747
749
  file,
748
750
  "/inheritance:r",
749
751
  "/grant:r",
@@ -985,7 +987,11 @@ const CSRF_COOKIE = "dsh_ma_csrf";
985
987
  const CSRF_HEADER = "x-dsh-mobile-csrf";
986
988
  const LOCAL_ADMIN_PREFIX = "/api/mobile-access";
987
989
  const AUTH_PREFIX = "/mobile-access";
988
- const WS_PATHS = /* @__PURE__ */ new Set(["/api/events.mux", "/api/events.host"]);
990
+ const WS_PATHS = /* @__PURE__ */ new Set([
991
+ "/api/events.mux",
992
+ "/api/events.host",
993
+ "/api/remote.mux"
994
+ ]);
989
995
  /** Terse request failure safe to expose without internal diagnostics. */
990
996
  var HttpError = class extends Error {
991
997
  status;
@@ -1146,6 +1152,13 @@ function assertLocalAdminTrust(request, requireBrowserOrigin) {
1146
1152
  if (requireBrowserOrigin && site !== void 0 && (origin === void 0 || site !== "same-origin")) throw new HttpError(403, "forbidden");
1147
1153
  }
1148
1154
  //#endregion
1155
+ //#region src/version.ts
1156
+ const manifest = createRequire(import.meta.url)("../package.json");
1157
+ /** Version of the installed DSH Mobile plugin package. */
1158
+ const DSH_MOBILE_VERSION = typeof manifest.version === "string" ? manifest.version : "unknown";
1159
+ /** Oldest Android App release supported by this plugin generation. */
1160
+ const MINIMUM_ANDROID_APP_VERSION = "0.2.2";
1161
+ //#endregion
1149
1162
  //#region src/computer-images.ts
1150
1163
  const MAX_ENTRIES = 500;
1151
1164
  const MAX_IMAGE_BYTES = 20971520;
@@ -1751,11 +1764,33 @@ const DISCOVERY_INTERVAL_MS = 3e3;
1751
1764
  const MDNS_SERVICE_TYPE = "dsh-mobile";
1752
1765
  const MOBILE_LAYOUT_MODULE = "@deepseek-ai/dsh-client-ui-layout";
1753
1766
  const MOBILE_LAYOUT_PATH = `${AUTH_PREFIX}/mobile-layout.js`;
1767
+ const MOBILE_BOOT_BATCH_PREFIX = `${AUTH_PREFIX}/mobile-boot/`;
1768
+ const MAX_MOBILE_BOOT_BATCH_BYTES = 33554432;
1769
+ const MAX_MOBILE_BOOT_ENTRY_BYTES = 8388608;
1770
+ const MAX_MOBILE_BOOT_BATCHES = 8;
1771
+ const UPSTREAM_AUTH_REFRESH_MARGIN_MS = 6e4;
1772
+ const UPSTREAM_COOKIE_PAIR = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+=[\x21-\x3A\x3C-\x7E]*$/u;
1773
+ const CUSTOM_STYLE_FALLBACK = "/* Add mobile overrides in the DSH home mobile-access/mobile.css file. */\n";
1774
+ const CUSTOM_SCRIPT_FALLBACK = "window.dshMobile?.register(() => undefined)\n";
1754
1775
  const MOBILE_CLIENT_MODULE = "dsh-mobile";
1755
1776
  const CONNECTION_MODULE = "@deepseek-ai/dsh-client-connection";
1756
1777
  const RUNTIME_MODULE = "@deepseek-ai/dsh-client-runtime";
1778
+ const RENDERER_MODULE = "@deepseek-ai/dsh-client-ui-renderer";
1779
+ const SIDEBAR_MODULE = "@deepseek-ai/dsh-client-ui-sidebar";
1757
1780
  const SETTINGS_MODULE = "@deepseek-ai/dsh-client-ui-settings";
1758
- const MOBILE_LAYOUT_DEPENDENCIES = Object.freeze([RUNTIME_MODULE, "@deepseek-ai/dsh-client-ui-theme"]);
1781
+ const MOBILE_LAYOUT_DEPENDENCY_PROFILES = Object.freeze([Object.freeze({
1782
+ slots: RUNTIME_MODULE,
1783
+ dependencies: Object.freeze([RUNTIME_MODULE, "@deepseek-ai/dsh-client-ui-theme"])
1784
+ }), Object.freeze({
1785
+ slots: RENDERER_MODULE,
1786
+ dependencies: Object.freeze([
1787
+ "@deepseek-ai/dsh-client-locale",
1788
+ RENDERER_MODULE,
1789
+ "@deepseek-ai/dsh-client-ui-session",
1790
+ "@deepseek-ai/dsh-client-ui-theme"
1791
+ ])
1792
+ })]);
1793
+ 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})};})();`;
1759
1794
  const PAIR_PAGE = `<!doctype html>
1760
1795
  <html lang="en">
1761
1796
  <meta charset="utf-8">
@@ -1773,6 +1808,7 @@ const PAIR_PAGE = `<!doctype html>
1773
1808
  <script src="/mobile-access/pair.js" defer><\/script>
1774
1809
  </html>
1775
1810
  `;
1811
+ const gzipBuffer = promisify(gzip);
1776
1812
  function ensureMobileViewport(html) {
1777
1813
  const match = /<meta\b(?=[^>]*\bname\s*=\s*["']viewport["'])[^>]*>/iu.exec(html);
1778
1814
  if (match === null) {
@@ -1786,18 +1822,24 @@ function ensureMobileViewport(html) {
1786
1822
  const next = content.test(match[0]) ? match[0].replace(content, (_whole, quote, value) => `content=${quote}${value},viewport-fit=cover${quote}`) : match[0].replace(/\s*\/?>$/u, " content=\"width=device-width,initial-scale=1,viewport-fit=cover\">");
1787
1823
  return `${html.slice(0, match.index)}${next}${html.slice(match.index + match[0].length)}`;
1788
1824
  }
1789
- function orderAuthenticatedSettings(entries) {
1825
+ function orderAuthenticatedSettings(entries, slotsProvider) {
1790
1826
  const mobile = entries.filter((entry) => entry !== null && typeof entry === "object" && entry.id === MOBILE_CLIENT_MODULE);
1791
1827
  const settings = entries.filter((entry) => entry !== null && typeof entry === "object" && entry.id === SETTINGS_MODULE);
1792
1828
  if (mobile.length === 0 || settings.length === 0) return;
1793
1829
  if (mobile.length !== 1 || settings.length !== 1) throw new Error("upstream DSH mobile settings graph is ambiguous");
1794
- if (!Array.isArray(mobile[0]?.inject) || !mobile[0].inject.includes(CONNECTION_MODULE) || !mobile[0].inject.includes(RUNTIME_MODULE)) throw new Error("dsh-mobile client has unsupported dependencies");
1795
- if (!Array.isArray(settings[0]?.inject) || !settings[0].inject.includes(CONNECTION_MODULE) || !settings[0].inject.includes(RUNTIME_MODULE)) throw new Error("upstream DSH settings module has unsupported dependencies");
1796
- mobile[0].inject = [CONNECTION_MODULE, RUNTIME_MODULE];
1830
+ if (!Array.isArray(mobile[0]?.inject) || !mobile[0].inject.includes(CONNECTION_MODULE) || !mobile[0].inject.includes(SIDEBAR_MODULE)) throw new Error("dsh-mobile client has unsupported dependencies");
1831
+ if (!Array.isArray(settings[0]?.inject) || !settings[0].inject.includes(CONNECTION_MODULE)) throw new Error("upstream DSH settings module has unsupported dependencies");
1832
+ mobile[0].inject = [CONNECTION_MODULE, slotsProvider];
1797
1833
  if (!settings[0].inject.includes(MOBILE_CLIENT_MODULE)) settings[0].inject = [...settings[0].inject, MOBILE_CLIENT_MODULE];
1798
1834
  }
1799
- /** Replace only DSH's layout client module while retaining its complete plugin graph. */
1800
- function rewriteMobileIndex(html) {
1835
+ function revisionedMobileBatchPath(entries) {
1836
+ const key = createHash("sha256").update(DSH_MOBILE_VERSION).update(JSON.stringify(entries)).digest("hex");
1837
+ return {
1838
+ key,
1839
+ path: `${MOBILE_BOOT_BATCH_PREFIX}${key}.js`
1840
+ };
1841
+ }
1842
+ function rewriteMobileIndexWithBatch(html) {
1801
1843
  const assignment = /(?:window\.__DSH_BOOT__|globalThis\["__DSH_BOOT__"\])\s*=\s*/u.exec(html);
1802
1844
  if (assignment?.index === void 0) throw new Error("upstream DSH index has no boot manifest");
1803
1845
  const start = assignment.index;
@@ -1810,12 +1852,55 @@ function rewriteMobileIndex(html) {
1810
1852
  const entries = parsed.entries;
1811
1853
  const layout = entries.filter((entry) => entry !== null && typeof entry === "object" && entry.id === MOBILE_LAYOUT_MODULE);
1812
1854
  if (layout.length !== 1 || typeof layout[0]?.url !== "string" || typeof layout[0].rev !== "string") throw new Error("upstream DSH boot manifest has no unique layout module");
1813
- if (!Array.isArray(layout[0].inject) || MOBILE_LAYOUT_DEPENDENCIES.some((dependency) => !layout[0]?.inject?.includes(dependency))) throw new Error("upstream DSH layout module has unsupported dependencies");
1855
+ if (!Array.isArray(layout[0].inject)) throw new Error("upstream DSH layout module has unsupported dependencies");
1856
+ const dependencyProfile = MOBILE_LAYOUT_DEPENDENCY_PROFILES.find((profile) => profile.dependencies.every((dependency) => layout[0]?.inject?.includes(dependency)));
1857
+ if (dependencyProfile === void 0) throw new Error("upstream DSH layout module has unsupported dependencies");
1814
1858
  layout[0].url = MOBILE_LAYOUT_PATH;
1815
- layout[0].rev = "dsh-mobile-layout-v1";
1816
- orderAuthenticatedSettings(entries);
1817
- const replacement = `window.__DSH_MOBILE_FRONTEND__="dedicated";${assignment[0]}${JSON.stringify(parsed)};`;
1818
- return ensureMobileViewport(`${html.slice(0, start)}${replacement}${html.slice(scriptEnd)}`);
1859
+ layout[0].rev = `dsh-mobile-layout-${DSH_MOBILE_VERSION}`;
1860
+ orderAuthenticatedSettings(entries, dependencyProfile.slots);
1861
+ let mobileBatch;
1862
+ if (parsed.batches !== void 0) {
1863
+ if (!Array.isArray(parsed.batches)) throw new Error("upstream DSH boot manifest batches are malformed");
1864
+ const batches = parsed.batches;
1865
+ const entryById = new Map(entries.map((entry) => [entry.id, entry]));
1866
+ if (entryById.size !== entries.length) throw new Error("upstream DSH boot manifest has duplicate entries");
1867
+ const layoutBatches = [];
1868
+ for (const batch of batches) {
1869
+ if (batch === null || typeof batch !== "object" || batch.phase !== "bootstrap" && batch.phase !== "application" || typeof batch.url !== "string" || typeof batch.rev !== "string" || !Array.isArray(batch.entries) || batch.entries.length === 0 || batch.entries.some((id) => typeof id !== "string" || !entryById.has(id))) throw new Error("upstream DSH boot manifest batches are malformed");
1870
+ if (batch.entries.includes(MOBILE_LAYOUT_MODULE)) layoutBatches.push(batch);
1871
+ }
1872
+ if (layoutBatches.length !== 1 || layoutBatches[0]?.phase !== "application") throw new Error("upstream DSH boot manifest has no unique application layout batch");
1873
+ const layoutBatch = layoutBatches[0];
1874
+ const planEntries = layoutBatch.entries.map((id) => {
1875
+ const entry = entryById.get(id);
1876
+ if (entry === void 0 || typeof entry.url !== "string" || typeof entry.rev !== "string") throw new Error("upstream DSH boot manifest batches are malformed");
1877
+ return Object.freeze({
1878
+ id,
1879
+ url: entry.url,
1880
+ rev: entry.rev
1881
+ });
1882
+ });
1883
+ const revision = revisionedMobileBatchPath(planEntries);
1884
+ layoutBatch.url = revision.path;
1885
+ layoutBatch.rev = revision.key;
1886
+ mobileBatch = Object.freeze({
1887
+ ...revision,
1888
+ entries: Object.freeze(planEntries)
1889
+ });
1890
+ parsed.rev = createHash("sha256").update(JSON.stringify({
1891
+ entries,
1892
+ batches
1893
+ })).digest("hex").slice(0, 16);
1894
+ }
1895
+ const replacement = `${MOBILE_CSRF_FETCH_BOOTSTRAP}window.__DSH_MOBILE_FRONTEND__="dedicated";${assignment[0]}${JSON.stringify(parsed)};`;
1896
+ return Object.freeze({
1897
+ html: ensureMobileViewport(`${html.slice(0, start)}${replacement}${html.slice(scriptEnd)}`),
1898
+ ...mobileBatch === void 0 ? {} : { batch: mobileBatch }
1899
+ });
1900
+ }
1901
+ /** Replace only DSH's layout client module while retaining its complete plugin graph. */
1902
+ function rewriteMobileIndex(html) {
1903
+ return rewriteMobileIndexWithBatch(html).html;
1819
1904
  }
1820
1905
  const PAIR_SCRIPT = `(() => {
1821
1906
  const form = document.getElementById('pair-form')
@@ -2083,6 +2168,20 @@ function shouldCompressResponse(request, response) {
2083
2168
  const pathname = request.url?.split("?", 1)[0] ?? "";
2084
2169
  return (request.method === "GET" && (pathname.startsWith("/plugins/") || pathname.startsWith("/assets/")) || request.method === "POST" && pathname === SESSION_HISTORY_PATH) && response.statusCode === 200 && request.headers.range === void 0 && response.headers["content-range"] === void 0 && response.headers["content-encoding"] === void 0 && acceptsGzip(request.headers["accept-encoding"]) && isCompressibleContentType(response.headers["content-type"]);
2085
2170
  }
2171
+ function revisionedStaticCacheControl(request) {
2172
+ if (request.method !== "GET" && request.method !== "HEAD") return void 0;
2173
+ let target;
2174
+ try {
2175
+ target = new URL(request.url ?? "/", "https://dsh-mobile.invalid");
2176
+ } catch {
2177
+ return;
2178
+ }
2179
+ const revision = target.searchParams.get("rev");
2180
+ const hasRevision = revision !== null && /^[a-z0-9_-]{4,128}$/iu.test(revision);
2181
+ const hashedAsset = /^\/assets\/.*-[a-z0-9_-]{8,}\.[a-z0-9]+$/iu.test(target.pathname);
2182
+ if (!(target.pathname.startsWith("/plugins/") && hasRevision) && !(target.pathname.startsWith("/assets/") && (hasRevision || hashedAsset))) return void 0;
2183
+ return "private, max-age=31536000, immutable";
2184
+ }
2086
2185
  function isJsonRecord(value) {
2087
2186
  return typeof value === "object" && value !== null && !Array.isArray(value);
2088
2187
  }
@@ -2177,6 +2276,9 @@ function extensionTarget(pathname) {
2177
2276
  path: `/${parts.join("/")}`.replace(/\/{2,}/gu, "/")
2178
2277
  };
2179
2278
  }
2279
+ function mobileBootBatchKey(pathname) {
2280
+ return new RegExp(`^${MOBILE_BOOT_BATCH_PREFIX.replaceAll("/", "\\/")}([a-f\\d]{64})\\.js$`, "u").exec(pathname)?.[1];
2281
+ }
2180
2282
  async function readBoundedBody(request, maximum) {
2181
2283
  const declared = request.headers["content-length"];
2182
2284
  if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > maximum)) throw new HttpError(413, "payload_too_large");
@@ -2226,6 +2328,7 @@ function extensionContentType(path) {
2226
2328
  var MobileAccessGateway = class {
2227
2329
  config;
2228
2330
  extensions;
2331
+ upstreamAuthenticatedUrl;
2229
2332
  access;
2230
2333
  listenerTlsEnabled;
2231
2334
  tlsEnabled;
@@ -2239,15 +2342,21 @@ var MobileAccessGateway = class {
2239
2342
  connectedSockets = /* @__PURE__ */ new Set();
2240
2343
  activeRequests = /* @__PURE__ */ new Map();
2241
2344
  activeWebSockets = /* @__PURE__ */ new Map();
2345
+ mobileBootBatches = /* @__PURE__ */ new Map();
2346
+ upstreamCookie;
2347
+ upstreamCookieExpiresAt = 0;
2348
+ upstreamCookieTask;
2349
+ upstreamAuthRequest;
2242
2350
  nextOperationId = 1;
2243
2351
  closing = false;
2244
2352
  started = false;
2245
2353
  closeTask;
2246
2354
  removeSessionListener;
2247
2355
  renewLimiter;
2248
- constructor(config, store, extensions) {
2356
+ constructor(config, store, extensions, upstreamAuthenticatedUrl) {
2249
2357
  this.config = config;
2250
2358
  this.extensions = extensions;
2359
+ this.upstreamAuthenticatedUrl = upstreamAuthenticatedUrl;
2251
2360
  this.listenerTlsEnabled = config.tls.mode === "provided";
2252
2361
  this.tlsEnabled = config.publicTls;
2253
2362
  this.access = new AccessController(store, {
@@ -2543,13 +2652,24 @@ var MobileAccessGateway = class {
2543
2652
  }
2544
2653
  async handleExternalRequest(request, response) {
2545
2654
  const target = parseRequestTarget(request.url);
2546
- assertExternalTrust(request, this.requirePolicy(), request.method !== "GET" && request.method !== "HEAD");
2655
+ const policy = this.requirePolicy();
2656
+ const isMutation = request.method !== "GET" && request.method !== "HEAD";
2657
+ assertExternalTrust(request, policy, isMutation);
2547
2658
  if (target.decodedPathname === "/api/mobile-access" || target.decodedPathname.startsWith(`/api/mobile-access/`)) throw new HttpError(404, "not_found");
2548
2659
  if (request.method === "TRACE" || request.method === "CONNECT") throw new HttpError(405, "method_not_allowed");
2549
2660
  if (target.search === "" && request.method === "GET" && target.decodedPathname === `/mobile-access/health`) {
2550
2661
  sendJson(response, 200, { ok: true }, this.tlsEnabled);
2551
2662
  return;
2552
2663
  }
2664
+ if (target.search === "" && request.method === "GET" && target.decodedPathname === `/mobile-access/metadata`) {
2665
+ sendJson(response, 200, {
2666
+ version: 1,
2667
+ pluginVersion: DSH_MOBILE_VERSION,
2668
+ minimumAndroidAppVersion: MINIMUM_ANDROID_APP_VERSION,
2669
+ discoveryProtocol: DISCOVERY_PROTOCOL
2670
+ }, this.tlsEnabled);
2671
+ return;
2672
+ }
2553
2673
  if (target.search === "" && request.method === "GET" && target.decodedPathname === `/mobile-access/discovery`) {
2554
2674
  sendJson(response, 200, {
2555
2675
  deviceName: discoveryDeviceName(),
@@ -2617,22 +2737,22 @@ var MobileAccessGateway = class {
2617
2737
  const computerImages = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-images`;
2618
2738
  const computerImage = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-image`;
2619
2739
  const requestedExtension = extensionTarget(target.decodedPathname);
2740
+ const requestedMobileBootBatch = mobileBootBatchKey(target.decodedPathname);
2620
2741
  const customAsset = request.method === "GET" ? target.decodedPathname === `/mobile-access/custom.css` ? {
2621
2742
  file: this.config.customCssFile,
2622
2743
  contentType: "text/css; charset=utf-8",
2623
- fallback: "/* Add mobile overrides in the DSH home mobile-access/mobile.css file. */\n"
2744
+ fallback: CUSTOM_STYLE_FALLBACK
2624
2745
  } : target.decodedPathname === `/mobile-access/custom.js` ? {
2625
2746
  file: this.config.customScriptFile,
2626
2747
  contentType: "text/javascript; charset=utf-8",
2627
- fallback: "window.dshMobile?.register(() => undefined)\n"
2748
+ fallback: CUSTOM_SCRIPT_FALLBACK
2628
2749
  } : target.decodedPathname === MOBILE_LAYOUT_PATH ? {
2629
2750
  file: this.config.mobileLayoutFile,
2630
2751
  contentType: "text/javascript; charset=utf-8",
2631
2752
  fallback: void 0
2632
2753
  } : void 0 : void 0;
2633
- if (customAsset === void 0 && !computerImages && !computerImage && extensionTarget(target.decodedPathname) === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
2754
+ if (customAsset === void 0 && requestedMobileBootBatch === void 0 && !computerImages && !computerImage && extensionTarget(target.decodedPathname) === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
2634
2755
  if (request.method !== "GET" && request.method !== "HEAD" && request.method !== "POST" && requestedExtension?.kind !== "route") throw new HttpError(405, "method_not_allowed");
2635
- if (request.method === "POST" && target.decodedPathname !== "/api" && !target.decodedPathname.startsWith("/api/") && requestedExtension?.kind !== "action" && requestedExtension?.kind !== "route") throw new HttpError(405, "method_not_allowed");
2636
2756
  let authorization;
2637
2757
  try {
2638
2758
  authorization = this.authorize(request);
@@ -2652,11 +2772,16 @@ var MobileAccessGateway = class {
2652
2772
  }
2653
2773
  throw error;
2654
2774
  }
2775
+ if (isMutation) this.requireCsrf(request, authorization);
2655
2776
  const extension = requestedExtension;
2656
2777
  if (extension !== void 0) {
2657
2778
  await this.handleExtensionRequest(extension, target, request, response, authorization);
2658
2779
  return;
2659
2780
  }
2781
+ if (requestedMobileBootBatch !== void 0) {
2782
+ await this.serveMobileBootBatch(requestedMobileBootBatch, request, response, authorization);
2783
+ return;
2784
+ }
2660
2785
  if (customAsset !== void 0) {
2661
2786
  const operation = this.allocateRequest(authorization, response, {});
2662
2787
  try {
@@ -2734,15 +2859,30 @@ var MobileAccessGateway = class {
2734
2859
  async handleExtensionRequest(targetInfo, target, request, response, authorization) {
2735
2860
  const extensions = this.extensions;
2736
2861
  if (extensions === void 0) throw new HttpError(404, "not_found");
2737
- if (request.method !== "GET" && request.method !== "HEAD") this.requireCsrf(request, authorization);
2738
2862
  if (targetInfo.kind === "manifest") {
2739
2863
  if (request.method !== "GET" && request.method !== "HEAD") throw new HttpError(405, "method_not_allowed");
2740
2864
  const operation = this.allocateRequest(authorization, response, {});
2741
2865
  try {
2742
2866
  operation.signal.throwIfAborted();
2867
+ const customRevision = async (file, fallback) => {
2868
+ let source;
2869
+ try {
2870
+ source = await readFile(file, { signal: operation.signal });
2871
+ } catch (error) {
2872
+ if (error.code !== "ENOENT") throw error;
2873
+ source = Buffer.from(fallback);
2874
+ }
2875
+ if (source.byteLength > 262144) throw new HttpError(413, "payload_too_large");
2876
+ return createHash("sha256").update(source).digest("hex");
2877
+ };
2878
+ const [scriptRevision, styleRevision] = await Promise.all([customRevision(this.config.customScriptFile, CUSTOM_SCRIPT_FALLBACK), customRevision(this.config.customCssFile, CUSTOM_STYLE_FALLBACK)]);
2743
2879
  const body = Buffer.from(JSON.stringify({
2744
2880
  protocol: 1,
2745
- extensions: extensions.manifest()
2881
+ extensions: extensions.manifest(),
2882
+ legacy: {
2883
+ scriptRevision,
2884
+ styleRevision
2885
+ }
2746
2886
  }));
2747
2887
  const etag = createHash("sha256").update(body).update(extensions.contentDigest()).digest("hex");
2748
2888
  if (headerValue(request.headers, "if-none-match") === etag) {
@@ -2897,11 +3037,81 @@ var MobileAccessGateway = class {
2897
3037
  }
2898
3038
  await pipeline(result.body, new ByteLimitTransform(4194304), response);
2899
3039
  }
3040
+ /** Exchange DSH's process-local launch token for an authority-bound cookie kept inside this gateway. */
3041
+ async upstreamCookieHeader() {
3042
+ if (this.upstreamAuthenticatedUrl === void 0) return void 0;
3043
+ if (this.upstreamCookie !== void 0 && this.upstreamCookieExpiresAt > Date.now() + UPSTREAM_AUTH_REFRESH_MARGIN_MS) return this.upstreamCookie;
3044
+ if (this.upstreamCookieTask !== void 0) return this.upstreamCookieTask;
3045
+ const task = this.exchangeUpstreamCookie();
3046
+ this.upstreamCookieTask = task;
3047
+ try {
3048
+ return await task;
3049
+ } finally {
3050
+ if (this.upstreamCookieTask === task) this.upstreamCookieTask = void 0;
3051
+ }
3052
+ }
3053
+ async exchangeUpstreamCookie() {
3054
+ const authenticatedUrl = this.upstreamAuthenticatedUrl;
3055
+ if (authenticatedUrl === void 0) throw new HttpError(502, "upstream_unavailable");
3056
+ let target;
3057
+ try {
3058
+ target = new URL(authenticatedUrl);
3059
+ } catch {
3060
+ throw new HttpError(502, "upstream_unavailable");
3061
+ }
3062
+ if (target.origin !== this.config.upstreamOrigin.origin || target.pathname !== "/" || target.hash !== "" || target.search === "") throw new HttpError(502, "upstream_unavailable");
3063
+ try {
3064
+ const proxied = await new Promise((resolve, reject) => {
3065
+ const upstreamRequest = request({
3066
+ protocol: "http:",
3067
+ hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
3068
+ port: Number(this.config.upstreamOrigin.port),
3069
+ method: "GET",
3070
+ path: `${target.pathname}${target.search}`,
3071
+ headers: {
3072
+ host: this.config.upstreamOrigin.host,
3073
+ accept: "text/html",
3074
+ "accept-encoding": "identity"
3075
+ },
3076
+ agent: false
3077
+ });
3078
+ this.upstreamAuthRequest = upstreamRequest;
3079
+ upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
3080
+ upstreamRequest.destroy(/* @__PURE__ */ new Error("upstream timeout"));
3081
+ });
3082
+ upstreamRequest.once("response", resolve);
3083
+ upstreamRequest.once("error", reject);
3084
+ upstreamRequest.end();
3085
+ });
3086
+ await new Promise((resolve, reject) => {
3087
+ proxied.once("end", resolve);
3088
+ proxied.once("error", reject);
3089
+ proxied.resume();
3090
+ });
3091
+ const setCookie = proxied.headers["set-cookie"]?.[0];
3092
+ const pair = setCookie?.split(";", 1)[0];
3093
+ const maxAgeText = setCookie === void 0 ? void 0 : /(?:^|;\s*)Max-Age=(\d+)(?:;|$)/iu.exec(setCookie)?.[1];
3094
+ const maxAgeSeconds = maxAgeText === void 0 ? NaN : Number(maxAgeText);
3095
+ const expiresAt = Date.now() + maxAgeSeconds * 1e3;
3096
+ if (proxied.statusCode !== 303 || pair === void 0 || pair.length > 4096 || !UPSTREAM_COOKIE_PAIR.test(pair) || !Number.isSafeInteger(expiresAt) || maxAgeSeconds <= 0) throw new HttpError(502, "upstream_unavailable");
3097
+ this.upstreamCookie = pair;
3098
+ this.upstreamCookieExpiresAt = expiresAt;
3099
+ return pair;
3100
+ } catch (error) {
3101
+ if (error instanceof HttpError) throw error;
3102
+ throw new HttpError(502, "upstream_unavailable");
3103
+ } finally {
3104
+ this.upstreamAuthRequest?.destroy();
3105
+ this.upstreamAuthRequest = void 0;
3106
+ }
3107
+ }
2900
3108
  async proxyMobileIndex(request$1, response, authorization) {
2901
3109
  const holder = {};
2902
3110
  const operation = this.allocateRequest(authorization, response, holder);
2903
3111
  try {
2904
3112
  const upstreamHeaders = sanitizeRequestHeaders(request$1, this.config.upstreamOrigin);
3113
+ const upstreamCookie = await this.upstreamCookieHeader();
3114
+ if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
2905
3115
  upstreamHeaders["accept-encoding"] = "identity";
2906
3116
  const proxied = await new Promise((resolve, reject) => {
2907
3117
  const upstreamRequest = request({
@@ -2932,7 +3142,9 @@ var MobileAccessGateway = class {
2932
3142
  }
2933
3143
  let body;
2934
3144
  try {
2935
- body = Buffer.from(rewriteMobileIndex(Buffer.concat(chunks).toString("utf8")));
3145
+ const rewritten = rewriteMobileIndexWithBatch(Buffer.concat(chunks).toString("utf8"));
3146
+ if (rewritten.batch !== void 0) this.rememberMobileBootBatch(rewritten.batch);
3147
+ body = Buffer.from(rewritten.html);
2936
3148
  } catch {
2937
3149
  throw new HttpError(502, "upstream_unavailable");
2938
3150
  }
@@ -2956,6 +3168,128 @@ var MobileAccessGateway = class {
2956
3168
  operation.release();
2957
3169
  }
2958
3170
  }
3171
+ rememberMobileBootBatch(plan) {
3172
+ const existing = this.mobileBootBatches.get(plan.key);
3173
+ this.mobileBootBatches.delete(plan.key);
3174
+ this.mobileBootBatches.set(plan.key, existing ?? { plan });
3175
+ while (this.mobileBootBatches.size > MAX_MOBILE_BOOT_BATCHES) {
3176
+ const oldest = this.mobileBootBatches.keys().next().value;
3177
+ if (oldest === void 0) break;
3178
+ this.mobileBootBatches.delete(oldest);
3179
+ }
3180
+ }
3181
+ async serveMobileBootBatch(key, request, response, authorization) {
3182
+ if (request.method !== "GET" && request.method !== "HEAD") throw new HttpError(405, "method_not_allowed");
3183
+ const stored = this.mobileBootBatches.get(key);
3184
+ if (stored === void 0) throw new HttpError(404, "not_found");
3185
+ const operation = this.allocateRequest(authorization, response, {});
3186
+ try {
3187
+ const layoutStat = await stat(this.config.mobileLayoutFile);
3188
+ if (stored.body === void 0 || stored.etag === void 0 || stored.layoutMtimeMs !== layoutStat.mtimeMs) {
3189
+ const body = await this.assembleMobileBootBatch(stored.plan, operation.signal);
3190
+ stored.body = body;
3191
+ delete stored.gzipBody;
3192
+ stored.etag = createHash("sha256").update(body).digest("hex");
3193
+ stored.layoutMtimeMs = layoutStat.mtimeMs;
3194
+ }
3195
+ const compressed = acceptsGzip(request.headers["accept-encoding"]);
3196
+ const body = compressed ? stored.gzipBody ??= await gzipBuffer(stored.body) : stored.body;
3197
+ const etag = compressed ? `${stored.etag}-gzip` : stored.etag;
3198
+ const headers = {
3199
+ "Content-Type": "text/javascript; charset=utf-8",
3200
+ "Content-Length": body.byteLength,
3201
+ "Cache-Control": "private, no-cache",
3202
+ ETag: etag
3203
+ };
3204
+ if (compressed) headers["Content-Encoding"] = "gzip";
3205
+ addVaryAcceptEncoding(headers);
3206
+ if (headerValue(request.headers, "if-none-match") === etag) {
3207
+ setSecurityHeaders(response, this.tlsEnabled);
3208
+ response.writeHead(304, {
3209
+ ETag: etag,
3210
+ "Cache-Control": "private, no-cache",
3211
+ Vary: String(headers.vary)
3212
+ });
3213
+ response.end();
3214
+ return;
3215
+ }
3216
+ setSecurityHeaders(response, this.tlsEnabled);
3217
+ response.writeHead(200, headers);
3218
+ if (request.method === "HEAD") response.end();
3219
+ else response.end(body);
3220
+ } catch (error) {
3221
+ if (error.code === "ENOENT") throw new HttpError(503, "mobile_frontend_unavailable");
3222
+ throw error;
3223
+ } finally {
3224
+ operation.release();
3225
+ }
3226
+ }
3227
+ async assembleMobileBootBatch(plan, signal) {
3228
+ const bodies = new Array(plan.entries.length);
3229
+ let cursor = 0;
3230
+ const worker = async () => {
3231
+ while (cursor < plan.entries.length) {
3232
+ const index = cursor++;
3233
+ const entry = plan.entries[index];
3234
+ bodies[index] = entry.id === MOBILE_LAYOUT_MODULE ? await readFile(this.config.mobileLayoutFile, { signal }) : await this.readUpstreamClientBundle(entry.url, signal);
3235
+ if (bodies[index].byteLength > MAX_MOBILE_BOOT_ENTRY_BYTES) throw new HttpError(502, "upstream_unavailable");
3236
+ }
3237
+ };
3238
+ await Promise.all(Array.from({ length: Math.min(8, plan.entries.length) }, worker));
3239
+ if (bodies.reduce((bytes, body) => bytes + body.byteLength + 2, 0) > MAX_MOBILE_BOOT_BATCH_BYTES) throw new HttpError(502, "upstream_unavailable");
3240
+ return Buffer.concat(bodies.flatMap((body) => [body, Buffer.from("\n;\n")]));
3241
+ }
3242
+ async readUpstreamClientBundle(source, signal) {
3243
+ if (!source.startsWith("/plugins/") || source.includes("#")) throw new HttpError(502, "upstream_unavailable");
3244
+ const target = new URL(source, this.config.upstreamOrigin);
3245
+ if (target.origin !== this.config.upstreamOrigin.origin) throw new HttpError(502, "upstream_unavailable");
3246
+ let upstreamRequest;
3247
+ const aborted = () => {
3248
+ upstreamRequest?.destroy(/* @__PURE__ */ new Error("request aborted"));
3249
+ };
3250
+ signal.addEventListener("abort", aborted, { once: true });
3251
+ try {
3252
+ const upstreamCookie = await this.upstreamCookieHeader();
3253
+ const proxied = await new Promise((resolve, reject) => {
3254
+ upstreamRequest = request({
3255
+ protocol: "http:",
3256
+ hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
3257
+ port: Number(this.config.upstreamOrigin.port),
3258
+ method: "GET",
3259
+ path: `${target.pathname}${target.search}`,
3260
+ headers: {
3261
+ host: this.config.upstreamOrigin.host,
3262
+ accept: "text/javascript",
3263
+ "accept-encoding": "identity",
3264
+ ...upstreamCookie === void 0 ? {} : { cookie: upstreamCookie }
3265
+ },
3266
+ agent: false
3267
+ });
3268
+ upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
3269
+ upstreamRequest?.destroy(/* @__PURE__ */ new Error("upstream timeout"));
3270
+ });
3271
+ upstreamRequest.once("response", resolve);
3272
+ upstreamRequest.once("error", reject);
3273
+ upstreamRequest.end();
3274
+ });
3275
+ if ((proxied.statusCode ?? 502) !== 200) throw new HttpError(502, "upstream_unavailable");
3276
+ const chunks = [];
3277
+ let bytes = 0;
3278
+ for await (const chunk of proxied) {
3279
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
3280
+ bytes += buffer.byteLength;
3281
+ if (bytes > MAX_MOBILE_BOOT_ENTRY_BYTES) throw new HttpError(502, "upstream_unavailable");
3282
+ chunks.push(buffer);
3283
+ }
3284
+ return Buffer.concat(chunks);
3285
+ } catch (error) {
3286
+ if (error instanceof HttpError) throw error;
3287
+ throw new HttpError(502, "upstream_unavailable");
3288
+ } finally {
3289
+ signal.removeEventListener("abort", aborted);
3290
+ upstreamRequest?.destroy();
3291
+ }
3292
+ }
2959
3293
  allocateRequest(authorization, response, upstream) {
2960
3294
  if (this.activeRequests.size >= this.config.maxActiveRequests) throw new HttpError(429, "busy");
2961
3295
  const id = this.nextOperationId++;
@@ -2991,6 +3325,8 @@ var MobileAccessGateway = class {
2991
3325
  try {
2992
3326
  const bufferedBody = request$2.method === "POST" && request$2.url?.split("?", 1)[0] === SESSION_HISTORY_PATH ? mobileHistoryRequestBody(request$2, await readBoundedBody(request$2, this.config.maxBodyBytes)) : void 0;
2993
3327
  const upstreamHeaders = sanitizeRequestHeaders(request$2, this.config.upstreamOrigin);
3328
+ const upstreamCookie = await this.upstreamCookieHeader();
3329
+ if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
2994
3330
  if (bufferedBody !== void 0) upstreamHeaders["content-length"] = String(bufferedBody.byteLength);
2995
3331
  const proxied = await new Promise((resolve, reject) => {
2996
3332
  const upstreamRequest = request({
@@ -3017,6 +3353,8 @@ var MobileAccessGateway = class {
3017
3353
  });
3018
3354
  setSecurityHeaders(response, this.tlsEnabled);
3019
3355
  const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
3356
+ const cacheControl = revisionedStaticCacheControl(request$2);
3357
+ if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
3020
3358
  const compressed = shouldCompressResponse(request$2, proxied);
3021
3359
  if (compressed) {
3022
3360
  delete headers["accept-ranges"];
@@ -3132,6 +3470,7 @@ var MobileAccessGateway = class {
3132
3470
  if (decodedKey.length !== 16 || decodedKey.toString("base64") !== key) throw new HttpError(400, "bad_request");
3133
3471
  const authorization = this.authorize(request);
3134
3472
  if (this.activeWebSockets.size >= this.config.maxWebSockets) throw new HttpError(429, "busy");
3473
+ const upstreamCookie = await this.upstreamCookieHeader();
3135
3474
  const upstream = connect({
3136
3475
  host: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
3137
3476
  port: Number(this.config.upstreamOrigin.port)
@@ -3190,6 +3529,7 @@ var MobileAccessGateway = class {
3190
3529
  `Sec-WebSocket-Key: ${key}`,
3191
3530
  "Sec-WebSocket-Version: 13"
3192
3531
  ];
3532
+ if (upstreamCookie !== void 0) requestLines.push(`Cookie: ${upstreamCookie}`);
3193
3533
  const protocol = headerValue(request.headers, "sec-websocket-protocol");
3194
3534
  const extensions = headerValue(request.headers, "sec-websocket-extensions");
3195
3535
  if (protocol !== void 0) requestLines.push(`Sec-WebSocket-Protocol: ${protocol}`);
@@ -3289,6 +3629,8 @@ var MobileAccessGateway = class {
3289
3629
  }
3290
3630
  async performClose() {
3291
3631
  this.closing = true;
3632
+ this.upstreamAuthRequest?.destroy();
3633
+ this.upstreamAuthRequest = void 0;
3292
3634
  this.removeSessionListener();
3293
3635
  const accessClose = this.access.close();
3294
3636
  for (const request of this.activeRequests.values()) request.abort();
@@ -3452,6 +3794,190 @@ var MemoryDeviceStore = class {
3452
3794
  }
3453
3795
  };
3454
3796
  //#endregion
3797
+ //#region src/diagnostics.ts
3798
+ const execFile$2 = promisify(execFile);
3799
+ const REMOTE_ERROR_GUIDANCE = Object.freeze({
3800
+ component_missing: "重新安装完整插件包。",
3801
+ funnel_permission_required: "继续完成 Tailscale Funnel 授权。",
3802
+ funnel_https_required: "继续完成 Tailscale HTTPS 授权。",
3803
+ funnel_start_failed: "重新打开授权页并允许 Funnel。",
3804
+ funnel_start_timeout: "检查网络后点击“重新连接”。",
3805
+ tailscale_dns_missing: "确认 Tailscale 登录仍有效后重新连接。",
3806
+ sidecar_launch_failed: "重新安装完整插件包后重试。",
3807
+ sidecar_stopped: "点击“重新连接”。",
3808
+ sidecar_exited: "点击“重新连接”;仍失败时复制诊断报告。",
3809
+ control_channel_failed: "点击“重新连接”。",
3810
+ cpolar_component_missing: "先安装 cpolar 官方组件。",
3811
+ cpolar_component_invalid: "彻底移除 cpolar 组件后重新安装。",
3812
+ cpolar_config_missing: "保存 cpolar Authtoken 后重试。",
3813
+ cpolar_config_invalid: "重新保存 cpolar Authtoken。",
3814
+ cpolar_start_timeout: "检查网络后点击“重新连接”。",
3815
+ cpolar_stopped: "点击“重新连接”。",
3816
+ cpolar_exited: "点击“重新连接”;仍失败时复制诊断报告。",
3817
+ gateway_start_failed: "确认 DSH 正在运行后重新连接。"
3818
+ });
3819
+ function check(id, status, label, detail, action) {
3820
+ return Object.freeze({
3821
+ id,
3822
+ status,
3823
+ label,
3824
+ detail,
3825
+ ...action === void 0 ? {} : { action }
3826
+ });
3827
+ }
3828
+ function maskLanOrigin(origin) {
3829
+ if (origin === void 0) return "未分配";
3830
+ try {
3831
+ const url = new URL(origin);
3832
+ const octets = url.hostname.split(".");
3833
+ const host = octets.length === 4 ? `${octets[0]}.${octets[1]}.${octets[2]}.x` : "局域网地址";
3834
+ return `${url.protocol}//${host}${url.port === "" ? "" : `:${url.port}`}`;
3835
+ } catch {
3836
+ return "地址格式无效";
3837
+ }
3838
+ }
3839
+ function remoteSuffix(origin) {
3840
+ if (origin === void 0) return "未分配";
3841
+ try {
3842
+ const hostname = new URL(origin).hostname;
3843
+ if (hostname.endsWith(".ts.net")) return "*.ts.net";
3844
+ for (const suffix of [
3845
+ ".cpolar.cn",
3846
+ ".cpolar.io",
3847
+ ".cpolar.top",
3848
+ ".cpolar.com"
3849
+ ]) if (hostname.endsWith(suffix)) return `*${suffix}`;
3850
+ return "公共 HTTPS 地址";
3851
+ } catch {
3852
+ return "地址格式无效";
3853
+ }
3854
+ }
3855
+ function defaultFirewallProbe(platform = process.platform) {
3856
+ return async (port) => {
3857
+ if (platform !== "win32") return { state: "not-applicable" };
3858
+ if (port === void 0) return { state: "unknown" };
3859
+ const script = [
3860
+ "$specs = @(@{ Name = 'DSH Mobile HTTPS'; Protocol = 'TCP' }, @{ Name = 'DSH Mobile Discovery'; Protocol = 'UDP' })",
3861
+ "$ready = $true",
3862
+ "$specs | ForEach-Object {",
3863
+ " $spec = $_",
3864
+ " $rule = Get-NetFirewallRule -DisplayName $spec.Name -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' } | Select-Object -First 1",
3865
+ " if ($null -eq $rule) { $ready = $false; return }",
3866
+ " $filters = @($rule | Get-NetFirewallPortFilter -ErrorAction SilentlyContinue)",
3867
+ ` $matching = @($filters | Where-Object { $_.Protocol -eq $spec.Protocol -and ($_.LocalPort -eq 'Any' -or $_.LocalPort -eq '${String(port)}') })`,
3868
+ " if ($matching.Count -eq 0) { $ready = $false }",
3869
+ "}",
3870
+ "if ($ready) { 'ready' } else { 'missing' }"
3871
+ ].join("; ");
3872
+ try {
3873
+ return { state: (await execFile$2("powershell.exe", [
3874
+ "-NoProfile",
3875
+ "-NonInteractive",
3876
+ "-Command",
3877
+ script
3878
+ ], {
3879
+ encoding: "utf8",
3880
+ timeout: 3e3,
3881
+ windowsHide: true
3882
+ })).stdout.trim() === "ready" ? "ready" : "missing" };
3883
+ } catch {
3884
+ return { state: "unknown" };
3885
+ }
3886
+ };
3887
+ }
3888
+ /** Allow remote relays enough time to answer without making diagnostics unbounded. */
3889
+ function remoteDiagnosticTimeoutMs(origin) {
3890
+ const hostname = new URL(origin).hostname.toLowerCase();
3891
+ if (hostname.endsWith(".ts.net") || hostname.includes(".cpolar.")) return 1e4;
3892
+ return 5e3;
3893
+ }
3894
+ async function defaultRemoteProbe(origin) {
3895
+ if (origin === void 0) return { state: "not-applicable" };
3896
+ const hostname = new URL(origin).hostname;
3897
+ const started = performance.now();
3898
+ try {
3899
+ const response = await fetch(new URL("/mobile-access/health", origin), {
3900
+ cache: "no-store",
3901
+ redirect: "error",
3902
+ signal: AbortSignal.timeout(remoteDiagnosticTimeoutMs(origin))
3903
+ });
3904
+ const latencyMs = Math.max(0, Math.round(performance.now() - started));
3905
+ if (response.status === 429) return {
3906
+ state: "rate-limited",
3907
+ latencyMs
3908
+ };
3909
+ return response.ok ? {
3910
+ state: "ready",
3911
+ latencyMs
3912
+ } : {
3913
+ state: "unreachable",
3914
+ latencyMs
3915
+ };
3916
+ } catch {
3917
+ let fakeIp = false;
3918
+ try {
3919
+ fakeIp = (await lookup(hostname, { all: true })).some(({ address }) => {
3920
+ const [first, second] = address.split(".").map(Number);
3921
+ return first === 198 && (second === 18 || second === 19);
3922
+ });
3923
+ } catch {}
3924
+ return {
3925
+ state: "unreachable",
3926
+ ...fakeIp ? { fakeIp: true } : {}
3927
+ };
3928
+ }
3929
+ }
3930
+ function reportLine(entry) {
3931
+ return `[${entry.status.toUpperCase()}] ${entry.label}: ${entry.detail}${entry.action === void 0 ? "" : ` ${entry.action}`}`;
3932
+ }
3933
+ /** Run bounded read-only checks and return a report safe to paste into an issue. */
3934
+ async function collectConnectionDiagnostics(snapshot, probes = {}) {
3935
+ const checks = [];
3936
+ 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" });
3937
+ const [firewall, remoteObservation] = await Promise.all([(probes.firewall ?? defaultFirewallProbe())(snapshot.lan.port), remoteProbe]);
3938
+ checks.push(check("versions", "ok", "版本兼容", `插件 ${DSH_MOBILE_VERSION},DSH ${snapshot.dshVersion},Android App 最低 ${MINIMUM_ANDROID_APP_VERSION}。`));
3939
+ if (snapshot.lan.networkError !== void 0) checks.push(check("network", "error", "局域网网卡", "已保存的网卡当前不可用。", "重新运行 dsh-mobile setup。"));
3940
+ else if (snapshot.lan.configuredInterface !== void 0) checks.push(check("network", "ok", "局域网网卡", `正在跟随 ${snapshot.lan.interfaceName ?? snapshot.lan.configuredInterface}。`));
3941
+ else checks.push(check("network", "info", "局域网网卡", "当前使用固定网络配置。"));
3942
+ if (snapshot.lan.running && snapshot.lan.origin !== void 0) checks.push(check("lan", "ok", "局域网网关", `已监听 ${maskLanOrigin(snapshot.lan.origin)},配对入口可用。`));
3943
+ else checks.push(check("lan", "info", "局域网网关", "当前未开启。", "需要手机直连时开启局域网访问。"));
3944
+ if (firewall.state === "ready") checks.push(check("firewall", "ok", "Windows 防火墙", "局域网 TCP 与发现规则已启用。"));
3945
+ else if (firewall.state === "missing") checks.push(check("firewall", "warning", "Windows 防火墙", "未找到完整的局域网放行规则。", "以管理员身份重新运行 dsh-mobile setup。"));
3946
+ else if (firewall.state === "unknown") checks.push(check("firewall", "info", "Windows 防火墙", "系统未允许插件读取防火墙状态。", "若手机找不到电脑,以管理员身份重新运行 setup。"));
3947
+ if (!snapshot.remote.running || snapshot.remote.state === "off") checks.push(check("remote", "info", "远程通道", "当前未启用。"));
3948
+ else if (snapshot.remote.state === "ready" && snapshot.remote.origin !== void 0) {
3949
+ if (remoteObservation.state === "ready") checks.push(check("remote", "ok", "远程通道", `${snapshot.remote.provider} 公共地址 ${remoteSuffix(snapshot.remote.origin)} 可达,往返约 ${String(remoteObservation.latencyMs ?? 0)} ms。`));
3950
+ else if (remoteObservation.state === "rate-limited") checks.push(check("remote", "warning", "远程通道", "公共地址可达,但本次检查观察到服务限流。", "稍后重试;旧会话会按需加载以减少流量。"));
3951
+ else if (snapshot.remote.provider === "tailscale" && remoteObservation.fakeIp === true) checks.push(check("remote", "error", "远程通道", "Tailscale 地址被当前 VPN 或 DNS 代理接管,但 TLS 链路未建立。", "切换 VPN 节点或代理模式;仍失败时改用 cpolar。"));
3952
+ else checks.push(check("remote", "error", "远程通道", "提供方显示已就绪,但公共地址暂不可达。", "点击“重新连接”;仍失败时检查提供方状态。"));
3953
+ } else if (snapshot.remote.state === "starting" || snapshot.remote.state === "connecting" || snapshot.remote.state === "needs-login") checks.push(check("remote", "warning", "远程通道", snapshot.remote.state === "needs-login" ? "等待完成 Tailscale 登录。" : "仍在建立连接。", snapshot.remote.state === "needs-login" ? "返回远程页继续登录。" : "等待片刻后重新检查。"));
3954
+ else checks.push(check("remote", "error", "远程通道", `连接未建立(${snapshot.remote.errorCode ?? snapshot.remote.state})。`, REMOTE_ERROR_GUIDANCE[snapshot.remote.errorCode ?? ""] ?? "返回远程页点击“重新连接”。"));
3955
+ checks.push(check("phone-network", "info", "手机网络", "电脑无法判断路由器是否隔离了手机。", "局域网仍失败时,确认手机与电脑在同一网络,并关闭访客网络或 AP 隔离。"));
3956
+ const overall = checks.some((entry) => entry.status === "error") ? "error" : checks.some((entry) => entry.status === "warning") ? "attention" : "ok";
3957
+ const summary = overall === "ok" ? "连接基础检查正常。" : overall === "attention" ? "发现需要留意的项目。" : "发现会影响连接的问题。";
3958
+ const report = [
3959
+ "DSH Mobile 诊断报告",
3960
+ `生成时间: ${(/* @__PURE__ */ new Date()).toISOString()}`,
3961
+ `版本: plugin=${DSH_MOBILE_VERSION}; dsh=${snapshot.dshVersion}; min-app=${MINIMUM_ANDROID_APP_VERSION}`,
3962
+ `LAN: ${snapshot.lan.running ? "on" : "off"}; endpoint=${maskLanOrigin(snapshot.lan.origin)}`,
3963
+ `Remote: provider=${snapshot.remote.provider}; state=${snapshot.remote.state}; endpoint=${remoteSuffix(snapshot.remote.origin)}`,
3964
+ ...checks.map(reportLine)
3965
+ ].join("\n");
3966
+ return Object.freeze({
3967
+ version: 1,
3968
+ generatedAt: Date.now(),
3969
+ overall,
3970
+ versions: Object.freeze({
3971
+ plugin: DSH_MOBILE_VERSION,
3972
+ dsh: snapshot.dshVersion,
3973
+ minimumAndroidApp: MINIMUM_ANDROID_APP_VERSION
3974
+ }),
3975
+ summary,
3976
+ checks: Object.freeze(checks),
3977
+ report
3978
+ });
3979
+ }
3980
+ //#endregion
3455
3981
  //#region src/mobile-guide.ts
3456
3982
  /**
3457
3983
  * Instructions handed to the DSH agent when the user runs `/mobile <task>`.
@@ -3487,6 +4013,7 @@ const MOBILE_CUSTOMIZATION_GUIDE = `你在为用户定制 DSH Mobile 的手机
3487
4013
  //#endregion
3488
4014
  //#region src/funnel.ts
3489
4015
  const MAX_PROTOCOL_LINE_BYTES = 16384;
4016
+ const FUNNEL_START_TIMEOUT_MS = 45e3;
3490
4017
  function publicStatus$1(status) {
3491
4018
  return Object.freeze({
3492
4019
  enabled: status.enabled,
@@ -3580,6 +4107,7 @@ var FunnelController = class {
3580
4107
  state: "off"
3581
4108
  });
3582
4109
  queue = Promise.resolve();
4110
+ startTimer;
3583
4111
  constructor(options) {
3584
4112
  this.options = options;
3585
4113
  if (!isAbsolute(options.executable) || !isAbsolute(options.stateDirectory)) throw new Error("Funnel paths must be absolute");
@@ -3718,6 +4246,11 @@ var FunnelController = class {
3718
4246
  windowsHide: true
3719
4247
  });
3720
4248
  this.child = child;
4249
+ this.clearStartTimer();
4250
+ this.startTimer = setTimeout(() => {
4251
+ this.enqueue(() => this.failGeneration(generation, "funnel_start_timeout"));
4252
+ }, FUNNEL_START_TIMEOUT_MS);
4253
+ this.startTimer.unref();
3721
4254
  child.stderr.resume();
3722
4255
  child.stdout.setEncoding("utf8");
3723
4256
  child.stdout.on("data", (chunk) => {
@@ -3756,6 +4289,7 @@ var FunnelController = class {
3756
4289
  }
3757
4290
  async handleEvent(generation, event) {
3758
4291
  if (generation !== this.generation || !this.enabled) return;
4292
+ this.clearStartTimer();
3759
4293
  if (event.type === "login") {
3760
4294
  this.publish({
3761
4295
  enabled: true,
@@ -3825,6 +4359,7 @@ var FunnelController = class {
3825
4359
  await this.stopProcessAndGateway();
3826
4360
  }
3827
4361
  async stopProcessAndGateway() {
4362
+ this.clearStartTimer();
3828
4363
  const child = this.child;
3829
4364
  this.child = void 0;
3830
4365
  child?.stdin.end();
@@ -3850,6 +4385,11 @@ var FunnelController = class {
3850
4385
  this.gatewayValue = void 0;
3851
4386
  await gateway?.close();
3852
4387
  }
4388
+ clearStartTimer() {
4389
+ if (this.startTimer === void 0) return;
4390
+ clearTimeout(this.startTimer);
4391
+ this.startTimer = void 0;
4392
+ }
3853
4393
  };
3854
4394
  /** Locate the current platform's bundled Funnel executable, with one local development override. */
3855
4395
  function funnelExecutable(importMetaUrl, environment = process.env) {
@@ -4789,8 +5329,16 @@ async function materializeManagedSetup(setup, table) {
4789
5329
  //#region src/plugin.ts
4790
5330
  /** Stable Cordis plugin name. */
4791
5331
  const name = "dsh-mobile";
4792
- /** The stock WebServer serves the control card; commands exposes /mobile to the DSH agent. */
4793
- const inject = ["webServer", "commands"];
5332
+ /** The stock WebServer serves the control card; Connection authenticates the loopback DSH origin. */
5333
+ const inject = [
5334
+ "webServer",
5335
+ "commands",
5336
+ "connection"
5337
+ ];
5338
+ function upstreamAuthenticatedUrl(ctx, upstreamOrigin) {
5339
+ const connection = ctx.connection;
5340
+ return typeof connection?.authenticatedUrl === "function" ? connection.authenticatedUrl(upstreamOrigin.origin) : void 0;
5341
+ }
4794
5342
  function installedDshVersion() {
4795
5343
  const manifest = createRequire(import.meta.url)("@deepseek-ai/dsh-host-webserver/package.json");
4796
5344
  if (manifest === null || typeof manifest !== "object") return void 0;
@@ -4922,10 +5470,12 @@ function remoteControlPayload(provider, status, gateway, providerStatuses, cpola
4922
5470
  }
4923
5471
  /** Mount the resident control route and its optional authenticated LAN gateway. */
4924
5472
  async function apply(ctx, config) {
4925
- assertSupportedDshVersion(installedDshVersion());
5473
+ const dshVersion = installedDshVersion();
5474
+ assertSupportedDshVersion(dshVersion);
4926
5475
  const loaded = await loadSetup(config);
4927
5476
  const mobileAccess = createMobileAccessService(ctx);
4928
5477
  const template = loopbackTemplate(loaded);
5478
+ const upstreamLoginUrl = upstreamAuthenticatedUrl(ctx, template.upstreamOrigin);
4929
5479
  const instanceId = await stableInstanceId(loaded, template);
4930
5480
  const stateDirectory = dirname(template.stateFile);
4931
5481
  const remoteDirectory = join(stateDirectory, "remote");
@@ -4966,7 +5516,7 @@ async function apply(ctx, config) {
4966
5516
  let lanGateway;
4967
5517
  const startGateway = async (candidateConfig) => {
4968
5518
  const resolved = parseGatewayConfig(candidateConfig);
4969
- const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess);
5519
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl);
4970
5520
  await candidate.start();
4971
5521
  lanGateway = candidate;
4972
5522
  return { close: async () => {
@@ -5006,7 +5556,7 @@ async function apply(ctx, config) {
5006
5556
  }
5007
5557
  const createRemoteGateway = async (publicOrigin, listenPort = 0) => {
5008
5558
  const resolved = remoteGatewayConfig(template, publicOrigin, remoteDeviceFile, instanceId, listenPort);
5009
- const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess);
5559
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl);
5010
5560
  await candidate.start();
5011
5561
  return candidate;
5012
5562
  };
@@ -5054,6 +5604,39 @@ async function apply(ctx, config) {
5054
5604
  origin: lanGateway?.address().origin,
5055
5605
  ...lanGateway === void 0 ? {} : { extensions: lanGateway.extensionStatus() }
5056
5606
  });
5607
+ const diagnosticsPayload = async () => {
5608
+ let interfaceName;
5609
+ let networkError;
5610
+ if (loaded.kind === "managed") try {
5611
+ interfaceName = selectLanNetwork(void 0, loaded.setup.networkInterface).name;
5612
+ } catch {
5613
+ networkError = "network_interface_unavailable";
5614
+ }
5615
+ const remote = remoteController().status();
5616
+ return collectConnectionDiagnostics({
5617
+ dshVersion,
5618
+ lan: {
5619
+ running: lanController.isRunning(),
5620
+ ...lanGateway === void 0 ? {} : {
5621
+ origin: lanGateway.address().origin,
5622
+ port: lanGateway.address().port
5623
+ },
5624
+ ...loaded.kind === "managed" ? {
5625
+ configuredInterface: loaded.setup.networkInterface,
5626
+ port: loaded.setup.listenPort
5627
+ } : {},
5628
+ ...interfaceName === void 0 ? {} : { interfaceName },
5629
+ ...networkError === void 0 ? {} : { networkError }
5630
+ },
5631
+ remote: {
5632
+ provider: remoteProvider,
5633
+ running: remote.enabled,
5634
+ state: remote.state,
5635
+ ...remote.origin === void 0 ? {} : { origin: remote.origin },
5636
+ ...remote.errorCode === void 0 ? {} : { errorCode: remote.errorCode }
5637
+ }
5638
+ });
5639
+ };
5057
5640
  const adminRoute = {
5058
5641
  kind: "prefix",
5059
5642
  path: LOCAL_ADMIN_PREFIX,
@@ -5067,6 +5650,10 @@ async function apply(ctx, config) {
5067
5650
  sendJson(response, 200, lanPayload(), false);
5068
5651
  return;
5069
5652
  }
5653
+ if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/diagnostics`) {
5654
+ sendJson(response, 200, await diagnosticsPayload(), false);
5655
+ return;
5656
+ }
5070
5657
  if (request.method === "POST" && lanControl) {
5071
5658
  const body = await readJsonObject(request, 4096);
5072
5659
  if (typeof body.running !== "boolean") throw new HttpError(400, "bad_request");