dsh-mobile 0.6.18 → 0.7.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
@@ -1021,6 +1021,105 @@ window.__ModuleLoader__.load({
1021
1021
  frpStopped: "FRP 连接已停止。",
1022
1022
  frpExited: "frpc 意外退出,请检查 VPS 配置后重新连接。"
1023
1023
  });
1024
+ Object.assign(MOBILE_CONTROL_MESSAGES.en, {
1025
+ irohName: "iroh (Experimental)",
1026
+ irohExperimental: "Experimental",
1027
+ irohNotReady: "iroh is not running. Start the channel first, then generate a pairing code.",
1028
+ irohPairingFailed: "Could not open an iroh pairing window. Reconnect the channel and retry.",
1029
+ irohPairingTitle: "Phone pairing (experimental)",
1030
+ generateIrohQr: "Generate iroh pairing QR code",
1031
+ enlargeQr: "Enlarge the QR code",
1032
+ closeEnlargedQr: "Close the enlarged QR code",
1033
+ irohPairingReady: "iroh pairing code {code} is live for five minutes. Scan it from the iroh pairing screen; opening another code replaces this one.",
1034
+ irohDescription: "Direct encrypted connection with relay fallback. No public domain or tunnel account needed.",
1035
+ prepareIroh: "Prepare iroh",
1036
+ irohInstallNote: "The Install button downloads the pinned sidecar release (dsh-mobile-iroh 0.1.0-alpha.1, about 15 MB on Windows x64, 24 MB on Linux x64) into the plugin private directory and verifies its SHA-256. An installed companion with a verified copy is reused instead, with no download.",
1037
+ irohComponentNote: "An installed sidecar is re-verified by SHA-256 on every use. It is not bundled with the plugin package; a companion-provided copy can only be removed by removing the companion.",
1038
+ irohPhaseNote: "Experimental connection. Requires the full Android App 0.7.0+ with iroh; the compatibility APK and mobile browsers cannot use it.",
1039
+ irohUnavailable: "iroh is not ready: no sidecar is installed yet. Install the official component below to download and verify it.",
1040
+ irohStarting: "Resolving the iroh endpoint identity…",
1041
+ irohConnecting: "Sidecar connected; starting the DSH authentication gateway…",
1042
+ irohCompanionMissing: "No verified iroh sidecar is available. Use Install official component below; a separate companion plugin is optional.",
1043
+ irohDataDirectoryInvalid: "Could not prepare the private sidecar directory. Check that DSH can manage its own profile directory, then retry.",
1044
+ irohSidecarInvalid: "The installed sidecar failed checksum verification. Reinstall the component.",
1045
+ irohDownloadFailed: "The sidecar download failed before verification. Check the network and retry.",
1046
+ irohPlatformUnsupported: "iroh sidecars are published for Windows x64 and Linux x64 only.",
1047
+ irohIdentityFailed: "Could not read the iroh endpoint identity from the sidecar.",
1048
+ irohLaunchFailed: "The iroh sidecar failed to start.",
1049
+ irohExited: "The iroh sidecar exited unexpectedly. Reconnect.",
1050
+ irohTimeout: "Timed out waiting for the iroh sidecar. Reconnect.",
1051
+ irohInstallConfirm: "Download and verify the pinned iroh sidecar now (about 15 MB on Windows x64, 24 MB on Linux x64)? Nothing else is installed.",
1052
+ installingIroh: "Downloading and verifying the iroh sidecar…",
1053
+ switchingIroh: "Switching to iroh…",
1054
+ reconnectingIroh: "Reconnecting the iroh transport…",
1055
+ resetIrohConfirm: "Disable the iroh channel? The companion and its pairings stay installed."
1056
+ });
1057
+ Object.assign(MOBILE_CONTROL_MESSAGES.it, {
1058
+ irohName: "iroh (Sperimentale)",
1059
+ irohExperimental: "Sperimentale",
1060
+ irohNotReady: "iroh non è attivo. Avvia il canale e genera di nuovo il codice.",
1061
+ irohPairingFailed: "Impossibile aprire una finestra di abbinamento iroh. Riconnetti il canale e riprova.",
1062
+ irohPairingTitle: "Abbinamento telefono (sperimentale)",
1063
+ generateIrohQr: "Genera QR di abbinamento iroh",
1064
+ enlargeQr: "Ingrandisci il codice QR",
1065
+ closeEnlargedQr: "Chiudi il codice QR ingrandito",
1066
+ irohPairingReady: "Il codice iroh {code} è attivo per cinque minuti. Scansionalo dalla schermata di abbinamento iroh; aprirne un altro sostituisce questo.",
1067
+ irohDescription: "Connessione cifrata diretta con ripiego su relay. Nessun dominio pubblico o account tunnel richiesto.",
1068
+ prepareIroh: "Prepara iroh",
1069
+ irohInstallNote: "Il pulsante Installa scarica la release pinnata del sidecar (dsh-mobile-iroh 0.1.0-alpha.1, circa 15 MB su Windows x64, 24 MB su Linux x64) nella directory privata del plugin e ne verifica lo SHA-256. Un complemento installato con una copia verificata viene invece riutilizzato senza scaricare nulla.",
1070
+ irohComponentNote: "Un sidecar installato viene riverificato tramite SHA-256 a ogni uso. Non è incluso nel pacchetto del plugin; una copia fornita dal complemento può essere rimossa solo rimuovendo il complemento.",
1071
+ irohPhaseNote: "Connessione sperimentale. Richiede l’App Android completa 0.7.0+ con iroh; APK compatibile e browser mobili non sono supportati.",
1072
+ irohUnavailable: "iroh non è pronto: nessun sidecar è ancora installato. Installa il componente ufficiale qui sotto per scaricarlo e verificarlo.",
1073
+ irohStarting: "Risoluzione dell’identità endpoint iroh…",
1074
+ irohConnecting: "Sidecar connesso; avvio del gateway di autenticazione DSH…",
1075
+ irohCompanionMissing: "Nessun sidecar iroh verificato disponibile. Usa Installa componente ufficiale qui sotto; il plugin complementare è facoltativo.",
1076
+ irohDataDirectoryInvalid: "Impossibile preparare la directory privata del sidecar. Verifica che DSH possa gestire la propria directory del profilo, poi riprova.",
1077
+ irohSidecarInvalid: "Il sidecar installato non ha superato la verifica del checksum. Reinstalla il componente.",
1078
+ irohDownloadFailed: "Download del sidecar non riuscito prima della verifica. Controlla la rete e riprova.",
1079
+ irohPlatformUnsupported: "I sidecar iroh sono pubblicati solo per Windows x64 e Linux x64.",
1080
+ irohIdentityFailed: "Impossibile leggere l’identità endpoint iroh dal sidecar.",
1081
+ irohLaunchFailed: "Avvio del sidecar iroh non riuscito.",
1082
+ irohExited: "Il sidecar iroh è terminato inaspettatamente. Riconnettiti.",
1083
+ irohTimeout: "Attesa del sidecar iroh scaduta. Riconnettiti.",
1084
+ irohInstallConfirm: "Scaricare e verificare ora il sidecar iroh pinnato (circa 15 MB su Windows x64, 24 MB su Linux x64)? Non viene installato altro.",
1085
+ installingIroh: "Download e verifica del sidecar iroh…",
1086
+ switchingIroh: "Passaggio a iroh…",
1087
+ reconnectingIroh: "Riconnessione del trasporto iroh…",
1088
+ resetIrohConfirm: "Disattivare il canale iroh? Complemento e abbinamenti restano installati."
1089
+ });
1090
+ Object.assign(MOBILE_CONTROL_MESSAGES.zh, {
1091
+ irohName: "iroh(实验性)",
1092
+ irohExperimental: "实验性",
1093
+ irohNotReady: "iroh 通道未运行,请先启动通道再生成配对码。",
1094
+ irohPairingFailed: "无法打开 iroh 配对窗口,请重连通道后重试。",
1095
+ irohPairingTitle: "手机配对(实验性)",
1096
+ generateIrohQr: "生成 iroh 配对二维码",
1097
+ enlargeQr: "放大二维码",
1098
+ closeEnlargedQr: "关闭放大的二维码",
1099
+ irohPairingReady: "iroh 配对码 {code} 已生效,有效期 5 分钟。请在 App 的 iroh 配对页扫码;再生成一个会替换当前这个。",
1100
+ irohDescription: "加密直连,失败时走中继。不需要公网域名或隧道账号。",
1101
+ prepareIroh: "准备 iroh",
1102
+ irohInstallNote: "点击安装会下载固定的 sidecar 发行版(dsh-mobile-iroh 0.1.0-alpha.1,Windows x64 约 15 MB,Linux x64 约 24 MB)到插件私有目录并校验 SHA-256;已安装的配套插件如有校验通过的副本会直接复用,不再下载。",
1103
+ irohComponentNote: "已安装的 sidecar 每次使用都会重新校验 SHA-256。它不随插件包发布;配套提供的副本只能通过移除配套插件来删除。",
1104
+ irohPhaseNote: "实验性连接。需使用含 iroh 的 Android App 0.7.0 及以上完整版;兼容版 APK 和手机浏览器不支持。",
1105
+ irohUnavailable: "iroh 未就绪:尚未安装 sidecar,点击下方「安装官方组件」下载并校验。",
1106
+ irohStarting: "正在解析 iroh 端点身份…",
1107
+ irohConnecting: "Sidecar 已连接,正在启动 DSH 认证网关…",
1108
+ irohCompanionMissing: "尚无校验通过的 iroh 组件,请点击下方「安装官方组件」;无需另装配套插件。",
1109
+ irohDataDirectoryInvalid: "无法准备 sidecar 私有目录。请确认 DSH 能管理自己的配置目录后重试。",
1110
+ irohSidecarInvalid: "已安装的 sidecar 哈希校验失败,请重装组件。",
1111
+ irohDownloadFailed: "下载 sidecar 失败,还没开始校验。请检查网络后重试。",
1112
+ irohPlatformUnsupported: "iroh sidecar 仅发布 Windows x64 与 Linux x64 版本。",
1113
+ irohIdentityFailed: "无法从 sidecar 读取 iroh 端点身份。",
1114
+ irohLaunchFailed: "iroh sidecar 启动失败。",
1115
+ irohExited: "iroh sidecar 意外退出,请重新连接。",
1116
+ irohTimeout: "等待 iroh sidecar 超时,请重新连接。",
1117
+ irohInstallConfirm: "现在下载并校验固定的 iroh sidecar 吗(Windows x64 约 15 MB,Linux x64 约 24 MB)?不会安装其他任何内容。",
1118
+ installingIroh: "正在下载并校验 iroh sidecar…",
1119
+ switchingIroh: "正在切换到 iroh…",
1120
+ reconnectingIroh: "正在重连 iroh 传输…",
1121
+ resetIrohConfirm: "要停用 iroh 通道吗?配套插件与配对不受影响。"
1122
+ });
1024
1123
  Object.assign(MOBILE_CONTROL_MESSAGES.en, {
1025
1124
  requestTimeout: "The operation timed out. Confirm that DSH is still running, then try again.",
1026
1125
  diagnostics: "Diagnostics",
@@ -7857,25 +7956,64 @@ window.__ModuleLoader__.load({
7857
7956
  function ownsMobileCustomization(gateway, frontend) {
7858
7957
  return gateway === true || frontend === "dedicated";
7859
7958
  }
7860
- /** Emit each safe failure once until recovery, and ignore work after cancellation or disposal. */
7959
+ /**
7960
+ * Emit each safe failure once until recovery, and ignore work after cancellation
7961
+ * or disposal.
7962
+ *
7963
+ * A failure that only reaches the console is invisible to the user, who sees the
7964
+ * customization disappear and cannot tell it from a third-party plugin bug
7965
+ * (issue #298). The reporter therefore also publishes its current state so the
7966
+ * page can render a visible, localized notice with an explicit recovery action.
7967
+ * @param locale - resolves the active UI locale for the copy.
7968
+ * @param output - console sink for the developer-facing line.
7969
+ * @returns the reporting calls plus a subscribable snapshot.
7970
+ */
7861
7971
  function createMobileAssetLoadReporter(locale, output = console) {
7862
7972
  const reported = /* @__PURE__ */ new Set();
7973
+ const listeners = /* @__PURE__ */ new Set();
7863
7974
  let disposed = false;
7975
+ const visible = () => {
7976
+ if (reported.has("authentication_required")) return "authentication_required";
7977
+ if (reported.has("gateway_required")) return "gateway_required";
7978
+ return reported.values().next().done === true ? void 0 : reported.values().next().value;
7979
+ };
7980
+ const publish = () => {
7981
+ const state = visible();
7982
+ for (const listener of listeners) try {
7983
+ listener(state);
7984
+ } catch (error) {}
7985
+ };
7864
7986
  return {
7865
7987
  fail(reason, signal) {
7866
7988
  if (disposed || signal?.aborted === true || reported.has(reason)) return;
7867
7989
  if (reason === "resource_unavailable" && reported.has("authentication_required")) return;
7868
7990
  reported.add(reason);
7869
7991
  output.warn(`[DSH Mobile] ${COPY[locale()][reason]}`);
7992
+ publish();
7870
7993
  },
7871
7994
  recover(signal) {
7872
7995
  if (disposed || signal.aborted || reported.size === 0) return;
7873
7996
  reported.clear();
7874
7997
  output.info(`[DSH Mobile] ${COPY[locale()].recovered}`);
7998
+ publish();
7875
7999
  },
7876
8000
  dispose() {
7877
8001
  disposed = true;
7878
8002
  reported.clear();
8003
+ listeners.clear();
8004
+ },
8005
+ getState() {
8006
+ return disposed ? void 0 : visible();
8007
+ },
8008
+ subscribe(listener) {
8009
+ if (disposed) return () => {};
8010
+ listeners.add(listener);
8011
+ return () => {
8012
+ listeners.delete(listener);
8013
+ };
8014
+ },
8015
+ message(reason) {
8016
+ return COPY[locale()][reason];
7879
8017
  }
7880
8018
  };
7881
8019
  }
@@ -7925,6 +8063,125 @@ window.__ModuleLoader__.load({
7925
8063
  }
7926
8064
  }
7927
8065
  //#endregion
8066
+ //#region src/mobile-customization-notice.ts
8067
+ /** Copy for the notice, per locale, kept beside the existing dictionaries. */
8068
+ const NOTICE_COPY = {
8069
+ en: {
8070
+ title: {
8071
+ authentication_required: "Device authentication is unavailable.",
8072
+ gateway_required: "Custom mobile CSS, scripts and extensions need the paired Mobile access entry.",
8073
+ other: "Mobile customization could not be loaded."
8074
+ },
8075
+ action: "Retry loading",
8076
+ dismiss: "Dismiss"
8077
+ },
8078
+ zh: {
8079
+ title: {
8080
+ authentication_required: "设备认证暂不可用。",
8081
+ gateway_required: "自定义移动样式、脚本与扩展需要通过已配对的移动访问入口打开。",
8082
+ other: "移动定制加载失败。"
8083
+ },
8084
+ action: "重试加载",
8085
+ dismiss: "忽略"
8086
+ },
8087
+ it: {
8088
+ title: {
8089
+ authentication_required: "Autenticazione del dispositivo non disponibile.",
8090
+ gateway_required: "CSS, script ed estensioni mobili richiedono l’accesso Mobile abbinato.",
8091
+ other: "Impossibile caricare la personalizzazione mobile."
8092
+ },
8093
+ action: "Riprova il caricamento",
8094
+ dismiss: "Ignora"
8095
+ }
8096
+ };
8097
+ const NOTICE_ATTRIBUTE = "data-dsh-mobile-customization-notice";
8098
+ const NOTICE_STYLES = `
8099
+ [${NOTICE_ATTRIBUTE}]{position:fixed;z-index:1000;left:8px;right:8px;bottom:calc(env(safe-area-inset-bottom) + 8px);display:flex;align-items:flex-start;gap:10px;box-sizing:border-box;padding:10px 12px;border:1px solid var(--dsw-alias-border-l3,#cfd5dd);border-radius:12px;background:var(--dsw-alias-bg-layer-2,#fff);box-shadow:0 8px 24px rgb(15 23 42 / 18%);font:13px/1.45 system-ui;color:var(--dsw-alias-label-primary,#16181d)}
8100
+ [${NOTICE_ATTRIBUTE}][hidden]{display:none}
8101
+ [${NOTICE_ATTRIBUTE}] .dshm-notice-text{flex:1 1 auto;min-width:0}
8102
+ [${NOTICE_ATTRIBUTE}] button{flex:none;box-sizing:border-box;min-height:44px;min-width:44px;padding:0 10px;border:1px solid transparent;border-radius:9px;background:transparent;color:var(--dsw-alias-label-primary,#16181d);font:600 13px/1.2 system-ui;cursor:pointer}
8103
+ [${NOTICE_ATTRIBUTE}] button.dshm-notice-action{background:var(--dsw-alias-state-business-primary,#2563eb);color:#fff;border-color:transparent}
8104
+ [${NOTICE_ATTRIBUTE}] button:hover{opacity:.9}
8105
+ [${NOTICE_ATTRIBUTE}]{max-height:40vh;overflow:auto;left:max(8px,env(safe-area-inset-left));right:max(8px,env(safe-area-inset-right));flex-wrap:wrap}
8106
+ [${NOTICE_ATTRIBUTE}] .dshm-notice-text{margin:0;flex-basis:100%}
8107
+ [${NOTICE_ATTRIBUTE}] button:focus-visible{outline:2px solid currentColor;outline-offset:2px}
8108
+ [${NOTICE_ATTRIBUTE}]{background-color:Canvas;background-image:linear-gradient(var(--dsw-alias-bg-layer-2,Canvas),var(--dsw-alias-bg-layer-2,Canvas));color:var(--dsw-alias-label-primary,CanvasText)}
8109
+ `;
8110
+ /** Copy key for a failure, so the notice and the console never disagree. */
8111
+ function titleKey(reason) {
8112
+ return reason === "authentication_required" || reason === "gateway_required" ? reason : "other";
8113
+ }
8114
+ /**
8115
+ * Mount one visible notice that follows the customization reporter's state.
8116
+ * @param document - the page that owns the notice.
8117
+ * @param options - locale lookup and the reconnect action.
8118
+ * @returns a disposer that removes the notice and its stylesheet.
8119
+ */
8120
+ function installCustomizationNotice(document, options) {
8121
+ if (!(typeof document.createElement === "function" && typeof document.body?.append === "function" && typeof document.head?.append === "function")) return () => {};
8122
+ const style = document.createElement("style");
8123
+ style.dataset.plugin = "dsh-mobile-notice";
8124
+ style.textContent = NOTICE_STYLES;
8125
+ document.head.append(style);
8126
+ const notice = document.createElement("div");
8127
+ if (typeof notice.setAttribute !== "function" || typeof notice.append !== "function") {
8128
+ style.remove();
8129
+ return () => {};
8130
+ }
8131
+ notice.setAttribute(NOTICE_ATTRIBUTE, "");
8132
+ notice.setAttribute("role", "status");
8133
+ notice.setAttribute("aria-live", "polite");
8134
+ notice.hidden = true;
8135
+ const text = document.createElement("p");
8136
+ text.className = "dshm-notice-text";
8137
+ const action = document.createElement("button");
8138
+ action.type = "button";
8139
+ action.className = "dshm-notice-action";
8140
+ const dismiss = document.createElement("button");
8141
+ dismiss.type = "button";
8142
+ notice.append(text, action, dismiss);
8143
+ document.body.append(notice);
8144
+ let current;
8145
+ let dismissed;
8146
+ const render = () => {
8147
+ const copy = NOTICE_COPY[options.locale()] ?? NOTICE_COPY.en;
8148
+ if (current === void 0 || current === dismissed) {
8149
+ notice.hidden = true;
8150
+ return;
8151
+ }
8152
+ const detail = options.message(current);
8153
+ text.textContent = `${copy.title[titleKey(current)]} ${detail}`;
8154
+ action.textContent = copy.action;
8155
+ dismiss.textContent = copy.dismiss;
8156
+ notice.hidden = false;
8157
+ };
8158
+ let disposed = false;
8159
+ const retry = () => {
8160
+ if (!disposed) options.reconnect();
8161
+ };
8162
+ const close = () => {
8163
+ if (disposed) return;
8164
+ dismissed = current;
8165
+ render();
8166
+ };
8167
+ action.addEventListener("click", retry);
8168
+ dismiss.addEventListener("click", close);
8169
+ const unsubscribe = options.subscribe((reason) => {
8170
+ if (reason !== current) dismissed = void 0;
8171
+ current = reason;
8172
+ render();
8173
+ });
8174
+ render();
8175
+ return () => {
8176
+ disposed = true;
8177
+ action.removeEventListener("click", retry);
8178
+ dismiss.removeEventListener("click", close);
8179
+ unsubscribe();
8180
+ notice.remove();
8181
+ style.remove();
8182
+ };
8183
+ }
8184
+ //#endregion
7928
8185
  //#region src/extension-limits.ts
7929
8186
  /** Maximum sizes accepted for legacy mobile customization resources. */
7930
8187
  const LEGACY_CUSTOM_ASSET_LIMIT = 262144;
@@ -7948,6 +8205,25 @@ window.__ModuleLoader__.load({
7948
8205
  function shouldRenewSession(connection, downForMs) {
7949
8206
  return isDisconnected(connection) && downForMs >= 5e3;
7950
8207
  }
8208
+ /**
8209
+ * Authentication rejection can warrant one renewal even while transport appears
8210
+ * connected. A 403 can also indicate a permission failure, so it is not proof
8211
+ * that the Session expired.
8212
+ *
8213
+ * Issue #298: DSH keeps Sessions in memory, so a Host restart kills every phone
8214
+ * Session while the device credential survives. The live WebSocket can keep
8215
+ * reporting `connected` for a while, so waiting for a disconnect never fires and
8216
+ * the customization channel stays dark. A 401/403 on an authenticated gateway
8217
+ * request is the signal that actually arrives, and it must be able to start the
8218
+ * same renewal the disconnect path uses.
8219
+ * @param status - HTTP status of a rejected gateway request.
8220
+ * @param connection - current public connection state, if known.
8221
+ * @returns whether this rejection should request a renewal.
8222
+ */
8223
+ function shouldRenewAfterRejection(status, connection) {
8224
+ if (status !== 401 && status !== 403) return false;
8225
+ return !isDisconnected(connection) || connection === void 0;
8226
+ }
7951
8227
  function isDisconnected(connection) {
7952
8228
  return connection === "connecting" || connection === "disconnected";
7953
8229
  }
@@ -7956,9 +8232,12 @@ window.__ModuleLoader__.load({
7956
8232
  * @param getConnection - Resolve the current service to reject a replaced owner.
7957
8233
  * @param page - The document's window and advertised native adapter.
7958
8234
  * @param document - The mounted document; page replacement withdraws its work.
8235
+ * @param onRejected - receives the rejection signal, so a caller that observes a
8236
+ * 401 can ask this same owner for one renewal instead of starting its own.
8237
+ * @param onRenewed - refresh read-only customization after confirmed Cookie installation.
7959
8238
  * @returns An awaited disposer, or nothing when no public connection is present.
7960
8239
  */
7961
- function installMobileConnectionRecovery(getConnection, page, document) {
8240
+ function installMobileConnectionRecovery(getConnection, page, document, onRejected, onRenewed) {
7962
8241
  const connection = getConnection();
7963
8242
  const state = connection?.state;
7964
8243
  if (connection === void 0 || state === void 0 || typeof connection.reconnect !== "function") return;
@@ -7968,6 +8247,8 @@ window.__ModuleLoader__.load({
7968
8247
  let currentState = state.getSnapshot();
7969
8248
  let downSince = isDisconnected(currentState) ? Date.now() : void 0;
7970
8249
  let attempted = false;
8250
+ let credentialsRejected = false;
8251
+ let rejectedSince;
7971
8252
  let bridge = page.__DSH_MOBILE_NATIVE__;
7972
8253
  let pending;
7973
8254
  let pendingAbort;
@@ -7981,8 +8262,13 @@ window.__ModuleLoader__.load({
7981
8262
  const next = state.getSnapshot();
7982
8263
  if (!isDisconnected(next)) {
7983
8264
  downSince = void 0;
7984
- attempted = false;
7985
- if (currentState !== next) cancelPending();
8265
+ if (currentState !== next) {
8266
+ if (next === "connected") {
8267
+ rejectedSince = void 0;
8268
+ attempted = false;
8269
+ }
8270
+ cancelPending();
8271
+ }
7986
8272
  } else if (downSince === void 0) {
7987
8273
  downSince = Date.now();
7988
8274
  attempted = false;
@@ -7999,30 +8285,63 @@ window.__ModuleLoader__.load({
7999
8285
  const renew = () => {
8000
8286
  if (!current()) {
8001
8287
  dispose();
8002
- return;
8288
+ return false;
8003
8289
  }
8004
8290
  updateState();
8005
8291
  updateBridge();
8006
8292
  const adapter = bridge;
8007
- if (document.visibilityState !== "visible" || adapter === void 0 || pending !== void 0 || attempted || downSince === void 0 || !shouldRenewSession(currentState, Date.now() - downSince)) return;
8293
+ const downLongEnough = downSince !== void 0 && shouldRenewSession(currentState, Date.now() - downSince);
8294
+ const rejectionPending = rejectedSince !== void 0;
8295
+ if (document.visibilityState !== "visible" || adapter === void 0 || pending !== void 0 || attempted || credentialsRejected || !downLongEnough && !rejectionPending) return false;
8008
8296
  attempted = true;
8009
8297
  const owner = generation;
8010
8298
  const abort = new AbortController();
8011
8299
  pendingAbort = abort;
8012
- const owns = () => current() && !abort.signal.aborted && generation === owner && page.__DSH_MOBILE_NATIVE__ === adapter && isDisconnected(state.getSnapshot());
8300
+ const owns = () => current() && !abort.signal.aborted && generation === owner && page.__DSH_MOBILE_NATIVE__ === adapter;
8013
8301
  const task = Promise.resolve().then(async () => {
8014
8302
  if (!owns()) return;
8015
8303
  const capabilities = await adapter.capabilities();
8016
8304
  if (!owns() || !capabilities.includes("auth.renew")) return;
8017
8305
  const result = await adapter.invoke("auth.renew", {}, { signal: abort.signal });
8018
8306
  if (!owns() || typeof result !== "object" || result === null || !("renewed" in result) || result.renewed !== true) return;
8307
+ rejectedSince = void 0;
8019
8308
  connection.reconnect?.();
8020
- }).catch((_error) => {}).finally(() => {
8309
+ if (current()) onRenewed?.();
8310
+ }).catch((error) => {
8311
+ if (!owns() || typeof error !== "object" || error === null || !("code" in error)) return;
8312
+ credentialsRejected = error.code === "device_revoked" || error.code === "device_expired" || error.code === "authentication_failed";
8313
+ }).finally(() => {
8021
8314
  if (pending !== task) return;
8022
8315
  pending = void 0;
8023
8316
  pendingAbort = void 0;
8024
8317
  });
8025
8318
  pending = task;
8319
+ return true;
8320
+ };
8321
+ const retry = () => {
8322
+ if (!current()) return false;
8323
+ updateState();
8324
+ updateBridge();
8325
+ if (!current() || pending !== void 0 || credentialsRejected || bridge === void 0 || rejectedSince === void 0 && downSince === void 0) return false;
8326
+ attempted = false;
8327
+ return renew();
8328
+ };
8329
+ /**
8330
+ * Record a rejected authenticated request and try the same single renewal.
8331
+ *
8332
+ * Only an owner that is still current and has not already attempted may act, so
8333
+ * a burst of 401s cannot start parallel renewals, and a revoked device cannot
8334
+ * keep retrying: revocation arrives as 401 too, but the native bridge refuses
8335
+ * `auth.renew` and the attempt is not restarted.
8336
+ * @param status - HTTP status the gateway returned.
8337
+ */
8338
+ const rejected = (status) => {
8339
+ if (!current()) return;
8340
+ updateState();
8341
+ updateBridge();
8342
+ if (!shouldRenewAfterRejection(status, state.getSnapshot())) return;
8343
+ rejectedSince ??= Date.now();
8344
+ renew();
8026
8345
  };
8027
8346
  const resume = () => {
8028
8347
  if (!current()) {
@@ -8054,6 +8373,7 @@ window.__ModuleLoader__.load({
8054
8373
  document.addEventListener("visibilitychange", resume);
8055
8374
  page.addEventListener("dsh-mobile-native-ready", updateBridge);
8056
8375
  page.addEventListener("pagehide", pagehide);
8376
+ onRejected?.(rejected, retry);
8057
8377
  return dispose;
8058
8378
  }
8059
8379
  //#endregion
@@ -9165,7 +9485,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
9165
9485
  //#region src/client.ts
9166
9486
  /** Map a host-reported provider onto the UI union, defaulting to the built-in one. */
9167
9487
  function parseMobileRemoteProvider(value) {
9168
- return value === "cpolar" || value === "cloudflared" || value === "frp" || value === "origin" ? value : "tailscale";
9488
+ return value === "cpolar" || value === "cloudflared" || value === "frp" || value === "origin" || value === "iroh" ? value : "tailscale";
9169
9489
  }
9170
9490
  /**
9171
9491
  * Set a surface's visibility; opening a page or overlay brings it to the front.
@@ -9711,6 +10031,23 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
9711
10031
  frp_discovery_invalid: "frpDiscoveryInvalid",
9712
10032
  frp_stopped: "frpStopped",
9713
10033
  frp_exited: "frpExited",
10034
+ iroh_companion_missing: "irohCompanionMissing",
10035
+ iroh_data_directory_invalid: "irohDataDirectoryInvalid",
10036
+ iroh_sidecar_invalid: "irohSidecarInvalid",
10037
+ iroh_download_failed: "irohDownloadFailed",
10038
+ iroh_download_size_mismatch: "irohDownloadFailed",
10039
+ iroh_download_hash_mismatch: "irohSidecarInvalid",
10040
+ iroh_download_timeout: "irohTimeout",
10041
+ iroh_platform_unsupported: "irohPlatformUnsupported",
10042
+ iroh_identity_failed: "irohIdentityFailed",
10043
+ iroh_identity_timeout: "irohIdentityFailed",
10044
+ iroh_identity_invalid: "irohIdentityFailed",
10045
+ iroh_launch_failed: "irohLaunchFailed",
10046
+ iroh_start_timeout: "irohTimeout",
10047
+ iroh_start_failed: "irohLaunchFailed",
10048
+ iroh_exited: "irohExited",
10049
+ iroh_not_ready: "irohNotReady",
10050
+ iroh_pairing_failed: "irohPairingFailed",
9714
10051
  frp_server_address_invalid: "frpServerAddressInvalid",
9715
10052
  frp_server_port_invalid: "frpServerPortInvalid",
9716
10053
  frp_token_invalid: "frpTokenInvalid",
@@ -9822,7 +10159,8 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
9822
10159
  cpolar: "cpolar",
9823
10160
  cloudflared: "cloudflared",
9824
10161
  frp: t("frpName"),
9825
- origin: t("originName")
10162
+ origin: t("originName"),
10163
+ iroh: t("irohName")
9826
10164
  };
9827
10165
  const providerId = typeof facts.provider === "string" ? facts.provider : "";
9828
10166
  const controllerCode = typeof facts.controllerCode === "string" ? facts.controllerCode : "";
@@ -10355,7 +10693,19 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10355
10693
  originChoiceDescription.textContent = t("originDescription");
10356
10694
  originChoiceTop.append(originChoiceName);
10357
10695
  originChoice.append(originChoiceTop, originChoiceDescription);
10358
- selfHostedBody.append(frpChoice, originChoice);
10696
+ const irohChoice = element("button", "dsh-mobile-control__provider is-iroh");
10697
+ irohChoice.type = "button";
10698
+ irohChoice.setAttribute("aria-pressed", "false");
10699
+ const irohChoiceTop = element("span", "dsh-mobile-control__provider-top");
10700
+ const irohChoiceName = element("strong");
10701
+ irohChoiceName.textContent = t("irohName");
10702
+ const irohChoiceBadge = element("span", "dsh-mobile-control__provider-badge is-iroh");
10703
+ irohChoiceBadge.textContent = t("irohExperimental");
10704
+ const irohChoiceDescription = element("span", "dsh-mobile-control__provider-description");
10705
+ irohChoiceDescription.textContent = t("irohDescription");
10706
+ irohChoiceTop.append(irohChoiceName, irohChoiceBadge);
10707
+ irohChoice.append(irohChoiceTop, irohChoiceDescription);
10708
+ selfHostedBody.append(frpChoice, originChoice, irohChoice);
10359
10709
  selfHosted.append(selfHostedSummary, selfHostedBody);
10360
10710
  providerSection.append(providerHeading, providerInfo, providerChoices, selfHosted);
10361
10711
  const frpSetup = element("section", "dsh-mobile-control__frp-setup");
@@ -10839,7 +11189,77 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10839
11189
  providerSetupMeta.append(providerSetupName, remoteStateBadge);
10840
11190
  providerSetupHeader.append(providerSetupHeading, providerSetupMeta);
10841
11191
  const providerSetupBody = element("div", "dsh-mobile-control__provider-setup-body");
10842
- providerSetupBody.append(cpolarSetup, cloudflaredSetup, frpSetup, originSetup, tailscaleInfo);
11192
+ const irohSetup = element("section", "dsh-mobile-control__frp-setup");
11193
+ irohSetup.hidden = true;
11194
+ const irohSetupTitle = element("h3", "dsh-mobile-control__section-title");
11195
+ irohSetupTitle.textContent = t("prepareIroh");
11196
+ const irohPhaseNote = element("p", "dsh-mobile-control__component-note");
11197
+ irohPhaseNote.textContent = t("irohPhaseNote");
11198
+ const irohComponentStatus = element("p", "dsh-mobile-control__component-status");
11199
+ irohComponentStatus.textContent = t("checkingComponent");
11200
+ irohComponentStatus.setAttribute("role", "status");
11201
+ irohComponentStatus.setAttribute("aria-live", "polite");
11202
+ const irohInstall = element("button", "dsh-mobile-control__primary");
11203
+ irohInstall.type = "button";
11204
+ irohInstall.textContent = t("installOfficial");
11205
+ const irohInstallProgress = element("progress", "dsh-mobile-control__progress");
11206
+ irohInstallProgress.hidden = true;
11207
+ irohInstallProgress.setAttribute("aria-hidden", "true");
11208
+ const irohPairing = element("div", "dsh-mobile-control__ws-paths");
11209
+ const irohPairingTitle = element("p", "dsh-mobile-control__component-status");
11210
+ irohPairingTitle.textContent = t("irohPairingTitle");
11211
+ const irohPair = element("button", "dsh-mobile-control__primary");
11212
+ irohPair.type = "button";
11213
+ irohPair.textContent = t("generateIrohQr");
11214
+ const irohQr = element("div", "dsh-mobile-control__qr is-expandable");
11215
+ irohQr.hidden = true;
11216
+ const irohPairStatus = element("p", "dsh-mobile-control__component-status");
11217
+ const irohCopyDescriptor = element("button", "dsh-mobile-control__secondary");
11218
+ irohCopyDescriptor.type = "button";
11219
+ irohCopyDescriptor.textContent = t("copyPairLink");
11220
+ irohCopyDescriptor.hidden = true;
11221
+ irohPairing.append(irohPairingTitle, irohPair, irohQr, irohPairStatus, irohCopyDescriptor);
11222
+ const irohDetails = element("details", "dsh-mobile-control__details");
11223
+ const irohDetailsSummary = element("summary");
11224
+ irohDetailsSummary.textContent = t("componentDetails");
11225
+ const irohDetailsBody = element("div", "dsh-mobile-control__details-body");
11226
+ const irohDetailsText = element("p");
11227
+ irohDetailsText.textContent = t("irohComponentNote");
11228
+ const irohInstallNote = element("p");
11229
+ irohInstallNote.textContent = t("irohInstallNote");
11230
+ const irohStorage = element("code", "dsh-mobile-control__storage");
11231
+ irohStorage.textContent = t("pluginPrivateDirectory");
11232
+ const irohOfficial = element("a", "dsh-mobile-control__text-link");
11233
+ irohOfficial.href = "https://www.npmjs.com/package/dsh-mobile-iroh";
11234
+ irohOfficial.target = "_blank";
11235
+ irohOfficial.rel = "noopener noreferrer";
11236
+ irohOfficial.textContent = t("officialDownload");
11237
+ irohDetailsBody.append(irohDetailsText, irohInstallNote, irohStorage, irohOfficial);
11238
+ irohDetails.append(irohDetailsSummary, irohDetailsBody);
11239
+ irohSetup.append(irohSetupTitle, irohPhaseNote, irohComponentStatus, irohInstall, irohInstallProgress, irohPairing, irohDetails);
11240
+ providerSetupBody.append(cpolarSetup, cloudflaredSetup, frpSetup, originSetup, irohSetup, tailscaleInfo);
11241
+ const irohQrZoom = element("div", "dsh-mobile-control__qr-zoom");
11242
+ irohQrZoom.hidden = true;
11243
+ const irohQrZoomImage = element("img", "dsh-mobile-control__qr-zoom-image");
11244
+ irohQrZoomImage.alt = t("pairingQr");
11245
+ const irohQrZoomClose = element("button", "dsh-mobile-control__qr-zoom-close");
11246
+ irohQrZoomClose.type = "button";
11247
+ irohQrZoomClose.textContent = "×";
11248
+ irohQrZoomClose.setAttribute("aria-label", t("closeEnlargedQr"));
11249
+ const setIrohQrZoomOpen = (open) => {
11250
+ if (open) {
11251
+ irohQrZoom.setAttribute("role", "dialog");
11252
+ irohQrZoom.setAttribute("aria-modal", "true");
11253
+ irohQrZoom.setAttribute("aria-label", t("enlargeQr"));
11254
+ } else {
11255
+ irohQrZoom.removeAttribute("role");
11256
+ irohQrZoom.removeAttribute("aria-modal");
11257
+ irohQrZoom.removeAttribute("aria-label");
11258
+ }
11259
+ irohQrZoom.hidden = !open;
11260
+ };
11261
+ irohQrZoom.append(irohQrZoomImage, irohQrZoomClose);
11262
+ root.append(irohQrZoom);
10843
11263
  const remoteAccess = element("div", "dsh-mobile-control__access");
10844
11264
  remoteAccess.hidden = true;
10845
11265
  const remoteAccessLabel = element("span", "dsh-mobile-control__access-label");
@@ -10905,6 +11325,8 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10905
11325
  remotePairLink.hidden = true;
10906
11326
  let activeRemotePairUrl = "";
10907
11327
  let activeRemotePairExpiresAt = 0;
11328
+ let activeIrohDescriptor = "";
11329
+ let activeIrohQrSvg = "";
10908
11330
  remoteActions.append(remoteToggle, remoteLogin, remoteReconnect, remotePair, remoteCopyLink);
10909
11331
  const remoteManageRow = element("div", "dsh-mobile-control__manage-row");
10910
11332
  const remoteDevices = element("button", "dsh-mobile-control__manage");
@@ -11201,6 +11623,9 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11201
11623
  let latestRemoteState = {};
11202
11624
  let frpInstalled = false;
11203
11625
  let frpConfigured = false;
11626
+ let irohInstalled = false;
11627
+ /** True while a companion checksum verification is in flight; polls must not wipe its progress. */
11628
+ let irohVerifying = false;
11204
11629
  let frpLayoutInitialized = false;
11205
11630
  let frpDownloadSize = "14.0";
11206
11631
  let configuredFrpServer = "";
@@ -11522,6 +11947,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11522
11947
  const cloudflared = remoteProvider === "cloudflared";
11523
11948
  const frp = remoteProvider === "frp";
11524
11949
  const origin = remoteProvider === "origin";
11950
+ const iroh = remoteProvider === "iroh";
11525
11951
  const managedOrigin = origin && data.originMode === "managed";
11526
11952
  const tailscale = remoteProvider === "tailscale";
11527
11953
  tailscaleChoice.classList.toggle("is-selected", tailscale);
@@ -11537,17 +11963,20 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11537
11963
  cloudflaredChoice.tabIndex = cloudflared ? 0 : -1;
11538
11964
  frpChoice.setAttribute("aria-pressed", String(frp));
11539
11965
  originChoice.setAttribute("aria-pressed", String(origin));
11540
- providerSetupName.textContent = cpolar ? "cpolar" : cloudflared ? "cloudflared" : frp ? t("frpName") : origin ? t(managedOrigin ? "caddyName" : "originName") : "Tailscale Funnel";
11966
+ irohChoice.setAttribute("aria-pressed", String(iroh));
11967
+ providerSetupName.textContent = cpolar ? "cpolar" : cloudflared ? "cloudflared" : frp ? t("frpName") : origin ? t(managedOrigin ? "caddyName" : "originName") : iroh ? t("irohName") : "Tailscale Funnel";
11541
11968
  tailscaleChoice.disabled = remoteProviderBusy;
11542
11969
  cpolarChoice.disabled = remoteProviderBusy;
11543
11970
  cloudflaredChoice.disabled = remoteProviderBusy;
11544
11971
  frpChoice.disabled = remoteProviderBusy;
11545
11972
  originChoice.disabled = remoteProviderBusy;
11973
+ irohChoice.disabled = remoteProviderBusy;
11546
11974
  cpolarSetup.hidden = !cpolar;
11547
11975
  cloudflaredSetup.hidden = !cloudflared;
11548
11976
  frpSetup.hidden = !frp;
11549
11977
  originSetup.hidden = !origin;
11550
- if (frp || origin) selfHosted.open = true;
11978
+ irohSetup.hidden = !iroh;
11979
+ if (frp || origin || iroh) selfHosted.open = true;
11551
11980
  tailscaleInfo.hidden = !tailscale;
11552
11981
  remoteReset.textContent = tailscale ? t("resetRemoteLogin") : t("resetRemoteDevices");
11553
11982
  const providers = data.providers !== null && typeof data.providers === "object" ? data.providers : {};
@@ -11702,6 +12131,30 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11702
12131
  frpVpsGroup.open = !frpConfigured;
11703
12132
  frpLayoutInitialized = true;
11704
12133
  }
12134
+ const irohProvider = providers.iroh !== null && typeof providers.iroh === "object" ? providers.iroh : {};
12135
+ const irohComponent = irohProvider.component !== null && typeof irohProvider.component === "object" ? irohProvider.component : {};
12136
+ irohInstalled = irohComponent.installed === true;
12137
+ const irohSupported = irohComponent.supported !== false;
12138
+ const irohComponentError = typeof irohComponent.errorCode === "string" ? irohComponent.errorCode : "";
12139
+ const irohVersion = typeof irohComponent.version === "string" ? irohComponent.version : "";
12140
+ const irohDownloadBytes = typeof irohComponent.downloadBytes === "number" ? irohComponent.downloadBytes : 0;
12141
+ const irohStoragePath = typeof irohComponent.storagePath === "string" ? irohComponent.storagePath : `DSH Mobile ${t("pluginPrivateDirectory")}`;
12142
+ irohInstall.hidden = irohInstalled || !irohSupported;
12143
+ if (irohVerifying) {
12144
+ irohInstallProgress.hidden = false;
12145
+ irohInstall.disabled = true;
12146
+ irohInstall.textContent = t("installingIroh");
12147
+ irohInstall.setAttribute("aria-busy", "true");
12148
+ irohComponentStatus.textContent = t("installingIroh");
12149
+ } else {
12150
+ irohInstallProgress.hidden = true;
12151
+ irohInstall.setAttribute("aria-busy", "false");
12152
+ irohInstall.disabled = remoteProviderBusy;
12153
+ irohInstall.textContent = irohDownloadBytes > 0 ? t("installWithSize", { size: formatMegabytes(irohDownloadBytes) }) : t("installOfficial");
12154
+ irohComponentStatus.textContent = !irohSupported ? t("irohPlatformUnsupported") : irohInstalled ? t("installed") + (irohVersion === "" ? "" : ` · ${irohVersion}`) : irohComponentError === "irohSidecarInvalid" ? t("irohSidecarInvalid") : t("irohUnavailable");
12155
+ }
12156
+ irohStorage.textContent = irohStoragePath;
12157
+ irohStorage.title = irohStoragePath;
11705
12158
  const originProvider = providers.origin !== null && typeof providers.origin === "object" ? providers.origin : {};
11706
12159
  const originConfiguration = originProvider.configuration !== null && typeof originProvider.configuration === "object" ? originProvider.configuration : {};
11707
12160
  originConfigured = originConfiguration.configured === true;
@@ -11792,10 +12245,10 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11792
12245
  remoteStatus.classList.toggle("is-running", remoteReady);
11793
12246
  const labels = {
11794
12247
  off: t("remoteOff"),
11795
- unavailable: cpolar ? t("remoteUnavailableCpolar") : cloudflared ? t("remoteUnavailableCloudflared") : frp ? t("remoteUnavailableFrp") : origin ? t("originUnavailable") : t("remoteUnavailableTailscale"),
11796
- starting: cpolar ? t("remoteStartingCpolar") : cloudflared ? t("remoteStartingCloudflared") : frp ? t("remoteStartingFrp") : origin ? t("originStarting") : t("remoteStartingTailscale"),
12248
+ unavailable: cpolar ? t("remoteUnavailableCpolar") : cloudflared ? t("remoteUnavailableCloudflared") : frp ? t("remoteUnavailableFrp") : origin ? t("originUnavailable") : iroh ? t("irohUnavailable") : t("remoteUnavailableTailscale"),
12249
+ starting: cpolar ? t("remoteStartingCpolar") : cloudflared ? t("remoteStartingCloudflared") : frp ? t("remoteStartingFrp") : origin ? t("originStarting") : iroh ? t("irohStarting") : t("remoteStartingTailscale"),
11797
12250
  "needs-login": t("remoteNeedsLogin"),
11798
- connecting: cpolar ? t("remoteConnectingCpolar") : cloudflared ? t("remoteConnectingCloudflared") : frp ? t("remoteConnectingFrp") : t("remoteConnectingTailscale"),
12251
+ connecting: cpolar ? t("remoteConnectingCpolar") : cloudflared ? t("remoteConnectingCloudflared") : frp ? t("remoteConnectingFrp") : iroh ? t("irohConnecting") : t("remoteConnectingTailscale"),
11799
12252
  ready: origin ? t(managedOrigin ? "caddyVerified" : "originReady") : t("remoteReady"),
11800
12253
  error: t("remoteError")
11801
12254
  };
@@ -11810,7 +12263,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11810
12263
  remoteSetupRetry.disabled = remoteReconnectBusy;
11811
12264
  remoteToggle.textContent = remoteRunning ? t("disableRemote") : t("enableRemote");
11812
12265
  const originPrepared = data.originMode === "managed" ? caddySupported && caddyInstalled && caddyConfiguration.configured === true : originConfigured && !caddyModeManagedSelected;
11813
- const providerPrepared = cpolar ? cpolarInstalled && cpolarConfigured : cloudflared ? cloudflaredInstalled : frp ? frpInstalled && frpConfigured : origin ? originPrepared : true;
12266
+ const providerPrepared = cpolar ? cpolarInstalled && cpolarConfigured : cloudflared ? cloudflaredInstalled : frp ? frpInstalled && frpConfigured : origin ? originPrepared : iroh ? irohInstalled : true;
11814
12267
  remoteToggle.disabled = remoteProviderBusy || !providerPrepared && !(origin && remoteRunning);
11815
12268
  remoteLogin.hidden = !tailscale || state !== "needs-login" || remoteLoginUrl === "";
11816
12269
  remoteReconnect.hidden = needsFunnelSetup || state !== "error" && state !== "unavailable" || !providerPrepared;
@@ -11818,6 +12271,21 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11818
12271
  remotePair.disabled = !remoteReady || remoteProviderBusy;
11819
12272
  remoteCopyLink.disabled = !remoteReady || remoteProviderBusy;
11820
12273
  remoteDevices.disabled = !remoteReady || remoteProviderBusy;
12274
+ remotePair.hidden = iroh;
12275
+ remoteCopyLink.hidden = iroh;
12276
+ if (iroh) {
12277
+ remoteQr.hidden = true;
12278
+ remotePairLink.hidden = true;
12279
+ }
12280
+ irohPair.disabled = !remoteReady || !iroh || remoteProviderBusy;
12281
+ if ((!iroh || !remoteReady) && activeIrohDescriptor !== "") {
12282
+ activeIrohDescriptor = "";
12283
+ activeIrohQrSvg = "";
12284
+ showQr("", irohQr);
12285
+ setIrohQrZoomOpen(false);
12286
+ irohCopyDescriptor.hidden = true;
12287
+ irohPairStatus.textContent = "";
12288
+ }
11821
12289
  if (origin) {
11822
12290
  remoteReconnect.disabled = remoteProviderBusy || remoteReconnectBusy;
11823
12291
  remoteReset.disabled = remoteProviderBusy;
@@ -11882,7 +12350,8 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11882
12350
  cloudflaredChoice.disabled = true;
11883
12351
  frpChoice.disabled = true;
11884
12352
  originChoice.disabled = true;
11885
- remoteStatus.textContent = provider === "cpolar" ? t("switchingCpolar") : provider === "cloudflared" ? t("switchingCloudflared") : provider === "frp" ? t("switchingFrp") : provider === "origin" ? t("switchingOrigin") : t("switchingTailscale");
12353
+ irohChoice.disabled = true;
12354
+ remoteStatus.textContent = provider === "cpolar" ? t("switchingCpolar") : provider === "cloudflared" ? t("switchingCloudflared") : provider === "frp" ? t("switchingFrp") : provider === "origin" ? t("switchingOrigin") : provider === "iroh" ? t("switchingIroh") : t("switchingTailscale");
11886
12355
  controlRequestJson("/api/mobile-access/remote/provider", {
11887
12356
  method: "POST",
11888
12357
  body: JSON.stringify({ provider })
@@ -11909,6 +12378,111 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11909
12378
  originChoice.addEventListener("click", () => {
11910
12379
  chooseRemoteProvider("origin");
11911
12380
  });
12381
+ irohChoice.addEventListener("click", () => {
12382
+ chooseRemoteProvider("iroh");
12383
+ });
12384
+ irohInstall.addEventListener("click", () => {
12385
+ if (remoteProviderBusy) return;
12386
+ if (!window.confirm(t("irohInstallConfirm"))) return;
12387
+ remoteProviderBusy = true;
12388
+ irohVerifying = true;
12389
+ irohInstall.disabled = true;
12390
+ irohInstall.textContent = t("installingIroh");
12391
+ irohInstall.setAttribute("aria-busy", "true");
12392
+ irohInstallProgress.hidden = false;
12393
+ irohComponentStatus.textContent = t("installingIroh");
12394
+ remoteStatus.textContent = t("installingIroh");
12395
+ controlRequestJson("/api/mobile-access/remote/iroh/component/install", {
12396
+ method: "POST",
12397
+ body: JSON.stringify({ confirm: true })
12398
+ }, LONG_CONTROL_REQUEST_TIMEOUT_MS).then(renderRemote, (error) => {
12399
+ remoteFailureText = remoteFailureTextFor(error, "installFailed");
12400
+ remoteStatus.textContent = remoteFailureText;
12401
+ irohComponentStatus.textContent = remoteFailureText;
12402
+ }).finally(() => {
12403
+ remoteProviderBusy = false;
12404
+ irohVerifying = false;
12405
+ loadRemote();
12406
+ });
12407
+ });
12408
+ const openIrohQrZoom = () => {
12409
+ if (activeIrohQrSvg === "" || irohQr.hidden) return;
12410
+ irohQrZoomImage.src = `data:image/svg+xml;base64,${btoa(activeIrohQrSvg)}`;
12411
+ setIrohQrZoomOpen(true);
12412
+ irohQrZoomClose.focus();
12413
+ };
12414
+ const closeIrohQrZoom = () => {
12415
+ setIrohQrZoomOpen(false);
12416
+ if (!irohQr.hidden) irohQr.focus();
12417
+ };
12418
+ irohQr.setAttribute("role", "button");
12419
+ irohQr.setAttribute("tabindex", "0");
12420
+ irohQr.setAttribute("aria-label", t("enlargeQr"));
12421
+ irohQr.addEventListener("click", openIrohQrZoom);
12422
+ irohQr.addEventListener("keydown", (event) => {
12423
+ if (event.key !== "Enter" && event.key !== " ") return;
12424
+ event.preventDefault();
12425
+ openIrohQrZoom();
12426
+ });
12427
+ irohQrZoom.addEventListener("click", closeIrohQrZoom);
12428
+ irohQrZoomClose.addEventListener("click", closeIrohQrZoom);
12429
+ irohQrZoom.addEventListener("keydown", (event) => {
12430
+ if (event.key !== "Escape") return;
12431
+ closeIrohQrZoom();
12432
+ });
12433
+ const openIrohPairing = () => controlRequestJson("/api/mobile-access/remote/iroh/pairing/begin", {
12434
+ method: "POST",
12435
+ body: "{}"
12436
+ }).then(async (data) => {
12437
+ const record = data;
12438
+ const qrSvg = typeof record.qrSvg === "string" ? record.qrSvg : "";
12439
+ const code = typeof record.irohCode === "string" ? record.irohCode : "";
12440
+ showQr(qrSvg, irohQr);
12441
+ if (qrSvg === "" || code === "") {
12442
+ activeIrohQrSvg = "";
12443
+ irohPairStatus.textContent = t("keyGenerationFailed");
12444
+ return;
12445
+ }
12446
+ activeIrohQrSvg = qrSvg;
12447
+ const fields = [
12448
+ "version",
12449
+ "type",
12450
+ "endpointId",
12451
+ "origin",
12452
+ "localPort",
12453
+ "irohCode",
12454
+ "gatewayToken",
12455
+ "caCertificate",
12456
+ "instanceId",
12457
+ "expiresAt"
12458
+ ];
12459
+ const descriptor = {};
12460
+ for (const field of fields) descriptor[field] = record[field];
12461
+ activeIrohDescriptor = JSON.stringify(descriptor);
12462
+ irohCopyDescriptor.hidden = false;
12463
+ irohPairStatus.textContent = t("irohPairingReady", { code });
12464
+ });
12465
+ irohPair.addEventListener("click", () => {
12466
+ if (remoteProviderBusy) return;
12467
+ remoteProviderBusy = true;
12468
+ irohPair.disabled = true;
12469
+ irohPairStatus.textContent = t("loadingRemoteStatus");
12470
+ openIrohPairing().catch((error) => {
12471
+ remoteFailureText = remoteFailureTextFor(error, "requestFailed");
12472
+ irohPairStatus.textContent = remoteFailureText;
12473
+ }).finally(() => {
12474
+ remoteProviderBusy = false;
12475
+ loadRemote();
12476
+ });
12477
+ });
12478
+ irohCopyDescriptor.addEventListener("click", () => {
12479
+ if (activeIrohDescriptor === "") return;
12480
+ navigator.clipboard.writeText(activeIrohDescriptor).then(() => {
12481
+ irohPairStatus.textContent = t("linkCopied");
12482
+ }, () => {
12483
+ irohPairStatus.textContent = t("remoteQrReady");
12484
+ });
12485
+ });
11912
12486
  cloudflaredInstall.addEventListener("click", () => {
11913
12487
  if (remoteProviderBusy) return;
11914
12488
  if (!window.confirm(t("installConfirmCloudflared", { size: cloudflaredDownloadSize }))) return;
@@ -12901,7 +13475,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12901
13475
  remoteReconnect.disabled = true;
12902
13476
  remoteSetup.disabled = true;
12903
13477
  remoteSetupRetry.disabled = true;
12904
- remoteStatus.textContent = remoteProvider === "cpolar" ? t("reconnectingCpolar") : remoteProvider === "cloudflared" ? t("reconnectingCloudflared") : remoteProvider === "frp" ? t("reconnectingFrp") : remoteProvider === "origin" ? t("reconnectingOrigin") : t("reconnectingTailscale");
13478
+ remoteStatus.textContent = remoteProvider === "cpolar" ? t("reconnectingCpolar") : remoteProvider === "cloudflared" ? t("reconnectingCloudflared") : remoteProvider === "frp" ? t("reconnectingFrp") : remoteProvider === "origin" ? t("reconnectingOrigin") : remoteProvider === "iroh" ? t("reconnectingIroh") : t("reconnectingTailscale");
12905
13479
  controlRequestJson("/api/mobile-access/remote/reconnect", {
12906
13480
  method: "POST",
12907
13481
  body: "{}"
@@ -13022,7 +13596,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
13022
13596
  if (show) loadRemoteDevices();
13023
13597
  });
13024
13598
  remoteReset.addEventListener("click", () => {
13025
- const prompt = remoteProvider === "cpolar" ? t("resetCpolarConfirm") : remoteProvider === "cloudflared" ? t("resetCloudflaredConfirm") : remoteProvider === "frp" ? t("resetFrpConfirm") : remoteProvider === "origin" ? t("resetOriginConfirm") : t("resetTailscaleConfirm");
13599
+ const prompt = remoteProvider === "cpolar" ? t("resetCpolarConfirm") : remoteProvider === "cloudflared" ? t("resetCloudflaredConfirm") : remoteProvider === "frp" ? t("resetFrpConfirm") : remoteProvider === "origin" ? t("resetOriginConfirm") : remoteProvider === "iroh" ? t("resetIrohConfirm") : t("resetTailscaleConfirm");
13026
13600
  if (!window.confirm(prompt)) return;
13027
13601
  controlRequestJson("/api/mobile-access/remote/reset", {
13028
13602
  method: "POST",
@@ -13821,10 +14395,28 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
13821
14395
  });
13822
14396
  }
13823
14397
  };
13824
- /** Install authenticated mobile customization refresh, retaining loaded legacy assets through temporary failures. */
13825
- function installCustomAssets() {
14398
+ /**
14399
+ * Install authenticated customization refresh, retaining assets through temporary failures.
14400
+ * @param recovery - optional instance-owned authentication feedback and refresh registration.
14401
+ * @returns synchronous teardown for the customization UI and refresh admission.
14402
+ */
14403
+ function installCustomAssets(recovery) {
13826
14404
  const reporter = createMobileAssetLoadReporter(selectedMobileControlLocale);
14405
+ let refreshNow = () => {};
13827
14406
  const customizationOwner = ownsMobileCustomization(document.querySelector("meta[name=\"dsh-mobile-gateway\"][content=\"authenticated\"]") !== null, window.__DSH_MOBILE_FRONTEND__);
14407
+ const removeNotice = customizationOwner ? installCustomizationNotice(document, {
14408
+ locale: () => {
14409
+ const locale = selectedMobileControlLocale();
14410
+ return locale === "zh" || locale === "it" ? locale : "en";
14411
+ },
14412
+ reconnect: () => {
14413
+ const attempted = recovery?.retry() ?? false;
14414
+ refreshNow();
14415
+ return attempted;
14416
+ },
14417
+ subscribe: (listener) => reporter.subscribe(listener),
14418
+ message: (reason) => reporter.message(reason)
14419
+ }) : () => {};
13828
14420
  const legacyStyle = element("style");
13829
14421
  legacyStyle.dataset.plugin = "dsh-mobile-custom";
13830
14422
  document.head.append(legacyStyle);
@@ -13856,7 +14448,9 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
13856
14448
  if (reason === "authentication_required") events?.setEnabled(false);
13857
14449
  };
13858
14450
  const unavailable = (response, signal, fallback) => {
13859
- failed(response.status === 401 || response.status === 403 ? "authentication_required" : fallback, signal);
14451
+ const rejectedAuth = response.status === 401 || response.status === 403;
14452
+ failed(rejectedAuth ? "authentication_required" : fallback, signal);
14453
+ if (rejectedAuth && !disposed && !signal.aborted) recovery?.rejected(response.status);
13860
14454
  response.body?.cancel().catch((error) => {});
13861
14455
  };
13862
14456
  let expectedDefinitionId;
@@ -14416,6 +15010,10 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
14416
15010
  events?.setEnabled(true);
14417
15011
  }
14418
15012
  });
15013
+ refreshNow = () => {
15014
+ stopRefresh.refresh();
15015
+ };
15016
+ const unregisterRefresh = recovery?.registerRefresh(refreshNow);
14419
15017
  const stopEvents = startExtensionChangeStream(() => {
14420
15018
  stopRefresh.refresh();
14421
15019
  }, void 0, (payload) => {
@@ -14436,6 +15034,8 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
14436
15034
  events = stopEvents;
14437
15035
  return () => {
14438
15036
  disposed = true;
15037
+ unregisterRefresh?.();
15038
+ removeNotice();
14439
15039
  reporter.dispose();
14440
15040
  stopNativeBack();
14441
15041
  stopEvents();
@@ -14506,12 +15106,12 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
14506
15106
  /* The cloudflared setup section deliberately reuses this card, so
14507
15107
  .dsh-mobile-control__cloudflared-setup is a DOM hook for targeting rather than a
14508
15108
  style rule of its own. */
14509
- .dsh-mobile-control__cpolar-setup{margin:0 0 12px;padding:12px;border:1px solid var(--dsw-alias-border-l2,#dbe1e8);border-radius:13px;background:var(--dsw-alias-bg-layer-2,#fff)}.dsh-mobile-control__cpolar-setup[hidden],.dsh-mobile-control__cpolar-account[hidden],.dsh-mobile-control__details[hidden],.dsh-mobile-control__view.is-remote .dsh-mobile-control__actions[hidden],.dsh-mobile-control__danger[hidden]{display:none}.dsh-mobile-control__component-status,.dsh-mobile-control__component-note{margin:0 0 10px;color:var(--dsw-alias-label-secondary,#606873);font-size:11px;line-height:1.55}.dsh-mobile-control__cpolar-setup>.dsh-mobile-control__primary{width:100%;min-height:44px;padding:9px 12px;border-radius:10px;font:600 12px/1.3 system-ui;cursor:pointer}.dsh-mobile-control__cpolar-account{margin-top:10px}.dsh-mobile-control__link-row{display:flex;flex-wrap:wrap;gap:6px 12px;margin:0 0 10px}.dsh-mobile-control__text-link{color:var(--dsw-alias-label-primary-bluish,#2563eb);font-size:11px;text-decoration:none}.dsh-mobile-control__text-link:hover{text-decoration:underline}.dsh-mobile-control__token-label{display:flex;flex-direction:column;gap:5px;margin:0 0 8px;color:var(--dsw-alias-label-secondary,#606873);font-size:11px}.dsh-mobile-control__token{box-sizing:border-box;width:100%;min-height:44px;padding:9px 10px;border:1px solid var(--dsw-alias-border-l3,#cfd5dd);border-radius:10px;background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-bg-layer-2,#fff));color:var(--dsw-alias-label-primary,#16181d);font:16px/1.4 system-ui}.dsh-mobile-control__cpolar-connect{display:flex;align-items:center;justify-content:center;box-sizing:border-box;width:100%;min-height:44px;padding:10px 14px;border-radius:12px;font:650 13px/1.2 system-ui;cursor:pointer;transition:background-color 160ms ease,border-color 160ms ease,opacity 160ms ease}.dsh-mobile-control__cpolar-connect:hover:not(:disabled){border-color:#1d4ed8;background:#1d4ed8}.dsh-mobile-control__cpolar-connect:active:not(:disabled){border-color:#1e40af;background:#1e40af}.dsh-mobile-control__cpolar-connect:disabled{cursor:wait;opacity:.55}.dsh-mobile-control__details{margin:10px 0 0;border-top:1px solid var(--dsw-alias-border-l2,#e1e5eb);padding-top:9px}.dsh-mobile-control__details>summary{min-height:36px;color:var(--dsw-alias-label-secondary,#606873);font-size:11px;line-height:30px;cursor:pointer}.dsh-mobile-control__details-body{display:flex;flex-wrap:wrap;align-items:center;gap:7px 12px;padding:4px 0}.dsh-mobile-control__details-body p{flex:1 0 100%;margin:0;color:var(--dsw-alias-label-secondary,#606873);font-size:11px;line-height:1.5}.dsh-mobile-control__storage{display:block;flex:1 0 100%;max-width:100%;overflow:hidden;padding:7px 8px;border-radius:8px;background:var(--dsw-alias-bg-layer-1,#f3f5f8);color:var(--dsw-alias-label-secondary,#475569);font:11px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;text-overflow:ellipsis;white-space:nowrap}.dsh-mobile-control__danger{flex:1 0 100%;min-height:44px;margin-top:3px;padding:7px 10px;border:1px solid var(--dsw-alias-state-error-primary,#dc2626);border-radius:9px;background:transparent;color:var(--dsw-alias-label-primary,#dc2626);font:12px/1.3 system-ui;cursor:pointer}
15109
+ .dsh-mobile-control__cpolar-setup{margin:0 0 12px;padding:12px;border:1px solid var(--dsw-alias-border-l2,#dbe1e8);border-radius:13px;background:var(--dsw-alias-bg-layer-2,#fff)}.dsh-mobile-control__cpolar-setup[hidden],.dsh-mobile-control__cpolar-account[hidden],.dsh-mobile-control__details[hidden],.dsh-mobile-control__view.is-remote .dsh-mobile-control__actions[hidden],.dsh-mobile-control__danger[hidden]{display:none}.dsh-mobile-control__component-status,.dsh-mobile-control__component-note{margin:0 0 10px;color:var(--dsw-alias-label-secondary,#606873);font-size:11px;line-height:1.55}.dsh-mobile-control__progress{box-sizing:border-box;width:100%;height:6px;margin:0 0 10px}.dsh-mobile-control__progress[hidden]{display:none}.dsh-mobile-control__cpolar-setup>.dsh-mobile-control__primary{width:100%;min-height:44px;padding:9px 12px;border-radius:10px;font:600 12px/1.3 system-ui;cursor:pointer}.dsh-mobile-control__cpolar-account{margin-top:10px}.dsh-mobile-control__link-row{display:flex;flex-wrap:wrap;gap:6px 12px;margin:0 0 10px}.dsh-mobile-control__text-link{color:var(--dsw-alias-label-primary-bluish,#2563eb);font-size:11px;text-decoration:none}.dsh-mobile-control__text-link:hover{text-decoration:underline}.dsh-mobile-control__token-label{display:flex;flex-direction:column;gap:5px;margin:0 0 8px;color:var(--dsw-alias-label-secondary,#606873);font-size:11px}.dsh-mobile-control__token{box-sizing:border-box;width:100%;min-height:44px;padding:9px 10px;border:1px solid var(--dsw-alias-border-l3,#cfd5dd);border-radius:10px;background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-bg-layer-2,#fff));color:var(--dsw-alias-label-primary,#16181d);font:16px/1.4 system-ui}.dsh-mobile-control__cpolar-connect{display:flex;align-items:center;justify-content:center;box-sizing:border-box;width:100%;min-height:44px;padding:10px 14px;border-radius:12px;font:650 13px/1.2 system-ui;cursor:pointer;transition:background-color 160ms ease,border-color 160ms ease,opacity 160ms ease}.dsh-mobile-control__cpolar-connect:hover:not(:disabled){border-color:#1d4ed8;background:#1d4ed8}.dsh-mobile-control__cpolar-connect:active:not(:disabled){border-color:#1e40af;background:#1e40af}.dsh-mobile-control__cpolar-connect:disabled{cursor:wait;opacity:.55}.dsh-mobile-control__details{margin:10px 0 0;border-top:1px solid var(--dsw-alias-border-l2,#e1e5eb);padding-top:9px}.dsh-mobile-control__details>summary{min-height:36px;color:var(--dsw-alias-label-secondary,#606873);font-size:11px;line-height:30px;cursor:pointer}.dsh-mobile-control__details-body{display:flex;flex-wrap:wrap;align-items:center;gap:7px 12px;padding:4px 0}.dsh-mobile-control__details-body p{flex:1 0 100%;margin:0;color:var(--dsw-alias-label-secondary,#606873);font-size:11px;line-height:1.5}.dsh-mobile-control__storage{display:block;flex:1 0 100%;max-width:100%;overflow:hidden;padding:7px 8px;border-radius:8px;background:var(--dsw-alias-bg-layer-1,#f3f5f8);color:var(--dsw-alias-label-secondary,#475569);font:11px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;text-overflow:ellipsis;white-space:nowrap}.dsh-mobile-control__danger{flex:1 0 100%;min-height:44px;margin-top:3px;padding:7px 10px;border:1px solid var(--dsw-alias-state-error-primary,#dc2626);border-radius:9px;background:transparent;color:var(--dsw-alias-label-primary,#dc2626);font:12px/1.3 system-ui;cursor:pointer}
14510
15110
  .dsh-mobile-control__danger:hover:not(:disabled),.dsh-mobile-control__device-revoke:hover{background:var(--dsw-alias-interactive-bg-hover-danger,rgb(220 38 38 / 8%))}
14511
15111
  .dsh-mobile-control__frp-setup{margin:0;padding:12px;border:1px solid var(--dsw-alias-border-l2,#dbe1e8);border-radius:13px;background:var(--dsw-alias-bg-layer-2,#fff)}.dsh-mobile-control__frp-setup[hidden]{display:none}.dsh-mobile-control__frp-step{padding:11px 0}.dsh-mobile-control__frp-step + .dsh-mobile-control__frp-step{border-top:1px solid var(--dsw-alias-border-l2,#e1e5eb)}.dsh-mobile-control__frp-step>strong{display:block;margin-bottom:3px;font-size:12px;line-height:1.4}.dsh-mobile-control__frp-step>p{margin:0 0 9px;color:var(--dsw-alias-label-secondary,#606873);font-size:11px;line-height:1.5}.dsh-mobile-control__frp-changes{margin:0 0 9px;padding:0;list-style:none;color:var(--dsw-alias-label-secondary,#606873);font-size:11px;line-height:1.5}.dsh-mobile-control__frp-changes li{position:relative;margin:0;padding-left:12px}.dsh-mobile-control__frp-changes li+li{margin-top:5px}.dsh-mobile-control__frp-changes li::before{content:"•";position:absolute;left:2px;color:var(--dsw-alias-label-tertiary,#98a1ad)}.dsh-mobile-control__frp-step>.dsh-mobile-control__frp-requirement{padding:8px 9px;border-radius:9px;background:var(--dsw-alias-bg-layer-1,#f3f5f8);color:var(--dsw-alias-label-primary,#384152);font-size:11px}.dsh-mobile-control__frp-fields{display:grid;grid-template-columns:minmax(0,1fr) 96px;gap:8px}.dsh-mobile-control__frp-fields>.dsh-mobile-control__field:nth-child(5),.dsh-mobile-control__frp-fields>.dsh-mobile-control__field:nth-child(6){grid-column:1/-1}.dsh-mobile-control__frp-fields>.dsh-mobile-control__field[hidden]{display:none}.dsh-mobile-control__frp-fields select{box-sizing:border-box;width:100%;min-height:44px;padding:9px 32px 9px 10px;border:1px solid var(--dsw-alias-border-l3,#cfd5dd);border-radius:10px;background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-primary,#16181d);font:14px/1.4 system-ui}.dsh-mobile-control__frp-fields select:disabled{cursor:not-allowed;opacity:.55}.dsh-mobile-control__frp-hint{margin:0;color:var(--dsw-alias-label-secondary,#606873);font-size:11px;line-height:1.5}.dsh-mobile-control__field{display:flex;min-width:0;flex-direction:column;gap:5px;color:var(--dsw-alias-label-secondary,#606873);font-size:11px}.dsh-mobile-control__field:nth-child(3),.dsh-mobile-control__field:nth-child(4){grid-column:1/-1}.dsh-mobile-control__field input{box-sizing:border-box;width:100%;min-height:44px;padding:9px 10px;border:1px solid var(--dsw-alias-border-l3,#cfd5dd);border-radius:10px;background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-primary,#16181d);font:16px/1.4 system-ui}.dsh-mobile-control__frp-action{box-sizing:border-box;width:100%;min-height:44px;padding:9px 12px;border-radius:10px;font:650 12px/1.3 system-ui;cursor:pointer}.dsh-mobile-control__frp-action:disabled{cursor:not-allowed;opacity:.5}.dsh-mobile-control__remote-workspace{margin:0;padding:12px;border:1px solid var(--dsw-alias-border-l2,#dbe1e8);border-radius:15px;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}.dsh-mobile-control__stage-header{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:9px}.dsh-mobile-control__stage-header .dsh-mobile-control__section-title{margin:0}.dsh-mobile-control__stage-meta{display:flex;min-width:0;align-items:center;justify-content:flex-end;gap:5px}.dsh-mobile-control__stage-value{max-width:115px;overflow:hidden;color:var(--dsw-alias-label-primary,#16181d);font:650 11px/1.3 system-ui;text-overflow:ellipsis;white-space:nowrap}.dsh-mobile-control__state-badge{flex:none;padding:3px 7px;border-radius:999px;background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-secondary,#606873);font:650 11px/1.25 system-ui}.dsh-mobile-control__state-badge.is-ready{background:var(--dsw-alias-state-success-tertiary,#e6f7f0);color:var(--dsw-alias-label-primary,#087454)}.dsh-mobile-control__state-badge.is-busy{background:var(--dsw-alias-state-business-tertiary,#e8f0ff);color:var(--dsw-alias-label-primary,#1d4ed8)}.dsh-mobile-control__state-badge.is-attention{background:var(--dsw-alias-state-warn-tertiary,#fff4dc);color:var(--dsw-alias-label-primary,#935100)}.dsh-mobile-control__remote-workspace>.dsh-mobile-control__status{box-sizing:border-box;margin:0 0 10px;padding:9px 10px;border-radius:10px;background:var(--dsw-alias-bg-layer-2,#fff);font-size:11px;line-height:1.45}.dsh-mobile-control__provider-setup-body{margin:0 0 10px}.dsh-mobile-control__provider-setup-body>.dsh-mobile-control__cpolar-setup{margin:0}.dsh-mobile-control__provider-setup-body>.dsh-mobile-control__cpolar-setup>.dsh-mobile-control__section-title,.dsh-mobile-control__provider-setup-body>.dsh-mobile-control__frp-setup>.dsh-mobile-control__section-title{display:none}.dsh-mobile-control__provider-setup-body>.dsh-mobile-control__details{margin:0;padding:9px 10px;border:1px solid var(--dsw-alias-border-l2,#dbe1e8);border-radius:11px;background:var(--dsw-alias-bg-layer-2,#fff)}.dsh-mobile-control__remote-workspace>.dsh-mobile-control__actions{margin-top:2px}.dsh-mobile-control__remote-workspace>.dsh-mobile-control__qr{margin:10px 0 0}.dsh-mobile-control__remote-workspace>.dsh-mobile-control__manage-row{margin-top:10px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2,#e1e5eb)}
14512
15112
  .dsh-mobile-control__frp-overview{display:grid;grid-template-columns:32px minmax(0,1fr);align-items:center;gap:10px;margin:0 0 10px;padding:10px;border:1px solid var(--dsw-alias-state-success-primary,#a9dfc9);border-radius:11px;background:var(--dsw-alias-state-success-tertiary,#edf9f4)}.dsh-mobile-control__frp-overview[hidden]{display:none}.dsh-mobile-control__frp-overview-mark{display:grid;width:32px;height:32px;place-items:center;border-radius:50%;background:#087454;color:#fff;font:700 15px/1 system-ui}.dsh-mobile-control__frp-overview-body{display:flex;min-width:0;flex-direction:column;gap:2px}.dsh-mobile-control__frp-overview-body strong{color:var(--dsw-alias-label-primary,#075d46);font-size:12px;line-height:1.35}.dsh-mobile-control__frp-overview-body span{overflow:hidden;color:var(--dsw-alias-label-secondary,#357061);font:11px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;text-overflow:ellipsis;white-space:nowrap}.dsh-mobile-control__frp-group{margin:8px 0 0;overflow:hidden;border:1px solid var(--dsw-alias-border-l2,#dbe1e8);border-radius:11px;background:var(--dsw-alias-bg-layer-1,#f8fafc)}.dsh-mobile-control__frp-group>summary{box-sizing:border-box;min-height:44px;padding:12px 34px 10px 12px;color:var(--dsw-alias-label-primary,#16181d);font:650 12px/1.4 system-ui;cursor:pointer}.dsh-mobile-control__frp-group[open]>summary{border-bottom:1px solid var(--dsw-alias-border-l2,#e1e5eb);background:var(--dsw-alias-bg-layer-2,#fff)}.dsh-mobile-control__frp-group>.dsh-mobile-control__frp-step{padding:12px}.dsh-mobile-control__frp-group>.dsh-mobile-control__frp-step>strong:first-child{display:none}.dsh-mobile-control__frp-group .dsh-mobile-control__frp-step{border-top:0}.dsh-mobile-control__frp-setup>.dsh-mobile-control__frp-step{margin-bottom:8px;padding:10px;border:1px solid var(--dsw-alias-border-l2,#dbe1e8);border-radius:11px;background:var(--dsw-alias-bg-layer-1,#f8fafc)}.dsh-mobile-control__tunnel{margin:0 0 10px}.dsh-mobile-control__tunnel[hidden]{display:none}
14513
15113
  .dsh-mobile-control__lan-setup{box-sizing:border-box;margin:0 0 12px;padding:14px;border:1px solid #6f96db;border-radius:15px;background:var(--dsw-alias-bg-layer-1,#f5f8ff)}.dsh-mobile-control__lan-setup[hidden],.dsh-mobile-control__lan-setup-form[hidden]{display:none}.dsh-mobile-control__lan-setup>.dsh-mobile-control__section-title{margin-bottom:5px}.dsh-mobile-control__lan-setup>.dsh-mobile-control__intro{margin-bottom:12px}.dsh-mobile-control__lan-setup-form{display:grid;gap:9px}.dsh-mobile-control__lan-setup .dsh-mobile-control__field select{box-sizing:border-box;width:100%;min-height:44px;padding:9px 32px 9px 10px;border:1px solid var(--dsw-alias-border-l3,#cfd5dd);border-radius:10px;background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-primary,#16181d);font:14px/1.4 system-ui}.dsh-mobile-control__lan-setup .dsh-mobile-control__component-note{margin:0}.dsh-mobile-control__lan-setup-actions{display:grid;grid-template-columns:1fr 1.35fr;gap:8px}.dsh-mobile-control__lan-setup-actions button{box-sizing:border-box;min-width:0;min-height:44px;padding:9px 10px;border-radius:10px;font:650 12px/1.3 system-ui;cursor:pointer}.dsh-mobile-control__lan-setup-actions button:disabled{cursor:wait;opacity:.55}.dsh-mobile-control__lan-setup>.dsh-mobile-control__status{margin:10px 0 0;line-height:1.5}.dsh-mobile-control__lan-setup>.dsh-mobile-control__status:empty{display:none}
14514
- .dsh-mobile-control__access{display:flex;align-items:baseline;gap:6px;min-width:0;margin:0 0 12px}.dsh-mobile-control__access[hidden]{display:none}.dsh-mobile-control__access-label{flex:none;color:var(--dsw-alias-label-secondary,#606873);white-space:nowrap}.dsh-mobile-control__access-label::after{content:":"}.dsh-mobile-control__access-link{min-width:0;overflow:hidden;color:var(--dsw-alias-label-primary-bluish,#2563eb);text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.dsh-mobile-control__access-link:hover{text-decoration:underline}.dsh-mobile-control__qr{display:flex;justify-content:center;margin:0 0 12px}.dsh-mobile-control__qr[hidden]{display:none}.dsh-mobile-control__qr img{border-radius:12px;background:#fff;padding:8px}
15114
+ .dsh-mobile-control__access{display:flex;align-items:baseline;gap:6px;min-width:0;margin:0 0 12px}.dsh-mobile-control__access[hidden]{display:none}.dsh-mobile-control__access-label{flex:none;color:var(--dsw-alias-label-secondary,#606873);white-space:nowrap}.dsh-mobile-control__access-label::after{content:":"}.dsh-mobile-control__access-link{min-width:0;overflow:hidden;color:var(--dsw-alias-label-primary-bluish,#2563eb);text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.dsh-mobile-control__access-link:hover{text-decoration:underline}.dsh-mobile-control__qr{display:flex;justify-content:center;margin:0 0 12px}.dsh-mobile-control__qr[hidden]{display:none}.dsh-mobile-control__qr img{border-radius:12px;background:#fff;padding:8px}.dsh-mobile-control__qr.is-expandable{cursor:zoom-in}.dsh-mobile-control__qr.is-expandable:focus-visible{outline:3px solid var(--dsw-alias-state-business-primary,#2563eb);outline-offset:2px;border-radius:12px}.dsh-mobile-control__qr-zoom{position:fixed;inset:0;z-index:1001;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.72)}.dsh-mobile-control__qr-zoom[hidden]{display:none}.dsh-mobile-control__qr-zoom-image{width:min(84vw,360px);height:min(84vw,360px);border-radius:12px;background:#fff;padding:12px}.dsh-mobile-control__qr-zoom-close{position:absolute;top:12px;right:16px;min-width:44px;min-height:44px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,#dbe1e8);background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-primary,#16181d);font:600 18px/1 system-ui;cursor:pointer}
14515
15115
  .dsh-mobile-control__status{margin:0 0 14px;overflow-wrap:anywhere;color:var(--dsw-alias-label-secondary,#606873)}.dsh-mobile-control__status::before{display:inline-block;width:8px;height:8px;margin-right:7px;border-radius:50%;background:var(--dsw-alias-label-tertiary,#98a1ad);content:""}.dsh-mobile-control__status.is-running::before{background:var(--dsw-alias-state-success-primary,#16a36a)}.dsh-mobile-control__status.is-key{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;word-break:break-all}
14516
15116
  .dsh-mobile-control__guide{margin:0 0 14px;padding:12px;border:1px solid #6f96db;border-radius:12px;background:var(--dsw-alias-bg-layer-1,#eff6ff)}.dsh-mobile-control__guide[hidden]{display:none}.dsh-mobile-control__guide-title{margin:0;color:var(--dsw-alias-label-primary,#172554);font:650 13px/1.45 system-ui}.dsh-mobile-control__guide-summary,.dsh-mobile-control__guide-note{margin:4px 0 0;color:var(--dsw-alias-label-secondary,#475569);font-size:12px;line-height:1.5}.dsh-mobile-control__guide-steps{margin:8px 0 0;padding-left:20px;color:var(--dsw-alias-label-primary,#1e293b);font-size:12px;line-height:1.6}.dsh-mobile-control__guide-note{color:var(--dsw-alias-label-secondary,#64748b)}.dsh-mobile-control__guide-actions{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px}.dsh-mobile-control__guide-actions button{min-width:0;min-height:44px;padding:8px;border-radius:10px;font:12px/1.25 system-ui;cursor:pointer}.dsh-mobile-control__guide-actions button:disabled{cursor:not-allowed;opacity:.45}
14517
15117
  .dsh-mobile-control__extensions{margin:0 0 12px;color:var(--dsw-alias-label-secondary,#606873);font-size:12px}
@@ -14538,6 +15138,9 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
14538
15138
  ` + EXTENSION_RECOVERY_STYLES;
14539
15139
  /** Mount the desktop control or mobile feature enhancements. */
14540
15140
  function apply(ctx) {
15141
+ let gatewayRejectionSink;
15142
+ let retryRecovery;
15143
+ let refreshCustomization;
14541
15144
  ctx.effect(() => installMobileUiLocalePersistence(document, window), "dsh-mobile: browser UI locale persistence");
14542
15145
  ctx.effect(() => {
14543
15146
  if (window.__DSH_MOBILE_FRONTEND__ !== "dedicated") return;
@@ -14546,7 +15149,26 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
14546
15149
  }, "dsh-mobile: authenticated gateway client trust");
14547
15150
  ctx.effect(() => {
14548
15151
  if (!ownsMobileCustomization(document.querySelector("meta[name=\"dsh-mobile-gateway\"][content=\"authenticated\"]") !== null, window.__DSH_MOBILE_FRONTEND__)) return;
14549
- return installMobileConnectionRecovery(() => ctx.get("connection"), window, document);
15152
+ let ownedRejection;
15153
+ const recovery = installMobileConnectionRecovery(() => ctx.get("connection"), window, document, (rejected, retry) => {
15154
+ ownedRejection = rejected;
15155
+ gatewayRejectionSink = rejected;
15156
+ retryRecovery = retry;
15157
+ }, () => {
15158
+ refreshCustomization?.();
15159
+ });
15160
+ if (recovery === void 0) {
15161
+ gatewayRejectionSink = void 0;
15162
+ retryRecovery = void 0;
15163
+ return;
15164
+ }
15165
+ return () => {
15166
+ if (gatewayRejectionSink === ownedRejection) {
15167
+ gatewayRejectionSink = void 0;
15168
+ retryRecovery = void 0;
15169
+ }
15170
+ return recovery();
15171
+ };
14550
15172
  }, "dsh-mobile: connection recovery");
14551
15173
  ctx.effect(() => {
14552
15174
  const desktopAdmin = isDesktopAdminSurface(location.hostname, location.search, window.__DSH_MOBILE_FRONTEND__, location.protocol);
@@ -14556,7 +15178,18 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
14556
15178
  style.textContent = desktopAdmin ? CONTROL_STYLES : NATIVE_MOBILE_STYLES;
14557
15179
  style.textContent += CLIENT_MODULE_STYLES;
14558
15180
  document.head.append(style);
14559
- const removeCustom = !desktopAdmin || gatewayCustomization ? installCustomAssets() : () => void 0;
15181
+ const removeCustom = !desktopAdmin || gatewayCustomization ? installCustomAssets({
15182
+ rejected: (status) => {
15183
+ gatewayRejectionSink?.(status);
15184
+ },
15185
+ retry: () => retryRecovery?.() ?? false,
15186
+ registerRefresh: (refresh) => {
15187
+ refreshCustomization = refresh;
15188
+ return () => {
15189
+ if (refreshCustomization === refresh) refreshCustomization = void 0;
15190
+ };
15191
+ }
15192
+ }) : () => void 0;
14560
15193
  const removeVoiceSession = installVoiceSession(!desktopAdmin);
14561
15194
  const removeDraftTransfer = !desktopAdmin && document.querySelector("meta[name=\"dsh-mobile-gateway\"][content=\"authenticated\"]") !== null ? installDraftTransfer(ctx, window, document) : () => void 0;
14562
15195
  const removeModuleSettings = ctx.slots.inject("settings.general.item", () => ctx.slots.register({