dsh-mobile 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -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 运行。",
@@ -3649,6 +3739,8 @@ window.__ModuleLoader__.load({
3649
3739
  [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
3740
  [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
3741
  [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; }
3742
+ [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; }
3743
+ [data-dsh-mobile-sidebar][data-open="false"] [data-dsh-mobile-toggle] { pointer-events:auto !important; }
3652
3744
  [data-dsh-mobile-sidebar][data-open="false"] [data-dsh-mobile-sidebar-root] > :not(:has([data-dsh-mobile-toggle])) { display:none !important; }
3653
3745
  [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
3746
  [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; }
@@ -9833,11 +9925,15 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
9833
9925
  const androidVersion = releaseVersion(data.androidVersion);
9834
9926
  const expectedAndroidDownloadUrls = androidVersion === void 0 ? [] : ["v", "android-v"].map((prefix) => `${GITHUB_RELEASES_URL}/download/${prefix}${androidVersion}/dsh-mobile-android-v${androidVersion}.apk`);
9835
9927
  const androidDownloadUrl = typeof data.androidDownloadUrl === "string" && expectedAndroidDownloadUrls.includes(data.androidDownloadUrl) ? data.androidDownloadUrl : GITHUB_RELEASES_URL;
9928
+ 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
9929
  return {
9837
9930
  updateAvailable: data.updateAvailable === true && latestVersion !== void 0,
9838
9931
  ...latestVersion === void 0 ? {} : { latestVersion },
9839
9932
  ...androidVersion === void 0 ? {} : { androidVersion },
9840
9933
  androidDownloadUrl,
9934
+ ...androidIrohDownloadUrl === void 0 ? {} : { androidIrohDownloadUrl },
9935
+ ...typeof data.androidCompatBytes === "number" && data.androidCompatBytes > 0 ? { androidCompatBytes: data.androidCompatBytes } : {},
9936
+ ...typeof data.androidIrohBytes === "number" && data.androidIrohBytes > 0 ? { androidIrohBytes: data.androidIrohBytes } : {},
9841
9937
  ...typeof data.releaseNotes === "string" && data.releaseNotes !== "" ? { releaseNotes: data.releaseNotes.slice(0, 4e3) } : {}
9842
9938
  };
9843
9939
  }
@@ -9858,7 +9954,18 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
9858
9954
  signal: controller.signal,
9859
9955
  headers: localAdminRequestHeaders(init, location)
9860
9956
  });
9861
- const body = await response.json();
9957
+ const text = await response.text();
9958
+ let body = {};
9959
+ if (text.trim() !== "") {
9960
+ let parsed;
9961
+ try {
9962
+ parsed = JSON.parse(text);
9963
+ } catch {
9964
+ throw new Error(!response.ok ? `HTTP ${String(response.status)} (${path})` : `${controlTranslator()("invalidResponse")} (${path})`);
9965
+ }
9966
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(!response.ok ? `HTTP ${String(response.status)} (${path})` : `${controlTranslator()("invalidResponse")} (${path})`);
9967
+ body = parsed;
9968
+ }
9862
9969
  if (!response.ok) throw new Error(typeof body.error === "string" ? body.error : `HTTP ${String(response.status)}`);
9863
9970
  return body;
9864
9971
  } catch (error) {
@@ -10048,6 +10155,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10048
10155
  iroh_exited: "irohExited",
10049
10156
  iroh_not_ready: "irohNotReady",
10050
10157
  iroh_pairing_failed: "irohPairingFailed",
10158
+ iroh_component_running: "irohComponentRunning",
10051
10159
  frp_server_address_invalid: "frpServerAddressInvalid",
10052
10160
  frp_server_port_invalid: "frpServerPortInvalid",
10053
10161
  frp_token_invalid: "frpTokenInvalid",
@@ -10261,17 +10369,31 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10261
10369
  close.type = "button";
10262
10370
  close.textContent = "×";
10263
10371
  close.setAttribute("aria-label", t("collapseMobileAccess"));
10264
- headerActions.append(updatePlugin, diagnosticsEntry, close);
10372
+ const devicesEntry = element("button", "dsh-mobile-control__diagnostic-entry");
10373
+ devicesEntry.type = "button";
10374
+ devicesEntry.textContent = t("device");
10375
+ devicesEntry.setAttribute("aria-label", t("managePairedDevices"));
10376
+ devicesEntry.setAttribute("aria-pressed", "false");
10377
+ headerActions.append(updatePlugin, diagnosticsEntry, devicesEntry, close);
10265
10378
  const releaseNotice = element("p", "dsh-mobile-control__release-notice");
10266
10379
  releaseNotice.hidden = true;
10267
10380
  releaseNotice.setAttribute("role", "status");
10268
10381
  releaseNotice.setAttribute("aria-live", "polite");
10382
+ const appDownloads = element("div", "dsh-mobile-control__app-downloads");
10269
10383
  const appDownload = element("a", "dsh-mobile-control__app-download");
10270
10384
  appDownload.href = GITHUB_RELEASES_URL;
10271
10385
  appDownload.target = "_blank";
10272
10386
  appDownload.rel = "noopener noreferrer";
10273
10387
  appDownload.textContent = t("downloadAndroid");
10274
10388
  appDownload.setAttribute("aria-label", t("downloadAndroidAria"));
10389
+ const appDownloadIroh = element("a", "dsh-mobile-control__app-download is-iroh");
10390
+ appDownloadIroh.href = GITHUB_RELEASES_URL;
10391
+ appDownloadIroh.target = "_blank";
10392
+ appDownloadIroh.rel = "noopener noreferrer";
10393
+ appDownloadIroh.textContent = t("downloadAndroidIroh");
10394
+ appDownloadIroh.setAttribute("aria-label", t("downloadAndroidIrohAria"));
10395
+ appDownloadIroh.hidden = true;
10396
+ appDownloads.append(appDownload, appDownloadIroh);
10275
10397
  const switcher = element("div", "dsh-mobile-control__switcher");
10276
10398
  const lanTab = element("button", "dsh-mobile-control__tab is-active");
10277
10399
  lanTab.type = "button";
@@ -10396,7 +10518,12 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10396
10518
  accessLink.rel = "noreferrer";
10397
10519
  access.append(accessLabel, accessLink);
10398
10520
  const qrBox = element("div", "dsh-mobile-control__qr");
10399
- qrBox.hidden = true;
10521
+ const lanPairingValidity = element("p", "dsh-mobile-control__component-status");
10522
+ lanPairingValidity.hidden = true;
10523
+ let activeLanPairUrl = "";
10524
+ let activeLanPairExpiresAt = 0;
10525
+ let lanPairingBusy = false;
10526
+ let lanPairingGeneration = 0;
10400
10527
  const status = element("p", "dsh-mobile-control__status");
10401
10528
  status.textContent = t("loadingStatus");
10402
10529
  const extensionStatus = element("p", "dsh-mobile-control__extensions");
@@ -10417,11 +10544,9 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10417
10544
  const resetAll = element("button", "dsh-mobile-control__manage");
10418
10545
  resetAll.type = "button";
10419
10546
  resetAll.textContent = t("clearAllDevices");
10420
- manageRow.append(manageDevices, resetAll);
10547
+ manageRow.append(manageDevices);
10421
10548
  const devicePanel = element("div", "dsh-mobile-control__devices");
10422
- devicePanel.hidden = true;
10423
10549
  const remoteView = element("div", "dsh-mobile-control__view is-remote");
10424
- remoteView.hidden = true;
10425
10550
  const remoteIntro = element("p", "dsh-mobile-control__intro");
10426
10551
  remoteIntro.textContent = t("remoteIntro");
10427
10552
  const httpFrameWarning = element("p", "dsh-mobile-control__http-frame-warning");
@@ -10431,27 +10556,6 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10431
10556
  const providerSection = element("section", "dsh-mobile-control__provider-section");
10432
10557
  const providerHeading = element("h3", "dsh-mobile-control__section-title");
10433
10558
  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
10559
  const providerChoices = element("div", "dsh-mobile-control__provider-choices");
10456
10560
  providerChoices.setAttribute("role", "radiogroup");
10457
10561
  providerChoices.setAttribute("aria-label", t("providerGroupAria"));
@@ -10519,6 +10623,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10519
10623
  const cpolarInstall = element("button", "dsh-mobile-control__primary");
10520
10624
  cpolarInstall.type = "button";
10521
10625
  cpolarInstall.textContent = t("installOfficial");
10626
+ cpolarInstall.hidden = true;
10522
10627
  const cpolarAccount = element("div", "dsh-mobile-control__cpolar-account");
10523
10628
  cpolarAccount.hidden = true;
10524
10629
  const cpolarAccountText = element("p", "dsh-mobile-control__component-note");
@@ -10570,7 +10675,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10570
10675
  cpolarPurge.textContent = t("purgeCpolar");
10571
10676
  cpolarDetailsBody.append(cpolarDetailsText, cpolarStorage, cpolarOfficial, cpolarTerms, cpolarPurge);
10572
10677
  cpolarDetails.append(cpolarDetailsSummary, cpolarDetailsBody);
10573
- cpolarSetup.append(cpolarSetupTitle, cpolarComponentStatus, cpolarInstall, cpolarAccount, cpolarDetails);
10678
+ cpolarSetup.append(cpolarSetupTitle, cpolarComponentStatus, cpolarAccount);
10574
10679
  const cloudflaredSetup = element("section", "dsh-mobile-control__cpolar-setup dsh-mobile-control__cloudflared-setup");
10575
10680
  cloudflaredSetup.hidden = true;
10576
10681
  const cloudflaredSetupTitle = element("h3", "dsh-mobile-control__section-title");
@@ -10587,6 +10692,41 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10587
10692
  cloudflaredMode.append(cloudflaredModeQuick, cloudflaredModeNamed);
10588
10693
  const cloudflaredModeHint = element("p", "dsh-mobile-control__component-status");
10589
10694
  cloudflaredModeHint.textContent = t("cloudflaredTunnelQuickHint");
10695
+ const cloudflaredNotesToggle = element("button", "dsh-mobile-control__notes-toggle");
10696
+ cloudflaredNotesToggle.type = "button";
10697
+ cloudflaredNotesToggle.textContent = "i";
10698
+ cloudflaredNotesToggle.setAttribute("aria-label", t("providerInfoAria"));
10699
+ cloudflaredNotesToggle.setAttribute("aria-expanded", "false");
10700
+ const cloudflaredNotesPanel = element("div", "dsh-mobile-control__notes-panel");
10701
+ cloudflaredNotesPanel.hidden = true;
10702
+ const cloudflaredNotesQuick = element("p");
10703
+ cloudflaredNotesQuick.textContent = t("cloudflaredQuickNote");
10704
+ const cloudflaredNotesNamed = element("p");
10705
+ cloudflaredNotesNamed.textContent = t("cloudflaredNamedNote");
10706
+ cloudflaredNotesPanel.append(cloudflaredNotesQuick, cloudflaredNotesNamed);
10707
+ const cloudflaredModeRow = element("div", "dsh-mobile-control__mode-row");
10708
+ cloudflaredModeRow.append(cloudflaredMode, cloudflaredNotesToggle, cloudflaredNotesPanel);
10709
+ const setCloudflaredNotesOpen = (open) => {
10710
+ cloudflaredNotesPanel.hidden = !open;
10711
+ cloudflaredNotesToggle.setAttribute("aria-expanded", String(open));
10712
+ };
10713
+ const hoverCapable = typeof window.matchMedia === "function" ? window.matchMedia("(hover: hover)") : void 0;
10714
+ cloudflaredModeRow.addEventListener("pointerenter", () => {
10715
+ if (hoverCapable?.matches) setCloudflaredNotesOpen(true);
10716
+ });
10717
+ cloudflaredModeRow.addEventListener("pointerleave", () => {
10718
+ if (hoverCapable?.matches) setCloudflaredNotesOpen(false);
10719
+ });
10720
+ cloudflaredNotesToggle.addEventListener("click", (event) => {
10721
+ setCloudflaredNotesOpen(hoverCapable?.matches && event.detail !== 0 ? true : Boolean(cloudflaredNotesPanel.hidden));
10722
+ });
10723
+ cloudflaredModeRow.addEventListener("keydown", (event) => {
10724
+ if (event.key !== "Escape" || cloudflaredNotesPanel.hidden) return;
10725
+ event.preventDefault();
10726
+ event.stopPropagation();
10727
+ setCloudflaredNotesOpen(false);
10728
+ cloudflaredNotesToggle.focus();
10729
+ });
10590
10730
  const cloudflaredTunnel = element("div", "dsh-mobile-control__tunnel");
10591
10731
  cloudflaredTunnel.hidden = true;
10592
10732
  const cloudflaredTunnelFields = element("div", "dsh-mobile-control__frp-fields");
@@ -10629,12 +10769,23 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10629
10769
  cloudflaredTunnelForget.type = "button";
10630
10770
  cloudflaredTunnelForget.textContent = t("cloudflaredTunnelForget");
10631
10771
  cloudflaredTunnelForget.hidden = true;
10632
- cloudflaredTunnel.append(cloudflaredTunnelFields, cloudflaredTunnelStatus, cloudflaredTunnelHint);
10772
+ const cloudflaredTunnelToggle = element("button", "dsh-mobile-control__tunnel-toggle");
10773
+ cloudflaredTunnelToggle.type = "button";
10774
+ cloudflaredTunnelToggle.textContent = t("cloudflaredShowConfig") + " ▾";
10775
+ cloudflaredTunnelToggle.hidden = true;
10776
+ const cloudflaredEditButton = element("button", "dsh-mobile-control__secondary");
10777
+ cloudflaredEditButton.type = "button";
10778
+ cloudflaredEditButton.textContent = t("cloudflaredEditConfig");
10779
+ cloudflaredEditButton.hidden = true;
10780
+ const cloudflaredTunnelFeedback = element("p", "dsh-mobile-control__component-status");
10781
+ cloudflaredTunnelFeedback.hidden = true;
10782
+ cloudflaredTunnel.append(cloudflaredTunnelFields, cloudflaredTunnelStatus, cloudflaredTunnelHint, cloudflaredEditButton, cloudflaredTunnelSave, cloudflaredTunnelForget, cloudflaredTunnelFeedback);
10633
10783
  const cloudflaredComponentStatus = element("p", "dsh-mobile-control__component-status");
10634
10784
  cloudflaredComponentStatus.textContent = t("checkingComponent");
10635
10785
  const cloudflaredInstall = element("button", "dsh-mobile-control__primary");
10636
10786
  cloudflaredInstall.type = "button";
10637
10787
  cloudflaredInstall.textContent = t("installOfficial");
10788
+ cloudflaredInstall.hidden = true;
10638
10789
  const cloudflaredNote = element("p", "dsh-mobile-control__origin-warning");
10639
10790
  cloudflaredNote.textContent = t("cloudflaredQuickNote");
10640
10791
  const cloudflaredDetails = element("details", "dsh-mobile-control__details");
@@ -10660,19 +10811,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10660
10811
  cloudflaredPurge.textContent = t("purgeCloudflared");
10661
10812
  cloudflaredDetailsBody.append(cloudflaredDetailsText, cloudflaredStorage, cloudflaredOfficial, cloudflaredTerms, cloudflaredPurge);
10662
10813
  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");
10814
+ cloudflaredSetup.append(cloudflaredSetupTitle, cloudflaredTunnelTitle, cloudflaredModeRow, cloudflaredModeHint, cloudflaredTunnelToggle, cloudflaredTunnel, cloudflaredComponentStatus);
10676
10815
  const frpChoice = element("button", "dsh-mobile-control__provider is-frp");
10677
10816
  frpChoice.type = "button";
10678
10817
  frpChoice.setAttribute("aria-pressed", "false");
@@ -10681,7 +10820,9 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10681
10820
  frpChoiceName.textContent = t("frpName");
10682
10821
  const frpChoiceDescription = element("span", "dsh-mobile-control__provider-description");
10683
10822
  frpChoiceDescription.textContent = t("frpDescription");
10684
- frpChoiceTop.append(frpChoiceName);
10823
+ const frpChoiceBadge = element("span", "dsh-mobile-control__provider-badge is-frp");
10824
+ frpChoiceBadge.textContent = t("advanced");
10825
+ frpChoiceTop.append(frpChoiceName, frpChoiceBadge);
10685
10826
  frpChoice.append(frpChoiceTop, frpChoiceDescription);
10686
10827
  const originChoice = element("button", "dsh-mobile-control__provider is-origin");
10687
10828
  originChoice.type = "button";
@@ -10691,7 +10832,9 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10691
10832
  originChoiceName.textContent = t("originName");
10692
10833
  const originChoiceDescription = element("span", "dsh-mobile-control__provider-description");
10693
10834
  originChoiceDescription.textContent = t("originDescription");
10694
- originChoiceTop.append(originChoiceName);
10835
+ const originChoiceBadge = element("span", "dsh-mobile-control__provider-badge");
10836
+ originChoiceBadge.textContent = t("advanced");
10837
+ originChoiceTop.append(originChoiceName, originChoiceBadge);
10695
10838
  originChoice.append(originChoiceTop, originChoiceDescription);
10696
10839
  const irohChoice = element("button", "dsh-mobile-control__provider is-iroh");
10697
10840
  irohChoice.type = "button";
@@ -10705,9 +10848,21 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
10705
10848
  irohChoiceDescription.textContent = t("irohDescription");
10706
10849
  irohChoiceTop.append(irohChoiceName, irohChoiceBadge);
10707
10850
  irohChoice.append(irohChoiceTop, irohChoiceDescription);
10708
- selfHostedBody.append(frpChoice, originChoice, irohChoice);
10709
- selfHosted.append(selfHostedSummary, selfHostedBody);
10710
- providerSection.append(providerHeading, providerInfo, providerChoices, selfHosted);
10851
+ providerChoices.append(irohChoice, frpChoice, originChoice);
10852
+ const lanChoice = element("button", "dsh-mobile-control__provider is-lan");
10853
+ lanChoice.type = "button";
10854
+ lanChoice.setAttribute("role", "radio");
10855
+ lanChoice.setAttribute("aria-checked", "true");
10856
+ const lanChoiceTop = element("span", "dsh-mobile-control__provider-top");
10857
+ const lanChoiceName = element("strong");
10858
+ lanChoiceName.textContent = t("lan");
10859
+ const lanChoiceBadge = element("span", "dsh-mobile-control__provider-badge");
10860
+ lanChoiceBadge.textContent = t("builtIn");
10861
+ const lanChoiceDescription = element("span", "dsh-mobile-control__provider-description");
10862
+ lanChoiceDescription.textContent = t("lanChoiceDescription");
10863
+ lanChoiceTop.append(lanChoiceName, lanChoiceBadge);
10864
+ lanChoice.append(lanChoiceTop, lanChoiceDescription);
10865
+ providerSection.append(providerHeading, lanChoice, providerChoices);
10711
10866
  const frpSetup = element("section", "dsh-mobile-control__frp-setup");
10712
10867
  frpSetup.hidden = true;
10713
10868
  const frpSetupTitle = element("h3", "dsh-mobile-control__section-title");
@@ -11003,7 +11158,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11003
11158
  const frpVerifySummary = element("summary");
11004
11159
  frpVerifySummary.textContent = t("frpStep4Title");
11005
11160
  frpVerifyGroup.append(frpVerifySummary, frpStep4);
11006
- frpSetup.append(frpSetupTitle, frpOverview, frpConnectionGroup, frpVpsGroup, frpComponentGroup, frpVerifyGroup, frpDetails);
11161
+ frpSetup.append(frpSetupTitle, frpOverview, frpConnectionGroup, frpVpsGroup, frpComponentGroup, frpVerifyGroup);
11007
11162
  const originSetup = element("section", "dsh-mobile-control__origin-setup");
11008
11163
  originSetup.hidden = true;
11009
11164
  const originSetupTitle = element("h3", "dsh-mobile-control__section-title");
@@ -11178,16 +11333,6 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11178
11333
  tailscaleInfoText.textContent = t("tailscaleHelpText");
11179
11334
  tailscaleInfoBody.append(tailscaleInfoText);
11180
11335
  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
11336
  const providerSetupBody = element("div", "dsh-mobile-control__provider-setup-body");
11192
11337
  const irohSetup = element("section", "dsh-mobile-control__frp-setup");
11193
11338
  irohSetup.hidden = true;
@@ -11205,7 +11350,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11205
11350
  const irohInstallProgress = element("progress", "dsh-mobile-control__progress");
11206
11351
  irohInstallProgress.hidden = true;
11207
11352
  irohInstallProgress.setAttribute("aria-hidden", "true");
11208
- const irohPairing = element("div", "dsh-mobile-control__ws-paths");
11353
+ const irohPairing = element("div", "dsh-mobile-control__iroh-pairing");
11209
11354
  const irohPairingTitle = element("p", "dsh-mobile-control__component-status");
11210
11355
  irohPairingTitle.textContent = t("irohPairingTitle");
11211
11356
  const irohPair = element("button", "dsh-mobile-control__primary");
@@ -11218,7 +11363,8 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11218
11363
  irohCopyDescriptor.type = "button";
11219
11364
  irohCopyDescriptor.textContent = t("copyPairLink");
11220
11365
  irohCopyDescriptor.hidden = true;
11221
- irohPairing.append(irohPairingTitle, irohPair, irohQr, irohPairStatus, irohCopyDescriptor);
11366
+ irohCopyDescriptor.disabled = true;
11367
+ irohPairing.append(irohPairingTitle, irohQr, irohPairStatus);
11222
11368
  const irohDetails = element("details", "dsh-mobile-control__details");
11223
11369
  const irohDetailsSummary = element("summary");
11224
11370
  irohDetailsSummary.textContent = t("componentDetails");
@@ -11234,9 +11380,12 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11234
11380
  irohOfficial.target = "_blank";
11235
11381
  irohOfficial.rel = "noopener noreferrer";
11236
11382
  irohOfficial.textContent = t("officialDownload");
11237
- irohDetailsBody.append(irohDetailsText, irohInstallNote, irohStorage, irohOfficial);
11383
+ const irohPurge = element("button", "dsh-mobile-control__danger");
11384
+ irohPurge.type = "button";
11385
+ irohPurge.textContent = t("purgeIroh");
11386
+ irohDetailsBody.append(irohDetailsText, irohInstallNote, irohStorage, irohOfficial, irohPurge);
11238
11387
  irohDetails.append(irohDetailsSummary, irohDetailsBody);
11239
- irohSetup.append(irohSetupTitle, irohPhaseNote, irohComponentStatus, irohInstall, irohInstallProgress, irohPairing, irohDetails);
11388
+ irohSetup.append(irohSetupTitle, irohPhaseNote, irohComponentStatus, irohInstall, irohInstallProgress, irohPairing);
11240
11389
  providerSetupBody.append(cpolarSetup, cloudflaredSetup, frpSetup, originSetup, irohSetup, tailscaleInfo);
11241
11390
  const irohQrZoom = element("div", "dsh-mobile-control__qr-zoom");
11242
11391
  irohQrZoom.hidden = true;
@@ -11323,11 +11472,16 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11323
11472
  remoteCopyLink.disabled = true;
11324
11473
  const remotePairLink = element("p", "dsh-mobile-control__status");
11325
11474
  remotePairLink.hidden = true;
11475
+ const pairingValidity = element("p", "dsh-mobile-control__component-status");
11476
+ pairingValidity.hidden = true;
11326
11477
  let activeRemotePairUrl = "";
11327
11478
  let activeRemotePairExpiresAt = 0;
11479
+ let remotePairingBusy = false;
11480
+ let remotePairingGeneration = 0;
11328
11481
  let activeIrohDescriptor = "";
11482
+ let activeIrohPairExpiresAt = 0;
11329
11483
  let activeIrohQrSvg = "";
11330
- remoteActions.append(remoteToggle, remoteLogin, remoteReconnect, remotePair, remoteCopyLink);
11484
+ remoteActions.append(remoteToggle, irohPair, irohCopyDescriptor, remoteLogin, remoteReconnect, remotePair, remoteCopyLink);
11331
11485
  const remoteManageRow = element("div", "dsh-mobile-control__manage-row");
11332
11486
  const remoteDevices = element("button", "dsh-mobile-control__manage");
11333
11487
  remoteDevices.type = "button";
@@ -11338,7 +11492,6 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11338
11492
  remoteReset.textContent = t("resetRemoteLogin");
11339
11493
  remoteManageRow.append(remoteDevices, remoteReset);
11340
11494
  const remoteDevicePanel = element("div", "dsh-mobile-control__devices");
11341
- remoteDevicePanel.hidden = true;
11342
11495
  const wsPathsSection = element("section", "dsh-mobile-control__ws-paths");
11343
11496
  const wsPathsTitle = element("h3", "dsh-mobile-control__section-title");
11344
11497
  wsPathsTitle.textContent = t("wsPathsTitle");
@@ -11522,10 +11675,53 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11522
11675
  wsPathsInput.value = "";
11523
11676
  saveWsPaths([...wsPathsCurrent, value]);
11524
11677
  });
