dsh-mobile 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -3634,6 +3634,163 @@ window.__ModuleLoader__.load({
3634
3634
  });
3635
3635
  }
3636
3636
  //#endregion
3637
+ //#region src/mobile-sidebar-scroll.ts
3638
+ function sidebarRestoreTop(snapshot, anchorTop) {
3639
+ return Math.max(0, anchorTop === void 0 || snapshot.offset === void 0 ? snapshot.top : anchorTop - snapshot.offset);
3640
+ }
3641
+ const keyedRows = "[data-row-key]";
3642
+ const hasSuffix = (element, suffix) => [...element.classList].some((token) => token.endsWith(suffix));
3643
+ function visible(element) {
3644
+ return element.isConnected && element.clientHeight > 0 && element.getClientRects().length > 0;
3645
+ }
3646
+ function captureSidebarScroll(element) {
3647
+ const top = element.scrollTop;
3648
+ const bounds = element.getBoundingClientRect();
3649
+ const row = [...element.querySelectorAll(keyedRows)].find((candidate) => {
3650
+ const rect = candidate.getBoundingClientRect();
3651
+ return rect.height > 0 && rect.bottom > bounds.top && rect.top < bounds.bottom;
3652
+ });
3653
+ return row === void 0 ? { top } : {
3654
+ top,
3655
+ anchor: row.getAttribute("data-row-key") ?? "",
3656
+ offset: row.getBoundingClientRect().top - bounds.top
3657
+ };
3658
+ }
3659
+ /**
3660
+ * Uses browser scrollports/keyed rows only; no React internals, DOM reordering,
3661
+ * forced group expansion or persistent storage of session identities. Absolute
3662
+ * position seeds a virtual window before a mounted keyed anchor refines it.
3663
+ */
3664
+ function installMobileSidebarScrollMemory() {
3665
+ const memories = /* @__PURE__ */ new Map();
3666
+ const stockViewport = window.matchMedia("(max-width:720px)");
3667
+ const dedicatedViewport = window.matchMedia("(max-width:899px)");
3668
+ let root;
3669
+ let open = false;
3670
+ let ports = /* @__PURE__ */ new Map();
3671
+ let pending = /* @__PURE__ */ new Map();
3672
+ let frame = 0;
3673
+ let deadline = 0;
3674
+ let disposed = false;
3675
+ const isOpen = () => root !== void 0 && visible(root) && !hasSuffix(root, "_collapsed") && root.closest("[data-open=\"false\"]") === null && root.querySelector("[data-dsh-mobile-settings]") === null;
3676
+ const cancel = () => {
3677
+ if (frame !== 0) cancelAnimationFrame(frame);
3678
+ frame = 0;
3679
+ pending.clear();
3680
+ };
3681
+ const remember = () => {
3682
+ if (!open || !isOpen()) return;
3683
+ for (const [port, key] of ports) if (!pending.has(port) && visible(port)) memories.set(key, port === root ? { top: port.scrollTop } : captureSidebarScroll(port));
3684
+ };
3685
+ const restore = () => {
3686
+ frame = 0;
3687
+ if (disposed || !open || !isOpen()) return;
3688
+ for (const [port, state] of pending) {
3689
+ if (!visible(port)) continue;
3690
+ const snapshot = state.snapshot;
3691
+ if (snapshot === void 0) {
3692
+ if (port === root) {
3693
+ pending.delete(port);
3694
+ continue;
3695
+ }
3696
+ const selected = port.querySelector("[role=\"treeitem\"][aria-selected=\"true\"]");
3697
+ if (selected !== null && visible(selected)) {
3698
+ const bounds = port.getBoundingClientRect(), rect = selected.getBoundingClientRect();
3699
+ if (rect.top < bounds.top || rect.bottom > bounds.bottom) port.scrollTop += rect.top - bounds.top - (port.clientHeight - rect.height) / 2;
3700
+ pending.delete(port);
3701
+ } else if (port.querySelector(keyedRows) !== null) pending.delete(port);
3702
+ continue;
3703
+ }
3704
+ const anchor = [...port.querySelectorAll(keyedRows)].find((row) => row.dataset.rowKey === snapshot.anchor);
3705
+ const target = sidebarRestoreTop(snapshot, anchor === void 0 ? void 0 : port.scrollTop + anchor.getBoundingClientRect().top - port.getBoundingClientRect().top);
3706
+ const before = port.scrollTop;
3707
+ port.scrollTop = target;
3708
+ if (Math.abs(port.scrollTop - target) <= 1 && Math.abs(before - port.scrollTop) <= 1) state.stable++;
3709
+ else state.stable = 0;
3710
+ if (state.stable >= 2) pending.delete(port);
3711
+ }
3712
+ if (pending.size > 0 && performance.now() < deadline) frame = requestAnimationFrame(restore);
3713
+ else pending.clear();
3714
+ };
3715
+ const sync = () => {
3716
+ if (disposed) return;
3717
+ const next = document.querySelector("[data-dsh-mobile-sidebar-root]") ?? void 0;
3718
+ const mobile = next?.closest("[data-pane=\"sidebar\"]") != null ? dedicatedViewport.matches : stockViewport.matches;
3719
+ if (next !== root) {
3720
+ cancel();
3721
+ ports.clear();
3722
+ root = next;
3723
+ open = false;
3724
+ }
3725
+ if (!(mobile && isOpen())) {
3726
+ cancel();
3727
+ open = false;
3728
+ ports.clear();
3729
+ return;
3730
+ }
3731
+ const reopening = !open;
3732
+ open = true;
3733
+ if (root === void 0) return;
3734
+ const candidates = [root, ...root.querySelectorAll("[class*=\"_regionArea\"] [class*=\"_list\"]")].filter((port) => port === root || hasSuffix(port, "_list"));
3735
+ const nextPorts = /* @__PURE__ */ new Map();
3736
+ for (const port of candidates) {
3737
+ const key = port === root ? "outer" : `workspace:${[...port.classList].sort().join(" ")}:${root.querySelector("[class*=\"_regionArea\"] input")?.value ?? ""}`;
3738
+ nextPorts.set(port, key);
3739
+ if (reopening || ports.get(port) !== key) {
3740
+ pending.set(port, {
3741
+ snapshot: memories.get(key),
3742
+ stable: 0
3743
+ });
3744
+ deadline = performance.now() + 1200;
3745
+ }
3746
+ }
3747
+ for (const port of pending.keys()) if (!nextPorts.has(port)) pending.delete(port);
3748
+ ports = nextPorts;
3749
+ if (pending.size > 0 && frame === 0) frame = requestAnimationFrame(restore);
3750
+ };
3751
+ const onScroll = (event) => {
3752
+ const port = event.target;
3753
+ if (!(port instanceof HTMLElement) || !open || !isOpen() || pending.has(port) || !visible(port)) return;
3754
+ const key = ports.get(port);
3755
+ if (key !== void 0) memories.set(key, port === root ? { top: port.scrollTop } : captureSidebarScroll(port));
3756
+ };
3757
+ const onIntent = (event) => {
3758
+ if (!(event.target instanceof Node) || !root?.contains(event.target)) return;
3759
+ cancel();
3760
+ remember();
3761
+ };
3762
+ stockViewport.addEventListener("change", sync);
3763
+ dedicatedViewport.addEventListener("change", sync);
3764
+ document.addEventListener("scroll", onScroll, true);
3765
+ for (const type of [
3766
+ "pointerdown",
3767
+ "touchstart",
3768
+ "wheel",
3769
+ "keydown",
3770
+ "click"
3771
+ ]) document.addEventListener(type, onIntent, true);
3772
+ return {
3773
+ sync,
3774
+ dispose: () => {
3775
+ disposed = true;
3776
+ cancel();
3777
+ stockViewport.removeEventListener("change", sync);
3778
+ dedicatedViewport.removeEventListener("change", sync);
3779
+ document.removeEventListener("scroll", onScroll, true);
3780
+ for (const type of [
3781
+ "pointerdown",
3782
+ "touchstart",
3783
+ "wheel",
3784
+ "keydown",
3785
+ "click"
3786
+ ]) document.removeEventListener(type, onIntent, true);
3787
+ ports.clear();
3788
+ memories.clear();
3789
+ root = void 0;
3790
+ }
3791
+ };
3792
+ }
3793
+ //#endregion
3637
3794
  //#region src/native-mobile.ts
