dsh-mobile 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -803,7 +803,22 @@ window.__ModuleLoader__.load({
803
803
  lanSetupConfiguring: "Creating private certificates and local network rules…",
804
804
  lanSetupRequired: "Local network setup has not been completed. Choose a network above to continue.",
805
805
  lanSetupRestart: "Setup complete for {origin}. Restart DSH once to start local network access.",
806
- lanSetupFailed: "Setup was not completed. Confirm the network selection and approve the Windows prompt, then try again."
806
+ lanSetupFailed: "Setup was not completed. Confirm the network selection and approve the Windows prompt, then try again.",
807
+ lanChangeTitle: "Change network",
808
+ lanChangeIntro: "Move local network access to another interface. The private CA and paired devices are kept; connected phones reconnect with the new address.",
809
+ lanChangeSelect: "New network",
810
+ lanChangeRefresh: "Scan again",
811
+ lanChangeConfirm: "Change network",
812
+ lanChangeBusy: "Changing network…",
813
+ lanChangeOk: "Network saved. If local access is stopped, enable it before connecting to the new address.",
814
+ lanChangeOkDisconnected: "Network changed and the listener restarted. Connected phones were disconnected; generate a new pairing QR to reconnect.",
815
+ lanChangeCurrent: "current",
816
+ lanChangeUnchanged: "That is already the current network.",
817
+ lanChangeUnavailable: "That network is no longer available. Scan again.",
818
+ lanChangeUnsupported: "This setup uses a fixed address, so it cannot change networks here.",
819
+ lanChangeFailed: "The change did not complete. Refresh the status before retrying; the previous configuration may have been restored.",
820
+ lanChangeRefreshFailed: "Could not refresh network status. A submitted change may have completed; check the current status before retrying.",
821
+ lanChangeRecoveryRequired: "The previous network could not be restored. Check local network configuration before enabling access again."
807
822
  });