11525
- const remoteWorkspace = element("section", "dsh-mobile-control__remote-workspace");
11526
- remoteWorkspace.append(providerSetupHeader, remoteStatus, remoteAccess, remoteGuide, providerSetupBody, remoteActions, remoteQr, remotePairLink, remoteManageRow, remoteDevicePanel);
11678
+ const remoteWorkspace = element("div", "dsh-mobile-control__remote-workspace");
11679
+ const providerInstallRow = element("div", "dsh-mobile-control__provider-install");
11680
+ providerInstallRow.append(cpolarInstall, cloudflaredInstall);
11681
+ const setupDetailsRow = element("div", "dsh-mobile-control__setup-details");
11682
+ for (const disclosure of [
11683
+ cpolarDetails,
11684
+ cloudflaredDetails,
11685
+ frpDetails,
11686
+ irohDetails
11687
+ ]) {
11688
+ disclosure.hidden = true;
11689
+ setupDetailsRow.append(disclosure);
11690
+ }
11691
+ remoteWorkspace.append(remoteStatus, remoteAccess, remoteGuide, providerSetupBody, remoteQr, pairingValidity, providerInstallRow, remoteActions, remotePairLink, setupDetailsRow);
11527
11692
  const diagnosticsView = element("div", "dsh-mobile-control__view is-diagnostics");