3638
3795
  /**
3639
3796
  * Viewport below which the injected chrome becomes an overlay drawer: the
@@ -3680,10 +3837,30 @@ window.__ModuleLoader__.load({
3680
3837
  .dsh-mobile-settings_fontFamily > span { flex:1 1 100px; }
3681
3838
  .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
3839
  .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; }
3840
+ /* Push category rows: compact label-left / native-Switch-right rows. The control is
3841
+ the actual public @deepseek-ai/dsh-client-ui-primitives Switch provided by the DSH
3842
+ web shell: its track/thumb paint, dimensions, focus ring and motion are owned by
3843
+ the shell stylesheet and are never re-created, resized or overridden here. Each
3844
+ row is a label associated with its switch button, so the full 48px row remains
3845
+ one tap target while the button keeps native keyboard semantics. Category names
3846
+ match the General settings row title typography (14px/20px, regular weight). */
3847
+ .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); }
3848
+ .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; }
3849
+ .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
3850
  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
3851
  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
3852
  /* The mobile row replaces the Host-backed font control, not the theme selector. */
3686
3853
  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; }
3854
+ /* The injected attachment actions exist on every viewport, not only under the
3855
+ narrow overlay: a tablet shows the same Add menu, and with no native item to
3856
+ mirror they fell back to an unstyled UA button box (bordered, centred). Keep
3857
+ this fallback outside the overlay query so the rows read like native ones. */
3858
+ .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; }
3859
+ .dsh-mobile-media-action:active { opacity:.72; }
3860
+ .dsh-mobile-media-action:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary,#4c82f7); outline-offset:1px; }
3861
+ .dsh-mobile-media-action:disabled { cursor:default; opacity:.38; }
3862
+ .dsh-mobile-media-action svg { flex:none; width:16px; height:16px; color:var(--dsw-alias-label-tertiary,currentColor); }
3863
+ .dsh-mobile-media-action span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
3687
3864
  @media ${NATIVE_MOBILE_OVERLAY_QUERY} {
3688
3865
  html.dsh-native-mobile-active,html.dsh-native-mobile-active body { width:100%; height:100%; overflow:hidden; }
3689
3866
  html.dsh-native-mobile-active { --dsh-mobile-motion-duration:200ms; --dsh-mobile-motion-ease:cubic-bezier(.22,1,.36,1); }
@@ -3869,14 +4046,6 @@ window.__ModuleLoader__.load({
3869
4046
  [data-dsh-mobile-center] [role="dialog"][aria-label*="Context"] :is(dt,dd) { white-space:nowrap !important; word-break:keep-all !important; }
3870
4047
  .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
4048
  .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
4049
  [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
4050
  /* Collapse the scroll viewport only; the editor, draft and attachments stay
3882
4051
  owned by DSH. Toolbar focus keeps the card expanded during menu taps. */
@@ -3961,6 +4130,19 @@ window.__ModuleLoader__.load({
3961
4130
  return classToken(candidate, "_frame") && firstByClassSuffix(candidate, "_sidebarCol") !== void 0 && firstByClassSuffix(candidate, "_centerCol") !== void 0;
3962
4131
  });
3963
4132
  }
4133
+ /**
4134
+ * Mirror the classes of the stock Add-menu item on an injected attachment action.
4135
+ *
4136
+ * An empty class name is not nullish: trusting it would strip the plugin's own
4137
+ * fallback class and leave a bordered, centred UA button on that Host version.
4138
+ * A stock item that does carry classes still wins, so the injected action keeps
4139
+ * following the native menu's spacing, colours and dark-mode states.
4140
+ */
4141
+ function mediaActionClassName(nativeItem) {
4142
+ const nativeClass = nativeItem?.className;
4143
+ const trimmed = typeof nativeClass === "string" ? nativeClass.trim() : "";
4144
+ return trimmed === "" ? "dsh-mobile-media-action" : trimmed;
4145
+ }
3964
4146
  /** Mark every mounted settings dialog so its mobile layout does not depend on the conversation shell. */
3965
4147
  function markNativeMobileSettings(root) {
3966
4148
  let marked = 0;
@@ -4886,7 +5068,7 @@ window.__ModuleLoader__.load({
4886
5068
  }
4887
5069
  })();
4888
5070
  for (const button of [cameraButton, galleryButton]) {
4889
- const className = nativeItem?.className ?? "dsh-mobile-media-action";
5071
+ const className = mediaActionClassName(nativeItem);
4890
5072
  if (button.className !== className) button.className = className;
4891
5073
  const icon = button.firstElementChild;
4892
5074
  const caption = button.lastElementChild;
@@ -5092,6 +5274,7 @@ window.__ModuleLoader__.load({
5092
5274
  document.addEventListener("focusin", onStripFocus, true);
5093
5275
  document.addEventListener("click", onStripClickCapture, true);
5094
5276
  document.addEventListener("click", onBranchClick, true);
5277
+ const sidebarScrollMemory = installMobileSidebarScrollMemory();
5095
5278
  let frame;
5096
5279
  let sidebar;
5097
5280
  let sidebarRoot;
@@ -5324,7 +5507,10 @@ window.__ModuleLoader__.load({
5324
5507
  const lastColumn = frame?.style.gridTemplateColumns.trim().split(/\s+/).at(-1);
5325
5508
  details.dataset.open = String(lastColumn !== void 0 && lastColumn !== "0px" && lastColumn !== "0");
5326
5509
  }
5327
- if (sidebar === void 0) return;
5510
+ if (sidebar === void 0) {
5511
+ sidebarScrollMemory.sync();
5512
+ return;
5513
+ }
5328
5514
  sidebar.dataset.dshMobileSidebar = "true";
5329
5515
  toggle = firstByClassSuffix(sidebar, "_toggle");
5330
5516
  let candidate = toggle?.parentElement;
@@ -5336,6 +5522,7 @@ window.__ModuleLoader__.load({
5336
5522
  if (toggle !== void 0) toggle.dataset.dshMobileToggle = "true";
5337
5523
  if (frame !== void 0) sidebar.dataset.open = String(!classToken(sidebarRoot, "_collapsed"));
5338
5524
  backdrop.hidden = frame === void 0 || !drawerScrimVisible(classToken(sidebarRoot, "_collapsed"), overlayQuery.matches);
5525
+ sidebarScrollMemory.sync();
5339
5526
  };
5340
5527
  const schedule = () => {
5341
5528
  if (scheduled === 0) scheduled = requestAnimationFrame(sync);
@@ -5400,6 +5587,7 @@ window.__ModuleLoader__.load({
5400
5587
  return () => {
5401
5588
  disposed = true;
5402
5589
  stopStockBack();
5590
+ sidebarScrollMemory.dispose();
5403
5591
  disposeTaskWatcher();
5404
5592
  mediaRequestGeneration += 1;
5405
5593
  mediaPickerAbortController.abort();
@@ -8506,6 +8694,779 @@ window.__ModuleLoader__.load({
8506
8694
  };
8507
8695
  }
8508
8696
  //#endregion
8697
+ //#region src/mobile-push/browser-api.ts
8698
+ function apiError(code) {
8699
+ return Object.assign(/* @__PURE__ */ new Error(code === "stale_generation" ? "Push settings changed elsewhere." : "Push request failed."), { code });
8700
+ }
8701
+ async function check(response) {
8702
+ if (response.ok) return;
8703
+ let code = "request_failed";
8704
+ if (response.status === 409) try {
8705
+ if ((await response.json())?.error === "stale_generation") code = "stale_generation";
8706
+ } catch {}
8707
+ throw apiError(code);
8708
+ }
8709
+ async function json(response) {
8710
+ try {
8711
+ return await response.json();
8712
+ } catch {
8713
+ throw apiError("request_failed");
8714
+ }
8715
+ }
8716
+ async function requestOnce(request, path, init) {
8717
+ try {
8718
+ return await request(path, init);
8719
+ } catch {
8720
+ throw apiError("request_failed");
8721
+ }
8722
+ }
8723
+ function postJson(request, path, value) {
8724
+ return requestOnce(request, path, {
8725
+ method: "POST",
8726
+ headers: { "content-type": "application/json" },
8727
+ body: JSON.stringify(value)
8728
+ });
8729
+ }
8730
+ /** Build the direct authenticated browser API over mobileRequest (single-shot, same-origin, CSRF-aware). */
8731
+ function createMobilePushBrowserApi(request) {
8732
+ return {
8733
+ async getConfig(installationId) {
8734
+ const response = await requestOnce(request, `/mobile-access/push/config?installationId=${encodeURIComponent(installationId)}`, { method: "GET" });
8735
+ await check(response);
8736
+ return json(response);
8737
+ },
8738
+ async enroll(value) {
8739
+ const response = await postJson(request, "/mobile-access/push/subscription", value);
8740
+ await check(response);
8741
+ return json(response);
8742
+ },
8743
+ async updatePreferences(value) {
8744
+ await check(await postJson(request, "/mobile-access/push/preferences", value));
8745
+ },
8746
+ async disable(value) {
8747
+ await check(await postJson(request, "/mobile-access/push/disable", value));
8748
+ },
8749
+ async sendTest(value) {
8750
+ const response = await postJson(request, "/mobile-access/push/test", { installationId: value.installationId });
8751
+ if (response.status === 429) throw apiError("rate_limited");
8752
+ await check(response);
8753
+ },
8754
+ async resolve(reference) {
8755
+ const response = await postJson(request, "/mobile-access/push/resolve", { reference });
8756
+ await check(response);
8757
+ const value = await json(response);
8758
+ 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;
8759
+ return value;
8760
+ }
8761
+ };
8762
+ }
8763
+ //#endregion
8764
+ //#region src/mobile-push/installation-id.ts
8765
+ /** Browser-safe installation-id validation shared by the browser controller and the Gateway. */
8766
+ 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;
8767
+ /** True only for a UUID-shaped installation id with an allowed version and variant nibble. */
8768
+ function isPushInstallationId(value) {
8769
+ return typeof value === "string" && INSTALLATION_ID.test(value);
8770
+ }
8771
+ //#endregion
8772
+ //#region src/mobile-push/client.ts
8773
+ const ID_KEY = "dsh-mobile-push-installation";
8774
+ const defaults = Object.freeze({
8775
+ question: true,
8776
+ plan: true,
8777
+ approval: true,
8778
+ response: true,
8779
+ goal: true,
8780
+ failure: true
8781
+ });
8782
+ function decodeKey(encoded) {
8783
+ const normalized = encoded.replace(/-/gu, "+").replace(/_/gu, "/");
8784
+ const raw = atob(normalized + "=".repeat((4 - normalized.length % 4) % 4));
8785
+ return Uint8Array.from(raw, (char) => char.charCodeAt(0));
8786
+ }
8787
+ function safeError(error) {
8788
+ return (error && typeof error === "object" && "code" in error ? String(error.code) : "") === "stale_generation" ? "push.error.stale" : "push.error.generic";
8789
+ }
8790
+ function sameKey(subscription, key) {
8791
+ const raw = subscription.options.applicationServerKey;
8792
+ if (!raw) return false;
8793
+ const bytes = new Uint8Array(raw instanceof ArrayBuffer ? raw : raw.buffer, raw instanceof ArrayBuffer ? 0 : raw.byteOffset, raw instanceof ArrayBuffer ? raw.byteLength : raw.byteLength);
8794
+ return bytes.length === key.length && bytes.every((value, index) => value === key[index]);
8795
+ }
8796
+ /** Construct with a caller-owned authenticated API adapter and an explicit public window environment. */
8797
+ function createMobilePushController(api, environment) {
8798
+ let snapshot = Object.freeze({ state: "unsupported" });
8799
+ const listeners = /* @__PURE__ */ new Set();
8800
+ let active = false;
8801
+ let generation = 0;
8802
+ let config;
8803
+ let preparedKey;
8804
+ let existing = null;
8805
+ let preparedInstallId = "";
8806
+ let memoryInstallId = "";
8807
+ let mutationBusy = false;
8808
+ let testInFlight = false;
8809
+ let testOwner = 0;
8810
+ let attempt = 0;
8811
+ let ownedSubscription;
8812
+ let currentHostSubscription;
8813
+ const consentedSubscriptions = /* @__PURE__ */ new WeakSet();
8814
+ const pendingEnrollments = /* @__PURE__ */ new WeakMap();
8815
+ const trackPendingEnrollment = (subscription, operation) => {
8816
+ let pending = pendingEnrollments.get(subscription);
8817
+ if (!pending) {
8818
+ pending = /* @__PURE__ */ new Set();
8819
+ pendingEnrollments.set(subscription, pending);
8820
+ }
8821
+ pending.add(operation);
8822
+ };
8823
+ const finishPendingEnrollment = (subscription, operation) => {
8824
+ const pending = pendingEnrollments.get(subscription);
8825
+ pending?.delete(operation);
8826
+ if (pending?.size === 0) pendingEnrollments.delete(subscription);
8827
+ };
8828
+ const mayUnsubscribeCreatedSubscription = (subscription) => !consentedSubscriptions.has(subscription) && (pendingEnrollments.get(subscription)?.size ?? 0) === 0;
8829
+ const publish = (value) => {
8830
+ snapshot = Object.freeze({
8831
+ ...value,
8832
+ testInFlight
8833
+ });
8834
+ for (const listener of listeners) listener();
8835
+ };
8836
+ const current = (token) => active && generation === token;
8837
+ const getInstallationId = () => {
8838
+ if (memoryInstallId) return memoryInstallId;
8839
+ try {
8840
+ const saved = environment.storage?.getItem(ID_KEY);
8841
+ if (isPushInstallationId(saved)) return memoryInstallId = saved;
8842
+ } catch {}
8843
+ const fresh = environment.randomUUID?.() ?? globalThis.crypto?.randomUUID?.();
8844
+ if (!isPushInstallationId(fresh)) throw new Error("uuid unavailable");
8845
+ memoryInstallId = fresh;
8846
+ try {
8847
+ environment.storage?.setItem(ID_KEY, fresh);
8848
+ } catch {}
8849
+ return fresh;
8850
+ };
8851
+ const detect = () => {
8852
+ if (!environment.secureContext || !environment.isTopLevel || !environment.pushManager || environment.permission === "unsupported") return "unsupported";
8853
+ if (!environment.standalone) return "install";
8854
+ if (environment.permission === "denied") return "denied";
8855
+ };
8856
+ const refresh = async (token) => {
8857
+ const blocked = detect();
8858
+ if (blocked) {
8859
+ if (current(token)) publish({ state: blocked });
8860
+ return false;
8861
+ }
8862
+ try {
8863
+ const requestedInstallId = getInstallationId();
8864
+ const [nextConfig, subscription] = await Promise.all([api.getConfig(requestedInstallId), environment.pushManager.getSubscription()]);
8865
+ if (!current(token)) return false;
8866
+ config = nextConfig;
8867
+ existing = subscription;
8868
+ preparedInstallId = nextConfig.installationId || requestedInstallId;
8869
+ memoryInstallId = preparedInstallId;
8870
+ try {
8871
+ environment.storage?.setItem(ID_KEY, preparedInstallId);
8872
+ } catch {}
8873
+ preparedKey = nextConfig.publicKey ? decodeKey(nextConfig.publicKey) : void 0;
8874
+ if (!nextConfig.available || !preparedKey) publish({
8875
+ state: "unsupported",
8876
+ preferences: nextConfig.preferences
8877
+ });
8878
+ else if (nextConfig.status === "enabled") {
8879
+ if (subscription) {
8880
+ currentHostSubscription = subscription;
8881
+ consentedSubscriptions.add(subscription);
8882
+ }
8883
+ publish({
8884
+ state: nextConfig.status,
8885
+ preferences: nextConfig.preferences
8886
+ });
8887
+ } else if (nextConfig.status === "suspended") {
8888
+ if (subscription) {
8889
+ currentHostSubscription = subscription;
8890
+ consentedSubscriptions.add(subscription);
8891
+ }
8892
+ publish({
8893
+ state: "suspended",
8894
+ preferences: nextConfig.preferences
8895
+ });
8896
+ } else {
8897
+ if (nextConfig.status === "disabled" && currentHostSubscription) {
8898
+ consentedSubscriptions.delete(currentHostSubscription);
8899
+ currentHostSubscription = void 0;
8900
+ }
8901
+ publish({
8902
+ state: "ready",
8903
+ preferences: nextConfig.preferences
8904
+ });
8905
+ }
8906
+ return true;
8907
+ } catch (error) {
8908
+ if (current(token)) publish({
8909
+ state: "failed",
8910
+ error: safeError(error)
8911
+ });
8912
+ return false;
8913
+ }
8914
+ };
8915
+ const refreshNow = () => {
8916
+ if (active) refresh(generation);
8917
+ };
8918
+ return {
8919
+ start() {
8920
+ active = true;
8921
+ generation += 1;
8922
+ config = void 0;
8923
+ ownedSubscription = void 0;
8924
+ publish({ state: "pending" });
8925
+ refreshNow();
8926
+ },
8927
+ stop() {
8928
+ active = false;
8929
+ generation += 1;
8930
+ attempt += 1;
8931
+ mutationBusy = false;
8932
+ testOwner += 1;
8933
+ if (testInFlight) {
8934
+ testInFlight = false;
8935
+ publish({ ...snapshot });
8936
+ }
8937
+ config = void 0;
8938
+ preparedKey = void 0;
8939
+ existing = null;
8940
+ ownedSubscription = void 0;
8941
+ },
8942
+ refresh: refreshNow,
8943
+ async enableFromGesture() {
8944
+ if (!active || mutationBusy || snapshot.state !== "ready" && snapshot.state !== "failed" && snapshot.state !== "disabled" || !config || !preparedKey || !environment.pushManager) return;
8945
+ const token = generation, operation = ++attempt, base = config;
8946
+ mutationBusy = true;
8947
+ publish({
8948
+ state: "pending",
8949
+ preferences: base.preferences
8950
+ });
8951
+ let subscription = existing;
8952
+ let created = false;
8953
+ let pendingSubscription;
8954
+ try {
8955
+ if (subscription && !sameKey(subscription, preparedKey)) {
8956
+ publish({
8957
+ state: "failed",
8958
+ error: "push.error.subscriptionConflict",
8959
+ preferences: base.preferences
8960
+ });
8961
+ return;
8962
+ }
8963
+ if (!subscription) {
8964
+ subscription = await environment.pushManager.subscribe({
8965
+ userVisibleOnly: true,
8966
+ applicationServerKey: preparedKey
8967
+ });
8968
+ created = true;
8969
+ }
8970
+ if (created) ownedSubscription = subscription;
8971
+ if (!current(token) || operation !== attempt) {
8972
+ if (created && mayUnsubscribeCreatedSubscription(subscription)) {
8973
+ if (await subscription.unsubscribe().catch(() => false) && ownedSubscription === subscription) ownedSubscription = void 0;
8974
+ }
8975
+ return;
8976
+ }
8977
+ trackPendingEnrollment(subscription, operation);
8978
+ pendingSubscription = subscription;
8979
+ const browserSubscription = subscription.toJSON();
8980
+ const result = await api.enroll({
8981
+ installationId: preparedInstallId || base.installationId,
8982
+ subscription: {
8983
+ endpoint: browserSubscription.endpoint,
8984
+ keys: {
8985
+ p256dh: browserSubscription.keys.p256dh,
8986
+ auth: browserSubscription.keys.auth
8987
+ }
8988
+ },
8989
+ locale: environment.locale ?? "en",
8990
+ preferences: base.preferences ?? defaults,
8991
+ keyFingerprint: base.keyFingerprint ?? "",
8992
+ ownerGeneration: base.ownerGeneration,
8993
+ registrationGeneration: base.registrationGeneration
8994
+ });
8995
+ finishPendingEnrollment(subscription, operation);
8996
+ pendingSubscription = void 0;
8997
+ if (!current(token) || operation !== attempt) {
8998
+ if (result.mutationGeneration !== void 0) await api.disable({
8999
+ installationId: result.registration.installationId,
9000
+ ifGeneration: result.mutationGeneration
9001
+ }).catch(() => void 0);
9002
+ if (created && mayUnsubscribeCreatedSubscription(subscription)) {
9003
+ if (await subscription.unsubscribe().catch(() => false) && ownedSubscription === subscription) ownedSubscription = void 0;
9004
+ }
9005
+ return;
9006
+ }
9007
+ if (created) ownedSubscription = subscription;
9008
+ consentedSubscriptions.add(subscription);
9009
+ currentHostSubscription = subscription;
9010
+ if (result.registration.origin === environment.origin && result.registration.installationId) {
9011
+ memoryInstallId = result.registration.installationId;
9012
+ preparedInstallId = memoryInstallId;
9013
+ try {
9014
+ environment.storage?.setItem(ID_KEY, memoryInstallId);
9015
+ } catch {}
9016
+ }
9017
+ config = {
9018
+ ...base,
9019
+ installationId: preparedInstallId || result.registration.installationId,
9020
+ registrationGeneration: result.registration.generation,
9021
+ status: result.registration.health === "suspended" ? "suspended" : "enabled",
9022
+ preferences: result.registration.preferences
9023
+ };
9024
+ publish({
9025
+ state: result.registration.health === "suspended" ? "suspended" : "enabled",
9026
+ preferences: result.registration.preferences
9027
+ });
9028
+ } catch (error) {
9029
+ if (pendingSubscription) finishPendingEnrollment(pendingSubscription, operation);
9030
+ if (created && subscription && mayUnsubscribeCreatedSubscription(subscription)) {
9031
+ if (await subscription.unsubscribe().catch(() => false) && ownedSubscription === subscription) ownedSubscription = void 0;
9032
+ }
9033
+ if (current(token) && operation === attempt) {
9034
+ const isStale = safeError(error) === "push.error.stale";
9035
+ publish({
9036
+ state: "failed",
9037
+ error: safeError(error),
9038
+ preferences: base.preferences
9039
+ });
9040
+ if (isStale) refresh(token);
9041
+ }
9042
+ } finally {
9043
+ if (current(token) && operation === attempt) mutationBusy = false;
9044
+ }
9045
+ },
9046
+ async disable() {
9047
+ if (!active || !config) return;
9048
+ const token = generation, operation = ++attempt, base = config;
9049
+ mutationBusy = true;
9050
+ publish({
9051
+ state: "pending",
9052
+ preferences: base.preferences
9053
+ });
9054
+ try {
9055
+ await api.disable({ installationId: preparedInstallId || base.installationId });
9056
+ if (!current(token) || operation !== attempt) return;
9057
+ config = {
9058
+ ...base,
9059
+ status: "disabled"
9060
+ };
9061
+ publish({
9062
+ state: "disabled",
9063
+ preferences: base.preferences
9064
+ });
9065
+ if (currentHostSubscription) consentedSubscriptions.delete(currentHostSubscription);
9066
+ currentHostSubscription = void 0;
9067
+ const sub = ownedSubscription;
9068
+ if (sub) {
9069
+ if (await sub.unsubscribe().catch(() => false) && ownedSubscription === sub) ownedSubscription = void 0;
9070
+ }
9071
+ if (await refresh(token) && current(token) && operation === attempt && config?.status === "disabled") publish({
9072
+ state: "disabled",
9073
+ preferences: config.preferences
9074
+ });
9075
+ } catch (error) {
9076
+ if (current(token) && operation === attempt) {
9077
+ publish({
9078
+ state: "failed",
9079
+ error: safeError(error),
9080
+ preferences: base.preferences
9081
+ });
9082
+ if (safeError(error) === "push.error.stale") refresh(token);
9083
+ }
9084
+ } finally {
9085
+ if (current(token) && operation === attempt) mutationBusy = false;
9086
+ }
9087
+ },
9088
+ async updatePreferences(value) {
9089
+ if (!active || !config || mutationBusy) return;
9090
+ const token = generation, operation = ++attempt, base = config;
9091
+ mutationBusy = true;
9092
+ publish({
9093
+ state: "pending",
9094
+ preferences: value
9095
+ });
9096
+ try {
9097
+ await api.updatePreferences({
9098
+ installationId: preparedInstallId || base.installationId,
9099
+ ownerGeneration: base.ownerGeneration,
9100
+ registrationGeneration: base.registrationGeneration,
9101
+ preferences: value
9102
+ });
9103
+ if (current(token) && operation === attempt) {
9104
+ config = {
9105
+ ...base,
9106
+ preferences: value
9107
+ };
9108
+ publish({
9109
+ state: base.status === "enabled" ? "enabled" : "ready",
9110
+ preferences: value
9111
+ });
9112
+ await refresh(token);
9113
+ }
9114
+ } catch (error) {
9115
+ if (current(token) && operation === attempt) {
9116
+ publish({
9117
+ state: "failed",
9118
+ error: safeError(error),
9119
+ preferences: base.preferences
9120
+ });
9121
+ if (safeError(error) === "push.error.stale") refresh(token);
9122
+ }
9123
+ } finally {
9124
+ if (current(token) && operation === attempt) mutationBusy = false;
9125
+ }
9126
+ },
9127
+ async sendTest() {
9128
+ if (!active || !config || config.status !== "enabled" || mutationBusy || testInFlight) return;
9129
+ const token = generation, operation = attempt, base = config;
9130
+ const owner = ++testOwner;
9131
+ testInFlight = true;
9132
+ publish({
9133
+ state: "enabled",
9134
+ preferences: base.preferences
9135
+ });
9136
+ try {
9137
+ await api.sendTest({
9138
+ installationId: preparedInstallId || base.installationId,
9139
+ ownerGeneration: base.ownerGeneration,
9140
+ registrationGeneration: base.registrationGeneration
9141
+ });
9142
+ if (current(token) && operation === attempt) publish({
9143
+ state: "enabled",
9144
+ error: "push.test.accepted",
9145
+ preferences: base.preferences
9146
+ });
9147
+ } catch (error) {
9148
+ if (!current(token) || operation !== attempt) return;
9149
+ const message = error?.code === "rate_limited" ? "push.test.rateLimited" : "push.test.failed";
9150
+ const refreshed = await refresh(token);
9151
+ if (!current(token) || operation !== attempt) return;
9152
+ if (refreshed && config?.status === "enabled") publish({
9153
+ state: "enabled",
9154
+ error: message,
9155
+ preferences: config.preferences
9156
+ });
9157
+ } finally {
9158
+ if (owner === testOwner) {
9159
+ testInFlight = false;
9160
+ publish({ ...snapshot });
9161
+ }
9162
+ }
9163
+ },
9164
+ getSnapshot: () => snapshot,
9165
+ subscribe(listener) {
9166
+ listeners.add(listener);
9167
+ return () => {
9168
+ listeners.delete(listener);
9169
+ };
9170
+ }
9171
+ };
9172
+ }
9173
+ /** Adapter factory uses only the page's public PushManager; it deliberately has no service-worker path. */
9174
+ function browserPushEnvironment(view = window) {
9175
+ const nav = view.navigator;
9176
+ const pushManager = view.pushManager;
9177
+ const storage = (() => {
9178
+ try {
9179
+ return view.localStorage;
9180
+ } catch {
9181
+ return;
9182
+ }
9183
+ })();
9184
+ const isInstalled = () => {
9185
+ try {
9186
+ return nav.standalone === true || view.matchMedia("(display-mode: standalone)").matches || view.matchMedia("(display-mode: fullscreen)").matches;
9187
+ } catch {
9188
+ return nav.standalone === true;
9189
+ }
9190
+ };
9191
+ return {
9192
+ secureContext: view.isSecureContext,
9193
+ isTopLevel: view.top === view.self,
9194
+ get standalone() {
9195
+ return isInstalled();
9196
+ },
9197
+ get permission() {
9198
+ return "Notification" in view ? view.Notification.permission : "unsupported";
9199
+ },
9200
+ pushManager,
9201
+ storage,
9202
+ origin: view.location.origin,
9203
+ randomUUID: () => view.crypto.randomUUID(),
9204
+ locale: view.document.documentElement.lang.toLowerCase().startsWith("zh") ? "zh-CN" : view.document.documentElement.lang.toLowerCase().startsWith("it") ? "it" : "en"
9205
+ };
9206
+ }
9207
+ //#endregion
9208
+ //#region src/mobile-push/switch-component.ts
9209
+ /**
9210
+ * Lazily require the public Switch from the DSH module loader at category render time.
9211
+ *
9212
+ * The plugin client bundle is a `window.__ModuleLoader__.load` factory whose `require` parameter
9213
+ * resolves seeded shell modules synchronously (`rc.2` and `rc.3` both seed the primitives namespace
9214
+ * unconditionally). Requiring lazily — inside the render that needs it, never at module scope —
9215
+ * keeps every Node test and provider-free fixture that mounts the settings row but not the push
9216
+ * toggles free of any browser-only module dependency. Outside a DSH shell there is no loader
9217
+ * `require`, so the categories are withdrawn instead of rendering a broken stand-in; native
9218
+ * rendering itself is verified by the strict packed smoke against the real mounted shell.
9219
+ */
9220
+ function requirePublicMobilePushSwitch() {
9221
+ if (typeof window === "undefined" || typeof require !== "function") return void 0;
9222
+ try {
9223
+ return require("@deepseek-ai/dsh-client-ui-primitives").Switch;
9224
+ } catch {
9225
+ return;
9226
+ }
9227
+ }
9228
+ //#endregion
9229
+ //#region src/mobile-push/settings.ts
9230
+ const MOBILE_PUSH_SETTINGS_COPY = {
9231
+ en: {
9232
+ title: "Web push notifications",
9233
+ desc: "Receive notifications when this page is closed.",
9234
+ enable: "Enable",
9235
+ disable: "Disable",
9236
+ test: "Send test",
9237
+ testBusy: "Sending…",
9238
+ accepted: "Accepted by the push provider; lock-screen delivery is not confirmed.",
9239
+ install: "Add this site to your Home Screen to enable push.",
9240
+ denied: "Notifications are blocked in browser settings.",
9241
+ unsupported: "Web push is unavailable in this context.",
9242
+ error: "Could not update push settings. Try again.",
9243
+ testFailed: "The test notification could not be sent. Your notification settings are unchanged.",
9244
+ testRateLimited: "Too many test notifications; wait a moment before trying again.",
9245
+ 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.",
9246
+ busy: "Working…",
9247
+ categories: [
9248
+ "Questions",
9249
+ "Plans",
9250
+ "Approvals",
9251
+ "Responses",
9252
+ "Goals",
9253
+ "Failures"
9254
+ ]
9255
+ },
9256
+ zh: {
9257
+ title: "网页推送通知",
9258
+ desc: "关闭此页面后仍可接收通知。",
9259
+ enable: "启用",
9260
+ disable: "停用",
9261
+ test: "发送测试",
9262
+ testBusy: "发送中…",
9263
+ accepted: "推送服务商已接受;不代表锁屏一定送达。",
9264
+ install: "请将此网站添加到主屏幕以启用推送。",
9265
+ denied: "浏览器设置已阻止通知。",
9266
+ unsupported: "此环境不支持网页推送。",
9267
+ error: "无法更新推送设置,请重试。",
9268
+ testFailed: "测试通知未能发送。通知设置保持不变。",
9269
+ testRateLimited: "测试通知发送过于频繁,请稍后再试。",
9270
+ conflict: "此设备已存在与旧服务器密钥绑定的推送订阅,因此无法完成启用。重试无法解决;请先由服务器管理员检查推送密钥状态。",
9271
+ busy: "处理中…",
9272
+ categories: [
9273
+ "问题",
9274
+ "计划",
9275
+ "审批",
9276
+ "回复",
9277
+ "目标",
9278
+ "失败"
9279
+ ]
9280
+ },
9281
+ it: {
9282
+ title: "Notifiche Web push",
9283
+ desc: "Ricevi notifiche quando la pagina è chiusa.",
9284
+ enable: "Attiva",
9285
+ disable: "Disattiva",
9286
+ test: "Invia test",
9287
+ testBusy: "Invio…",
9288
+ accepted: "Accettata dal provider; la consegna sulla schermata di blocco non è confermata.",
9289
+ install: "Aggiungi il sito alla schermata Home per attivare il push.",
9290
+ denied: "Le notifiche sono bloccate nelle impostazioni del browser.",
9291
+ unsupported: "Web push non disponibile in questo contesto.",
9292
+ error: "Impossibile aggiornare le impostazioni push. Riprova.",
9293
+ testFailed: "Impossibile inviare la notifica di test. Le impostazioni delle notifiche restano invariate.",
9294
+ testRateLimited: "Troppe notifiche di test; attendi qualche istante prima di riprovare.",
9295
+ 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.",
9296
+ busy: "Operazione…",
9297
+ categories: [
9298
+ "Domande",
9299
+ "Piani",
9300
+ "Approvazioni",
9301
+ "Risposte",
9302
+ "Obiettivi",
9303
+ "Errori"
9304
+ ]
9305
+ }
9306
+ };
9307
+ const KEYS = [
9308
+ "question",
9309
+ "plan",
9310
+ "approval",
9311
+ "response",
9312
+ "goal",
9313
+ "failure"
9314
+ ];
9315
+ function message(snapshot, copy) {
9316
+ if (snapshot.state === "install") return copy.install;
9317
+ if (snapshot.state === "denied") return copy.denied;
9318
+ if (snapshot.state === "unsupported") return copy.unsupported;
9319
+ if (snapshot.state === "enabled" && snapshot.error === "push.test.accepted") return copy.accepted;
9320
+ if (snapshot.state === "enabled" && snapshot.error === "push.test.rateLimited") return copy.testRateLimited;
9321
+ if (snapshot.state === "enabled" && snapshot.error === "push.test.failed") return copy.testFailed;
9322
+ if (snapshot.state === "failed" && snapshot.error === "push.error.subscriptionConflict") return copy.conflict;
9323
+ return snapshot.state === "failed" ? copy.error : copy.desc;
9324
+ }
9325
+ /** Browser-only settings row; mounting it never requests permission or creates a subscription. */
9326
+ function MobilePushSettings({ controller, locale, switchComponent }) {
9327
+ const snapshot = (0, react.useSyncExternalStore)(controller.subscribe, controller.getSnapshot);
9328
+ const copy = MOBILE_PUSH_SETTINGS_COPY[locale];
9329
+ if (snapshot.state === "unsupported") return null;
9330
+ const prefs = snapshot.preferences;
9331
+ const Switch = switchComponent ?? requirePublicMobilePushSwitch();
9332
+ const toggle = (key, checked) => {
9333
+ if (!prefs) return;
9334
+ controller.updatePreferences({
9335
+ ...prefs,
9336
+ [key]: checked
9337
+ });
9338
+ };
9339
+ return (0, react.createElement)("div", {
9340
+ className: "dsh-mobile-settings_row",
9341
+ style: { flexWrap: "wrap" },
9342
+ lang: locale === "zh" ? "zh-CN" : locale
9343
+ }, (0, react.createElement)("div", {
9344
+ className: "dsh-mobile-settings_rowText",
9345
+ style: { paddingRight: 8 }
9346
+ }, (0, react.createElement)("div", { className: "dsh-mobile-settings_title" }, copy.title), (0, react.createElement)("div", {
9347
+ className: "dsh-mobile-settings_desc",
9348
+ "aria-live": "polite"
9349
+ }, message(snapshot, copy)), prefs && Switch !== void 0 ? (0, react.createElement)("div", { className: "dsh-mobile-push-categories" }, ...KEYS.map((key, index) => {
9350
+ const name = copy.categories[index];
9351
+ if (name === void 0) return null;
9352
+ return (0, react.createElement)("label", {
9353
+ key,
9354
+ className: "dsh-mobile-push-category"
9355
+ }, (0, react.createElement)("span", { className: "dsh-mobile-push-categoryName" }, name), (0, react.createElement)(Switch, {
9356
+ checked: prefs[key],
9357
+ label: name,
9358
+ onChange: (next) => {
9359
+ toggle(key, next);
9360
+ }
9361
+ }));
9362
+ })) : null), (0, react.createElement)("div", {
9363
+ className: "dsh-mobile-push-actions",
9364
+ style: {
9365
+ display: "flex",
9366
+ flexWrap: "wrap",
9367
+ gap: 8
9368
+ }
9369
+ }, snapshot.state === "enabled" ? (0, react.createElement)("button", {
9370
+ type: "button",
9371
+ className: "dsh-mobile-settings_selector",
9372
+ disabled: snapshot.testInFlight === true,
9373
+ "aria-busy": snapshot.testInFlight === true ? "true" : void 0,
9374
+ onClick: () => void controller.sendTest()
9375
+ }, snapshot.testInFlight ? copy.testBusy : copy.test) : null, snapshot.state === "enabled" || snapshot.state === "suspended" ? (0, react.createElement)("button", {
9376
+ type: "button",
9377
+ className: "dsh-mobile-settings_selector",
9378
+ onClick: () => void controller.disable()
9379
+ }, copy.disable) : snapshot.state === "ready" || snapshot.state === "failed" || snapshot.state === "disabled" ? (0, react.createElement)("button", {
9380
+ type: "button",
9381
+ className: "dsh-mobile-settings_selector",
9382
+ onClick: () => void controller.enableFromGesture()
9383
+ }, copy.enable) : (0, react.createElement)("button", {
9384
+ type: "button",
9385
+ className: "dsh-mobile-settings_selector",
9386
+ disabled: true
9387
+ }, copy.busy)));
9388
+ }
9389
+ //#endregion
9390
+ //#region src/mobile-push/navigation.ts
9391
+ const REF_PARAMETER = "mobile-push";
9392
+ /**
9393
+ * Consume one authenticated tap reference after the public Workspace and Session
9394
+ * list baselines are ready. Return the disposal function for list subscriptions.
9395
+ */
9396
+ function installPushNavigation(ctx, options) {
9397
+ const context = ctx;
9398
+ const browser = options.browser ?? {
9399
+ location: window.location,
9400
+ history: window.history
9401
+ };
9402
+ const params = new URLSearchParams(browser.location.search);
9403
+ const reference = params.get(REF_PARAMETER);
9404
+ let disposed = false;
9405
+ let consumed = false;
9406
+ let resolving = false;
9407
+ let opened = false;
9408
+ const clearReference = () => {
9409
+ if (!params.has(REF_PARAMETER)) return;
9410
+ params.delete(REF_PARAMETER);
9411
+ const query = params.toString();
9412
+ const url = `${browser.location.pathname}${query ? `?${query}` : ""}${browser.location.hash}`;
9413
+ browser.history.replaceState(null, "", url);
9414
+ };
9415
+ const neutral = (key = "session-unavailable") => {
9416
+ if (disposed || consumed) return;
9417
+ consumed = true;
9418
+ clearReference();
9419
+ options.notify(key);
9420
+ };
9421
+ const check = () => {
9422
+ if (disposed || consumed || resolving) return;
9423
+ if (!reference) {
9424
+ neutral();
9425
+ return;
9426
+ }
9427
+ const sessions = context.sessions.list.getSnapshot();
9428
+ const workspaces = context.workspaces.list.getSnapshot();
9429
+ if (sessions.phase !== "pending" && sessions.phase !== "ready" || workspaces.phase !== "pending" && workspaces.phase !== "ready") {
9430
+ neutral();
9431
+ return;
9432
+ }
9433
+ if (sessions.phase !== "ready" || workspaces.phase !== "ready") return;
9434
+ if (!reference) {
9435
+ neutral();
9436
+ return;
9437
+ }
9438
+ resolving = true;
9439
+ consumed = true;
9440
+ clearReference();
9441
+ options.resolve(reference).then((target) => {
9442
+ if (disposed) return;
9443
+ if (!target || target.interaction !== "current" && target.interaction !== "resolved" || !sessionsAvailable(context, target.sessionId)) {
9444
+ options.notify("session-unavailable");
9445
+ return;
9446
+ }
9447
+ if (opened) return;
9448
+ opened = true;
9449
+ context.uiWorkspace.openSession(target.sessionId);
9450
+ if (target.interaction === "resolved") options.notify("session-resolved");
9451
+ }).catch(() => {
9452
+ if (!disposed) options.notify("session-unavailable");
9453
+ });
9454
+ };
9455
+ const unsubscribeSessions = context.sessions.list.subscribe(check);
9456
+ const unsubscribeWorkspaces = context.workspaces.list.subscribe(check);
9457
+ check();
9458
+ return () => {
9459
+ if (disposed) return;
9460
+ disposed = true;
9461
+ unsubscribeSessions();
9462
+ unsubscribeWorkspaces();
9463
+ };
9464
+ }
9465
+ function sessionsAvailable(ctx, sessionId) {
9466
+ const snapshot = ctx.sessions.list.getSnapshot();
9467
+ return snapshot.phase === "ready" && snapshot.ids.includes(sessionId) && snapshot.byId[sessionId] !== void 0;
9468
+ }
9469
+ //#endregion
8509
9470
  //#region src/mobile-directory-browser.ts
8510
9471
  /** Expected Gateway or response-validation failure for one read-only listing. */
8511
9472
  var MobileDirectoryReadError = class extends Error {
@@ -9579,6 +10540,12 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
9579
10540
  function parseMobileRemoteProvider(value) {
9580
10541
  return value === "cpolar" || value === "cloudflared" || value === "frp" || value === "origin" || value === "iroh" ? value : "tailscale";
9581
10542
  }
10543
+ /** Public client services the authenticated tap consumer follows (scoped child lifecycle, never a required plugin dependency). */
10544
+ const mobilePushNavigationServices = [
10545
+ "sessions",
10546
+ "workspaces",
10547
+ "uiWorkspace"
10548
+ ];
9582
10549
  /**
9583
10550
  * Set a surface's visibility; opening a page or overlay brings it to the front.
9584
10551
  * @param container - the registered surface element.
@@ -15738,6 +16705,107 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
15738
16705
  .dsh-mobile-control__remote-workspace .dsh-mobile-control__details{border:0;border-radius:0;background:transparent;box-shadow:none}
15739
16706
  .dsh-mobile-control__self-hosted{border:0;border-radius:0;background:transparent;box-shadow:none}
15740
16707
  ` + EXTENSION_RECOVERY_STYLES;
16708
+ /** Resolve safe, locale-owned copy for tap-navigation results. */
16709
+ function mobilePushNavigationNotice(locale, key) {
16710
+ return {
16711
+ en: {
16712
+ "session-unavailable": "This conversation is no longer available.",
16713
+ "session-resolved": "Opened the conversation."
16714
+ },
16715
+ it: {
16716
+ "session-unavailable": "Questa conversazione non è più disponibile.",
16717
+ "session-resolved": "Conversazione aperta."
16718
+ },
16719
+ zh: {
16720
+ "session-unavailable": "此会话已不可用。",
16721
+ "session-resolved": "已打开会话。"
16722
+ }
16723
+ }[locale][key];
16724
+ }
16725
+ function showMobilePushNavigationNotice(page, view, key) {
16726
+ const node = page.createElement("div");
16727
+ node.className = "dsh-mobile-push-navigation-notice";
16728
+ node.setAttribute("role", "status");
16729
+ node.setAttribute("aria-live", "polite");
16730
+ node.lang = selectedMobileControlLocale();
16731
+ node.textContent = mobilePushNavigationNotice(selectedMobileControlLocale(), key);
16732
+ 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";
16733
+ page.body.append(node);
16734
+ view.setTimeout(() => node.remove(), 3e3);
16735
+ }
16736
+ /** Own the browser-only push controller, settings row, and optional authenticated tap navigation. */
16737
+ function installMobilePushBrowserClient(ctx, runtime) {
16738
+ const { page, view } = runtime;
16739
+ 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;
16740
+ const api = runtime.api ?? createMobilePushBrowserApi(mobileRequest);
16741
+ const controller = createMobilePushController(api, runtime.environment ?? browserPushEnvironment(view));
16742
+ controller.start();
16743
+ const removeSettings = ctx.slots.inject("settings.general.item", () => ctx.slots.register({
16744
+ name: "settings.general.item",
16745
+ id: "dsh-mobile-push-browser",
16746
+ order: 120
16747
+ }, () => (0, react.createElement)(MobilePushSettings, {
16748
+ controller,
16749
+ locale: selectedMobileControlLocale()
16750
+ })));
16751
+ const refresh = () => {
16752
+ controller.refresh();
16753
+ };
16754
+ const onVisible = () => {
16755
+ if (page.visibilityState === "visible") refresh();
16756
+ };
16757
+ view.addEventListener("focus", refresh);
16758
+ page.addEventListener("visibilitychange", onVisible);
16759
+ const connection = ctx.get("connection");
16760
+ const unbindConnection = connection?.state?.subscribe(() => {
16761
+ if (connection.state?.getSnapshot() === "connected") refresh();
16762
+ }) ?? (() => void 0);
16763
+ let removeNavigation = () => void 0;
16764
+ let disposeNavigationLifecycle = () => void 0;
16765
+ const reference = new URLSearchParams(view.location.search).get("mobile-push");
16766
+ if (reference !== null && reference !== "" && ctx.inject !== void 0) {
16767
+ let navigationClosed = false;
16768
+ const navigationFiber = ctx.inject(mobilePushNavigationServices, (navigationContext) => {
16769
+ if (navigationClosed) return () => void 0;
16770
+ const pending = new URLSearchParams(view.location.search).get("mobile-push");
16771
+ if (pending === null || pending === "") return () => void 0;
16772
+ const remove = installPushNavigation(navigationContext, {
16773
+ resolve: (value) => api.resolve(value),
16774
+ notify: (key) => {
16775
+ if (key !== "session-unavailable" && key !== "session-resolved") return;
16776
+ if (runtime.notify) runtime.notify(key);
16777
+ else showMobilePushNavigationNotice(page, view, key);
16778
+ },
16779
+ browser: {
16780
+ location: view.location,
16781
+ history: view.history
16782
+ }
16783
+ });
16784
+ removeNavigation = remove;
16785
+ return () => {
16786
+ if (removeNavigation === remove) removeNavigation = () => void 0;
16787
+ remove();
16788
+ };
16789
+ });
16790
+ disposeNavigationLifecycle = () => {
16791
+ if (navigationClosed) return;
16792
+ navigationClosed = true;
16793
+ removeNavigation();
16794
+ navigationFiber.dispose();
16795
+ };
16796
+ }
16797
+ let disposed = false;
16798
+ return () => {
16799
+ if (disposed) return;
16800
+ disposed = true;
16801
+ view.removeEventListener("focus", refresh);
16802
+ page.removeEventListener("visibilitychange", onVisible);
16803
+ unbindConnection();
16804
+ disposeNavigationLifecycle();
16805
+ removeSettings();
16806
+ controller.stop();
16807
+ };
16808
+ }
15741
16809
  /** Mount the desktop control or mobile feature enhancements. */
15742
16810
  function apply(ctx) {
15743
16811
  let gatewayRejectionSink;
@@ -15772,6 +16840,10 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
15772
16840
  return recovery();
15773
16841
  };
15774
16842
  }, "dsh-mobile: connection recovery");
16843
+ ctx.effect(() => installMobilePushBrowserClient(ctx, {
16844
+ page: document,
16845
+ view: window
16846
+ }), "dsh-mobile: authenticated browser push lifecycle");
15775
16847
  ctx.effect(() => {
15776
16848
  const desktopAdmin = isDesktopAdminSurface(location.hostname, location.search, window.__DSH_MOBILE_FRONTEND__, location.protocol);
15777
16849
  const gatewayCustomization = ownsMobileCustomization(document.querySelector("meta[name=\"dsh-mobile-gateway\"][content=\"authenticated\"]") !== null, window.__DSH_MOBILE_FRONTEND__);
@@ -15940,8 +17012,10 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
15940
17012
  exports.installCustomAssets = installCustomAssets;
15941
17013
  exports.installDshLanguageBoundSurface = installDshLanguageBoundSurface;
15942
17014
  exports.installMobileNativeBack = installMobileNativeBack;
17015
+ exports.installMobilePushBrowserClient = installMobilePushBrowserClient;
15943
17016
  exports.localizedDiagnosticEntryCopy = localizedDiagnosticEntryCopy;
15944
17017
  exports.mobileBackBlockedMessage = mobileBackBlockedMessage;
17018
+ exports.mobilePushNavigationNotice = mobilePushNavigationNotice;
15945
17019
  exports.normalizeDiagnosticOverall = normalizeDiagnosticOverall;
15946
17020
  exports.normalizeDiagnosticStatus = normalizeDiagnosticStatus;
15947
17021
  exports.parseMobileExtensionManifest = parseMobileExtensionManifest;