dsh-mobile 0.4.6 → 0.5.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.d.mts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { X509Certificate } from "node:crypto";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { ChildProcessWithoutNullStreams } from "node:child_process";
4
+ import { IncomingMessage } from "node:http";
4
5
  import { Readable } from "node:stream";
5
6
  import { Context, Service } from "@deepseek-ai/cordis";
6
7
  import { WebRoute } from "@deepseek-ai/dsh-host-webserver";
@@ -262,6 +263,8 @@ interface PluginConfig {
262
263
  mobileLayoutFile?: string;
263
264
  /** Standalone browser compatibility bundle used before DSH boot. */
264
265
  mobileCompatibilityFile?: string;
266
+ /** Optional application-phase client package ids omitted only from the mobile boot graph. */
267
+ excludedClientModules?: string[];
265
268
  /** Stable public discovery identifier; it is not an authentication secret. */
266
269
  instanceId?: string;
267
270
  /** Managed CA certificate offered to the Android installer after fingerprint binding. */
@@ -302,6 +305,7 @@ interface ResolvedGatewayConfig {
302
305
  readonly customScriptFile: string;
303
306
  readonly mobileLayoutFile: string;
304
307
  readonly mobileCompatibilityFile: string;
308
+ readonly excludedClientModules: readonly string[];
305
309
  readonly instanceId: string;
306
310
  readonly pairingCaFile?: string;
307
311
  readonly tls: TlsConfig;
@@ -589,8 +593,8 @@ declare class MobileAccessService extends Service {
589
593
  declare function createMobileAccessService(ctx: Context): MobileAccessService;
590
594
  //#endregion
591
595
  //#region src/gateway.d.ts
592
- /** Replace only DSH's layout client module while retaining its complete plugin graph. */
593
- declare function rewriteMobileIndex(html: string): string;
596
+ /** Rewrite the dedicated mobile graph; exclusions are opt-in and leave stock DSH unchanged. */
597
+ declare function rewriteMobileIndex(html: string, excludedClientModules?: readonly string[]): string;
594
598
  /** Authenticated TLS edge in front of the ordinary loopback-only DSH Web server. */
595
599
  declare class MobileAccessGateway {
596
600
  readonly config: ResolvedGatewayConfig;
@@ -616,6 +620,7 @@ declare class MobileAccessGateway {
616
620
  private mdnsError;
617
621
  /** Names of the bundled mobile assets that could not be read, if any. */
618
622
  private mobileAssetError;
623
+ private lastModuleExclusionError;
619
624
  private bonjour;
620
625
  private pairingCaCertificate;
621
626
  private listenerPort;
@@ -752,7 +757,7 @@ declare class MobileAccessGateway {
752
757
  blockedUpgradePathReport(): BlockedUpgradePathEntry[];
753
758
  private handleUpgrade;
754
759
  /** Loopback-only DSH WebServer route for opening pairing and managing devices. */
755
- localAdminRoute(prefix?: string): WebRoute;
760
+ localAdminRoute(prefix?: string, authenticateDesktop?: (request: IncomingMessage) => boolean): WebRoute;
756
761
  /** Close listeners and abort all accepted work before resolving teardown. */
757
762
  close(): Promise<void>;
758
763
  private performClose;
package/lib/index.mjs CHANGED
@@ -761,6 +761,7 @@ const Config = z.object({
761
761
  customScriptFile: z.string().hidden(),
762
762
  mobileLayoutFile: z.string().hidden(),
763
763
  mobileCompatibilityFile: z.string().hidden(),
764
+ excludedClientModules: z.array(String).default([]),
764
765
  instanceId: z.string().hidden(),
765
766
  pairingCaFile: z.string().hidden(),
766
767
  initiallyEnabled: z.boolean().hidden().required(),
@@ -797,6 +798,13 @@ function absoluteFile(value, name) {
797
798
  if (typeof value !== "string" || value.length === 0 || !isAbsolute(value)) throw new Error(`${name} must be an absolute file path`);
798
799
  return resolve(value);
799
800
  }
801
+ function excludedClientModules(value) {
802
+ if (value === void 0) return Object.freeze([]);
803
+ if (!Array.isArray(value) || value.some((id) => typeof id !== "string" || id.length === 0 || id.trim() !== id)) throw new Error("excludedClientModules must be an array of exact, non-empty client module ids");
804
+ const ids = value;
805
+ if (new Set(ids).size !== ids.length) throw new Error("excludedClientModules must not contain duplicate ids");
806
+ return Object.freeze([...ids]);
807
+ }
800
808
  /** Resolve the hidden runtime-control file independently from gateway configuration. */
801
809
  function parseControlFile(value) {
802
810
  return absoluteFile(value, "controlFile");
@@ -887,6 +895,7 @@ function parseGatewayConfig(raw) {
887
895
  customScriptFile: value.customScriptFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.js") : absoluteFile(value.customScriptFile, "customScriptFile"),
888
896
  mobileLayoutFile: value.mobileLayoutFile === void 0 ? fileURLToPath(new URL("./mobile-layout.js", import.meta.url)) : absoluteFile(value.mobileLayoutFile, "mobileLayoutFile"),
889
897
  mobileCompatibilityFile: value.mobileCompatibilityFile === void 0 ? fileURLToPath(new URL("./mobile-compat.js", import.meta.url)) : absoluteFile(value.mobileCompatibilityFile, "mobileCompatibilityFile"),
898
+ excludedClientModules: excludedClientModules(value.excludedClientModules),
890
899
  instanceId: value.instanceId === void 0 ? createHash("sha256").update(absoluteFile(value.stateFile, "stateFile")).digest("hex") : /^[a-f\d]{64}$/u.test(value.instanceId) ? value.instanceId : (() => {
891
900
  throw new Error("instanceId must be a lowercase SHA-256 value");
892
901
  })(),
@@ -1213,9 +1222,6 @@ var MobileAccessGatewayController = class {
1213
1222
  return run;
1214
1223
  }
1215
1224
  };
1216
- //#endregion
1217
- //#region src/local-admin-host.ts
1218
- /** Browser-safe desktop-admin Host checks. Do not import Node APIs here. */
1219
1225
  function unwrapBrackets(hostname) {
1220
1226
  return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
1221
1227
  }
@@ -1330,7 +1336,8 @@ function setSecurityHeaders(response, tls, framing = "gateway") {
1330
1336
  "worker-src 'self' blob:",
1331
1337
  ...proxied ? [PROXIED_FRAME_SRC] : []
1332
1338
  ].join("; "));
1333
- response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
1339
+ const microphone = proxied ? "microphone=(self)" : "microphone=()";
1340
+ response.setHeader("Permissions-Policy", `camera=(), ${microphone}, geolocation=(), payment=(), usb=()`);
1334
1341
  response.setHeader("Referrer-Policy", "no-referrer");
1335
1342
  response.setHeader("X-Content-Type-Options", "nosniff");
1336
1343
  response.setHeader("X-Frame-Options", proxied ? "SAMEORIGIN" : "DENY");
@@ -1427,7 +1434,7 @@ function localAuthority(header) {
1427
1434
  }
1428
1435
  }
1429
1436
  /** Protect the inner management route from non-loopback and DNS-rebinding callers. */
1430
- function assertLocalAdminTrust(request, requireBrowserOrigin) {
1437
+ function assertLocalAdminTrust(request, requireBrowserOrigin, authenticateDesktop = () => false) {
1431
1438
  if (request.socket.remoteAddress === void 0 || !isLoopbackAddress(request.socket.remoteAddress)) throw new HttpError(403, "forbidden");
1432
1439
  const host = localAuthority(request.headers.host);
1433
1440
  if (host === void 0 || !isLocalAdminHostname(host.hostname)) throw new HttpError(403, "forbidden");
@@ -1441,7 +1448,8 @@ function assertLocalAdminTrust(request, requireBrowserOrigin) {
1441
1448
  if (error instanceof HttpError) throw error;
1442
1449
  throw new HttpError(403, "forbidden");
1443
1450
  }
1444
- if (requireBrowserOrigin && origin === void 0) throw new HttpError(403, "forbidden");
1451
+ const desktopForward = site === void 0 && request.headers["x-dsh-mobile-desktop-admin"] === "dsh-app://app";
1452
+ if (requireBrowserOrigin && origin === void 0 && !(desktopForward && authenticateDesktop())) throw new HttpError(403, "forbidden");
1445
1453
  if (requireBrowserOrigin && site !== void 0 && site !== "same-origin") throw new HttpError(403, "forbidden");
1446
1454
  }
1447
1455
  //#endregion
@@ -2614,6 +2622,17 @@ const SIDEBAR_MODULE = "@deepseek-ai/dsh-client-ui-sidebar";
2614
2622
  const SETTINGS_MODULE = "@deepseek-ai/dsh-client-ui-settings";
2615
2623
  const API_GATEWAY_MODULE = "@deepseek-ai/dsh-api-gateway";
2616
2624
  const API_REMOTES_MODULE = "@deepseek-ai/dsh-api-remotes";
2625
+ const MOBILE_BOOT_CORE_MODULES = /* @__PURE__ */ new Set([
2626
+ MOBILE_CLIENT_MODULE,
2627
+ MOBILE_LAYOUT_MODULE,
2628
+ CONNECTION_MODULE,
2629
+ RUNTIME_MODULE,
2630
+ RENDERER_MODULE,
2631
+ SIDEBAR_MODULE,
2632
+ SETTINGS_MODULE,
2633
+ API_GATEWAY_MODULE,
2634
+ API_REMOTES_MODULE
2635
+ ]);
2617
2636
  const MOBILE_LAYOUT_DEPENDENCY_PROFILES = Object.freeze([Object.freeze({
2618
2637
  slots: RUNTIME_MODULE,
2619
2638
  dependencies: Object.freeze([RUNTIME_MODULE, "@deepseek-ai/dsh-client-ui-theme"])
@@ -2627,7 +2646,7 @@ const MOBILE_LAYOUT_DEPENDENCY_PROFILES = Object.freeze([Object.freeze({
2627
2646
  ])
2628
2647
  })]);
2629
2648
  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})};})();`;
2630
- 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}})();`;
2649
+ 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},window.__DSH_FILE_UPLOAD__={fetch:(input,init)=>window.fetch(input,init)}})();`;
2631
2650
  /** Use this page's gateway health, rather than OS internet validation, for DSH reconnects. */
2632
2651
  const MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP = `(()=>{
2633
2652
  const view=globalThis;
@@ -2719,13 +2738,49 @@ function revisionedMobileBatchPath(entries) {
2719
2738
  path: `${MOBILE_BOOT_BATCH_PREFIX}${key}.js`
2720
2739
  };
2721
2740
  }
2741
+ var ExcludedClientModulesError = class extends Error {
2742
+ constructor(message) {
2743
+ super(`excludedClientModules: ${message}`);
2744
+ }
2745
+ };
2746
+ /** Validate graph edges before removing application rows from both manifest views. */
2747
+ function excludeMobileBootEntries(entries, batches, excludedIds) {
2748
+ if (excludedIds.length === 0) return;
2749
+ const excluded = new Set(excludedIds);
2750
+ if (excluded.size !== excludedIds.length) throw new ExcludedClientModulesError("duplicate module ids");
2751
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
2752
+ const phases = /* @__PURE__ */ new Map();
2753
+ for (const batch of batches) for (const id of batch.entries) {
2754
+ if (phases.has(id)) throw new ExcludedClientModulesError(`upstream module ${id} belongs to multiple batches`);
2755
+ phases.set(id, batch.phase);
2756
+ }
2757
+ for (const entry of entries) if (!phases.has(entry.id)) throw new ExcludedClientModulesError(`upstream module ${entry.id} belongs to no batch`);
2758
+ for (const id of excluded) {
2759
+ const entry = byId.get(id);
2760
+ if (entry === void 0) throw new ExcludedClientModulesError(`module ${id} is not installed in this DSH client graph`);
2761
+ if (MOBILE_BOOT_CORE_MODULES.has(id) || entry.immediately === true || phases.get(id) !== "application") throw new ExcludedClientModulesError(`module ${id} is required for mobile boot or belongs to a bootstrap batch`);
2762
+ }
2763
+ for (const entry of entries) {
2764
+ if (excluded.has(entry.id)) continue;
2765
+ for (const [field, edges] of [["inject", entry.inject], ["external", entry.external]]) {
2766
+ if (edges !== void 0 && (!Array.isArray(edges) || edges.some((id) => typeof id !== "string"))) throw new ExcludedClientModulesError(`upstream module ${entry.id} has malformed ${field} dependencies`);
2767
+ for (const dependency of edges ?? []) {
2768
+ const packageId = dependency.endsWith("/client") ? dependency.slice(0, -7) : dependency;
2769
+ if (excluded.has(packageId)) throw new ExcludedClientModulesError(`module ${entry.id} still depends on excluded module ${packageId}; exclude the dependent too or keep ${packageId}`);
2770
+ }
2771
+ }
2772
+ }
2773
+ entries.splice(0, entries.length, ...entries.filter((entry) => !excluded.has(entry.id)));
2774
+ for (const batch of batches) batch.entries = batch.entries.filter((id) => !excluded.has(id));
2775
+ batches.splice(0, batches.length, ...batches.filter((batch) => batch.entries.length > 0));
2776
+ }
2722
2777
  /**
2723
2778
  * Parse one upstream boot manifest and rewire the layout entry onto the
2724
- * gateway-owned layout module. The layout entry's URL and revision become the
2725
- * local `mobile-layout.js` path; the dedicated frontend is authenticated in the
2726
- * gateway, so the requested layout needs no upstream address.
2779
+ * gateway-owned layout module. Optional exclusions remove application entries
2780
+ * and identify every affected batch for reassembly. The dedicated frontend is
2781
+ * authenticated in the gateway, so the layout needs no upstream address.
2727
2782
  */
2728
- function parseMobileBootPlan(html) {
2783
+ function parseMobileBootPlan(html, excludedClientModules = []) {
2729
2784
  const assignment = /(?:window\.__DSH_BOOT__|globalThis\["__DSH_BOOT__"\])\s*=\s*/u.exec(html);
2730
2785
  if (assignment?.index === void 0) throw new Error("upstream DSH index has no boot manifest");
2731
2786
  const replaceStart = assignment.index;
@@ -2744,14 +2799,17 @@ function parseMobileBootPlan(html) {
2744
2799
  layout[0].url = MOBILE_LAYOUT_PATH;
2745
2800
  layout[0].rev = `dsh-mobile-layout-${DSH_MOBILE_VERSION}`;
2746
2801
  const parsedBatches = parsed.batches;
2747
- if (parsedBatches === void 0) return Object.freeze({
2748
- parsed,
2749
- entries,
2750
- slotProvider: dependencyProfile.slots,
2751
- assignment: assignment[0],
2752
- replaceStart,
2753
- replaceEnd: scriptEnd
2754
- });
2802
+ if (parsedBatches === void 0) {
2803
+ if (excludedClientModules.length > 0) throw new ExcludedClientModulesError("this DSH version has no batched boot manifest; disable exclusions or update DSH");
2804
+ return Object.freeze({
2805
+ parsed,
2806
+ entries,
2807
+ slotProvider: dependencyProfile.slots,
2808
+ assignment: assignment[0],
2809
+ replaceStart,
2810
+ replaceEnd: scriptEnd
2811
+ });
2812
+ }
2755
2813
  if (!Array.isArray(parsedBatches)) throw new Error("upstream DSH boot manifest batches are malformed");
2756
2814
  const batches = parsedBatches;
2757
2815
  const entryById = new Map(entries.map((entry) => [entry.id, entry]));
@@ -2763,15 +2821,26 @@ function parseMobileBootPlan(html) {
2763
2821
  }
2764
2822
  if (layoutBatches.length !== 1 || layoutBatches[0]?.phase !== "application") throw new Error("upstream DSH boot manifest has no unique application layout batch");
2765
2823
  const layoutBatch = layoutBatches[0];
2766
- const planEntries = layoutBatch.entries.map((id) => {
2767
- const entry = entryById.get(id);
2768
- if (entry === void 0 || typeof entry.url !== "string" || typeof entry.rev !== "string") throw new Error("upstream DSH boot manifest batches are malformed");
2769
- return Object.freeze({
2770
- id,
2771
- url: entry.url,
2772
- rev: entry.rev
2773
- });
2774
- });
2824
+ const originalApplicationBatches = batches.filter((batch) => batch.phase === "application").map((batch) => ({
2825
+ ...batch,
2826
+ entries: [...batch.entries]
2827
+ }));
2828
+ excludeMobileBootEntries(entries, batches, excludedClientModules);
2829
+ const affectedBatchUrls = new Set(originalApplicationBatches.filter((batch) => batch.entries.some((id) => excludedClientModules.includes(id))).map((batch) => batch.url));
2830
+ const rewrittenBatchEntries = batches.filter((batch) => batch === layoutBatch || batch.phase === "application" && affectedBatchUrls.has(batch.url)).map((batch) => ({
2831
+ batch,
2832
+ entries: [...batch.entries].sort((left, right) => left < right ? -1 : left > right ? 1 : 0).map((id) => {
2833
+ const entry = entryById.get(id);
2834
+ if (entry === void 0 || typeof entry.url !== "string" || typeof entry.rev !== "string") throw new Error("upstream DSH boot manifest batches are malformed");
2835
+ return Object.freeze({
2836
+ id,
2837
+ url: entry.url,
2838
+ rev: entry.rev
2839
+ });
2840
+ })
2841
+ }));
2842
+ const planEntries = rewrittenBatchEntries.find((candidate) => candidate.batch === layoutBatch)?.entries;
2843
+ if (planEntries === void 0) throw new Error("upstream DSH boot manifest lost the application layout batch");
2775
2844
  return Object.freeze({
2776
2845
  parsed,
2777
2846
  entries,
@@ -2781,7 +2850,9 @@ function parseMobileBootPlan(html) {
2781
2850
  replaceStart,
2782
2851
  replaceEnd: scriptEnd,
2783
2852
  layoutBatch,
2784
- planEntries
2853
+ planEntries,
2854
+ originalApplicationBatches,
2855
+ rewrittenBatchEntries
2785
2856
  });
2786
2857
  }
2787
2858
  /**
@@ -2840,30 +2911,62 @@ function splitMobileBootBatch(entries, sizes, passThrough) {
2840
2911
  rows
2841
2912
  };
2842
2913
  }
2914
+ function escapeMobilePreloadAttribute(value) {
2915
+ return value.replaceAll("&", "&amp;").replaceAll("\"", "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
2916
+ }
2917
+ function mobileApplicationPreload(url) {
2918
+ return `<link rel="preload" as="script" href="${escapeMobilePreloadAttribute(url)}">`;
2919
+ }
2920
+ function preloadAttribute(tag, name) {
2921
+ const match = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "iu").exec(tag);
2922
+ return match?.[1] ?? match?.[2];
2923
+ }
2924
+ /** Replace only DSH's application-script preloads; bootstrap and unrelated links are untouched. */
2925
+ function rewriteMobileApplicationPreloads(html, replacements) {
2926
+ if (replacements.size === 0) return html;
2927
+ const escaped = new Map([...replacements].map(([url, markup]) => [escapeMobilePreloadAttribute(url), markup]));
2928
+ return html.replace(/<link\b[^>]*>/giu, (tag) => {
2929
+ if (!preloadAttribute(tag, "rel")?.split(/\s+/u).includes("preload") || preloadAttribute(tag, "as") !== "script") return tag;
2930
+ return escaped.get(preloadAttribute(tag, "href") ?? "") ?? tag;
2931
+ });
2932
+ }
2843
2933
  function rewriteMobileIndexWithBatch(html, options = {}) {
2844
- const plan = parseMobileBootPlan(html);
2934
+ const plan = parseMobileBootPlan(html, options.excludedClientModules);
2845
2935
  const remoteSettings = orderAuthenticatedSettings(plan.entries, plan.slotProvider);
2846
2936
  let batches = [];
2847
- if (plan.batches !== void 0 && plan.layoutBatch !== void 0 && plan.planEntries !== void 0) {
2848
- const split = splitMobileBootBatch(plan.planEntries, options.sizes ?? /* @__PURE__ */ new Map(), options.passThrough ?? /* @__PURE__ */ new Set());
2849
- const at = plan.batches.indexOf(plan.layoutBatch);
2850
- if (at < 0) throw new Error("upstream DSH boot manifest has no unique application layout batch");
2851
- plan.batches.splice(at, 1, ...split.rows);
2852
- batches = Object.freeze(split.plans);
2937
+ let preloadReplacements = /* @__PURE__ */ new Map();
2938
+ if (plan.batches !== void 0 && plan.rewrittenBatchEntries !== void 0) {
2939
+ const rewrites = plan.rewrittenBatchEntries.map(({ batch, entries }) => ({
2940
+ batch,
2941
+ split: splitMobileBootBatch(entries, options.sizes ?? /* @__PURE__ */ new Map(), options.passThrough ?? /* @__PURE__ */ new Set())
2942
+ }));
2943
+ if (rewrites.reduce((count, item) => count + item.split.plans.length, 0) > MAX_MOBILE_BOOT_BATCHES) throw new Error(`upstream DSH mobile boot requires more than ${String(MAX_MOBILE_BOOT_BATCHES)} merged batches`);
2944
+ for (const { batch, split } of rewrites) {
2945
+ const at = plan.batches.indexOf(batch);
2946
+ if (at < 0) throw new Error("upstream DSH boot manifest lost an application batch");
2947
+ plan.batches.splice(at, 1, ...split.rows);
2948
+ batches = [...batches, ...split.plans];
2949
+ }
2950
+ for (const original of plan.originalApplicationBatches ?? []) {
2951
+ const replacement = rewrites.find((item) => item.batch.url === original.url);
2952
+ preloadReplacements.set(original.url, replacement === void 0 ? plan.batches.some((batch) => batch.url === original.url) ? mobileApplicationPreload(original.url) : "" : replacement.split.rows.map((row) => mobileApplicationPreload(row.url)).join(""));
2953
+ }
2954
+ batches = Object.freeze(batches);
2853
2955
  plan.parsed.rev = createHash("sha256").update(JSON.stringify({
2854
2956
  entries: plan.entries,
2855
2957
  batches: plan.batches
2856
2958
  })).digest("hex").slice(0, 16);
2857
2959
  }
2858
2960
  const replacement = `${MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP}${remoteSettings ? MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP : ""}${MOBILE_CSRF_FETCH_BOOTSTRAP}window.__DSH_MOBILE_FRONTEND__="dedicated";${plan.assignment}${JSON.stringify(plan.parsed)};`;
2961
+ const rewritten = `${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`;
2859
2962
  return Object.freeze({
2860
- html: ensureMobileViewport(ensureMobileCompatibility(`${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`)),
2963
+ html: ensureMobileViewport(ensureMobileCompatibility(rewriteMobileApplicationPreloads(rewritten, preloadReplacements))),
2861
2964
  batches
2862
2965
  });
2863
2966
  }
2864
- /** Replace only DSH's layout client module while retaining its complete plugin graph. */
2865
- function rewriteMobileIndex(html) {
2866
- return rewriteMobileIndexWithBatch(html).html;
2967
+ /** Rewrite the dedicated mobile graph; exclusions are opt-in and leave stock DSH unchanged. */
2968
+ function rewriteMobileIndex(html, excludedClientModules = []) {
2969
+ return rewriteMobileIndexWithBatch(html, { excludedClientModules }).html;
2867
2970
  }
2868
2971
  var ByteLimitTransform = class extends Transform {
2869
2972
  maximum;
@@ -3307,6 +3410,7 @@ var MobileAccessGateway = class {
3307
3410
  mdnsError;
3308
3411
  /** Names of the bundled mobile assets that could not be read, if any. */
3309
3412
  mobileAssetError;
3413
+ lastModuleExclusionError;
3310
3414
  bonjour;
3311
3415
  pairingCaCertificate;
3312
3416
  listenerPort;
@@ -4276,11 +4380,26 @@ var MobileAccessGateway = class {
4276
4380
  let body;
4277
4381
  try {
4278
4382
  const html = Buffer.concat(chunks).toString("utf8");
4279
- const plan = parseMobileBootPlan(html);
4280
- const rewritten = rewriteMobileIndexWithBatch(html, plan.planEntries === void 0 ? {} : await this.resolveMobileBootSizes(plan.planEntries));
4383
+ const plan = parseMobileBootPlan(html, this.config.excludedClientModules);
4384
+ const rewritten = rewriteMobileIndexWithBatch(html, plan.rewrittenBatchEntries === void 0 ? { excludedClientModules: this.config.excludedClientModules } : {
4385
+ ...await this.resolveMobileBootSizes(plan.rewrittenBatchEntries.flatMap((batch) => batch.entries)),
4386
+ excludedClientModules: this.config.excludedClientModules
4387
+ });
4281
4388
  for (const batch of rewritten.batches) this.rememberMobileBootBatch(batch);
4282
4389
  body = Buffer.from(rewritten.html);
4283
- } catch {
4390
+ } catch (error) {
4391
+ if (error instanceof ExcludedClientModulesError) {
4392
+ const detail = error.message.replace(/[\r\n]/gu, " ").slice(0, 512);
4393
+ if (detail !== this.lastModuleExclusionError) {
4394
+ this.lastModuleExclusionError = detail;
4395
+ process.emitWarning(detail, { code: "DSH_MOBILE_MODULE_EXCLUSION_INVALID" });
4396
+ }
4397
+ sendJson(response, 409, {
4398
+ error: "excluded_client_modules_invalid",
4399
+ detail
4400
+ }, this.tlsEnabled);
4401
+ return;
4402
+ }
4284
4403
  throw new HttpError(502, "upstream_unavailable");
4285
4404
  }
4286
4405
  const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
@@ -4965,14 +5084,14 @@ var MobileAccessGateway = class {
4965
5084
  }
4966
5085
  }
4967
5086
  /** Loopback-only DSH WebServer route for opening pairing and managing devices. */
4968
- localAdminRoute(prefix = LOCAL_ADMIN_PREFIX) {
5087
+ localAdminRoute(prefix = LOCAL_ADMIN_PREFIX, authenticateDesktop) {
4969
5088
  return {
4970
5089
  kind: "prefix",
4971
5090
  path: prefix,
4972
5091
  handler: async (request, response) => {
4973
5092
  try {
4974
5093
  const target = parseRequestTarget(request.url);
4975
- assertLocalAdminTrust(request, request.method === "POST");
5094
+ assertLocalAdminTrust(request, request.method === "POST", () => authenticateDesktop?.(request) === true);
4976
5095
  if (target.search !== "") throw new HttpError(400, "bad_request");
4977
5096
  if (request.method === "GET" && target.decodedPathname === `${prefix}/status`) {
4978
5097
  sendJson(response, 200, {
@@ -9389,6 +9508,10 @@ var TaskEventHub = class {
9389
9508
  };
9390
9509
  //#endregion
9391
9510
  //#region src/diagnostics.ts
9511
+ /** Recognize the active remote-web-ui boot hook without evaluating or modifying it. */
9512
+ function hasCompetingRemoteChannelBoot(rows) {
9513
+ return rows.some((row) => row.kind === "script" && row.text?.includes("__DSH_REMOTE_CHANNEL_BOOT__") === true);
9514
+ }
9392
9515
  const REMOTE_ERROR_GUIDANCE = Object.freeze({
9393
9516
  component_missing: "重新安装完整插件包。",
9394
9517
  funnel_permission_required: "继续完成 Tailscale Funnel 授权。",
@@ -9783,6 +9906,7 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
9783
9906
  const remoteProbe = snapshot.remote.provider !== "origin" && snapshot.remote.running && snapshot.remote.state === "ready" && snapshot.remote.origin !== void 0 ? (probes.remote ?? defaultRemoteProbe)(snapshot.remote.origin) : Promise.resolve({ state: "not-applicable" });
9784
9907
  const [firewall, remoteObservation] = await Promise.all([(probes.firewall ?? defaultFirewallProbe())(snapshot.lan.port), remoteProbe]);
9785
9908
  checks.push(check("versions", "ok", "versions-current", "版本兼容", `插件 ${DSH_MOBILE_VERSION},DSH ${snapshot.dshVersion},Android App 最低 ${MINIMUM_ANDROID_APP_VERSION}。`));
9909
+ if (snapshot.competingRemoteChannelBoot === true) checks.push(check("remote-plugin-conflict", "warning", "competing-remote-channel", "第三方远程插件", "检测到另一套远程请求通道脚本;手机页面可能进入该插件自己的配对流程。", "在电脑端关闭 dsh-remote-web-ui 的远程访问后刷新,再使用 DSH Mobile 当前二维码配对;不要混用两种配对码。"));
9786
9910
  if (snapshot.lan.configured === false) checks.push(check("network", "error", "lan-setup-required", "局域网配置", "尚未完成局域网初始化。", "返回局域网页选择网卡并完成配置。"));
9787
9911
  else if (snapshot.lan.networkError !== void 0) checks.push(check("network", "error", "network-unavailable", "局域网网卡", "已保存的网卡当前不可用。", "重新运行 dsh-mobile setup。"));
9788
9912
  else if (snapshot.lan.configuredInterface !== void 0) {
@@ -11731,6 +11855,87 @@ async function regularFile(file, expectedBytes) {
11731
11855
  throw error;
11732
11856
  }
11733
11857
  }
11858
+ async function installStep(code, operation) {
11859
+ try {
11860
+ return await operation();
11861
+ } catch (error) {
11862
+ if (error instanceof Error && error.message.startsWith("cpolar_")) throw error;
11863
+ throw new Error(code, { cause: error });
11864
+ }
11865
+ }
11866
+ async function pathExists(path) {
11867
+ try {
11868
+ await lstat(path);
11869
+ return true;
11870
+ } catch (error) {
11871
+ if (error.code === "ENOENT") return false;
11872
+ throw error;
11873
+ }
11874
+ }
11875
+ /** Replace only this version's verified binary, retaining the previous directory until promotion succeeds. */
11876
+ async function publishVerifiedCpolarExecutable(extracted, componentRoot, componentStorage, executableName, operations = {}) {
11877
+ if (dirname(componentStorage) !== componentRoot || !inside(componentRoot, componentStorage) || basename(executableName) !== executableName || executableName === "." || executableName === "..") throw new Error("cpolar component storage escaped its private root");
11878
+ const move = operations.move ?? rename;
11879
+ const remove = operations.remove ?? ((path) => rm(path, {
11880
+ recursive: true,
11881
+ force: true
11882
+ }));
11883
+ const candidate = join(componentRoot, `.install-${randomBytes(12).toString("hex")}`);
11884
+ const backup = join(componentRoot, `.previous-${randomBytes(12).toString("hex")}`);
11885
+ let backupContainsPrevious = false;
11886
+ let replacementInstalled = false;
11887
+ try {
11888
+ await mkdir(candidate, {
11889
+ recursive: true,
11890
+ mode: 448
11891
+ });
11892
+ await copyFile(extracted, join(candidate, executableName));
11893
+ await chmod(join(candidate, executableName), 448);
11894
+ if (await pathExists(componentStorage)) {
11895
+ await move(componentStorage, backup);
11896
+ backupContainsPrevious = true;
11897
+ }
11898
+ try {
11899
+ await move(candidate, componentStorage);
11900
+ replacementInstalled = true;
11901
+ } catch (error) {
11902
+ if (backupContainsPrevious) try {
11903
+ await move(backup, componentStorage);
11904
+ backupContainsPrevious = false;
11905
+ } catch (rollbackError) {
11906
+ process.emitWarning(`cpolar previous component remains at ${backup}; automatic rollback failed`, { code: "DSH_MOBILE_CPOLAR_ROLLBACK_FAILED" });
11907
+ throw new Error("cpolar_storage_failed", { cause: new AggregateError([error, rollbackError]) });
11908
+ }
11909
+ throw error;
11910
+ }
11911
+ } finally {
11912
+ try {
11913
+ await remove(candidate);
11914
+ } catch {
11915
+ process.emitWarning(`cpolar temporary component remains at ${candidate}; cleanup failed`, { code: "DSH_MOBILE_CPOLAR_CLEANUP_FAILED" });
11916
+ }
11917
+ if (replacementInstalled && backupContainsPrevious) try {
11918
+ await remove(backup);
11919
+ } catch {
11920
+ process.emitWarning(`cpolar previous component remains at ${backup}; cleanup failed`, { code: "DSH_MOBILE_CPOLAR_CLEANUP_FAILED" });
11921
+ }
11922
+ }
11923
+ }
11924
+ /** Remove only this install's staging directory without replacing a preceding failure. */
11925
+ async function cleanupCpolarInstallStaging(staging, installFailed, remove = (path) => rm(path, {
11926
+ recursive: true,
11927
+ force: true
11928
+ })) {
11929
+ try {
11930
+ await remove(staging);
11931
+ } catch (error) {
11932
+ if (installFailed) {
11933
+ process.emitWarning(`cpolar staging directory remains at ${staging}; cleanup failed`, { code: "DSH_MOBILE_CPOLAR_CLEANUP_FAILED" });
11934
+ return;
11935
+ }
11936
+ throw new Error("cpolar_storage_failed", { cause: error });
11937
+ }
11938
+ }
11734
11939
  async function run(file, args) {
11735
11940
  await new Promise((resolveRun, reject) => {
11736
11941
  execFile(file, [...args], {
@@ -11885,11 +12090,14 @@ var CpolarComponentManager = class {
11885
12090
  return this.enqueue(async () => {
11886
12091
  const release = this.release;
11887
12092
  if (release === void 0) throw new Error("cpolar_component_unsupported");
11888
- await mkdir(this.stagingRoot, {
11889
- recursive: true,
11890
- mode: 448
12093
+ const staging = await installStep("cpolar_storage_failed", async () => {
12094
+ await mkdir(this.stagingRoot, {
12095
+ recursive: true,
12096
+ mode: 448
12097
+ });
12098
+ return mkdtemp(join(this.stagingRoot, "install-"));
11891
12099
  });
11892
- const staging = await mkdtemp(join(this.stagingRoot, "install-"));
12100
+ let installFailed = false;
11893
12101
  try {
11894
12102
  const controller = new AbortController();
11895
12103
  const timeout = setTimeout(() => {
@@ -11898,38 +12106,35 @@ var CpolarComponentManager = class {
11898
12106
  timeout.unref();
11899
12107
  let bytes;
11900
12108
  try {
11901
- bytes = await this.fetchArtifact(release.downloadUrl, controller.signal);
12109
+ bytes = await installStep("cpolar_download_failed", () => this.fetchArtifact(release.downloadUrl, controller.signal));
12110
+ } catch (error) {
12111
+ if (controller.signal.aborted) throw new Error("cpolar_download_timeout", { cause: error });
12112
+ throw error;
11902
12113
  } finally {
11903
12114
  clearTimeout(timeout);
11904
12115
  }
11905
12116
  if (createHash("sha256").update(bytes).digest("hex") !== release.downloadSha256) throw new Error("cpolar_download_hash_mismatch");
11906
12117
  const archive = join(staging, release.archiveKind === "tar.gz" ? "cpolar.tar.gz" : "cpolar.zip");
11907
- await writeFile(archive, bytes, {
12118
+ await installStep("cpolar_storage_failed", () => writeFile(archive, bytes, {
11908
12119
  flag: "wx",
11909
12120
  mode: 384
11910
- });
11911
- await this.extractArtifact(archive, staging);
12121
+ }));
12122
+ await installStep("cpolar_extract_failed", () => this.extractArtifact(archive, staging));
11912
12123
  const extracted = join(staging, release.executableName);
11913
- if (!await regularFile(extracted, release.executableBytes) || await sha256(extracted) !== release.executableSha256) throw new Error("cpolar_executable_hash_mismatch");
11914
- const candidate = join(this.componentRoot, `.install-${randomBytes(12).toString("hex")}`);
11915
- await mkdir(candidate, {
11916
- recursive: true,
11917
- mode: 448
11918
- });
11919
- await copyFile(extracted, join(candidate, release.executableName));
11920
- await chmod(join(candidate, release.executableName), 448);
11921
- await rm(this.componentStorage, {
11922
- recursive: true,
11923
- force: true
11924
- });
11925
- await rename(candidate, this.componentStorage);
12124
+ if (!await installStep("cpolar_extract_failed", async () => await regularFile(extracted, release.executableBytes) && await sha256(extracted) === release.executableSha256)) throw new Error("cpolar_executable_hash_mismatch");
12125
+ await installStep("cpolar_storage_failed", () => publishVerifiedCpolarExecutable(extracted, this.componentRoot, this.componentStorage, release.executableName));
11926
12126
  this.installed = true;
11927
12127
  this.errorCode = void 0;
12128
+ } catch (error) {
12129
+ installFailed = true;
12130
+ try {
12131
+ this.installed = await regularFile(this.executable, release.executableBytes) && await sha256(this.executable) === release.executableSha256;
12132
+ } catch (_inspectionError) {
12133
+ this.installed = false;
12134
+ }
12135
+ throw error;
11928
12136
  } finally {
11929
- await rm(staging, {
11930
- recursive: true,
11931
- force: true
11932
- });
12137
+ await cleanupCpolarInstallStaging(staging, installFailed);
11933
12138
  }
11934
12139
  });
11935
12140
  }
@@ -13103,6 +13308,7 @@ async function apply(ctx, config) {
13103
13308
  } : void 0;
13104
13309
  return collectConnectionDiagnostics({
13105
13310
  dshVersion,
13311
+ competingRemoteChannelBoot: hasCompetingRemoteChannelBoot(ctx.webServer.collectIndexInjections?.() ?? []),
13106
13312
  lan: {
13107
13313
  configured: loaded.kind !== "unconfigured" || preparedLanSetup !== void 0,
13108
13314
  running: lanController.isRunning(),
@@ -13126,13 +13332,17 @@ async function apply(ctx, config) {
13126
13332
  }
13127
13333
  }, pinnedFrpProbe === void 0 ? {} : { remote: pinnedFrpProbe });
13128
13334
  };
13335
+ const authenticateDesktopAdmin = (request) => {
13336
+ const connection = ctx.connection;
13337
+ return typeof connection?.requestRejection === "function" && connection.requestRejection(request) === void 0;
13338
+ };
13129
13339
  const adminRoute = {
13130
13340
  kind: "prefix",
13131
13341
  path: LOCAL_ADMIN_PREFIX,
13132
13342
  handler: async (request, response) => {
13133
13343
  try {
13134
13344
  const target = parseRequestTarget(request.url);
13135
- assertLocalAdminTrust(request, request.method === "POST");
13345
+ assertLocalAdminTrust(request, request.method === "POST", () => authenticateDesktopAdmin(request));
13136
13346
  if (target.search !== "") throw new HttpError(400, "bad_request");
13137
13347
  const lanControl = target.decodedPathname === `/api/mobile-access/control` || target.decodedPathname === `/api/mobile-access/lan/control`;
13138
13348
  if (request.method === "GET" && lanControl) {
@@ -13221,7 +13431,15 @@ async function apply(ctx, config) {
13221
13431
  }
13222
13432
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cpolar/component/install`) {
13223
13433
  if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
13224
- await remoteProviders.mutate(async () => cpolarComponent.install());
13434
+ logger.info("cpolar component install started");
13435
+ try {
13436
+ await remoteProviders.mutate(async () => cpolarComponent.install());
13437
+ logger.info("cpolar component install completed");
13438
+ } catch (error) {
13439
+ const cause = error instanceof Error && error.cause instanceof Error ? error.cause : void 0;
13440
+ logger.error("cpolar component install failed: %s%s", error instanceof Error ? error.stack ?? error.message : String(error), cause === void 0 ? "" : `; cause: ${cause.message}`);
13441
+ throw error;
13442
+ }
13225
13443
  sendJson(response, 200, remotePayload(), false);
13226
13444
  return;
13227
13445
  }
@@ -13464,18 +13682,18 @@ async function apply(ctx, config) {
13464
13682
  if (target.decodedPathname.startsWith(`/api/mobile-access/remote/`)) {
13465
13683
  const active = remoteController().gateway();
13466
13684
  if (active === void 0) throw new HttpError(409, "gateway_stopped");
13467
- await active.localAdminRoute(`${LOCAL_ADMIN_PREFIX}/remote`).handler(request, response);
13685
+ await active.localAdminRoute(`${LOCAL_ADMIN_PREFIX}/remote`, authenticateDesktopAdmin).handler(request, response);
13468
13686
  return;
13469
13687
  }
13470
13688
  if (target.decodedPathname.startsWith(`/api/mobile-access/lan/`)) {
13471
13689
  const active = lanGateway;
13472
13690
  if (active === void 0) throw new HttpError(409, "gateway_stopped");
13473
- await active.localAdminRoute(`${LOCAL_ADMIN_PREFIX}/lan`).handler(request, response);
13691
+ await active.localAdminRoute(`${LOCAL_ADMIN_PREFIX}/lan`, authenticateDesktopAdmin).handler(request, response);
13474
13692
  return;
13475
13693
  }
13476
13694
  const active = lanGateway;
13477
13695
  if (active === void 0) throw new HttpError(409, "gateway_stopped");
13478
- await active.localAdminRoute().handler(request, response);
13696
+ await active.localAdminRoute(LOCAL_ADMIN_PREFIX, authenticateDesktopAdmin).handler(request, response);
13479
13697
  } catch (error) {
13480
13698
  const mapped = mapAdminError(error);
13481
13699
  if (response.headersSent) response.destroy();