11528
11693
  diagnosticsView.hidden = true;
11694
+ const devicesView = element("div", "dsh-mobile-control__view is-devices");
11695
+ devicesView.hidden = true;
11696
+ const devicesSwitcher = element("div", "dsh-mobile-control__switcher");
11697
+ const devicesLanTab = element("button", "dsh-mobile-control__tab is-active");
11698
+ devicesLanTab.type = "button";
11699
+ devicesLanTab.textContent = t("devicesLanTitle");
11700
+ const devicesRemoteTab = element("button", "dsh-mobile-control__tab");
11701
+ devicesRemoteTab.type = "button";
11702
+ devicesRemoteTab.textContent = t("devicesRemoteTitle");
11703
+ devicesSwitcher.append(devicesLanTab, devicesRemoteTab);
11704
+ const devicesLanGroup = element("div");
11705
+ const devicesLanRow = element("div", "dsh-mobile-control__manage-row");
11706
+ devicesLanRow.append(resetAll);
11707
+ devicesLanGroup.append(devicePanel, devicesLanRow);
11708
+ const devicesRemoteGroup = element("div");
11709
+ devicesRemoteGroup.hidden = true;
11710
+ const devicesRemoteRow = element("div", "dsh-mobile-control__manage-row");
11711
+ devicesRemoteRow.append(remoteReset);
11712
+ devicesRemoteGroup.append(remoteDevicePanel, devicesRemoteRow);
11713
+ devicesView.append(devicesSwitcher, devicesLanGroup, devicesRemoteGroup);
11714
+ const showDevicesGroup = (which) => {
11715
+ const lan = which === "lan";
11716
+ devicesLanGroup.hidden = !lan;
11717
+ devicesRemoteGroup.hidden = lan;
11718
+ devicesLanTab.classList.toggle("is-active", lan);
11719
+ devicesRemoteTab.classList.toggle("is-active", !lan);
11720
+ if (lan) loadDevices();
11721
+ else loadRemoteDevices();
11722
+ };
11723
+ devicesLanTab.addEventListener("click", () => showDevicesGroup("lan"));
11724
+ devicesRemoteTab.addEventListener("click", () => showDevicesGroup("remote"));
11529
11725
  const diagnosticsIntro = element("p", "dsh-mobile-control__intro");
11530
11726
  diagnosticsIntro.textContent = t("diagnosticsIntro");
11531
11727
  const diagnosticsSummary = element("section", "dsh-mobile-control__diagnostic-summary is-idle");
@@ -11567,10 +11763,14 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11567
11763
  diagnosticsDetails.append(diagnosticsDetailsSummary, diagnosticsReport);
11568
11764
  header.append(title, headerActions);
11569
11765
  actions.append(toggle, pair, linkPair);
11570
- lanView.append(lanSetup, access, qrBox, status, extensionStatus, actions, manageRow, devicePanel, trustedNetworks);
11571
- remoteView.append(remoteIntro, httpFrameWarning, providerSection, remoteWorkspace);
11766
+ lanView.append(lanSetup, access, qrBox, lanPairingValidity, status, extensionStatus, actions, trustedNetworks);
11767
+ const channelPane = element("div", "dsh-mobile-control__channel-pane");
11768
+ channelPane.append(lanView, remoteWorkspace);
11769
+ const remoteColumns = element("div", "dsh-mobile-control__remote-columns");
11770
+ remoteColumns.append(providerSection, channelPane);
11771
+ remoteView.append(remoteIntro, httpFrameWarning, remoteColumns);
11572
11772
  diagnosticsView.append(diagnosticsIntro, diagnosticsSummary, diagnosticsToolbar, diagnosticsFeedback, diagnosticsChecks, wsPathsSection, diagnosticsDetails);
11573
- panel.append(header, releaseNotice, updateCard, appDownload, extensionRecovery.section, switcher, lanView, remoteView, diagnosticsView);
11773
+ panel.append(header, releaseNotice, updateCard, appDownloads, extensionRecovery.section, remoteView, diagnosticsView, devicesView);
11574
11774
  root.append(panel);
11575
11775
  document.body.append(root);
11576
11776
  let running = false;
@@ -11593,6 +11793,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11593
11793
  let cloudflaredInstalled = false;
11594
11794
  let cloudflaredDownloadSize = "";
11595
11795
  let cloudflaredTunnelMode = "quick";
11796
+ let cloudflaredConfigEditing = false;
11596
11797
  let cloudflaredModeDraft = null;
11597
11798
  let cloudflaredTunnelConfigured = false;
11598
11799
  let configuredCloudflaredHostname = "";
@@ -11641,8 +11842,6 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11641
11842
  let frpEntryTlsDraft = false;
11642
11843
  let frpVhostDraft = false;
11643
11844
  let frpAttachVhostExplicit = false;
11644
- let providerInfoPinned = false;
11645
- let providerInfoHovered = false;
11646
11845
  let previousAccessView = "lan";
11647
11846
  let diagnosticsBusy = false;
11648
11847
  let copiedDiagnosticReport = "";
@@ -11659,78 +11858,140 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11659
11858
  updatePlugin.textContent = t("updatePlugin");
11660
11859
  if (pluginLatestVersion !== "") updatePlugin.setAttribute("aria-label", t("updatePluginAria", { version: pluginLatestVersion }));
11661
11860
  if (release.androidVersion !== void 0) {
11662
- appDownload.textContent = t("downloadAndroidVersion", { version: release.androidVersion });
11663
- appDownload.setAttribute("aria-label", t("downloadAndroidVersionAria", { version: release.androidVersion }));
11861
+ const version = release.androidVersion;
11862
+ const sizeMb = (bytes) => bytes === void 0 ? void 0 : (bytes / 1048576).toFixed(1);
11863
+ const compatSize = sizeMb(release.androidCompatBytes);
11864
+ const irohSize = sizeMb(release.androidIrohBytes);
11865
+ appDownload.textContent = compatSize === void 0 ? t("downloadAndroidVersion", { version }) : t("downloadAndroidVersionSize", {
11866
+ version,
11867
+ size: compatSize
11868
+ });
11869
+ appDownload.setAttribute("aria-label", t("downloadAndroidVersionAria", { version }));
11870
+ appDownloadIroh.textContent = irohSize === void 0 ? t("downloadAndroidIrohVersion", { version }) : t("downloadAndroidIrohVersionSize", {
11871
+ version,
11872
+ size: irohSize
11873
+ });
11874
+ appDownloadIroh.setAttribute("aria-label", t("downloadAndroidIrohAria", { version }));
11664
11875
  }
11665
11876
  appDownload.href = release.androidDownloadUrl;
11877
+ if (release.androidIrohDownloadUrl === void 0) appDownloadIroh.hidden = true;
11878
+ else {
11879
+ appDownloadIroh.href = release.androidIrohDownloadUrl;
11880
+ appDownloadIroh.hidden = false;
11881
+ }
11882
+ };
11883
+ let currentAccessView = "lan";
11884
+ const applyChoiceSelection = () => {
11885
+ const inRemote = currentAccessView === "remote";
11886
+ lanChoice.classList.toggle("is-selected", currentAccessView === "lan");
11887
+ lanChoice.setAttribute("aria-checked", String(currentAccessView === "lan"));
11888
+ tailscaleChoice.classList.toggle("is-selected", inRemote && remoteProvider === "tailscale");
11889
+ cpolarChoice.classList.toggle("is-selected", inRemote && remoteProvider === "cpolar");
11890
+ cloudflaredChoice.classList.toggle("is-selected", inRemote && remoteProvider === "cloudflared");
11891
+ frpChoice.classList.toggle("is-selected", inRemote && remoteProvider === "frp");
11892
+ originChoice.classList.toggle("is-selected", inRemote && remoteProvider === "origin");
11893
+ irohChoice.classList.toggle("is-selected", inRemote && remoteProvider === "iroh");
11666
11894
  };