808
823
  Object.assign(MOBILE_CONTROL_MESSAGES.it, {
809
824
  lanSetupTitle: "Completa la configurazione della rete locale",
@@ -818,7 +833,22 @@ window.__ModuleLoader__.load({
818
833
  lanSetupConfiguring: "Creazione dei certificati privati e delle regole di rete locale…",
819
834
  lanSetupRequired: "La configurazione della rete locale non è completa. Scegli una rete qui sopra per continuare.",
820
835
  lanSetupRestart: "Configurazione completata per {origin}. Riavvia DSH una volta per attivare l’accesso locale.",
821
- lanSetupFailed: "Configurazione non completata. Controlla la rete e approva la richiesta di Windows, quindi riprova."
836
+ lanSetupFailed: "Configurazione non completata. Controlla la rete e approva la richiesta di Windows, quindi riprova.",
837
+ lanChangeTitle: "Cambia rete",
838
+ lanChangeIntro: "Sposta l’accesso alla rete locale su un’altra interfaccia. La CA privata e i dispositivi abbinati restano; i telefoni collegati si riconnettono con il nuovo indirizzo.",
839
+ lanChangeSelect: "Nuova rete",
840
+ lanChangeRefresh: "Cerca di nuovo",
841
+ lanChangeConfirm: "Cambia rete",
842
+ lanChangeBusy: "Cambio della rete…",
843
+ lanChangeOk: "Rete salvata. Se l’accesso locale è disattivato, attivalo prima di collegarti al nuovo indirizzo.",
844
+ lanChangeOkDisconnected: "Rete cambiata e listener riavviato. I telefoni collegati sono stati disconnessi; genera un nuovo QR di abbinamento per riconnetterti.",
845
+ lanChangeCurrent: "attuale",
846
+ lanChangeUnchanged: "Questa è già la rete attuale.",
847
+ lanChangeUnavailable: "La rete non è più disponibile. Cerca di nuovo.",
848
+ lanChangeUnsupported: "Questa configurazione usa un indirizzo fisso: non può cambiare rete qui.",
849
+ lanChangeFailed: "Cambio non completato. Aggiorna lo stato prima di riprovare; la configurazione precedente potrebbe essere stata ripristinata.",
850
+ lanChangeRefreshFailed: "Impossibile aggiornare lo stato. Una modifica inviata potrebbe essere stata completata; verifica prima di riprovare.",
851
+ lanChangeRecoveryRequired: "Impossibile ripristinare la rete precedente. Controlla la configurazione locale prima di riattivare l’accesso."
822
852
  });
823
853
  Object.assign(MOBILE_CONTROL_MESSAGES.zh, {
824
854
  lanSetupTitle: "完成局域网配置",
@@ -833,7 +863,22 @@ window.__ModuleLoader__.load({
833
863
  lanSetupConfiguring: "正在生成私有证书并配置局域网规则…",
834
864
  lanSetupRequired: "尚未完成局域网配置,请选择上方网络继续。",
835
865
  lanSetupRestart: "已为 {origin} 完成配置。请重启一次 DSH,局域网访问会自动开启。",
836
- lanSetupFailed: "配置未完成。请确认网卡选择并允许 Windows 授权提示,然后重试。"
866
+ lanSetupFailed: "配置未完成。请确认网卡选择并允许 Windows 授权提示,然后重试。",
867
+ lanChangeTitle: "更换网络",
868
+ lanChangeIntro: "把局域网访问切换到另一块网卡。私有 CA 与已配对设备都会保留,已连接的手机需要用新地址重新连接。",
869
+ lanChangeSelect: "新网络",
870
+ lanChangeRefresh: "重新扫描",
871
+ lanChangeConfirm: "更换网络",
872
+ lanChangeBusy: "正在更换网络…",
873
+ lanChangeOk: "网络配置已保存。若局域网访问已关闭,请开启后再用新地址连接。",
874
+ lanChangeOkDisconnected: "网络已更换并重启监听,已连接的手机已断开;请生成新的配对二维码重新连接。",
875
+ lanChangeCurrent: "当前",
876
+ lanChangeUnchanged: "这已经是当前网络。",
877
+ lanChangeUnavailable: "该网络已不可用,请重新扫描。",
878
+ lanChangeUnsupported: "当前配置使用固定地址,无法在这里更换网络。",
879
+ lanChangeFailed: "更换未完成。请先刷新状态再决定是否重试;先前配置可能已恢复。",
880
+ lanChangeRefreshFailed: "无法刷新网络状态,已提交的更换可能已完成;请先确认当前状态,不要重复提交。",
881
+ lanChangeRecoveryRequired: "无法恢复先前网络,请先检查局域网配置,再决定是否重新开启访问。"
837
882
  });
838
883
  Object.assign(MOBILE_CONTROL_MESSAGES.en, {
839
884
  selfHostedConnections: "Self-hosted connection",
@@ -3634,6 +3679,163 @@ window.__ModuleLoader__.load({
3634
3679
  });
3635
3680
  }
3636
3681
  //#endregion
3682
+ //#region src/mobile-sidebar-scroll.ts
3683
+ function sidebarRestoreTop(snapshot, anchorTop) {
3684
+ return Math.max(0, anchorTop === void 0 || snapshot.offset === void 0 ? snapshot.top : anchorTop - snapshot.offset);
3685
+ }
3686
+ const keyedRows = "[data-row-key]";
3687
+ const hasSuffix = (element, suffix) => [...element.classList].some((token) => token.endsWith(suffix));
3688
+ function visible(element) {
3689
+ return element.isConnected && element.clientHeight > 0 && element.getClientRects().length > 0;
3690
+ }
3691
+ function captureSidebarScroll(element) {
3692
+ const top = element.scrollTop;
3693
+ const bounds = element.getBoundingClientRect();
3694
+ const row = [...element.querySelectorAll(keyedRows)].find((candidate) => {
3695
+ const rect = candidate.getBoundingClientRect();
3696
+ return rect.height > 0 && rect.bottom > bounds.top && rect.top < bounds.bottom;
3697
+ });
3698
+ return row === void 0 ? { top } : {
3699
+ top,
3700
+ anchor: row.getAttribute("data-row-key") ?? "",
3701
+ offset: row.getBoundingClientRect().top - bounds.top
3702
+ };
3703
+ }
3704
+ /**
3705
+ * Uses browser scrollports/keyed rows only; no React internals, DOM reordering,
3706
+ * forced group expansion or persistent storage of session identities. Absolute
3707
+ * position seeds a virtual window before a mounted keyed anchor refines it.
3708
+ */
3709
+ function installMobileSidebarScrollMemory() {
3710
+ const memories = /* @__PURE__ */ new Map();
3711
+ const stockViewport = window.matchMedia("(max-width:720px)");
3712
+ const dedicatedViewport = window.matchMedia("(max-width:899px)");
3713
+ let root;
3714
+ let open = false;
3715
+ let ports = /* @__PURE__ */ new Map();
3716
+ let pending = /* @__PURE__ */ new Map();
3717
+ let frame = 0;
3718
+ let deadline = 0;
3719
+ let disposed = false;
3720
+ const isOpen = () => root !== void 0 && visible(root) && !hasSuffix(root, "_collapsed") && root.closest("[data-open=\"false\"]") === null && root.querySelector("[data-dsh-mobile-settings]") === null;
3721
+ const cancel = () => {
3722
+ if (frame !== 0) cancelAnimationFrame(frame);
3723
+ frame = 0;
3724
+ pending.clear();
3725
+ };
3726
+ const remember = () => {
3727
+ if (!open || !isOpen()) return;
3728
+ for (const [port, key] of ports) if (!pending.has(port) && visible(port)) memories.set(key, port === root ? { top: port.scrollTop } : captureSidebarScroll(port));
3729
+ };
3730
+ const restore = () => {
3731
+ frame = 0;
3732
+ if (disposed || !open || !isOpen()) return;
3733
+ for (const [port, state] of pending) {
3734
+ if (!visible(port)) continue;
3735
+ const snapshot = state.snapshot;
3736
+ if (snapshot === void 0) {
3737
+ if (port === root) {
3738
+ pending.delete(port);
3739
+ continue;
3740
+ }
3741
+ const selected = port.querySelector("[role=\"treeitem\"][aria-selected=\"true\"]");
3742
+ if (selected !== null && visible(selected)) {
3743
+ const bounds = port.getBoundingClientRect(), rect = selected.getBoundingClientRect();
3744
+ if (rect.top < bounds.top || rect.bottom > bounds.bottom) port.scrollTop += rect.top - bounds.top - (port.clientHeight - rect.height) / 2;
3745
+ pending.delete(port);
3746
+ } else if (port.querySelector(keyedRows) !== null) pending.delete(port);
3747
+ continue;
3748
+ }
3749
+ const anchor = [...port.querySelectorAll(keyedRows)].find((row) => row.dataset.rowKey === snapshot.anchor);
3750
+ const target = sidebarRestoreTop(snapshot, anchor === void 0 ? void 0 : port.scrollTop + anchor.getBoundingClientRect().top - port.getBoundingClientRect().top);
3751
+ const before = port.scrollTop;
3752
+ port.scrollTop = target;
3753
+ if (Math.abs(port.scrollTop - target) <= 1 && Math.abs(before - port.scrollTop) <= 1) state.stable++;
3754
+ else state.stable = 0;
3755
+ if (state.stable >= 2) pending.delete(port);
3756
+ }
3757
+ if (pending.size > 0 && performance.now() < deadline) frame = requestAnimationFrame(restore);
3758
+ else pending.clear();
3759
+ };
3760
+ const sync = () => {
3761
+ if (disposed) return;
3762
+ const next = document.querySelector("[data-dsh-mobile-sidebar-root]") ?? void 0;
3763
+ const mobile = next?.closest("[data-pane=\"sidebar\"]") != null ? dedicatedViewport.matches : stockViewport.matches;
3764
+ if (next !== root) {
3765
+ cancel();
3766
+ ports.clear();
3767
+ root = next;
3768
+ open = false;
3769
+ }
3770
+ if (!(mobile && isOpen())) {
3771
+ cancel();
3772
+ open = false;
3773
+ ports.clear();
3774
+ return;
3775
+ }
3776
+ const reopening = !open;
3777
+ open = true;
3778
+ if (root === void 0) return;
3779
+ const candidates = [root, ...root.querySelectorAll("[class*=\"_regionArea\"] [class*=\"_list\"]")].filter((port) => port === root || hasSuffix(port, "_list"));
3780
+ const nextPorts = /* @__PURE__ */ new Map();
3781
+ for (const port of candidates) {
3782
+ const key = port === root ? "outer" : `workspace:${[...port.classList].sort().join(" ")}:${root.querySelector("[class*=\"_regionArea\"] input")?.value ?? ""}`;
3783
+ nextPorts.set(port, key);
3784
+ if (reopening || ports.get(port) !== key) {
3785
+ pending.set(port, {
3786
+ snapshot: memories.get(key),
3787
+ stable: 0
3788
+ });
3789
+ deadline = performance.now() + 1200;
3790
+ }
3791
+ }
3792
+ for (const port of pending.keys()) if (!nextPorts.has(port)) pending.delete(port);
3793
+ ports = nextPorts;
3794
+ if (pending.size > 0 && frame === 0) frame = requestAnimationFrame(restore);
3795
+ };
3796
+ const onScroll = (event) => {
3797
+ const port = event.target;
3798
+ if (!(port instanceof HTMLElement) || !open || !isOpen() || pending.has(port) || !visible(port)) return;
3799
+ const key = ports.get(port);
3800
+ if (key !== void 0) memories.set(key, port === root ? { top: port.scrollTop } : captureSidebarScroll(port));
3801
+ };
3802
+ const onIntent = (event) => {
3803
+ if (!(event.target instanceof Node) || !root?.contains(event.target)) return;
3804
+ cancel();
3805
+ remember();
3806
+ };
3807
+ stockViewport.addEventListener("change", sync);
3808
+ dedicatedViewport.addEventListener("change", sync);
3809
+ document.addEventListener("scroll", onScroll, true);
3810
+ for (const type of [
3811
+ "pointerdown",
3812
+ "touchstart",
3813
+ "wheel",
3814
+ "keydown",
3815
+ "click"
3816
+ ]) document.addEventListener(type, onIntent, true);
3817
+ return {
3818
+ sync,
3819
+ dispose: () => {
3820
+ disposed = true;
3821
+ cancel();
3822
+ stockViewport.removeEventListener("change", sync);
3823
+ dedicatedViewport.removeEventListener("change", sync);
3824
+ document.removeEventListener("scroll", onScroll, true);
3825
+ for (const type of [
3826
+ "pointerdown",
3827
+ "touchstart",
3828
+ "wheel",
3829
+ "keydown",
3830
+ "click"
3831
+ ]) document.removeEventListener(type, onIntent, true);
3832
+ ports.clear();
3833
+ memories.clear();
3834
+ root = void 0;
3835
+ }
3836
+ };
3837
+ }
3838
+ //#endregion
3637
3839
  //#region src/native-mobile.ts
3638
3840
  /**
3639
3841
  * Viewport below which the injected chrome becomes an overlay drawer: the
@@ -3680,10 +3882,30 @@ window.__ModuleLoader__.load({
3680
3882
  .dsh-mobile-settings_fontFamily > span { flex:1 1 100px; }
3681
3883
  .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; }
3682
3884
  .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; }
3885
+ /* Push category rows: compact label-left / native-Switch-right rows. The control is
3886
+ the actual public @deepseek-ai/dsh-client-ui-primitives Switch provided by the DSH
3887
+ web shell: its track/thumb paint, dimensions, focus ring and motion are owned by
3888
+ the shell stylesheet and are never re-created, resized or overridden here. Each
3889
+ row is a label associated with its switch button, so the full 48px row remains
3890
+ one tap target while the button keeps native keyboard semantics. Category names
3891
+ match the General settings row title typography (14px/20px, regular weight). */
3892
+ .dsh-mobile-push-categories { display:flex; flex-direction:column; width:100%; margin:8px 0 4px; border-top:0.5px solid var(--dsw-alias-border-l2,#e8ebef); }
3893
+ .dsh-mobile-push-category { display:flex; align-items:center; justify-content:space-between; gap:12px; box-sizing:border-box; width:100%; min-height:48px; padding:6px 0; border-bottom:0.5px solid var(--dsw-alias-border-l2,#e8ebef); cursor:pointer; }
3894
+ .dsh-mobile-push-categoryName { flex:1 1 auto; min-width:0; color:var(--dsw-alias-label-primary,#171a21); font-size:14px; line-height:20px; font-weight:400; overflow-wrap:anywhere; }
3683
3895
  html.dsh-native-mobile-active [data-dsh-mobile-settings-header] { box-sizing:border-box !important; height:56px !important; min-height:56px !important; padding:4px 12px !important; }
3684
3896
  html.dsh-native-mobile-active [data-dsh-mobile-settings-header] [class*="_close"] { box-sizing:border-box !important; flex:none !important; width:48px !important; min-width:48px !important; height:48px !important; min-height:48px !important; }
3685
3897
  /* The mobile row replaces the Host-backed font control, not the theme selector. */
3686
3898
  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; }
3899
+ /* The injected attachment actions exist on every viewport, not only under the
3900
+ narrow overlay: a tablet shows the same Add menu, and with no native item to
3901
+ mirror they fell back to an unstyled UA button box (bordered, centred). Keep
3902
+ this fallback outside the overlay query so the rows read like native ones. */
3903
+ .dsh-mobile-media-action { box-sizing:border-box; display:flex; align-items:center; justify-content:flex-start; gap:8px; width:100%; min-width:0; min-height:40px; padding:8px 10px; border:0; border-radius:10px; background:transparent; color:var(--dsw-alias-label-primary,inherit); cursor:pointer; font:inherit; font-size:14px; line-height:22px; text-align:left; touch-action:manipulation; }
3904
+ .dsh-mobile-media-action:active { opacity:.72; }
3905
+ .dsh-mobile-media-action:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary,#4c82f7); outline-offset:1px; }
3906
+ .dsh-mobile-media-action:disabled { cursor:default; opacity:.38; }
3907
+ .dsh-mobile-media-action svg { flex:none; width:16px; height:16px; color:var(--dsw-alias-label-tertiary,currentColor); }
3908
+ .dsh-mobile-media-action span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
3687
3909
  @media ${NATIVE_MOBILE_OVERLAY_QUERY} {
3688
3910
  html.dsh-native-mobile-active,html.dsh-native-mobile-active body { width:100%; height:100%; overflow:hidden; }
3689
3911
  html.dsh-native-mobile-active { --dsh-mobile-motion-duration:200ms; --dsh-mobile-motion-ease:cubic-bezier(.22,1,.36,1); }
@@ -3756,7 +3978,7 @@ window.__ModuleLoader__.load({
3756
3978
  [data-dsh-mobile-details] { position:fixed !important; z-index:250 !important; inset:0 0 0 auto !important; width:min(94vw,460px) !important; max-width:none !important; transform:translateX(100%); transition:transform var(--dsh-mobile-motion-duration) var(--dsh-mobile-motion-ease); background:var(--dsw-alias-bg-layer-1, #fff); box-shadow:-18px 0 46px rgb(15 23 42 / 18%); }
3757
3979
  [data-dsh-mobile-details][data-open="true"] { transform:translateX(0); }
3758
3980
  [data-dsh-mobile-handle] { display:none !important; }
3759
- [data-dsh-mobile-settings] { flex-direction:column !important; width:100vw !important; height:100dvh !important; max-width:none !important; border-radius:0 !important; animation:dsh-mobile-panel-in var(--dsh-mobile-motion-duration) var(--dsh-mobile-motion-ease); }
3981
+ [data-dsh-mobile-settings="true"] { flex-direction:column !important; width:100vw !important; height:100dvh !important; max-width:none !important; border-radius:0 !important; animation:dsh-mobile-panel-in var(--dsh-mobile-motion-duration) var(--dsh-mobile-motion-ease); }
3760
3982
  [data-dsh-mobile-settings-nav] { flex:none !important; width:100% !important; padding:max(14px,env(safe-area-inset-top)) 12px 8px !important; gap:10px !important; border-bottom:1px solid var(--dsw-alias-border-l2,#e8ebef); }
3761
3983
  [data-dsh-mobile-settings-nav] [class*="_navTitle"] { padding:0 8px !important; font-size:18px !important; line-height:28px !important; }
3762
3984
  [data-dsh-mobile-settings-list] { flex-direction:row !important; gap:4px !important; overflow-x:auto !important; scrollbar-width:none; }
@@ -3869,14 +4091,6 @@ window.__ModuleLoader__.load({
3869
4091
  [data-dsh-mobile-center] [role="dialog"][aria-label*="Context"] :is(dt,dd) { white-space:nowrap !important; word-break:keep-all !important; }
3870
4092
  .dsh-mobile-branch-toast,.dsh-mobile-media-toast { display:block; position:fixed; z-index:330; top:max(12px,env(safe-area-inset-top)); left:50%; max-width:calc(100vw - 32px); box-sizing:border-box; padding:7px 14px; border:1px solid rgb(15 23 42 / 10%); border-radius:999px; background:rgb(15 23 42 / 92%); color:#fff; font-size:13px; line-height:20px; text-align:center; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; opacity:0; pointer-events:none; transform:translate(-50%,-8px); transition:opacity 160ms ease,transform 160ms ease; }
3871
4093
  .dsh-mobile-branch-toast[data-visible="true"],.dsh-mobile-media-toast[data-visible="true"] { opacity:1; transform:translate(-50%,0); }
3872
- /* Fallback only: when the native Add group has no item to clone, keep the
3873
- camera action on the same row metrics and tokens as the native menu. */
3874
- .dsh-mobile-media-action { box-sizing:border-box; display:flex; align-items:center; justify-content:flex-start; gap:8px; width:100%; min-width:0; min-height:40px; padding:8px 10px; border:0; border-radius:10px; background:transparent; color:var(--dsw-alias-label-primary,inherit); cursor:pointer; font:inherit; font-size:14px; line-height:22px; text-align:left; touch-action:manipulation; }
3875
- .dsh-mobile-media-action:active { opacity:.72; }
3876
- .dsh-mobile-media-action:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary,#4c82f7); outline-offset:1px; }
3877
- .dsh-mobile-media-action:disabled { cursor:default; opacity:.38; }
3878
- .dsh-mobile-media-action svg { flex:none; width:16px; height:16px; color:var(--dsw-alias-label-tertiary,currentColor); }
3879
- .dsh-mobile-media-action span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
3880
4094
  [data-dsh-mobile-center] [class*="_composer"] { padding-left:8px !important; padding-right:8px !important; padding-bottom:max(8px,env(safe-area-inset-bottom)) !important; }
3881
4095
  /* Collapse the scroll viewport only; the editor, draft and attachments stay
3882
4096
  owned by DSH. Toolbar focus keeps the card expanded during menu taps. */
@@ -3961,6 +4175,19 @@ window.__ModuleLoader__.load({
3961
4175
  return classToken(candidate, "_frame") && firstByClassSuffix(candidate, "_sidebarCol") !== void 0 && firstByClassSuffix(candidate, "_centerCol") !== void 0;
3962
4176
  });
3963
4177
  }
4178
+ /**
4179
+ * Mirror the classes of the stock Add-menu item on an injected attachment action.
4180
+ *
4181
+ * An empty class name is not nullish: trusting it would strip the plugin's own
4182
+ * fallback class and leave a bordered, centred UA button on that Host version.
4183
+ * A stock item that does carry classes still wins, so the injected action keeps
4184
+ * following the native menu's spacing, colours and dark-mode states.
4185
+ */
4186
+ function mediaActionClassName(nativeItem) {
4187
+ const nativeClass = nativeItem?.className;
4188
+ const trimmed = typeof nativeClass === "string" ? nativeClass.trim() : "";
4189
+ return trimmed === "" ? "dsh-mobile-media-action" : trimmed;
4190
+ }
3964
4191
  /** Mark every mounted settings dialog so its mobile layout does not depend on the conversation shell. */
3965
4192
  function markNativeMobileSettings(root) {
3966
4193
  let marked = 0;
@@ -3968,7 +4195,17 @@ window.__ModuleLoader__.load({
3968
4195
  const children = Array.from(dialog.children);
3969
4196
  const nav = children.find((child) => classToken(child, "_nav"));
3970
4197
  const content = children.find((child) => classToken(child, "_content"));
3971
- if (nav === void 0 || content === void 0) continue;
4198
+ if (nav === void 0) continue;
4199
+ if (content === void 0) {
4200
+ const header = children.find((child) => classToken(child, "_header"));
4201
+ const options = children.find((child) => classToken(child, "_options"));
4202
+ const title = children.find((child) => classToken(child, "_navTitle"));
4203
+ if (dialog.getAttribute("data-shortcut-modal") !== "settings" || header === void 0 || options === void 0 || title === void 0) continue;
4204
+ dialog.dataset.dshMobileSettings = "grid";
4205
+ header.setAttribute("data-dsh-mobile-settings-header", "true");
4206
+ marked += 1;
4207
+ continue;
4208
+ }
3972
4209
  dialog.dataset.dshMobileSettings = "true";
3973
4210
  nav.dataset.dshMobileSettingsNav = "true";
3974
4211
  content.dataset.dshMobileSettingsContent = "true";
@@ -4886,7 +5123,7 @@ window.__ModuleLoader__.load({
4886
5123
  }
4887
5124
  })();
4888
5125
  for (const button of [cameraButton, galleryButton]) {
4889
- const className = nativeItem?.className ?? "dsh-mobile-media-action";
5126
+ const className = mediaActionClassName(nativeItem);
4890
5127
  if (button.className !== className) button.className = className;
4891
5128
  const icon = button.firstElementChild;
4892
5129
  const caption = button.lastElementChild;
@@ -5092,6 +5329,7 @@ window.__ModuleLoader__.load({
5092
5329
  document.addEventListener("focusin", onStripFocus, true);
5093
5330
  document.addEventListener("click", onStripClickCapture, true);
5094
5331
  document.addEventListener("click", onBranchClick, true);
5332
+ const sidebarScrollMemory = installMobileSidebarScrollMemory();
5095
5333
  let frame;
5096
5334
  let sidebar;
5097
5335
  let sidebarRoot;
@@ -5324,7 +5562,10 @@ window.__ModuleLoader__.load({
5324
5562
  const lastColumn = frame?.style.gridTemplateColumns.trim().split(/\s+/).at(-1);
5325
5563
  details.dataset.open = String(lastColumn !== void 0 && lastColumn !== "0px" && lastColumn !== "0");
5326
5564
  }
5327
- if (sidebar === void 0) return;
5565
+ if (sidebar === void 0) {
5566
+ sidebarScrollMemory.sync();
5567
+ return;
5568
+ }
5328
5569
  sidebar.dataset.dshMobileSidebar = "true";
5329
5570
  toggle = firstByClassSuffix(sidebar, "_toggle");
5330
5571
  let candidate = toggle?.parentElement;
@@ -5336,6 +5577,7 @@ window.__ModuleLoader__.load({
5336
5577
  if (toggle !== void 0) toggle.dataset.dshMobileToggle = "true";
5337
5578
  if (frame !== void 0) sidebar.dataset.open = String(!classToken(sidebarRoot, "_collapsed"));
5338
5579
  backdrop.hidden = frame === void 0 || !drawerScrimVisible(classToken(sidebarRoot, "_collapsed"), overlayQuery.matches);
5580
+ sidebarScrollMemory.sync();
5339
5581
  };
5340
5582
  const schedule = () => {
5341
5583
  if (scheduled === 0) scheduled = requestAnimationFrame(sync);
@@ -5400,6 +5642,7 @@ window.__ModuleLoader__.load({
5400
5642
  return () => {
5401
5643
  disposed = true;
5402
5644
  stopStockBack();
5645
+ sidebarScrollMemory.dispose();
5403
5646
  disposeTaskWatcher();
5404
5647
  mediaRequestGeneration += 1;
5405
5648
  mediaPickerAbortController.abort();
@@ -8506,6 +8749,779 @@ window.__ModuleLoader__.load({
8506
8749
  };
8507
8750
  }
8508
8751
  //#endregion
8752
+ //#region src/mobile-push/browser-api.ts
8753
+ function apiError(code) {
8754
+ return Object.assign(/* @__PURE__ */ new Error(code === "stale_generation" ? "Push settings changed elsewhere." : "Push request failed."), { code });
8755
+ }
8756
+ async function check(response) {
8757
+ if (response.ok) return;
8758
+ let code = "request_failed";
8759
+ if (response.status === 409) try {
8760
+ if ((await response.json())?.error === "stale_generation") code = "stale_generation";
8761
+ } catch {}
8762
+ throw apiError(code);
8763
+ }
8764
+ async function json(response) {
8765
+ try {
8766
+ return await response.json();
8767
+ } catch {
8768
+ throw apiError("request_failed");
8769
+ }
8770
+ }
8771
+ async function requestOnce(request, path, init) {
8772
+ try {
8773
+ return await request(path, init);
8774
+ } catch {
8775
+ throw apiError("request_failed");
8776
+ }
8777
+ }
8778
+ function postJson(request, path, value) {
8779
+ return requestOnce(request, path, {
8780
+ method: "POST",
8781
+ headers: { "content-type": "application/json" },
8782
+ body: JSON.stringify(value)
8783
+ });
8784
+ }
8785
+ /** Build the direct authenticated browser API over mobileRequest (single-shot, same-origin, CSRF-aware). */
8786
+ function createMobilePushBrowserApi(request) {
8787
+ return {
8788
+ async getConfig(installationId) {
8789
+ const response = await requestOnce(request, `/mobile-access/push/config?installationId=${encodeURIComponent(installationId)}`, { method: "GET" });
8790
+ await check(response);
8791
+ return json(response);
8792
+ },
8793
+ async enroll(value) {
8794
+ const response = await postJson(request, "/mobile-access/push/subscription", value);
8795
+ await check(response);
8796
+ return json(response);
8797
+ },
8798
+ async updatePreferences(value) {
8799
+ await check(await postJson(request, "/mobile-access/push/preferences", value));
8800
+ },
8801
+ async disable(value) {
8802
+ await check(await postJson(request, "/mobile-access/push/disable", value));
8803
+ },
8804
+ async sendTest(value) {
8805
+ const response = await postJson(request, "/mobile-access/push/test", { installationId: value.installationId });
8806
+ if (response.status === 429) throw apiError("rate_limited");
8807
+ await check(response);
8808
+ },
8809
+ async resolve(reference) {
8810
+ const response = await postJson(request, "/mobile-access/push/resolve", { reference });
8811
+ await check(response);
8812
+ const value = await json(response);
8813
+ if (typeof value !== "object" || value === null || !("sessionId" in value) || typeof value.sessionId !== "string" || value.sessionId === "" || !("interaction" in value) || value.interaction !== "current" && value.interaction !== "resolved") return void 0;
8814
+ return value;
8815
+ }
8816
+ };
8817
+ }
8818
+ //#endregion
8819
+ //#region src/mobile-push/installation-id.ts
8820
+ /** Browser-safe installation-id validation shared by the browser controller and the Gateway. */
8821
+ const INSTALLATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
8822
+ /** True only for a UUID-shaped installation id with an allowed version and variant nibble. */
8823
+ function isPushInstallationId(value) {
8824
+ return typeof value === "string" && INSTALLATION_ID.test(value);
8825
+ }
8826
+ //#endregion
8827
+ //#region src/mobile-push/client.ts
8828
+ const ID_KEY = "dsh-mobile-push-installation";
8829
+ const defaults = Object.freeze({
8830
+ question: true,
8831
+ plan: true,
8832
+ approval: true,
8833
+ response: true,
8834
+ goal: true,
8835
+ failure: true
8836
+ });
8837
+ function decodeKey(encoded) {
8838
+ const normalized = encoded.replace(/-/gu, "+").replace(/_/gu, "/");
8839
+ const raw = atob(normalized + "=".repeat((4 - normalized.length % 4) % 4));
8840
+ return Uint8Array.from(raw, (char) => char.charCodeAt(0));
8841
+ }
8842
+ function safeError(error) {
8843
+ return (error && typeof error === "object" && "code" in error ? String(error.code) : "") === "stale_generation" ? "push.error.stale" : "push.error.generic";
8844
+ }
8845
+ function sameKey(subscription, key) {
8846
+ const raw = subscription.options.applicationServerKey;
8847
+ if (!raw) return false;
8848
+ const bytes = new Uint8Array(raw instanceof ArrayBuffer ? raw : raw.buffer, raw instanceof ArrayBuffer ? 0 : raw.byteOffset, raw instanceof ArrayBuffer ? raw.byteLength : raw.byteLength);
8849
+ return bytes.length === key.length && bytes.every((value, index) => value === key[index]);
8850
+ }
8851
+ /** Construct with a caller-owned authenticated API adapter and an explicit public window environment. */
8852
+ function createMobilePushController(api, environment) {
8853
+ let snapshot = Object.freeze({ state: "unsupported" });
8854
+ const listeners = /* @__PURE__ */ new Set();
8855
+ let active = false;
8856
+ let generation = 0;
8857
+ let config;
8858
+ let preparedKey;
8859
+ let existing = null;
8860
+ let preparedInstallId = "";
8861
+ let memoryInstallId = "";
8862
+ let mutationBusy = false;
8863
+ let testInFlight = false;
8864
+ let testOwner = 0;
8865
+ let attempt = 0;
8866
+ let ownedSubscription;
8867
+ let currentHostSubscription;
8868
+ const consentedSubscriptions = /* @__PURE__ */ new WeakSet();
8869
+ const pendingEnrollments = /* @__PURE__ */ new WeakMap();
8870
+ const trackPendingEnrollment = (subscription, operation) => {
8871
+ let pending = pendingEnrollments.get(subscription);
8872
+ if (!pending) {
8873
+ pending = /* @__PURE__ */ new Set();
8874
+ pendingEnrollments.set(subscription, pending);
8875
+ }
8876
+ pending.add(operation);
8877
+ };
8878
+ const finishPendingEnrollment = (subscription, operation) => {
8879
+ const pending = pendingEnrollments.get(subscription);
8880
+ pending?.delete(operation);
8881
+ if (pending?.size === 0) pendingEnrollments.delete(subscription);
8882
+ };
8883
+ const mayUnsubscribeCreatedSubscription = (subscription) => !consentedSubscriptions.has(subscription) && (pendingEnrollments.get(subscription)?.size ?? 0) === 0;
8884
+ const publish = (value) => {
8885
+ snapshot = Object.freeze({
8886
+ ...value,
8887
+ testInFlight
8888
+ });
8889
+ for (const listener of listeners) listener();
8890
+ };
8891
+ const current = (token) => active && generation === token;
8892
+ const getInstallationId = () => {
8893
+ if (memoryInstallId) return memoryInstallId;
8894
+ try {
8895
+ const saved = environment.storage?.getItem(ID_KEY);
8896
+ if (isPushInstallationId(saved)) return memoryInstallId = saved;
8897
+ } catch {}
8898
+ const fresh = environment.randomUUID?.() ?? globalThis.crypto?.randomUUID?.();
8899
+ if (!isPushInstallationId(fresh)) throw new Error("uuid unavailable");
8900
+ memoryInstallId = fresh;
8901
+ try {
8902
+ environment.storage?.setItem(ID_KEY, fresh);
8903
+ } catch {}
8904
+ return fresh;
8905
+ };
8906
+ const detect = () => {
8907
+ if (!environment.secureContext || !environment.isTopLevel || !environment.pushManager || environment.permission === "unsupported") return "unsupported";
8908
+ if (!environment.standalone) return "install";
8909
+ if (environment.permission === "denied") return "denied";
8910
+ };
8911
+ const refresh = async (token) => {
8912
+ const blocked = detect();
8913
+ if (blocked) {
8914
+ if (current(token)) publish({ state: blocked });
8915
+ return false;
8916
+ }
8917
+ try {
8918
+ const requestedInstallId = getInstallationId();
8919
+ const [nextConfig, subscription] = await Promise.all([api.getConfig(requestedInstallId), environment.pushManager.getSubscription()]);
8920
+ if (!current(token)) return false;
8921
+ config = nextConfig;
8922
+ existing = subscription;
8923
+ preparedInstallId = nextConfig.installationId || requestedInstallId;
8924
+ memoryInstallId = preparedInstallId;
8925
+ try {
8926
+ environment.storage?.setItem(ID_KEY, preparedInstallId);
8927
+ } catch {}
8928
+ preparedKey = nextConfig.publicKey ? decodeKey(nextConfig.publicKey) : void 0;
8929
+ if (!nextConfig.available || !preparedKey) publish({
8930
+ state: "unsupported",
8931
+ preferences: nextConfig.preferences
8932
+ });
8933
+ else if (nextConfig.status === "enabled") {
8934
+ if (subscription) {
8935
+ currentHostSubscription = subscription;
8936
+ consentedSubscriptions.add(subscription);
8937
+ }
8938
+ publish({
8939
+ state: nextConfig.status,
8940
+ preferences: nextConfig.preferences
8941
+ });
8942
+ } else if (nextConfig.status === "suspended") {
8943
+ if (subscription) {
8944
+ currentHostSubscription = subscription;
8945
+ consentedSubscriptions.add(subscription);
8946
+ }
8947
+ publish({
8948
+ state: "suspended",
8949
+ preferences: nextConfig.preferences
8950
+ });
8951
+ } else {
8952
+ if (nextConfig.status === "disabled" && currentHostSubscription) {
8953
+ consentedSubscriptions.delete(currentHostSubscription);
8954
+ currentHostSubscription = void 0;
8955
+ }
8956
+ publish({
8957
+ state: "ready",
8958
+ preferences: nextConfig.preferences
8959
+ });
8960
+ }
8961
+ return true;
8962
+ } catch (error) {
8963
+ if (current(token)) publish({
8964
+ state: "failed",
8965
+ error: safeError(error)
8966
+ });
8967
+ return false;
8968
+ }
8969
+ };
8970
+ const refreshNow = () => {
8971
+ if (active) refresh(generation);
8972
+ };
8973
+ return {
8974
+ start() {
8975
+ active = true;
8976
+ generation += 1;
8977
+ config = void 0;
8978
+ ownedSubscription = void 0;
8979
+ publish({ state: "pending" });
8980
+ refreshNow();
8981
+ },
8982
+ stop() {
8983
+ active = false;
8984
+ generation += 1;
8985
+ attempt += 1;
8986
+ mutationBusy = false;
8987
+ testOwner += 1;
8988
+ if (testInFlight) {
8989
+ testInFlight = false;
8990
+ publish({ ...snapshot });
8991
+ }
8992
+ config = void 0;
8993
+ preparedKey = void 0;
8994
+ existing = null;
8995
+ ownedSubscription = void 0;
8996
+ },
8997
+ refresh: refreshNow,
8998
+ async enableFromGesture() {
8999
+ if (!active || mutationBusy || snapshot.state !== "ready" && snapshot.state !== "failed" && snapshot.state !== "disabled" || !config || !preparedKey || !environment.pushManager) return;
9000
+ const token = generation, operation = ++attempt, base = config;
9001
+ mutationBusy = true;
9002
+ publish({
9003
+ state: "pending",
9004
+ preferences: base.preferences
9005
+ });
9006
+ let subscription = existing;
9007
+ let created = false;
9008
+ let pendingSubscription;
9009
+ try {
9010
+ if (subscription && !sameKey(subscription, preparedKey)) {
9011
+ publish({
9012
+ state: "failed",
9013
+ error: "push.error.subscriptionConflict",
9014
+ preferences: base.preferences
9015
+ });
9016
+ return;
9017
+ }
9018
+ if (!subscription) {
9019
+ subscription = await environment.pushManager.subscribe({
9020
+ userVisibleOnly: true,
9021
+ applicationServerKey: preparedKey
9022
+ });
9023
+ created = true;
9024
+ }
9025
+ if (created) ownedSubscription = subscription;
9026
+ if (!current(token) || operation !== attempt) {
9027
+ if (created && mayUnsubscribeCreatedSubscription(subscription)) {
9028
+ if (await subscription.unsubscribe().catch(() => false) && ownedSubscription === subscription) ownedSubscription = void 0;
9029
+ }
9030
+ return;
9031
+ }
9032
+ trackPendingEnrollment(subscription, operation);
9033
+ pendingSubscription = subscription;
9034
+ const browserSubscription = subscription.toJSON();
9035
+ const result = await api.enroll({
9036
+ installationId: preparedInstallId || base.installationId,
9037
+ subscription: {
9038
+ endpoint: browserSubscription.endpoint,
9039
+ keys: {
9040
+ p256dh: browserSubscription.keys.p256dh,
9041
+ auth: browserSubscription.keys.auth
9042
+ }
9043
+ },
9044
+ locale: environment.locale ?? "en",
9045
+ preferences: base.preferences ?? defaults,
9046
+ keyFingerprint: base.keyFingerprint ?? "",
9047
+ ownerGeneration: base.ownerGeneration,
9048
+ registrationGeneration: base.registrationGeneration
9049
+ });
9050
+ finishPendingEnrollment(subscription, operation);
9051
+ pendingSubscription = void 0;
9052
+ if (!current(token) || operation !== attempt) {
9053
+ if (result.mutationGeneration !== void 0) await api.disable({
9054
+ installationId: result.registration.installationId,
9055
+ ifGeneration: result.mutationGeneration
9056
+ }).catch(() => void 0);
9057
+ if (created && mayUnsubscribeCreatedSubscription(subscription)) {
9058
+ if (await subscription.unsubscribe().catch(() => false) && ownedSubscription === subscription) ownedSubscription = void 0;
9059
+ }
9060
+ return;
9061
+ }
9062
+ if (created) ownedSubscription = subscription;
9063
+ consentedSubscriptions.add(subscription);
9064
+ currentHostSubscription = subscription;
9065
+ if (result.registration.origin === environment.origin && result.registration.installationId) {
9066
+ memoryInstallId = result.registration.installationId;
9067
+ preparedInstallId = memoryInstallId;
9068
+ try {
9069
+ environment.storage?.setItem(ID_KEY, memoryInstallId);
9070
+ } catch {}
9071
+ }
9072
+ config = {
9073
+ ...base,
9074
+ installationId: preparedInstallId || result.registration.installationId,
9075
+ registrationGeneration: result.registration.generation,
9076
+ status: result.registration.health === "suspended" ? "suspended" : "enabled",
9077
+ preferences: result.registration.preferences
9078
+ };
9079
+ publish({
9080
+ state: result.registration.health === "suspended" ? "suspended" : "enabled",
9081
+ preferences: result.registration.preferences
9082
+ });
9083
+ } catch (error) {
9084
+ if (pendingSubscription) finishPendingEnrollment(pendingSubscription, operation);
9085
+ if (created && subscription && mayUnsubscribeCreatedSubscription(subscription)) {
9086
+ if (await subscription.unsubscribe().catch(() => false) && ownedSubscription === subscription) ownedSubscription = void 0;
9087
+ }
9088
+ if (current(token) && operation === attempt) {
9089
+ const isStale = safeError(error) === "push.error.stale";
9090
+ publish({
9091
+ state: "failed",
9092
+ error: safeError(error),
9093
+ preferences: base.preferences
9094
+ });
9095
+ if (isStale) refresh(token);
9096
+ }
9097
+ } finally {
9098
+ if (current(token) && operation === attempt) mutationBusy = false;
9099
+ }
9100
+ },
9101
+ async disable() {
9102
+ if (!active || !config) return;
9103
+ const token = generation, operation = ++attempt, base = config;
9104
+ mutationBusy = true;
9105
+ publish({
9106
+ state: "pending",
9107
+ preferences: base.preferences
9108
+ });
9109
+ try {
9110
+ await api.disable({ installationId: preparedInstallId || base.installationId });
9111
+ if (!current(token) || operation !== attempt) return;
9112
+ config = {
9113
+ ...base,
9114
+ status: "disabled"
9115
+ };
9116
+ publish({
9117
+ state: "disabled",
9118
+ preferences: base.preferences
9119
+ });
9120
+ if (currentHostSubscription) consentedSubscriptions.delete(currentHostSubscription);
9121
+ currentHostSubscription = void 0;
9122
+ const sub = ownedSubscription;
9123
+ if (sub) {
9124
+ if (await sub.unsubscribe().catch(() => false) && ownedSubscription === sub) ownedSubscription = void 0;
9125
+ }
9126
+ if (await refresh(token) && current(token) && operation === attempt && config?.status === "disabled") publish({
9127
+ state: "disabled",
9128
+ preferences: config.preferences
9129
+ });
9130
+ } catch (error) {
9131
+ if (current(token) && operation === attempt) {
9132
+ publish({
9133
+ state: "failed",
9134
+ error: safeError(error),
9135
+ preferences: base.preferences
9136
+ });
9137
+ if (safeError(error) === "push.error.stale") refresh(token);
9138
+ }
9139
+ } finally {
9140
+ if (current(token) && operation === attempt) mutationBusy = false;
9141
+ }
9142
+ },
9143
+ async updatePreferences(value) {
9144
+ if (!active || !config || mutationBusy) return;
9145
+ const token = generation, operation = ++attempt, base = config;
9146
+ mutationBusy = true;
9147
+ publish({
9148
+ state: "pending",
9149
+ preferences: value
9150
+ });
9151
+ try {
9152
+ await api.updatePreferences({
9153
+ installationId: preparedInstallId || base.installationId,
9154
+ ownerGeneration: base.ownerGeneration,
9155
+ registrationGeneration: base.registrationGeneration,
9156
+ preferences: value
9157
+ });
9158
+ if (current(token) && operation === attempt) {
9159
+ config = {
9160
+ ...base,
9161
+ preferences: value
9162
+ };
9163
+ publish({
9164
+ state: base.status === "enabled" ? "enabled" : "ready",
9165
+ preferences: value
9166
+ });
9167
+ await refresh(token);
9168
+ }
9169
+ } catch (error) {
9170
+ if (current(token) && operation === attempt) {
9171
+ publish({
9172
+ state: "failed",
9173
+ error: safeError(error),
9174
+ preferences: base.preferences
9175
+ });
9176
+ if (safeError(error) === "push.error.stale") refresh(token);
9177
+ }
9178
+ } finally {
9179
+ if (current(token) && operation === attempt) mutationBusy = false;
9180
+ }
9181
+ },
9182
+ async sendTest() {
9183
+ if (!active || !config || config.status !== "enabled" || mutationBusy || testInFlight) return;
9184
+ const token = generation, operation = attempt, base = config;
9185
+ const owner = ++testOwner;
9186
+ testInFlight = true;
9187
+ publish({
9188
+ state: "enabled",
9189
+ preferences: base.preferences
9190
+ });
9191
+ try {
9192
+ await api.sendTest({
9193
+ installationId: preparedInstallId || base.installationId,
9194
+ ownerGeneration: base.ownerGeneration,
9195
+ registrationGeneration: base.registrationGeneration
9196
+ });
9197
+ if (current(token) && operation === attempt) publish({
9198
+ state: "enabled",
9199
+ error: "push.test.accepted",
9200
+ preferences: base.preferences
9201
+ });
9202
+ } catch (error) {
9203
+ if (!current(token) || operation !== attempt) return;
9204
+ const message = error?.code === "rate_limited" ? "push.test.rateLimited" : "push.test.failed";
9205
+ const refreshed = await refresh(token);
9206
+ if (!current(token) || operation !== attempt) return;
9207
+ if (refreshed && config?.status === "enabled") publish({
9208
+ state: "enabled",
9209
+ error: message,
9210
+ preferences: config.preferences
9211
+ });
9212
+ } finally {
9213
+ if (owner === testOwner) {
9214
+ testInFlight = false;
9215
+ publish({ ...snapshot });
9216
+ }
9217
+ }
9218
+ },
9219
+ getSnapshot: () => snapshot,
9220
+ subscribe(listener) {
9221
+ listeners.add(listener);
9222
+ return () => {
9223
+ listeners.delete(listener);
9224
+ };
9225
+ }
9226
+ };
9227
+ }
9228
+ /** Adapter factory uses only the page's public PushManager; it deliberately has no service-worker path. */
9229
+ function browserPushEnvironment(view = window) {
9230
+ const nav = view.navigator;
9231
+ const pushManager = view.pushManager;
9232
+ const storage = (() => {
9233
+ try {
9234
+ return view.localStorage;
9235
+ } catch {
9236
+ return;
9237
+ }
9238
+ })();
9239
+ const isInstalled = () => {
9240
+ try {
9241
+ return nav.standalone === true || view.matchMedia("(display-mode: standalone)").matches || view.matchMedia("(display-mode: fullscreen)").matches;
9242
+ } catch {
9243
+ return nav.standalone === true;
9244
+ }
9245
+ };
9246
+ return {
9247
+ secureContext: view.isSecureContext,
9248
+ isTopLevel: view.top === view.self,
9249
+ get standalone() {
9250
+ return isInstalled();
9251
+ },
9252
+ get permission() {
9253
+ return "Notification" in view ? view.Notification.permission : "unsupported";
9254
+ },
9255
+ pushManager,
9256
+ storage,
9257
+ origin: view.location.origin,
9258
+ randomUUID: () => view.crypto.randomUUID(),
9259
+ locale: view.document.documentElement.lang.toLowerCase().startsWith("zh") ? "zh-CN" : view.document.documentElement.lang.toLowerCase().startsWith("it") ? "it" : "en"
9260
+ };
9261
+ }
9262
+ //#endregion
9263
+ //#region src/mobile-push/switch-component.ts
9264
+ /**
9265
+ * Lazily require the public Switch from the DSH module loader at category render time.
9266
+ *
9267
+ * The plugin client bundle is a `window.__ModuleLoader__.load` factory whose `require` parameter
9268
+ * resolves seeded shell modules synchronously (`rc.2` and `rc.3` both seed the primitives namespace
9269
+ * unconditionally). Requiring lazily — inside the render that needs it, never at module scope —
9270
+ * keeps every Node test and provider-free fixture that mounts the settings row but not the push
9271
+ * toggles free of any browser-only module dependency. Outside a DSH shell there is no loader
9272
+ * `require`, so the categories are withdrawn instead of rendering a broken stand-in; native
9273
+ * rendering itself is verified by the strict packed smoke against the real mounted shell.
9274
+ */
9275
+ function requirePublicMobilePushSwitch() {
9276
+ if (typeof window === "undefined" || typeof require !== "function") return void 0;
9277
+ try {
9278
+ return require("@deepseek-ai/dsh-client-ui-primitives").Switch;
9279
+ } catch {
9280
+ return;
9281
+ }
9282
+ }
9283
+ //#endregion
9284
+ //#region src/mobile-push/settings.ts
9285
+ const MOBILE_PUSH_SETTINGS_COPY = {
9286
+ en: {
9287
+ title: "Web push notifications",
9288
+ desc: "Receive notifications when this page is closed.",
9289
+ enable: "Enable",
9290
+ disable: "Disable",
9291
+ test: "Send test",
9292
+ testBusy: "Sending…",
9293
+ accepted: "Accepted by the push provider; lock-screen delivery is not confirmed.",
9294
+ install: "Add this site to your Home Screen to enable push.",
9295
+ denied: "Notifications are blocked in browser settings.",
9296
+ unsupported: "Web push is unavailable in this context.",
9297
+ error: "Could not update push settings. Try again.",
9298
+ testFailed: "The test notification could not be sent. Your notification settings are unchanged.",
9299
+ testRateLimited: "Too many test notifications; wait a moment before trying again.",
9300
+ conflict: "This device already has a push subscription for an earlier server key, so enabling cannot complete. Retrying will not fix this; the server operator must check the push key state first.",
9301
+ busy: "Working…",
9302
+ categories: [
9303
+ "Questions",
9304
+ "Plans",
9305
+ "Approvals",
9306
+ "Responses",
9307
+ "Goals",
9308
+ "Failures"
9309
+ ]
9310
+ },
9311
+ zh: {
9312
+ title: "网页推送通知",
9313
+ desc: "关闭此页面后仍可接收通知。",
9314
+ enable: "启用",
9315
+ disable: "停用",
9316
+ test: "发送测试",
9317
+ testBusy: "发送中…",
9318
+ accepted: "推送服务商已接受;不代表锁屏一定送达。",
9319
+ install: "请将此网站添加到主屏幕以启用推送。",
9320
+ denied: "浏览器设置已阻止通知。",
9321
+ unsupported: "此环境不支持网页推送。",
9322
+ error: "无法更新推送设置,请重试。",
9323
+ testFailed: "测试通知未能发送。通知设置保持不变。",
9324
+ testRateLimited: "测试通知发送过于频繁,请稍后再试。",
9325
+ conflict: "此设备已存在与旧服务器密钥绑定的推送订阅,因此无法完成启用。重试无法解决;请先由服务器管理员检查推送密钥状态。",
9326
+ busy: "处理中…",
9327
+ categories: [
9328
+ "问题",
9329
+ "计划",
9330
+ "审批",
9331
+ "回复",
9332
+ "目标",
9333
+ "失败"
9334
+ ]
9335
+ },
9336
+ it: {
9337
+ title: "Notifiche Web push",
9338
+ desc: "Ricevi notifiche quando la pagina è chiusa.",
9339
+ enable: "Attiva",
9340
+ disable: "Disattiva",
9341
+ test: "Invia test",
9342
+ testBusy: "Invio…",
9343
+ accepted: "Accettata dal provider; la consegna sulla schermata di blocco non è confermata.",
9344
+ install: "Aggiungi il sito alla schermata Home per attivare il push.",
9345
+ denied: "Le notifiche sono bloccate nelle impostazioni del browser.",
9346
+ unsupported: "Web push non disponibile in questo contesto.",
9347
+ error: "Impossibile aggiornare le impostazioni push. Riprova.",
9348
+ testFailed: "Impossibile inviare la notifica di test. Le impostazioni delle notifiche restano invariate.",
9349
+ testRateLimited: "Troppe notifiche di test; attendi qualche istante prima di riprovare.",
9350
+ conflict: "Questo dispositivo ha già una sottoscrizione push associata a una chiave server precedente, quindi l’attivazione non può completarsi. Riprovare non risolve il problema; l’operatore del server deve prima controllare lo stato della chiave push.",
9351
+ busy: "Operazione…",
9352
+ categories: [
9353
+ "Domande",
9354
+ "Piani",
9355
+ "Approvazioni",
9356
+ "Risposte",
9357
+ "Obiettivi",
9358
+ "Errori"
9359
+ ]
9360
+ }
9361
+ };
9362
+ const KEYS = [
9363
+ "question",
9364
+ "plan",
9365
+ "approval",
9366
+ "response",
9367
+ "goal",
9368
+ "failure"
9369
+ ];
9370
+ function message(snapshot, copy) {
9371
+ if (snapshot.state === "install") return copy.install;
9372
+ if (snapshot.state === "denied") return copy.denied;
9373
+ if (snapshot.state === "unsupported") return copy.unsupported;
9374
+ if (snapshot.state === "enabled" && snapshot.error === "push.test.accepted") return copy.accepted;
9375
+ if (snapshot.state === "enabled" && snapshot.error === "push.test.rateLimited") return copy.testRateLimited;
9376
+ if (snapshot.state === "enabled" && snapshot.error === "push.test.failed") return copy.testFailed;
9377
+ if (snapshot.state === "failed" && snapshot.error === "push.error.subscriptionConflict") return copy.conflict;
9378
+ return snapshot.state === "failed" ? copy.error : copy.desc;
9379
+ }
9380
+ /** Browser-only settings row; mounting it never requests permission or creates a subscription. */
9381
+ function MobilePushSettings({ controller, locale, switchComponent }) {
9382
+ const snapshot = (0, react.useSyncExternalStore)(controller.subscribe, controller.getSnapshot);
9383
+ const copy = MOBILE_PUSH_SETTINGS_COPY[locale];
9384
+ if (snapshot.state === "unsupported") return null;
9385
+ const prefs = snapshot.preferences;
9386
+ const Switch = switchComponent ?? requirePublicMobilePushSwitch();
9387
+ const toggle = (key, checked) => {
9388
+ if (!prefs) return;
9389
+ controller.updatePreferences({
9390
+ ...prefs,
9391
+ [key]: checked
9392
+ });
9393
+ };
9394
+ return (0, react.createElement)("div", {
9395
+ className: "dsh-mobile-settings_row",
9396
+ style: { flexWrap: "wrap" },
9397
+ lang: locale === "zh" ? "zh-CN" : locale
9398
+ }, (0, react.createElement)("div", {
9399
+ className: "dsh-mobile-settings_rowText",
9400
+ style: { paddingRight: 8 }
9401
+ }, (0, react.createElement)("div", { className: "dsh-mobile-settings_title" }, copy.title), (0, react.createElement)("div", {
9402
+ className: "dsh-mobile-settings_desc",
9403
+ "aria-live": "polite"
9404
+ }, message(snapshot, copy)), prefs && Switch !== void 0 ? (0, react.createElement)("div", { className: "dsh-mobile-push-categories" }, ...KEYS.map((key, index) => {
9405
+ const name = copy.categories[index];
9406
+ if (name === void 0) return null;
9407
+ return (0, react.createElement)("label", {
9408
+ key,
9409
+ className: "dsh-mobile-push-category"
9410
+ }, (0, react.createElement)("span", { className: "dsh-mobile-push-categoryName" }, name), (0, react.createElement)(Switch, {
9411
+ checked: prefs[key],
9412
+ label: name,
9413
+ onChange: (next) => {
9414
+ toggle(key, next);
9415
+ }
9416
+ }));
9417
+ })) : null), (0, react.createElement)("div", {
9418
+ className: "dsh-mobile-push-actions",
9419
+ style: {
9420
+ display: "flex",
9421
+ flexWrap: "wrap",
9422
+ gap: 8
9423
+ }
9424
+ }, snapshot.state === "enabled" ? (0, react.createElement)("button", {
9425
+ type: "button",
9426
+ className: "dsh-mobile-settings_selector",
9427
+ disabled: snapshot.testInFlight === true,
9428
+ "aria-busy": snapshot.testInFlight === true ? "true" : void 0,
9429
+ onClick: () => void controller.sendTest()
9430
+ }, snapshot.testInFlight ? copy.testBusy : copy.test) : null, snapshot.state === "enabled" || snapshot.state === "suspended" ? (0, react.createElement)("button", {
9431
+ type: "button",
9432
+ className: "dsh-mobile-settings_selector",
9433
+ onClick: () => void controller.disable()
9434
+ }, copy.disable) : snapshot.state === "ready" || snapshot.state === "failed" || snapshot.state === "disabled" ? (0, react.createElement)("button", {
9435
+ type: "button",
9436
+ className: "dsh-mobile-settings_selector",
9437
+ onClick: () => void controller.enableFromGesture()
9438
+ }, copy.enable) : (0, react.createElement)("button", {
9439
+ type: "button",
9440
+ className: "dsh-mobile-settings_selector",
9441
+ disabled: true
9442
+ }, copy.busy)));
9443
+ }
9444
+ //#endregion
9445
+ //#region src/mobile-push/navigation.ts
9446
+ const REF_PARAMETER = "mobile-push";
9447
+ /**
9448
+ * Consume one authenticated tap reference after the public Workspace and Session
9449
+ * list baselines are ready. Return the disposal function for list subscriptions.
9450
+ */
9451
+ function installPushNavigation(ctx, options) {
9452
+ const context = ctx;
9453
+ const browser = options.browser ?? {
9454
+ location: window.location,
9455
+ history: window.history
9456
+ };
9457
+ const params = new URLSearchParams(browser.location.search);
9458
+ const reference = params.get(REF_PARAMETER);
9459
+ let disposed = false;
9460
+ let consumed = false;
9461
+ let resolving = false;
9462
+ let opened = false;
9463
+ const clearReference = () => {
9464
+ if (!params.has(REF_PARAMETER)) return;
9465
+ params.delete(REF_PARAMETER);
9466
+ const query = params.toString();
9467
+ const url = `${browser.location.pathname}${query ? `?${query}` : ""}${browser.location.hash}`;
9468
+ browser.history.replaceState(null, "", url);
9469
+ };
9470
+ const neutral = (key = "session-unavailable") => {
9471
+ if (disposed || consumed) return;
9472
+ consumed = true;
9473
+ clearReference();
9474
+ options.notify(key);
9475
+ };
9476
+ const check = () => {
9477
+ if (disposed || consumed || resolving) return;
9478
+ if (!reference) {
9479
+ neutral();
9480
+ return;
9481
+ }
9482
+ const sessions = context.sessions.list.getSnapshot();
9483
+ const workspaces = context.workspaces.list.getSnapshot();
9484
+ if (sessions.phase !== "pending" && sessions.phase !== "ready" || workspaces.phase !== "pending" && workspaces.phase !== "ready") {
9485
+ neutral();
9486
+ return;
9487
+ }
9488
+ if (sessions.phase !== "ready" || workspaces.phase !== "ready") return;
9489
+ if (!reference) {
9490
+ neutral();
9491
+ return;
9492
+ }
9493
+ resolving = true;
9494
+ consumed = true;
9495
+ clearReference();
9496
+ options.resolve(reference).then((target) => {
9497
+ if (disposed) return;
9498
+ if (!target || target.interaction !== "current" && target.interaction !== "resolved" || !sessionsAvailable(context, target.sessionId)) {
9499
+ options.notify("session-unavailable");
9500
+ return;
9501
+ }
9502
+ if (opened) return;
9503
+ opened = true;
9504
+ context.uiWorkspace.openSession(target.sessionId);
9505
+ if (target.interaction === "resolved") options.notify("session-resolved");
9506
+ }).catch(() => {
9507
+ if (!disposed) options.notify("session-unavailable");
9508
+ });
9509
+ };
9510
+ const unsubscribeSessions = context.sessions.list.subscribe(check);
9511
+ const unsubscribeWorkspaces = context.workspaces.list.subscribe(check);
9512
+ check();
9513
+ return () => {
9514
+ if (disposed) return;
9515
+ disposed = true;
9516
+ unsubscribeSessions();
9517
+ unsubscribeWorkspaces();
9518
+ };
9519
+ }
9520
+ function sessionsAvailable(ctx, sessionId) {
9521
+ const snapshot = ctx.sessions.list.getSnapshot();
9522
+ return snapshot.phase === "ready" && snapshot.ids.includes(sessionId) && snapshot.byId[sessionId] !== void 0;
9523
+ }
9524
+ //#endregion
8509
9525
  //#region src/mobile-directory-browser.ts
8510
9526
  /** Expected Gateway or response-validation failure for one read-only listing. */
8511
9527
  var MobileDirectoryReadError = class extends Error {
@@ -9579,6 +10595,12 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
9579
10595
  function parseMobileRemoteProvider(value) {
9580
10596
  return value === "cpolar" || value === "cloudflared" || value === "frp" || value === "origin" || value === "iroh" ? value : "tailscale";
9581
10597
  }
10598
+ /** Public client services the authenticated tap consumer follows (scoped child lifecycle, never a required plugin dependency). */
10599
+ const mobilePushNavigationServices = [
10600
+ "sessions",
10601
+ "workspaces",
10602
+ "uiWorkspace"
10603
+ ];
9582
10604
  /**
9583
10605
  * Set a surface's visibility; opening a page or overlay brings it to the front.
9584
10606
  * @param container - the registered surface element.
@@ -10428,6 +11450,31 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10428
11450
  const lanSetupStatus = element("p", "dsh-mobile-control__status");
10429
11451
  lanSetupStatus.setAttribute("role", "status");
10430
11452
  lanSetupStatus.setAttribute("aria-live", "polite");
11453
+ const lanChange = element("section", "dsh-mobile-control__lan-setup");
11454
+ lanChange.hidden = true;
11455
+ const lanChangeTitle = element("h3", "dsh-mobile-control__section-title");
11456
+ lanChangeTitle.textContent = t("lanChangeTitle");
11457
+ const lanChangeIntro = element("p", "dsh-mobile-control__intro");
11458
+ lanChangeIntro.textContent = t("lanChangeIntro");
11459
+ const lanChangeForm = element("div", "dsh-mobile-control__lan-setup-form");
11460
+ const lanChangeLabel = element("label", "dsh-mobile-control__field");
11461
+ lanChangeLabel.textContent = t("lanChangeSelect");
11462
+ const lanChangeSelect = element("select");
11463
+ lanChangeSelect.setAttribute("aria-label", t("lanChangeSelect"));
11464
+ lanChangeLabel.append(lanChangeSelect);
11465
+ const lanChangeActions = element("div", "dsh-mobile-control__lan-setup-actions");
11466
+ const lanChangeRefresh = element("button", "dsh-mobile-control__secondary");
11467
+ lanChangeRefresh.type = "button";
11468
+ lanChangeRefresh.textContent = t("lanChangeRefresh");
11469
+ const lanChangeConfirm = element("button", "dsh-mobile-control__primary");
11470
+ lanChangeConfirm.type = "button";
11471
+ lanChangeConfirm.textContent = t("lanChangeConfirm");
11472
+ lanChangeActions.append(lanChangeRefresh, lanChangeConfirm);
11473
+ const lanChangeStatus = element("p", "dsh-mobile-control__status");
11474
+ lanChangeStatus.setAttribute("role", "status");
11475
+ lanChangeStatus.setAttribute("aria-live", "polite");
11476
+ lanChangeForm.append(lanChangeLabel, lanChangeActions);
11477
+ lanChange.append(lanChangeTitle, lanChangeIntro, lanChangeForm, lanChangeStatus);
10431
11478
  const trustedNetworks = element("details", "dsh-mobile-control__details");
10432
11479
  const trustedNetworksSummary = element("summary");
10433
11480
  trustedNetworksSummary.textContent = t("trustedNetworksTitle");
@@ -11763,7 +12810,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11763
12810
  diagnosticsDetails.append(diagnosticsDetailsSummary, diagnosticsReport);
11764
12811
  header.append(title, headerActions);
11765
12812
  actions.append(toggle, pair, linkPair);
11766
- lanView.append(lanSetup, access, qrBox, lanPairingValidity, status, extensionStatus, actions, trustedNetworks);
12813
+ lanView.append(lanSetup, lanChange, access, qrBox, lanPairingValidity, status, extensionStatus, actions, trustedNetworks);
11767
12814
  const channelPane = element("div", "dsh-mobile-control__channel-pane");
11768
12815
  channelPane.append(lanView, remoteWorkspace);
11769
12816
  const remoteColumns = element("div", "dsh-mobile-control__remote-columns");
@@ -11778,6 +12825,9 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11778
12825
  let lanConfigured = true;
11779
12826
  let lanRestartRequired = false;
11780
12827
  let lanSetupLoaded = false;
12828
+ let lanChangeLoaded = false;
12829
+ let lanChangeBusy = false;
12830
+ let lanChangeRequest;
11781
12831
  let remoteRunning = false;
11782
12832
  let remoteReady = false;
11783
12833
  let remoteProvider = "tailscale";
@@ -11994,6 +13044,8 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11994
13044
  lanChoiceBadge.textContent = running ? t("lanBadgeOn") : t("lanBadgeOff");
11995
13045
  origin = running && typeof data.origin === "string" ? data.origin : "";
11996
13046
  lanSetup.hidden = lanConfigured && !lanRestartRequired;
13047
+ lanChange.hidden = !lanConfigured || lanRestartRequired || data.rebindSupported !== true;
13048
+ if (!lanChange.hidden && !lanChangeLoaded) loadLanChange();
11997
13049
  lanSetupForm.hidden = lanRestartRequired;
11998
13050
  lanSetupIntro.hidden = lanRestartRequired;
11999
13051
  status.hidden = !lanConfigured || lanRestartRequired;
@@ -12007,7 +13059,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12007
13059
  accessLink.textContent = origin;
12008
13060
  accessLink.title = origin;
12009
13061
  status.classList.toggle("is-running", running);
12010
- status.textContent = !lanConfigured ? t("lanSetupRequired") : lanRestartRequired ? t("lanSetupRestart", { origin: typeof data.pendingOrigin === "string" ? data.pendingOrigin : "" }) : running ? t("lanOn") : t("lanOff");
13062
+ status.textContent = data.recoveryRequired === true ? t("lanChangeRecoveryRequired") : !lanConfigured ? t("lanSetupRequired") : lanRestartRequired ? t("lanSetupRestart", { origin: typeof data.pendingOrigin === "string" ? data.pendingOrigin : "" }) : running ? t("lanOn") : t("lanOff");
12011
13063
  const extensionData = data.extensions;
12012
13064
  if (extensionData !== null && typeof extensionData === "object") {
12013
13065
  const loaded = typeof extensionData.loaded === "number" ? extensionData.loaded : 0;
@@ -12098,6 +13150,80 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12098
13150
  lanSetupConfigure.disabled = lanRestartRequired || lanSetupSelect.options.length === 0;
12099
13151
  });
12100
13152
  });
13153
+ const syncLanChange = () => {
13154
+ const waiting = lanChangeBusy || lanChangeRequest !== void 0;
13155
+ lanChangeSelect.disabled = waiting;
13156
+ lanChangeRefresh.disabled = waiting;
13157
+ lanChangeConfirm.disabled = waiting || lanChangeSelect.options.length === 0;
13158
+ };
13159
+ const renderLanChange = (data) => {
13160
+ const networks = Array.isArray(data.networks) ? data.networks.filter((entry) => typeof entry === "object" && entry !== null) : [];
13161
+ lanChangeSelect.replaceChildren();
13162
+ for (const network of networks) {
13163
+ const name = typeof network.name === "string" ? network.name : "";
13164
+ const address = typeof network.address === "string" ? network.address : "";
13165
+ if (name === "") continue;
13166
+ const option = element("option");
13167
+ option.value = name;
13168
+ option.textContent = `${name} · ${address}${network.current === true ? ` · ${t("lanChangeCurrent")}` : ""}`;
13169
+ if (network.current === true) option.selected = true;
13170
+ lanChangeSelect.append(option);
13171
+ }
13172
+ syncLanChange();
13173
+ };
13174
+ const loadLanChange = () => {
13175
+ if (lanChangeRequest !== void 0) return lanChangeRequest;
13176
+ lanChangeLoaded = true;
13177
+ lanChangeRequest = Promise.all([controlRequestJson("/api/mobile-access/lan/networks"), controlRequestJson("/api/mobile-access/lan/control")]).then(([data, control]) => {
13178
+ if (!lifecycle.signal.aborted) {
13179
+ renderLanChange(data);
13180
+ render(control);
13181
+ }
13182
+ }, () => {
13183
+ if (!lifecycle.signal.aborted) lanChangeStatus.textContent = t("lanChangeRefreshFailed");
13184
+ }).finally(() => {
13185
+ lanChangeRequest = void 0;
13186
+ if (!lifecycle.signal.aborted) syncLanChange();
13187
+ });
13188
+ syncLanChange();
13189
+ return lanChangeRequest;
13190
+ };
13191
+ lanChangeRefresh.addEventListener("click", () => {
13192
+ loadLanChange();
13193
+ });
13194
+ lanChangeSelect.addEventListener("change", syncLanChange);
13195
+ lanChangeConfirm.addEventListener("click", () => {
13196
+ if (lanChangeSelect.value === "" || lanChangeBusy || lanChangeRequest !== void 0) return;
13197
+ lanChangeBusy = true;
13198
+ syncLanChange();
13199
+ lanChangeConfirm.textContent = t("lanChangeBusy");
13200
+ lanChangeStatus.textContent = t("lanChangeBusy");
13201
+ controlRequestJson("/api/mobile-access/lan/rebind", {
13202
+ method: "POST",
13203
+ body: JSON.stringify({ networkInterface: lanChangeSelect.value })
13204
+ }, LONG_CONTROL_REQUEST_TIMEOUT_MS).then(async (data) => {
13205
+ if (lifecycle.signal.aborted) return;
13206
+ lanChangeStatus.textContent = data.disconnected === true && data.ready === true ? t("lanChangeOkDisconnected") : t("lanChangeOk");
13207
+ try {
13208
+ const control = await controlRequestJson("/api/mobile-access/lan/control");
13209
+ if (lifecycle.signal.aborted) return;
13210
+ render(control);
13211
+ await loadLanChange();
13212
+ if (!lifecycle.signal.aborted && data.ready === true) openPairing("key");
13213
+ } catch {
13214
+ if (!lifecycle.signal.aborted) lanChangeStatus.textContent = t("lanChangeRefreshFailed");
13215
+ }
13216
+ }, (error) => {
13217
+ if (lifecycle.signal.aborted) return;
13218
+ const text = String(error);
13219
+ lanChangeStatus.textContent = text.includes("lan_rebind_unchanged") ? t("lanChangeUnchanged") : text.includes("lan_rebind_network_unavailable") ? t("lanChangeUnavailable") : text.includes("lan_rebind_unsupported") ? t("lanChangeUnsupported") : text.includes("lan_rebind_recovery_required") ? t("lanChangeRecoveryRequired") : t("lanChangeFailed");
13220
+ }).finally(() => {
13221
+ lanChangeBusy = false;
13222
+ if (lifecycle.signal.aborted) return;
13223
+ lanChangeConfirm.textContent = t("lanChangeConfirm");
13224
+ syncLanChange();
13225
+ });
13226
+ });
12101
13227
  const clearQrTarget = (target) => {
12102
13228
  if (typeof target.replaceChildren === "function") {
12103
13229
  target.replaceChildren();
@@ -15738,6 +16864,107 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
15738
16864
  .dsh-mobile-control__remote-workspace .dsh-mobile-control__details{border:0;border-radius:0;background:transparent;box-shadow:none}
15739
16865
  .dsh-mobile-control__self-hosted{border:0;border-radius:0;background:transparent;box-shadow:none}
15740
16866
  ` + EXTENSION_RECOVERY_STYLES;
16867
+ /** Resolve safe, locale-owned copy for tap-navigation results. */
16868
+ function mobilePushNavigationNotice(locale, key) {
16869
+ return {
16870
+ en: {
16871
+ "session-unavailable": "This conversation is no longer available.",
16872
+ "session-resolved": "Opened the conversation."
16873
+ },
16874
+ it: {
16875
+ "session-unavailable": "Questa conversazione non è più disponibile.",
16876
+ "session-resolved": "Conversazione aperta."
16877
+ },
16878
+ zh: {
16879
+ "session-unavailable": "此会话已不可用。",
16880
+ "session-resolved": "已打开会话。"
16881
+ }
16882
+ }[locale][key];
16883
+ }
16884
+ function showMobilePushNavigationNotice(page, view, key) {
16885
+ const node = page.createElement("div");
16886
+ node.className = "dsh-mobile-push-navigation-notice";
16887
+ node.setAttribute("role", "status");
16888
+ node.setAttribute("aria-live", "polite");
16889
+ node.lang = selectedMobileControlLocale();
16890
+ node.textContent = mobilePushNavigationNotice(selectedMobileControlLocale(), key);
16891
+ node.style.cssText = "position:fixed;z-index:1201;left:50%;bottom:max(24px,env(safe-area-inset-bottom));transform:translateX(-50%);max-width:calc(100vw - 32px);padding:10px 16px;border-radius:12px;background:var(--dsw-alias-bg-layer-3,#20242b);color:var(--dsw-alias-label-primary,#fff);font:14px/1.45 system-ui,sans-serif;box-shadow:0 8px 24px #0003;pointer-events:none";
16892
+ page.body.append(node);
16893
+ view.setTimeout(() => node.remove(), 3e3);
16894
+ }
16895
+ /** Own the browser-only push controller, settings row, and optional authenticated tap navigation. */
16896
+ function installMobilePushBrowserClient(ctx, runtime) {
16897
+ const { page, view } = runtime;
16898
+ if (view.__DSH_MOBILE_FRONTEND__ !== "dedicated" || view.__DSH_MOBILE_NATIVE__ !== void 0 || page.querySelector("meta[name=\"dsh-mobile-gateway\"][content=\"authenticated\"]") === null) return () => void 0;
16899
+ const api = runtime.api ?? createMobilePushBrowserApi(mobileRequest);
16900
+ const controller = createMobilePushController(api, runtime.environment ?? browserPushEnvironment(view));
16901
+ controller.start();
16902
+ const removeSettings = ctx.slots.inject("settings.general.item", () => ctx.slots.register({
16903
+ name: "settings.general.item",
16904
+ id: "dsh-mobile-push-browser",
16905
+ order: 120
16906
+ }, () => (0, react.createElement)(MobilePushSettings, {
16907
+ controller,
16908
+ locale: selectedMobileControlLocale()
16909
+ })));
16910
+ const refresh = () => {
16911
+ controller.refresh();
16912
+ };
16913
+ const onVisible = () => {
16914
+ if (page.visibilityState === "visible") refresh();
16915
+ };
16916
+ view.addEventListener("focus", refresh);
16917
+ page.addEventListener("visibilitychange", onVisible);
16918
+ const connection = ctx.get("connection");
16919
+ const unbindConnection = connection?.state?.subscribe(() => {
16920
+ if (connection.state?.getSnapshot() === "connected") refresh();
16921
+ }) ?? (() => void 0);
16922
+ let removeNavigation = () => void 0;
16923
+ let disposeNavigationLifecycle = () => void 0;
16924
+ const reference = new URLSearchParams(view.location.search).get("mobile-push");
16925
+ if (reference !== null && reference !== "" && ctx.inject !== void 0) {
16926
+ let navigationClosed = false;
16927
+ const navigationFiber = ctx.inject(mobilePushNavigationServices, (navigationContext) => {
16928
+ if (navigationClosed) return () => void 0;
16929
+ const pending = new URLSearchParams(view.location.search).get("mobile-push");
16930
+ if (pending === null || pending === "") return () => void 0;
16931
+ const remove = installPushNavigation(navigationContext, {
16932
+ resolve: (value) => api.resolve(value),
16933
+ notify: (key) => {
16934
+ if (key !== "session-unavailable" && key !== "session-resolved") return;
16935
+ if (runtime.notify) runtime.notify(key);
16936
+ else showMobilePushNavigationNotice(page, view, key);
16937
+ },
16938
+ browser: {
16939
+ location: view.location,
16940
+ history: view.history
16941
+ }
16942
+ });
16943
+ removeNavigation = remove;
16944
+ return () => {
16945
+ if (removeNavigation === remove) removeNavigation = () => void 0;
16946
+ remove();
16947
+ };
16948
+ });
16949
+ disposeNavigationLifecycle = () => {
16950
+ if (navigationClosed) return;
16951
+ navigationClosed = true;
16952
+ removeNavigation();
16953
+ navigationFiber.dispose();
16954
+ };
16955
+ }
16956
+ let disposed = false;
16957
+ return () => {
16958
+ if (disposed) return;
16959
+ disposed = true;
16960
+ view.removeEventListener("focus", refresh);
16961
+ page.removeEventListener("visibilitychange", onVisible);
16962
+ unbindConnection();
16963
+ disposeNavigationLifecycle();
16964
+ removeSettings();
16965
+ controller.stop();
16966
+ };
16967
+ }
15741
16968
  /** Mount the desktop control or mobile feature enhancements. */
15742
16969
  function apply(ctx) {
15743
16970
  let gatewayRejectionSink;
@@ -15772,6 +16999,10 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
15772
16999
  return recovery();
15773
17000
  };
15774
17001
  }, "dsh-mobile: connection recovery");
17002
+ ctx.effect(() => installMobilePushBrowserClient(ctx, {
17003
+ page: document,
17004
+ view: window
17005
+ }), "dsh-mobile: authenticated browser push lifecycle");
15775
17006
  ctx.effect(() => {
15776
17007
  const desktopAdmin = isDesktopAdminSurface(location.hostname, location.search, window.__DSH_MOBILE_FRONTEND__, location.protocol);
15777
17008
  const gatewayCustomization = ownsMobileCustomization(document.querySelector("meta[name=\"dsh-mobile-gateway\"][content=\"authenticated\"]") !== null, window.__DSH_MOBILE_FRONTEND__);
@@ -15940,8 +17171,10 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
15940
17171
  exports.installCustomAssets = installCustomAssets;
15941
17172
  exports.installDshLanguageBoundSurface = installDshLanguageBoundSurface;
15942
17173
  exports.installMobileNativeBack = installMobileNativeBack;
17174
+ exports.installMobilePushBrowserClient = installMobilePushBrowserClient;
15943
17175
  exports.localizedDiagnosticEntryCopy = localizedDiagnosticEntryCopy;
15944
17176
  exports.mobileBackBlockedMessage = mobileBackBlockedMessage;
17177
+ exports.mobilePushNavigationNotice = mobilePushNavigationNotice;
15945
17178
  exports.normalizeDiagnosticOverall = normalizeDiagnosticOverall;
15946
17179
  exports.normalizeDiagnosticStatus = normalizeDiagnosticStatus;
15947
17180
  exports.parseMobileExtensionManifest = parseMobileExtensionManifest;