dsh-mobile 0.7.0 → 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
@@ -104,6 +104,11 @@ window.__ModuleLoader__.load({
104
104
  collapseMobileAccess: "Collapse Mobile access",
105
105
  downloadAndroid: "Download latest Android app",
106
106
  downloadAndroidVersion: "Download latest Android app · v{version}",
107
+ downloadAndroidIroh: "Download full APK (iroh)",
108
+ downloadAndroidIrohAria: "Download the full Android APK that includes iroh",
109
+ downloadAndroidIrohVersion: "Download full APK (iroh) · v{version}",
110
+ downloadAndroidVersionSize: "Download latest Android app · v{version} · {size} MB",
111
+ downloadAndroidIrohVersionSize: "Download full APK (iroh) · v{version} · {size} MB",
107
112
  downloadAndroidAria: "Download the latest Android app from GitHub Releases",
108
113
  downloadAndroidVersionAria: "Download Android app v{version} from GitHub Releases",
109
114
  updatePlugin: "Update plugin",
@@ -117,6 +122,7 @@ window.__ModuleLoader__.load({
117
122
  updateNow: "Update now",
118
123
  updateLater: "Not now",
119
124
  lan: "Local network",
125
+ lanChoiceDescription: "Same Wi-Fi or hotspot. Fastest path, no internet or account needed.",
120
126
  remote: "Remote",
121
127
  lanAccess: "Local network access",
122
128
  remoteAccess: "Remote access",
@@ -124,7 +130,7 @@ window.__ModuleLoader__.load({
124
130
  remoteAddress: "Remote address",
125
131
  loadingStatus: "Loading status…",
126
132
  loadingRemoteStatus: "Loading remote status…",
127
- generateCopyKey: "Generate and copy key",
133
+ generateCopyKey: "Generate pairing QR code",
128
134
  copyPairLink: "Copy pairing link",
129
135
  managePairedDevices: "Manage paired devices",
130
136
  clearAllDevices: "Clear all devices",
@@ -139,11 +145,11 @@ window.__ModuleLoader__.load({
139
145
  providerInfoAria: "View remote connection security and network information",
140
146
  providerGroupAria: "Remote connection method",
141
147
  providerSafeTitle: "You remain protected",
142
- providerSafeText: "Only paired devices can access DSH. cpolar is installed on demand and can be removed completely; Tailscale may be slow or unavailable on mainland China networks, where cpolar is recommended.",
148
+ providerSafeText: "Only paired devices can access DSH. On mainland China networks prefer iroh (peer-to-peer, no domain or account needed) or cpolar; Tailscale may be slow or unavailable there. Every on-demand component can be removed completely.",
143
149
  builtIn: "Built in",
144
150
  mainlandPreferred: "Preferred in mainland China",
145
- tailscaleDescription: "Wider coverage; mainland China networks may be unstable. The first connection requires login and Funnel authorization.",
146
- cpolarDescription: "Installs the official component on demand and is suitable for mainland China networks.",
151
+ tailscaleDescription: "Built in, no install. Broad coverage; sign in to Tailscale and enable Funnel once. Mainland China networks may be unstable.",
152
+ cpolarDescription: "Works well on mainland China networks. Installs the official component on demand and gives you a temporary public address.",
147
153
  prepareCpolar: "Prepare cpolar",
148
154
  checkingComponent: "Checking component…",
149
155
  installOfficial: "Install official component",
@@ -174,13 +180,35 @@ window.__ModuleLoader__.load({
174
180
  disableRemote: "Disable remote access",
175
181
  continueLogin: "Continue login",
176
182
  reconnect: "Reconnect",
177
- generateRemoteQr: "Generate remote pairing QR code",
183
+ generateRemoteQr: "Generate pairing QR code",
178
184
  copyRemoteLink: "Copy remote pairing link",
179
185
  manageRemoteDevices: "Manage remote devices",
180
186
  resetRemoteLogin: "Sign out and clear remote login",
181
187
  resetRemoteDevices: "Disable and clear remote devices",
182
188
  lanOn: "Local network access is on.",
183
189
  lanOff: "Local network access is off.",
190
+ lanBadgeOn: "On",
191
+ lanBadgeOff: "Off",
192
+ qrGenerated: "LAN QR code generated. Scan it from the app.",
193
+ qrPlaceholder: "Tap “Generate pairing QR code” to show the pairing QR.",
194
+ irohQrPlaceholder: "Tap “Generate iroh pairing QR code” to show the QR.",
195
+ irohLinkCopied: "Pairing link copied. Paste or open it in the full Android app; browsers cannot use iroh.",
196
+ remoteQrPlaceholder: "Tap “Generate pairing QR code” to show the QR.",
197
+ remoteQrPlaceholderIdle: "Turn on remote access to generate the pairing QR.",
198
+ pairingExpires: "Pairing QR valid until {time}. Regenerating invalidates the old code.",
199
+ pairingExpired: "The pairing QR has expired. Generate a new one.",
200
+ cloudflaredEnableNeedsConfig: "Save the named-tunnel settings with “Save and connect” before turning on remote access.",
201
+ cloudflaredShowConfig: "Show settings",
202
+ cloudflaredHideConfig: "Hide settings",
203
+ cloudflaredEditConfig: "Edit",
204
+ cloudflaredSwitchModeDisconnect: "Switching tunnel type closes the current remote connection first. Continue?",
205
+ cloudflaredTunnelSaved: "Settings saved. Check the connection status above.",
206
+ purgeIroh: "Remove the downloaded iroh component",
207
+ irohPurgeConfirm: "Remove the iroh component downloaded by this plugin? Saved identity, pairings, companion packages and other programs are kept.",
208
+ irohComponentRunning: "Turn off iroh remote access before removing its component.",
209
+ purgingIroh: "Removing the iroh component…",
210
+ devicesLanTitle: "LAN devices",
211
+ devicesRemoteTitle: "Remote devices",
184
212
  enableLan: "Enable local network access",
185
213
  disableLan: "Disable local network access",
186
214
  extensionsLoaded: "Extensions: {loaded} loaded",
@@ -273,6 +301,11 @@ window.__ModuleLoader__.load({
273
301
  collapseMobileAccess: "Riduci Accesso mobile",
274
302
  downloadAndroid: "Scarica l’ultima app Android",
275
303
  downloadAndroidVersion: "Scarica l’ultima app Android · v{version}",
304
+ downloadAndroidIroh: "Scarica APK completa (iroh)",
305
+ downloadAndroidIrohAria: "Scarica l’APK Android completo con iroh",
306
+ downloadAndroidIrohVersion: "Scarica APK completa (iroh) · v{version}",
307
+ downloadAndroidVersionSize: "Scarica l’ultima app Android · v{version} · {size} MB",
308
+ downloadAndroidIrohVersionSize: "Scarica APK completa (iroh) · v{version} · {size} MB",
276
309
  downloadAndroidAria: "Scarica l’ultima app Android da GitHub Releases",
277
310
  downloadAndroidVersionAria: "Scarica l’app Android v{version} da GitHub Releases",
278
311
  updatePlugin: "Aggiorna plugin",
@@ -286,6 +319,7 @@ window.__ModuleLoader__.load({
286
319
  updateNow: "Aggiorna ora",
287
320
  updateLater: "Non ora",
288
321
  lan: "Rete locale",
322
+ lanChoiceDescription: "Stessa Wi-Fi o hotspot. Percorso più veloce, senza internet né account.",
289
323
  remote: "Remoto",
290
324
  lanAccess: "Accesso rete locale",
291
325
  remoteAccess: "Accesso remoto",
@@ -293,7 +327,7 @@ window.__ModuleLoader__.load({
293
327
  remoteAddress: "Indirizzo remoto",
294
328
  loadingStatus: "Lettura stato…",
295
329
  loadingRemoteStatus: "Lettura stato remoto…",
296
- generateCopyKey: "Genera e copia chiave",
330
+ generateCopyKey: "Genera codice QR di abbinamento",
297
331
  copyPairLink: "Copia link di abbinamento",
298
332
  managePairedDevices: "Gestisci dispositivi abbinati",
299
333
  clearAllDevices: "Rimuovi tutti i dispositivi",
@@ -308,11 +342,11 @@ window.__ModuleLoader__.load({
308
342
  providerInfoAria: "Informazioni su sicurezza e rete della connessione remota",
309
343
  providerGroupAria: "Metodo di connessione remota",
310
344
  providerSafeTitle: "La protezione resta attiva",
311
- providerSafeText: "Solo i dispositivi abbinati possono accedere a DSH. cpolar viene installato su richiesta e può essere rimosso completamente; Tailscale può essere lento o non disponibile nelle reti della Cina continentale, dove è consigliato cpolar.",
345
+ providerSafeText: "Solo i dispositivi abbinati possono accedere a DSH. Sulle reti della Cina continentale preferisci iroh (peer-to-peer, senza dominio o account) o cpolar; Tailscale può essere lento o non disponibile. Ogni componente installato su richiesta può essere rimosso completamente.",
312
346
  builtIn: "Integrato",
313
347
  mainlandPreferred: "Preferito in Cina continentale",
314
- tailscaleDescription: "Copertura più ampia; le reti della Cina continentale possono essere instabili. Il primo collegamento richiede accesso e autorizzazione Funnel.",
315
- cpolarDescription: "Installa su richiesta il componente ufficiale, adatto alle reti della Cina continentale.",
348
+ tailscaleDescription: "Integrato, nessuna installazione. Copertura ampia; accedi a Tailscale e abilita Funnel una volta. Le reti della Cina continentale possono essere instabili.",
349
+ cpolarDescription: "Adatto alle reti della Cina continentale. Installa il componente ufficiale su richiesta e fornisce un indirizzo pubblico temporaneo.",
316
350
  prepareCpolar: "Prepara cpolar",
317
351
  checkingComponent: "Controllo componente…",
318
352
  installOfficial: "Installa componente ufficiale",
@@ -343,13 +377,35 @@ window.__ModuleLoader__.load({
343
377
  disableRemote: "Disattiva accesso remoto",
344
378
  continueLogin: "Continua accesso",
345
379
  reconnect: "Riconnetti",
346
- generateRemoteQr: "Genera QR di abbinamento remoto",
380
+ generateRemoteQr: "Genera codice QR di abbinamento",
347
381
  copyRemoteLink: "Copia link di abbinamento remoto",
348
382
  manageRemoteDevices: "Gestisci dispositivi remoti",
349
383
  resetRemoteLogin: "Esci e rimuovi accesso remoto",
350
384
  resetRemoteDevices: "Disattiva e rimuovi dispositivi remoti",
351
385
  lanOn: "Accesso dalla rete locale attivo.",
352
386
  lanOff: "Accesso dalla rete locale disattivato.",
387
+ lanBadgeOn: "Attivo",
388
+ lanBadgeOff: "Disattivo",
389
+ qrGenerated: "Codice QR LAN generato. Scansionalo dall’app.",
390
+ qrPlaceholder: "Tocca «Genera codice QR di abbinamento» per mostrare il QR.",
391
+ irohQrPlaceholder: "Tocca «Genera codice QR iroh» per mostrare il QR.",
392
+ irohLinkCopied: "Link di abbinamento copiato. Incollalo o aprilo nell’app Android completa; i browser non supportano iroh.",
393
+ remoteQrPlaceholder: "Tocca «Genera codice QR di abbinamento» per mostrare il QR.",
394
+ remoteQrPlaceholderIdle: "Attiva l’accesso remoto per generare il QR di abbinamento.",
395
+ pairingExpires: "QR di abbinamento valido fino a {time}. Rigenerare invalida il vecchio codice.",
396
+ pairingExpired: "Il QR di abbinamento è scaduto. Generane uno nuovo.",
397
+ cloudflaredEnableNeedsConfig: "Salva le impostazioni del tunnel con nome («Salva e connetti») prima di attivare l’accesso remoto.",
398
+ cloudflaredShowConfig: "Mostra impostazioni",
399
+ cloudflaredHideConfig: "Nascondi impostazioni",
400
+ cloudflaredEditConfig: "Modifica",
401
+ cloudflaredSwitchModeDisconnect: "Cambiare tipo di tunnel chiude prima la connessione remota attuale. Continuare?",
402
+ cloudflaredTunnelSaved: "Impostazioni salvate. Controlla lo stato della connessione qui sopra.",
403
+ purgeIroh: "Rimuovi il componente iroh scaricato",
404
+ irohPurgeConfirm: "Rimuovere il componente iroh scaricato da questo plugin? Identità, abbinamenti, pacchetti esterni e altri programmi vengono conservati.",
405
+ irohComponentRunning: "Disattiva l’accesso remoto iroh prima di rimuovere il componente.",
406
+ purgingIroh: "Rimozione del componente iroh…",
407
+ devicesLanTitle: "Dispositivi rete locale",
408
+ devicesRemoteTitle: "Dispositivi remoti",
353
409
  enableLan: "Attiva accesso rete locale",
354
410
  disableLan: "Disattiva accesso rete locale",
355
411
  extensionsLoaded: "Estensioni: {loaded} caricate",
@@ -453,6 +509,11 @@ window.__ModuleLoader__.load({
453
509
  collapseMobileAccess: "收起移动访问",
454
510
  downloadAndroid: "下载最新 Android App",
455
511
  downloadAndroidVersion: "下载最新 Android App · v{version}",
512
+ downloadAndroidIroh: "下载完整版 APK(含 iroh)",
513
+ downloadAndroidIrohAria: "下载包含 iroh 的完整版 Android APK",
514
+ downloadAndroidIrohVersion: "下载完整版(含 iroh)· v{version}",
515
+ downloadAndroidVersionSize: "下载最新 Android App · v{version} · {size} MB",
516
+ downloadAndroidIrohVersionSize: "下载完整版(含 iroh)· v{version} · {size} MB",
456
517
  downloadAndroidAria: "从 GitHub Releases 下载最新版 Android App",
457
518
  downloadAndroidVersionAria: "从 GitHub Releases 下载 Android App v{version}",
458
519
  updatePlugin: "更新插件",
@@ -466,6 +527,7 @@ window.__ModuleLoader__.load({
466
527
  updateNow: "立即更新",
467
528
  updateLater: "暂不",
468
529
  lan: "局域网",
530
+ lanChoiceDescription: "同一 Wi-Fi 或热点下直连,最快,不需要外网或账号。",
469
531
  remote: "远程",
470
532
  lanAccess: "局域网访问",
471
533
  remoteAccess: "远程访问",
@@ -473,7 +535,7 @@ window.__ModuleLoader__.load({
473
535
  remoteAddress: "远程地址",
474
536
  loadingStatus: "正在读取状态…",
475
537
  loadingRemoteStatus: "正在读取远程状态…",
476
- generateCopyKey: "生成并复制密钥",
538
+ generateCopyKey: "生成配对二维码",
477
539
  copyPairLink: "复制配对链接",
478
540
  managePairedDevices: "管理配对设备",
479
541
  clearAllDevices: "清除所有设备",
@@ -488,11 +550,11 @@ window.__ModuleLoader__.load({
488
550
  providerInfoAria: "查看远程连接安全与网络说明",
489
551
  providerGroupAria: "远程连接方式",
490
552
  providerSafeTitle: "你始终可以放心",
491
- providerSafeText: "只有已配对设备能进入 DSH。cpolar 按需安装并可彻底清理;Tailscale 在中国大陆网络下可能连接缓慢、中断或无法使用,国内网络建议优先尝试 cpolar。",
553
+ providerSafeText: "只有已配对设备能进入 DSH。国内网络建议优先尝试 iroh(点对点直连,无需域名或账号)或 cpolar;Tailscale 在中国大陆网络下可能连接缓慢、中断或无法使用。所有按需安装的组件都可彻底清理。",
492
554
  builtIn: "内置",
493
555
  mainlandPreferred: "国内网络优先",
494
- tailscaleDescription: "覆盖更广;中国大陆网络可能不稳定,首次需登录并允许 Funnel。",
495
- cpolarDescription: "按需安装官方组件,适合国内网络环境。",
556
+ tailscaleDescription: "内置无需安装。覆盖广;首次需登录 Tailscale 并开启 Funnel。中国大陆网络可能不稳定。",
557
+ cpolarDescription: "国内网络友好。按需安装官方组件,提供临时公网地址。",
496
558
  prepareCpolar: "准备 cpolar",
497
559
  checkingComponent: "正在检查组件…",
498
560
  installOfficial: "安装官方组件",
@@ -523,13 +585,35 @@ window.__ModuleLoader__.load({
523
585
  disableRemote: "关闭远程访问",
524
586
  continueLogin: "继续登录",
525
587
  reconnect: "重新连接",
526
- generateRemoteQr: "生成远程配对二维码",
588
+ generateRemoteQr: "生成配对二维码",
527
589
  copyRemoteLink: "复制远程配对链接",
528
590
  manageRemoteDevices: "管理远程设备",
529
591
  resetRemoteLogin: "退出并清除远程登录",
530
592
  resetRemoteDevices: "关闭并清除远程设备",
531
593
  lanOn: "局域网访问已开启。",
532
594
  lanOff: "局域网访问已关闭。",
595
+ lanBadgeOn: "已开启",
596
+ lanBadgeOff: "已关闭",
597
+ qrGenerated: "局域网二维码已生成,用 App 扫描。",
598
+ qrPlaceholder: "点击「生成配对二维码」显示配对二维码。",
599
+ irohQrPlaceholder: "点击「生成 iroh 配对二维码」显示配对二维码。",
600
+ irohLinkCopied: "配对链接已复制。请在完整版 Android App 中粘贴或打开;浏览器无法使用 iroh。",
601
+ remoteQrPlaceholder: "点击「生成配对二维码」显示配对二维码。",
602
+ remoteQrPlaceholderIdle: "开启远程访问后可生成配对二维码。",
603
+ pairingExpires: "配对二维码有效期至 {time}。重新生成会使旧码失效。",
604
+ pairingExpired: "配对二维码已过期,请重新生成。",
605
+ cloudflaredEnableNeedsConfig: "请先保存命名隧道配置(点「保存并连接」)后再启用远程访问。",
606
+ cloudflaredShowConfig: "展开配置",
607
+ cloudflaredHideConfig: "收起配置",
608
+ cloudflaredEditConfig: "修改",
609
+ cloudflaredSwitchModeDisconnect: "切换快速隧道/命名隧道会先关闭当前远程连接,是否继续?",
610
+ cloudflaredTunnelSaved: "配置已保存,连接结果以上方状态为准。",
611
+ purgeIroh: "移除已下载的 iroh 组件",
612
+ irohPurgeConfirm: "移除本插件下载的 iroh 组件?已保存的身份、配对、外部伴随包和其他程序均会保留。",
613
+ irohComponentRunning: "请先关闭 iroh 远程访问,再移除组件。",
614
+ purgingIroh: "正在移除 iroh 组件…",
615
+ devicesLanTitle: "局域网设备",
616
+ devicesRemoteTitle: "远程设备",
533
617
  enableLan: "开启局域网访问",
534
618
  disableLan: "关闭局域网访问",
535
619
  extensionsLoaded: "扩展:{loaded} 个已加载",
@@ -755,7 +839,7 @@ window.__ModuleLoader__.load({
755
839
  selfHostedConnections: "Self-hosted connection",
756
840
  advanced: "Advanced",
757
841
  frpName: "Self-hosted FRP",
758
- frpDescription: "A restricted FRP channel for this DSH gateway only. You manage the VPS and public address.",
842
+ frpDescription: "Needs a server with a public IP. Opens one dedicated channel for this DSH gateway; you own the VPS and port.",
759
843
  prepareFrp: "Configure self-hosted FRP",
760
844
  frpStep1Title: "1 · Connection details",
761
845
  frpStep1Text: "Enter the VPS address, frps control port, shared token, and public HTTPS origin.",
@@ -845,7 +929,7 @@ window.__ModuleLoader__.load({
845
929
  selfHostedConnections: "Connessione autogestita",
846
930
  advanced: "Avanzato",
847
931
  frpName: "FRP autogestito",
848
- frpDescription: "Canale FRP limitato al solo gateway DSH. VPS e indirizzo pubblico restano sotto il tuo controllo.",
932
+ frpDescription: "Richiede un server con IP pubblico. Apre un canale dedicato per questo gateway DSH; VPS e porta restano tuoi.",
849
933
  prepareFrp: "Configura FRP autogestito",
850
934
  frpStep1Title: "1 · Dati di connessione",
851
935
  frpStep1Text: "Inserisci indirizzo VPS, porta di controllo frps, token condiviso e origine HTTPS pubblica.",
@@ -935,7 +1019,7 @@ window.__ModuleLoader__.load({
935
1019
  selfHostedConnections: "自建连接",
936
1020
  advanced: "高级",
937
1021
  frpName: "自建 FRP",
938
- frpDescription: "只为当前 DSH 网关建立单用途 FRP 通道,VPS 和公开入口由你掌控。",
1022
+ frpDescription: "需要一台有公网 IP 的服务器。只为当前 DSH 网关开一条专用通道,VPS 和端口由你掌控。",
939
1023
  prepareFrp: "配置自建 FRP",
940
1024
  frpStep1Title: "1 · 连接信息",
941
1025
  frpStep1Text: "填写中转服务器地址、frps 控制端口、共享 Token 和手机访问地址。没有域名时,可直接使用公网 IPv4。",
@@ -1031,12 +1115,13 @@ window.__ModuleLoader__.load({
1031
1115
  enlargeQr: "Enlarge the QR code",
1032
1116
  closeEnlargedQr: "Close the enlarged QR code",
1033
1117
  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.",
1118
+ irohDescription: "Peer-to-peer encrypted link with relay fallback. No domain, port or account needed; requires the full Android App.",
1035
1119
  prepareIroh: "Prepare iroh",
1036
1120
  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
1121
  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
1122
  irohPhaseNote: "Experimental connection. Requires the full Android App 0.7.0+ with iroh; the compatibility APK and mobile browsers cannot use it.",
1039
1123
  irohUnavailable: "iroh is not ready: no sidecar is installed yet. Install the official component below to download and verify it.",
1124
+ irohInstalledNotReady: "iroh is installed but not running. Turn on remote access to start it.",
1040
1125
  irohStarting: "Resolving the iroh endpoint identity…",
1041
1126
  irohConnecting: "Sidecar connected; starting the DSH authentication gateway…",
1042
1127
  irohCompanionMissing: "No verified iroh sidecar is available. Use Install official component below; a separate companion plugin is optional.",
@@ -1064,12 +1149,13 @@ window.__ModuleLoader__.load({
1064
1149
  enlargeQr: "Ingrandisci il codice QR",
1065
1150
  closeEnlargedQr: "Chiudi il codice QR ingrandito",
1066
1151
  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.",
1152
+ irohDescription: "Collegamento cifrato peer-to-peer con ripiego su relay. Nessun dominio, porta o account richiesto; richiede l’App Android completa.",
1068
1153
  prepareIroh: "Prepara iroh",
1069
1154
  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
1155
  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
1156
  irohPhaseNote: "Connessione sperimentale. Richiede l’App Android completa 0.7.0+ con iroh; APK compatibile e browser mobili non sono supportati.",
1072
1157
  irohUnavailable: "iroh non è pronto: nessun sidecar è ancora installato. Installa il componente ufficiale qui sotto per scaricarlo e verificarlo.",
1158
+ irohInstalledNotReady: "iroh è installato ma non in esecuzione. Attiva l’accesso remoto per avviarlo.",
1073
1159
  irohStarting: "Risoluzione dell’identità endpoint iroh…",
1074
1160
  irohConnecting: "Sidecar connesso; avvio del gateway di autenticazione DSH…",
1075
1161
  irohCompanionMissing: "Nessun sidecar iroh verificato disponibile. Usa Installa componente ufficiale qui sotto; il plugin complementare è facoltativo.",
@@ -1097,12 +1183,13 @@ window.__ModuleLoader__.load({
1097
1183
  enlargeQr: "放大二维码",
1098
1184
  closeEnlargedQr: "关闭放大的二维码",
1099
1185
  irohPairingReady: "iroh 配对码 {code} 已生效,有效期 5 分钟。请在 App 的 iroh 配对页扫码;再生成一个会替换当前这个。",
1100
- irohDescription: "加密直连,失败时走中继。不需要公网域名或隧道账号。",
1186
+ irohDescription: "点对点加密直连,跨网自动经中继。无需域名、端口或账号;需完整版 App。",
1101
1187
  prepareIroh: "准备 iroh",
1102
1188
  irohInstallNote: "点击安装会下载固定的 sidecar 发行版(dsh-mobile-iroh 0.1.0-alpha.1,Windows x64 约 15 MB,Linux x64 约 24 MB)到插件私有目录并校验 SHA-256;已安装的配套插件如有校验通过的副本会直接复用,不再下载。",
1103
1189
  irohComponentNote: "已安装的 sidecar 每次使用都会重新校验 SHA-256。它不随插件包发布;配套提供的副本只能通过移除配套插件来删除。",
1104
1190
  irohPhaseNote: "实验性连接。需使用含 iroh 的 Android App 0.7.0 及以上完整版;兼容版 APK 和手机浏览器不支持。",
1105
1191
  irohUnavailable: "iroh 未就绪:尚未安装 sidecar,点击下方「安装官方组件」下载并校验。",
1192
+ irohInstalledNotReady: "iroh 已安装但未运行,启用远程访问即可启动。",
1106
1193
  irohStarting: "正在解析 iroh 端点身份…",
1107
1194
  irohConnecting: "Sidecar 已连接,正在启动 DSH 认证网关…",
1108
1195
  irohCompanionMissing: "尚无校验通过的 iroh 组件,请点击下方「安装官方组件」;无需另装配套插件。",
@@ -1122,6 +1209,7 @@ window.__ModuleLoader__.load({
1122
1209
  });
1123
1210
  Object.assign(MOBILE_CONTROL_MESSAGES.en, {
1124
1211
  requestTimeout: "The operation timed out. Confirm that DSH is still running, then try again.",
1212
+ invalidResponse: "The server replied with a body that is not valid JSON. Confirm the plugin and DSH versions match, then reload the page.",
1125
1213
  diagnostics: "Diagnostics",
1126
1214
  openDiagnostics: "Open connection diagnostics",
1127
1215
  back: "Back",
@@ -1170,6 +1258,7 @@ window.__ModuleLoader__.load({
1170
1258
  });
1171
1259
  Object.assign(MOBILE_CONTROL_MESSAGES.it, {
1172
1260
  requestTimeout: "Operazione scaduta. Verifica che DSH sia ancora in esecuzione e riprova.",
1261
+ invalidResponse: "Il server ha restituito un contenuto che non è JSON valido. Verifica che le versioni del plugin e di DSH corrispondano, poi ricarica la pagina.",
1173
1262
  diagnostics: "Diagnostica",
1174
1263
  openDiagnostics: "Apri diagnostica connessione",
1175
1264
  back: "Indietro",
@@ -1218,6 +1307,7 @@ window.__ModuleLoader__.load({
1218
1307
  });
1219
1308
  Object.assign(MOBILE_CONTROL_MESSAGES.zh, {
1220
1309
  requestTimeout: "操作超时,请确认 DSH 仍在运行后重试。",
1310
+ invalidResponse: "服务端返回的内容不是有效的 JSON,请确认插件与 DSH 版本匹配后刷新页面。",
1221
1311
  diagnostics: "诊断",
1222
1312
  openDiagnostics: "打开连接诊断",
1223
1313
  back: "返回",
@@ -1351,11 +1441,11 @@ window.__ModuleLoader__.load({
1351
1441
  }
1352
1442
  };
1353
1443
  Object.assign(MOBILE_CONTROL_MESSAGES.en, {
1354
- selfHostedDescription: "Use your own VPS or HTTPS reverse proxy without a public tunnel service.",
1444
+ selfHostedDescription: "For your own server or domain. Does not depend on a third-party tunnel service.",
1355
1445
  originName: "Own reverse proxy",
1356
1446
  caddyName: "Managed Caddy",
1357
1447
  caddyModePreviewNote: "HTTPS form preview differs from the current connection.",
1358
- originDescription: "Use managed Caddy or your existing HTTPS reverse proxy with DSH Mobile’s private, authenticated backend.",
1448
+ originDescription: "Already have an HTTPS domain or reverse proxy (Caddy, Nginx)? Point it at the local DSH backend.",
1359
1449
  prepareOrigin: "Connect your HTTPS reverse proxy",
1360
1450
  originPublicOrigin: "Public HTTPS address",
1361
1451
  originListenHost: "Private listen IP (IPv4 / IPv6)",
@@ -1410,11 +1500,11 @@ window.__ModuleLoader__.load({
1410
1500
  originControllerUnavailable: "The reverse-proxy provider is not available in this session. Restart DSH and try again."
1411
1501
  });
1412
1502
  Object.assign(MOBILE_CONTROL_MESSAGES.it, {
1413
- selfHostedDescription: "Usa il tuo VPS o reverse proxy HTTPS senza un servizio di tunnel pubblico.",
1503
+ selfHostedDescription: "Per un tuo server o dominio. Non dipende da un servizio tunnel di terze parti.",
1414
1504
  originName: "Reverse proxy proprio",
1415
1505
  caddyName: "Caddy gestito",
1416
1506
  caddyModePreviewNote: "L’anteprima del modulo HTTPS differisce dalla connessione attuale.",
1417
- originDescription: "Usa Caddy gestito o il tuo reverse proxy HTTPS con il backend privato autenticato di DSH Mobile.",
1507
+ originDescription: "Hai già un dominio HTTPS o un reverse proxy (Caddy, Nginx)? Puntalo al backend DSH locale.",
1418
1508
  prepareOrigin: "Collega il tuo reverse proxy HTTPS",
1419
1509
  originPublicOrigin: "Indirizzo HTTPS pubblico",
1420
1510
  originListenHost: "IP privato di ascolto (IPv4 / IPv6)",
@@ -1469,11 +1559,11 @@ window.__ModuleLoader__.load({
1469
1559
  originControllerUnavailable: "Il provider reverse proxy non è disponibile in questa sessione. Riavvia DSH e riprova."
1470
1560
  });
1471
1561
  Object.assign(MOBILE_CONTROL_MESSAGES.zh, {
1472
- selfHostedDescription: "使用自己的 VPS 或 HTTPS 反向代理,不依赖公共隧道服务。",
1562
+ selfHostedDescription: "有自己的服务器或域名时用这里,不依赖第三方隧道服务。",
1473
1563
  originName: "自有反向代理",
1474
1564
  caddyName: "托管 Caddy",
1475
1565
  caddyModePreviewNote: "HTTPS 表单预览与当前连接不同。",
1476
- originDescription: "使用托管 Caddy 或已有 HTTPS 反向代理,接入 DSH Mobile 带认证的私有后端。",
1566
+ originDescription: "已有 HTTPS 域名或反向代理(Caddy、Nginx 等)?直接指向本机 DSH 后端。",
1477
1567
  prepareOrigin: "接入自有 HTTPS 反向代理",
1478
1568
  originPublicOrigin: "公网 HTTPS 地址",
1479
1569
  originListenHost: "私有监听 IP(IPv4 / IPv6)",
@@ -1529,13 +1619,13 @@ window.__ModuleLoader__.load({
1529
1619
  });
1530
1620
  Object.assign(MOBILE_CONTROL_MESSAGES.en, {
1531
1621
  cloudflaredBadge: "Account optional",
1532
- cloudflaredDescription: "Installs the official cloudflared client on demand. A quick tunnel needs no account and gives a temporary public address; a named tunnel uses a Cloudflare account token and keeps one hostname that survives restarts.",
1622
+ cloudflaredDescription: "Free temporary address without an account; sign in to Cloudflare for a fixed domain. Installs the official client on demand.",
1533
1623
  prepareCloudflared: "Prepare cloudflared",
1534
1624
  cloudflaredUnsupported: "Windows x64, Linux x64/arm64, and macOS x64/arm64 are supported.",
1535
1625
  cloudflaredNotInstalled: "Not installed. A fixed version is downloaded from the official cloudflared release page only after you click the button below.",
1536
1626
  cloudflaredReady: "Official component {version} is ready.",
1537
1627
  cloudflaredQuickNote: "Quick tunnel: no Cloudflare account is needed, but Cloudflare positions quick tunnels for testing — the address is random, rate-limited, and carries no uptime guarantee. Do not rely on one for access that must stay reachable.",
1538
- cloudflaredNamedNote: "Named tunnel: the hostname above stays the same across restarts and Cloudflare routes it to the local forward port. The connector token is stored only in the DSH Mobile private directory, is handed to cloudflared through its environment rather than the command line, and is never shown again once saved.",
1628
+ cloudflaredNamedNote: "Named tunnel: the saved hostname stays the same across restarts and Cloudflare routes it to the local forward port. The connector token is stored only in the DSH Mobile private directory, is handed to cloudflared through its environment rather than the command line, and is never shown again once saved.",
1539
1629
  cloudflaredComponentNote: "Downloaded from the official cloudflared release page and verified at a fixed version only after you choose Install. The client is started with automatic updates disabled. It does not add a system service, startup item, registry entry, or PATH entry.",
1540
1630
  installConfirmCloudflared: "Download and verify cloudflared {size} from the official release page and store it only in the DSH Mobile private directory? No system service, PATH/registry entry, or startup item is added.",
1541
1631
  installingCloudflared: "Installing the official cloudflared component. Keep DSH running until it completes.",
@@ -1597,13 +1687,13 @@ window.__ModuleLoader__.load({
1597
1687
  });
1598
1688
  Object.assign(MOBILE_CONTROL_MESSAGES.it, {
1599
1689
  cloudflaredBadge: "Account facoltativo",
1600
- cloudflaredDescription: "Installa su richiesta il client cloudflared ufficiale. Il tunnel rapido non richiede account e fornisce un indirizzo pubblico temporaneo; il tunnel con nome usa il token di un account Cloudflare e mantiene un hostname stabile tra i riavvii.",
1690
+ cloudflaredDescription: "Indirizzo temporaneo gratuito senza account; accedi a Cloudflare per un dominio fisso. Installa il client ufficiale su richiesta.",
1601
1691
  prepareCloudflared: "Prepara cloudflared",
1602
1692
  cloudflaredUnsupported: "Sono supportati Windows x64, Linux x64/arm64 e macOS x64/arm64.",
1603
1693
  cloudflaredNotInstalled: "Non installato. Una versione fissa viene scaricata dalla pagina ufficiale di cloudflared solo dopo aver premuto il pulsante.",
1604
1694
  cloudflaredReady: "Componente ufficiale {version} pronto.",
1605
1695
  cloudflaredQuickNote: "Tunnel rapido: non serve un account Cloudflare, ma Cloudflare li considera pensati per i test — l’indirizzo è casuale, soggetto a limiti di frequenza e senza garanzia di disponibilità. Non usarlo per un accesso che deve restare raggiungibile.",
1606
- cloudflaredNamedNote: "Tunnel con nome: l’hostname qui sopra resta lo stesso tra i riavvii e Cloudflare lo instrada verso la porta di inoltro locale. Il token del connettore è salvato solo nella directory privata DSH Mobile, viene passato a cloudflared tramite l’ambiente e non tramite la riga di comando, e dopo il salvataggio non viene più mostrato.",
1696
+ cloudflaredNamedNote: "Tunnel con nome: l’hostname salvato resta lo stesso tra i riavvii e Cloudflare lo instrada verso la porta di inoltro locale. Il token del connettore è salvato solo nella directory privata DSH Mobile, viene passato a cloudflared tramite l’ambiente e non tramite la riga di comando, e dopo il salvataggio non viene più mostrato.",
1607
1697
  cloudflaredComponentNote: "Viene scaricato dalla pagina ufficiale di cloudflared e verificato a una versione fissa solo dopo aver scelto Installa. Il client viene avviato con gli aggiornamenti automatici disattivati. Non aggiunge servizi di sistema, avvio automatico, voci di registro o PATH.",
1608
1698
  installConfirmCloudflared: "Scaricare e verificare cloudflared {size} dalla pagina ufficiale e salvarlo solo nella directory privata DSH Mobile? Non vengono aggiunti servizi, PATH/registro o avvio automatico.",
1609
1699
  installingCloudflared: "Installazione del componente cloudflared ufficiale. Mantieni DSH in esecuzione fino al termine.",
@@ -1665,13 +1755,13 @@ window.__ModuleLoader__.load({
1665
1755
  });
1666
1756
  Object.assign(MOBILE_CONTROL_MESSAGES.zh, {
1667
1757
  cloudflaredBadge: "账号可选",
1668
- cloudflaredDescription: "按需安装官方 cloudflared 客户端。快速隧道无需账号,提供临时公网地址;命名隧道使用 Cloudflare 账号令牌,地址固定、重启不变。",
1758
+ cloudflaredDescription: "免账号获得临时地址;登录 Cloudflare 可换固定域名。按需安装官方客户端。",
1669
1759
  prepareCloudflared: "准备 cloudflared",
1670
1760
  cloudflaredUnsupported: "支持 Windows x64、Linux x64/arm64 与 macOS x64/arm64。",
1671
1761
  cloudflaredNotInstalled: "尚未安装。只有点击下方按钮后,才会从 cloudflared 官方发布页下载固定版本。",
1672
1762
  cloudflaredReady: "官方组件 {version} 已就绪。",
1673
1763
  cloudflaredQuickNote: "快速隧道:无需 Cloudflare 账号,但官方定位是测试用途 —— 地址随机、有限流、无可用性保证。请勿用于必须长期可达的访问。",
1674
- cloudflaredNamedNote: "命名隧道:上面的公网域名重启后不变,由 Cloudflare 转发到本机转发端口。连接器令牌只保存在 DSH Mobile 私有目录,经环境变量而非命令行传给 cloudflared,保存后不再显示。",
1764
+ cloudflaredNamedNote: "命名隧道:保存的公网域名重启后不变,由 Cloudflare 转发到本机转发端口。连接器令牌只保存在 DSH Mobile 私有目录,经环境变量而非命令行传给 cloudflared,保存后不再显示。",
1675
1765
  cloudflaredComponentNote: "仅在你点击安装后从 cloudflared 官方发布页下载并校验固定版本,且启动时关闭自动更新。不会写入系统服务、开机启动、注册表或 PATH。",
1676
1766
  installConfirmCloudflared: "将从 cloudflared 官方发布页下载并校验固定版本({size}),仅保存到 DSH Mobile 私有目录。不会安装系统服务、写入 PATH/注册表或设置开机启动。是否继续?",
1677
1767
  installingCloudflared: "正在安装 cloudflared 官方组件。完成前请保持 DSH 运行。",
@@ -3544,6 +3634,163 @@ window.__ModuleLoader__.load({
3544
3634
  });
3545
3635
  }
3546
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
3547
3794
  //#region src/native-mobile.ts
3548
3795
  /**
3549
3796
  * Viewport below which the injected chrome becomes an overlay drawer: the
@@ -3590,10 +3837,30 @@ window.__ModuleLoader__.load({
3590
3837
  .dsh-mobile-settings_fontFamily > span { flex:1 1 100px; }
3591
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; }
3592
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; }
3593
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; }
3594
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; }
3595
3852
  /* The mobile row replaces the Host-backed font control, not the theme selector. */
3596
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; }
3597
3864
  @media ${NATIVE_MOBILE_OVERLAY_QUERY} {
3598
3865
  html.dsh-native-mobile-active,html.dsh-native-mobile-active body { width:100%; height:100%; overflow:hidden; }
3599
3866
  html.dsh-native-mobile-active { --dsh-mobile-motion-duration:200ms; --dsh-mobile-motion-ease:cubic-bezier(.22,1,.36,1); }
@@ -3649,6 +3916,8 @@ window.__ModuleLoader__.load({
3649
3916
  [data-dsh-mobile-sidebar][data-open="true"]:not(:has([data-dsh-mobile-settings])) [data-dsh-mobile-sidebar-root] > [class*="_regionArea"] { flex:1 0 50% !important; }
3650
3917
  [data-dsh-mobile-sidebar][data-open="true"] [data-dsh-mobile-sidebar-root] [class*="_logoRow"] { height:52px !important; padding:4px 0 4px 4px !important; margin-bottom:4px !important; }
3651
3918
  [data-dsh-mobile-sidebar][data-open="false"] [data-dsh-mobile-sidebar-root] { width:0 !important; border:0 !important; background:transparent !important; box-shadow:none !important; overflow:visible !important; }
3919
+ [data-dsh-mobile-sidebar][data-open="false"]:not(:has([data-dsh-mobile-settings])) [data-dsh-mobile-sidebar-root] { box-sizing:border-box !important; min-width:0 !important; padding:0 !important; pointer-events:none !important; }
3920
+ [data-dsh-mobile-sidebar][data-open="false"] [data-dsh-mobile-toggle] { pointer-events:auto !important; }
3652
3921
  [data-dsh-mobile-sidebar][data-open="false"] [data-dsh-mobile-sidebar-root] > :not(:has([data-dsh-mobile-toggle])) { display:none !important; }
3653
3922
  [data-dsh-mobile-sidebar][data-open="false"] [data-dsh-mobile-sidebar-root] > :has([data-dsh-mobile-toggle]) { position:fixed !important; z-index:244 !important; top:env(safe-area-inset-top) !important; left:0 !important; box-sizing:border-box !important; width:50px !important; height:52px !important; padding:4px !important; border:0 !important; background:transparent !important; }
3654
3923
  [data-dsh-mobile-sidebar][data-open="false"] [data-dsh-mobile-sidebar-root] > :has([data-dsh-mobile-toggle]) > :not([data-dsh-mobile-toggle]) { display:none !important; }
@@ -3777,14 +4046,6 @@ window.__ModuleLoader__.load({
3777
4046
  [data-dsh-mobile-center] [role="dialog"][aria-label*="Context"] :is(dt,dd) { white-space:nowrap !important; word-break:keep-all !important; }
3778
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; }
3779
4048
  .dsh-mobile-branch-toast[data-visible="true"],.dsh-mobile-media-toast[data-visible="true"] { opacity:1; transform:translate(-50%,0); }
3780
- /* Fallback only: when the native Add group has no item to clone, keep the
3781
- camera action on the same row metrics and tokens as the native menu. */
3782
- .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; }
3783
- .dsh-mobile-media-action:active { opacity:.72; }
3784
- .dsh-mobile-media-action:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary,#4c82f7); outline-offset:1px; }
3785
- .dsh-mobile-media-action:disabled { cursor:default; opacity:.38; }
3786
- .dsh-mobile-media-action svg { flex:none; width:16px; height:16px; color:var(--dsw-alias-label-tertiary,currentColor); }
3787
- .dsh-mobile-media-action span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
3788
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; }
3789
4050
  /* Collapse the scroll viewport only; the editor, draft and attachments stay
3790
4051
  owned by DSH. Toolbar focus keeps the card expanded during menu taps. */
@@ -3869,6 +4130,19 @@ window.__ModuleLoader__.load({
3869
4130
  return classToken(candidate, "_frame") && firstByClassSuffix(candidate, "_sidebarCol") !== void 0 && firstByClassSuffix(candidate, "_centerCol") !== void 0;
3870
4131
  });
3871
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
+ }
3872
4146
  /** Mark every mounted settings dialog so its mobile layout does not depend on the conversation shell. */
3873
4147
  function markNativeMobileSettings(root) {
3874
4148
  let marked = 0;
@@ -4794,7 +5068,7 @@ window.__ModuleLoader__.load({
4794
5068
  }
4795
5069
  })();
4796
5070
  for (const button of [cameraButton, galleryButton]) {
4797
- const className = nativeItem?.className ?? "dsh-mobile-media-action";
5071
+ const className = mediaActionClassName(nativeItem);
4798
5072
  if (button.className !== className) button.className = className;
4799
5073
  const icon = button.firstElementChild;
4800
5074
  const caption = button.lastElementChild;
@@ -5000,6 +5274,7 @@ window.__ModuleLoader__.load({
5000
5274
  document.addEventListener("focusin", onStripFocus, true);
5001
5275
  document.addEventListener("click", onStripClickCapture, true);
5002
5276
  document.addEventListener("click", onBranchClick, true);
5277
+ const sidebarScrollMemory = installMobileSidebarScrollMemory();
5003
5278
  let frame;
5004
5279
  let sidebar;
5005
5280
  let sidebarRoot;
@@ -5232,7 +5507,10 @@ window.__ModuleLoader__.load({
5232
5507
  const lastColumn = frame?.style.gridTemplateColumns.trim().split(/\s+/).at(-1);
5233
5508
  details.dataset.open = String(lastColumn !== void 0 && lastColumn !== "0px" && lastColumn !== "0");
5234
5509
  }
5235
- if (sidebar === void 0) return;
5510
+ if (sidebar === void 0) {
5511
+ sidebarScrollMemory.sync();
5512
+ return;
5513
+ }
5236
5514
  sidebar.dataset.dshMobileSidebar = "true";
5237
5515
  toggle = firstByClassSuffix(sidebar, "_toggle");
5238
5516
  let candidate = toggle?.parentElement;
@@ -5244,6 +5522,7 @@ window.__ModuleLoader__.load({
5244
5522
  if (toggle !== void 0) toggle.dataset.dshMobileToggle = "true";
5245
5523
  if (frame !== void 0) sidebar.dataset.open = String(!classToken(sidebarRoot, "_collapsed"));
5246
5524
  backdrop.hidden = frame === void 0 || !drawerScrimVisible(classToken(sidebarRoot, "_collapsed"), overlayQuery.matches);
5525
+ sidebarScrollMemory.sync();
5247
5526
  };
5248
5527
  const schedule = () => {
5249
5528
  if (scheduled === 0) scheduled = requestAnimationFrame(sync);
@@ -5308,6 +5587,7 @@ window.__ModuleLoader__.load({
5308
5587
  return () => {
5309
5588
  disposed = true;
5310
5589
  stopStockBack();
5590
+ sidebarScrollMemory.dispose();
5311
5591
  disposeTaskWatcher();
5312
5592
  mediaRequestGeneration += 1;
5313
5593
  mediaPickerAbortController.abort();
@@ -8414,6 +8694,779 @@ window.__ModuleLoader__.load({
8414
8694
  };
8415
8695
  }
8416
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
8417
9470
  //#region src/mobile-directory-browser.ts
8418
9471
  /** Expected Gateway or response-validation failure for one read-only listing. */
8419
9472
  var MobileDirectoryReadError = class extends Error {
@@ -9487,6 +10540,12 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
9487
10540
  function parseMobileRemoteProvider(value) {
9488
10541
  return value === "cpolar" || value === "cloudflared" || value === "frp" || value === "origin" || value === "iroh" ? value : "tailscale";
9489
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
+ ];
9490
10549
  /**
9491
10550
  * Set a surface's visibility; opening a page or overlay brings it to the front.
9492
10551
  * @param container - the registered surface element.
@@ -9833,11 +10892,15 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
9833
10892
  const androidVersion = releaseVersion(data.androidVersion);
9834
10893
  const expectedAndroidDownloadUrls = androidVersion === void 0 ? [] : ["v", "android-v"].map((prefix) => `${GITHUB_RELEASES_URL}/download/${prefix}${androidVersion}/dsh-mobile-android-v${androidVersion}.apk`);
9835
10894
  const androidDownloadUrl = typeof data.androidDownloadUrl === "string" && expectedAndroidDownloadUrls.includes(data.androidDownloadUrl) ? data.androidDownloadUrl : GITHUB_RELEASES_URL;
10895
+ const androidIrohDownloadUrl = androidVersion === void 0 || androidDownloadUrl === GITHUB_RELEASES_URL ? void 0 : `${GITHUB_RELEASES_URL}/download/android-v${androidVersion}/dsh-mobile-android-v${androidVersion}-iroh.apk`;
9836
10896
  return {
9837
10897
  updateAvailable: data.updateAvailable === true && latestVersion !== void 0,
9838
10898
  ...latestVersion === void 0 ? {} : { latestVersion },
9839
10899
  ...androidVersion === void 0 ? {} : { androidVersion },
9840
10900
  androidDownloadUrl,
10901
+ ...androidIrohDownloadUrl === void 0 ? {} : { androidIrohDownloadUrl },
10902
+ ...typeof data.androidCompatBytes === "number" && data.androidCompatBytes > 0 ? { androidCompatBytes: data.androidCompatBytes } : {},
10903
+ ...typeof data.androidIrohBytes === "number" && data.androidIrohBytes > 0 ? { androidIrohBytes: data.androidIrohBytes } : {},
9841
10904
  ...typeof data.releaseNotes === "string" && data.releaseNotes !== "" ? { releaseNotes: data.releaseNotes.slice(0, 4e3) } : {}
9842
10905
  };
9843
10906
  }
@@ -9858,7 +10921,18 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
9858
10921
  signal: controller.signal,
9859
10922
  headers: localAdminRequestHeaders(init, location)
9860
10923
  });
9861
- const body = await response.json();
10924
+ const text = await response.text();
10925
+ let body = {};
10926
+ if (text.trim() !== "") {
10927
+ let parsed;
10928
+ try {
10929
+ parsed = JSON.parse(text);
10930
+ } catch {
10931
+ throw new Error(!response.ok ? `HTTP ${String(response.status)} (${path})` : `${controlTranslator()("invalidResponse")} (${path})`);
10932
+ }
10933
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(!response.ok ? `HTTP ${String(response.status)} (${path})` : `${controlTranslator()("invalidResponse")} (${path})`);
10934
+ body = parsed;
10935
+ }
9862
10936
  if (!response.ok) throw new Error(typeof body.error === "string" ? body.error : `HTTP ${String(response.status)}`);
9863
10937
  return body;
9864
10938
  } catch (error) {
@@ -10048,6 +11122,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10048
11122
  iroh_exited: "irohExited",
10049
11123
  iroh_not_ready: "irohNotReady",
10050
11124
  iroh_pairing_failed: "irohPairingFailed",
11125
+ iroh_component_running: "irohComponentRunning",
10051
11126
  frp_server_address_invalid: "frpServerAddressInvalid",
10052
11127
  frp_server_port_invalid: "frpServerPortInvalid",
10053
11128
  frp_token_invalid: "frpTokenInvalid",
@@ -10261,17 +11336,31 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10261
11336
  close.type = "button";
10262
11337
  close.textContent = "×";
10263
11338
  close.setAttribute("aria-label", t("collapseMobileAccess"));
10264
- headerActions.append(updatePlugin, diagnosticsEntry, close);
11339
+ const devicesEntry = element("button", "dsh-mobile-control__diagnostic-entry");
11340
+ devicesEntry.type = "button";
11341
+ devicesEntry.textContent = t("device");
11342
+ devicesEntry.setAttribute("aria-label", t("managePairedDevices"));
11343
+ devicesEntry.setAttribute("aria-pressed", "false");
11344
+ headerActions.append(updatePlugin, diagnosticsEntry, devicesEntry, close);
10265
11345
  const releaseNotice = element("p", "dsh-mobile-control__release-notice");
10266
11346
  releaseNotice.hidden = true;
10267
11347
  releaseNotice.setAttribute("role", "status");
10268
11348
  releaseNotice.setAttribute("aria-live", "polite");
11349
+ const appDownloads = element("div", "dsh-mobile-control__app-downloads");
10269
11350
  const appDownload = element("a", "dsh-mobile-control__app-download");
10270
11351
  appDownload.href = GITHUB_RELEASES_URL;
10271
11352
  appDownload.target = "_blank";
10272
11353
  appDownload.rel = "noopener noreferrer";
10273
11354
  appDownload.textContent = t("downloadAndroid");
10274
11355
  appDownload.setAttribute("aria-label", t("downloadAndroidAria"));
11356
+ const appDownloadIroh = element("a", "dsh-mobile-control__app-download is-iroh");
11357
+ appDownloadIroh.href = GITHUB_RELEASES_URL;
11358
+ appDownloadIroh.target = "_blank";
11359
+ appDownloadIroh.rel = "noopener noreferrer";
11360
+ appDownloadIroh.textContent = t("downloadAndroidIroh");
11361
+ appDownloadIroh.setAttribute("aria-label", t("downloadAndroidIrohAria"));
11362
+ appDownloadIroh.hidden = true;
11363
+ appDownloads.append(appDownload, appDownloadIroh);
10275
11364
  const switcher = element("div", "dsh-mobile-control__switcher");
10276
11365
  const lanTab = element("button", "dsh-mobile-control__tab is-active");
10277
11366
  lanTab.type = "button";
@@ -10396,7 +11485,12 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10396
11485
  accessLink.rel = "noreferrer";
10397
11486
  access.append(accessLabel, accessLink);
10398
11487
  const qrBox = element("div", "dsh-mobile-control__qr");
10399
- qrBox.hidden = true;
11488
+ const lanPairingValidity = element("p", "dsh-mobile-control__component-status");
11489
+ lanPairingValidity.hidden = true;
11490
+ let activeLanPairUrl = "";
11491
+ let activeLanPairExpiresAt = 0;
11492
+ let lanPairingBusy = false;
11493
+ let lanPairingGeneration = 0;
10400
11494
  const status = element("p", "dsh-mobile-control__status");
10401
11495
  status.textContent = t("loadingStatus");
10402
11496
  const extensionStatus = element("p", "dsh-mobile-control__extensions");
@@ -10417,11 +11511,9 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10417
11511
  const resetAll = element("button", "dsh-mobile-control__manage");
10418
11512
  resetAll.type = "button";
10419
11513
  resetAll.textContent = t("clearAllDevices");
10420
- manageRow.append(manageDevices, resetAll);
11514
+ manageRow.append(manageDevices);
10421
11515
  const devicePanel = element("div", "dsh-mobile-control__devices");
10422
- devicePanel.hidden = true;
10423
11516
  const remoteView = element("div", "dsh-mobile-control__view is-remote");
10424
- remoteView.hidden = true;
10425
11517
  const remoteIntro = element("p", "dsh-mobile-control__intro");
10426
11518
  remoteIntro.textContent = t("remoteIntro");
10427
11519
  const httpFrameWarning = element("p", "dsh-mobile-control__http-frame-warning");
@@ -10431,27 +11523,6 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10431
11523
  const providerSection = element("section", "dsh-mobile-control__provider-section");
10432
11524
  const providerHeading = element("h3", "dsh-mobile-control__section-title");
10433
11525
  providerHeading.textContent = t("chooseProvider");
10434
- const providerInfo = element("div", "dsh-mobile-control__provider-info");
10435
- const providerInfoButton = element("button", "dsh-mobile-control__provider-info-button");
10436
- providerInfoButton.type = "button";
10437
- providerInfoButton.setAttribute("aria-label", t("providerInfoAria"));
10438
- providerInfoButton.setAttribute("aria-expanded", "false");
10439
- providerInfoButton.setAttribute("aria-controls", "dsh-mobile-provider-info");
10440
- providerInfoButton.setAttribute("aria-describedby", "dsh-mobile-provider-info");
10441
- const providerInfoGlyph = element("span", "dsh-mobile-control__provider-info-glyph");
10442
- providerInfoGlyph.textContent = "i";
10443
- providerInfoGlyph.setAttribute("aria-hidden", "true");
10444
- const providerInfoPopover = element("div", "dsh-mobile-control__provider-info-popover");
10445
- providerInfoPopover.id = "dsh-mobile-provider-info";
10446
- providerInfoPopover.setAttribute("role", "tooltip");
10447
- providerInfoPopover.hidden = true;
10448
- const providerInfoTitle = element("strong");
10449
- providerInfoTitle.textContent = t("providerSafeTitle");
10450
- const providerInfoText = element("span");
10451
- providerInfoText.textContent = t("providerSafeText");
10452
- providerInfoButton.append(providerInfoGlyph);
10453
- providerInfoPopover.append(providerInfoTitle, providerInfoText);
10454
- providerInfo.append(providerInfoButton, providerInfoPopover);
10455
11526
  const providerChoices = element("div", "dsh-mobile-control__provider-choices");
10456
11527
  providerChoices.setAttribute("role", "radiogroup");
10457
11528
  providerChoices.setAttribute("aria-label", t("providerGroupAria"));
@@ -10519,6 +11590,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10519
11590
  const cpolarInstall = element("button", "dsh-mobile-control__primary");
10520
11591
  cpolarInstall.type = "button";
10521
11592
  cpolarInstall.textContent = t("installOfficial");
11593
+ cpolarInstall.hidden = true;
10522
11594
  const cpolarAccount = element("div", "dsh-mobile-control__cpolar-account");
10523
11595
  cpolarAccount.hidden = true;
10524
11596
  const cpolarAccountText = element("p", "dsh-mobile-control__component-note");
@@ -10570,7 +11642,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10570
11642
  cpolarPurge.textContent = t("purgeCpolar");
10571
11643
  cpolarDetailsBody.append(cpolarDetailsText, cpolarStorage, cpolarOfficial, cpolarTerms, cpolarPurge);
10572
11644
  cpolarDetails.append(cpolarDetailsSummary, cpolarDetailsBody);
10573
- cpolarSetup.append(cpolarSetupTitle, cpolarComponentStatus, cpolarInstall, cpolarAccount, cpolarDetails);
11645
+ cpolarSetup.append(cpolarSetupTitle, cpolarComponentStatus, cpolarAccount);
10574
11646
  const cloudflaredSetup = element("section", "dsh-mobile-control__cpolar-setup dsh-mobile-control__cloudflared-setup");
10575
11647
  cloudflaredSetup.hidden = true;
10576
11648
  const cloudflaredSetupTitle = element("h3", "dsh-mobile-control__section-title");
@@ -10587,6 +11659,41 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10587
11659
  cloudflaredMode.append(cloudflaredModeQuick, cloudflaredModeNamed);
10588
11660
  const cloudflaredModeHint = element("p", "dsh-mobile-control__component-status");
10589
11661
  cloudflaredModeHint.textContent = t("cloudflaredTunnelQuickHint");
11662
+ const cloudflaredNotesToggle = element("button", "dsh-mobile-control__notes-toggle");
11663
+ cloudflaredNotesToggle.type = "button";
11664
+ cloudflaredNotesToggle.textContent = "i";
11665
+ cloudflaredNotesToggle.setAttribute("aria-label", t("providerInfoAria"));
11666
+ cloudflaredNotesToggle.setAttribute("aria-expanded", "false");
11667
+ const cloudflaredNotesPanel = element("div", "dsh-mobile-control__notes-panel");
11668
+ cloudflaredNotesPanel.hidden = true;
11669
+ const cloudflaredNotesQuick = element("p");
11670
+ cloudflaredNotesQuick.textContent = t("cloudflaredQuickNote");
11671
+ const cloudflaredNotesNamed = element("p");
11672
+ cloudflaredNotesNamed.textContent = t("cloudflaredNamedNote");
11673
+ cloudflaredNotesPanel.append(cloudflaredNotesQuick, cloudflaredNotesNamed);
11674
+ const cloudflaredModeRow = element("div", "dsh-mobile-control__mode-row");
11675
+ cloudflaredModeRow.append(cloudflaredMode, cloudflaredNotesToggle, cloudflaredNotesPanel);
11676
+ const setCloudflaredNotesOpen = (open) => {
11677
+ cloudflaredNotesPanel.hidden = !open;
11678
+ cloudflaredNotesToggle.setAttribute("aria-expanded", String(open));
11679
+ };
11680
+ const hoverCapable = typeof window.matchMedia === "function" ? window.matchMedia("(hover: hover)") : void 0;
11681
+ cloudflaredModeRow.addEventListener("pointerenter", () => {
11682
+ if (hoverCapable?.matches) setCloudflaredNotesOpen(true);
11683
+ });
11684
+ cloudflaredModeRow.addEventListener("pointerleave", () => {
11685
+ if (hoverCapable?.matches) setCloudflaredNotesOpen(false);
11686
+ });
11687
+ cloudflaredNotesToggle.addEventListener("click", (event) => {
11688
+ setCloudflaredNotesOpen(hoverCapable?.matches && event.detail !== 0 ? true : Boolean(cloudflaredNotesPanel.hidden));
11689
+ });
11690
+ cloudflaredModeRow.addEventListener("keydown", (event) => {
11691
+ if (event.key !== "Escape" || cloudflaredNotesPanel.hidden) return;
11692
+ event.preventDefault();
11693
+ event.stopPropagation();
11694
+ setCloudflaredNotesOpen(false);
11695
+ cloudflaredNotesToggle.focus();
11696
+ });
10590
11697
  const cloudflaredTunnel = element("div", "dsh-mobile-control__tunnel");
10591
11698
  cloudflaredTunnel.hidden = true;
10592
11699
  const cloudflaredTunnelFields = element("div", "dsh-mobile-control__frp-fields");
@@ -10629,12 +11736,23 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10629
11736
  cloudflaredTunnelForget.type = "button";
10630
11737
  cloudflaredTunnelForget.textContent = t("cloudflaredTunnelForget");
10631
11738
  cloudflaredTunnelForget.hidden = true;
10632
- cloudflaredTunnel.append(cloudflaredTunnelFields, cloudflaredTunnelStatus, cloudflaredTunnelHint);
11739
+ const cloudflaredTunnelToggle = element("button", "dsh-mobile-control__tunnel-toggle");
11740
+ cloudflaredTunnelToggle.type = "button";
11741
+ cloudflaredTunnelToggle.textContent = t("cloudflaredShowConfig") + " ▾";
11742
+ cloudflaredTunnelToggle.hidden = true;
11743
+ const cloudflaredEditButton = element("button", "dsh-mobile-control__secondary");
11744
+ cloudflaredEditButton.type = "button";
11745
+ cloudflaredEditButton.textContent = t("cloudflaredEditConfig");
11746
+ cloudflaredEditButton.hidden = true;
11747
+ const cloudflaredTunnelFeedback = element("p", "dsh-mobile-control__component-status");
11748
+ cloudflaredTunnelFeedback.hidden = true;
11749
+ cloudflaredTunnel.append(cloudflaredTunnelFields, cloudflaredTunnelStatus, cloudflaredTunnelHint, cloudflaredEditButton, cloudflaredTunnelSave, cloudflaredTunnelForget, cloudflaredTunnelFeedback);
10633
11750
  const cloudflaredComponentStatus = element("p", "dsh-mobile-control__component-status");
10634
11751
  cloudflaredComponentStatus.textContent = t("checkingComponent");
10635
11752
  const cloudflaredInstall = element("button", "dsh-mobile-control__primary");
10636
11753
  cloudflaredInstall.type = "button";
10637
11754
  cloudflaredInstall.textContent = t("installOfficial");
11755
+ cloudflaredInstall.hidden = true;
10638
11756
  const cloudflaredNote = element("p", "dsh-mobile-control__origin-warning");
10639
11757
  cloudflaredNote.textContent = t("cloudflaredQuickNote");
10640
11758
  const cloudflaredDetails = element("details", "dsh-mobile-control__details");
@@ -10660,19 +11778,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10660
11778
  cloudflaredPurge.textContent = t("purgeCloudflared");
10661
11779
  cloudflaredDetailsBody.append(cloudflaredDetailsText, cloudflaredStorage, cloudflaredOfficial, cloudflaredTerms, cloudflaredPurge);
10662
11780
  cloudflaredDetails.append(cloudflaredDetailsSummary, cloudflaredDetailsBody);
10663
- cloudflaredSetup.append(cloudflaredSetupTitle, cloudflaredTunnelTitle, cloudflaredMode, cloudflaredModeHint, cloudflaredTunnel, cloudflaredTunnelSave, cloudflaredTunnelForget, cloudflaredComponentStatus, cloudflaredInstall, cloudflaredNote, cloudflaredDetails);
10664
- const selfHosted = element("details", "dsh-mobile-control__self-hosted");
10665
- const selfHostedSummary = element("summary", "dsh-mobile-control__self-hosted-summary");
10666
- const selfHostedSummaryText = element("span");
10667
- const selfHostedSummaryTitle = element("strong");
10668
- selfHostedSummaryTitle.textContent = t("selfHostedConnections");
10669
- const selfHostedSummaryDescription = element("span");
10670
- selfHostedSummaryDescription.textContent = t("selfHostedDescription");
10671
- const selfHostedBadge = element("span", "dsh-mobile-control__provider-badge is-frp");
10672
- selfHostedBadge.textContent = t("advanced");
10673
- selfHostedSummaryText.append(selfHostedSummaryTitle, selfHostedSummaryDescription);
10674
- selfHostedSummary.append(selfHostedSummaryText, selfHostedBadge);
10675
- const selfHostedBody = element("div", "dsh-mobile-control__self-hosted-body");
11781
+ cloudflaredSetup.append(cloudflaredSetupTitle, cloudflaredTunnelTitle, cloudflaredModeRow, cloudflaredModeHint, cloudflaredTunnelToggle, cloudflaredTunnel, cloudflaredComponentStatus);
10676
11782
  const frpChoice = element("button", "dsh-mobile-control__provider is-frp");
10677
11783
  frpChoice.type = "button";
10678
11784
  frpChoice.setAttribute("aria-pressed", "false");
@@ -10681,7 +11787,9 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10681
11787
  frpChoiceName.textContent = t("frpName");
10682
11788
  const frpChoiceDescription = element("span", "dsh-mobile-control__provider-description");
10683
11789
  frpChoiceDescription.textContent = t("frpDescription");
10684
- frpChoiceTop.append(frpChoiceName);
11790
+ const frpChoiceBadge = element("span", "dsh-mobile-control__provider-badge is-frp");
11791
+ frpChoiceBadge.textContent = t("advanced");
11792
+ frpChoiceTop.append(frpChoiceName, frpChoiceBadge);
10685
11793
  frpChoice.append(frpChoiceTop, frpChoiceDescription);
10686
11794
  const originChoice = element("button", "dsh-mobile-control__provider is-origin");
10687
11795
  originChoice.type = "button";
@@ -10691,7 +11799,9 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10691
11799
  originChoiceName.textContent = t("originName");
10692
11800
  const originChoiceDescription = element("span", "dsh-mobile-control__provider-description");
10693
11801
  originChoiceDescription.textContent = t("originDescription");
10694
- originChoiceTop.append(originChoiceName);
11802
+ const originChoiceBadge = element("span", "dsh-mobile-control__provider-badge");
11803
+ originChoiceBadge.textContent = t("advanced");
11804
+ originChoiceTop.append(originChoiceName, originChoiceBadge);
10695
11805
  originChoice.append(originChoiceTop, originChoiceDescription);
10696
11806
  const irohChoice = element("button", "dsh-mobile-control__provider is-iroh");
10697
11807
  irohChoice.type = "button";
@@ -10705,9 +11815,21 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10705
11815
  irohChoiceDescription.textContent = t("irohDescription");
10706
11816
  irohChoiceTop.append(irohChoiceName, irohChoiceBadge);
10707
11817
  irohChoice.append(irohChoiceTop, irohChoiceDescription);
10708
- selfHostedBody.append(frpChoice, originChoice, irohChoice);
10709
- selfHosted.append(selfHostedSummary, selfHostedBody);
10710
- providerSection.append(providerHeading, providerInfo, providerChoices, selfHosted);
11818
+ providerChoices.append(irohChoice, frpChoice, originChoice);
11819
+ const lanChoice = element("button", "dsh-mobile-control__provider is-lan");
11820
+ lanChoice.type = "button";
11821
+ lanChoice.setAttribute("role", "radio");
11822
+ lanChoice.setAttribute("aria-checked", "true");
11823
+ const lanChoiceTop = element("span", "dsh-mobile-control__provider-top");
11824
+ const lanChoiceName = element("strong");
11825
+ lanChoiceName.textContent = t("lan");
11826
+ const lanChoiceBadge = element("span", "dsh-mobile-control__provider-badge");
11827
+ lanChoiceBadge.textContent = t("builtIn");
11828
+ const lanChoiceDescription = element("span", "dsh-mobile-control__provider-description");
11829
+ lanChoiceDescription.textContent = t("lanChoiceDescription");
11830
+ lanChoiceTop.append(lanChoiceName, lanChoiceBadge);
11831
+ lanChoice.append(lanChoiceTop, lanChoiceDescription);
11832
+ providerSection.append(providerHeading, lanChoice, providerChoices);
10711
11833
  const frpSetup = element("section", "dsh-mobile-control__frp-setup");
10712
11834
  frpSetup.hidden = true;
10713
11835
  const frpSetupTitle = element("h3", "dsh-mobile-control__section-title");
@@ -11003,7 +12125,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11003
12125
  const frpVerifySummary = element("summary");
11004
12126
  frpVerifySummary.textContent = t("frpStep4Title");
11005
12127
  frpVerifyGroup.append(frpVerifySummary, frpStep4);
11006
- frpSetup.append(frpSetupTitle, frpOverview, frpConnectionGroup, frpVpsGroup, frpComponentGroup, frpVerifyGroup, frpDetails);
12128
+ frpSetup.append(frpSetupTitle, frpOverview, frpConnectionGroup, frpVpsGroup, frpComponentGroup, frpVerifyGroup);
11007
12129
  const originSetup = element("section", "dsh-mobile-control__origin-setup");
11008
12130
  originSetup.hidden = true;
11009
12131
  const originSetupTitle = element("h3", "dsh-mobile-control__section-title");
@@ -11178,16 +12300,6 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11178
12300
  tailscaleInfoText.textContent = t("tailscaleHelpText");
11179
12301
  tailscaleInfoBody.append(tailscaleInfoText);
11180
12302
  tailscaleInfo.append(tailscaleInfoSummary, tailscaleInfoBody);
11181
- const providerSetupHeader = element("div", "dsh-mobile-control__stage-header");
11182
- const providerSetupHeading = element("h3", "dsh-mobile-control__section-title");
11183
- providerSetupHeading.textContent = t("currentProvider");
11184
- const providerSetupName = element("span", "dsh-mobile-control__stage-value");
11185
- providerSetupName.textContent = "Tailscale Funnel";
11186
- const remoteStateBadge = element("span", "dsh-mobile-control__state-badge");
11187
- remoteStateBadge.textContent = t("remoteStateOff");
11188
- const providerSetupMeta = element("div", "dsh-mobile-control__stage-meta");
11189
- providerSetupMeta.append(providerSetupName, remoteStateBadge);
11190
- providerSetupHeader.append(providerSetupHeading, providerSetupMeta);
11191
12303
  const providerSetupBody = element("div", "dsh-mobile-control__provider-setup-body");
11192
12304
  const irohSetup = element("section", "dsh-mobile-control__frp-setup");
11193
12305
  irohSetup.hidden = true;
@@ -11205,7 +12317,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11205
12317
  const irohInstallProgress = element("progress", "dsh-mobile-control__progress");
11206
12318
  irohInstallProgress.hidden = true;
11207
12319
  irohInstallProgress.setAttribute("aria-hidden", "true");
11208
- const irohPairing = element("div", "dsh-mobile-control__ws-paths");
12320
+ const irohPairing = element("div", "dsh-mobile-control__iroh-pairing");
11209
12321
  const irohPairingTitle = element("p", "dsh-mobile-control__component-status");
11210
12322
  irohPairingTitle.textContent = t("irohPairingTitle");
11211
12323
  const irohPair = element("button", "dsh-mobile-control__primary");
@@ -11218,7 +12330,8 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11218
12330
  irohCopyDescriptor.type = "button";
11219
12331
  irohCopyDescriptor.textContent = t("copyPairLink");
11220
12332
  irohCopyDescriptor.hidden = true;
11221
- irohPairing.append(irohPairingTitle, irohPair, irohQr, irohPairStatus, irohCopyDescriptor);
12333
+ irohCopyDescriptor.disabled = true;
12334
+ irohPairing.append(irohPairingTitle, irohQr, irohPairStatus);
11222
12335
  const irohDetails = element("details", "dsh-mobile-control__details");
11223
12336
  const irohDetailsSummary = element("summary");
11224
12337
  irohDetailsSummary.textContent = t("componentDetails");
@@ -11234,9 +12347,12 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11234
12347
  irohOfficial.target = "_blank";
11235
12348
  irohOfficial.rel = "noopener noreferrer";
11236
12349
  irohOfficial.textContent = t("officialDownload");
11237
- irohDetailsBody.append(irohDetailsText, irohInstallNote, irohStorage, irohOfficial);
12350
+ const irohPurge = element("button", "dsh-mobile-control__danger");
12351
+ irohPurge.type = "button";
12352
+ irohPurge.textContent = t("purgeIroh");
12353
+ irohDetailsBody.append(irohDetailsText, irohInstallNote, irohStorage, irohOfficial, irohPurge);
11238
12354
  irohDetails.append(irohDetailsSummary, irohDetailsBody);
11239
- irohSetup.append(irohSetupTitle, irohPhaseNote, irohComponentStatus, irohInstall, irohInstallProgress, irohPairing, irohDetails);
12355
+ irohSetup.append(irohSetupTitle, irohPhaseNote, irohComponentStatus, irohInstall, irohInstallProgress, irohPairing);
11240
12356
  providerSetupBody.append(cpolarSetup, cloudflaredSetup, frpSetup, originSetup, irohSetup, tailscaleInfo);
11241
12357
  const irohQrZoom = element("div", "dsh-mobile-control__qr-zoom");
11242
12358
  irohQrZoom.hidden = true;
@@ -11323,11 +12439,16 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11323
12439
  remoteCopyLink.disabled = true;
11324
12440
  const remotePairLink = element("p", "dsh-mobile-control__status");
11325
12441
  remotePairLink.hidden = true;
12442
+ const pairingValidity = element("p", "dsh-mobile-control__component-status");
12443
+ pairingValidity.hidden = true;
11326
12444
  let activeRemotePairUrl = "";
11327
12445
  let activeRemotePairExpiresAt = 0;
12446
+ let remotePairingBusy = false;
12447
+ let remotePairingGeneration = 0;
11328
12448
  let activeIrohDescriptor = "";
12449
+ let activeIrohPairExpiresAt = 0;
11329
12450
  let activeIrohQrSvg = "";
11330
- remoteActions.append(remoteToggle, remoteLogin, remoteReconnect, remotePair, remoteCopyLink);
12451
+ remoteActions.append(remoteToggle, irohPair, irohCopyDescriptor, remoteLogin, remoteReconnect, remotePair, remoteCopyLink);
11331
12452
  const remoteManageRow = element("div", "dsh-mobile-control__manage-row");
11332
12453
  const remoteDevices = element("button", "dsh-mobile-control__manage");
11333
12454
  remoteDevices.type = "button";
@@ -11338,7 +12459,6 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11338
12459
  remoteReset.textContent = t("resetRemoteLogin");
11339
12460
  remoteManageRow.append(remoteDevices, remoteReset);
11340
12461
  const remoteDevicePanel = element("div", "dsh-mobile-control__devices");
11341
- remoteDevicePanel.hidden = true;
11342
12462
  const wsPathsSection = element("section", "dsh-mobile-control__ws-paths");
11343
12463
  const wsPathsTitle = element("h3", "dsh-mobile-control__section-title");
11344
12464
  wsPathsTitle.textContent = t("wsPathsTitle");
@@ -11522,10 +12642,53 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11522
12642
  wsPathsInput.value = "";
11523
12643
  saveWsPaths([...wsPathsCurrent, value]);
11524
12644
  });
11525
- const remoteWorkspace = element("section", "dsh-mobile-control__remote-workspace");
11526
- remoteWorkspace.append(providerSetupHeader, remoteStatus, remoteAccess, remoteGuide, providerSetupBody, remoteActions, remoteQr, remotePairLink, remoteManageRow, remoteDevicePanel);
12645
+ const remoteWorkspace = element("div", "dsh-mobile-control__remote-workspace");
12646
+ const providerInstallRow = element("div", "dsh-mobile-control__provider-install");
12647
+ providerInstallRow.append(cpolarInstall, cloudflaredInstall);
12648
+ const setupDetailsRow = element("div", "dsh-mobile-control__setup-details");
12649
+ for (const disclosure of [
12650
+ cpolarDetails,
12651
+ cloudflaredDetails,
12652
+ frpDetails,
12653
+ irohDetails
12654
+ ]) {
12655
+ disclosure.hidden = true;
12656
+ setupDetailsRow.append(disclosure);
12657
+ }
12658
+ remoteWorkspace.append(remoteStatus, remoteAccess, remoteGuide, providerSetupBody, remoteQr, pairingValidity, providerInstallRow, remoteActions, remotePairLink, setupDetailsRow);
11527
12659
  const diagnosticsView = element("div", "dsh-mobile-control__view is-diagnostics");
11528
12660
  diagnosticsView.hidden = true;
12661
+ const devicesView = element("div", "dsh-mobile-control__view is-devices");
12662
+ devicesView.hidden = true;
12663
+ const devicesSwitcher = element("div", "dsh-mobile-control__switcher");
12664
+ const devicesLanTab = element("button", "dsh-mobile-control__tab is-active");
12665
+ devicesLanTab.type = "button";
12666
+ devicesLanTab.textContent = t("devicesLanTitle");
12667
+ const devicesRemoteTab = element("button", "dsh-mobile-control__tab");
12668
+ devicesRemoteTab.type = "button";
12669
+ devicesRemoteTab.textContent = t("devicesRemoteTitle");
12670
+ devicesSwitcher.append(devicesLanTab, devicesRemoteTab);
12671
+ const devicesLanGroup = element("div");
12672
+ const devicesLanRow = element("div", "dsh-mobile-control__manage-row");
12673
+ devicesLanRow.append(resetAll);
12674
+ devicesLanGroup.append(devicePanel, devicesLanRow);
12675
+ const devicesRemoteGroup = element("div");
12676
+ devicesRemoteGroup.hidden = true;
12677
+ const devicesRemoteRow = element("div", "dsh-mobile-control__manage-row");
12678
+ devicesRemoteRow.append(remoteReset);
12679
+ devicesRemoteGroup.append(remoteDevicePanel, devicesRemoteRow);
12680
+ devicesView.append(devicesSwitcher, devicesLanGroup, devicesRemoteGroup);
12681
+ const showDevicesGroup = (which) => {
12682
+ const lan = which === "lan";
12683
+ devicesLanGroup.hidden = !lan;
12684
+ devicesRemoteGroup.hidden = lan;
12685
+ devicesLanTab.classList.toggle("is-active", lan);
12686
+ devicesRemoteTab.classList.toggle("is-active", !lan);
12687
+ if (lan) loadDevices();
12688
+ else loadRemoteDevices();
12689
+ };
12690
+ devicesLanTab.addEventListener("click", () => showDevicesGroup("lan"));
12691
+ devicesRemoteTab.addEventListener("click", () => showDevicesGroup("remote"));
11529
12692
  const diagnosticsIntro = element("p", "dsh-mobile-control__intro");
11530
12693
  diagnosticsIntro.textContent = t("diagnosticsIntro");
11531
12694
  const diagnosticsSummary = element("section", "dsh-mobile-control__diagnostic-summary is-idle");
@@ -11567,10 +12730,14 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11567
12730
  diagnosticsDetails.append(diagnosticsDetailsSummary, diagnosticsReport);
11568
12731
  header.append(title, headerActions);
11569
12732
  actions.append(toggle, pair, linkPair);
11570
- lanView.append(lanSetup, access, qrBox, status, extensionStatus, actions, manageRow, devicePanel, trustedNetworks);
11571
- remoteView.append(remoteIntro, httpFrameWarning, providerSection, remoteWorkspace);
12733
+ lanView.append(lanSetup, access, qrBox, lanPairingValidity, status, extensionStatus, actions, trustedNetworks);
12734
+ const channelPane = element("div", "dsh-mobile-control__channel-pane");
12735
+ channelPane.append(lanView, remoteWorkspace);
12736
+ const remoteColumns = element("div", "dsh-mobile-control__remote-columns");
12737
+ remoteColumns.append(providerSection, channelPane);
12738
+ remoteView.append(remoteIntro, httpFrameWarning, remoteColumns);
11572
12739
  diagnosticsView.append(diagnosticsIntro, diagnosticsSummary, diagnosticsToolbar, diagnosticsFeedback, diagnosticsChecks, wsPathsSection, diagnosticsDetails);
11573
- panel.append(header, releaseNotice, updateCard, appDownload, extensionRecovery.section, switcher, lanView, remoteView, diagnosticsView);
12740
+ panel.append(header, releaseNotice, updateCard, appDownloads, extensionRecovery.section, remoteView, diagnosticsView, devicesView);
11574
12741
  root.append(panel);
11575
12742
  document.body.append(root);
11576
12743
  let running = false;
@@ -11593,6 +12760,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11593
12760
  let cloudflaredInstalled = false;
11594
12761
  let cloudflaredDownloadSize = "";
11595
12762
  let cloudflaredTunnelMode = "quick";
12763
+ let cloudflaredConfigEditing = false;
11596
12764
  let cloudflaredModeDraft = null;
11597
12765
  let cloudflaredTunnelConfigured = false;
11598
12766
  let configuredCloudflaredHostname = "";
@@ -11641,8 +12809,6 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11641
12809
  let frpEntryTlsDraft = false;
11642
12810
  let frpVhostDraft = false;
11643
12811
  let frpAttachVhostExplicit = false;
11644
- let providerInfoPinned = false;
11645
- let providerInfoHovered = false;
11646
12812
  let previousAccessView = "lan";
11647
12813
  let diagnosticsBusy = false;
11648
12814
  let copiedDiagnosticReport = "";
@@ -11659,78 +12825,140 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11659
12825
  updatePlugin.textContent = t("updatePlugin");
11660
12826
  if (pluginLatestVersion !== "") updatePlugin.setAttribute("aria-label", t("updatePluginAria", { version: pluginLatestVersion }));
11661
12827
  if (release.androidVersion !== void 0) {
11662
- appDownload.textContent = t("downloadAndroidVersion", { version: release.androidVersion });
11663
- appDownload.setAttribute("aria-label", t("downloadAndroidVersionAria", { version: release.androidVersion }));
12828
+ const version = release.androidVersion;
12829
+ const sizeMb = (bytes) => bytes === void 0 ? void 0 : (bytes / 1048576).toFixed(1);
12830
+ const compatSize = sizeMb(release.androidCompatBytes);
12831
+ const irohSize = sizeMb(release.androidIrohBytes);
12832
+ appDownload.textContent = compatSize === void 0 ? t("downloadAndroidVersion", { version }) : t("downloadAndroidVersionSize", {
12833
+ version,
12834
+ size: compatSize
12835
+ });
12836
+ appDownload.setAttribute("aria-label", t("downloadAndroidVersionAria", { version }));
12837
+ appDownloadIroh.textContent = irohSize === void 0 ? t("downloadAndroidIrohVersion", { version }) : t("downloadAndroidIrohVersionSize", {
12838
+ version,
12839
+ size: irohSize
12840
+ });
12841
+ appDownloadIroh.setAttribute("aria-label", t("downloadAndroidIrohAria", { version }));
11664
12842
  }
11665
12843
  appDownload.href = release.androidDownloadUrl;
12844
+ if (release.androidIrohDownloadUrl === void 0) appDownloadIroh.hidden = true;
12845
+ else {
12846
+ appDownloadIroh.href = release.androidIrohDownloadUrl;
12847
+ appDownloadIroh.hidden = false;
12848
+ }
11666
12849
  };
11667
- const syncProviderInfo = () => {
11668
- const open = providerInfoPinned || providerInfoHovered || providerInfo.contains(document.activeElement);
11669
- providerInfoPopover.hidden = !open;
11670
- providerInfoButton.setAttribute("aria-expanded", String(open));
12850
+ let currentAccessView = "lan";
12851
+ const applyChoiceSelection = () => {
12852
+ const inRemote = currentAccessView === "remote";
12853
+ lanChoice.classList.toggle("is-selected", currentAccessView === "lan");
12854
+ lanChoice.setAttribute("aria-checked", String(currentAccessView === "lan"));
12855
+ tailscaleChoice.classList.toggle("is-selected", inRemote && remoteProvider === "tailscale");
12856
+ cpolarChoice.classList.toggle("is-selected", inRemote && remoteProvider === "cpolar");
12857
+ cloudflaredChoice.classList.toggle("is-selected", inRemote && remoteProvider === "cloudflared");
12858
+ frpChoice.classList.toggle("is-selected", inRemote && remoteProvider === "frp");
12859
+ originChoice.classList.toggle("is-selected", inRemote && remoteProvider === "origin");
12860
+ irohChoice.classList.toggle("is-selected", inRemote && remoteProvider === "iroh");
11671
12861
  };
11672
- providerInfo.addEventListener("pointerenter", () => {
11673
- providerInfoHovered = true;
11674
- syncProviderInfo();
11675
- });
11676
- providerInfo.addEventListener("pointerleave", () => {
11677
- providerInfoHovered = false;
11678
- syncProviderInfo();
11679
- });
11680
- providerInfo.addEventListener("focusin", syncProviderInfo);
11681
- providerInfo.addEventListener("focusout", () => {
11682
- window.setTimeout(syncProviderInfo, 0);
11683
- });
11684
- providerInfoButton.addEventListener("click", () => {
11685
- providerInfoPinned = !providerInfoPinned;
11686
- syncProviderInfo();
11687
- });
11688
- providerInfoButton.addEventListener("keydown", (event) => {
11689
- if (event.key !== "Escape") return;
11690
- providerInfoPinned = false;
11691
- providerInfoHovered = false;
11692
- providerInfoPopover.hidden = true;
11693
- providerInfoButton.setAttribute("aria-expanded", "false");
11694
- });
11695
12862
  const selectView = (view) => {
11696
12863
  if (!panel.hidden) extensionRecovery.refresh();
11697
- if (view !== "diagnostics") previousAccessView = view;
12864
+ currentAccessView = view;
12865
+ if (view === "lan" || view === "remote") previousAccessView = view;
12866
+ const subpage = view === "diagnostics" || view === "devices";
12867
+ remoteView.hidden = subpage;
11698
12868
  lanView.hidden = view !== "lan";
11699
- remoteView.hidden = view !== "remote";
12869
+ remoteWorkspace.hidden = view !== "remote";
11700
12870
  diagnosticsView.hidden = view !== "diagnostics";
12871
+ devicesView.hidden = view !== "devices";
11701
12872
  lanTab.classList.toggle("is-active", view === "lan");
11702
12873
  remoteTab.classList.toggle("is-active", view === "remote");
12874
+ applyChoiceSelection();
11703
12875
  lanTab.setAttribute("aria-pressed", String(view === "lan"));
11704
12876
  remoteTab.setAttribute("aria-pressed", String(view === "remote"));
11705
12877
  diagnosticsEntry.setAttribute("aria-pressed", String(view === "diagnostics"));
11706
12878
  diagnosticsEntry.textContent = view === "diagnostics" ? t("back") : t("diagnostics");
11707
12879
  diagnosticsEntry.setAttribute("aria-label", view === "diagnostics" ? t("backToMobile") : t("openDiagnostics"));
12880
+ devicesEntry.setAttribute("aria-pressed", String(view === "devices"));
12881
+ devicesEntry.textContent = view === "devices" ? t("back") : t("device");
12882
+ devicesEntry.setAttribute("aria-label", view === "devices" ? t("backToMobile") : t("managePairedDevices"));
11708
12883
  if (view === "diagnostics") {
11709
12884
  wsPathsSeenAttempts = wsBlockedTotal(wsPathsBlocked);
11710
12885
  renderWsDot();
11711
12886
  if (!wsPathsLoaded) loadWsPaths();
11712
12887
  }
11713
- updatePlugin.hidden = view === "diagnostics" || !pluginUpdateAvailable;
11714
- appDownload.hidden = view === "diagnostics";
11715
- switcher.hidden = view === "diagnostics";
11716
- title.textContent = view === "lan" ? t("lanAccess") : view === "remote" ? t("remoteAccess") : t("connectionDiagnostics");
12888
+ updatePlugin.hidden = subpage || !pluginUpdateAvailable;
12889
+ appDownloads.hidden = subpage;
12890
+ switcher.hidden = subpage;
12891
+ title.textContent = view === "lan" ? t("lanAccess") : view === "remote" ? t("remoteAccess") : view === "devices" ? t("managePairedDevices") : t("connectionDiagnostics");
11717
12892
  };
11718
12893
  lanTab.addEventListener("click", () => {
11719
12894
  selectView("lan");
11720
12895
  });
12896
+ lanChoice.addEventListener("click", () => {
12897
+ selectView("lan");
12898
+ });
12899
+ providerChoices.addEventListener("click", () => {
12900
+ selectView("remote");
12901
+ loadRemote();
12902
+ });
11721
12903
  remoteTab.addEventListener("click", () => {
11722
12904
  selectView("remote");
11723
12905
  loadRemote();
11724
12906
  });
12907
+ selectView("lan");
12908
+ let returnFocus;
11725
12909
  const setOpen = (open) => {
12910
+ const changed = panel.hidden === open;
12911
+ if (open && changed && document.activeElement instanceof HTMLElement) returnFocus = document.activeElement;
11726
12912
  panel.hidden = !open;
12913
+ if (open) {
12914
+ panel.setAttribute("role", "dialog");
12915
+ panel.setAttribute("aria-modal", "true");
12916
+ } else {
12917
+ panel.removeAttribute("role");
12918
+ panel.removeAttribute("aria-modal");
12919
+ }
11727
12920
  if (open) extensionRecovery.refresh();
12921
+ if (open && changed) controlRequestJson("/api/mobile-access/release").then(renderRelease, () => {});
12922
+ if (changed) {
12923
+ if (open) close.focus();
12924
+ else if (returnFocus?.isConnected) returnFocus.focus();
12925
+ }
11728
12926
  for (const trigger of document.querySelectorAll(".dsh-mobile-control__trigger")) trigger.setAttribute("aria-expanded", String(open));
11729
12927
  };
12928
+ panel.addEventListener("keydown", (event) => {
12929
+ if (event.defaultPrevented) return;
12930
+ if (event.key === "Escape") {
12931
+ event.preventDefault();
12932
+ setOpen(false);
12933
+ return;
12934
+ }
12935
+ if (event.key !== "Tab") return;
12936
+ const controls = [...panel.querySelectorAll("button,a[href],input,select,textarea,[tabindex]")].filter((node) => node.tabIndex >= 0 && !node.matches(":disabled") && node.getClientRects().length > 0);
12937
+ const first = controls[0], last = controls.at(-1);
12938
+ if (event.shiftKey && document.activeElement === first && last !== void 0) {
12939
+ event.preventDefault();
12940
+ last.focus();
12941
+ } else if (!event.shiftKey && document.activeElement === last && first !== void 0) {
12942
+ event.preventDefault();
12943
+ first.focus();
12944
+ }
12945
+ });
12946
+ const renderLanPairingExpiry = () => {
12947
+ if (!running || activeLanPairExpiresAt === 0) return;
12948
+ lanPairingValidity.hidden = false;
12949
+ if (Date.now() >= activeLanPairExpiresAt) {
12950
+ activeLanPairUrl = "";
12951
+ showQr("");
12952
+ qrBox.textContent = t("pairingExpired");
12953
+ lanPairingValidity.textContent = t("pairingExpired");
12954
+ } else lanPairingValidity.textContent = t("pairingExpires", { time: formatTime(activeLanPairExpiresAt) });
12955
+ };
11730
12956
  const render = (data) => {
12957
+ const previousOrigin = origin;
11731
12958
  lanConfigured = data.configured !== false;
11732
12959
  lanRestartRequired = data.restartRequired === true;
11733
12960
  running = data.running === true;
12961
+ lanChoiceBadge.textContent = running ? t("lanBadgeOn") : t("lanBadgeOff");
11734
12962
  origin = running && typeof data.origin === "string" ? data.origin : "";
11735
12963
  lanSetup.hidden = lanConfigured && !lanRestartRequired;
11736
12964
  lanSetupForm.hidden = lanRestartRequired;
@@ -11757,12 +12985,18 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11757
12985
  failed
11758
12986
  });
11759
12987
  } else extensionStatus.hidden = true;
11760
- if (!running) qrBox.hidden = true;
12988
+ if (!running || previousOrigin !== origin) {
12989
+ lanPairingGeneration += 1;
12990
+ activeLanPairUrl = "";
12991
+ activeLanPairExpiresAt = 0;
12992
+ lanPairingValidity.hidden = true;
12993
+ showQr("");
12994
+ } else renderLanPairingExpiry();
11761
12995
  toggle.textContent = running ? t("disableLan") : t("enableLan");
11762
12996
  actions.hidden = !lanConfigured || lanRestartRequired;
11763
12997
  manageRow.hidden = !lanConfigured || lanRestartRequired;
11764
- pair.disabled = !running;
11765
- linkPair.disabled = !running;
12998
+ pair.disabled = !running || lanPairingBusy;
12999
+ linkPair.disabled = !running || lanPairingBusy;
11766
13000
  manageDevices.disabled = !running;
11767
13001
  resetAll.disabled = !running;
11768
13002
  };
@@ -11831,10 +13065,25 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11831
13065
  lanSetupConfigure.disabled = lanRestartRequired || lanSetupSelect.options.length === 0;
11832
13066
  });
11833
13067
  });
13068
+ const clearQrTarget = (target) => {
13069
+ if (typeof target.replaceChildren === "function") {
13070
+ target.replaceChildren();
13071
+ return;
13072
+ }
13073
+ try {
13074
+ while (target.firstChild) target.removeChild(target.firstChild);
13075
+ } catch {}
13076
+ };
11834
13077
  const showQr = (svg, target = qrBox) => {
11835
- target.replaceChildren();
13078
+ clearQrTarget(target);
11836
13079
  if (svg === "") {
11837
- target.hidden = true;
13080
+ const placeholderText = target === irohQr ? t("irohQrPlaceholder") : target === remoteQr ? t("remoteQrPlaceholder") : target === qrBox ? t("qrPlaceholder") : void 0;
13081
+ if (placeholderText !== void 0) {
13082
+ const placeholder = element("span", "dsh-mobile-control__qr-placeholder");
13083
+ placeholder.textContent = placeholderText;
13084
+ target.hidden = false;
13085
+ target.append(placeholder);
13086
+ } else target.hidden = true;
11838
13087
  return;
11839
13088
  }
11840
13089
  const image = element("img");
@@ -11845,23 +13094,68 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11845
13094
  target.hidden = false;
11846
13095
  target.append(image);
11847
13096
  };
13097
+ showQr("");
13098
+ const showIrohQrPlaceholder = () => {
13099
+ clearQrTarget(irohQr);
13100
+ const placeholder = element("span", "dsh-mobile-control__qr-placeholder");
13101
+ placeholder.textContent = t("irohQrPlaceholder");
13102
+ irohQr.hidden = false;
13103
+ irohQr.append(placeholder);
13104
+ };
13105
+ const showRemoteQrPlaceholder = (text) => {
13106
+ clearQrTarget(remoteQr);
13107
+ const placeholder = element("span", "dsh-mobile-control__qr-placeholder");
13108
+ placeholder.textContent = text;
13109
+ remoteQr.hidden = false;
13110
+ remoteQr.append(placeholder);
13111
+ };
11848
13112
  const openPairing = (target) => {
11849
- controlRequestJson("/api/mobile-access/lan/pairing/open", {
13113
+ if (lanPairingBusy || !running) return;
13114
+ lanPairingBusy = true;
13115
+ pair.disabled = true;
13116
+ linkPair.disabled = true;
13117
+ const pairingOrigin = origin;
13118
+ const generation = lanPairingGeneration;
13119
+ if (target === "key" || activeLanPairExpiresAt <= Date.now()) {
13120
+ activeLanPairUrl = "";
13121
+ activeLanPairExpiresAt = 0;
13122
+ lanPairingValidity.hidden = true;
13123
+ status.classList.remove("is-key");
13124
+ showQr("");
13125
+ }
13126
+ (target === "link" && activeLanPairUrl !== "" && activeLanPairExpiresAt > Date.now() ? Promise.resolve({
13127
+ pairUrl: activeLanPairUrl,
13128
+ expiresAt: activeLanPairExpiresAt
13129
+ }) : controlRequestJson("/api/mobile-access/lan/pairing/open", {
11850
13130
  method: "POST",
11851
13131
  body: "{}"
11852
- }).then(async (data) => {
11853
- const value = target === "key" ? typeof data.appKey === "string" ? data.appKey : "" : typeof data.pairUrl === "string" ? data.pairUrl : "";
11854
- showQr(typeof data.qrSvg === "string" ? data.qrSvg : "");
13132
+ })).then(async (data) => {
13133
+ if (lifecycle.signal.aborted || !running || origin !== pairingOrigin || generation !== lanPairingGeneration) return;
13134
+ const value = typeof data.pairUrl === "string" ? data.pairUrl : "";
13135
+ if (value === "" || typeof data.expiresAt !== "number" || !Number.isFinite(data.expiresAt) || data.expiresAt <= Date.now()) {
13136
+ status.textContent = t("keyGenerationFailed");
13137
+ return;
13138
+ }
13139
+ activeLanPairUrl = value;
13140
+ activeLanPairExpiresAt = data.expiresAt;
13141
+ lanPairingValidity.hidden = false;
13142
+ lanPairingValidity.textContent = t("pairingExpires", { time: formatTime(data.expiresAt) });
13143
+ if ("qrSvg" in data) showQr(typeof data.qrSvg === "string" ? data.qrSvg : "");
13144
+ if (target === "key") {
13145
+ status.classList.remove("is-key");
13146
+ status.textContent = t("qrGenerated");
13147
+ return;
13148
+ }
11855
13149
  if (value === "") {
11856
13150
  status.textContent = t("keyGenerationFailed");
11857
13151
  return;
11858
13152
  }
11859
13153
  try {
11860
13154
  await navigator.clipboard.writeText(value);
11861
- status.textContent = target === "key" ? t("keyCopied") : t("linkCopied");
13155
+ status.textContent = t("linkCopied");
11862
13156
  } catch {
11863
13157
  status.textContent = t("copySecret", {
11864
- kind: target === "key" ? t("pairingKey") : t("pairingLink"),
13158
+ kind: t("pairingLink"),
11865
13159
  value
11866
13160
  });
11867
13161
  status.classList.add("is-key");
@@ -11869,6 +13163,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11869
13163
  }, (error) => {
11870
13164
  status.textContent = t("requestFailed", { error: String(error) });
11871
13165
  }).finally(() => {
13166
+ lanPairingBusy = false;
11872
13167
  pair.disabled = !running;
11873
13168
  linkPair.disabled = !running;
11874
13169
  });
@@ -11923,12 +13218,15 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11923
13218
  const loadDevices = () => {
11924
13219
  controlRequestJson("/api/mobile-access/lan/devices").then(renderDevices, (error) => {
11925
13220
  status.textContent = t("requestFailed", { error: String(error) });
13221
+ devicePanel.replaceChildren();
13222
+ const note = element("p", "dsh-mobile-control__device-empty");
13223
+ note.textContent = t("noDevices");
13224
+ devicePanel.append(note);
11926
13225
  });
11927
13226
  };
11928
13227
  manageDevices.addEventListener("click", () => {
11929
- const show = devicePanel.hidden;
11930
- devicePanel.hidden = !show;
11931
- if (show) loadDevices();
13228
+ selectView("devices");
13229
+ loadDevices();
11932
13230
  });
11933
13231
  resetAll.addEventListener("click", () => {
11934
13232
  if (!window.confirm(t("confirmResetDevices"))) return;
@@ -11940,6 +13238,15 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11940
13238
  });
11941
13239
  });
11942
13240
  const renderRemote = (data) => {
13241
+ if (data.provider !== latestRemoteState.provider || data.origin !== latestRemoteState.origin) {
13242
+ remotePairingGeneration += 1;
13243
+ activeRemotePairUrl = "";
13244
+ activeRemotePairExpiresAt = 0;
13245
+ remotePairLink.hidden = true;
13246
+ remotePairLink.textContent = "";
13247
+ pairingValidity.hidden = true;
13248
+ showQr("", remoteQr);
13249
+ }
11943
13250
  latestRemoteState = data;
11944
13251
  remoteRunning = data.running === true;
11945
13252
  remoteProvider = parseMobileRemoteProvider(data.provider);
@@ -11950,11 +13257,6 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11950
13257
  const iroh = remoteProvider === "iroh";
11951
13258
  const managedOrigin = origin && data.originMode === "managed";
11952
13259
  const tailscale = remoteProvider === "tailscale";
11953
- tailscaleChoice.classList.toggle("is-selected", tailscale);
11954
- cpolarChoice.classList.toggle("is-selected", cpolar);
11955
- cloudflaredChoice.classList.toggle("is-selected", cloudflared);
11956
- frpChoice.classList.toggle("is-selected", frp);
11957
- originChoice.classList.toggle("is-selected", origin);
11958
13260
  tailscaleChoice.setAttribute("aria-checked", String(tailscale));
11959
13261
  cpolarChoice.setAttribute("aria-checked", String(cpolar));
11960
13262
  cloudflaredChoice.setAttribute("aria-checked", String(cloudflared));
@@ -11964,7 +13266,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11964
13266
  frpChoice.setAttribute("aria-pressed", String(frp));
11965
13267
  originChoice.setAttribute("aria-pressed", String(origin));
11966
13268
  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";
13269
+ applyChoiceSelection();
11968
13270
  tailscaleChoice.disabled = remoteProviderBusy;
11969
13271
  cpolarChoice.disabled = remoteProviderBusy;
11970
13272
  cloudflaredChoice.disabled = remoteProviderBusy;
@@ -11976,7 +13278,6 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11976
13278
  frpSetup.hidden = !frp;
11977
13279
  originSetup.hidden = !origin;
11978
13280
  irohSetup.hidden = !iroh;
11979
- if (frp || origin || iroh) selfHosted.open = true;
11980
13281
  tailscaleInfo.hidden = !tailscale;
11981
13282
  remoteReset.textContent = tailscale ? t("resetRemoteLogin") : t("resetRemoteDevices");
11982
13283
  const providers = data.providers !== null && typeof data.providers === "object" ? data.providers : {};
@@ -11991,7 +13292,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11991
13292
  const componentStorage = typeof component.storagePath === "string" ? component.storagePath : `DSH Mobile ${t("pluginPrivateDirectory")}`;
11992
13293
  cpolarStorage.textContent = componentStorage;
11993
13294
  cpolarStorage.title = componentStorage;
11994
- cpolarInstall.hidden = cpolarInstalled || !cpolarSupported;
13295
+ cpolarInstall.hidden = !cpolar || cpolarInstalled || !cpolarSupported;
11995
13296
  cpolarInstall.textContent = componentDownloadBytes > 0 ? t("installWithSize", { size: formatMegabytes(componentDownloadBytes) }) : t("installOfficial");
11996
13297
  cpolarInstall.disabled = remoteProviderBusy;
11997
13298
  cpolarAccount.hidden = !cpolarInstalled || cpolarConfigured;
@@ -12010,7 +13311,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12010
13311
  cloudflaredStorage.textContent = cloudflaredStoragePath;
12011
13312
  cloudflaredStorage.title = cloudflaredStoragePath;
12012
13313
  cloudflaredChoiceBadge.textContent = cloudflaredInstalled ? t("ready") : t("cloudflaredBadge");
12013
- cloudflaredInstall.hidden = cloudflaredInstalled || !cloudflaredSupported;
13314
+ cloudflaredInstall.hidden = !cloudflared || cloudflaredInstalled || !cloudflaredSupported;
12014
13315
  cloudflaredInstall.textContent = cloudflaredDownloadBytes > 0 ? t("installWithSize", { size: formatMegabytes(cloudflaredDownloadBytes) }) : t("installOfficial");
12015
13316
  cloudflaredInstall.disabled = remoteProviderBusy;
12016
13317
  cloudflaredPurge.hidden = !cloudflaredInstalled;
@@ -12031,8 +13332,8 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12031
13332
  cloudflaredModeNamed.disabled = remoteProviderBusy;
12032
13333
  cloudflaredModeHint.textContent = cloudflaredShownMode === "named" ? t("cloudflaredTunnelNamedHint") : t("cloudflaredTunnelQuickHint");
12033
13334
  cloudflaredNote.textContent = cloudflaredShownMode === "named" ? t("cloudflaredNamedNote") : t("cloudflaredQuickNote");
12034
- cloudflaredTunnel.hidden = cloudflaredShownMode !== "named";
12035
13335
  cloudflaredTunnelSave.textContent = cloudflaredSaveLabel();
13336
+ syncCloudflaredForm();
12036
13337
  if (!cloudflaredHostnameDirty) cloudflaredHostname.value = configuredCloudflaredHostname;
12037
13338
  if (!cloudflaredPortDirty) cloudflaredPort.value = String(configuredCloudflaredPort);
12038
13339
  cloudflaredToken.placeholder = cloudflaredTunnelConfigured ? t("cloudflaredTunnelTokenSaved") : t("cloudflaredTunnelTokenPlaceholder");
@@ -12140,6 +13441,8 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12140
13441
  const irohDownloadBytes = typeof irohComponent.downloadBytes === "number" ? irohComponent.downloadBytes : 0;
12141
13442
  const irohStoragePath = typeof irohComponent.storagePath === "string" ? irohComponent.storagePath : `DSH Mobile ${t("pluginPrivateDirectory")}`;
12142
13443
  irohInstall.hidden = irohInstalled || !irohSupported;
13444
+ irohPurge.hidden = !irohInstalled;
13445
+ irohPurge.disabled = iroh && remoteRunning;
12143
13446
  if (irohVerifying) {
12144
13447
  irohInstallProgress.hidden = false;
12145
13448
  irohInstall.disabled = true;
@@ -12233,11 +13536,9 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12233
13536
  }[errorCode] ?? "";
12234
13537
  const needsFunnelSetup = state === "error" && remoteSetupUrl !== "";
12235
13538
  remoteReady = remoteRunning && state === "ready" && remoteOrigin !== "";
12236
- selfHostedBadge.textContent = origin ? managedOrigin ? t(remoteReady ? "ready" : "advanced") : originListening ? t("originBackendReady") : t("advanced") : frpConfigured && frpInstalled ? t("ready") : t("advanced");
12237
- remoteStateBadge.classList.toggle("is-ready", remoteReady);
12238
- remoteStateBadge.classList.toggle("is-busy", state === "starting" || state === "connecting" || state === "needs-login");
12239
- remoteStateBadge.classList.toggle("is-attention", state === "error" || state === "unavailable");
12240
- remoteStateBadge.textContent = remoteReady ? origin && !managedOrigin ? t("originBackendReady") : t("ready") : state === "starting" || state === "connecting" || state === "needs-login" ? t("remoteStateConnecting") : state === "error" || state === "unavailable" ? t("remoteStateAttention") : t("remoteStateOff");
13539
+ frpChoiceBadge.textContent = frpConfigured && frpInstalled ? t("ready") : t("advanced");
13540
+ originChoiceBadge.textContent = managedOrigin ? t(remoteReady ? "ready" : "advanced") : originListening ? t("originBackendReady") : t("advanced");
13541
+ if (remoteStatus.style) remoteStatus.style.display = state === "off" && remoteFailureText === "" ? "none" : "";
12241
13542
  remoteAccess.hidden = !remoteReady;
12242
13543
  remoteAccessLink.href = remoteOrigin;
12243
13544
  remoteAccessLink.textContent = remoteOrigin;
@@ -12245,7 +13546,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12245
13546
  remoteStatus.classList.toggle("is-running", remoteReady);
12246
13547
  const labels = {
12247
13548
  off: t("remoteOff"),
12248
- unavailable: cpolar ? t("remoteUnavailableCpolar") : cloudflared ? t("remoteUnavailableCloudflared") : frp ? t("remoteUnavailableFrp") : origin ? t("originUnavailable") : iroh ? t("irohUnavailable") : t("remoteUnavailableTailscale"),
13549
+ unavailable: cpolar ? t("remoteUnavailableCpolar") : cloudflared ? t("remoteUnavailableCloudflared") : frp ? t("remoteUnavailableFrp") : origin ? t("originUnavailable") : iroh ? irohInstalled ? t("irohInstalledNotReady") : t("irohUnavailable") : t("remoteUnavailableTailscale"),
12249
13550
  starting: cpolar ? t("remoteStartingCpolar") : cloudflared ? t("remoteStartingCloudflared") : frp ? t("remoteStartingFrp") : origin ? t("originStarting") : iroh ? t("irohStarting") : t("remoteStartingTailscale"),
12250
13551
  "needs-login": t("remoteNeedsLogin"),
12251
13552
  connecting: cpolar ? t("remoteConnectingCpolar") : cloudflared ? t("remoteConnectingCloudflared") : frp ? t("remoteConnectingFrp") : iroh ? t("irohConnecting") : t("remoteConnectingTailscale"),
@@ -12264,12 +13565,19 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12264
13565
  remoteToggle.textContent = remoteRunning ? t("disableRemote") : t("enableRemote");
12265
13566
  const originPrepared = data.originMode === "managed" ? caddySupported && caddyInstalled && caddyConfiguration.configured === true : originConfigured && !caddyModeManagedSelected;
12266
13567
  const providerPrepared = cpolar ? cpolarInstalled && cpolarConfigured : cloudflared ? cloudflaredInstalled : frp ? frpInstalled && frpConfigured : origin ? originPrepared : iroh ? irohInstalled : true;
12267
- remoteToggle.disabled = remoteProviderBusy || !providerPrepared && !(origin && remoteRunning);
13568
+ const namedUnconfigured = cloudflared && cloudflaredShownMode === "named" && cloudflaredTunnelMode !== "named" && !remoteRunning;
13569
+ remoteToggle.disabled = remoteProviderBusy || !providerPrepared && !(origin && remoteRunning) || namedUnconfigured;
13570
+ if (namedUnconfigured) {
13571
+ remoteStatus.textContent = t("cloudflaredEnableNeedsConfig");
13572
+ if (remoteStatus.style) remoteStatus.style.display = "";
13573
+ }
12268
13574
  remoteLogin.hidden = !tailscale || state !== "needs-login" || remoteLoginUrl === "";
12269
13575
  remoteReconnect.hidden = needsFunnelSetup || state !== "error" && state !== "unavailable" || !providerPrepared;
12270
13576
  remoteActions.hidden = !providerPrepared && !(origin && remoteRunning);
12271
- remotePair.disabled = !remoteReady || remoteProviderBusy;
12272
- remoteCopyLink.disabled = !remoteReady || remoteProviderBusy;
13577
+ if (!providerPrepared) remoteQr.hidden = true;
13578
+ providerInstallRow.hidden = cpolarInstall.hidden && cloudflaredInstall.hidden;
13579
+ remotePair.disabled = !remoteReady || remoteProviderBusy || remotePairingBusy;
13580
+ remoteCopyLink.disabled = !remoteReady || remoteProviderBusy || remotePairingBusy;
12273
13581
  remoteDevices.disabled = !remoteReady || remoteProviderBusy;
12274
13582
  remotePair.hidden = iroh;
12275
13583
  remoteCopyLink.hidden = iroh;
@@ -12278,24 +13586,49 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12278
13586
  remotePairLink.hidden = true;
12279
13587
  }
12280
13588
  irohPair.disabled = !remoteReady || !iroh || remoteProviderBusy;
13589
+ irohPair.hidden = !iroh;
13590
+ irohCopyDescriptor.hidden = !iroh;
13591
+ if (iroh && irohInstalled && activeIrohQrSvg === "") showIrohQrPlaceholder();
13592
+ else if (!iroh || !irohInstalled) irohQr.hidden = true;
12281
13593
  if ((!iroh || !remoteReady) && activeIrohDescriptor !== "") {
12282
13594
  activeIrohDescriptor = "";
13595
+ activeIrohPairExpiresAt = 0;
12283
13596
  activeIrohQrSvg = "";
12284
13597
  showQr("", irohQr);
12285
13598
  setIrohQrZoomOpen(false);
12286
- irohCopyDescriptor.hidden = true;
12287
13599
  irohPairStatus.textContent = "";
12288
13600
  }
13601
+ irohCopyDescriptor.disabled = !iroh || !remoteReady || remoteProviderBusy || activeIrohDescriptor === "" || activeIrohPairExpiresAt <= Date.now();
12289
13602
  if (origin) {
12290
13603
  remoteReconnect.disabled = remoteProviderBusy || remoteReconnectBusy;
12291
13604
  remoteReset.disabled = remoteProviderBusy;
12292
13605
  } else remoteReset.disabled = false;
12293
13606
  if (!remoteReady) {
12294
- remoteQr.hidden = true;
13607
+ remotePairingGeneration += 1;
12295
13608
  remotePairLink.hidden = true;
12296
13609
  activeRemotePairUrl = "";
12297
13610
  activeRemotePairExpiresAt = 0;
13611
+ pairingValidity.hidden = true;
13612
+ if (iroh) remoteQr.hidden = true;
13613
+ else showRemoteQrPlaceholder(t("remoteQrPlaceholderIdle"));
13614
+ } else if (!iroh && activeRemotePairExpiresAt > 0 && Date.now() >= activeRemotePairExpiresAt) {
13615
+ activeRemotePairUrl = "";
13616
+ remotePairLink.hidden = true;
13617
+ remotePairLink.textContent = "";
13618
+ showRemoteQrPlaceholder(t("pairingExpired"));
13619
+ pairingValidity.hidden = false;
13620
+ pairingValidity.textContent = t("pairingExpired");
13621
+ } else if (!iroh && activeRemotePairUrl !== "") {
13622
+ pairingValidity.hidden = false;
13623
+ pairingValidity.textContent = t("pairingExpires", { time: formatTime(activeRemotePairExpiresAt) });
13624
+ } else {
13625
+ pairingValidity.hidden = true;
13626
+ if (!iroh) showQr("", remoteQr);
12298
13627
  }
13628
+ cpolarDetails.hidden = !cpolar;
13629
+ cloudflaredDetails.hidden = !cloudflared;
13630
+ frpDetails.hidden = !frp;
13631
+ irohDetails.hidden = !iroh;
12299
13632
  if (!needsFunnelSetup) remoteSetupPending = false;
12300
13633
  };
12301
13634
  frpMode.addEventListener("change", () => {
@@ -12405,6 +13738,26 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12405
13738
  loadRemote();
12406
13739
  });
12407
13740
  });
13741
+ irohPurge.addEventListener("click", () => {
13742
+ if (remoteProviderBusy) return;
13743
+ if (!window.confirm(t("irohPurgeConfirm"))) return;
13744
+ remoteProviderBusy = true;
13745
+ irohPurge.disabled = true;
13746
+ irohComponentStatus.textContent = t("purgingIroh");
13747
+ remoteStatus.textContent = t("purgingIroh");
13748
+ controlRequestJson("/api/mobile-access/remote/iroh/component/purge", {
13749
+ method: "POST",
13750
+ body: JSON.stringify({ confirm: true })
13751
+ }, LONG_CONTROL_REQUEST_TIMEOUT_MS).then(renderRemote, (error) => {
13752
+ remoteFailureText = remoteFailureTextFor(error, "purgeFailed");
13753
+ remoteStatus.textContent = remoteFailureText;
13754
+ irohComponentStatus.textContent = remoteFailureText;
13755
+ }).finally(() => {
13756
+ remoteProviderBusy = false;
13757
+ irohPurge.disabled = false;
13758
+ loadRemote();
13759
+ });
13760
+ });
12408
13761
  const openIrohQrZoom = () => {
12409
13762
  if (activeIrohQrSvg === "" || irohQr.hidden) return;
12410
13763
  irohQrZoomImage.src = `data:image/svg+xml;base64,${btoa(activeIrohQrSvg)}`;
@@ -12430,15 +13783,22 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12430
13783
  if (event.key !== "Escape") return;
12431
13784
  closeIrohQrZoom();
12432
13785
  });
12433
- const openIrohPairing = () => controlRequestJson("/api/mobile-access/remote/iroh/pairing/begin", {
12434
- method: "POST",
12435
- body: "{}"
12436
- }).then(async (data) => {
13786
+ const openIrohPairing = async () => {
13787
+ const generation = remotePairingGeneration;
13788
+ activeIrohDescriptor = "";
13789
+ activeIrohPairExpiresAt = 0;
13790
+ irohCopyDescriptor.disabled = true;
13791
+ const data = await controlRequestJson("/api/mobile-access/remote/iroh/pairing/begin", {
13792
+ method: "POST",
13793
+ body: "{}"
13794
+ });
13795
+ if (lifecycle.signal.aborted || remoteProvider !== "iroh" || !remoteReady || generation !== remotePairingGeneration) return;
12437
13796
  const record = data;
12438
13797
  const qrSvg = typeof record.qrSvg === "string" ? record.qrSvg : "";
12439
13798
  const code = typeof record.irohCode === "string" ? record.irohCode : "";
13799
+ const expiresAt = typeof record.expiresAt === "number" && Number.isFinite(record.expiresAt) ? record.expiresAt : 0;
12440
13800
  showQr(qrSvg, irohQr);
12441
- if (qrSvg === "" || code === "") {
13801
+ if (qrSvg === "" || code === "" || expiresAt <= Date.now()) {
12442
13802
  activeIrohQrSvg = "";
12443
13803
  irohPairStatus.textContent = t("keyGenerationFailed");
12444
13804
  return;
@@ -12459,12 +13819,14 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12459
13819
  const descriptor = {};
12460
13820
  for (const field of fields) descriptor[field] = record[field];
12461
13821
  activeIrohDescriptor = JSON.stringify(descriptor);
12462
- irohCopyDescriptor.hidden = false;
13822
+ activeIrohPairExpiresAt = expiresAt;
12463
13823
  irohPairStatus.textContent = t("irohPairingReady", { code });
12464
- });
13824
+ };
12465
13825
  irohPair.addEventListener("click", () => {
12466
- if (remoteProviderBusy) return;
13826
+ if (remoteProviderBusy || !remoteReady || remoteProvider !== "iroh") return;
12467
13827
  remoteProviderBusy = true;
13828
+ remoteSnapshotEpoch += 1;
13829
+ renderRemote(latestRemoteState);
12468
13830
  irohPair.disabled = true;
12469
13831
  irohPairStatus.textContent = t("loadingRemoteStatus");
12470
13832
  openIrohPairing().catch((error) => {
@@ -12476,9 +13838,9 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12476
13838
  });
12477
13839
  });
12478
13840
  irohCopyDescriptor.addEventListener("click", () => {
12479
- if (activeIrohDescriptor === "") return;
13841
+ if (remoteProviderBusy || !remoteReady || remoteProvider !== "iroh" || activeIrohDescriptor === "" || activeIrohPairExpiresAt <= Date.now()) return;
12480
13842
  navigator.clipboard.writeText(activeIrohDescriptor).then(() => {
12481
- irohPairStatus.textContent = t("linkCopied");
13843
+ irohPairStatus.textContent = t("irohLinkCopied");
12482
13844
  }, () => {
12483
13845
  irohPairStatus.textContent = t("remoteQrReady");
12484
13846
  });
@@ -12519,6 +13881,19 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12519
13881
  loadRemote();
12520
13882
  });
12521
13883
  });
13884
+ const syncCloudflaredForm = () => {
13885
+ const named = (cloudflaredModeDraft ?? cloudflaredTunnelMode) === "named";
13886
+ const locked = named && cloudflaredTunnelConfigured && cloudflaredTunnelMode === "named" && !cloudflaredConfigEditing;
13887
+ cloudflaredHostname.disabled = locked || remoteProviderBusy;
13888
+ cloudflaredPort.disabled = locked || remoteProviderBusy;
13889
+ cloudflaredToken.disabled = locked || remoteProviderBusy;
13890
+ cloudflaredEditButton.disabled = remoteProviderBusy;
13891
+ cloudflaredTunnelForget.disabled = remoteProviderBusy;
13892
+ cloudflaredTunnelToggle.hidden = !named;
13893
+ cloudflaredEditButton.hidden = !locked;
13894
+ cloudflaredTunnelSave.hidden = !named || locked;
13895
+ cloudflaredTunnelForget.hidden = !named || cloudflaredTunnelMode !== "named" || cloudflaredConfigEditing;
13896
+ };
12522
13897
  const showCloudflaredMode = (mode) => {
12523
13898
  cloudflaredModeDraft = mode;
12524
13899
  cloudflaredModeQuick.classList.toggle("is-active", mode === "quick");
@@ -12527,14 +13902,60 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12527
13902
  cloudflaredModeNamed.setAttribute("aria-pressed", String(mode === "named"));
12528
13903
  cloudflaredModeHint.textContent = mode === "named" ? t("cloudflaredTunnelNamedHint") : t("cloudflaredTunnelQuickHint");
12529
13904
  cloudflaredNote.textContent = mode === "named" ? t("cloudflaredNamedNote") : t("cloudflaredQuickNote");
12530
- cloudflaredTunnel.hidden = mode !== "named";
13905
+ cloudflaredTunnel.hidden = true;
13906
+ cloudflaredTunnelToggle.textContent = t("cloudflaredShowConfig") + " ▾";
12531
13907
  cloudflaredTunnelSave.textContent = cloudflaredSaveLabel();
13908
+ syncCloudflaredForm();
13909
+ if (mode === "named" && cloudflaredTunnelMode !== "named") {
13910
+ remoteToggle.disabled = true;
13911
+ remoteStatus.textContent = t("cloudflaredEnableNeedsConfig");
13912
+ if (remoteStatus.style) remoteStatus.style.display = "";
13913
+ }
13914
+ };
13915
+ cloudflaredTunnelToggle.addEventListener("click", () => {
13916
+ const open = cloudflaredTunnel.hidden;
13917
+ cloudflaredTunnel.hidden = !open;
13918
+ cloudflaredTunnelToggle.textContent = open ? t("cloudflaredHideConfig") + " ▴" : t("cloudflaredShowConfig") + " ▾";
13919
+ });
13920
+ cloudflaredEditButton.addEventListener("click", () => {
13921
+ cloudflaredConfigEditing = true;
13922
+ syncCloudflaredForm();
13923
+ cloudflaredTunnel.hidden = false;
13924
+ cloudflaredTunnelToggle.textContent = t("cloudflaredHideConfig") + " ▴";
13925
+ });
13926
+ const switchCloudflaredMode = (mode) => {
13927
+ if (remoteProviderBusy || (cloudflaredModeDraft ?? cloudflaredTunnelMode) === mode) return;
13928
+ const activeState = typeof latestRemoteState.state === "string" ? latestRemoteState.state : "off";
13929
+ if (remoteRunning || activeState !== "off") {
13930
+ if (!window.confirm(t("cloudflaredSwitchModeDisconnect"))) return;
13931
+ remoteProviderBusy = true;
13932
+ remoteSnapshotEpoch += 1;
13933
+ renderRemote(latestRemoteState);
13934
+ controlRequestJson("/api/mobile-access/remote/control", {
13935
+ method: "POST",
13936
+ body: JSON.stringify({
13937
+ running: false,
13938
+ provider: "cloudflared"
13939
+ })
13940
+ }).then((data) => {
13941
+ renderRemote(data);
13942
+ showCloudflaredMode(mode);
13943
+ }, (error) => {
13944
+ remoteFailureText = remoteFailureTextFor(error, "requestFailed");
13945
+ remoteStatus.textContent = remoteFailureText;
13946
+ }).finally(() => {
13947
+ remoteProviderBusy = false;
13948
+ loadRemote();
13949
+ });
13950
+ return;
13951
+ }
13952
+ showCloudflaredMode(mode);
12532
13953
  };
12533
13954
  cloudflaredModeQuick.addEventListener("click", () => {
12534
- if (!remoteProviderBusy) showCloudflaredMode("quick");
13955
+ switchCloudflaredMode("quick");
12535
13956
  });
12536
13957
  cloudflaredModeNamed.addEventListener("click", () => {
12537
- if (!remoteProviderBusy) showCloudflaredMode("named");
13958
+ switchCloudflaredMode("named");
12538
13959
  });
12539
13960
  cloudflaredHostname.addEventListener("input", () => {
12540
13961
  cloudflaredHostnameDirty = true;
@@ -12544,6 +13965,11 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12544
13965
  });
12545
13966
  cloudflaredTunnelSave.addEventListener("click", () => {
12546
13967
  if (remoteProviderBusy) return;
13968
+ const report = (message) => {
13969
+ remoteStatus.textContent = message;
13970
+ cloudflaredTunnelFeedback.hidden = false;
13971
+ cloudflaredTunnelFeedback.textContent = message;
13972
+ };
12547
13973
  const mode = cloudflaredModeDraft ?? cloudflaredTunnelMode;
12548
13974
  const hostname = String(cloudflaredHostname.value ?? "").trim();
12549
13975
  const portValue = String(cloudflaredPort.value ?? "").trim();
@@ -12551,48 +13977,57 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12551
13977
  const token = String(cloudflaredToken.value ?? "");
12552
13978
  if (mode === "named") {
12553
13979
  if (hostname === "") {
12554
- remoteStatus.textContent = t("cloudflaredTunnelHostnameInvalid");
13980
+ report(t("cloudflaredTunnelHostnameInvalid"));
12555
13981
  return;
12556
13982
  }
12557
13983
  if (portValue === "" || !Number.isSafeInteger(port) || port < 1024 || port > 65535) {
12558
- remoteStatus.textContent = t("cloudflaredTunnelPortInvalid");
13984
+ report(t("cloudflaredTunnelPortInvalid"));
12559
13985
  return;
12560
13986
  }
12561
13987
  if (port === 3443) {
12562
- remoteStatus.textContent = t("cloudflaredTunnelPortReserved");
13988
+ report(t("cloudflaredTunnelPortReserved"));
12563
13989
  return;
12564
13990
  }
12565
13991
  if (token === "" && !cloudflaredTunnelConfigured) {
12566
- remoteStatus.textContent = t("cloudflaredTunnelConfigMissing");
13992
+ report(t("cloudflaredTunnelConfigMissing"));
12567
13993
  return;
12568
13994
  }
12569
13995
  if (token !== "" && (token.length < 32 || !/^[A-Za-z0-9_=+/-]+$/u.test(token))) {
12570
- remoteStatus.textContent = t("cloudflaredTunnelTokenInvalid");
13996
+ report(t("cloudflaredTunnelTokenInvalid"));
12571
13997
  return;
12572
13998
  }
12573
13999
  }
12574
14000
  remoteProviderBusy = true;
14001
+ remoteSnapshotEpoch += 1;
14002
+ renderRemote(latestRemoteState);
12575
14003
  cloudflaredTunnelSave.disabled = true;
12576
14004
  cloudflaredTunnelSave.textContent = t("saving");
12577
- remoteStatus.textContent = mode === "named" ? t("cloudflaredTunnelSaving") : t("switchingCloudflared");
14005
+ report(mode === "named" ? t("cloudflaredTunnelSaving") : t("switchingCloudflared"));
12578
14006
  controlRequestJson("/api/mobile-access/remote/cloudflared/tunnel", {
12579
14007
  method: "POST",
12580
14008
  body: JSON.stringify(mode === "named" ? {
12581
14009
  mode,
12582
14010
  hostname,
12583
14011
  port,
12584
- token
12585
- } : { mode: "quick" })
14012
+ token,
14013
+ connect: true
14014
+ } : {
14015
+ mode: "quick",
14016
+ connect: true
14017
+ })
12586
14018
  }).then((data) => {
12587
14019
  cloudflaredToken.value = "";
12588
14020
  cloudflaredModeDraft = null;
14021
+ cloudflaredConfigEditing = false;
12589
14022
  cloudflaredHostnameDirty = false;
12590
14023
  cloudflaredPortDirty = false;
12591
14024
  remoteFailureText = "";
14025
+ cloudflaredTunnelFeedback.hidden = false;
14026
+ cloudflaredTunnelFeedback.textContent = t("cloudflaredTunnelSaved");
12592
14027
  renderRemote(data);
12593
14028
  }, (error) => {
12594
14029
  remoteFailureText = remoteFailureTextFor(error, "requestFailed");
12595
- remoteStatus.textContent = remoteFailureText;
14030
+ report(remoteFailureText);
12596
14031
  }).finally(() => {
12597
14032
  remoteProviderBusy = false;
12598
14033
  cloudflaredTunnelSave.disabled = false;
@@ -13457,14 +14892,35 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
13457
14892
  });
13458
14893
  });
13459
14894
  remoteToggle.addEventListener("click", () => {
13460
- remoteToggle.disabled = true;
13461
- controlRequestJson("/api/mobile-access/remote/control", {
14895
+ if (remoteProviderBusy) return;
14896
+ const nextRunning = !remoteRunning;
14897
+ const provider = remoteProvider;
14898
+ remoteProviderBusy = true;
14899
+ remoteSnapshotEpoch += 1;
14900
+ renderRemote(latestRemoteState);
14901
+ (nextRunning && provider === "cloudflared" && cloudflaredModeDraft !== null && cloudflaredModeDraft !== cloudflaredTunnelMode ? controlRequestJson("/api/mobile-access/remote/cloudflared/tunnel", {
13462
14902
  method: "POST",
13463
- body: JSON.stringify({ running: !remoteRunning })
13464
- }).then(renderRemote, (error) => {
14903
+ body: JSON.stringify({
14904
+ mode: cloudflaredModeDraft,
14905
+ connect: true
14906
+ })
14907
+ }) : controlRequestJson("/api/mobile-access/remote/control", {
14908
+ method: "POST",
14909
+ body: JSON.stringify({
14910
+ running: nextRunning,
14911
+ provider
14912
+ })
14913
+ })).then(renderRemote, (error) => {
13465
14914
  remoteFailureText = remoteFailureTextFor(error, "requestFailed");
13466
14915
  remoteStatus.textContent = remoteFailureText;
13467
- }).finally(loadRemote);
14916
+ if (remoteProvider === "cloudflared") {
14917
+ cloudflaredTunnelFeedback.hidden = false;
14918
+ cloudflaredTunnelFeedback.textContent = remoteFailureText;
14919
+ }
14920
+ }).finally(() => {
14921
+ remoteProviderBusy = false;
14922
+ loadRemote();
14923
+ });
13468
14924
  });
13469
14925
  remoteLogin.addEventListener("click", () => {
13470
14926
  if (remoteLoginUrl !== "") window.open(remoteLoginUrl, "_blank", "noopener,noreferrer");
@@ -13516,40 +14972,64 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
13516
14972
  remoteStatus.textContent = t("remoteQrReady");
13517
14973
  }
13518
14974
  };
13519
- const openRemotePairing = () => controlRequestJson("/api/mobile-access/remote/pairing/open", {
13520
- method: "POST",
13521
- body: "{}"
13522
- }).then(async (data) => {
13523
- const pairUrl = typeof data.pairUrl === "string" ? data.pairUrl : "";
13524
- const expiresAt = typeof data.expiresAt === "number" && Number.isFinite(data.expiresAt) ? data.expiresAt : 0;
13525
- showQr(typeof data.qrSvg === "string" ? data.qrSvg : "", remoteQr);
13526
- if (pairUrl === "") {
13527
- remoteStatus.textContent = t("keyGenerationFailed");
13528
- return;
13529
- }
13530
- activeRemotePairUrl = pairUrl;
13531
- activeRemotePairExpiresAt = expiresAt;
13532
- remotePairLink.textContent = pairUrl;
13533
- remotePairLink.classList.add("is-key");
13534
- remotePairLink.hidden = false;
13535
- await copyRemotePairUrl();
13536
- });
14975
+ const openRemotePairing = () => {
14976
+ const provider = remoteProvider;
14977
+ const origin = latestRemoteState.origin;
14978
+ const generation = remotePairingGeneration;
14979
+ activeRemotePairUrl = "";
14980
+ activeRemotePairExpiresAt = 0;
14981
+ pairingValidity.hidden = true;
14982
+ remotePairLink.hidden = true;
14983
+ remotePairLink.textContent = "";
14984
+ showQr("", remoteQr);
14985
+ return controlRequestJson("/api/mobile-access/remote/pairing/open", {
14986
+ method: "POST",
14987
+ body: "{}"
14988
+ }).then(async (data) => {
14989
+ if (lifecycle.signal.aborted || !remoteReady || provider !== remoteProvider || origin !== latestRemoteState.origin || generation !== remotePairingGeneration) return;
14990
+ const pairUrl = typeof data.pairUrl === "string" ? data.pairUrl : "";
14991
+ const expiresAt = typeof data.expiresAt === "number" && Number.isFinite(data.expiresAt) ? data.expiresAt : 0;
14992
+ if (pairUrl === "" || expiresAt <= Date.now()) {
14993
+ remoteStatus.textContent = t("keyGenerationFailed");
14994
+ return;
14995
+ }
14996
+ showQr(typeof data.qrSvg === "string" ? data.qrSvg : "", remoteQr);
14997
+ activeRemotePairUrl = pairUrl;
14998
+ activeRemotePairExpiresAt = expiresAt;
14999
+ pairingValidity.hidden = false;
15000
+ pairingValidity.textContent = t("pairingExpires", { time: formatTime(expiresAt) });
15001
+ remotePairLink.textContent = pairUrl;
15002
+ remotePairLink.classList.add("is-key");
15003
+ remotePairLink.hidden = false;
15004
+ await copyRemotePairUrl();
15005
+ });
15006
+ };
13537
15007
  remotePair.addEventListener("click", () => {
15008
+ if (!remoteReady || remoteProviderBusy || remotePairingBusy) return;
15009
+ remotePairingBusy = true;
13538
15010
  remotePair.disabled = true;
15011
+ remoteCopyLink.disabled = true;
13539
15012
  openRemotePairing().catch((error) => {
13540
15013
  remoteFailureText = remoteFailureTextFor(error, "requestFailed");
13541
15014
  remoteStatus.textContent = remoteFailureText;
13542
15015
  }).finally(() => {
13543
- remotePair.disabled = !remoteReady;
15016
+ remotePairingBusy = false;
15017
+ remotePair.disabled = !remoteReady || remoteProviderBusy;
15018
+ remoteCopyLink.disabled = !remoteReady || remoteProviderBusy;
13544
15019
  });
13545
15020
  });
13546
15021
  remoteCopyLink.addEventListener("click", () => {
15022
+ if (!remoteReady || remoteProviderBusy || remotePairingBusy) return;
15023
+ remotePairingBusy = true;
15024
+ remotePair.disabled = true;
13547
15025
  remoteCopyLink.disabled = true;
13548
15026
  (activeRemotePairUrl !== "" && activeRemotePairExpiresAt > Date.now() ? copyRemotePairUrl() : openRemotePairing()).catch((error) => {
13549
15027
  remoteFailureText = remoteFailureTextFor(error, "requestFailed");
13550
15028
  remoteStatus.textContent = remoteFailureText;
13551
15029
  }).finally(() => {
13552
- remoteCopyLink.disabled = !remoteReady;
15030
+ remotePairingBusy = false;
15031
+ remotePair.disabled = !remoteReady || remoteProviderBusy;
15032
+ remoteCopyLink.disabled = !remoteReady || remoteProviderBusy;
13553
15033
  });
13554
15034
  });
13555
15035
  const renderRemoteDevices = (data) => {
@@ -13588,6 +15068,10 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
13588
15068
  controlRequestJson("/api/mobile-access/remote/devices").then(renderRemoteDevices, (error) => {
13589
15069
  remoteFailureText = remoteFailureTextFor(error, "requestFailed");
13590
15070
  remoteStatus.textContent = remoteFailureText;
15071
+ remoteDevicePanel.replaceChildren();
15072
+ const note = element("p", "dsh-mobile-control__device-empty");
15073
+ note.textContent = remoteRunning ? t("noRemoteDevices") : t("remoteOff");
15074
+ remoteDevicePanel.append(note);
13591
15075
  });
13592
15076
  };
13593
15077
  remoteDevices.addEventListener("click", () => {
@@ -13746,6 +15230,14 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
13746
15230
  selectView("diagnostics");
13747
15231
  loadDiagnostics();
13748
15232
  });
15233
+ devicesEntry.addEventListener("click", () => {
15234
+ if (!devicesView.hidden) {
15235
+ selectView(previousAccessView);
15236
+ return;
15237
+ }
15238
+ selectView("devices");
15239
+ showDevicesGroup("lan");
15240
+ });
13749
15241
  diagnosticsRun.addEventListener("click", loadDiagnostics);
13750
15242
  diagnosticsRun.addEventListener("click", () => {
13751
15243
  extensionRecovery.refresh();
@@ -13806,11 +15298,9 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
13806
15298
  });
13807
15299
  });
13808
15300
  pair.addEventListener("click", () => {
13809
- pair.disabled = true;
13810
15301
  openPairing("key");
13811
15302
  });
13812
15303
  linkPair.addEventListener("click", () => {
13813
- linkPair.disabled = true;
13814
15304
  openPairing("link");
13815
15305
  });
13816
15306
  close.addEventListener("click", () => {
@@ -13818,12 +15308,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
13818
15308
  });
13819
15309
  const dismiss = (event) => {
13820
15310
  if (panel.hidden || !(event.target instanceof Node)) return;
13821
- if (!providerInfo.contains(event.target)) {
13822
- providerInfoPinned = false;
13823
- providerInfoHovered = false;
13824
- syncProviderInfo();
13825
- }
13826
- if (!panel.contains(event.target) && !document.querySelector(".dsh-mobile-control__trigger")?.contains(event.target)) setOpen(false);
15311
+ if (!panel.contains(event.target) && !irohQrZoom.contains(event.target) && !document.querySelector(".dsh-mobile-control__trigger")?.contains(event.target)) setOpen(false);
13827
15312
  };
13828
15313
  document.addEventListener("pointerdown", dismiss);
13829
15314
  controlRequestJson("/api/mobile-access/release").then(renderRelease, () => {});
@@ -13832,7 +15317,10 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
13832
15317
  });
13833
15318
  loadRemote();
13834
15319
  const remotePoll = window.setInterval(() => {
13835
- if (!panel.hidden && !remoteView.hidden) loadRemote();
15320
+ if (!panel.hidden && !remoteView.hidden) {
15321
+ renderLanPairingExpiry();
15322
+ loadRemote();
15323
+ }
13836
15324
  }, 1500);
13837
15325
  const pollWsBlocked = () => {
13838
15326
  controlRequestJson("/api/mobile-access/remote/websocket-paths/blocked").then((blocked) => {
@@ -15095,9 +16583,31 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
15095
16583
  /** Theme-aware desktop Mobile Access panel styles. */
15096
16584
  const CONTROL_STYLES = `
15097
16585
  .dsh-mobile-control{position:fixed;z-index:1000;left:16px;bottom:112px;font:14px/1.45 system-ui;color:var(--dsw-alias-label-primary,#16181d)}
15098
- .dsh-mobile-control__panel{box-sizing:border-box;width:min(380px,calc(100vw - 32px));max-height:calc(100vh - 140px);overflow-y:auto;padding:16px;border:1px solid var(--dsw-alias-border-l2,#e1e5eb);border-radius:18px;background:var(--dsw-alias-bg-layer-2,#fff);box-shadow:0 18px 50px rgb(15 23 42 / 18%)}
16586
+ .dsh-mobile-control__panel{box-sizing:border-box;width:min(704px,calc(100vw - 32px));max-height:calc(100vh - 140px);overflow-y:auto;padding:16px;border:0;border-radius:16px;background:var(--dsw-alias-bg-layer-2,#fff);box-shadow:0 12px 34px rgb(15 23 42 / 13%)}.dsh-mobile-control__remote-columns{display:grid;grid-template-columns:minmax(230px,300px) minmax(0,1fr);gap:14px;align-items:start;max-height:calc(100vh - 330px)}
16587
+ /* Floating panel with dense, borderless content. */
16588
+ .dsh-mobile-control{inset:auto;left:16px;bottom:112px;pointer-events:none}
16589
+ .dsh-mobile-control__panel{position:relative;inset:auto;width:min(704px,calc(100vw - 32px));height:auto;max-height:calc(100vh - 140px);border:0;border-radius:16px;box-shadow:0 12px 34px rgb(15 23 42 / 13%);display:flex;flex-direction:column;overflow:hidden;pointer-events:auto;background:var(--dsw-alias-bg-layer-2,#fff)}.dsh-mobile-control__panel[hidden]{display:none}
16590
+ .dsh-mobile-control__header{flex:none;margin:0;padding:12px 16px;border-bottom:1px solid var(--dsw-alias-border-l2,#e1e5eb)}
16591
+ .dsh-mobile-control__app-downloads{flex:none;padding:8px 16px;border-bottom:1px solid var(--dsw-alias-border-l2,#e1e5eb);margin:0}
16592
+ .dsh-mobile-control__view.is-remote{flex:1;min-height:0;display:flex;flex-direction:column}
16593
+ .dsh-mobile-control__remote-columns{flex:1;min-height:0;display:grid;grid-template-columns:260px minmax(0,1fr);gap:0;max-height:none;align-items:stretch}
16594
+ .dsh-mobile-control__remote-columns>.dsh-mobile-control__provider-section{overflow-y:auto;border-right:1px solid var(--dsw-alias-border-l2,#e1e5eb);background:var(--dsw-alias-bg-layer-1,#f7f8fa);padding:10px 8px}
16595
+ .dsh-mobile-control__channel-pane{overflow-y:auto;padding:14px 20px;min-width:0;scrollbar-gutter:stable}
16596
+ .dsh-mobile-control__provider{position:relative;border:0;border-radius:8px;padding:8px 10px;min-height:0;background:transparent;box-shadow:none}
16597
+ .dsh-mobile-control__provider:hover{background:var(--dsw-alias-interactive-bg-hover,#f1f5f9)}
16598
+ .dsh-mobile-control__provider.is-selected{background:var(--dsw-alias-interactive-bg-active,#f5f8ff);box-shadow:none}
16599
+ .dsh-mobile-control__provider.is-selected::before{content:"";position:absolute;left:0;top:8px;bottom:8px;width:2px;border-radius:2px;background:var(--dsw-alias-label-primary-bluish,#2563eb)}
16600
+ .dsh-mobile-control__remote-workspace section{border:0;border-radius:0;padding:12px 0;background:transparent;box-shadow:none;border-top:1px solid var(--dsw-alias-border-l2,#e1e5eb)}
16601
+ .dsh-mobile-control__remote-workspace section:first-child{border-top:0}
16602
+ .dsh-mobile-control__remote-workspace .dsh-mobile-control__details{border:0;border-radius:0;background:transparent;box-shadow:none}
16603
+ .dsh-mobile-control__btn,.dsh-mobile-control__primary,.dsh-mobile-control__secondary{min-height:32px;padding:6px 12px;border-radius:8px;font-size:12px}
16604
+ .dsh-mobile-control__self-hosted{border:0;border-radius:0;background:transparent;box-shadow:none}
16605
+ .dsh-mobile-control__self-hosted-summary{border-bottom:1px solid var(--dsw-alias-border-l2,#e1e5eb)}
16606
+ .dsh-mobile-control__self-hosted-body{padding:0}
16607
+ @media (max-width:860px){.dsh-mobile-control__remote-columns{grid-template-columns:1fr}.dsh-mobile-control__remote-columns>.dsh-mobile-control__provider-section{border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2,#e1e5eb);max-height:40vh}}
16608
+ .dsh-mobile-control__channel-pane{min-width:0;overflow-y:auto;padding-right:4px;max-height:100%}.dsh-mobile-control__channel-pane>.dsh-mobile-control__view{display:block}.dsh-mobile-control__provider.is-lan{border-color:var(--dsw-alias-label-primary-bluish,#2563eb);background:var(--dsw-alias-interactive-bg-active,var(--dsw-alias-bg-layer-1,#f5f8ff))}.dsh-mobile-control__remote-columns>.dsh-mobile-control__provider-section{overflow-y:auto;padding-right:4px}.dsh-mobile-control__remote-columns>.dsh-mobile-control__remote-workspace{overflow-y:auto;padding-right:4px}.dsh-mobile-control__remote-columns>.dsh-mobile-control__provider-section{margin:0}@media (max-width:760px){.dsh-mobile-control__remote-columns{grid-template-columns:1fr}}
15099
16609
  .dsh-mobile-control__header{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px}.dsh-mobile-control__panel h2{margin:0;font-size:17px;line-height:24px}.dsh-mobile-control__header-actions{display:flex;align-items:center;gap:2px}.dsh-mobile-control__update-plugin,.dsh-mobile-control__diagnostic-entry,.dsh-mobile-control__close{display:inline-flex;align-items:center;justify-content:center;min-width:44px;height:44px;padding:0;border:0;border-radius:10px;background:transparent;color:inherit;cursor:pointer}.dsh-mobile-control__update-plugin,.dsh-mobile-control__diagnostic-entry{padding:0 8px;color:var(--dsw-alias-label-primary-bluish,#2563eb);font:650 12px/1 system-ui;white-space:nowrap}.dsh-mobile-control__update-plugin[hidden]{display:none}.dsh-mobile-control__update-plugin:disabled{cursor:wait;opacity:.55}.dsh-mobile-control__close{font-size:24px;line-height:1}.dsh-mobile-control__update-plugin:hover:not(:disabled),.dsh-mobile-control__diagnostic-entry:hover,.dsh-mobile-control__close:hover{background:var(--dsw-alias-interactive-bg-hover,#f1f3f6)}
15100
- .dsh-mobile-control__release-notice{margin:-2px 0 10px;padding:8px 10px;border-radius:9px;background:var(--dsw-alias-bg-layer-1,#eff6ff);color:var(--dsw-alias-label-primary,#1d4ed8);font-size:11px;line-height:1.45}.dsh-mobile-control__release-notice.is-error{color:var(--dsw-alias-label-primary,#dc2626);border-left:3px solid var(--dsw-alias-state-error-primary,#dc2626)}.dsh-mobile-control__release-notice[hidden]{display:none}.dsh-mobile-control__update-card{margin:0 0 10px;padding:11px;border:1px solid var(--dsw-alias-border-l2,#dbe1e8);border-radius:12px;background:var(--dsw-alias-bg-layer-1,#f8fafc)}.dsh-mobile-control__update-card[hidden]{display:none}.dsh-mobile-control__update-card-title{margin:0 0 7px;color:var(--dsw-alias-label-primary,#16181d);font:650 13px/1.4 system-ui}.dsh-mobile-control__update-notes{display:block;box-sizing:border-box;max-height:150px;overflow-y:auto;margin:0 0 8px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,#e1e5eb);border-radius:9px;background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-secondary,#475569);white-space:pre-wrap;word-break:break-word;font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.dsh-mobile-control__update-notes[hidden]{display:none}.dsh-mobile-control__update-notice{margin:0 0 9px;padding:7px 9px;border-radius:8px;background:var(--dsw-alias-state-warn-tertiary,#fff4d6);color:var(--dsw-alias-state-warn-label,#92400e);font-size:11px;line-height:1.5}.dsh-mobile-control__update-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px}.dsh-mobile-control__update-actions .dsh-mobile-control__primary,.dsh-mobile-control__update-actions .dsh-mobile-control__secondary{min-height:36px;padding:0 12px}.dsh-mobile-control__app-download{display:flex;align-items:center;justify-content:space-between;box-sizing:border-box;min-height:44px;margin:0 0 10px;padding:8px 11px;border:1px solid var(--dsw-alias-border-l2,#dbe1e8);border-radius:11px;background:var(--dsw-alias-bg-layer-1,#f7f8fa);color:var(--dsw-alias-label-primary,#16181d);font:600 12px/1.3 system-ui;text-decoration:none}.dsh-mobile-control__app-download[hidden]{display:none}.dsh-mobile-control__app-download::after{color:var(--dsw-alias-label-primary-bluish,#2563eb);font-size:14px;content:"↗"}.dsh-mobile-control__app-download:hover{border-color:#9fb9e8;background:var(--dsw-alias-interactive-bg-hover-solid,var(--dsw-alias-bg-layer-2,#f5f8ff));color:var(--dsw-alias-label-primary,#1d4ed8)}
16610
+ .dsh-mobile-control__release-notice{margin:-2px 0 10px;padding:8px 10px;border-radius:9px;background:var(--dsw-alias-bg-layer-1,#eff6ff);color:var(--dsw-alias-label-primary,#1d4ed8);font-size:11px;line-height:1.45}.dsh-mobile-control__release-notice.is-error{color:var(--dsw-alias-label-primary,#dc2626);border-left:3px solid var(--dsw-alias-state-error-primary,#dc2626)}.dsh-mobile-control__release-notice[hidden]{display:none}.dsh-mobile-control__update-card{margin:0 0 10px;padding:11px;border:1px solid var(--dsw-alias-border-l2,#dbe1e8);border-radius:12px;background:var(--dsw-alias-bg-layer-1,#f8fafc)}.dsh-mobile-control__update-card[hidden]{display:none}.dsh-mobile-control__update-card-title{margin:0 0 7px;color:var(--dsw-alias-label-primary,#16181d);font:650 13px/1.4 system-ui}.dsh-mobile-control__update-notes{display:block;box-sizing:border-box;max-height:150px;overflow-y:auto;margin:0 0 8px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,#e1e5eb);border-radius:9px;background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-secondary,#475569);white-space:pre-wrap;word-break:break-word;font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.dsh-mobile-control__update-notes[hidden]{display:none}.dsh-mobile-control__update-notice{margin:0 0 9px;padding:7px 9px;border-radius:8px;background:var(--dsw-alias-state-warn-tertiary,#fff4d6);color:var(--dsw-alias-state-warn-label,#92400e);font-size:11px;line-height:1.5}.dsh-mobile-control__update-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px}.dsh-mobile-control__primary{display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;min-height:40px;padding:9px 14px;border:1px solid var(--dsw-alias-label-primary-bluish,#2563eb);border-radius:10px;background:var(--dsw-alias-label-primary-bluish,#2563eb);color:#fff;font:600 12px/1.3 system-ui;cursor:pointer;touch-action:manipulation}.dsh-mobile-control__primary:hover:not(:disabled){border-color:#1d4ed8;background:#1d4ed8}.dsh-mobile-control__primary:active:not(:disabled){border-color:#1e40af;background:#1e40af}.dsh-mobile-control__primary:disabled{cursor:wait;opacity:.55}.dsh-mobile-control__primary[hidden]{display:none}.dsh-mobile-control__update-actions .dsh-mobile-control__primary,.dsh-mobile-control__update-actions .dsh-mobile-control__secondary{min-height:36px;padding:0 12px}.dsh-mobile-control__app-download{display:flex;align-items:center;justify-content:space-between;box-sizing:border-box;min-height:44px;margin:0 0 10px;padding:8px 11px;border:1px solid var(--dsw-alias-border-l2,#dbe1e8);border-radius:11px;background:var(--dsw-alias-bg-layer-1,#f7f8fa);color:var(--dsw-alias-label-primary,#16181d);font:600 12px/1.3 system-ui;text-decoration:none}.dsh-mobile-control__app-downloads{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px}.dsh-mobile-control__app-downloads .dsh-mobile-control__app-download{flex:1 1 240px;margin:0}.dsh-mobile-control__app-download.is-iroh{border-color:var(--dsw-alias-label-primary-bluish,#2563eb);color:var(--dsw-alias-label-primary-bluish,#2563eb)}.dsh-mobile-control__app-download.is-iroh[hidden]{display:none}.dsh-mobile-control__app-download[hidden]{display:none}.dsh-mobile-control__app-download::after{color:var(--dsw-alias-label-primary-bluish,#2563eb);font-size:14px;content:"↗"}.dsh-mobile-control__app-download:hover{border-color:#9fb9e8;background:var(--dsw-alias-interactive-bg-hover-solid,var(--dsw-alias-bg-layer-2,#f5f8ff));color:var(--dsw-alias-label-primary,#1d4ed8)}
15101
16611
  .dsh-mobile-control__switcher{display:grid;grid-template-columns:repeat(2,1fr);gap:4px;margin:0 0 14px;padding:4px;border-radius:12px;background:var(--dsw-alias-bg-layer-1,#f3f5f8)}.dsh-mobile-control__switcher[hidden]{display:none}.dsh-mobile-control__tab{min-height:36px;border:0;border-radius:9px;background:transparent;color:var(--dsw-alias-label-secondary,#606873);font:600 13px/1 system-ui;cursor:pointer}.dsh-mobile-control__tab.is-active{background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-primary,#16181d);box-shadow:0 1px 3px rgb(15 23 42 / 10%)}.dsh-mobile-control__view[hidden]{display:none}.dsh-mobile-control__intro{margin:0 0 12px;color:var(--dsw-alias-label-secondary,#606873);font-size:12px;line-height:1.55}.dsh-mobile-control__http-frame-warning{box-sizing:border-box;margin:-4px 0 12px;padding:8px 10px;border:1px solid #d6a83f;border-radius:9px;background:var(--dsw-alias-bg-layer-1,#fff7df);color:var(--dsw-alias-label-primary,#694b00);font-size:11px;line-height:1.5;overflow-wrap:anywhere}.dsh-mobile-control__http-frame-warning[hidden]{display:none}.dsh-mobile-control__view.is-remote .dsh-mobile-control__actions{display:grid;grid-template-columns:1fr 1fr;gap:8px}.dsh-mobile-control__view.is-remote .dsh-mobile-control__actions button[hidden]{display:none}
15102
16612
  .dsh-mobile-control__provider-section{position:relative;margin:0 0 14px}.dsh-mobile-control__section-title{margin:0 0 8px;color:var(--dsw-alias-label-primary,#16181d);font:650 13px/1.4 system-ui}.dsh-mobile-control__provider-section>.dsh-mobile-control__section-title{padding-right:42px}.dsh-mobile-control__provider-choices{display:grid;grid-template-columns:1fr;gap:8px}.dsh-mobile-control__provider{display:flex;min-width:0;flex-direction:column;gap:6px;min-height:94px;padding:10px 11px;border:1px solid var(--dsw-alias-border-l2,#dbe1e8);border-radius:13px;background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-primary,#16181d);text-align:left;cursor:pointer;touch-action:manipulation;transition:border-color 160ms ease,background-color 160ms ease,box-shadow 160ms ease}.dsh-mobile-control__provider:hover{border-color:#6f96db;background:var(--dsw-alias-interactive-bg-hover-solid,var(--dsw-alias-bg-layer-1,#f8fbff))}.dsh-mobile-control__provider.is-selected{border-color:var(--dsw-alias-label-primary-bluish,#2563eb);background:var(--dsw-alias-interactive-bg-active,var(--dsw-alias-bg-layer-1,#f5f8ff));box-shadow:0 0 0 1px var(--dsw-alias-label-primary-bluish,#2563eb) inset}.dsh-mobile-control__provider:disabled{cursor:wait;opacity:.62}.dsh-mobile-control__provider-top{display:flex;min-width:0;align-items:flex-start;justify-content:space-between;gap:5px}.dsh-mobile-control__provider-top strong{min-width:0;font-size:12px;line-height:1.3}.dsh-mobile-control__provider-badge{flex:none;padding:2px 5px;border-radius:999px;background:var(--dsw-alias-state-business-tertiary,#e8f0ff);color:var(--dsw-alias-label-primary,#1d4ed8);font:650 11px/1.25 system-ui}.dsh-mobile-control__provider-badge.is-cpolar{background:var(--dsw-alias-state-success-tertiary,#eaf8f2);color:var(--dsw-alias-label-primary,#087454)}.dsh-mobile-control__provider-description{color:var(--dsw-alias-label-secondary,#606873);font-size:12px;line-height:1.45}.dsh-mobile-control__provider-info{position:absolute;z-index:5;top:-13px;right:-8px}.dsh-mobile-control__provider-info-button{display:flex;align-items:center;justify-content:center;width:44px;height:44px;padding:0;border:0;border-radius:50%;background:transparent;color:var(--dsw-alias-label-secondary,#475569);cursor:pointer;touch-action:manipulation}.dsh-mobile-control__provider-info-button:hover{background:var(--dsw-alias-interactive-bg-hover,#f1f5f9);color:var(--dsw-alias-label-primary-bluish,#2563eb)}.dsh-mobile-control__provider-info-glyph{display:flex;align-items:center;justify-content:center;box-sizing:border-box;width:18px;height:18px;border:1.5px solid currentColor;border-radius:50%;font:700 12px/1 system-ui}.dsh-mobile-control__provider-info-popover{position:absolute;z-index:6;top:38px;right:4px;box-sizing:border-box;width:min(292px,calc(100vw - 72px));padding:10px 12px;border:1px solid var(--dsw-alias-border-l2,#dbe1e8);border-radius:12px;background:var(--dsw-alias-bg-layer-2,#fff);box-shadow:0 10px 28px rgb(15 23 42 / 16%)}.dsh-mobile-control__provider-info-popover[hidden]{display:none}.dsh-mobile-control__provider-info-popover strong,.dsh-mobile-control__provider-info-popover span{display:block}.dsh-mobile-control__provider-info-popover strong{margin-bottom:3px;font-size:12px}.dsh-mobile-control__provider-info-popover span{color:var(--dsw-alias-label-secondary,#606873);font-size:11px;line-height:1.55}
15103
16613
  .dsh-mobile-control__self-hosted{margin:8px 0 0;border:1px solid var(--dsw-alias-border-l2,#dbe1e8);border-radius:12px;background:var(--dsw-alias-bg-layer-1,#f8fafc)}.dsh-mobile-control__self-hosted-summary{display:flex;box-sizing:border-box;min-height:48px;align-items:center;justify-content:space-between;gap:10px;padding:8px 11px;cursor:pointer;list-style-position:inside}.dsh-mobile-control__self-hosted-summary>span:first-child{display:flex;min-width:0;flex-direction:column;gap:1px}.dsh-mobile-control__self-hosted-summary strong{font-size:11px}.dsh-mobile-control__self-hosted-summary span span{color:var(--dsw-alias-label-secondary,#606873);font-size:11px;line-height:1.35}.dsh-mobile-control__provider-badge.is-frp{background:var(--dsw-alias-bg-layer-1,#eef0f3);color:var(--dsw-alias-label-secondary,#475569)}.dsh-mobile-control__self-hosted-body{padding:0 8px 8px}.dsh-mobile-control__provider.is-frp{width:100%;min-height:64px;background:var(--dsw-alias-bg-layer-2,#fff)}
@@ -15121,7 +16631,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
15121
16631
  .dsh-mobile-control__diagnostic-checks{display:grid;gap:12px;margin-top:12px;animation:dsh-diagnostic-reveal 160ms ease-out both}.dsh-mobile-control__diagnostic-checks[hidden]{display:none}.dsh-mobile-control__diagnostic-group{overflow:hidden;border:1px solid var(--dsw-alias-border-l2,#dbe1e8);border-radius:13px;background:var(--dsw-alias-bg-layer-2,#fff)}.dsh-mobile-control__diagnostic-group-header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 11px;border-bottom:1px solid var(--dsw-alias-border-l2,#e1e5eb);background:var(--dsw-alias-bg-layer-1,#f8fafc)}.dsh-mobile-control__diagnostic-group-header h3{margin:0;font:650 11px/1.4 system-ui}.dsh-mobile-control__diagnostic-group-header span{color:var(--dsw-alias-label-secondary,#606873);font-size:11px}.dsh-mobile-control__diagnostic-list{display:flex;flex-direction:column}.dsh-mobile-control__diagnostic-check{display:grid;grid-template-columns:26px minmax(0,1fr);gap:9px;padding:11px;background:var(--dsw-alias-bg-layer-2,#fff)}.dsh-mobile-control__diagnostic-check + .dsh-mobile-control__diagnostic-check{border-top:1px solid var(--dsw-alias-border-l2,#e1e5eb)}.dsh-mobile-control__diagnostic-marker{position:relative;width:26px;height:26px;border-radius:50%;background:var(--dsw-alias-bg-layer-1,#edf1f5);color:var(--dsh-diagnostic-info)}.dsh-mobile-control__diagnostic-marker::before,.dsh-mobile-control__diagnostic-marker::after{position:absolute;content:""}.dsh-mobile-control__diagnostic-check.is-ok .dsh-mobile-control__diagnostic-marker{background:var(--dsw-alias-state-success-tertiary,#e6f7f0);color:var(--dsh-diagnostic-ok)}.dsh-mobile-control__diagnostic-check.is-ok .dsh-mobile-control__diagnostic-marker::before{top:7px;left:7px;width:9px;height:5px;border-bottom:1.8px solid currentColor;border-left:1.8px solid currentColor;transform:rotate(-45deg)}.dsh-mobile-control__diagnostic-check.is-warning .dsh-mobile-control__diagnostic-marker{background:var(--dsw-alias-state-warn-tertiary,#fff4dc);color:var(--dsh-diagnostic-warning)}.dsh-mobile-control__diagnostic-check.is-error .dsh-mobile-control__diagnostic-marker{background:var(--dsw-alias-state-error-secondary,#fdecec);color:var(--dsh-diagnostic-error)}.dsh-mobile-control__diagnostic-check.is-warning .dsh-mobile-control__diagnostic-marker::before,.dsh-mobile-control__diagnostic-check.is-error .dsh-mobile-control__diagnostic-marker::before{top:6px;left:12px;width:2px;height:9px;border-radius:2px;background:currentColor}.dsh-mobile-control__diagnostic-check.is-warning .dsh-mobile-control__diagnostic-marker::after,.dsh-mobile-control__diagnostic-check.is-error .dsh-mobile-control__diagnostic-marker::after{bottom:6px;left:12px;width:2px;height:2px;border-radius:50%;background:currentColor}.dsh-mobile-control__diagnostic-check.is-info .dsh-mobile-control__diagnostic-marker::before{top:6px;left:12px;width:2px;height:2px;border-radius:50%;background:currentColor}.dsh-mobile-control__diagnostic-check.is-info .dsh-mobile-control__diagnostic-marker::after{top:10px;left:12px;width:2px;height:9px;border-radius:2px;background:currentColor}.dsh-mobile-control__diagnostic-check-body{min-width:0}.dsh-mobile-control__diagnostic-check-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}.dsh-mobile-control__diagnostic-check-header strong{min-width:0;font-size:12px;line-height:1.4}.dsh-mobile-control__diagnostic-badge{flex:none;padding:2px 6px;border-radius:999px;background:var(--dsw-alias-bg-layer-1,#edf1f5);color:var(--dsh-diagnostic-info);font:650 11px/1.3 system-ui}.dsh-mobile-control__diagnostic-check.is-ok .dsh-mobile-control__diagnostic-badge{background:var(--dsw-alias-state-success-tertiary,#e6f7f0);color:var(--dsh-diagnostic-ok)}.dsh-mobile-control__diagnostic-check.is-warning .dsh-mobile-control__diagnostic-badge{background:var(--dsw-alias-state-warn-tertiary,#fff4dc);color:var(--dsh-diagnostic-warning)}.dsh-mobile-control__diagnostic-check.is-error .dsh-mobile-control__diagnostic-badge{background:var(--dsw-alias-state-error-secondary,#fdecec);color:var(--dsh-diagnostic-error)}.dsh-mobile-control__diagnostic-check p{margin:4px 0 0;color:var(--dsw-alias-label-secondary,#606873);font-size:11px;line-height:1.5;overflow-wrap:anywhere}.dsh-mobile-control__diagnostic-check .dsh-mobile-control__diagnostic-action{margin-top:7px;padding:7px 8px;border-radius:8px;background:var(--dsw-alias-bg-layer-1,#f8fafc);color:var(--dsw-alias-label-primary,#16181d)}.dsh-mobile-control__diagnostic-action span{display:inline-block;margin-right:6px;color:var(--dsw-alias-label-primary-bluish,#2563eb);font-weight:700}.dsh-mobile-control__diagnostic-details{margin-top:12px}.dsh-mobile-control__diagnostic-details[hidden]{display:none}.dsh-mobile-control__diagnostic-details>summary{box-sizing:border-box;min-height:44px;line-height:44px}.dsh-mobile-control__diagnostic-report{box-sizing:border-box;max-height:220px;margin:4px 0 0;overflow:auto;padding:10px;border:1px solid var(--dsw-alias-border-l2,#dbe1e8);border-radius:10px;background:var(--dsw-alias-bg-layer-1,#f3f5f8);color:var(--dsw-alias-label-secondary,#606873);font:11px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere}
15122
16632
  .dsh-mobile-control__diagnostic-checks{transition:opacity 150ms ease}.dsh-mobile-control__diagnostic-checks.is-refreshing{opacity:.52}
15123
16633
  @keyframes dsh-diagnostic-spin{to{transform:rotate(360deg)}}@keyframes dsh-diagnostic-reveal{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
15124
- .dsh-mobile-control__actions{display:flex;flex-wrap:nowrap;gap:6px}.dsh-mobile-control__actions[hidden],.dsh-mobile-control__manage-row[hidden]{display:none}.dsh-mobile-control__actions button{flex:1 1 0;min-width:0;min-height:44px;padding:8px 4px;border-radius:10px;font:12px/1.2 system-ui;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dsh-mobile-control__secondary{border:1px solid var(--dsw-alias-border-l3,#cfd5dd);background:transparent;color:inherit}.dsh-mobile-control__primary{border:1px solid #2563eb;background:#2563eb;color:#fff}.dsh-mobile-control__actions button:disabled{cursor:not-allowed;opacity:.45}
16634
+ .dsh-mobile-control__actions{display:flex;flex-wrap:nowrap;gap:6px}.dsh-mobile-control__actions[hidden],.dsh-mobile-control__manage-row[hidden]{display:none}.dsh-mobile-control__actions button{flex:1 1 0;min-width:0;min-height:44px;padding:8px 4px;border-radius:10px;font:12px/1.2 system-ui;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dsh-mobile-control__secondary{display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;min-height:40px;padding:9px 14px;border:1px solid var(--dsw-alias-border-l3,#cfd5dd);border-radius:10px;background:transparent;color:inherit;font:600 12px/1.3 system-ui;cursor:pointer;touch-action:manipulation}.dsh-mobile-control__secondary:hover:not(:disabled){border-color:#6f96db;background:var(--dsw-alias-interactive-bg-hover-solid,var(--dsw-alias-bg-layer-1,#f8fbff))}.dsh-mobile-control__secondary:disabled{cursor:wait;opacity:.55}.dsh-mobile-control__secondary[hidden]{display:none}.dsh-mobile-control__primary{border:1px solid #2563eb;background:#2563eb;color:#fff}.dsh-mobile-control__actions button:disabled{cursor:not-allowed;opacity:.45}
15125
16635
  .dsh-mobile-control button:focus-visible,.dsh-mobile-control a:focus-visible,.dsh-mobile-control input:focus-visible,.dsh-mobile-control select:focus-visible,.dsh-mobile-control summary:focus-visible{outline:3px solid rgb(37 99 235 / 28%);outline-offset:2px}
15126
16636
  /* 42px matches the DSH sidebar footer row; it is not one of the panel target tiers. */
15127
16637
  /* Other footer entries share the shell's row; only our own action becomes compact. */
@@ -15135,7 +16645,167 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
15135
16645
  .dsh-mobile-control__origin-modes{min-width:0;margin:0 0 14px;padding:0;border:0}.dsh-mobile-control__origin-modes legend{margin:0 0 8px;padding:0;color:var(--dsw-alias-label-secondary,#606873);font:600 12px/1.4 system-ui}.dsh-mobile-control__origin-mode{display:flex;box-sizing:border-box;min-height:44px;align-items:center;gap:10px;padding:10px;border:1px solid var(--dsw-alias-border-l2,#dbe1e8);border-radius:10px;background:var(--dsw-alias-bg-layer-1,#f3f5f8);font:12px/1.5 system-ui;cursor:pointer}.dsh-mobile-control__origin-mode+.dsh-mobile-control__origin-mode{margin-top:8px}.dsh-mobile-control__origin-mode:has(input:checked){border-color:var(--dsw-alias-label-primary-bluish,#2563eb)}.dsh-mobile-control__origin-mode:focus-within{outline:2px solid var(--dsw-alias-state-business-primary,#2563eb);outline-offset:2px}.dsh-mobile-control__origin-mode:has(input:disabled){cursor:wait;opacity:.55}.dsh-mobile-control__origin-mode input[type=radio]{flex:0 0 18px;box-sizing:border-box;width:18px;height:18px;min-height:18px;margin:0;padding:0;accent-color:var(--dsw-alias-label-primary-bluish,#2563eb)}.dsh-mobile-control__origin-mode span{min-width:0;overflow-wrap:anywhere}
15136
16646
  /* Base the columns on the card width, not the desktop viewport behind the popup. */
15137
16647
  .dsh-mobile-control__origin-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,140px),1fr));gap:12px}.dsh-mobile-control__origin-fields>.dsh-mobile-control__field{min-width:0;grid-column:auto;font-size:12px}.dsh-mobile-control__origin-fields>.dsh-mobile-control__field--full{grid-column:1/-1}.dsh-mobile-control__origin-fields select{box-sizing:border-box;width:100%;min-height:44px;padding:9px 10px;border:1px solid var(--dsw-alias-border-l3,#cfd5dd);border-radius:10px;background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-primary,#16181d);font:16px/1.4 system-ui}.dsh-mobile-control__origin-fields select:disabled{cursor:not-allowed;opacity:.55}.dsh-mobile-control__caddy-form{display:grid;gap:12px}.dsh-mobile-control__caddy-form>.dsh-mobile-control__component-note,.dsh-mobile-control__caddy-form>.dsh-mobile-control__component-status,.dsh-mobile-control__caddy-form>.dsh-mobile-control__origin-feedback{margin:0;overflow-wrap:anywhere}.dsh-mobile-control__origin-setup .dsh-mobile-control__actions.dsh-mobile-control__caddy-actions{grid-template-columns:minmax(0,1fr);margin:0}.dsh-mobile-control__caddy-actions button{box-sizing:border-box;min-width:0;min-height:44px;padding:10px 12px;border-radius:10px;font:600 12px/1.4 system-ui;cursor:pointer}.dsh-mobile-control__origin-warning{margin:12px 0;padding:10px 12px;border-left:3px solid var(--dsw-alias-state-warn-primary,#a56810);border-radius:4px;background:var(--dsw-alias-bg-layer-1,#f3f5f8);color:var(--dsw-alias-label-primary,#384152);font-size:12px;line-height:1.6}.dsh-mobile-control__origin-feedback{font-size:12px;line-height:1.6;overflow-wrap:anywhere}.dsh-mobile-control__origin-feedback.is-error{color:var(--dsw-alias-state-error-primary,#bc3030)}.dsh-mobile-control__origin-fields input[aria-invalid=true]{border-color:var(--dsw-alias-state-error-primary,#bc3030)}.dsh-mobile-control__origin-backend{display:flex;min-width:0;flex-direction:column;align-items:stretch;gap:8px;margin-top:12px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l2,#dbe1e8)}.dsh-mobile-control__origin-backend button{align-self:flex-start;min-height:44px}.dsh-mobile-control__self-hosted-body .is-origin{margin-top:8px}
16648
+ /* Final overrides: dense borderless layout must beat the base rules above.
16649
+ The earlier dense block sits before those base rules, so these repeat the
16650
+ essential de-boxing here at the end of the sheet where they win. */
16651
+ .dsh-mobile-control__provider{position:relative;border:0;border-radius:8px;padding:8px 10px;min-height:0;background:transparent;box-shadow:none}
16652
+ .dsh-mobile-control__provider:hover{border-color:transparent;background:var(--dsw-alias-interactive-bg-hover,#f1f5f9)}
16653
+ .dsh-mobile-control__provider.is-selected{border-color:transparent;background:var(--dsw-alias-interactive-bg-active,#f5f8ff);box-shadow:none}
16654
+ .dsh-mobile-control__provider.is-lan:not(.is-selected){border-color:transparent;background:transparent}
16655
+ .dsh-mobile-control__view:not(.is-remote)>.dsh-mobile-control__actions{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
16656
+ .dsh-mobile-control__view.is-diagnostics,.dsh-mobile-control__view.is-devices{flex:1;min-height:0;overflow-y:auto;padding:14px 20px}
16657
+ .dsh-mobile-control__view.is-devices .dsh-mobile-control__devices{max-height:none;margin-top:0}
16658
+ .dsh-mobile-control__actions button{white-space:normal;overflow:visible;text-overflow:clip;line-height:1.25;height:auto;padding-top:8px;padding-bottom:8px;word-break:break-word}
16659
+ .dsh-mobile-control__qr-placeholder{display:flex;align-items:center;justify-content:center;box-sizing:border-box;width:192px;height:192px;padding:12px;border:1px dashed var(--dsw-alias-border-l2,#dbe1e8);border-radius:12px;color:var(--dsw-alias-label-secondary,#606873);font-size:12px;line-height:1.5;text-align:center}
16660
+ .dsh-mobile-control__lan-setup .dsh-mobile-control__actions,.dsh-mobile-control__cpolar-setup .dsh-mobile-control__actions,.dsh-mobile-control__frp-setup .dsh-mobile-control__actions,.dsh-mobile-control__origin-setup .dsh-mobile-control__actions{margin-top:12px}
16661
+ .dsh-mobile-control__origin-setup .dsh-mobile-control__actions.dsh-mobile-control__caddy-actions{margin-top:12px}
16662
+ .dsh-mobile-control__qr-zoom{pointer-events:auto}
16663
+ .dsh-mobile-control__iroh-pairing{border:0;border-radius:0;background:transparent;padding:0;margin:12px 0 0}
16664
+ /* Config setup panels: drop the rounded card frame so provider configuration
16665
+ reads as one flat page with hairline dividers instead of nested boxes. */
16666
+ .dsh-mobile-control__lan-setup,.dsh-mobile-control__cpolar-setup,.dsh-mobile-control__frp-setup,.dsh-mobile-control__origin-setup{border:0;border-radius:0;background:transparent;padding:0;margin:0 0 12px}
16667
+ .dsh-mobile-control__frp-setup>.dsh-mobile-control__frp-step,.dsh-mobile-control__frp-group,.dsh-mobile-control__frp-overview{border:0;border-radius:0;background:transparent}
16668
+ .dsh-mobile-control__ws-paths{border:0;border-radius:0;background:transparent;padding:0;margin:10px 0 0}
16669
+ .dsh-mobile-control__view.is-remote .dsh-mobile-control__remote-workspace>.dsh-mobile-control__actions{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
16670
+ .dsh-mobile-control__view.is-remote .dsh-mobile-control__remote-workspace>.dsh-mobile-control__actions[hidden]{display:none}
16671
+ .dsh-mobile-control__view.is-remote .dsh-mobile-control__origin-setup .dsh-mobile-control__actions.dsh-mobile-control__caddy-actions{display:grid;grid-template-columns:minmax(0,1fr)}
16672
+ .dsh-mobile-control__remote-workspace{border:0;border-radius:0;background:transparent;box-shadow:none}
16673
+ .dsh-mobile-control__stage-header{border:0;border-radius:0;background:transparent;box-shadow:none}
16674
+ .dsh-mobile-control__app-downloads{display:flex;gap:8px;padding:6px 16px;border-bottom:1px solid var(--dsw-alias-border-l2,#e1e5eb)}
16675
+ .dsh-mobile-control__app-download{flex:1 1 0;display:flex;align-items:center;justify-content:space-between;gap:6px;box-sizing:border-box;min-height:34px;padding:6px 10px;border:1px solid var(--dsw-alias-border-l3,#cfd5dd);border-radius:8px;background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-primary,#16181d);font-size:12px;text-decoration:none;white-space:normal}
16676
+ .dsh-mobile-control__app-download.is-iroh{border-color:var(--dsw-alias-label-primary-bluish,#2563eb)}
16677
+ .dsh-mobile-control__setup-details{margin-top:12px;padding-top:8px;border-top:1px solid var(--dsw-alias-border-l2,#e1e5eb)}
16678
+ .dsh-mobile-control__remote-workspace>.dsh-mobile-control__qr{margin-bottom:18px}
16679
+ .dsh-mobile-control__stage-header{display:none}
16680
+ .dsh-mobile-control__provider-install{margin:0 0 12px}
16681
+ .dsh-mobile-control__provider-install>.dsh-mobile-control__primary{width:100%;min-height:44px}
16682
+ .dsh-mobile-control__tunnel-toggle{display:inline-flex;align-items:center;gap:4px;box-sizing:border-box;margin:0 0 10px;padding:6px 12px;border:1px solid var(--dsw-alias-border-l3,#cfd5dd);border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary-bluish,#2563eb);font:600 12px/1 system-ui;cursor:pointer}
16683
+ .dsh-mobile-control__tunnel-toggle:hover{background:var(--dsw-alias-interactive-bg-hover,#f1f3f6)}
16684
+ .dsh-mobile-control__tunnel>.dsh-mobile-control__primary,.dsh-mobile-control__tunnel>.dsh-mobile-control__secondary{width:100%;min-height:44px;margin-top:10px}
16685
+ .dsh-mobile-control__provider-info-popover p{margin:0 0 8px;font-size:11px;line-height:1.5}
16686
+ .dsh-mobile-control__provider-info-popover p:last-child{margin-bottom:0}
16687
+ .dsh-mobile-control__provider-info-popover{max-width:240px;max-height:44vh;overflow:auto;white-space:normal}
16688
+ .dsh-mobile-control__mode-row{position:relative;display:flex;align-items:center;gap:8px;margin:0 0 8px}
16689
+ .dsh-mobile-control__mode-row>.dsh-mobile-control__switcher{flex:1 1 auto;margin:0}
16690
+ .dsh-mobile-control__notes-toggle{flex:none;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:1px solid var(--dsw-alias-border-l3,#cfd5dd);border-radius:50%;background:transparent;color:var(--dsw-alias-label-secondary,#606873);font:600 13px/1 system-ui;cursor:pointer}
16691
+ .dsh-mobile-control__notes-toggle:hover{background:var(--dsw-alias-interactive-bg-hover,#f1f3f6)}
16692
+ .dsh-mobile-control__notes-panel{position:absolute;top:calc(100% + 6px);right:0;z-index:40;width:min(320px,78vw);max-height:50vh;overflow:auto;box-sizing:border-box;margin:0;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2,#dbe1e8);border-radius:8px;background:var(--dsw-alias-bg-layer-2,#fff);box-shadow:0 6px 20px rgb(15 23 42 / 12%);font-size:11px;line-height:1.5;color:var(--dsw-alias-label-secondary,#475569)}
16693
+ .dsh-mobile-control__notes-panel p{margin:0 0 8px}.dsh-mobile-control__notes-panel p:last-child{margin:0}
16694
+ .dsh-mobile-control__notes-panel[hidden]{display:none}
16695
+ .dsh-mobile-control__tunnel-toggle[hidden]{display:none}
16696
+ .dsh-mobile-control__provider-setup-body .dsh-mobile-control__switcher{border-radius:0;background:transparent;padding:0;gap:8px}
16697
+ .dsh-mobile-control__provider-setup-body .dsh-mobile-control__tab{border-radius:0;box-shadow:none;border-bottom:2px solid transparent}
16698
+ .dsh-mobile-control__provider-setup-body .dsh-mobile-control__tab.is-active{background:transparent;box-shadow:none;border-bottom-color:var(--dsw-alias-label-primary-bluish,#2563eb)}
16699
+ .dsh-mobile-control__origin-setup .dsh-mobile-control__origin-mode{border-radius:0;background:transparent}
16700
+ .dsh-mobile-control__devices{border:0;border-radius:0;padding:0;margin:0;background:transparent;max-height:none}
16701
+ .dsh-mobile-control__provider.is-frp:not(.is-selected),.dsh-mobile-control__provider.is-origin:not(.is-selected),.dsh-mobile-control__provider.is-iroh:not(.is-selected){border-color:transparent;background:transparent}
16702
+ .dsh-mobile-control__provider.is-selected::before{content:"";position:absolute;left:0;top:8px;bottom:8px;width:2px;border-radius:2px;background:var(--dsw-alias-label-primary-bluish,#2563eb)}
16703
+ .dsh-mobile-control__remote-workspace section{border:0;border-radius:0;padding:12px 0;background:transparent;box-shadow:none;border-top:1px solid var(--dsw-alias-border-l2,#e1e5eb)}
16704
+ .dsh-mobile-control__remote-workspace section:first-child{border-top:0}
16705
+ .dsh-mobile-control__remote-workspace .dsh-mobile-control__details{border:0;border-radius:0;background:transparent;box-shadow:none}
16706
+ .dsh-mobile-control__self-hosted{border:0;border-radius:0;background:transparent;box-shadow:none}
15138
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
+ }
15139
16809
  /** Mount the desktop control or mobile feature enhancements. */
15140
16810
  function apply(ctx) {
15141
16811
  let gatewayRejectionSink;
@@ -15170,6 +16840,10 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
15170
16840
  return recovery();
15171
16841
  };
15172
16842
  }, "dsh-mobile: connection recovery");
16843
+ ctx.effect(() => installMobilePushBrowserClient(ctx, {
16844
+ page: document,
16845
+ view: window
16846
+ }), "dsh-mobile: authenticated browser push lifecycle");
15173
16847
  ctx.effect(() => {
15174
16848
  const desktopAdmin = isDesktopAdminSurface(location.hostname, location.search, window.__DSH_MOBILE_FRONTEND__, location.protocol);
15175
16849
  const gatewayCustomization = ownsMobileCustomization(document.querySelector("meta[name=\"dsh-mobile-gateway\"][content=\"authenticated\"]") !== null, window.__DSH_MOBILE_FRONTEND__);
@@ -15338,8 +17012,10 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
15338
17012
  exports.installCustomAssets = installCustomAssets;
15339
17013
  exports.installDshLanguageBoundSurface = installDshLanguageBoundSurface;
15340
17014
  exports.installMobileNativeBack = installMobileNativeBack;
17015
+ exports.installMobilePushBrowserClient = installMobilePushBrowserClient;
15341
17016
  exports.localizedDiagnosticEntryCopy = localizedDiagnosticEntryCopy;
15342
17017
  exports.mobileBackBlockedMessage = mobileBackBlockedMessage;
17018
+ exports.mobilePushNavigationNotice = mobilePushNavigationNotice;
15343
17019
  exports.normalizeDiagnosticOverall = normalizeDiagnosticOverall;
15344
17020
  exports.normalizeDiagnosticStatus = normalizeDiagnosticStatus;
15345
17021
  exports.parseMobileExtensionManifest = parseMobileExtensionManifest;