11667
- const syncProviderInfo = () => {
11668
- const open = providerInfoPinned || providerInfoHovered || providerInfo.contains(document.activeElement);
11669
- providerInfoPopover.hidden = !open;
11670
- providerInfoButton.setAttribute("aria-expanded", String(open));
11671
- };
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
11895
  const selectView = (view) => {
11696
11896
  if (!panel.hidden) extensionRecovery.refresh();
11697
- if (view !== "diagnostics") previousAccessView = view;
11897
+ currentAccessView = view;
11898
+ if (view === "lan" || view === "remote") previousAccessView = view;
11899
+ const subpage = view === "diagnostics" || view === "devices";
11900
+ remoteView.hidden = subpage;
11698
11901
  lanView.hidden = view !== "lan";
11699
- remoteView.hidden = view !== "remote";
11902
+ remoteWorkspace.hidden = view !== "remote";
11700
11903
  diagnosticsView.hidden = view !== "diagnostics";
11904
+ devicesView.hidden = view !== "devices";
11701
11905
  lanTab.classList.toggle("is-active", view === "lan");
11702
11906
  remoteTab.classList.toggle("is-active", view === "remote");
11907
+ applyChoiceSelection();
11703
11908
  lanTab.setAttribute("aria-pressed", String(view === "lan"));
11704
11909
  remoteTab.setAttribute("aria-pressed", String(view === "remote"));
11705
11910
  diagnosticsEntry.setAttribute("aria-pressed", String(view === "diagnostics"));
11706
11911
  diagnosticsEntry.textContent = view === "diagnostics" ? t("back") : t("diagnostics");
11707
11912
  diagnosticsEntry.setAttribute("aria-label", view === "diagnostics" ? t("backToMobile") : t("openDiagnostics"));
11913
+ devicesEntry.setAttribute("aria-pressed", String(view === "devices"));
11914
+ devicesEntry.textContent = view === "devices" ? t("back") : t("device");
11915
+ devicesEntry.setAttribute("aria-label", view === "devices" ? t("backToMobile") : t("managePairedDevices"));
11708
11916
  if (view === "diagnostics") {
11709
11917
  wsPathsSeenAttempts = wsBlockedTotal(wsPathsBlocked);
11710
11918
  renderWsDot();
11711
11919
  if (!wsPathsLoaded) loadWsPaths();
11712
11920
  }
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");
11921
+ updatePlugin.hidden = subpage || !pluginUpdateAvailable;
11922
+ appDownloads.hidden = subpage;
11923
+ switcher.hidden = subpage;
11924
+ title.textContent = view === "lan" ? t("lanAccess") : view === "remote" ? t("remoteAccess") : view === "devices" ? t("managePairedDevices") : t("connectionDiagnostics");
11717
11925
  };
11718
11926
  lanTab.addEventListener("click", () => {
11719
11927
  selectView("lan");
11720
11928
  });
11929
+ lanChoice.addEventListener("click", () => {
11930
+ selectView("lan");
11931
+ });
11932
+ providerChoices.addEventListener("click", () => {
11933
+ selectView("remote");
11934
+ loadRemote();
11935
+ });
11721
11936
  remoteTab.addEventListener("click", () => {
11722
11937
  selectView("remote");
11723
11938
  loadRemote();
11724
11939
  });
11940
+ selectView("lan");
11941
+ let returnFocus;
11725
11942
  const setOpen = (open) => {
11943
+ const changed = panel.hidden === open;
11944
+ if (open && changed && document.activeElement instanceof HTMLElement) returnFocus = document.activeElement;
11726
11945
  panel.hidden = !open;
11946
+ if (open) {
11947
+ panel.setAttribute("role", "dialog");
11948
+ panel.setAttribute("aria-modal", "true");
11949
+ } else {
11950
+ panel.removeAttribute("role");
11951
+ panel.removeAttribute("aria-modal");
11952
+ }
11727
11953
  if (open) extensionRecovery.refresh();
11954
+ if (open && changed) controlRequestJson("/api/mobile-access/release").then(renderRelease, () => {});
11955
+ if (changed) {
11956
+ if (open) close.focus();
11957
+ else if (returnFocus?.isConnected) returnFocus.focus();
11958
+ }
11728
11959
  for (const trigger of document.querySelectorAll(".dsh-mobile-control__trigger")) trigger.setAttribute("aria-expanded", String(open));
11729
11960
  };
11961
+ panel.addEventListener("keydown", (event) => {
11962
+ if (event.defaultPrevented) return;
11963
+ if (event.key === "Escape") {
11964
+ event.preventDefault();
11965
+ setOpen(false);
11966
+ return;
11967
+ }
11968
+ if (event.key !== "Tab") return;
11969
+ const controls = [...panel.querySelectorAll("button,a[href],input,select,textarea,[tabindex]")].filter((node) => node.tabIndex >= 0 && !node.matches(":disabled") && node.getClientRects().length > 0);
11970
+ const first = controls[0], last = controls.at(-1);
11971
+ if (event.shiftKey && document.activeElement === first && last !== void 0) {
11972
+ event.preventDefault();
11973
+ last.focus();
11974
+ } else if (!event.shiftKey && document.activeElement === last && first !== void 0) {
11975
+ event.preventDefault();
11976
+ first.focus();
11977
+ }
11978
+ });
11979
+ const renderLanPairingExpiry = () => {
11980
+ if (!running || activeLanPairExpiresAt === 0) return;
11981
+ lanPairingValidity.hidden = false;
11982
+ if (Date.now() >= activeLanPairExpiresAt) {
11983
+ activeLanPairUrl = "";
11984
+ showQr("");
11985
+ qrBox.textContent = t("pairingExpired");
11986
+ lanPairingValidity.textContent = t("pairingExpired");
11987
+ } else lanPairingValidity.textContent = t("pairingExpires", { time: formatTime(activeLanPairExpiresAt) });
11988
+ };
11730
11989
  const render = (data) => {
11990
+ const previousOrigin = origin;
11731
11991
  lanConfigured = data.configured !== false;
11732
11992
  lanRestartRequired = data.restartRequired === true;
11733
11993
  running = data.running === true;
11994
+ lanChoiceBadge.textContent = running ? t("lanBadgeOn") : t("lanBadgeOff");
11734
11995
  origin = running && typeof data.origin === "string" ? data.origin : "";
11735
11996
  lanSetup.hidden = lanConfigured && !lanRestartRequired;
11736
11997
  lanSetupForm.hidden = lanRestartRequired;
@@ -11757,12 +12018,18 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11757
12018
  failed
11758
12019
  });
11759
12020
  } else extensionStatus.hidden = true;
11760
- if (!running) qrBox.hidden = true;
12021
+ if (!running || previousOrigin !== origin) {
12022
+ lanPairingGeneration += 1;
12023
+ activeLanPairUrl = "";
12024
+ activeLanPairExpiresAt = 0;
12025
+ lanPairingValidity.hidden = true;
12026
+ showQr("");
12027
+ } else renderLanPairingExpiry();
11761
12028
  toggle.textContent = running ? t("disableLan") : t("enableLan");
11762
12029
  actions.hidden = !lanConfigured || lanRestartRequired;
11763
12030
  manageRow.hidden = !lanConfigured || lanRestartRequired;
11764
- pair.disabled = !running;
11765
- linkPair.disabled = !running;
12031
+ pair.disabled = !running || lanPairingBusy;
12032
+ linkPair.disabled = !running || lanPairingBusy;
11766
12033
  manageDevices.disabled = !running;
11767
12034
  resetAll.disabled = !running;
11768
12035
  };
@@ -11831,10 +12098,25 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11831
12098
  lanSetupConfigure.disabled = lanRestartRequired || lanSetupSelect.options.length === 0;
11832
12099
  });
11833
12100
  });
12101
+ const clearQrTarget = (target) => {
12102
+ if (typeof target.replaceChildren === "function") {
12103
+ target.replaceChildren();
12104
+ return;
12105
+ }
12106
+ try {
12107
+ while (target.firstChild) target.removeChild(target.firstChild);
12108
+ } catch {}
12109
+ };
11834
12110
  const showQr = (svg, target = qrBox) => {
11835
- target.replaceChildren();
12111
+ clearQrTarget(target);
11836
12112
  if (svg === "") {
11837
- target.hidden = true;
12113
+ const placeholderText = target === irohQr ? t("irohQrPlaceholder") : target === remoteQr ? t("remoteQrPlaceholder") : target === qrBox ? t("qrPlaceholder") : void 0;
12114
+ if (placeholderText !== void 0) {
12115
+ const placeholder = element("span", "dsh-mobile-control__qr-placeholder");
12116
+ placeholder.textContent = placeholderText;
12117
+ target.hidden = false;
12118
+ target.append(placeholder);
12119
+ } else target.hidden = true;
11838
12120
  return;
11839
12121
  }
11840
12122
  const image = element("img");
@@ -11845,23 +12127,68 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11845
12127
  target.hidden = false;
11846
12128
  target.append(image);
11847
12129
  };
