dsh-mobile 0.6.2 → 0.6.4

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/CONTRIBUTORS.md +19 -4
  3. package/README.en.md +16 -8
  4. package/README.md +16 -8
  5. package/SECURITY.md +3 -2
  6. package/THIRD_PARTY_NOTICES.md +29 -1
  7. package/apps/mobile/README.md +25 -5
  8. package/apps/mobile/release.json +3 -3
  9. package/cordis.patch.yml +7 -3
  10. package/docs/ATTACH_EXISTING_FRPS.en.md +1 -1
  11. package/docs/ATTACH_EXISTING_FRPS.md +2 -2
  12. package/docs/CADDY_MANAGED.en.md +15 -1
  13. package/docs/CADDY_MANAGED.md +15 -1
  14. package/docs/CLOUDFLARE_TUNNEL.en.md +2 -2
  15. package/docs/CLOUDFLARE_TUNNEL.md +3 -3
  16. package/docs/EXTENSION_WORKERS.en.md +49 -0
  17. package/docs/EXTENSION_WORKERS.md +49 -0
  18. package/docs/MOBILE_COMPOSER_TOOLBAR.en.md +27 -0
  19. package/docs/MOBILE_COMPOSER_TOOLBAR.md +27 -0
  20. package/docs/README.md +2 -0
  21. package/docs/SELF_HOSTED_FRP.en.md +4 -2
  22. package/docs/SELF_HOSTED_FRP.md +4 -2
  23. package/docs/SLOW_CONNECTIONS.en.md +6 -0
  24. package/docs/SLOW_CONNECTIONS.md +6 -0
  25. package/docs/TRUSTED_NETWORKS.en.md +1 -1
  26. package/docs/TRUSTED_NETWORKS.md +1 -1
  27. package/lib/cli.js +140 -19
  28. package/lib/cli.js.map +1 -1
  29. package/lib/client.js +1264 -106
  30. package/lib/client.js.map +1 -1
  31. package/lib/extension-worker-runtime.mjs +1646 -0
  32. package/lib/extension-worker-runtime.mjs.map +1 -0
  33. package/lib/index.d.mts +209 -12
  34. package/lib/index.mjs +2152 -811
  35. package/lib/index.mjs.map +1 -1
  36. package/lib/mobile-layout.js.map +1 -1
  37. package/package.json +17 -6
  38. package/packages/question-fixes/LICENSE +201 -0
  39. package/packages/question-fixes/package.json +9 -2
  40. package/node_modules/dsh-mobile-question-fixes/lib/client.js +0 -78
  41. package/node_modules/dsh-mobile-question-fixes/lib/client.js.map +0 -1
  42. package/node_modules/dsh-mobile-question-fixes/lib/index.mjs +0 -14
  43. package/node_modules/dsh-mobile-question-fixes/lib/index.mjs.map +0 -1
  44. package/node_modules/dsh-mobile-question-fixes/package.json +0 -25
