dsh-mobile 0.6.6 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.d.mts CHANGED
@@ -333,6 +333,15 @@ interface PluginConfig {
333
333
  keyFile?: string;
334
334
  caFile?: string;
335
335
  };
336
+ /**
337
+ * Reserved cpolar subdomain passed through as `cpolar http -subdomain`.
338
+ *
339
+ * A compatible account reservation and region are required. Unset or blank values
340
+ * keep provider-selected allocation; credentials may still expire or be revoked.
341
+ */
342
+ cpolarSubdomain?: string;
343
+ /** cpolar region for `-region`; use the region chosen when reserving the subdomain. */
344
+ cpolarRegion?: string;
336
345
  pairingTtlMs?: number;
337
346
  deviceTtlMs?: number;
338
347
  sessionTtlMs?: number;
@@ -664,8 +673,8 @@ declare class ExtensionWorkerHost {
664
673
  private cancelStream;
665
674
  }
666
675
  //#endregion
667
- //#region src/extensions.d.ts
668
- /** Maximum sizes enforced at the local-extension filesystem boundary. */
676
+ //#region src/extension-limits.d.ts
677
+ /** Maximum sizes enforced when reading local extension files. */
669
678
  export declare const EXTENSION_LIMITS: Readonly<{
670
679
  manifest: number;
671
680
  script: number;
@@ -675,6 +684,8 @@ export declare const EXTENSION_LIMITS: Readonly<{
675
684
  assetBytes: number;
676
685
  assetDepth: 8;
677
686
  }>;
687
+ //#endregion
688
+ //#region src/extensions.d.ts
678
689
  /** A controlled business failure returned by an extension action or route. */
679
690
  export declare class MobileExtensionError extends Error {
680
691
  readonly code: string;
package/lib/index.mjs CHANGED
@@ -956,6 +956,8 @@ const Config = z.object({
956
956
  keyFile: z.string(),
957
957
  caFile: z.string()
958
958
  }),
959
+ cpolarSubdomain: z.string(),
960
+ cpolarRegion: z.string(),
959
961
  pairingTtlMs: z.natural(),
960
962
  deviceTtlMs: z.natural(),
961
963
  sessionTtlMs: z.natural(),
@@ -1991,6 +1993,13 @@ function attributeValue(tag, name) {
1991
1993
  function commentRanges(html) {
1992
1994
  return [...html.matchAll(/<!--[\s\S]*?-->/gu)].map((match) => [match.index, match.index + match[0].length]);
1993
1995
  }
1996
+ /** Mark an authenticated Gateway document without changing its layout, transport or module selection. */
1997
+ function ensureMobileGatewayMarker(html) {
1998
+ const comments = commentRanges(html);
1999
+ const first = [...html.matchAll(/<script\b[^>]*>/giu)].find((match) => !comments.some(([start, end]) => match.index >= start && match.index < end));
2000
+ if (first === void 0) return html;
2001
+ return `${html.slice(0, first.index)}<meta name="dsh-mobile-gateway" content="authenticated">${html.slice(first.index)}`;
2002
+ }
1994
2003
  /**
1995
2004
  * Load the compatibility bundle synchronously, before the first DSH script.
1996
2005
  *
@@ -2010,6 +2019,16 @@ function ensureMobileCompatibility(html) {
2010
2019
  const script = `<script src="${MOBILE_COMPAT_PATH}"${attribute}><\/script>`;
2011
2020
  return `${html.slice(0, first.index)}${script}${html.slice(first.index)}`;
2012
2021
  }
2022
+ /** Maximum sizes enforced when reading local extension files. */
2023
+ const EXTENSION_LIMITS = Object.freeze({
2024
+ manifest: 65536,
2025
+ script: 1048576,
2026
+ css: 524288,
2027
+ asset: 8388608,
2028
+ assetFiles: 256,
2029
+ assetBytes: 33554432,
2030
+ assetDepth: 8
2031
+ });
2013
2032
  //#endregion
2014
2033
  //#region src/http-cache.ts
2015
2034
  /** Entity-tag handling for authenticated GET and HEAD cache validation. */
@@ -3066,16 +3085,6 @@ var WorkerStreamBridge = class extends Readable {
3066
3085
  };
3067
3086
  //#endregion
3068
3087
  //#region src/extensions.ts
3069
- /** Maximum sizes enforced at the local-extension filesystem boundary. */
3070
- const EXTENSION_LIMITS = Object.freeze({
3071
- manifest: 65536,
3072
- script: 1048576,
3073
- css: 524288,
3074
- asset: 8388608,
3075
- assetFiles: 256,
3076
- assetBytes: 33554432,
3077
- assetDepth: 8
3078
- });
3079
3088
  /** A misbehaving host activation must not wedge the local watcher forever. */
3080
3089
  const HOST_ACTIVATION_TIMEOUT_MS = 5e3;
3081
3090
  /** The previous Host outlives the hidden-page refresh interval and one timed refresh. */
@@ -4811,7 +4820,7 @@ function rewriteMobileIndexWithBatch(html, options = {}) {
4811
4820
  const replacement = `${MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP}${remoteSettings ? MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP : ""}${mobileCsrfFetchBootstrap(options.csrfCookie ?? "dsh_ma_csrf")}window.__DSH_MOBILE_FRONTEND__="dedicated";${plan.assignment}${JSON.stringify(plan.parsed)};`;
4812
4821
  const rewritten = `${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`;
4813
4822
  return Object.freeze({
4814
- html: ensureMobileViewport(ensureMobileCompatibility(rewriteMobileApplicationPreloads(rewritten, preloadReplacements))),
4823
+ html: ensureMobileGatewayMarker(ensureMobileViewport(ensureMobileCompatibility(rewriteMobileApplicationPreloads(rewritten, preloadReplacements)))),
4815
4824
  batches
4816
4825
  });
4817
4826
  }
@@ -5986,8 +5995,9 @@ var MobileAccessGateway = class {
5986
5995
  const stockFrontend = new URL(target.raw, this.address().origin).searchParams.get("frontend") === "stock";
5987
5996
  const acceptsHtml = request.headers.accept?.split(",").some((value) => value.trim().split(";", 1)[0] === "text/html") ?? false;
5988
5997
  const frontendDocument = target.decodedPathname === "/" || target.decodedPathname === "/index.html";
5989
- if (request.method === "GET" && frontendDocument && acceptsHtml && !stockFrontend) {
5990
- await this.proxyMobileIndex(request, response, authorization);
5998
+ if (request.method === "GET" && frontendDocument && acceptsHtml) {
5999
+ if (stockFrontend && target.decodedPathname === "/") request.url = "/";
6000
+ await this.proxyMobileIndex(request, response, authorization, stockFrontend);
5991
6001
  return;
5992
6002
  }
5993
6003
  if (stockFrontend && target.decodedPathname === "/") request.url = "/";
@@ -6378,6 +6388,8 @@ var MobileAccessGateway = class {
6378
6388
  signal.throwIfAborted();
6379
6389
  if (upstreamCookie !== void 0) headers.cookie = upstreamCookie;
6380
6390
  headers["accept-encoding"] = "identity";
6391
+ delete headers["if-none-match"];
6392
+ delete headers["if-modified-since"];
6381
6393
  const proxied = await new Promise((resolve, reject) => {
6382
6394
  upstreamRequest = request({
6383
6395
  protocol: "http:",
@@ -6417,7 +6429,7 @@ var MobileAccessGateway = class {
6417
6429
  upstreamRequest?.destroy();
6418
6430
  }
6419
6431
  }
6420
- async proxyMobileIndex(request, response, authorization) {
6432
+ async proxyMobileIndex(request, response, authorization, stockFrontend = false) {
6421
6433
  const holder = {};
6422
6434
  const operation = this.allocateRequest(authorization, response, holder);
6423
6435
  const onClose = () => {
@@ -6427,50 +6439,53 @@ var MobileAccessGateway = class {
6427
6439
  response.once("close", onClose);
6428
6440
  try {
6429
6441
  const index = await this.fetchAuthenticatedUpstreamIndex(operation.signal, request);
6430
- const preference = await this.clientModulePreferences.read(authorization.deviceId);
6431
- const excludedClientModules = preference.excludedClientModules;
6432
6442
  let body;
6433
- try {
6434
- const html = index.html;
6435
- const plan = parseMobileBootPlan(html, excludedClientModules);
6436
- const options = plan.rewrittenBatchEntries === void 0 ? { excludedClientModules } : {
6437
- ...await this.resolveMobileBootSizes(plan.rewrittenBatchEntries.flatMap((batch) => batch.entries), operation.signal),
6438
- excludedClientModules
6439
- };
6440
- const cookies = browserAuthCookies(request.headers.cookie);
6441
- const csrfCookie = this.tlsEnabled && cookies.has("__Host-dsh_ma_session") ? HOST_CSRF_COOKIE : CSRF_COOKIE;
6442
- const rewritten = rewriteMobileIndexWithBatch(html, {
6443
- ...options,
6444
- csrfCookie
6445
- });
6446
- for (const batch of rewritten.batches) this.rememberMobileBootBatch(batch);
6447
- body = Buffer.from(rewritten.html);
6448
- } catch (error) {
6449
- if (error instanceof ExcludedClientModulesError) {
6450
- const detail = error.message.replace(/[\r\n]/gu, " ").slice(0, 512);
6451
- if (detail !== this.lastModuleExclusionError) {
6452
- this.lastModuleExclusionError = detail;
6453
- process.emitWarning(detail, { code: "DSH_MOBILE_MODULE_EXCLUSION_INVALID" });
6454
- }
6455
- if (preference.source === "device" || preference.source === "computer") {
6456
- operation.signal.throwIfAborted();
6457
- const locale = resolveAuthPageLocale(headerValue(request.headers, "accept-language"));
6458
- const recovery = Buffer.from(renderClientModuleRecoveryPage(locale));
6459
- setSecurityHeaders(response, this.tlsEnabled);
6460
- response.writeHead(200, {
6461
- "Content-Type": "text/html; charset=utf-8",
6462
- "Content-Length": recovery.byteLength
6463
- });
6464
- response.end(recovery);
6443
+ if (stockFrontend) body = Buffer.from(ensureMobileGatewayMarker(index.html));
6444
+ else {
6445
+ const preference = await this.clientModulePreferences.read(authorization.deviceId);
6446
+ const excludedClientModules = preference.excludedClientModules;
6447
+ try {
6448
+ const html = index.html;
6449
+ const plan = parseMobileBootPlan(html, excludedClientModules);
6450
+ const options = plan.rewrittenBatchEntries === void 0 ? { excludedClientModules } : {
6451
+ ...await this.resolveMobileBootSizes(plan.rewrittenBatchEntries.flatMap((batch) => batch.entries), operation.signal),
6452
+ excludedClientModules
6453
+ };
6454
+ const cookies = browserAuthCookies(request.headers.cookie);
6455
+ const csrfCookie = this.tlsEnabled && cookies.has("__Host-dsh_ma_session") ? HOST_CSRF_COOKIE : CSRF_COOKIE;
6456
+ const rewritten = rewriteMobileIndexWithBatch(html, {
6457
+ ...options,
6458
+ csrfCookie
6459
+ });
6460
+ for (const batch of rewritten.batches) this.rememberMobileBootBatch(batch);
6461
+ body = Buffer.from(rewritten.html);
6462
+ } catch (error) {
6463
+ if (error instanceof ExcludedClientModulesError) {
6464
+ const detail = error.message.replace(/[\r\n]/gu, " ").slice(0, 512);
6465
+ if (detail !== this.lastModuleExclusionError) {
6466
+ this.lastModuleExclusionError = detail;
6467
+ process.emitWarning(detail, { code: "DSH_MOBILE_MODULE_EXCLUSION_INVALID" });
6468
+ }
6469
+ if (preference.source === "device" || preference.source === "computer") {
6470
+ operation.signal.throwIfAborted();
6471
+ const locale = resolveAuthPageLocale(headerValue(request.headers, "accept-language"));
6472
+ const recovery = Buffer.from(renderClientModuleRecoveryPage(locale));
6473
+ setSecurityHeaders(response, this.tlsEnabled);
6474
+ response.writeHead(200, {
6475
+ "Content-Type": "text/html; charset=utf-8",
6476
+ "Content-Length": recovery.byteLength
6477
+ });
6478
+ response.end(recovery);
6479
+ return;
6480
+ }
6481
+ sendJson(response, 409, {
6482
+ error: "excluded_client_modules_invalid",
6483
+ detail
6484
+ }, this.tlsEnabled);
6465
6485
  return;
6466
6486
  }
6467
- sendJson(response, 409, {
6468
- error: "excluded_client_modules_invalid",
6469
- detail
6470
- }, this.tlsEnabled);
6471
- return;
6487
+ throw new HttpError(502, "upstream_unavailable");
6472
6488
  }
6473
- throw new HttpError(502, "upstream_unavailable");
6474
6489
  }
6475
6490
  operation.signal.throwIfAborted();
6476
6491
  const headers = sanitizeResponseHeaders(index.headers, this.config.upstreamOrigin);
@@ -13281,11 +13296,11 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
13281
13296
  //#region src/mobile-guide.ts
13282
13297
  /** Compose the guide with the current customization state injected. */
13283
13298
  function buildMobileGuide(state) {
13284
- const styleLine = state.hasCustomCss ? "存在(当前生效的自定义样式)" : "不存在(使用内置默认样式)";
13285
- const scriptLine = state.hasCustomJs ? "存在(当前生效的自定义脚本)" : "不存在(无自定义脚本)";
13299
+ const styleLine = state.hasCustomCss ? "存在(已检测到文件,需在已配对页面验证加载)" : "不存在(使用内置默认样式)";
13300
+ const scriptLine = state.hasCustomJs ? "存在(已检测到文件,需在已配对页面验证加载)" : "不存在(无自定义脚本)";
13286
13301
  const extensionLines = state.extensions.length === 0 ? "(无)" : state.extensions.map((entry) => `- ${entry.id}(${entry.name} v${entry.version})${entry.executionMode === void 0 ? "" : ` · ${entry.executionMode}${entry.executionState === "unavailable" ? " · 当前不可用,需要显式重启扩展" : ""}`}`).join("\n");
13287
13302
  const failureLine = state.failedExtensionCount > 0 ? `注意:${state.failedExtensionCount} 个扩展的电脑端 host 激活失败,如改动相关扩展请先检查其 host.mjs 与 extension.json。` : "";
13288
- return `${`## 手机端当前状态(改动前必读,避免覆盖已有定制)
13303
+ return `${`## 本机定制文件与扩展状态(改动前必读,避免覆盖已有定制)
13289
13304
 
13290
13305
  - 定制目录:${state.directory}(所有改动只允许在这里进行)
13291
13306
  - mobile.css:${styleLine}
@@ -13309,7 +13324,9 @@ const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手
13309
13324
  1. 界面与交互 —— 只改外观和交互,不需要碰电脑的文件或程序:
13310
13325
  - $DSH_HOME/mobile-access/mobile.css:手机端样式
13311
13326
  - $DSH_HOME/mobile-access/mobile.js:手机端脚本,用 window.dshMobile.register(({ root }) => { ... }) 把内容挂载到 root,返回清理函数
13312
- - 保存后手机端几秒内自动应用,无需重启
13327
+ - 保存后已配对的 DSH Mobile 页面会自动检查并应用,无需重启;文件存在不表示某个页面已成功加载
13328
+
13329
+ 定制资源通过 DSH Mobile Gateway 的移动配对会话认证。普通 DSH 网页的登录凭据不等于移动配对凭据;直接打开普通 DSH 地址或仅用手机视口预览,不能据此验证 mobile.css / mobile.js。若扩展 manifest 返回 401,页面不会继续加载定制样式与脚本;先确认是通过“移动访问”配对入口进入,并检查实际请求与页面中的定制样式,不要盲改 CSS 或放松认证。核对 Cookie 时只确认是否存在,不读取或公开其值。
13313
13330
 
13314
13331
  动手前先读已有的定制文件。若要调整 DSH 自带的输入框、侧栏或设置等现有界面,先确认当前 DSH 版本与手机页面模式(专用移动页面或 ?frontend=stock),只读核对运行页面的 DOM、属性和样式;本机有同版本前端源码时可参考相应组件,没有源码时可查看已安装的客户端产物,不必下载源码。优先使用扩展 API、插槽和稳定的 data-* 标记,不凭猜测依赖构建类名。只读查看不改变前述写入范围。
13315
13332
 
@@ -14824,6 +14841,7 @@ const CPOLAR_HOST_SUFFIXES = Object.freeze([
14824
14841
  ".cpolar.top",
14825
14842
  ".cpolar.com"
14826
14843
  ]);
14844
+ const CPOLAR_SUBDOMAIN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/u;
14827
14845
  function publicStatus(status) {
14828
14846
  return Object.freeze({
14829
14847
  enabled: status.enabled,
@@ -14918,7 +14936,16 @@ var CpolarController = class {
14918
14936
  constructor(options) {
14919
14937
  this.options = options;
14920
14938
  if (!isAbsolute(options.executable) || !isAbsolute(options.configFile)) throw new Error("cpolar paths must be absolute");
14921
- if (options.region !== void 0 && !/^[a-z][a-z0-9_]{0,31}$/u.test(options.region)) throw new Error("cpolar region is invalid");
14939
+ const { subdomain: rawSubdomain, region: rawRegion, ...resources } = options;
14940
+ const subdomain = rawSubdomain?.trim() || void 0;
14941
+ const region = rawRegion?.trim() || void 0;
14942
+ if (region !== void 0 && !/^[a-z][a-z0-9_]{0,31}$/u.test(region)) throw new Error("cpolar region is invalid");
14943
+ if (subdomain !== void 0 && !CPOLAR_SUBDOMAIN_PATTERN.test(subdomain)) throw new Error("cpolar subdomain is invalid");
14944
+ this.options = {
14945
+ ...resources,
14946
+ ...subdomain === void 0 ? {} : { subdomain },
14947
+ ...region === void 0 ? {} : { region }
14948
+ };
14922
14949
  }
14923
14950
  /** Restore the remembered cpolar switch independently from LAN and Funnel state. */
14924
14951
  async initialize(initialState) {
@@ -15068,6 +15095,7 @@ var CpolarController = class {
15068
15095
  const args = [
15069
15096
  "http",
15070
15097
  `-config=${resolve(this.options.configFile)}`,
15098
+ ...this.options.subdomain === void 0 ? [] : [`-subdomain=${this.options.subdomain}`],
15071
15099
  ...this.options.region === void 0 ? [] : [`-region=${this.options.region}`],
15072
15100
  "-inspect-addr=false",
15073
15101
  "-redirect-https=true",
@@ -17271,6 +17299,8 @@ async function apply(ctx, config) {
17271
17299
  store: cpolarStore,
17272
17300
  executable: cpolarComponent.executable,
17273
17301
  configFile: cpolarComponent.configFile,
17302
+ ...config.cpolarSubdomain === void 0 ? {} : { subdomain: config.cpolarSubdomain },
17303
+ ...config.cpolarRegion === void 0 ? {} : { region: config.cpolarRegion },
17274
17304
  createGateway: createRemoteGateway
17275
17305
  }),
17276
17306
  cloudflared: new CloudflaredController({