12130
+ showQr("");
12131
+ const showIrohQrPlaceholder = () => {
12132
+ clearQrTarget(irohQr);
12133
+ const placeholder = element("span", "dsh-mobile-control__qr-placeholder");
12134
+ placeholder.textContent = t("irohQrPlaceholder");
12135
+ irohQr.hidden = false;
12136
+ irohQr.append(placeholder);
12137
+ };
12138
+ const showRemoteQrPlaceholder = (text) => {
12139
+ clearQrTarget(remoteQr);
12140
+ const placeholder = element("span", "dsh-mobile-control__qr-placeholder");
12141
+ placeholder.textContent = text;
12142
+ remoteQr.hidden = false;
12143
+ remoteQr.append(placeholder);
12144
+ };
11848
12145
  const openPairing = (target) => {
11849
- controlRequestJson("/api/mobile-access/lan/pairing/open", {
12146
+ if (lanPairingBusy || !running) return;
12147
+ lanPairingBusy = true;
12148
+ pair.disabled = true;
12149
+ linkPair.disabled = true;
12150
+ const pairingOrigin = origin;
12151
+ const generation = lanPairingGeneration;
12152
+ if (target === "key" || activeLanPairExpiresAt <= Date.now()) {
12153
+ activeLanPairUrl = "";
12154
+ activeLanPairExpiresAt = 0;
12155
+ lanPairingValidity.hidden = true;
12156
+ status.classList.remove("is-key");
12157
+ showQr("");
12158
+ }
12159
+ (target === "link" && activeLanPairUrl !== "" && activeLanPairExpiresAt > Date.now() ? Promise.resolve({
12160
+ pairUrl: activeLanPairUrl,
12161
+ expiresAt: activeLanPairExpiresAt
12162
+ }) : controlRequestJson("/api/mobile-access/lan/pairing/open", {
11850
12163
  method: "POST",
11851
12164
  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 : "");
12165
+ })).then(async (data) => {
12166
+ if (lifecycle.signal.aborted || !running || origin !== pairingOrigin || generation !== lanPairingGeneration) return;
12167
+ const value = typeof data.pairUrl === "string" ? data.pairUrl : "";
12168
+ if (value === "" || typeof data.expiresAt !== "number" || !Number.isFinite(data.expiresAt) || data.expiresAt <= Date.now()) {
12169
+ status.textContent = t("keyGenerationFailed");
12170
+ return;
12171
+ }
12172
+ activeLanPairUrl = value;
12173
+ activeLanPairExpiresAt = data.expiresAt;
12174
+ lanPairingValidity.hidden = false;
12175
+ lanPairingValidity.textContent = t("pairingExpires", { time: formatTime(data.expiresAt) });
12176
+ if ("qrSvg" in data) showQr(typeof data.qrSvg === "string" ? data.qrSvg : "");
12177
+ if (target === "key") {
12178
+ status.classList.remove("is-key");
12179
+ status.textContent = t("qrGenerated");
12180
+ return;
12181
+ }
11855
12182
  if (value === "") {
11856
12183
  status.textContent = t("keyGenerationFailed");
11857
12184
  return;
11858
12185
  }
11859
12186
  try {
11860
12187
  await navigator.clipboard.writeText(value);
11861
- status.textContent = target === "key" ? t("keyCopied") : t("linkCopied");
12188
+ status.textContent = t("linkCopied");
11862
12189
  } catch {
11863
12190
  status.textContent = t("copySecret", {
11864
- kind: target === "key" ? t("pairingKey") : t("pairingLink"),
12191
+ kind: t("pairingLink"),
11865
12192
  value
11866
12193
  });
11867
12194
  status.classList.add("is-key");
@@ -11869,6 +12196,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11869
12196
  }, (error) => {
11870
12197
  status.textContent = t("requestFailed", { error: String(error) });
11871
12198
  }).finally(() => {
12199
+ lanPairingBusy = false;
11872
12200
  pair.disabled = !running;
11873
12201
  linkPair.disabled = !running;
11874
12202
  });
@@ -11923,12 +12251,15 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11923
12251
  const loadDevices = () => {
11924
12252
  controlRequestJson("/api/mobile-access/lan/devices").then(renderDevices, (error) => {
11925
12253
  status.textContent = t("requestFailed", { error: String(error) });
12254
+ devicePanel.replaceChildren();
12255
+ const note = element("p", "dsh-mobile-control__device-empty");
12256
+ note.textContent = t("noDevices");
12257
+ devicePanel.append(note);
11926
12258
  });
11927
12259
  };
11928
12260
  manageDevices.addEventListener("click", () => {
11929
- const show = devicePanel.hidden;
11930
- devicePanel.hidden = !show;
11931
- if (show) loadDevices();
12261
+ selectView("devices");
12262
+ loadDevices();
11932
12263
  });
11933
12264
  resetAll.addEventListener("click", () => {
11934
12265
  if (!window.confirm(t("confirmResetDevices"))) return;
@@ -11940,6 +12271,15 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11940
12271
  });
11941
12272
  });
11942
12273
  const renderRemote = (data) => {
12274
+ if (data.provider !== latestRemoteState.provider || data.origin !== latestRemoteState.origin) {
12275
+ remotePairingGeneration += 1;
12276
+ activeRemotePairUrl = "";
12277
+ activeRemotePairExpiresAt = 0;
12278
+ remotePairLink.hidden = true;
12279
+ remotePairLink.textContent = "";
12280
+ pairingValidity.hidden = true;
12281
+ showQr("", remoteQr);
12282
+ }
11943
12283
  latestRemoteState = data;
11944
12284
  remoteRunning = data.running === true;
11945
12285
  remoteProvider = parseMobileRemoteProvider(data.provider);
@@ -11950,11 +12290,6 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11950
12290
  const iroh = remoteProvider === "iroh";
11951
12291
  const managedOrigin = origin && data.originMode === "managed";
11952
12292
  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
12293
  tailscaleChoice.setAttribute("aria-checked", String(tailscale));
11959
12294
  cpolarChoice.setAttribute("aria-checked", String(cpolar));
11960
12295
  cloudflaredChoice.setAttribute("aria-checked", String(cloudflared));
@@ -11964,7 +12299,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11964
12299
  frpChoice.setAttribute("aria-pressed", String(frp));
11965
12300
  originChoice.setAttribute("aria-pressed", String(origin));
11966
12301
  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";
12302
+ applyChoiceSelection();
11968
12303
  tailscaleChoice.disabled = remoteProviderBusy;
11969
12304
  cpolarChoice.disabled = remoteProviderBusy;
11970
12305
  cloudflaredChoice.disabled = remoteProviderBusy;
@@ -11976,7 +12311,6 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11976
12311
  frpSetup.hidden = !frp;
11977
12312
  originSetup.hidden = !origin;
11978
12313
  irohSetup.hidden = !iroh;
11979
- if (frp || origin || iroh) selfHosted.open = true;
11980
12314
  tailscaleInfo.hidden = !tailscale;
11981
12315
  remoteReset.textContent = tailscale ? t("resetRemoteLogin") : t("resetRemoteDevices");
11982
12316
  const providers = data.providers !== null && typeof data.providers === "object" ? data.providers : {};
@@ -11991,7 +12325,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11991
12325
  const componentStorage = typeof component.storagePath === "string" ? component.storagePath : `DSH Mobile ${t("pluginPrivateDirectory")}`;
11992
12326
  cpolarStorage.textContent = componentStorage;
11993
12327
  cpolarStorage.title = componentStorage;
11994
- cpolarInstall.hidden = cpolarInstalled || !cpolarSupported;
12328
+ cpolarInstall.hidden = !cpolar || cpolarInstalled || !cpolarSupported;
11995
12329
  cpolarInstall.textContent = componentDownloadBytes > 0 ? t("installWithSize", { size: formatMegabytes(componentDownloadBytes) }) : t("installOfficial");
11996
12330
  cpolarInstall.disabled = remoteProviderBusy;
11997
12331
  cpolarAccount.hidden = !cpolarInstalled || cpolarConfigured;
@@ -12010,7 +12344,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12010
12344
  cloudflaredStorage.textContent = cloudflaredStoragePath;
12011
12345
  cloudflaredStorage.title = cloudflaredStoragePath;
12012
12346
  cloudflaredChoiceBadge.textContent = cloudflaredInstalled ? t("ready") : t("cloudflaredBadge");
12013
- cloudflaredInstall.hidden = cloudflaredInstalled || !cloudflaredSupported;
12347
+ cloudflaredInstall.hidden = !cloudflared || cloudflaredInstalled || !cloudflaredSupported;
12014
12348
  cloudflaredInstall.textContent = cloudflaredDownloadBytes > 0 ? t("installWithSize", { size: formatMegabytes(cloudflaredDownloadBytes) }) : t("installOfficial");
12015
12349
  cloudflaredInstall.disabled = remoteProviderBusy;
12016
12350
  cloudflaredPurge.hidden = !cloudflaredInstalled;
@@ -12031,8 +12365,8 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12031
12365
  cloudflaredModeNamed.disabled = remoteProviderBusy;
12032
12366
  cloudflaredModeHint.textContent = cloudflaredShownMode === "named" ? t("cloudflaredTunnelNamedHint") : t("cloudflaredTunnelQuickHint");
12033
12367
  cloudflaredNote.textContent = cloudflaredShownMode === "named" ? t("cloudflaredNamedNote") : t("cloudflaredQuickNote");
12034
- cloudflaredTunnel.hidden = cloudflaredShownMode !== "named";
12035
12368
  cloudflaredTunnelSave.textContent = cloudflaredSaveLabel();
12369
+ syncCloudflaredForm();
12036
12370
  if (!cloudflaredHostnameDirty) cloudflaredHostname.value = configuredCloudflaredHostname;
12037
12371
  if (!cloudflaredPortDirty) cloudflaredPort.value = String(configuredCloudflaredPort);
12038
12372
  cloudflaredToken.placeholder = cloudflaredTunnelConfigured ? t("cloudflaredTunnelTokenSaved") : t("cloudflaredTunnelTokenPlaceholder");
@@ -12140,6 +12474,8 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12140
12474
  const irohDownloadBytes = typeof irohComponent.downloadBytes === "number" ? irohComponent.downloadBytes : 0;
12141
12475
  const irohStoragePath = typeof irohComponent.storagePath === "string" ? irohComponent.storagePath : `DSH Mobile ${t("pluginPrivateDirectory")}`;
12142
12476
  irohInstall.hidden = irohInstalled || !irohSupported;
12477
+ irohPurge.hidden = !irohInstalled;
12478
+ irohPurge.disabled = iroh && remoteRunning;
12143
12479
  if (irohVerifying) {
12144
12480
  irohInstallProgress.hidden = false;
12145
12481
  irohInstall.disabled = true;
@@ -12233,11 +12569,9 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12233
12569
  }[errorCode] ?? "";
12234
12570
  const needsFunnelSetup = state === "error" && remoteSetupUrl !== "";
12235
12571
  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");
12572
+ frpChoiceBadge.textContent = frpConfigured && frpInstalled ? t("ready") : t("advanced");
12573
+ originChoiceBadge.textContent = managedOrigin ? t(remoteReady ? "ready" : "advanced") : originListening ? t("originBackendReady") : t("advanced");
12574
+ if (remoteStatus.style) remoteStatus.style.display = state === "off" && remoteFailureText === "" ? "none" : "";
12241
12575
  remoteAccess.hidden = !remoteReady;
12242
12576
  remoteAccessLink.href = remoteOrigin;
12243
12577
  remoteAccessLink.textContent = remoteOrigin;
@@ -12245,7 +12579,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12245
12579
  remoteStatus.classList.toggle("is-running", remoteReady);
12246
12580
  const labels = {
12247
12581
  off: t("remoteOff"),
12248
- unavailable: cpolar ? t("remoteUnavailableCpolar") : cloudflared ? t("remoteUnavailableCloudflared") : frp ? t("remoteUnavailableFrp") : origin ? t("originUnavailable") : iroh ? t("irohUnavailable") : t("remoteUnavailableTailscale"),
12582
+ unavailable: cpolar ? t("remoteUnavailableCpolar") : cloudflared ? t("remoteUnavailableCloudflared") : frp ? t("remoteUnavailableFrp") : origin ? t("originUnavailable") : iroh ? irohInstalled ? t("irohInstalledNotReady") : t("irohUnavailable") : t("remoteUnavailableTailscale"),
12249
12583
  starting: cpolar ? t("remoteStartingCpolar") : cloudflared ? t("remoteStartingCloudflared") : frp ? t("remoteStartingFrp") : origin ? t("originStarting") : iroh ? t("irohStarting") : t("remoteStartingTailscale"),
12250
12584
  "needs-login": t("remoteNeedsLogin"),
12251
12585
  connecting: cpolar ? t("remoteConnectingCpolar") : cloudflared ? t("remoteConnectingCloudflared") : frp ? t("remoteConnectingFrp") : iroh ? t("irohConnecting") : t("remoteConnectingTailscale"),
@@ -12264,12 +12598,19 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12264
12598
  remoteToggle.textContent = remoteRunning ? t("disableRemote") : t("enableRemote");
12265
12599
  const originPrepared = data.originMode === "managed" ? caddySupported && caddyInstalled && caddyConfiguration.configured === true : originConfigured && !caddyModeManagedSelected;
12266
12600
  const providerPrepared = cpolar ? cpolarInstalled && cpolarConfigured : cloudflared ? cloudflaredInstalled : frp ? frpInstalled && frpConfigured : origin ? originPrepared : iroh ? irohInstalled : true;
12267
- remoteToggle.disabled = remoteProviderBusy || !providerPrepared && !(origin && remoteRunning);
12601
+ const namedUnconfigured = cloudflared && cloudflaredShownMode === "named" && cloudflaredTunnelMode !== "named" && !remoteRunning;
12602
+ remoteToggle.disabled = remoteProviderBusy || !providerPrepared && !(origin && remoteRunning) || namedUnconfigured;
12603
+ if (namedUnconfigured) {
12604
+ remoteStatus.textContent = t("cloudflaredEnableNeedsConfig");
12605
+ if (remoteStatus.style) remoteStatus.style.display = "";
12606
+ }
12268
12607
  remoteLogin.hidden = !tailscale || state !== "needs-login" || remoteLoginUrl === "";
12269
12608
  remoteReconnect.hidden = needsFunnelSetup || state !== "error" && state !== "unavailable" || !providerPrepared;
12270
12609
  remoteActions.hidden = !providerPrepared && !(origin && remoteRunning);
12271
- remotePair.disabled = !remoteReady || remoteProviderBusy;
12272
- remoteCopyLink.disabled = !remoteReady || remoteProviderBusy;
12610
+ if (!providerPrepared) remoteQr.hidden = true;
12611
+ providerInstallRow.hidden = cpolarInstall.hidden && cloudflaredInstall.hidden;
12612
+ remotePair.disabled = !remoteReady || remoteProviderBusy || remotePairingBusy;
12613
+ remoteCopyLink.disabled = !remoteReady || remoteProviderBusy || remotePairingBusy;
12273
12614
  remoteDevices.disabled = !remoteReady || remoteProviderBusy;
12274
12615
  remotePair.hidden = iroh;
12275
12616
  remoteCopyLink.hidden = iroh;
@@ -12278,24 +12619,49 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12278
12619
  remotePairLink.hidden = true;
12279
12620
  }
12280
12621
  irohPair.disabled = !remoteReady || !iroh || remoteProviderBusy;
12622
+ irohPair.hidden = !iroh;
12623
+ irohCopyDescriptor.hidden = !iroh;
12624
+ if (iroh && irohInstalled && activeIrohQrSvg === "") showIrohQrPlaceholder();
12625
+ else if (!iroh || !irohInstalled) irohQr.hidden = true;
12281
12626
  if ((!iroh || !remoteReady) && activeIrohDescriptor !== "") {
12282
12627
  activeIrohDescriptor = "";
12628
+ activeIrohPairExpiresAt = 0;
12283
12629
  activeIrohQrSvg = "";
12284
12630
  showQr("", irohQr);
12285
12631
  setIrohQrZoomOpen(false);
12286
- irohCopyDescriptor.hidden = true;
12287
12632
  irohPairStatus.textContent = "";
12288
12633
  }
12634
+ irohCopyDescriptor.disabled = !iroh || !remoteReady || remoteProviderBusy || activeIrohDescriptor === "" || activeIrohPairExpiresAt <= Date.now();
12289
12635
  if (origin) {
12290
12636
  remoteReconnect.disabled = remoteProviderBusy || remoteReconnectBusy;
12291
12637
  remoteReset.disabled = remoteProviderBusy;
12292
12638
  } else remoteReset.disabled = false;
12293
12639
  if (!remoteReady) {
12294
- remoteQr.hidden = true;
12640
+ remotePairingGeneration += 1;
12295
12641
  remotePairLink.hidden = true;
12296
12642
  activeRemotePairUrl = "";
12297
12643
  activeRemotePairExpiresAt = 0;
12644
+ pairingValidity.hidden = true;
12645
+ if (iroh) remoteQr.hidden = true;
12646
+ else showRemoteQrPlaceholder(t("remoteQrPlaceholderIdle"));
12647
+ } else if (!iroh && activeRemotePairExpiresAt > 0 && Date.now() >= activeRemotePairExpiresAt) {
12648
+ activeRemotePairUrl = "";
12649
+ remotePairLink.hidden = true;
12650
+ remotePairLink.textContent = "";
12651
+ showRemoteQrPlaceholder(t("pairingExpired"));
12652
+ pairingValidity.hidden = false;
12653
+ pairingValidity.textContent = t("pairingExpired");
12654
+ } else if (!iroh && activeRemotePairUrl !== "") {
12655
+ pairingValidity.hidden = false;
12656
+ pairingValidity.textContent = t("pairingExpires", { time: formatTime(activeRemotePairExpiresAt) });
12657
+ } else {
12658
+ pairingValidity.hidden = true;
12659
+ if (!iroh) showQr("", remoteQr);
12298
12660
  }
12661
+ cpolarDetails.hidden = !cpolar;
12662
+ cloudflaredDetails.hidden = !cloudflared;
12663
+ frpDetails.hidden = !frp;
12664
+ irohDetails.hidden = !iroh;
12299
12665
  if (!needsFunnelSetup) remoteSetupPending = false;
12300
12666
  };
12301
12667
  frpMode.addEventListener("change", () => {
@@ -12405,6 +12771,26 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12405
12771
  loadRemote();
12406
12772
  });
12407
12773
  });