package/lib/client.js CHANGED
@@ -6,6 +6,59 @@ window.__ModuleLoader__.load({
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react = require("react");
8
8
  //#region src/client-messages.ts
9
+ const storageMessages = {
10
+ en: {
11
+ funnelStateInvalid: "The private Tailscale state folder cannot be accessed safely. Check its permissions or directory links; reinstalling the component is not required.",
12
+ componentPathUnsafe: "The component folder contains an unsafe link or cannot be accessed. Check the directory and permissions, then retry."
13
+ },
14
+ zh: {
15
+ funnelStateInvalid: "Tailscale 私有登录目录无法安全访问。请检查目录权限或链接,不必重新安装组件。",
16
+ componentPathUnsafe: "组件目录包含不安全的链接或无法访问。请检查目录和权限后重试。"
17
+ },
18
+ it: {
19
+ funnelStateInvalid: "La cartella privata Tailscale non è accessibile in sicurezza. Controlla permessi o collegamenti; non occorre reinstallare il componente.",
20
+ componentPathUnsafe: "La cartella del componente contiene un collegamento non sicuro o non è accessibile. Controlla directory e permessi, poi riprova."
21
+ }
22
+ };
23
+ /** Desktop-only stopped-worker recovery, independent of remote provider state. */
24
+ const EXTENSION_RECOVERY_MESSAGES = {
25
+ en: {
26
+ extensionRecoveryTitle: "An extension needs a restart",
27
+ extensionRecoveryHelp: "Only the stopped extension restarts. Unfinished actions are not retried.",
28
+ extensionRecoveryRestart: "Restart",
29
+ extensionRecoveryRestarting: "Restarting…",
30
+ extensionRecoveryRestartAria: "Restart extension {name}",
31
+ extensionRecoveryConfirm: "Restart extension “{name}”? Unfinished actions will not be retried. Previously completed changes will not be undone.",
32
+ extensionRecoveryRefresh: "Check again",
33
+ extensionRecoveryLoadFailed: "Could not check the extension status. Check again before restarting.",
34
+ extensionRecoveryFailed: "The extension did not restart. Check its configuration, then retry.",
35
+ extensionRecoveryChanged: "The extension files changed. Review the changes and reload the extension before trying again."
36
+ },
37
+ it: {
38
+ extensionRecoveryTitle: "Un’estensione richiede un riavvio",
39
+ extensionRecoveryHelp: "Si riavvia solo l’estensione arrestata. Le azioni incompiute non vengono ripetute.",
40
+ extensionRecoveryRestart: "Riavvia",
41
+ extensionRecoveryRestarting: "Riavvio…",
42
+ extensionRecoveryRestartAria: "Riavvia l’estensione {name}",
43
+ extensionRecoveryConfirm: "Riavviare l’estensione “{name}”? Le azioni incompiute non verranno ripetute. Le modifiche già completate non saranno annullate.",
44
+ extensionRecoveryRefresh: "Controlla di nuovo",
45
+ extensionRecoveryLoadFailed: "Impossibile verificare lo stato. Controlla di nuovo prima di riavviare.",
46
+ extensionRecoveryFailed: "L’estensione non si è riavviata. Controlla la configurazione e riprova.",
47
+ extensionRecoveryChanged: "I file dell’estensione sono cambiati. Verifica le modifiche e ricarica l’estensione prima di riprovare."
48
+ },
49
+ zh: {
50
+ extensionRecoveryTitle: "扩展需要重启",
51
+ extensionRecoveryHelp: "仅重启已停止的扩展,不会重新执行未完成的操作。",
52
+ extensionRecoveryRestart: "重启扩展",
53
+ extensionRecoveryRestarting: "正在重启…",
54
+ extensionRecoveryRestartAria: "重启扩展 {name}",
55
+ extensionRecoveryConfirm: "要重启扩展“{name}”吗?未完成的操作不会重新执行,已经完成的修改也不会撤销。",
56
+ extensionRecoveryRefresh: "重新检测",
57
+ extensionRecoveryLoadFailed: "暂时无法检查扩展状态,请重新检测后再操作。",
58
+ extensionRecoveryFailed: "扩展没有重启成功,请检查配置后重试。",
59
+ extensionRecoveryChanged: "扩展文件已经变化,请核对修改并重新加载扩展后再操作。"
60
+ }
61
+ };
9
62
  const MOBILE_CONTROL_MESSAGES = {
10
63
  en: {
11
64
  mobileAccess: "Mobile access",
@@ -345,6 +398,16 @@ window.__ModuleLoader__.load({
345
398
  },
346
399
  zh: {}
347
400
  };
401
+ for (const locale of [
402
+ "en",
403
+ "it",
404
+ "zh"
405
+ ]) Object.assign(MOBILE_CONTROL_MESSAGES[locale], EXTENSION_RECOVERY_MESSAGES[locale]);
406
+ for (const locale of [
407
+ "en",
408
+ "it",
409
+ "zh"
410
+ ]) Object.assign(MOBILE_CONTROL_MESSAGES[locale], storageMessages[locale]);
348
411
  Object.assign(MOBILE_CONTROL_MESSAGES.zh, {
349
412
  mobileAccess: "移动访问",
350
413
  collapseMobileAccess: "收起移动访问",
@@ -1136,7 +1199,9 @@ window.__ModuleLoader__.load({
1136
1199
  Object.assign(MOBILE_CONTROL_MESSAGES.en, {
1137
1200
  selfHostedDescription: "Use your own VPS or HTTPS reverse proxy without a public tunnel service.",
1138
1201
  originName: "Own reverse proxy",
1139
- originDescription: "You manage HTTPS. DSH Mobile provides a private, authenticated HTTP backend; no tunnel component is installed.",
1202
+ caddyName: "Managed Caddy",
1203
+ caddyModePreviewNote: "HTTPS form preview differs from the current connection.",
1204
+ originDescription: "Use managed Caddy or your existing HTTPS reverse proxy with DSH Mobile’s private, authenticated backend.",
1140
1205
  prepareOrigin: "Connect your HTTPS reverse proxy",
1141
1206
  originPublicOrigin: "Public HTTPS address",
1142
1207
  originListenHost: "Private listen IPv4",
@@ -1193,7 +1258,9 @@ window.__ModuleLoader__.load({
1193
1258
  Object.assign(MOBILE_CONTROL_MESSAGES.it, {
1194
1259
  selfHostedDescription: "Usa il tuo VPS o reverse proxy HTTPS senza un servizio di tunnel pubblico.",
1195
1260
  originName: "Reverse proxy proprio",
1196
- originDescription: "Gestisci tu HTTPS. DSH Mobile offre un backend HTTP privato con autenticazione; non installa componenti tunnel.",
1261
+ caddyName: "Caddy gestito",
1262
+ caddyModePreviewNote: "L’anteprima del modulo HTTPS differisce dalla connessione attuale.",
1263
+ originDescription: "Usa Caddy gestito o il tuo reverse proxy HTTPS con il backend privato autenticato di DSH Mobile.",
1197
1264
  prepareOrigin: "Collega il tuo reverse proxy HTTPS",
1198
1265
  originPublicOrigin: "Indirizzo HTTPS pubblico",
1199
1266
  originListenHost: "IPv4 privato di ascolto",
@@ -1250,7 +1317,9 @@ window.__ModuleLoader__.load({
1250
1317
  Object.assign(MOBILE_CONTROL_MESSAGES.zh, {
1251
1318
  selfHostedDescription: "使用自己的 VPS 或 HTTPS 反向代理,不依赖公共隧道服务。",
1252
1319
  originName: "自有反向代理",
1253
- originDescription: "HTTPS 由你管理,插件提供带认证的私有 HTTP 后端,无需安装隧道组件。",
1320
+ caddyName: "托管 Caddy",
1321
+ caddyModePreviewNote: "HTTPS 表单预览与当前连接不同。",
1322
+ originDescription: "使用托管 Caddy 或已有 HTTPS 反向代理,接入 DSH Mobile 带认证的私有后端。",
1254
1323
  prepareOrigin: "接入自有 HTTPS 反向代理",
1255
1324
  originPublicOrigin: "公网 HTTPS 地址",
1256
1325
  originListenHost: "私有监听 IPv4",
@@ -2409,19 +2478,396 @@ window.__ModuleLoader__.load({
2409
2478
  function createFrpAttachTemplate(settings, options = {}) {
2410
2479
  return createFrpAttachTemplateParts(settings, options).text;
2411
2480
  }
2412
- /** Wire bounds mirrored by the Android notification policy. */
2413
- const TASK_NOTIFY_LIMITS = Object.freeze({
2481
+ //#endregion
2482
+ //#region node_modules/@noble/hashes/esm/utils.js
2483
+ /*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) */
2484
+ /** Checks if something is Uint8Array. Be careful: nodejs Buffer will return true. */
2485
+ function isBytes(a) {
2486
+ return a instanceof Uint8Array || ArrayBuffer.isView(a) && a.constructor.name === "Uint8Array";
2487
+ }
2488
+ /** Asserts something is Uint8Array. */
2489
+ function abytes(b, ...lengths) {
2490
+ if (!isBytes(b)) throw new Error("Uint8Array expected");
2491
+ if (lengths.length > 0 && !lengths.includes(b.length)) throw new Error("Uint8Array expected of length " + lengths + ", got length=" + b.length);
2492
+ }
2493
+ /** Asserts a hash instance has not been destroyed / finished */
2494
+ function aexists(instance, checkFinished = true) {
2495
+ if (instance.destroyed) throw new Error("Hash instance has been destroyed");
2496
+ if (checkFinished && instance.finished) throw new Error("Hash#digest() has already been called");
2497
+ }
2498
+ /** Asserts output is properly-sized byte array */
2499
+ function aoutput(out, instance) {
2500
+ abytes(out);
2501
+ const min = instance.outputLen;
2502
+ if (out.length < min) throw new Error("digestInto() expects output buffer of length at least " + min);
2503
+ }
2504
+ /** Zeroize a byte array. Warning: JS provides no guarantees. */
2505
+ function clean(...arrays) {
2506
+ for (let i = 0; i < arrays.length; i++) arrays[i].fill(0);
2507
+ }
2508
+ /** Create DataView of an array for easy byte-level manipulation. */
2509
+ function createView(arr) {
2510
+ return new DataView(arr.buffer, arr.byteOffset, arr.byteLength);
2511
+ }
2512
+ /** The rotate right (circular right shift) operation for uint32 */
2513
+ function rotr(word, shift) {
2514
+ return word << 32 - shift | word >>> shift;
2515
+ }
2516
+ const hasHexBuiltin = /* @__PURE__ */ (() => typeof Uint8Array.from([]).toHex === "function" && typeof Uint8Array.fromHex === "function")();
2517
+ const hexes = /* @__PURE__ */ Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
2518
+ /**
2519
+ * Convert byte array to hex string. Uses built-in function, when available.
2520
+ * @example bytesToHex(Uint8Array.from([0xca, 0xfe, 0x01, 0x23])) // 'cafe0123'
2521
+ */
2522
+ function bytesToHex(bytes) {
2523
+ abytes(bytes);
2524
+ if (hasHexBuiltin) return bytes.toHex();
2525
+ let hex = "";
2526
+ for (let i = 0; i < bytes.length; i++) hex += hexes[bytes[i]];
2527
+ return hex;
2528
+ }
2529
+ /**
2530
+ * Converts string to bytes using UTF8 encoding.
2531
+ * @example utf8ToBytes('abc') // Uint8Array.from([97, 98, 99])
2532
+ */
2533
+ function utf8ToBytes(str) {
2534
+ if (typeof str !== "string") throw new Error("string expected");
2535
+ return new Uint8Array(new TextEncoder().encode(str));
2536
+ }
2537
+ /**
2538
+ * Normalizes (non-hex) string or Uint8Array to Uint8Array.
2539
+ * Warning: when Uint8Array is passed, it would NOT get copied.
2540
+ * Keep in mind for future mutable operations.
2541
+ */
2542
+ function toBytes(data) {
2543
+ if (typeof data === "string") data = utf8ToBytes(data);
2544
+ abytes(data);
2545
+ return data;
2546
+ }
2547
+ /** For runtime check if class implements interface */
2548
+ var Hash = class {};
2549
+ /** Wraps hash function, creating an interface on top of it */
2550
+ function createHasher(hashCons) {
2551
+ const hashC = (msg) => hashCons().update(toBytes(msg)).digest();
2552
+ const tmp = hashCons();
2553
+ hashC.outputLen = tmp.outputLen;
2554
+ hashC.blockLen = tmp.blockLen;
2555
+ hashC.create = () => hashCons();
2556
+ return hashC;
2557
+ }
2558
+ //#endregion
2559
+ //#region node_modules/@noble/hashes/esm/_md.js
2560
+ /**
2561
+ * Internal Merkle-Damgard hash utils.
2562
+ * @module
2563
+ */
2564
+ /** Polyfill for Safari 14. https://caniuse.com/mdn-javascript_builtins_dataview_setbiguint64 */
2565
+ function setBigUint64(view, byteOffset, value, isLE) {
2566
+ if (typeof view.setBigUint64 === "function") return view.setBigUint64(byteOffset, value, isLE);
2567
+ const _32n = BigInt(32);
2568
+ const _u32_max = BigInt(4294967295);
2569
+ const wh = Number(value >> _32n & _u32_max);
2570
+ const wl = Number(value & _u32_max);
2571
+ const h = isLE ? 4 : 0;
2572
+ const l = isLE ? 0 : 4;
2573
+ view.setUint32(byteOffset + h, wh, isLE);
2574
+ view.setUint32(byteOffset + l, wl, isLE);
2575
+ }
2576
+ /** Choice: a ? b : c */
2577
+ function Chi(a, b, c) {
2578
+ return a & b ^ ~a & c;
2579
+ }
2580
+ /** Majority function, true if any two inputs is true. */
2581
+ function Maj(a, b, c) {
2582
+ return a & b ^ a & c ^ b & c;
2583
+ }
2584
+ /**
2585
+ * Merkle-Damgard hash construction base class.
2586
+ * Could be used to create MD5, RIPEMD, SHA1, SHA2.
2587
+ */
2588
+ var HashMD = class extends Hash {
2589
+ constructor(blockLen, outputLen, padOffset, isLE) {
2590
+ super();
2591
+ this.finished = false;
2592
+ this.length = 0;
2593
+ this.pos = 0;
2594
+ this.destroyed = false;
2595
+ this.blockLen = blockLen;
2596
+ this.outputLen = outputLen;
2597
+ this.padOffset = padOffset;
2598
+ this.isLE = isLE;
2599
+ this.buffer = new Uint8Array(blockLen);
2600
+ this.view = createView(this.buffer);
2601
+ }
2602
+ update(data) {
2603
+ aexists(this);
2604
+ data = toBytes(data);
2605
+ abytes(data);
2606
+ const { view, buffer, blockLen } = this;
2607
+ const len = data.length;
2608
+ for (let pos = 0; pos < len;) {
2609
+ const take = Math.min(blockLen - this.pos, len - pos);
2610
+ if (take === blockLen) {
2611
+ const dataView = createView(data);
2612
+ for (; blockLen <= len - pos; pos += blockLen) this.process(dataView, pos);
2613
+ continue;
2614
+ }
2615
+ buffer.set(data.subarray(pos, pos + take), this.pos);
2616
+ this.pos += take;
2617
+ pos += take;
2618
+ if (this.pos === blockLen) {
2619
+ this.process(view, 0);
2620
+ this.pos = 0;
2621
+ }
2622
+ }
2623
+ this.length += data.length;
2624
+ this.roundClean();
2625
+ return this;
2626
+ }
2627
+ digestInto(out) {
2628
+ aexists(this);
2629
+ aoutput(out, this);
2630
+ this.finished = true;
2631
+ const { buffer, view, blockLen, isLE } = this;
2632
+ let { pos } = this;
2633
+ buffer[pos++] = 128;
2634
+ clean(this.buffer.subarray(pos));
2635
+ if (this.padOffset > blockLen - pos) {
2636
+ this.process(view, 0);
2637
+ pos = 0;
2638
+ }
2639
+ for (let i = pos; i < blockLen; i++) buffer[i] = 0;
2640
+ setBigUint64(view, blockLen - 8, BigInt(this.length * 8), isLE);
2641
+ this.process(view, 0);
2642
+ const oview = createView(out);
2643
+ const len = this.outputLen;
2644
+ if (len % 4) throw new Error("_sha2: outputLen should be aligned to 32bit");
2645
+ const outLen = len / 4;
2646
+ const state = this.get();
2647
+ if (outLen > state.length) throw new Error("_sha2: outputLen bigger than state");
2648
+ for (let i = 0; i < outLen; i++) oview.setUint32(4 * i, state[i], isLE);
2649
+ }
2650
+ digest() {
2651
+ const { buffer, outputLen } = this;
2652
+ this.digestInto(buffer);
2653
+ const res = buffer.slice(0, outputLen);
2654
+ this.destroy();
2655
+ return res;
2656
+ }
2657
+ _cloneInto(to) {
2658
+ to || (to = new this.constructor());
2659
+ to.set(...this.get());
2660
+ const { blockLen, buffer, length, finished, destroyed, pos } = this;
2661
+ to.destroyed = destroyed;
2662
+ to.finished = finished;
2663
+ to.length = length;
2664
+ to.pos = pos;
2665
+ if (length % blockLen) to.buffer.set(buffer);
2666
+ return to;
2667
+ }
2668
+ clone() {
2669
+ return this._cloneInto();
2670
+ }
2671
+ };
2672
+ /**
2673
+ * Initial SHA-2 state: fractional parts of square roots of first 16 primes 2..53.
2674
+ * Check out `test/misc/sha2-gen-iv.js` for recomputation guide.
2675
+ */
2676
+ /** Initial SHA256 state. Bits 0..32 of frac part of sqrt of primes 2..19 */
2677
+ const SHA256_IV = /* @__PURE__ */ Uint32Array.from([
2678
+ 1779033703,
2679
+ 3144134277,
2680
+ 1013904242,
2681
+ 2773480762,
2682
+ 1359893119,
2683
+ 2600822924,
2684
+ 528734635,
2685
+ 1541459225
2686
+ ]);
2687
+ //#endregion
2688
+ //#region node_modules/@noble/hashes/esm/sha2.js
2689
+ /**
2690
+ * SHA2 hash function. A.k.a. sha256, sha384, sha512, sha512_224, sha512_256.
2691
+ * SHA256 is the fastest hash implementable in JS, even faster than Blake3.
2692
+ * Check out [RFC 4634](https://datatracker.ietf.org/doc/html/rfc4634) and
2693
+ * [FIPS 180-4](https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.180-4.pdf).
2694
+ * @module
2695
+ */
2696
+ /**
2697
+ * Round constants:
2698
+ * First 32 bits of fractional parts of the cube roots of the first 64 primes 2..311)
2699
+ */
2700
+ const SHA256_K = /* @__PURE__ */ Uint32Array.from([
2701
+ 1116352408,
2702
+ 1899447441,
2703
+ 3049323471,
2704
+ 3921009573,
2705
+ 961987163,
2706
+ 1508970993,
2707
+ 2453635748,
2708
+ 2870763221,
2709
+ 3624381080,
2710
+ 310598401,
2711
+ 607225278,
2712
+ 1426881987,
2713
+ 1925078388,
2714
+ 2162078206,
2715
+ 2614888103,
2716
+ 3248222580,
2717
+ 3835390401,
2718
+ 4022224774,
2719
+ 264347078,
2720
+ 604807628,
2721
+ 770255983,
2722
+ 1249150122,
2723
+ 1555081692,
2724
+ 1996064986,
2725
+ 2554220882,
2726
+ 2821834349,
2727
+ 2952996808,
2728
+ 3210313671,
2729
+ 3336571891,
2730
+ 3584528711,
2731
+ 113926993,
2732
+ 338241895,
2733
+ 666307205,
2734
+ 773529912,
2735
+ 1294757372,
2736
+ 1396182291,
2737
+ 1695183700,
2738
+ 1986661051,
2739
+ 2177026350,
2740
+ 2456956037,
2741
+ 2730485921,
2742
+ 2820302411,
2743
+ 3259730800,
2744
+ 3345764771,
2745
+ 3516065817,
2746
+ 3600352804,
2747
+ 4094571909,
2748
+ 275423344,
2749
+ 430227734,
2750
+ 506948616,
2751
+ 659060556,
2752
+ 883997877,
2753
+ 958139571,
2754
+ 1322822218,
2755
+ 1537002063,
2756
+ 1747873779,
2757
+ 1955562222,
2758
+ 2024104815,
2759
+ 2227730452,
2760
+ 2361852424,
2761
+ 2428436474,
2762
+ 2756734187,
2763
+ 3204031479,
2764
+ 3329325298
2765
+ ]);
2766
+ /** Reusable temporary buffer. "W" comes straight from spec. */
2767
+ const SHA256_W = /* @__PURE__ */ new Uint32Array(64);
2768
+ var SHA256 = class extends HashMD {
2769
+ constructor(outputLen = 32) {
2770
+ super(64, outputLen, 8, false);
2771
+ this.A = SHA256_IV[0] | 0;
2772
+ this.B = SHA256_IV[1] | 0;
2773
+ this.C = SHA256_IV[2] | 0;
2774
+ this.D = SHA256_IV[3] | 0;
2775
+ this.E = SHA256_IV[4] | 0;
2776
+ this.F = SHA256_IV[5] | 0;
2777
+ this.G = SHA256_IV[6] | 0;
2778
+ this.H = SHA256_IV[7] | 0;
2779
+ }
2780
+ get() {
2781
+ const { A, B, C, D, E, F, G, H } = this;
2782
+ return [
2783
+ A,
2784
+ B,
2785
+ C,
2786
+ D,
2787
+ E,
2788
+ F,
2789
+ G,
2790
+ H
2791
+ ];
2792
+ }
2793
+ set(A, B, C, D, E, F, G, H) {
2794
+ this.A = A | 0;
2795
+ this.B = B | 0;
2796
+ this.C = C | 0;
2797
+ this.D = D | 0;
2798
+ this.E = E | 0;
2799
+ this.F = F | 0;
2800
+ this.G = G | 0;
2801
+ this.H = H | 0;
2802
+ }
2803
+ process(view, offset) {
2804
+ for (let i = 0; i < 16; i++, offset += 4) SHA256_W[i] = view.getUint32(offset, false);
2805
+ for (let i = 16; i < 64; i++) {
2806
+ const W15 = SHA256_W[i - 15];
2807
+ const W2 = SHA256_W[i - 2];
2808
+ const s0 = rotr(W15, 7) ^ rotr(W15, 18) ^ W15 >>> 3;
2809
+ const s1 = rotr(W2, 17) ^ rotr(W2, 19) ^ W2 >>> 10;
2810
+ SHA256_W[i] = s1 + SHA256_W[i - 7] + s0 + SHA256_W[i - 16] | 0;
2811
+ }
2812
+ let { A, B, C, D, E, F, G, H } = this;
2813
+ for (let i = 0; i < 64; i++) {
2814
+ const sigma1 = rotr(E, 6) ^ rotr(E, 11) ^ rotr(E, 25);
2815
+ const T1 = H + sigma1 + Chi(E, F, G) + SHA256_K[i] + SHA256_W[i] | 0;
2816
+ const T2 = (rotr(A, 2) ^ rotr(A, 13) ^ rotr(A, 22)) + Maj(A, B, C) | 0;
2817
+ H = G;
2818
+ G = F;
2819
+ F = E;
2820
+ E = D + T1 | 0;
2821
+ D = C;
2822
+ C = B;
2823
+ B = A;
2824
+ A = T1 + T2 | 0;
2825
+ }
2826
+ A = A + this.A | 0;
2827
+ B = B + this.B | 0;
2828
+ C = C + this.C | 0;
2829
+ D = D + this.D | 0;
2830
+ E = E + this.E | 0;
2831
+ F = F + this.F | 0;
2832
+ G = G + this.G | 0;
2833
+ H = H + this.H | 0;
2834
+ this.set(A, B, C, D, E, F, G, H);
2835
+ }
2836
+ roundClean() {
2837
+ clean(SHA256_W);
2838
+ }
2839
+ destroy() {
2840
+ this.set(0, 0, 0, 0, 0, 0, 0, 0);
2841
+ clean(this.buffer);
2842
+ }
2843
+ };
2844
+ //#endregion
2845
+ //#region node_modules/@noble/hashes/esm/sha256.js
2846
+ /**
2847
+ * SHA2-256 a.k.a. sha256. In JS, it is the fastest hash, even faster than Blake3.
2848
+ *
2849
+ * To break sha256 using birthday attack, attackers need to try 2^128 hashes.
2850
+ * BTC network is doing 2^70 hashes/sec (2^95 hashes/year) as per 2025.
2851
+ *
2852
+ * Check out [FIPS 180-4](https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.180-4.pdf).
2853
+ * @module
2854
+ * @deprecated
2855
+ */
2856
+ /** @deprecated Use import from `noble/hashes/sha2` module */
2857
+ const sha256 = /* @__PURE__ */ createHasher(() => new SHA256());
2858
+ Object.freeze({
2414
2859
  title: 80,
2415
2860
  body: 200,
2416
2861
  tag: 64
2417
2862
  });
2418
2863
  const MAX_SEEN_QUESTION_KEYS = 20;
2419
- function sanitizeTagFragment(value) {
2420
- return value.toLowerCase().replaceAll(/[^a-z0-9-]+/gu, "-").replaceAll(/^-+|-+$/gu, "").slice(0, TASK_NOTIFY_LIMITS.tag);
2864
+ /** A bounded native identifier derived from the complete, case-sensitive wire ID. */
2865
+ function notificationIdentity(value) {
2866
+ return bytesToHex(sha256(JSON.stringify(value)).subarray(0, 16));
2421
2867
  }
2422
2868
  /** Stable per-turn tag so concurrent sessions keep separate notifications. */
2423
2869
  function taskCompletionTag(sessionId, turn) {
2424
- return `dsh-task-done-${sanitizeTagFragment(`${sessionId}-${String(turn)}`) || "task"}`.slice(0, TASK_NOTIFY_LIMITS.tag);
2870
+ return `dsh-task-done-${notificationIdentity(sessionId)}-${String(turn)}`;
2425
2871
  }
2426
2872
  /** Decide whether the current snapshot deserves a system notification. */
2427
2873
  var TaskNotifyTracker = class {
@@ -2512,7 +2958,7 @@ window.__ModuleLoader__.load({
2512
2958
  }
2513
2959
  event(kind, sessionLabel, questionKey) {
2514
2960
  const text = this.format(kind, sessionLabel);
2515
- const tag = kind === "question" && questionKey !== void 0 ? `dsh-task-question-${sanitizeTagFragment(questionKey) || "pending"}` : "dsh-task-done";
2961
+ const tag = kind === "question" && questionKey !== void 0 ? `dsh-task-question-${notificationIdentity(questionKey)}` : "dsh-task-done";
2516
2962
  return Object.freeze({
2517
2963
  kind,
2518
2964
  title: text.title,
@@ -2597,7 +3043,7 @@ window.__ModuleLoader__.load({
2597
3043
  if (typeof record.sessionId !== "string" || record.sessionId.trim() === "") return void 0;
2598
3044
  const turn = typeof record.turn === "number" && Number.isSafeInteger(record.turn) && record.turn >= 0 ? record.turn : 0;
2599
3045
  return Object.freeze({
2600
- sessionId: record.sessionId.slice(0, 128),
3046
+ sessionId: record.sessionId,
2601
3047
  turn
2602
3048
  });
2603
3049
  }
@@ -2707,6 +3153,42 @@ window.__ModuleLoader__.load({
2707
3153
  if (anchorTimer !== 0) window.clearTimeout(anchorTimer);
2708
3154
  };
2709
3155
  }
3156
+ //#endregion
3157
+ //#region src/mobile-composer-toolbar.ts
3158
+ /**
3159
+ * Compact the stock InputBar without changing its React ownership, handlers,
3160
+ * activity tree or the two groups observed by DSH's model-width controller.
3161
+ * Extra controls wrap inside their own group; menus remain overflow-visible.
3162
+ */
3163
+ const MOBILE_COMPOSER_TOOLBAR_STYLES = `
3164
+ /* These markers are attached only to the stock composer on the mobile page.
3165
+ Body portals and inline dialogs are not part of the compact toolbar. */
3166
+ [data-dsh-mobile-composer-row] { min-width:0 !important; max-width:100% !important; }
3167
+ [data-dsh-mobile-composer-tools]:not([hidden]),[data-dsh-mobile-composer-controls]:not([hidden]) { flex-wrap:wrap !important; min-width:0 !important; max-width:100% !important; }
3168
+ [data-dsh-mobile-composer-tools] > :not(:where(dialog,[popover],[role="dialog"],[role="menu"],[role="listbox"],[data-trigger-menu])),
3169
+ [data-dsh-mobile-composer-tools] > [data-slot] > :not(:where(dialog,[popover],[role="dialog"],[role="menu"],[role="listbox"],[data-trigger-menu])),
3170
+ [data-dsh-mobile-composer-controls] > :not(:where(dialog,[popover],[role="dialog"],[role="menu"],[role="listbox"],[data-trigger-menu])),
3171
+ [data-dsh-mobile-composer-controls] > [data-slot] > :not(:where(dialog,[popover],[role="dialog"],[role="menu"],[role="listbox"],[data-trigger-menu])) { min-width:0 !important; max-width:100% !important; }
3172
+ [data-dsh-mobile-composer-tools] button:not(:where(dialog *,[popover] *,[role="dialog"] *,[role="menu"] *,[role="listbox"] *,[data-trigger-menu] *)),
3173
+ [data-dsh-mobile-composer-controls] button:not(:where(dialog *,[popover] *,[role="dialog"] *,[role="menu"] *,[role="listbox"] *,[data-trigger-menu] *)) { white-space:normal !important; overflow-wrap:anywhere !important; }
3174
+ @media (max-width:720px) {
3175
+ [data-dsh-mobile-composer-row] button:not([role="switch"]):not(:where(dialog *,[popover] *,[role="dialog"] *,[role="menu"] *,[role="listbox"] *,[data-trigger-menu] *)) { min-width:44px !important; min-height:44px !important; touch-action:manipulation; }
3176
+ /* Public slot icon wrappers must reserve the same space as their enlarged
3177
+ child targets. Keep wrapper and tooltip ownership intact; pure switches,
3178
+ dialogs and wrappers without direct non-switch buttons are not selected. */
3179
+ :is([data-dsh-mobile-composer-tools],[data-dsh-mobile-composer-controls]) > :is([data-slot="conversation.input.left"],[data-slot="conversation.input.right"]) > :where(span,div):not(:where(dialog,[popover],[role="dialog"],[role="menu"],[role="listbox"],[data-trigger-menu])):has(> button:not([role="switch"])) { min-width:44px !important; min-height:44px !important; flex-shrink:0 !important; }
3180
+ [data-dsh-mobile-composer-trailing] > button[class*="_primary"] { min-width:44px !important; flex-shrink:0 !important; }
3181
+ [data-dsh-mobile-composer-row] { display:flex !important; flex-wrap:wrap !important; align-items:center !important; gap:4px 8px !important; min-width:0 !important; max-width:100% !important; }
3182
+ [data-dsh-mobile-composer-tools]:not([hidden]) { display:flex !important; flex:0 1 auto !important; flex-wrap:wrap !important; width:auto !important; min-width:0 !important; max-width:100% !important; gap:6px !important; }
3183
+ /* The activity seat can be wider than a single icon. Intrinsic demand, not
3184
+ a fixed 100px reserve, decides whether this intact group needs to wrap. */
3185
+ [data-dsh-mobile-composer-trailing] { display:flex !important; flex:1 0 auto !important; flex-wrap:nowrap !important; width:auto !important; min-width:0 !important; max-width:100% !important; gap:6px !important; margin-left:0 !important; justify-content:flex-end !important; }
3186
+ [data-dsh-mobile-composer-controls]:not([hidden]) { display:flex !important; flex:1 1 0 !important; flex-wrap:wrap !important; justify-content:flex-end !important; min-width:44px !important; max-width:100% !important; gap:6px !important; }
3187
+ [data-dsh-mobile-composer-model] { flex:0 1 auto !important; width:auto !important; min-width:44px !important; max-width:100% !important; }
3188
+ [data-dsh-mobile-composer-model-trigger] { box-sizing:border-box !important; width:100% !important; max-width:100% !important; min-width:0 !important; padding-left:6px !important; padding-right:4px !important; }
3189
+ [data-dsh-mobile-composer-model-label] { flex:1 1 auto !important; max-width:none !important; min-width:0 !important; overflow:hidden !important; text-overflow:ellipsis !important; white-space:nowrap !important; }
3190
+ }
3191
+ `;
2710
3192
  //#endregion
2711
3193
  //#region src/native-mobile.ts
2712
3194
  /**
@@ -2747,13 +3229,15 @@ window.__ModuleLoader__.load({
2747
3229
  .dsh-mobile-settings_fontControls { display:flex; align-items:center; gap:8px; flex-shrink:0; }
2748
3230
  .dsh-mobile-settings_fontControls button { min-width:48px; padding:0 12px; }
2749
3231
  .dsh-mobile-settings_fontControls output { min-width:42px; text-align:center; font-variant-numeric:tabular-nums; }
3232
+ .dsh-mobile-settings_fontDetails { min-width:0; padding:0 0 16px; border-bottom:0.5px solid var(--dsw-alias-border-l2); }
3233
+ .dsh-mobile-settings_fontDetails > summary { display:list-item; min-height:48px; padding:14px 0; box-sizing:border-box; color:var(--dsw-alias-label-primary); font-size:14px; cursor:pointer; }
3234
+ .dsh-mobile-settings_fontDetails .dsh-mobile-settings_rowText { padding-right:8px; }
3235
+ .dsh-mobile-settings_fontFamily { display:flex; flex-wrap:wrap; align-items:center; gap:8px; min-width:0; padding:12px 0; }
3236
+ .dsh-mobile-settings_fontFamily > span { flex:1 1 100px; }
3237
+ .dsh-mobile-settings_fontFamily input { box-sizing:border-box; flex:1 1 180px; width:100%; min-width:0; min-height:48px; padding:8px 12px; border:0.5px solid var(--dsw-alias-border-l2); border-radius:10px; background:var(--dsw-alias-bg-module-platform); color:var(--dsw-alias-label-primary); font:inherit; }
3238
+ .dsh-mobile-settings_fontFamily input:focus-visible,.dsh-mobile-settings_fontDetails > summary:focus-visible { outline:2px solid var(--dsw-alias-label-primary-bluish,#2563eb); outline-offset:2px; }
2750
3239
  /* The mobile row replaces the Host-backed font control, not the theme selector. */
2751
3240
  html.dsh-native-mobile-active [data-slot="settings.general.item"]:has([data-mobile-font-setting]) > :not([data-mobile-font-setting])[class*="_row"]:has([class*="_control"] > [class*="_stepper"] > [class*="_arrows"]) { display:none !important; }
2752
- /* Landscape and wide App windows also constrain standard extension controls. */
2753
- [data-dsh-mobile-composer-row] { min-width:0 !important; max-width:100% !important; }
2754
- [data-dsh-mobile-composer-tools]:not([hidden]),[data-dsh-mobile-composer-controls]:not([hidden]) { flex-wrap:wrap !important; min-width:0 !important; max-width:100% !important; }
2755
- [data-dsh-mobile-composer-tools] > *,[data-dsh-mobile-composer-tools] > [data-slot] > *,[data-dsh-mobile-composer-controls] > *,[data-dsh-mobile-composer-controls] > [data-slot] > * { min-width:0 !important; max-width:100% !important; }
2756
- [data-dsh-mobile-composer-tools] button,[data-dsh-mobile-composer-controls] button { white-space:normal !important; overflow-wrap:anywhere !important; }
2757
3241
  @media ${NATIVE_MOBILE_OVERLAY_QUERY} {
2758
3242
  html.dsh-native-mobile-active,html.dsh-native-mobile-active body { width:100%; height:100%; overflow:hidden; }
2759
3243
  html.dsh-native-mobile-active { --dsh-mobile-motion-duration:200ms; --dsh-mobile-motion-ease:cubic-bezier(.22,1,.36,1); }
@@ -2770,6 +3254,9 @@ window.__ModuleLoader__.load({
2770
3254
  the ellipsis action visible and give it a reliable hit target. */
2771
3255
  html.dsh-native-mobile-active [data-dsh-mobile-sidebar] [class*="_rowActions"] { display:inline-flex !important; align-items:center !important; gap:8px !important; }
2772
3256
  html.dsh-native-mobile-active [data-dsh-mobile-sidebar] [class*="_sessionRow"] [class*="_time"] { display:none !important; }
3257
+ /* The always-visible pin action already conveys pinned state. Desktop only
3258
+ hides this passive marker on hover; touch rows must hide it throughout. */
3259
+ html.dsh-native-mobile-active [data-dsh-mobile-sidebar] [class*="_sessionRow"] [class*="_pinIndicator"] { display:none !important; }
2773
3260
  html.dsh-native-mobile-active [data-dsh-mobile-sidebar] [class*="_rowActions"] button { box-sizing:border-box !important; width:32px !important; min-width:32px !important; height:32px !important; min-height:32px !important; }
2774
3261
  [data-dsh-mobile-frame] { grid-template-columns:0 minmax(0,1fr) 0 !important; width:100% !important; height:100dvh !important; overflow:hidden !important; }
2775
3262
  [data-dsh-mobile-center] { grid-column:2 !important; width:100vw !important; min-width:0 !important; }
@@ -2827,6 +3314,11 @@ window.__ModuleLoader__.load({
2827
3314
  [data-dsh-mobile-settings-options] [data-slot="settings.general.item"] [class*="_selector"] { box-sizing:border-box !important; align-self:flex-start !important; justify-content:space-between !important; min-width:0 !important; min-height:48px !important; max-width:100% !important; }
2828
3315
  [data-dsh-mobile-settings-options] :is(input,select,textarea,button) { max-width:100%; }
2829
3316
  [data-dsh-mobile-settings-options] :is(input,select,textarea) { box-sizing:border-box; width:100%; min-width:0; }
3317
+ /* Local steppers and native page-scale controls keep their own touch targets. */
3318
+ [data-dsh-mobile-settings-options] [data-slot="settings.general.item"] .dsh-mobile-settings_fontControls button { flex:0 0 auto !important; width:auto !important; min-width:48px !important; align-self:center !important; justify-content:center !important; }
3319
+ [data-dsh-mobile-settings-options] [data-slot="settings.general.item"] .dsh-mobile-settings_fontControls select { flex:0 0 auto !important; width:auto !important; min-width:76px !important; align-self:center !important; }
3320
+ [data-dsh-mobile-settings-options] [data-mobile-font-setting] .dsh-mobile-settings_row { flex-wrap:wrap !important; }
3321
+ [data-dsh-mobile-settings-options] [data-mobile-font-setting] .dsh-mobile-settings_rowText { flex:1 1 140px !important; width:auto !important; padding-right:8px !important; }
2830
3322
  [data-dsh-mobile-settings-options] [class*="_head"] { min-width:0; flex-wrap:wrap; }
2831
3323
  /* Provider names may shrink, but their edit/delete actions remain horizontal
2832
3324
  and retain a full touch target on narrow screens. */
@@ -2931,23 +3423,6 @@ window.__ModuleLoader__.load({
2931
3423
  /* Collapse the scroll viewport only; the editor, draft and attachments stay
2932
3424
  owned by DSH. Toolbar focus keeps the card expanded during menu taps. */
2933
3425
  [data-dsh-mobile-center] [data-composer-card]:not(:focus-within) > [data-input-scroll] { max-height:72px !important; overflow-y:auto !important; }
2934
- /* Stock Send/Queue/Steer and Stop keep their handlers and disabled state. */
2935
- [data-dsh-mobile-composer-row] button { min-width:44px !important; min-height:44px !important; touch-action:manipulation; }
2936
- [data-dsh-mobile-composer-row] button[class*="_primary"] { min-width:44px !important; flex-shrink:0 !important; }
2937
- /* Keep ordinary controls on one row. The trailing group reserves room for
2938
- its model and primary buttons; extra tools wrap only when space is short. */
2939
- [data-dsh-mobile-composer-row] { display:flex !important; flex-wrap:wrap !important; align-items:center !important; gap:4px 8px !important; min-width:0 !important; max-width:100% !important; }
2940
- [data-dsh-mobile-composer-tools]:not([hidden]) { display:flex !important; flex:0 1 auto !important; flex-wrap:wrap !important; width:auto !important; min-width:0 !important; max-width:100% !important; gap:6px !important; }
2941
- [data-dsh-mobile-composer-tools] > *,[data-dsh-mobile-composer-tools] > [data-slot] > * { min-width:0 !important; max-width:100% !important; }
2942
- [data-dsh-mobile-composer-tools] button { white-space:normal !important; overflow-wrap:anywhere !important; }
2943
- [data-dsh-mobile-composer-trailing] { display:flex !important; flex:1 0 100px !important; flex-wrap:nowrap !important; width:auto !important; min-width:min(100%,100px) !important; max-width:100% !important; gap:6px !important; margin-left:0 !important; justify-content:flex-end !important; }
2944
- [data-dsh-mobile-composer-trailing]:has(> button[class*="_primary"] ~ button[class*="_primary"]) { flex-basis:144px !important; min-width:min(100%,144px) !important; }
2945
- [data-dsh-mobile-composer-controls]:not([hidden]) { display:flex !important; flex:1 1 0 !important; flex-wrap:wrap !important; justify-content:flex-end !important; min-width:0 !important; max-width:100% !important; gap:6px !important; }
2946
- [data-dsh-mobile-composer-controls] > *,[data-dsh-mobile-composer-controls] > [data-slot] > * { min-width:0 !important; max-width:100% !important; }
2947
- [data-dsh-mobile-composer-controls] button { white-space:normal !important; overflow-wrap:anywhere !important; }
2948
- [data-dsh-mobile-composer-model] { flex:0 1 auto !important; width:auto !important; min-width:44px !important; max-width:100% !important; }
2949
- [data-dsh-mobile-composer-model-trigger] { box-sizing:border-box !important; width:100% !important; max-width:100% !important; min-width:0 !important; padding-left:6px !important; padding-right:4px !important; }
2950
- [data-dsh-mobile-composer-model-label] { flex:1 1 auto !important; max-width:none !important; min-width:0 !important; overflow:hidden !important; text-overflow:ellipsis !important; white-space:nowrap !important; }
2951
3426
  [data-dsh-mobile-center] [class*="_root"]:has(> [class*="_card"] textarea) { box-sizing:border-box !important; width:100% !important; padding:0 0 8px !important; }
2952
3427
  [data-dsh-mobile-center] [class*="_root"]:has(> [class*="_card"] textarea) > [class=""]:last-child { display:none !important; }
2953
3428
  }
@@ -2972,7 +3447,7 @@ window.__ModuleLoader__.load({
2972
3447
  [data-dsh-mobile-settings-content][data-dsh-mobile-view-transition="true"],
2973
3448
  [data-dsh-mobile-view][data-dsh-mobile-view-transition="true"] { animation:none !important; }
2974
3449
  }
2975
- `;
3450
+ ` + MOBILE_COMPOSER_TOOLBAR_STYLES;
2976
3451
  function classToken(element, suffix) {
2977
3452
  return Array.from(element.classList).some((value) => value.endsWith(suffix));
2978
3453
  }
@@ -4306,6 +4781,92 @@ window.__ModuleLoader__.load({
4306
4781
  //#region src/mobile-font.ts
4307
4782
  /** Device-local typography for authenticated mobile pages, without Host settings writes. */
4308
4783
  const MOBILE_FONT_STORAGE_KEY = "dsh-mobile.content-font-size.v1";
4784
+ const MOBILE_TYPOGRAPHY_STORAGE_KEY = "dsh-mobile.typography.v1";
4785
+ const MOBILE_FONT_ROLES = ["text", "code"];
4786
+ const roleSpecs = {
4787
+ text: {
4788
+ default: 16,
4789
+ min: 12,
4790
+ variable: "--dsh-content-font-size"
4791
+ },
4792
+ code: {
4793
+ default: 11,
4794
+ min: 10,
4795
+ variable: "--dsh-code-font-size"
4796
+ }
4797
+ };
4798
+ const genericFamilies = /* @__PURE__ */ new Set([
4799
+ "serif",
4800
+ "sans-serif",
4801
+ "monospace",
4802
+ "cursive",
4803
+ "fantasy",
4804
+ "system-ui",
4805
+ "ui-serif",
4806
+ "ui-sans-serif",
4807
+ "ui-monospace",
4808
+ "ui-rounded",
4809
+ "emoji",
4810
+ "math",
4811
+ "fangsong",
4812
+ "-apple-system",
4813
+ "blinkmacsystemfont"
4814
+ ]);
4815
+ const MAX_FONT_FAMILY_LENGTH = 1024;
4816
+ /** Normalize a comma-separated local font list without loading or discovering fonts. */
4817
+ function normalizeMobileFontFamily(value) {
4818
+ let result = "";
4819
+ for (const part of value.slice(0, MAX_FONT_FAMILY_LENGTH * 2).split(",")) {
4820
+ const name = part.replace(/["'\\<>\u0000-\u001f\u007f]/gu, "").replace(/\s+/gu, " ").trim();
4821
+ if (name === "") continue;
4822
+ const generic = genericFamilies.has(name.toLowerCase());
4823
+ const separator = result === "" ? "" : ", ";
4824
+ const remaining = MAX_FONT_FAMILY_LENGTH - result.length - separator.length - (generic ? 0 : 2);
4825
+ if (remaining < 1 || generic && name.length > remaining) break;
4826
+ result += separator + (generic ? name : `"${name.slice(0, remaining)}"`);
4827
+ }
4828
+ return result;
4829
+ }
4830
+ function defaultTypography(textSize = 16) {
4831
+ return Object.freeze({
4832
+ sizes: Object.freeze({
4833
+ text: textSize,
4834
+ code: roleSpecs.code.default
4835
+ }),
4836
+ families: Object.freeze({
4837
+ text: "",
4838
+ code: ""
4839
+ })
4840
+ });
4841
+ }
4842
+ /** Durable local JSON is validated independently for each typography role. */
4843
+ function readTypography(value, legacySize) {
4844
+ const fallback = defaultTypography(legacySize);
4845
+ if (value === null) return fallback;
4846
+ let parsed;
4847
+ try {
4848
+ parsed = JSON.parse(value);
4849
+ } catch (error) {
4850
+ return fallback;
4851
+ }
4852
+ if (typeof parsed !== "object" || parsed === null || !("sizes" in parsed) || !("families" in parsed)) return fallback;
4853
+ const sizes = { ...fallback.sizes };
4854
+ const families = { ...fallback.families };
4855
+ for (const role of MOBILE_FONT_ROLES) {
4856
+ if (typeof parsed.sizes === "object" && parsed.sizes !== null && role in parsed.sizes) {
4857
+ const size = Reflect.get(parsed.sizes, role);
4858
+ if (typeof size === "number" && Number.isInteger(size) && size >= roleSpecs[role].min && size <= 32) sizes[role] = size;
4859
+ }
4860
+ if (typeof parsed.families === "object" && parsed.families !== null && role in parsed.families) {
4861
+ const family = Reflect.get(parsed.families, role);
4862
+ if (typeof family === "string") families[role] = normalizeMobileFontFamily(family);
4863
+ }
4864
+ }
4865
+ return Object.freeze({
4866
+ sizes: Object.freeze(sizes),
4867
+ families: Object.freeze(families)
4868
+ });
4869
+ }
4309
4870
  /** Read one local storage value; malformed or unavailable preferences use the mobile default. */
4310
4871
  function parseMobileFontSize(value) {
4311
4872
  if (value === null || !/^\d+$/.test(value)) return 16;
@@ -4315,15 +4876,21 @@ window.__ModuleLoader__.load({
4315
4876
  /** Local preference storage; unavailable browser storage still permits in-page adjustment. */
4316
4877
  var MobileFontPreference = class {
4317
4878
  storage;
4318
- size = 16;
4879
+ typography = defaultTypography();
4319
4880
  listeners = /* @__PURE__ */ new Set();
4320
4881
  constructor(storage) {
4321
4882
  this.storage = storage;
4883
+ let legacySize = 16;
4322
4884
  try {
4323
- this.size = parseMobileFontSize(storage?.getItem("dsh-mobile.content-font-size.v1") ?? null);
4885
+ legacySize = parseMobileFontSize(storage?.getItem("dsh-mobile.content-font-size.v1") ?? null);
4886
+ } catch (error) {}
4887
+ this.typography = defaultTypography(legacySize);
4888
+ try {
4889
+ this.typography = readTypography(storage?.getItem("dsh-mobile.typography.v1") ?? null, legacySize);
4324
4890
  } catch (error) {}
4325
4891
  }
4326
- getSnapshot = () => this.size;
4892
+ getSnapshot = () => this.typography.sizes.text;
4893
+ getTypographySnapshot = () => this.typography;
4327
4894
  subscribe = (listener) => {
4328
4895
  this.listeners.add(listener);
4329
4896
  return () => {
@@ -4332,11 +4899,45 @@ window.__ModuleLoader__.load({
4332
4899
  };
4333
4900
  /** Apply a user-selected size locally; never send it to DSH's settings RPC. */
4334
4901
  set(size) {
4335
- if (!Number.isInteger(size) || size < 12 || size > 32) throw new RangeError("Mobile font size must be an integer from 12 to 32");
4336
- if (this.size === size) return;
4337
- this.size = size;
4902
+ this.setRoleSize("text", size);
4903
+ }
4904
+ /** Change one mobile font role independently; all values remain address-local. */
4905
+ setRoleSize(role, size) {
4906
+ if (!Number.isInteger(size) || size < roleSpecs[role].min || size > 32) throw new RangeError(`Mobile ${role} font size must be an integer from ${roleSpecs[role].min} to 32`);
4907
+ if (this.typography.sizes[role] === size) return;
4908
+ this.persist({
4909
+ sizes: Object.freeze({
4910
+ ...this.typography.sizes,
4911
+ [role]: size
4912
+ }),
4913
+ families: this.typography.families
4914
+ });
4915
+ }
4916
+ /** Select existing local fonts; an empty list uses DSH's built-in font stack. */
4917
+ setRoleFamily(role, family) {
4918
+ const normalized = normalizeMobileFontFamily(family);
4919
+ if (this.typography.families[role] === normalized) return;
4920
+ this.persist({
4921
+ sizes: this.typography.sizes,
4922
+ families: Object.freeze({
4923
+ ...this.typography.families,
4924
+ [role]: normalized
4925
+ })
4926
+ });
4927
+ }
4928
+ /** Restore mobile sizes and built-in font stacks without modifying Host settings. */
4929
+ reset() {
4930
+ const next = defaultTypography();
4931
+ if (MOBILE_FONT_ROLES.every((role) => this.typography.sizes[role] === next.sizes[role] && this.typography.families[role] === "")) return;
4932
+ this.persist(next);
4933
+ }
4934
+ persist(typography) {
4935
+ this.typography = Object.freeze(typography);
4338
4936
  try {
4339
- this.storage?.setItem(MOBILE_FONT_STORAGE_KEY, String(size));
4937
+ this.storage?.setItem(MOBILE_TYPOGRAPHY_STORAGE_KEY, JSON.stringify(typography));
4938
+ } catch (error) {}
4939
+ try {
4940
+ this.storage?.setItem(MOBILE_FONT_STORAGE_KEY, String(typography.sizes.text));
4340
4941
  } catch (error) {}
4341
4942
  for (const listener of this.listeners) listener();
4342
4943
  }
@@ -4348,19 +4949,31 @@ window.__ModuleLoader__.load({
4348
4949
  storage = window.localStorage;
4349
4950
  } catch (error) {}
4350
4951
  const preference = new MobileFontPreference(storage);
4351
- const property = "--dsh-content-font-size";
4352
- let previous = document.body.style.getPropertyValue(property);
4353
- let previousPriority = document.body.style.getPropertyPriority(property);
4354
- let ownedValue;
4952
+ const style = document.body.style;
4953
+ const properties = MOBILE_FONT_ROLES.flatMap((role) => [roleSpecs[role].variable, `--dsh-font-family-${role}`]);
4954
+ const previous = new Map(properties.map((property) => [property, {
4955
+ value: style.getPropertyValue(property),
4956
+ priority: style.getPropertyPriority(property)
4957
+ }]));
4958
+ const owned = /* @__PURE__ */ new Map();
4355
4959
  const apply = () => {
4356
- const current = document.body.style.getPropertyValue(property);
4357
- if (ownedValue !== void 0 && current !== ownedValue) {
4358
- previous = current;
4359
- previousPriority = document.body.style.getPropertyPriority(property);
4960
+ const typography = preference.getTypographySnapshot();
4961
+ for (const role of MOBILE_FONT_ROLES) for (const [property, value] of [[roleSpecs[role].variable, `${typography.sizes[role]}px`], [`--dsh-font-family-${role}`, typography.families[role]]]) {
4962
+ const current = style.getPropertyValue(property);
4963
+ const priority = style.getPropertyPriority(property);
4964
+ const last = owned.get(property);
4965
+ if (last !== void 0 && (current !== last.value || priority !== last.priority)) previous.set(property, {
4966
+ value: current,
4967
+ priority
4968
+ });
4969
+ owned.set(property, {
4970
+ value,
4971
+ priority: ""
4972
+ });
4973
+ if (current === value && priority === "") continue;
4974
+ if (value === "") style.removeProperty(property);
4975
+ else style.setProperty(property, value);
4360
4976
  }
4361
- const value = `${preference.getSnapshot()}px`;
4362
- ownedValue = value;
4363
- if (current !== value) document.body.style.setProperty(property, value);
4364
4977
  };
4365
4978
  apply();
4366
4979
  const unsubscribe = preference.subscribe(apply);
@@ -4374,59 +4987,343 @@ window.__ModuleLoader__.load({
4374
4987
  dispose: () => {
4375
4988
  observer.disconnect();
4376
4989
  unsubscribe();
4377
- if (previous === "") document.body.style.removeProperty(property);
4378
- else document.body.style.setProperty(property, previous, previousPriority);
4990
+ for (const [property, value] of previous) {
4991
+ const last = owned.get(property);
4992
+ if (last === void 0 || style.getPropertyValue(property) !== last.value || style.getPropertyPriority(property) !== last.priority) continue;
4993
+ if (value.value === "") style.removeProperty(property);
4994
+ else style.setProperty(property, value.value, value.priority);
4995
+ }
4379
4996
  }
4380
4997
  };
4381
4998
  }
4382
- const messages = {
4999
+ const messages$1 = {
4383
5000
  zh: {
4384
5001
  title: "移动端字号",
4385
5002
  description: "仅保存在当前访问地址的本地数据中,不影响电脑或其他设备。",
4386
5003
  decrease: "减小移动端字号",
4387
- increase: "增大移动端字号"
5004
+ increase: "增大移动端字号",
5005
+ more: "更多字体设置",
5006
+ reset: "恢复默认",
5007
+ defaultFamily: "默认字体",
5008
+ familyHint: "填写设备已有的字体,多个名称用逗号分隔;留空使用默认字体。",
5009
+ codeSize: "代码字号",
5010
+ textFamily: "正文字体",
5011
+ codeFamily: "代码字体"
4388
5012
  },
4389
5013
  en: {
4390
5014
  title: "Mobile font size",
4391
5015
  description: "Saved locally for this address, without changing your computer or other devices.",
4392
5016
  decrease: "Decrease mobile font size",
4393
- increase: "Increase mobile font size"
5017
+ increase: "Increase mobile font size",
5018
+ more: "More font settings",
5019
+ reset: "Restore defaults",
5020
+ defaultFamily: "Default font",
5021
+ familyHint: "Use fonts already on your device, separated by commas. Leave empty for the default.",
5022
+ codeSize: "Code font size",
5023
+ textFamily: "Text font",
5024
+ codeFamily: "Code font"
4394
5025
  },
4395
5026
  it: {
4396
5027
  title: "Dimensione testo mobile",
4397
5028
  description: "Salvata localmente per questo indirizzo, senza modificare il computer o altri dispositivi.",
4398
5029
  decrease: "Riduci il testo mobile",
4399
- increase: "Aumenta il testo mobile"
5030
+ increase: "Aumenta il testo mobile",
5031
+ more: "Altre impostazioni dei caratteri",
5032
+ reset: "Ripristina predefiniti",
5033
+ defaultFamily: "Carattere predefinito",
5034
+ familyHint: "Usa caratteri presenti sul dispositivo, separati da virgole. Lascia vuoto per il predefinito.",
5035
+ codeSize: "Dimensione codice",
5036
+ textFamily: "Carattere testo",
5037
+ codeFamily: "Carattere codice"
4400
5038
  }
4401
5039
  };
5040
+ function FontFamilyInput({ preference, role, value, label, placeholder }) {
5041
+ const [draft, setDraft] = (0, react.useState)(value);
5042
+ (0, react.useEffect)(() => {
5043
+ setDraft(value);
5044
+ }, [value]);
5045
+ return (0, react.createElement)("label", { className: "dsh-mobile-settings_fontFamily" }, (0, react.createElement)("span", { className: "dsh-mobile-settings_title" }, label), (0, react.createElement)("input", {
5046
+ type: "text",
5047
+ value: draft,
5048
+ maxLength: MAX_FONT_FAMILY_LENGTH,
5049
+ placeholder,
5050
+ autoComplete: "off",
5051
+ spellCheck: false,
5052
+ "data-mobile-font-family": role,
5053
+ onChange: (event) => {
5054
+ setDraft(event.currentTarget.value);
5055
+ },
5056
+ onBlur: () => {
5057
+ preference.setRoleFamily(role, draft);
5058
+ setDraft(preference.getTypographySnapshot().families[role]);
5059
+ }
5060
+ }));
5061
+ }
4402
5062
  /** General settings row matching the existing Mobile controls and DSH theme tokens. */
4403
5063
  function MobileFontSizeRow({ preference, locale }) {
4404
- const size = (0, react.useSyncExternalStore)(preference.subscribe, preference.getSnapshot);
4405
- const copy = messages[locale];
5064
+ const typography = (0, react.useSyncExternalStore)(preference.subscribe, preference.getTypographySnapshot);
5065
+ const copy = messages$1[locale];
5066
+ const controls = (role, title) => {
5067
+ const size = typography.sizes[role];
5068
+ return (0, react.createElement)("div", {
5069
+ className: "dsh-mobile-settings_fontControls",
5070
+ "data-mobile-font-role": role
5071
+ }, (0, react.createElement)("button", {
5072
+ type: "button",
5073
+ className: "dsh-mobile-settings_selector",
5074
+ "aria-label": `${copy.decrease}: ${title}`,
5075
+ disabled: size === roleSpecs[role].min,
5076
+ onClick: () => {
5077
+ preference.setRoleSize(role, size - 1);
5078
+ }
5079
+ }, "−"), (0, react.createElement)("output", {
5080
+ "aria-live": "polite",
5081
+ "aria-label": title
5082
+ }, `${size}px`), (0, react.createElement)("button", {
5083
+ type: "button",
5084
+ className: "dsh-mobile-settings_selector",
5085
+ "aria-label": `${copy.increase}: ${title}`,
5086
+ disabled: size === 32,
5087
+ onClick: () => {
5088
+ preference.setRoleSize(role, size + 1);
5089
+ }
5090
+ }, "+"));
5091
+ };
4406
5092
  return (0, react.createElement)("div", {
4407
- className: "dsh-mobile-settings_row",
4408
5093
  lang: locale,
4409
5094
  "data-mobile-font-setting": true
4410
- }, (0, react.createElement)("div", { className: "dsh-mobile-settings_rowText" }, (0, react.createElement)("div", { className: "dsh-mobile-settings_title" }, copy.title), (0, react.createElement)("div", { className: "dsh-mobile-settings_desc" }, copy.description)), (0, react.createElement)("div", { className: "dsh-mobile-settings_fontControls" }, (0, react.createElement)("button", {
5095
+ }, (0, react.createElement)("div", { className: "dsh-mobile-settings_row" }, (0, react.createElement)("div", { className: "dsh-mobile-settings_rowText" }, (0, react.createElement)("div", { className: "dsh-mobile-settings_title" }, copy.title), (0, react.createElement)("div", { className: "dsh-mobile-settings_desc" }, copy.description)), controls("text", copy.title)), (0, react.createElement)("details", { className: "dsh-mobile-settings_fontDetails" }, (0, react.createElement)("summary", null, copy.more), (0, react.createElement)("p", { className: "dsh-mobile-settings_desc" }, copy.familyHint), (0, react.createElement)("div", { className: "dsh-mobile-settings_row" }, (0, react.createElement)("span", { className: "dsh-mobile-settings_rowText dsh-mobile-settings_title" }, copy.codeSize), controls("code", copy.codeSize)), ...MOBILE_FONT_ROLES.map((role) => (0, react.createElement)(FontFamilyInput, {
5096
+ key: role,
5097
+ preference,
5098
+ role,
5099
+ value: typography.families[role],
5100
+ label: role === "text" ? copy.textFamily : copy.codeFamily,
5101
+ placeholder: copy.defaultFamily
5102
+ })), (0, react.createElement)("button", {
4411
5103
  type: "button",
4412
5104
  className: "dsh-mobile-settings_selector",
4413
- "aria-label": copy.decrease,
4414
- disabled: size === 12,
4415
5105
  onClick: () => {
4416
- preference.set(size - 1);
5106
+ preference.reset();
5107
+ }
5108
+ }, copy.reset)));
5109
+ }
5110
+ //#endregion
5111
+ //#region src/mobile-display-scale.ts
5112
+ /** Native App page scaling in DSH General settings, independent from local typography. */
5113
+ /** Validate the native reply before displaying or reusing its values in a request. */
5114
+ function parseMobileDisplayScale(value) {
5115
+ if (typeof value !== "object" || value === null || !("percent" in value) || !("minPercent" in value) || !("maxPercent" in value)) throw new TypeError("Native display scale reply is invalid");
5116
+ const { percent, minPercent, maxPercent } = value;
5117
+ if (typeof percent !== "number" || !Number.isInteger(percent) || typeof minPercent !== "number" || !Number.isInteger(minPercent) || typeof maxPercent !== "number" || !Number.isInteger(maxPercent) || minPercent < 80 || maxPercent > 125 || minPercent > 100 || maxPercent < 100 || percent < minPercent || percent > maxPercent) throw new TypeError("Native display scale reply is invalid");
5118
+ return Object.freeze({
5119
+ percent,
5120
+ minPercent,
5121
+ maxPercent
5122
+ });
5123
+ }
5124
+ /** Serial native requests; a replaced bridge or disposed row cannot publish stale results. */
5125
+ var MobileDisplayScaleController = class {
5126
+ getBridge;
5127
+ snapshot = {
5128
+ state: "hidden",
5129
+ value: null
5130
+ };
5131
+ listeners = /* @__PURE__ */ new Set();
5132
+ bridge;
5133
+ generation = 0;
5134
+ active = false;
5135
+ constructor(getBridge) {
5136
+ this.getBridge = getBridge;
5137
+ }
5138
+ getSnapshot = () => this.snapshot;
5139
+ subscribe = (listener) => {
5140
+ this.listeners.add(listener);
5141
+ return () => {
5142
+ this.listeners.delete(listener);
5143
+ };
5144
+ };
5145
+ start() {
5146
+ this.active = true;
5147
+ this.refresh();
5148
+ }
5149
+ stop() {
5150
+ this.active = false;
5151
+ this.generation += 1;
5152
+ this.bridge = void 0;
5153
+ }
5154
+ /** Discover both capabilities before showing a control or querying its current native value. */
5155
+ refresh = () => {
5156
+ if (!this.active) return;
5157
+ const generation = ++this.generation;
5158
+ const bridge = this.getBridge();
5159
+ this.bridge = bridge;
5160
+ this.publish({
5161
+ state: "hidden",
5162
+ value: null
5163
+ });
5164
+ if (bridge === void 0) return;
5165
+ Promise.resolve().then(() => bridge.capabilities()).then(async (capabilities) => {
5166
+ if (!this.current(bridge, generation)) return;
5167
+ if (!capabilities.includes("mobile.display-scale.get") || !capabilities.includes("mobile.display-scale.set")) return;
5168
+ this.publish({
5169
+ state: "loading",
5170
+ value: null
5171
+ });
5172
+ try {
5173
+ const value = parseMobileDisplayScale(await bridge.invoke("mobile.display-scale.get", {}));
5174
+ if (this.current(bridge, generation)) this.publish({
5175
+ state: "ready",
5176
+ value
5177
+ });
5178
+ } catch (error) {
5179
+ if (this.current(bridge, generation)) this.publish({
5180
+ state: "error",
5181
+ value: null
5182
+ });
5183
+ }
5184
+ }, () => {});
5185
+ };
5186
+ /** Apply a device-local value once, preserving the last confirmed value when the native request fails. */
5187
+ setPercent(percent) {
5188
+ const value = this.snapshot.value;
5189
+ const bridge = this.bridge;
5190
+ if (!this.active || bridge === void 0 || bridge !== this.getBridge() || value === null || this.snapshot.state === "busy" || this.snapshot.state === "loading") return;
5191
+ if (!Number.isInteger(percent) || percent < value.minPercent || percent > value.maxPercent) return;
5192
+ if (percent === value.percent && this.snapshot.state !== "error") return;
5193
+ const generation = this.generation;
5194
+ this.publish({
5195
+ state: "busy",
5196
+ value
5197
+ });
5198
+ Promise.resolve().then(() => this.current(bridge, generation) ? bridge.invoke("mobile.display-scale.set", { percent }) : void 0).then((reply) => {
5199
+ if (!this.current(bridge, generation)) return;
5200
+ try {
5201
+ this.publish({
5202
+ state: "ready",
5203
+ value: parseMobileDisplayScale(reply)
5204
+ });
5205
+ } catch (error) {
5206
+ this.publish({
5207
+ state: "error",
5208
+ value
5209
+ });
5210
+ }
5211
+ }, () => {
5212
+ if (this.current(bridge, generation)) this.publish({
5213
+ state: "error",
5214
+ value
5215
+ });
5216
+ });
5217
+ }
5218
+ current(bridge, generation) {
5219
+ return this.active && generation === this.generation && this.bridge === bridge && this.getBridge() === bridge;
5220
+ }
5221
+ publish(snapshot) {
5222
+ this.snapshot = Object.freeze(snapshot);
5223
+ for (const listener of this.listeners) listener();
5224
+ }
5225
+ };
5226
+ const messages = {
5227
+ zh: {
5228
+ title: "页面缩放",
5229
+ description: "调整整个页面的显示大小,仅保存在当前 App 中,与字号设置独立。",
5230
+ reset: "恢复 100%",
5231
+ loading: "读取中…",
5232
+ busy: "正在调整…",
5233
+ error: "无法调整页面缩放,请重试。",
5234
+ retry: "重试"
5235
+ },
5236
+ en: {
5237
+ title: "Page zoom",
5238
+ description: "Resize the whole page. Saved in this App, separately from font size.",
5239
+ reset: "Reset to 100%",
5240
+ loading: "Loading…",
5241
+ busy: "Applying…",
5242
+ error: "Could not change page zoom. Try again.",
5243
+ retry: "Retry"
5244
+ },
5245
+ it: {
5246
+ title: "Zoom pagina",
5247
+ description: "Ridimensiona tutta la pagina. Salvato in questa app, separatamente dalla dimensione del testo.",
5248
+ reset: "Ripristina 100%",
5249
+ loading: "Caricamento…",
5250
+ busy: "Applicazione…",
5251
+ error: "Impossibile modificare lo zoom. Riprova.",
5252
+ retry: "Riprova"
5253
+ }
5254
+ };
5255
+ /** Native-only General settings row using the existing DSH Mobile controls and locale. */
5256
+ function MobileDisplayScaleRow({ locale }) {
5257
+ const [controller] = (0, react.useState)(() => new MobileDisplayScaleController(() => window.__DSH_MOBILE_NATIVE__));
5258
+ const snapshot = (0, react.useSyncExternalStore)(controller.subscribe, controller.getSnapshot);
5259
+ (0, react.useEffect)(() => {
5260
+ controller.start();
5261
+ window.addEventListener("dsh-mobile-native-ready", controller.refresh);
5262
+ return () => {
5263
+ window.removeEventListener("dsh-mobile-native-ready", controller.refresh);
5264
+ controller.stop();
5265
+ };
5266
+ }, [controller]);
5267
+ if (snapshot.state === "hidden") return null;
5268
+ const copy = messages[locale];
5269
+ const value = snapshot.value;
5270
+ const busy = snapshot.state === "busy" || snapshot.state === "loading";
5271
+ const description = snapshot.state === "error" ? copy.error : snapshot.state === "loading" ? copy.loading : snapshot.state === "busy" ? copy.busy : copy.description;
5272
+ const options = value === null ? [] : [.../* @__PURE__ */ new Set([
5273
+ value.minPercent,
5274
+ value.maxPercent,
5275
+ value.percent,
5276
+ ...[
5277
+ 80,
5278
+ 85,
5279
+ 90,
5280
+ 95,
5281
+ 100,
5282
+ 105,
5283
+ 110,
5284
+ 115,
5285
+ 120,
5286
+ 125
5287
+ ].filter((percent) => percent >= value.minPercent && percent <= value.maxPercent)
5288
+ ])].sort((a, b) => a - b);
5289
+ return (0, react.createElement)("div", {
5290
+ className: "dsh-mobile-settings_row",
5291
+ style: { flexWrap: "wrap" },
5292
+ lang: locale,
5293
+ "data-mobile-display-scale-setting": true,
5294
+ "aria-busy": busy
5295
+ }, (0, react.createElement)("div", {
5296
+ className: "dsh-mobile-settings_rowText",
5297
+ style: {
5298
+ flexBasis: 160,
5299
+ paddingRight: 8
5300
+ }
5301
+ }, (0, react.createElement)("div", { className: "dsh-mobile-settings_title" }, copy.title), (0, react.createElement)("div", {
5302
+ className: "dsh-mobile-settings_desc",
5303
+ "aria-live": "polite"
5304
+ }, description)), (0, react.createElement)("div", { className: "dsh-mobile-settings_fontControls" }, value === null ? null : (0, react.createElement)("select", {
5305
+ className: "dsh-mobile-settings_selector",
5306
+ "aria-label": copy.title,
5307
+ disabled: busy,
5308
+ value: value.percent,
5309
+ onChange: (event) => {
5310
+ controller.setPercent(Number(event.currentTarget.value));
4417
5311
  }
4418
- }, "−"), (0, react.createElement)("output", {
4419
- "aria-live": "polite",
4420
- "aria-label": copy.title
4421
- }, `${size}px`), (0, react.createElement)("button", {
5312
+ }, ...options.map((percent) => (0, react.createElement)("option", {
5313
+ key: percent,
5314
+ value: percent
5315
+ }, `${percent}%`))), snapshot.state === "error" ? (0, react.createElement)("button", {
5316
+ type: "button",
5317
+ className: "dsh-mobile-settings_selector",
5318
+ onClick: controller.refresh
5319
+ }, copy.retry) : (0, react.createElement)("button", {
4422
5320
  type: "button",
4423
5321
  className: "dsh-mobile-settings_selector",
4424
- "aria-label": copy.increase,
4425
- disabled: size === 32,
5322
+ disabled: busy || value === null || value.percent === 100,
4426
5323
  onClick: () => {
4427
- preference.set(size + 1);
5324
+ controller.setPercent(100);
4428
5325
  }
4429
- }, "+")));
5326
+ }, copy.reset)));
4430
5327
  }
4431
5328
  //#endregion
4432
5329
  //#region src/local-admin-host.ts
@@ -4778,6 +5675,244 @@ window.__ModuleLoader__.load({
4778
5675
  }, copy.open));
4779
5676
  }
4780
5677
  //#endregion
5678
+ //#region src/extension-recovery-ui.ts
5679
+ /** Validate the Host response and select only stopped workers, never in-process hosts. */
5680
+ function parseUnavailableExtensionHosts(payload) {
5681
+ if (typeof payload !== "object" || payload === null || !("hosts" in payload) || !Array.isArray(payload.hosts)) return void 0;
5682
+ const ids = /* @__PURE__ */ new Set();
5683
+ const hosts = [];
5684
+ for (const value of payload.hosts) {
5685
+ if (typeof value !== "object" || value === null) return void 0;
5686
+ const entry = value;
5687
+ if (typeof entry.id !== "string" || !/^[a-z][a-z0-9-]{0,63}$/u.test(entry.id) || ids.has(entry.id) || typeof entry.name !== "string" || entry.name.trim() === "" || entry.mode !== "worker" && entry.mode !== "in-process" || entry.state !== "ready" && entry.state !== "unavailable" || typeof entry.generation !== "string" || !/^[a-f\d]{64}$/u.test(entry.generation)) return void 0;
5688
+ ids.add(entry.id);
5689
+ if (entry.mode === "worker" && entry.state === "unavailable") hosts.push({
5690
+ id: entry.id,
5691
+ name: entry.name,
5692
+ generation: entry.generation
5693
+ });
5694
+ }
5695
+ return hosts;
5696
+ }
5697
+ /** One section shared across desktop access views; recovery is never automatic. */
5698
+ function installExtensionRecoveryControl(options) {
5699
+ const { document, signal, request, confirm, translate: t } = options;
5700
+ const section = document.createElement("section");
5701
+ section.className = "dsh-mobile-control__extension-recovery";
5702
+ section.hidden = true;
5703
+ section.setAttribute("aria-label", t("extensionRecoveryTitle"));
5704
+ const title = document.createElement("h3");
5705
+ title.textContent = t("extensionRecoveryTitle");
5706
+ const help = document.createElement("p");
5707
+ help.textContent = t("extensionRecoveryHelp");
5708
+ const list = document.createElement("ul");
5709
+ const feedback = document.createElement("p");
5710
+ feedback.className = "dsh-mobile-control__extension-recovery-error";
5711
+ feedback.hidden = true;
5712
+ feedback.setAttribute("role", "status");
5713
+ feedback.setAttribute("aria-live", "polite");
5714
+ const retry = document.createElement("button");
5715
+ retry.type = "button";
5716
+ retry.className = "dsh-mobile-control__secondary";
5717
+ retry.textContent = t("extensionRecoveryRefresh");
5718
+ retry.hidden = true;
5719
+ section.append(title, help, list, feedback, retry);
5720
+ const rows = /* @__PURE__ */ new Map();
5721
+ let hosts = [];
5722
+ let busyId;
5723
+ let errorText = "";
5724
+ let errorSource;
5725
+ let revision = 0;
5726
+ let reading = false;
5727
+ let refreshPending = false;
5728
+ let disposed = false;
5729
+ const live = () => !disposed && !signal.aborted;
5730
+ const render = () => {
5731
+ if (!live()) return;
5732
+ section.hidden = hosts.length === 0;
5733
+ section.setAttribute("aria-busy", String(busyId !== void 0));
5734
+ for (const [id, entry] of rows) {
5735
+ if (hosts.some((host) => host.id === id)) continue;
5736
+ entry.row.remove();
5737
+ rows.delete(id);
5738
+ }
5739
+ for (const host of hosts) {
5740
+ let entry = rows.get(host.id);
5741
+ if (entry === void 0) {
5742
+ const row = document.createElement("li");
5743
+ const text = document.createElement("div");
5744
+ const name = document.createElement("strong");
5745
+ const id = document.createElement("code");
5746
+ const button = document.createElement("button");
5747
+ button.type = "button";
5748
+ button.className = "dsh-mobile-control__secondary";
5749
+ button.dataset.extensionRecoveryId = host.id;
5750
+ button.addEventListener("click", () => {
5751
+ recover(host.id);
5752
+ });
5753
+ text.append(name, id);
5754
+ row.append(text, button);
5755
+ list.append(row);
5756
+ entry = {
5757
+ row,
5758
+ name,
5759
+ id,
5760
+ button
5761
+ };
5762
+ rows.set(host.id, entry);
5763
+ }
5764
+ entry.name.textContent = host.name;
5765
+ entry.id.textContent = host.id;
5766
+ entry.button.disabled = busyId !== void 0;
5767
+ entry.button.textContent = t(busyId === host.id ? "extensionRecoveryRestarting" : "extensionRecoveryRestart");
5768
+ entry.button.setAttribute("aria-label", t("extensionRecoveryRestartAria", { name: host.name }));
5769
+ }
5770
+ feedback.hidden = errorText === "";
5771
+ feedback.textContent = errorText;
5772
+ retry.hidden = errorText === "";
5773
+ retry.disabled = reading || busyId !== void 0;
5774
+ };
5775
+ const accept = (payload, recovered = false) => {
5776
+ const parsed = parseUnavailableExtensionHosts(payload);
5777
+ if (parsed === void 0) throw new Error("extension_recovery_invalid_response");
5778
+ hosts = parsed;
5779
+ if (recovered || hosts.length === 0 || errorSource === "load") {
5780
+ errorText = "";
5781
+ errorSource = void 0;
5782
+ }
5783
+ render();
5784
+ };
5785
+ const refresh = async () => {
5786
+ if (!live()) return;
5787
+ if (reading || busyId !== void 0) {
5788
+ refreshPending = true;
5789
+ return;
5790
+ }
5791
+ reading = true;
5792
+ const current = ++revision;
5793
+ render();
5794
+ try {
5795
+ const payload = await request("/api/mobile-access/extensions/hosts");
5796
+ if (live() && current === revision) accept(payload);
5797
+ } catch (_error) {
5798
+ if (live() && current === revision && hosts.length > 0 && errorSource !== "recover") {
5799
+ errorText = t("extensionRecoveryLoadFailed");
5800
+ errorSource = "load";
5801
+ }
5802
+ } finally {
5803
+ reading = false;
5804
+ if (live()) {
5805
+ render();
5806
+ if (refreshPending && busyId === void 0) {
5807
+ refreshPending = false;
5808
+ await refresh();
5809
+ }
5810
+ }
5811
+ }
5812
+ };
5813
+ const recover = async (id) => {
5814
+ const host = hosts.find((entry) => entry.id === id);
5815
+ if (!live() || busyId !== void 0 || host === void 0 || !confirm(t("extensionRecoveryConfirm", { name: host.name }))) return;
5816
+ if (!live()) return;
5817
+ busyId = id;
5818
+ errorText = "";
5819
+ errorSource = void 0;
5820
+ const current = ++revision;
5821
+ render();
5822
+ try {
5823
+ const payload = await request("/api/mobile-access/extensions/recover", {
5824
+ method: "POST",
5825
+ body: JSON.stringify({
5826
+ id,
5827
+ confirm: true
5828
+ })
5829
+ });
5830
+ if (live() && current === revision) accept(payload, true);
5831
+ } catch (error) {
5832
+ if (live() && current === revision) {
5833
+ errorText = t(error instanceof Error && error.message === "extension_changed_during_activation" ? "extensionRecoveryChanged" : "extensionRecoveryFailed");
5834
+ errorSource = "recover";
5835
+ }
5836
+ } finally {
5837
+ busyId = void 0;
5838
+ if (live()) {
5839
+ render();
5840
+ if (refreshPending && !reading) {
5841
+ refreshPending = false;
5842
+ await refresh();
5843
+ }
5844
+ }
5845
+ }
5846
+ };
5847
+ retry.addEventListener("click", () => {
5848
+ errorText = "";
5849
+ errorSource = void 0;
5850
+ refresh();
5851
+ });
5852
+ return {
5853
+ section,
5854
+ refresh,
5855
+ dispose: () => {
5856
+ disposed = true;
5857
+ revision++;
5858
+ section.remove();
5859
+ rows.clear();
5860
+ }
5861
+ };
5862
+ }
5863
+ /** Theme-owned compact recovery rows; normal connection views keep their layout. */
5864
+ const EXTENSION_RECOVERY_STYLES = `
5865
+ .dsh-mobile-control__extension-recovery{margin:0 0 14px;padding:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary)}
5866
+ .dsh-mobile-control__extension-recovery[hidden]{display:none}
5867
+ .dsh-mobile-control__extension-recovery h3{margin:0 0 6px;font-size:13px;line-height:1.5;font-weight:600}
5868
+ .dsh-mobile-control__extension-recovery p{margin:0 0 8px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary)}
5869
+ .dsh-mobile-control__extension-recovery ul{list-style:none;margin:0;padding:0}
5870
+ .dsh-mobile-control__extension-recovery li{display:flex;align-items:center;gap:12px;padding:6px 0}
5871
+ .dsh-mobile-control__extension-recovery li>div{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;overflow-wrap:anywhere}
5872
+ .dsh-mobile-control__extension-recovery strong{font-size:12px;line-height:1.5}
5873
+ .dsh-mobile-control__extension-recovery code{font-size:11px;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}
5874
+ .dsh-mobile-control__extension-recovery button{flex:none;box-sizing:border-box;min-height:48px;min-width:48px;white-space:normal}
5875
+ .dsh-mobile-control__extension-recovery button:disabled{cursor:wait;opacity:.55}
5876
+ .dsh-mobile-control__extension-recovery button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
5877
+ .dsh-mobile-control__extension-recovery button:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active,var(--dsw-alias-interactive-bg-hover))}
5878
+ .dsh-mobile-control__extension-recovery .dsh-mobile-control__extension-recovery-error{color:var(--dsw-alias-label-primary)}
5879
+ `;
5880
+ //#endregion
5881
+ //#region src/native-capability.ts
5882
+ /** Cancel queued calls before native effects start; started effects are not rolled back. */
5883
+ function invokeBoundNativeCapability(bridge, currentBridge, action, input, signal) {
5884
+ const abortReason = () => signal.reason ?? new DOMException("mobile extension disposed", "AbortError");
5885
+ if (signal.aborted) return Promise.reject(abortReason());
5886
+ return new Promise((resolve, reject) => {
5887
+ let settled = false;
5888
+ const finish = (callback) => {
5889
+ if (settled) return;
5890
+ settled = true;
5891
+ signal.removeEventListener("abort", onAbort);
5892
+ callback();
5893
+ };
5894
+ const onAbort = () => {
5895
+ finish(() => {
5896
+ reject(abortReason());
5897
+ });
5898
+ };
5899
+ signal.addEventListener("abort", onAbort, { once: true });
5900
+ Promise.resolve().then(() => {
5901
+ if (settled || signal.aborted) throw abortReason();
5902
+ if (currentBridge() !== bridge) throw new DOMException("native bridge changed", "AbortError");
5903
+ return bridge.invoke(action, input);
5904
+ }).then((value) => {
5905
+ finish(() => {
5906
+ resolve(value);
5907
+ });
5908
+ }, (error) => {
5909
+ finish(() => {
5910
+ reject(error);
5911
+ });
5912
+ });
5913
+ });
5914
+ }
5915
+ //#endregion
4781
5916
  //#region src/client.ts
4782
5917
  /** Map a host-reported provider onto the UI union, defaulting to the built-in one. */
4783
5918
  function parseMobileRemoteProvider(value) {
@@ -5291,6 +6426,14 @@ window.__ModuleLoader__.load({
5291
6426
  funnel_https_required: "funnelHttps",
5292
6427
  funnel_start_failed: "funnelStart",
5293
6428
  funnel_start_timeout: "funnelTimeout",
6429
+ funnel_state_invalid: "funnelStateInvalid",
6430
+ cpolar_path_invalid: "componentPathUnsafe",
6431
+ cloudflared_path_invalid: "componentPathUnsafe",
6432
+ cloudflared_tunnel_path_invalid: "componentPathUnsafe",
6433
+ frp_path_invalid: "componentPathUnsafe",
6434
+ frp_config_path_invalid: "componentPathUnsafe",
6435
+ frp_ingress_path_invalid: "componentPathUnsafe",
6436
+ caddy_path_invalid: "componentPathUnsafe",
5294
6437
  tailscale_dns_missing: "tailscaleDnsMissing",
5295
6438
  gateway_start_failed: "gatewayStartFailed",
5296
6439
  control_channel_failed: "controlChannelFailed",
@@ -5388,6 +6531,7 @@ window.__ModuleLoader__.load({
5388
6531
  "cpolar_component_invalid",
5389
6532
  "cpolar_config_missing",
5390
6533
  "cpolar_config_invalid",
6534
+ "funnel_state_invalid",
5391
6535
  "cpolar_start_timeout",
5392
6536
  "cpolar_stopped",
5393
6537
  "cpolar_exited",
@@ -5462,6 +6606,13 @@ window.__ModuleLoader__.load({
5462
6606
  ...init,
5463
6607
  signal: lifecycle.signal
5464
6608
  }, timeoutMs);
6609
+ const extensionRecovery = installExtensionRecoveryControl({
6610
+ document,
6611
+ signal: lifecycle.signal,
6612
+ request: controlRequestJson,
6613
+ confirm: (message) => window.confirm(message),
6614
+ translate: t
6615
+ });
5465
6616
  const root = element("div", "dsh-mobile-control");
5466
6617
  root.lang = locale;
5467
6618
  const panel = element("section", "dsh-mobile-control__panel");
@@ -6274,6 +7425,9 @@ window.__ModuleLoader__.load({
6274
7425
  caddyModeManagedLabel.append(caddyModeManaged, caddyManagedText);
6275
7426
  caddyModeExternalLabel.append(caddyModeExternal, caddyExternalText);
6276
7427
  caddyModeRow.append(caddyModeLegend, caddyModeExternalLabel, caddyModeManagedLabel);
7428
+ const caddyModePreviewNote = element("p", "dsh-mobile-control__component-note");
7429
+ caddyModePreviewNote.textContent = t("caddyModePreviewNote");
7430
+ caddyModePreviewNote.hidden = true;
6277
7431
  const caddyForm = element("div", "dsh-mobile-control__caddy-form");
6278
7432
  caddyForm.hidden = true;
6279
7433
  const caddyFields = element("div", "dsh-mobile-control__origin-fields");
@@ -6341,7 +7495,7 @@ window.__ModuleLoader__.load({
6341
7495
  const caddyActions = element("div", "dsh-mobile-control__actions dsh-mobile-control__caddy-actions");
6342
7496
  caddyActions.append(caddyInstall, caddySave, caddyPurgeButton);
6343
7497
  caddyForm.append(caddyFields, caddyInstallNote, caddyStatus, caddyActions, caddyFeedback);
6344
- originSetup.append(originSetupTitle, caddyModeRow, originHelp, originWarning, originFields, originFeedback, originFormActions, caddyForm, originBackend);
7498
+ originSetup.append(originSetupTitle, caddyModeRow, caddyModePreviewNote, originHelp, originWarning, originFields, originFeedback, originFormActions, caddyForm, originBackend);
6345
7499
  const tailscaleInfo = element("details", "dsh-mobile-control__details");
6346
7500
  const tailscaleInfoSummary = element("summary");
6347
7501
  tailscaleInfoSummary.textContent = t("tailscaleHelp");
@@ -6670,7 +7824,7 @@ window.__ModuleLoader__.load({
6670
7824
  lanView.append(lanSetup, access, qrBox, status, extensionStatus, actions, manageRow, devicePanel, trustedNetworks);
6671
7825
  remoteView.append(remoteIntro, httpFrameWarning, providerSection, remoteWorkspace);
6672
7826
  diagnosticsView.append(diagnosticsIntro, diagnosticsSummary, diagnosticsToolbar, diagnosticsFeedback, diagnosticsChecks, wsPathsSection, diagnosticsDetails);
6673
- panel.append(header, releaseNotice, updateCard, appDownload, switcher, lanView, remoteView, diagnosticsView);
7827
+ panel.append(header, releaseNotice, updateCard, appDownload, extensionRecovery.section, switcher, lanView, remoteView, diagnosticsView);
6674
7828
  root.append(panel);
6675
7829
  document.body.append(root);
6676
7830
  let running = false;
@@ -6790,6 +7944,7 @@ window.__ModuleLoader__.load({
6790
7944
  providerInfoButton.setAttribute("aria-expanded", "false");
6791
7945
  });
6792
7946
  const selectView = (view) => {
7947
+ if (!panel.hidden) extensionRecovery.refresh();
6793
7948
  if (view !== "diagnostics") previousAccessView = view;
6794
7949
  lanView.hidden = view !== "lan";
6795
7950
  remoteView.hidden = view !== "remote";
@@ -6820,6 +7975,7 @@ window.__ModuleLoader__.load({
6820
7975
  });
6821
7976
  const setOpen = (open) => {
6822
7977
  panel.hidden = !open;
7978
+ if (open) extensionRecovery.refresh();
6823
7979
  for (const trigger of document.querySelectorAll(".dsh-mobile-control__trigger")) trigger.setAttribute("aria-expanded", String(open));
6824
7980
  };
6825
7981
  const render = (data) => {
@@ -7042,6 +8198,7 @@ window.__ModuleLoader__.load({
7042
8198
  const cloudflared = remoteProvider === "cloudflared";
7043
8199
  const frp = remoteProvider === "frp";
7044
8200
  const origin = remoteProvider === "origin";
8201
+ const managedOrigin = origin && data.originMode === "managed";
7045
8202
  const tailscale = remoteProvider === "tailscale";
7046
8203
  tailscaleChoice.classList.toggle("is-selected", tailscale);
7047
8204
  cpolarChoice.classList.toggle("is-selected", cpolar);
@@ -7056,7 +8213,7 @@ window.__ModuleLoader__.load({
7056
8213
  cloudflaredChoice.tabIndex = cloudflared ? 0 : -1;
7057
8214
  frpChoice.setAttribute("aria-pressed", String(frp));
7058
8215
  originChoice.setAttribute("aria-pressed", String(origin));
7059
- providerSetupName.textContent = cpolar ? "cpolar" : cloudflared ? "cloudflared" : frp ? t("frpName") : origin ? t("originName") : "Tailscale Funnel";
8216
+ providerSetupName.textContent = cpolar ? "cpolar" : cloudflared ? "cloudflared" : frp ? t("frpName") : origin ? t(managedOrigin ? "caddyName" : "originName") : "Tailscale Funnel";
7060
8217
  tailscaleChoice.disabled = remoteProviderBusy;
7061
8218
  cpolarChoice.disabled = remoteProviderBusy;
7062
8219
  cloudflaredChoice.disabled = remoteProviderBusy;
@@ -7251,6 +8408,7 @@ window.__ModuleLoader__.load({
7251
8408
  const caddySupported = caddyComponentInfo !== void 0 && caddyComponentInfo.supported === true;
7252
8409
  if (!caddyModePreviewDirty) caddyModeManagedSelected = data.originMode === "managed";
7253
8410
  const showManaged = origin && caddyModeManagedSelected;
8411
+ caddyModePreviewNote.hidden = !origin || !caddyModePreviewDirty || caddyModeManagedSelected === managedOrigin;
7254
8412
  const caddyConfiguration = typeof data.caddyConfiguration === "object" && data.caddyConfiguration !== null ? data.caddyConfiguration : {};
7255
8413
  const caddyState = typeof data.caddyState === "object" && data.caddyState !== null ? data.caddyState : {};
7256
8414
  caddyModeRow.hidden = !origin;
@@ -7284,7 +8442,6 @@ window.__ModuleLoader__.load({
7284
8442
  caddySecretKey,
7285
8443
  caddyPort
7286
8444
  ]) input.disabled = remoteProviderBusy || !caddySupported;
7287
- selfHostedBadge.textContent = origin ? originListening ? t("originBackendReady") : t("advanced") : frpConfigured && frpInstalled ? t("ready") : t("advanced");
7288
8445
  const state = typeof data.state === "string" ? data.state : "error";
7289
8446
  const errorCode = typeof data.errorCode === "string" ? data.errorCode : "";
7290
8447
  const remoteOrigin = typeof data.origin === "string" ? data.origin : "";
@@ -7297,10 +8454,11 @@ window.__ModuleLoader__.load({
7297
8454
  }[errorCode] ?? "";
7298
8455
  const needsFunnelSetup = state === "error" && remoteSetupUrl !== "";
7299
8456
  remoteReady = remoteRunning && state === "ready" && remoteOrigin !== "";
8457
+ selfHostedBadge.textContent = origin ? managedOrigin ? t(remoteReady ? "ready" : "advanced") : originListening ? t("originBackendReady") : t("advanced") : frpConfigured && frpInstalled ? t("ready") : t("advanced");
7300
8458
  remoteStateBadge.classList.toggle("is-ready", remoteReady);
7301
8459
  remoteStateBadge.classList.toggle("is-busy", state === "starting" || state === "connecting" || state === "needs-login");
7302
8460
  remoteStateBadge.classList.toggle("is-attention", state === "error" || state === "unavailable");
7303
- remoteStateBadge.textContent = remoteReady ? origin ? t("originBackendReady") : t("ready") : state === "starting" || state === "connecting" || state === "needs-login" ? t("remoteStateConnecting") : state === "error" || state === "unavailable" ? t("remoteStateAttention") : t("remoteStateOff");
8461
+ remoteStateBadge.textContent = remoteReady ? origin && !managedOrigin ? t("originBackendReady") : t("ready") : state === "starting" || state === "connecting" || state === "needs-login" ? t("remoteStateConnecting") : state === "error" || state === "unavailable" ? t("remoteStateAttention") : t("remoteStateOff");
7304
8462
  remoteAccess.hidden = !remoteReady;
7305
8463
  remoteAccessLink.href = remoteOrigin;
7306
8464
  remoteAccessLink.textContent = remoteOrigin;
@@ -7312,7 +8470,7 @@ window.__ModuleLoader__.load({
7312
8470
  starting: cpolar ? t("remoteStartingCpolar") : cloudflared ? t("remoteStartingCloudflared") : frp ? t("remoteStartingFrp") : origin ? t("originStarting") : t("remoteStartingTailscale"),
7313
8471
  "needs-login": t("remoteNeedsLogin"),
7314
8472
  connecting: cpolar ? t("remoteConnectingCpolar") : cloudflared ? t("remoteConnectingCloudflared") : frp ? t("remoteConnectingFrp") : t("remoteConnectingTailscale"),
7315
- ready: origin ? t("originReady") : t("remoteReady"),
8473
+ ready: origin ? t(managedOrigin ? "caddyVerified" : "originReady") : t("remoteReady"),
7316
8474
  error: t("remoteError")
7317
8475
  };
7318
8476
  const errorLabels = translatedRemoteErrors();
@@ -8711,6 +9869,9 @@ window.__ModuleLoader__.load({
8711
9869
  loadDiagnostics();
8712
9870
  });
8713
9871
  diagnosticsRun.addEventListener("click", loadDiagnostics);
9872
+ diagnosticsRun.addEventListener("click", () => {
9873
+ extensionRecovery.refresh();
9874
+ });
8714
9875
  const closeUpdateCard = () => {
8715
9876
  updateCard.hidden = true;
8716
9877
  if (pluginUpdateAvailable && pluginLatestVersion !== "") updatePlugin.hidden = !diagnosticsView.hidden;
@@ -8802,10 +9963,14 @@ window.__ModuleLoader__.load({
8802
9963
  if (!diagnosticsView.hidden) renderWsBlocked(wsPathsBlocked);
8803
9964
  }, () => {});
8804
9965
  };
8805
- const wsBlockedPoll = window.setInterval(pollWsBlocked, 2e4);
9966
+ const wsBlockedPoll = window.setInterval(() => {
9967
+ pollWsBlocked();
9968
+ if (!panel.hidden) extensionRecovery.refresh();
9969
+ }, 2e4);
8806
9970
  return {
8807
9971
  remove: () => {
8808
9972
  lifecycle.abort();
9973
+ extensionRecovery.dispose();
8809
9974
  window.clearInterval(remotePoll);
8810
9975
  window.clearInterval(wsBlockedPoll);
8811
9976
  window.removeEventListener("focus", retryAfterSetup);
@@ -9352,6 +10517,7 @@ window.__ModuleLoader__.load({
9352
10517
  const activations = new PerIdActivationLifecycle();
9353
10518
  const styleNodes = /* @__PURE__ */ new Map();
9354
10519
  const styleEtags = /* @__PURE__ */ new Map();
10520
+ const scriptEtags = /* @__PURE__ */ new Map();
9355
10521
  const scriptDigests = /* @__PURE__ */ new Map();
9356
10522
  const activeHostGenerations = /* @__PURE__ */ new Map();
9357
10523
  const activationKeys = /* @__PURE__ */ new Map();
@@ -9412,30 +10578,7 @@ window.__ModuleLoader__.load({
9412
10578
  const abortReason = () => signal.reason ?? new DOMException("mobile extension disposed", "AbortError");
9413
10579
  if (signal.aborted) throw abortReason();
9414
10580
  const bridge = window.__DSH_MOBILE_NATIVE__;
9415
- if (bridge !== void 0) return new Promise((resolve, reject) => {
9416
- let settled = false;
9417
- const finish = (callback) => {
9418
- if (settled) return;
9419
- settled = true;
9420
- signal.removeEventListener("abort", onAbort);
9421
- callback();
9422
- };
9423
- const onAbort = () => {
9424
- finish(() => {
9425
- reject(abortReason());
9426
- });
9427
- };
9428
- signal.addEventListener("abort", onAbort, { once: true });
9429
- Promise.resolve().then(() => bridge.invoke(action, input)).then((value) => {
9430
- finish(() => {
9431
- resolve(materializeNativeFile(value));
9432
- });
9433
- }, (error) => {
9434
- finish(() => {
9435
- reject(error);
9436
- });
9437
- });
9438
- });
10581
+ if (bridge !== void 0) return invokeBoundNativeCapability(bridge, () => window.__DSH_MOBILE_NATIVE__, action, input, signal).then(materializeNativeFile);
9439
10582
  if (action === "share" && typeof navigator.share === "function") {
9440
10583
  await navigator.share(input ?? {});
9441
10584
  return { ok: true };
@@ -9755,6 +10898,7 @@ window.__ModuleLoader__.load({
9755
10898
  styleNodes.get(id)?.remove();
9756
10899
  styleNodes.delete(id);
9757
10900
  styleEtags.delete(id);
10901
+ scriptEtags.delete(id);
9758
10902
  scriptDigests.delete(id);
9759
10903
  activeHostGenerations.delete(id);
9760
10904
  activationKeys.delete(id);
@@ -9763,6 +10907,7 @@ window.__ModuleLoader__.load({
9763
10907
  const managedManifestIdSources = () => [
9764
10908
  styleNodes.keys(),
9765
10909
  styleEtags.keys(),
10910
+ scriptEtags.keys(),
9766
10911
  scriptDigests.keys(),
9767
10912
  managedDefinitionIds
9768
10913
  ];
@@ -9856,12 +11001,13 @@ window.__ModuleLoader__.load({
9856
11001
  commitStyle(entry.id, styleChange, pendingCss, pendingStyleEtag);
9857
11002
  activations.remove(entry.id);
9858
11003
  activeHostGenerations.delete(entry.id);
11004
+ scriptEtags.delete(entry.id);
9859
11005
  scriptDigests.delete(entry.id);
9860
11006
  if (managedDefinitionIds.delete(entry.id)) definitions.delete(entry.id);
9861
11007
  } else {
9862
11008
  const scriptHeaders = {};
9863
- const storedDigest = scriptDigests.get(entry.id);
9864
- if (storedDigest !== void 0) scriptHeaders["if-none-match"] = storedDigest;
11009
+ const storedEtag = scriptEtags.get(entry.id);
11010
+ if (storedEtag !== void 0) scriptHeaders["if-none-match"] = storedEtag;
9865
11011
  const scriptResponse = await fetch(scriptUrl, {
9866
11012
  credentials: "same-origin",
9867
11013
  cache: "no-store",
@@ -9869,8 +11015,10 @@ window.__ModuleLoader__.load({
9869
11015
  signal
9870
11016
  });
9871
11017
  let nextDigest;
11018
+ let nextEtag;
9872
11019
  if (scriptResponse.status !== 304) {
9873
11020
  if (!scriptResponse.ok) throw new Error("mobile extension script failed to load");
11021
+ nextEtag = scriptResponse.headers.get("etag") ?? void 0;
9874
11022
  const source = await scriptResponse.text();
9875
11023
  if (refreshAborted(signal)) return false;
9876
11024
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(source));
@@ -9912,6 +11060,10 @@ window.__ModuleLoader__.load({
9912
11060
  }
9913
11061
  managedDefinitionIds.add(entry.id);
9914
11062
  if (nextDigest !== void 0) scriptDigests.set(entry.id, nextDigest);
11063
+ if (scriptResponse.status !== 304) {
11064
+ if (nextEtag === void 0 || nextEtag === "") scriptEtags.delete(entry.id);
11065
+ else scriptEtags.set(entry.id, nextEtag);
11066
+ }
9915
11067
  }
9916
11068
  } catch {
9917
11069
  if (evaluatedDefinition) {
@@ -10051,7 +11203,7 @@ window.__ModuleLoader__.load({
10051
11203
  .dsh-mobile-control__origin-modes{min-width:0;margin:0 0 14px;padding:0;border:0}.dsh-mobile-control__origin-modes legend{margin:0 0 8px;padding:0;color:var(--dsw-alias-label-secondary,#606873);font:600 12px/1.4 system-ui}.dsh-mobile-control__origin-mode{display:flex;box-sizing:border-box;min-height:44px;align-items:center;gap:10px;padding:10px;border:1px solid var(--dsw-alias-border-l2,#dbe1e8);border-radius:10px;background:var(--dsw-alias-bg-layer-1,#f3f5f8);font:12px/1.5 system-ui;cursor:pointer}.dsh-mobile-control__origin-mode+.dsh-mobile-control__origin-mode{margin-top:8px}.dsh-mobile-control__origin-mode:has(input:checked){border-color:var(--dsw-alias-label-primary-bluish,#2563eb)}.dsh-mobile-control__origin-mode:focus-within{outline:2px solid var(--dsw-alias-state-business-primary,#2563eb);outline-offset:2px}.dsh-mobile-control__origin-mode:has(input:disabled){cursor:wait;opacity:.55}.dsh-mobile-control__origin-mode input[type=radio]{flex:0 0 18px;box-sizing:border-box;width:18px;height:18px;min-height:18px;margin:0;padding:0;accent-color:var(--dsw-alias-label-primary-bluish,#2563eb)}.dsh-mobile-control__origin-mode span{min-width:0;overflow-wrap:anywhere}
10052
11204
  /* Base the columns on the card width, not the desktop viewport behind the popup. */
10053
11205
  .dsh-mobile-control__origin-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,140px),1fr));gap:12px}.dsh-mobile-control__origin-fields>.dsh-mobile-control__field{min-width:0;grid-column:auto;font-size:12px}.dsh-mobile-control__origin-fields>.dsh-mobile-control__field--full{grid-column:1/-1}.dsh-mobile-control__origin-fields select{box-sizing:border-box;width:100%;min-height:44px;padding:9px 10px;border:1px solid var(--dsw-alias-border-l3,#cfd5dd);border-radius:10px;background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-primary,#16181d);font:16px/1.4 system-ui}.dsh-mobile-control__origin-fields select:disabled{cursor:not-allowed;opacity:.55}.dsh-mobile-control__caddy-form{display:grid;gap:12px}.dsh-mobile-control__caddy-form>.dsh-mobile-control__component-note,.dsh-mobile-control__caddy-form>.dsh-mobile-control__component-status,.dsh-mobile-control__caddy-form>.dsh-mobile-control__origin-feedback{margin:0;overflow-wrap:anywhere}.dsh-mobile-control__origin-setup .dsh-mobile-control__actions.dsh-mobile-control__caddy-actions{grid-template-columns:minmax(0,1fr);margin:0}.dsh-mobile-control__caddy-actions button{box-sizing:border-box;min-width:0;min-height:44px;padding:10px 12px;border-radius:10px;font:600 12px/1.4 system-ui;cursor:pointer}.dsh-mobile-control__origin-warning{margin:12px 0;padding:10px 12px;border-left:3px solid var(--dsw-alias-state-warn-primary,#a56810);border-radius:4px;background:var(--dsw-alias-bg-layer-1,#f3f5f8);color:var(--dsw-alias-label-primary,#384152);font-size:12px;line-height:1.6}.dsh-mobile-control__origin-feedback{font-size:12px;line-height:1.6;overflow-wrap:anywhere}.dsh-mobile-control__origin-feedback.is-error{color:var(--dsw-alias-state-error-primary,#bc3030)}.dsh-mobile-control__origin-fields input[aria-invalid=true]{border-color:var(--dsw-alias-state-error-primary,#bc3030)}.dsh-mobile-control__origin-backend{display:flex;min-width:0;flex-direction:column;align-items:stretch;gap:8px;margin-top:12px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l2,#dbe1e8)}.dsh-mobile-control__origin-backend button{align-self:flex-start;min-height:44px}.dsh-mobile-control__self-hosted-body .is-origin{margin-top:8px}
10054
- `;
11206
+ ` + EXTENSION_RECOVERY_STYLES;
10055
11207
  /** Mount the desktop control or mobile feature enhancements. */
10056
11208
  function apply(ctx) {
10057
11209
  ctx.effect(() => {
@@ -10086,6 +11238,11 @@ window.__ModuleLoader__.load({
10086
11238
  preference: localFont.preference,
10087
11239
  locale: selectedMobileControlLocale()
10088
11240
  }));
11241
+ const removeDisplayScale = ctx.slots.register({
11242
+ name: "settings.general.item",
11243
+ id: "dsh-mobile-display-scale",
11244
+ order: 12
11245
+ }, () => (0, react.createElement)(MobileDisplayScaleRow, { locale: selectedMobileControlLocale() }));
10089
11246
  const removeSwitchComputer = ctx.slots.register({
10090
11247
  name: "settings.general.item",
10091
11248
  id: "dsh-mobile-switch-computer",
@@ -10099,6 +11256,7 @@ window.__ModuleLoader__.load({
10099
11256
  return () => {
10100
11257
  removeTaskNotifications();
10101
11258
  removeSwitchComputer();
11259
+ removeDisplayScale();
10102
11260
  removeFontSize();
10103
11261
  };
10104
11262
  });