12774
+ irohPurge.addEventListener("click", () => {
12775
+ if (remoteProviderBusy) return;
12776
+ if (!window.confirm(t("irohPurgeConfirm"))) return;
12777
+ remoteProviderBusy = true;
12778
+ irohPurge.disabled = true;
12779
+ irohComponentStatus.textContent = t("purgingIroh");
12780
+ remoteStatus.textContent = t("purgingIroh");
12781
+ controlRequestJson("/api/mobile-access/remote/iroh/component/purge", {
12782
+ method: "POST",
12783
+ body: JSON.stringify({ confirm: true })
12784
+ }, LONG_CONTROL_REQUEST_TIMEOUT_MS).then(renderRemote, (error) => {
12785
+ remoteFailureText = remoteFailureTextFor(error, "purgeFailed");
12786
+ remoteStatus.textContent = remoteFailureText;
12787
+ irohComponentStatus.textContent = remoteFailureText;
12788
+ }).finally(() => {
12789
+ remoteProviderBusy = false;
12790
+ irohPurge.disabled = false;
12791
+ loadRemote();
12792
+ });
12793
+ });
12408
12794
  const openIrohQrZoom = () => {
12409
12795
  if (activeIrohQrSvg === "" || irohQr.hidden) return;
12410
12796
  irohQrZoomImage.src = `data:image/svg+xml;base64,${btoa(activeIrohQrSvg)}`;
@@ -12430,15 +12816,22 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12430
12816
  if (event.key !== "Escape") return;
12431
12817
  closeIrohQrZoom();
12432
12818
  });
12433
- const openIrohPairing = () => controlRequestJson("/api/mobile-access/remote/iroh/pairing/begin", {
12434
- method: "POST",
12435
- body: "{}"
12436
- }).then(async (data) => {
12819
+ const openIrohPairing = async () => {
12820
+ const generation = remotePairingGeneration;
12821
+ activeIrohDescriptor = "";
12822
+ activeIrohPairExpiresAt = 0;
12823
+ irohCopyDescriptor.disabled = true;
12824
+ const data = await controlRequestJson("/api/mobile-access/remote/iroh/pairing/begin", {
12825
+ method: "POST",
12826
+ body: "{}"
12827
+ });
12828
+ if (lifecycle.signal.aborted || remoteProvider !== "iroh" || !remoteReady || generation !== remotePairingGeneration) return;
12437
12829
  const record = data;
12438
12830
  const qrSvg = typeof record.qrSvg === "string" ? record.qrSvg : "";
12439
12831
  const code = typeof record.irohCode === "string" ? record.irohCode : "";
12832
+ const expiresAt = typeof record.expiresAt === "number" && Number.isFinite(record.expiresAt) ? record.expiresAt : 0;
12440
12833
  showQr(qrSvg, irohQr);
12441
- if (qrSvg === "" || code === "") {
12834
+ if (qrSvg === "" || code === "" || expiresAt <= Date.now()) {
12442
12835
  activeIrohQrSvg = "";
12443
12836
  irohPairStatus.textContent = t("keyGenerationFailed");
12444
12837
  return;
@@ -12459,12 +12852,14 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12459
12852
  const descriptor = {};
12460
12853
  for (const field of fields) descriptor[field] = record[field];
12461
12854
  activeIrohDescriptor = JSON.stringify(descriptor);
12462
- irohCopyDescriptor.hidden = false;
12855
+ activeIrohPairExpiresAt = expiresAt;
12463
12856
  irohPairStatus.textContent = t("irohPairingReady", { code });
12464
- });
12857
+ };
12465
12858
  irohPair.addEventListener("click", () => {
12466
- if (remoteProviderBusy) return;
12859
+ if (remoteProviderBusy || !remoteReady || remoteProvider !== "iroh") return;
12467
12860
  remoteProviderBusy = true;
12861
+ remoteSnapshotEpoch += 1;
12862
+ renderRemote(latestRemoteState);
12468
12863
  irohPair.disabled = true;
12469
12864
  irohPairStatus.textContent = t("loadingRemoteStatus");
12470
12865
  openIrohPairing().catch((error) => {
@@ -12476,9 +12871,9 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12476
12871
  });
12477
12872
  });
12478
12873
  irohCopyDescriptor.addEventListener("click", () => {
12479
- if (activeIrohDescriptor === "") return;
12874
+ if (remoteProviderBusy || !remoteReady || remoteProvider !== "iroh" || activeIrohDescriptor === "" || activeIrohPairExpiresAt <= Date.now()) return;
12480
12875
  navigator.clipboard.writeText(activeIrohDescriptor).then(() => {
12481
- irohPairStatus.textContent = t("linkCopied");
12876
+ irohPairStatus.textContent = t("irohLinkCopied");
12482
12877
  }, () => {
12483
12878
  irohPairStatus.textContent = t("remoteQrReady");
12484
12879
  });
@@ -12519,6 +12914,19 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12519
12914
  loadRemote();
12520
12915
  });
12521
12916
  });
12917
+ const syncCloudflaredForm = () => {
12918
+ const named = (cloudflaredModeDraft ?? cloudflaredTunnelMode) === "named";
12919
+ const locked = named && cloudflaredTunnelConfigured && cloudflaredTunnelMode === "named" && !cloudflaredConfigEditing;
12920
+ cloudflaredHostname.disabled = locked || remoteProviderBusy;
12921
+ cloudflaredPort.disabled = locked || remoteProviderBusy;
12922
+ cloudflaredToken.disabled = locked || remoteProviderBusy;
12923
+ cloudflaredEditButton.disabled = remoteProviderBusy;
12924
+ cloudflaredTunnelForget.disabled = remoteProviderBusy;
12925
+ cloudflaredTunnelToggle.hidden = !named;
12926
+ cloudflaredEditButton.hidden = !locked;
12927
+ cloudflaredTunnelSave.hidden = !named || locked;
12928
+ cloudflaredTunnelForget.hidden = !named || cloudflaredTunnelMode !== "named" || cloudflaredConfigEditing;
12929
+ };
12522
12930
  const showCloudflaredMode = (mode) => {
12523
12931
  cloudflaredModeDraft = mode;
12524
12932
  cloudflaredModeQuick.classList.toggle("is-active", mode === "quick");
@@ -12527,14 +12935,60 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12527
12935
  cloudflaredModeNamed.setAttribute("aria-pressed", String(mode === "named"));
12528
12936
  cloudflaredModeHint.textContent = mode === "named" ? t("cloudflaredTunnelNamedHint") : t("cloudflaredTunnelQuickHint");
12529
12937
  cloudflaredNote.textContent = mode === "named" ? t("cloudflaredNamedNote") : t("cloudflaredQuickNote");
12530
- cloudflaredTunnel.hidden = mode !== "named";
12938
+ cloudflaredTunnel.hidden = true;
12939
+ cloudflaredTunnelToggle.textContent = t("cloudflaredShowConfig") + " ▾";
12531
12940
  cloudflaredTunnelSave.textContent = cloudflaredSaveLabel();
12941
+ syncCloudflaredForm();
12942
+ if (mode === "named" && cloudflaredTunnelMode !== "named") {
12943
+ remoteToggle.disabled = true;
12944
+ remoteStatus.textContent = t("cloudflaredEnableNeedsConfig");
12945
+ if (remoteStatus.style) remoteStatus.style.display = "";
12946
+ }
12947
+ };
12948
+ cloudflaredTunnelToggle.addEventListener("click", () => {
12949
+ const open = cloudflaredTunnel.hidden;
12950
+ cloudflaredTunnel.hidden = !open;
12951
+ cloudflaredTunnelToggle.textContent = open ? t("cloudflaredHideConfig") + " ▴" : t("cloudflaredShowConfig") + " ▾";
12952
+ });
12953
+ cloudflaredEditButton.addEventListener("click", () => {
12954
+ cloudflaredConfigEditing = true;
12955
+ syncCloudflaredForm();
12956
+ cloudflaredTunnel.hidden = false;
12957
+ cloudflaredTunnelToggle.textContent = t("cloudflaredHideConfig") + " ▴";
12958
+ });
12959
+ const switchCloudflaredMode = (mode) => {
12960
+ if (remoteProviderBusy || (cloudflaredModeDraft ?? cloudflaredTunnelMode) === mode) return;
12961
+ const activeState = typeof latestRemoteState.state === "string" ? latestRemoteState.state : "off";
12962
+ if (remoteRunning || activeState !== "off") {
12963
+ if (!window.confirm(t("cloudflaredSwitchModeDisconnect"))) return;
12964
+ remoteProviderBusy = true;
12965
+ remoteSnapshotEpoch += 1;
12966
+ renderRemote(latestRemoteState);
12967
+ controlRequestJson("/api/mobile-access/remote/control", {
12968
+ method: "POST",
12969
+ body: JSON.stringify({
12970
+ running: false,
12971
+ provider: "cloudflared"
12972
+ })
12973
+ }).then((data) => {
12974
+ renderRemote(data);
12975
+ showCloudflaredMode(mode);
12976
+ }, (error) => {
12977
+ remoteFailureText = remoteFailureTextFor(error, "requestFailed");
12978
+ remoteStatus.textContent = remoteFailureText;
12979
+ }).finally(() => {
12980
+ remoteProviderBusy = false;
12981
+ loadRemote();
12982
+ });
12983
+ return;
12984
+ }
12985
+ showCloudflaredMode(mode);
12532
12986
  };
12533
12987
  cloudflaredModeQuick.addEventListener("click", () => {
12534
- if (!remoteProviderBusy) showCloudflaredMode("quick");
12988
+ switchCloudflaredMode("quick");
12535
12989
  });
12536
12990
  cloudflaredModeNamed.addEventListener("click", () => {
12537
- if (!remoteProviderBusy) showCloudflaredMode("named");
12991
+ switchCloudflaredMode("named");
12538
12992
  });
12539
12993
  cloudflaredHostname.addEventListener("input", () => {
12540
12994
  cloudflaredHostnameDirty = true;
@@ -12544,6 +12998,11 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12544
12998
  });
12545
12999
  cloudflaredTunnelSave.addEventListener("click", () => {
12546
13000
  if (remoteProviderBusy) return;
13001
+ const report = (message) => {
13002
+ remoteStatus.textContent = message;
13003
+ cloudflaredTunnelFeedback.hidden = false;
13004
+ cloudflaredTunnelFeedback.textContent = message;
13005
+ };
12547
13006
  const mode = cloudflaredModeDraft ?? cloudflaredTunnelMode;
12548
13007
  const hostname = String(cloudflaredHostname.value ?? "").trim();
12549
13008
  const portValue = String(cloudflaredPort.value ?? "").trim();
@@ -12551,48 +13010,57 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
12551
13010
  const token = String(cloudflaredToken.value ?? "");
12552
13011
  if (mode === "named") {
12553
13012
  if (hostname === "") {
12554
- remoteStatus.textContent = t("cloudflaredTunnelHostnameInvalid");
13013
+ report(t("cloudflaredTunnelHostnameInvalid"));
12555
13014
  return;
12556
13015
  }
12557
13016
  if (portValue === "" || !Number.isSafeInteger(port) || port < 1024 || port > 65535) {
12558
- remoteStatus.textContent = t("cloudflaredTunnelPortInvalid");
13017
+ report(t("cloudflaredTunnelPortInvalid"));
12559
13018
  return;
12560
13019
  }
12561
13020
  if (port === 3443) {
12562
- remoteStatus.textContent = t("cloudflaredTunnelPortReserved");
13021
+ report(t("cloudflaredTunnelPortReserved"));
12563
13022
  return;
12564
13023
  }
12565
13024
  if (token === "" && !cloudflaredTunnelConfigured) {
12566
- remoteStatus.textContent = t("cloudflaredTunnelConfigMissing");
13025
+ report(t("cloudflaredTunnelConfigMissing"));
12567
13026
  return;
12568
13027
  }
12569
13028
  if (token !== "" && (token.length < 32 || !/^[A-Za-z0-9_=+/-]+$/u.test(token))) {
12570
- remoteStatus.textContent = t("cloudflaredTunnelTokenInvalid");
13029
+ report(t("cloudflaredTunnelTokenInvalid"));
12571
13030
  return;
12572
13031
  }
12573
13032
  }
12574
13033
  remoteProviderBusy = true;
13034
+ remoteSnapshotEpoch += 1;
13035
+ renderRemote(latestRemoteState);
12575
13036
  cloudflaredTunnelSave.disabled = true;
12576
13037
  cloudflaredTunnelSave.textContent = t("saving");
12577
- remoteStatus.textContent = mode === "named" ? t("cloudflaredTunnelSaving") : t("switchingCloudflared");
13038
+ report(mode === "named" ? t("cloudflaredTunnelSaving") : t("switchingCloudflared"));
12578
13039
  controlRequestJson("/api/mobile-access/remote/cloudflared/tunnel", {
12579
13040
  method: "POST",
12580
13041
  body: JSON.stringify(mode === "named" ? {
12581
13042
  mode,
12582
13043
  hostname,
12583
13044
  port,
12584
- token
12585
- } : { mode: "quick" })
13045
+ token,
13046
+ connect: true
13047
+ } : {
13048
+ mode: "quick",
13049
+ connect: true
13050
+ })
12586
13051
  }).then((data) => {
12587
13052
  cloudflaredToken.value = "";
12588
13053
  cloudflaredModeDraft = null;
13054
+ cloudflaredConfigEditing = false;
12589
13055
  cloudflaredHostnameDirty = false;
12590
13056
  cloudflaredPortDirty = false;
12591
13057
  remoteFailureText = "";
13058
+ cloudflaredTunnelFeedback.hidden = false;
13059
+ cloudflaredTunnelFeedback.textContent = t("cloudflaredTunnelSaved");
12592
13060
  renderRemote(data);
12593
13061
  }, (error) => {
12594
13062
  remoteFailureText = remoteFailureTextFor(error, "requestFailed");
12595
- remoteStatus.textContent = remoteFailureText;
13063
+ report(remoteFailureText);
12596
13064
  }).finally(() => {
12597
13065
  remoteProviderBusy = false;
12598
13066
  cloudflaredTunnelSave.disabled = false;
@@ -13457,14 +13925,35 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
13457
13925
  });
13458
13926
  });
13459
13927
  remoteToggle.addEventListener("click", () => {
13460
- remoteToggle.disabled = true;
13461
- controlRequestJson("/api/mobile-access/remote/control", {
13928
+ if (remoteProviderBusy) return;
13929
+ const nextRunning = !remoteRunning;
13930
+ const provider = remoteProvider;
13931
+ remoteProviderBusy = true;
13932
+ remoteSnapshotEpoch += 1;
13933
+ renderRemote(latestRemoteState);
13934
+ (nextRunning && provider === "cloudflared" && cloudflaredModeDraft !== null && cloudflaredModeDraft !== cloudflaredTunnelMode ? controlRequestJson("/api/mobile-access/remote/cloudflared/tunnel", {
13462
13935
  method: "POST",
13463
- body: JSON.stringify({ running: !remoteRunning })
13464
- }).then(renderRemote, (error) => {
13936
+ body: JSON.stringify({
13937
+ mode: cloudflaredModeDraft,
13938
+ connect: true
13939
+ })
13940
+ }) : controlRequestJson("/api/mobile-access/remote/control", {
13941
+ method: "POST",
13942
+ body: JSON.stringify({
13943
+ running: nextRunning,
13944
+ provider
13945
+ })
13946
+ })).then(renderRemote, (error) => {
13465
13947
  remoteFailureText = remoteFailureTextFor(error, "requestFailed");
13466
13948
  remoteStatus.textContent = remoteFailureText;
13467
- }).finally(loadRemote);
13949
+ if (remoteProvider === "cloudflared") {
13950
+ cloudflaredTunnelFeedback.hidden = false;
13951
+ cloudflaredTunnelFeedback.textContent = remoteFailureText;
13952
+ }
13953
+ }).finally(() => {
13954
+ remoteProviderBusy = false;
13955
+ loadRemote();
13956
+ });
13468
13957
  });
13469
13958
  remoteLogin.addEventListener("click", () => {
13470
13959
  if (remoteLoginUrl !== "") window.open(remoteLoginUrl, "_blank", "noopener,noreferrer");
@@ -13516,40 +14005,64 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
13516
14005
  remoteStatus.textContent = t("remoteQrReady");
13517
14006
  }
13518
14007
  };
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
- });
14008
+ const openRemotePairing = () => {
14009
+ const provider = remoteProvider;
14010
+ const origin = latestRemoteState.origin;
14011
+ const generation = remotePairingGeneration;
14012
+ activeRemotePairUrl = "";
14013
+ activeRemotePairExpiresAt = 0;
14014
+ pairingValidity.hidden = true;
14015
+ remotePairLink.hidden = true;
14016
+ remotePairLink.textContent = "";
14017
+ showQr("", remoteQr);
14018
+ return controlRequestJson("/api/mobile-access/remote/pairing/open", {
14019
+ method: "POST",
14020
+ body: "{}"
14021
+ }).then(async (data) => {
14022
+ if (lifecycle.signal.aborted || !remoteReady || provider !== remoteProvider || origin !== latestRemoteState.origin || generation !== remotePairingGeneration) return;
14023
+ const pairUrl = typeof data.pairUrl === "string" ? data.pairUrl : "";
14024
+ const expiresAt = typeof data.expiresAt === "number" && Number.isFinite(data.expiresAt) ? data.expiresAt : 0;
14025
+ if (pairUrl === "" || expiresAt <= Date.now()) {
14026
+ remoteStatus.textContent = t("keyGenerationFailed");
14027
+ return;
14028
+ }
14029
+ showQr(typeof data.qrSvg === "string" ? data.qrSvg : "", remoteQr);
14030
+ activeRemotePairUrl = pairUrl;
14031
+ activeRemotePairExpiresAt = expiresAt;
14032
+ pairingValidity.hidden = false;
14033
+ pairingValidity.textContent = t("pairingExpires", { time: formatTime(expiresAt) });
14034
+ remotePairLink.textContent = pairUrl;
14035
+ remotePairLink.classList.add("is-key");
14036
+ remotePairLink.hidden = false;
14037
+ await copyRemotePairUrl();
14038
+ });
14039
+ };
13537
14040
  remotePair.addEventListener("click", () => {
14041
+ if (!remoteReady || remoteProviderBusy || remotePairingBusy) return;
14042
+ remotePairingBusy = true;
13538
14043
  remotePair.disabled = true;
14044
+ remoteCopyLink.disabled = true;
13539
14045
  openRemotePairing().catch((error) => {
13540
14046
  remoteFailureText = remoteFailureTextFor(error, "requestFailed");
13541
14047
  remoteStatus.textContent = remoteFailureText;
13542
14048
  }).finally(() => {
13543
- remotePair.disabled = !remoteReady;
14049
+ remotePairingBusy = false;
14050
+ remotePair.disabled = !remoteReady || remoteProviderBusy;
14051
+ remoteCopyLink.disabled = !remoteReady || remoteProviderBusy;
13544
14052
  });
13545
14053
  });
13546
14054
  remoteCopyLink.addEventListener("click", () => {
14055
+ if (!remoteReady || remoteProviderBusy || remotePairingBusy) return;
14056
+ remotePairingBusy = true;
14057
+ remotePair.disabled = true;
13547
14058
  remoteCopyLink.disabled = true;
13548
14059
  (activeRemotePairUrl !== "" && activeRemotePairExpiresAt > Date.now() ? copyRemotePairUrl() : openRemotePairing()).catch((error) => {
13549
14060
  remoteFailureText = remoteFailureTextFor(error, "requestFailed");
13550
14061
  remoteStatus.textContent = remoteFailureText;
13551
14062
  }).finally(() => {
13552
- remoteCopyLink.disabled = !remoteReady;
14063
+ remotePairingBusy = false;
14064
+ remotePair.disabled = !remoteReady || remoteProviderBusy;
14065
+ remoteCopyLink.disabled = !remoteReady || remoteProviderBusy;
13553
14066
  });
13554
14067
  });
13555
14068
  const renderRemoteDevices = (data) => {
@@ -13588,6 +14101,10 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
13588
14101
  controlRequestJson("/api/mobile-access/remote/devices").then(renderRemoteDevices, (error) => {
13589
14102
  remoteFailureText = remoteFailureTextFor(error, "requestFailed");
13590
14103
  remoteStatus.textContent = remoteFailureText;
14104
+ remoteDevicePanel.replaceChildren();
14105
+ const note = element("p", "dsh-mobile-control__device-empty");
14106
+ note.textContent = remoteRunning ? t("noRemoteDevices") : t("remoteOff");
14107
+ remoteDevicePanel.append(note);
13591
14108
  });
13592
14109
  };
13593
14110
  remoteDevices.addEventListener("click", () => {
@@ -13746,6 +14263,14 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
13746
14263
  selectView("diagnostics");
13747
14264
  loadDiagnostics();
13748
14265
  });
14266
+ devicesEntry.addEventListener("click", () => {
14267
+ if (!devicesView.hidden) {
14268
+ selectView(previousAccessView);
14269
+ return;
14270
+ }
14271
+ selectView("devices");
14272
+ showDevicesGroup("lan");
14273
+ });
13749
14274
  diagnosticsRun.addEventListener("click", loadDiagnostics);
13750
14275
  diagnosticsRun.addEventListener("click", () => {
13751
14276
  extensionRecovery.refresh();
@@ -13806,11 +14331,9 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
13806
14331
  });
13807
14332
  });
13808
14333
  pair.addEventListener("click", () => {
13809
- pair.disabled = true;
13810
14334
  openPairing("key");
13811
14335
  });
13812
14336
  linkPair.addEventListener("click", () => {
13813
- linkPair.disabled = true;
13814
14337
  openPairing("link");
13815
14338
  });
13816
14339
  close.addEventListener("click", () => {
@@ -13818,12 +14341,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
13818
14341
  });
13819
14342
  const dismiss = (event) => {
13820
14343
  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);
14344
+ if (!panel.contains(event.target) && !irohQrZoom.contains(event.target) && !document.querySelector(".dsh-mobile-control__trigger")?.contains(event.target)) setOpen(false);
13827
14345
  };
13828
14346
  document.addEventListener("pointerdown", dismiss);
13829
14347
  controlRequestJson("/api/mobile-access/release").then(renderRelease, () => {});
@@ -13832,7 +14350,10 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
13832
14350
  });
13833
14351
  loadRemote();
13834
14352
  const remotePoll = window.setInterval(() => {
13835
- if (!panel.hidden && !remoteView.hidden) loadRemote();
14353
+ if (!panel.hidden && !remoteView.hidden) {
14354
+ renderLanPairingExpiry();
14355
+ loadRemote();
14356
+ }
13836
14357
  }, 1500);
13837
14358
  const pollWsBlocked = () => {
13838
14359
  controlRequestJson("/api/mobile-access/remote/websocket-paths/blocked").then((blocked) => {
@@ -15095,9 +15616,31 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
15095
15616
  /** Theme-aware desktop Mobile Access panel styles. */
15096
15617
  const CONTROL_STYLES = `
15097
15618
  .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%)}
15619
+ .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)}
15620
+ /* Floating panel with dense, borderless content. */
15621
+ .dsh-mobile-control{inset:auto;left:16px;bottom:112px;pointer-events:none}
15622
+ .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}
15623
+ .dsh-mobile-control__header{flex:none;margin:0;padding:12px 16px;border-bottom:1px solid var(--dsw-alias-border-l2,#e1e5eb)}
15624
+ .dsh-mobile-control__app-downloads{flex:none;padding:8px 16px;border-bottom:1px solid var(--dsw-alias-border-l2,#e1e5eb);margin:0}
15625
+ .dsh-mobile-control__view.is-remote{flex:1;min-height:0;display:flex;flex-direction:column}
15626
+ .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}
15627
+ .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}
15628
+ .dsh-mobile-control__channel-pane{overflow-y:auto;padding:14px 20px;min-width:0;scrollbar-gutter:stable}
15629
+ .dsh-mobile-control__provider{position:relative;border:0;border-radius:8px;padding:8px 10px;min-height:0;background:transparent;box-shadow:none}
15630
+ .dsh-mobile-control__provider:hover{background:var(--dsw-alias-interactive-bg-hover,#f1f5f9)}
15631
+ .dsh-mobile-control__provider.is-selected{background:var(--dsw-alias-interactive-bg-active,#f5f8ff);box-shadow:none}
15632
+ .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)}
15633
+ .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)}
15634
+ .dsh-mobile-control__remote-workspace section:first-child{border-top:0}
15635
+ .dsh-mobile-control__remote-workspace .dsh-mobile-control__details{border:0;border-radius:0;background:transparent;box-shadow:none}
15636
+ .dsh-mobile-control__btn,.dsh-mobile-control__primary,.dsh-mobile-control__secondary{min-height:32px;padding:6px 12px;border-radius:8px;font-size:12px}
15637
+ .dsh-mobile-control__self-hosted{border:0;border-radius:0;background:transparent;box-shadow:none}
15638
+ .dsh-mobile-control__self-hosted-summary{border-bottom:1px solid var(--dsw-alias-border-l2,#e1e5eb)}
15639
+ .dsh-mobile-control__self-hosted-body{padding:0}
15640
+ @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}}
15641
+ .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
15642
  .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)}
15643
+ .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
15644
  .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
15645
  .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
15646
  .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 +15664,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
15121
15664
  .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
15665
  .dsh-mobile-control__diagnostic-checks{transition:opacity 150ms ease}.dsh-mobile-control__diagnostic-checks.is-refreshing{opacity:.52}
15123
15666
  @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}
15667
+ .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
15668
  .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
15669
  /* 42px matches the DSH sidebar footer row; it is not one of the panel target tiers. */
15127
15670
  /* Other footer entries share the shell's row; only our own action becomes compact. */
@@ -15135,6 +15678,65 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
15135
15678
  .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
15679
  /* Base the columns on the card width, not the desktop viewport behind the popup. */
15137
15680
  .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}
15681
+ /* Final overrides: dense borderless layout must beat the base rules above.
15682
+ The earlier dense block sits before those base rules, so these repeat the
15683
+ essential de-boxing here at the end of the sheet where they win. */
15684
+ .dsh-mobile-control__provider{position:relative;border:0;border-radius:8px;padding:8px 10px;min-height:0;background:transparent;box-shadow:none}
15685
+ .dsh-mobile-control__provider:hover{border-color:transparent;background:var(--dsw-alias-interactive-bg-hover,#f1f5f9)}
15686
+ .dsh-mobile-control__provider.is-selected{border-color:transparent;background:var(--dsw-alias-interactive-bg-active,#f5f8ff);box-shadow:none}
15687
+ .dsh-mobile-control__provider.is-lan:not(.is-selected){border-color:transparent;background:transparent}
15688
+ .dsh-mobile-control__view:not(.is-remote)>.dsh-mobile-control__actions{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
15689
+ .dsh-mobile-control__view.is-diagnostics,.dsh-mobile-control__view.is-devices{flex:1;min-height:0;overflow-y:auto;padding:14px 20px}
15690
+ .dsh-mobile-control__view.is-devices .dsh-mobile-control__devices{max-height:none;margin-top:0}
15691
+ .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}
15692
+ .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}
15693
+ .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}
15694
+ .dsh-mobile-control__origin-setup .dsh-mobile-control__actions.dsh-mobile-control__caddy-actions{margin-top:12px}
15695
+ .dsh-mobile-control__qr-zoom{pointer-events:auto}
15696
+ .dsh-mobile-control__iroh-pairing{border:0;border-radius:0;background:transparent;padding:0;margin:12px 0 0}
15697
+ /* Config setup panels: drop the rounded card frame so provider configuration
15698
+ reads as one flat page with hairline dividers instead of nested boxes. */
15699
+ .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}
15700
+ .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}
15701
+ .dsh-mobile-control__ws-paths{border:0;border-radius:0;background:transparent;padding:0;margin:10px 0 0}
15702
+ .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}
15703
+ .dsh-mobile-control__view.is-remote .dsh-mobile-control__remote-workspace>.dsh-mobile-control__actions[hidden]{display:none}
15704
+ .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)}
15705
+ .dsh-mobile-control__remote-workspace{border:0;border-radius:0;background:transparent;box-shadow:none}
15706
+ .dsh-mobile-control__stage-header{border:0;border-radius:0;background:transparent;box-shadow:none}
15707
+ .dsh-mobile-control__app-downloads{display:flex;gap:8px;padding:6px 16px;border-bottom:1px solid var(--dsw-alias-border-l2,#e1e5eb)}
15708
+ .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}
15709
+ .dsh-mobile-control__app-download.is-iroh{border-color:var(--dsw-alias-label-primary-bluish,#2563eb)}
15710
+ .dsh-mobile-control__setup-details{margin-top:12px;padding-top:8px;border-top:1px solid var(--dsw-alias-border-l2,#e1e5eb)}
15711
+ .dsh-mobile-control__remote-workspace>.dsh-mobile-control__qr{margin-bottom:18px}
15712
+ .dsh-mobile-control__stage-header{display:none}
15713
+ .dsh-mobile-control__provider-install{margin:0 0 12px}
15714
+ .dsh-mobile-control__provider-install>.dsh-mobile-control__primary{width:100%;min-height:44px}
15715
+ .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}
15716
+ .dsh-mobile-control__tunnel-toggle:hover{background:var(--dsw-alias-interactive-bg-hover,#f1f3f6)}
15717
+ .dsh-mobile-control__tunnel>.dsh-mobile-control__primary,.dsh-mobile-control__tunnel>.dsh-mobile-control__secondary{width:100%;min-height:44px;margin-top:10px}
15718
+ .dsh-mobile-control__provider-info-popover p{margin:0 0 8px;font-size:11px;line-height:1.5}
15719
+ .dsh-mobile-control__provider-info-popover p:last-child{margin-bottom:0}
15720
+ .dsh-mobile-control__provider-info-popover{max-width:240px;max-height:44vh;overflow:auto;white-space:normal}
15721
+ .dsh-mobile-control__mode-row{position:relative;display:flex;align-items:center;gap:8px;margin:0 0 8px}
15722
+ .dsh-mobile-control__mode-row>.dsh-mobile-control__switcher{flex:1 1 auto;margin:0}
15723
+ .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}
15724
+ .dsh-mobile-control__notes-toggle:hover{background:var(--dsw-alias-interactive-bg-hover,#f1f3f6)}
15725
+ .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)}
15726
+ .dsh-mobile-control__notes-panel p{margin:0 0 8px}.dsh-mobile-control__notes-panel p:last-child{margin:0}
15727
+ .dsh-mobile-control__notes-panel[hidden]{display:none}
15728
+ .dsh-mobile-control__tunnel-toggle[hidden]{display:none}
15729
+ .dsh-mobile-control__provider-setup-body .dsh-mobile-control__switcher{border-radius:0;background:transparent;padding:0;gap:8px}
15730
+ .dsh-mobile-control__provider-setup-body .dsh-mobile-control__tab{border-radius:0;box-shadow:none;border-bottom:2px solid transparent}
15731
+ .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)}
15732
+ .dsh-mobile-control__origin-setup .dsh-mobile-control__origin-mode{border-radius:0;background:transparent}
15733
+ .dsh-mobile-control__devices{border:0;border-radius:0;padding:0;margin:0;background:transparent;max-height:none}
15734
+ .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}
15735
+ .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)}
15736
+ .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)}
15737
+ .dsh-mobile-control__remote-workspace section:first-child{border-top:0}
15738
+ .dsh-mobile-control__remote-workspace .dsh-mobile-control__details{border:0;border-radius:0;background:transparent;box-shadow:none}
15739
+ .dsh-mobile-control__self-hosted{border:0;border-radius:0;background:transparent;box-shadow:none}
15138
15740
  ` + EXTENSION_RECOVERY_STYLES;
15139
15741
  /** Mount the desktop control or mobile feature enhancements. */
15140
15742
  function apply(ctx) {