@linxin666/dsh-remote-web-ui 0.2.4 → 0.2.6

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.
Files changed (90) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +148 -77
  3. package/README.zh.md +32 -26
  4. package/lib/client.js +603 -68
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +667 -32
  7. package/lib/mobile.js +3 -1
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/FenceNotice.d.ts +21 -0
  10. package/lib/types/client/FenceNotice.d.ts.map +1 -0
  11. package/lib/types/client/FenceNotice.js +10 -0
  12. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  13. package/lib/types/client/RemoteEntry.js +18 -11
  14. package/lib/types/client/RemotePanel.d.ts +10 -4
  15. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  16. package/lib/types/client/RemotePanel.js +7 -6
  17. package/lib/types/client/RemoteSettingsCard.d.ts +4 -0
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/RemoteSettingsCard.js +3 -1
  20. package/lib/types/client/device-name.d.ts +3 -0
  21. package/lib/types/client/device-name.d.ts.map +1 -0
  22. package/lib/types/client/device-name.js +30 -0
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +48 -0
  25. package/lib/types/client/locales.d.ts +51 -1
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +79 -29
  28. package/lib/types/client/pair-api.d.ts +35 -0
  29. package/lib/types/client/pair-api.d.ts.map +1 -1
  30. package/lib/types/client/pair-api.js +39 -0
  31. package/lib/types/client/remote-channel.d.ts +93 -0
  32. package/lib/types/client/remote-channel.d.ts.map +1 -0
  33. package/lib/types/client/remote-channel.js +246 -0
  34. package/lib/types/dsh-home.d.ts +17 -0
  35. package/lib/types/dsh-home.d.ts.map +1 -0
  36. package/lib/types/dsh-home.js +34 -0
  37. package/lib/types/gate.d.ts.map +1 -1
  38. package/lib/types/gate.js +5 -2
  39. package/lib/types/index.d.ts +19 -12
  40. package/lib/types/index.d.ts.map +1 -1
  41. package/lib/types/index.js +73 -5
  42. package/lib/types/loopback-proxy.d.ts +28 -0
  43. package/lib/types/loopback-proxy.d.ts.map +1 -0
  44. package/lib/types/loopback-proxy.js +112 -0
  45. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
  46. package/lib/types/mobile/views/SessionListView.js +6 -1
  47. package/lib/types/mobile-api.d.ts.map +1 -1
  48. package/lib/types/mobile-api.js +6 -2
  49. package/lib/types/mobile-routes.js +1 -1
  50. package/lib/types/pairing.d.ts +63 -11
  51. package/lib/types/pairing.d.ts.map +1 -1
  52. package/lib/types/pairing.js +137 -20
  53. package/lib/types/pairing.persist.test.js +60 -5
  54. package/lib/types/posture.d.ts +75 -0
  55. package/lib/types/posture.d.ts.map +1 -0
  56. package/lib/types/posture.js +116 -0
  57. package/lib/types/remote-api.d.ts +60 -0
  58. package/lib/types/remote-api.d.ts.map +1 -0
  59. package/lib/types/remote-api.js +181 -0
  60. package/lib/types/remote-methods.d.ts +33 -0
  61. package/lib/types/remote-methods.d.ts.map +1 -0
  62. package/lib/types/remote-methods.js +54 -0
  63. package/lib/types/routes.d.ts +4 -0
  64. package/lib/types/routes.d.ts.map +1 -1
  65. package/lib/types/routes.js +33 -4
  66. package/package.json +2 -2
  67. package/src/client/FenceNotice.tsx +43 -0
  68. package/src/client/RemoteEntry.tsx +19 -9
  69. package/src/client/RemotePanel.tsx +83 -10
  70. package/src/client/RemoteSettingsCard.tsx +16 -0
  71. package/src/client/device-name.ts +31 -0
  72. package/src/client/index.ts +46 -0
  73. package/src/client/locales.ts +79 -29
  74. package/src/client/pair-api.ts +60 -0
  75. package/src/client/remote-channel.ts +269 -0
  76. package/src/client/remote.module.css +240 -16
  77. package/src/client/settings-card.module.css +2 -0
  78. package/src/dsh-home.ts +36 -0
  79. package/src/gate.ts +5 -2
  80. package/src/index.ts +89 -17
  81. package/src/loopback-proxy.ts +126 -0
  82. package/src/mobile/views/SessionListView.tsx +6 -1
  83. package/src/mobile-api.ts +6 -2
  84. package/src/mobile-routes.ts +1 -1
  85. package/src/pairing.persist.test.ts +64 -5
  86. package/src/pairing.ts +167 -20
  87. package/src/posture.ts +154 -0
  88. package/src/remote-api.ts +217 -0
  89. package/src/remote-methods.ts +62 -0
  90. package/src/routes.ts +33 -4
package/lib/client.js CHANGED
@@ -1298,6 +1298,19 @@ window.__ModuleLoader__.load({
1298
1298
  const response = await fetch("/api/pair/stop", { method: "POST" });
1299
1299
  if (!response.ok) throw new Error(`remote-web-ui: stop failed with ${String(response.status)}`);
1300
1300
  }
1301
+ /**
1302
+ * Revoke one paired device from the loopback panel.
1303
+ * @param deviceId - the session id of the row to drop.
1304
+ */
1305
+ async function revokePair(deviceId) {
1306
+ const response = await fetch("/api/pair/revoke", {
1307
+ method: "POST",
1308
+ headers: { "content-type": "application/json" },
1309
+ body: JSON.stringify({ deviceId })
1310
+ });
1311
+ if (response.status === 404) return;
1312
+ if (!response.ok) throw new Error(`remote-web-ui: revoke failed with ${String(response.status)}`);
1313
+ }
1301
1314
  /** Presence heartbeat from a paired phone (unpaired heartbeats 401 harmlessly). */
1302
1315
  async function sendHeartbeat() {
1303
1316
  await fetch("/api/pair/heartbeat", { method: "POST" });
@@ -1312,11 +1325,22 @@ window.__ModuleLoader__.load({
1312
1325
  ...workspace !== null && workspace !== "" ? { workspace } : {}
1313
1326
  };
1314
1327
  }
1328
+ /** Convert an issued `/m/` link into the desktop pairing form. */
1329
+ function desktopPairUrl(mobileUrl) {
1330
+ const url = new URL(mobileUrl);
1331
+ url.pathname = "/";
1332
+ return url.href;
1333
+ }
1315
1334
  /** Human-readable expiry clock, e.g. "10:35". */
1316
1335
  function formatClock(epochMs) {
1317
1336
  const date = new Date(epochMs);
1318
1337
  return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
1319
1338
  }
1339
+ /** Calendar + clock for last-seen timestamps, e.g. "2026-08-19 10:35". */
1340
+ function formatLastSeen(epochMs) {
1341
+ const date = new Date(epochMs);
1342
+ return `${String(date.getFullYear())}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${formatClock(epochMs)}`;
1343
+ }
1320
1344
  /**
1321
1345
  * Copy text to the clipboard with a fallback for insecure contexts
1322
1346
  * (plain-HTTP LAN origins lack navigator.clipboard).
@@ -1343,8 +1367,18 @@ window.__ModuleLoader__.load({
1343
1367
  }
1344
1368
  }
1345
1369
  //#endregion
1370
+ //#region src/client/device-name.ts
1371
+ /** Derive a short, non-sensitive device label from a browser User-Agent. */
1372
+ function deviceNameFromUserAgent(userAgent) {
1373
+ if (userAgent === void 0 || userAgent.trim() === "") return void 0;
1374
+ const os = /Windows NT/i.test(userAgent) ? "Windows" : /Android/i.test(userAgent) ? "Android" : /iPhone|iPad|iPod/i.test(userAgent) ? "iOS" : /Macintosh|Mac OS X/i.test(userAgent) ? "macOS" : /Linux/i.test(userAgent) ? "Linux" : void 0;
1375
+ const browser = /Edg(?:A|iOS)?\//i.test(userAgent) ? "Edge" : /(?:OPR|Opera)\//i.test(userAgent) ? "Opera" : /(?:Chrome|CriOS)\//i.test(userAgent) ? "Chrome" : /(?:Firefox|FxiOS)\//i.test(userAgent) ? "Firefox" : /Safari\//i.test(userAgent) && /Version\//i.test(userAgent) ? "Safari" : void 0;
1376
+ if (os !== void 0 && browser !== void 0) return `${os} · ${browser}`;
1377
+ return os ?? browser;
1378
+ }
1379
+ //#endregion
1346
1380
  //#region \0dsh-css:packages/dsh-remote-web-ui/src/client/remote.module.css.mjs
1347
- const css$1 = ".fThDlq_overlay{z-index:1000;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.fThDlq_mask{background:var(--dsw-alias-bg-mask-1);backdrop-filter:var(--dsw-mask-blur);position:absolute;inset:0}.fThDlq_trigger{width:36px;height:36px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:50%;flex:none;justify-content:center;align-items:center;padding:0;transition:background-color .12s,color .12s,box-shadow .12s;display:inline-flex;position:relative}.fThDlq_trigger[data-update-available]:after{box-sizing:border-box;border:2px solid var(--dsw-alias-bg-layer-1);background:var(--dsw-alias-brand-primary);content:\"\";pointer-events:none;border-radius:50%;width:8px;height:8px;position:absolute;top:4px;right:4px}.fThDlq_trigger[data-wide=wide]{border-radius:8px;flex:auto;justify-content:flex-start;gap:8px;width:auto;min-width:0;padding:0 10px}.fThDlq_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.fThDlq_trigger:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}.fThDlq_trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);outline:none}.fThDlq_trigger:disabled{opacity:.5;cursor:default}.fThDlq_panel{z-index:1;box-sizing:border-box;background:var(--dsw-alias-bg-layer-2);width:560px;max-width:calc(100vw - 48px);max-height:calc(100vh - 48px);box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);border-radius:24px;flex-direction:column;gap:14px;padding:24px;font-size:14px;line-height:22px;display:flex;position:relative;overflow:auto}.fThDlq_header{align-items:flex-start;gap:12px;display:flex}.fThDlq_heading{flex:1;min-width:0}.fThDlq_title{margin:0;font-size:18px;font-weight:600;line-height:26px}.fThDlq_subtitle{color:var(--dsw-alias-label-secondary);margin:4px 0 0;font-size:13px}.fThDlq_close{width:28px;height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:50%;flex:none;justify-content:center;align-items:center;padding:0;transition:background-color .12s,color .12s,box-shadow .12s;display:inline-flex}.fThDlq_close:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_close:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}.fThDlq_close:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);outline:none}.fThDlq_close:disabled{opacity:.5;cursor:default}.fThDlq_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:16px;flex-direction:column;align-items:center;gap:12px;padding:16px;display:flex}.fThDlq_cardHeader{justify-content:space-between;align-items:center;gap:12px;width:100%;display:flex}.fThDlq_cardTitle{font-weight:500}.fThDlq_badge{white-space:nowrap;border-radius:999px;flex:none;align-items:center;gap:6px;min-width:0;padding:2px 10px;font-size:12px;line-height:18px;display:inline-flex}.fThDlq_badge:before{content:\"\";background:currentColor;border-radius:50%;width:8px;height:8px}.fThDlq_badge-waiting{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badge-connected{color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badge-disconnected{color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badge-stopped{color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badgePublic{color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badges{flex:none;align-items:center;gap:6px;display:inline-flex}.fThDlq_qrWrap{background:var(--dsw-alias-bg-base);border-radius:12px;justify-content:center;align-items:center;padding:12px;display:flex}.fThDlq_qr{display:block}.fThDlq_expired{color:var(--dsw-alias-state-error-primary);margin:0;font-size:13px}.fThDlq_expiry{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px}.fThDlq_hint{color:var(--dsw-alias-label-secondary);margin:0;font-size:13px}.fThDlq_link{text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-caption);font-family:var(--dsw-font-mono,ui-monospace, monospace);margin:0;font-size:12px;overflow:hidden}.fThDlq_stoppedHint{color:var(--dsw-alias-state-error-primary);margin:0;font-size:13px}.fThDlq_tunnelNote{color:var(--dsw-alias-label-secondary);margin:0;font-size:13px}.fThDlq_tunnelFailed{color:var(--dsw-alias-state-error-primary);margin:0;font-size:13px}.fThDlq_actions{gap:8px;display:flex}.fThDlq_action{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-button-elevated-fill);height:34px;color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;border-radius:10px;justify-content:center;align-items:center;gap:6px;padding:0 14px;font-size:13px;transition:background-color .12s,border-color .12s,box-shadow .12s;display:inline-flex}.fThDlq_action:hover:not(:disabled){background:var(--dsw-alias-button-floating-hover)}.fThDlq_action:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}.fThDlq_action:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);outline:none}.fThDlq_action:disabled{opacity:.5;cursor:default}.fThDlq_banner{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:16px;padding:16px}.fThDlq_bannerTitle{color:var(--dsw-alias-state-warn-primary);margin:0;font-weight:500}.fThDlq_bannerHint{color:var(--dsw-alias-label-secondary);margin:6px 0 0;font-size:13px}.fThDlq_notice{z-index:2000;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);max-width:min(420px,100vw - 32px);box-shadow:var(--dsw-shadow-lv3);border-radius:12px;padding:12px 16px;position:fixed;top:16px;left:50%;transform:translate(-50%)}.fThDlq_noticeTitle{color:var(--dsw-alias-state-error-primary);margin:0;font-weight:500}.fThDlq_noticeDetail{color:var(--dsw-alias-label-secondary);margin:4px 0 0;font-size:13px}.fThDlq_addresses{border:none;margin:12px 0 0;padding:0}.fThDlq_addresses legend{color:var(--dsw-alias-label-secondary);padding:0;font-size:13px}.fThDlq_address{color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums;cursor:pointer;border-radius:6px;align-items:center;gap:8px;margin-top:6px;padding:4px 6px;font-size:13px;transition:background-color .12s;display:flex}.fThDlq_address:hover{background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_address input:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);border-radius:50%;outline:none}.fThDlq_addressValue{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-secondary);flex:1;font-size:12px;overflow:hidden}.fThDlq_addressHint{color:var(--dsw-alias-label-tertiary);margin:6px 0 0;font-size:12px}@media (prefers-reduced-motion:reduce){.fThDlq_trigger,.fThDlq_close,.fThDlq_action,.fThDlq_address{transition:none}}.fThDlq_entryRow{flex:none;align-items:center;gap:6px;min-width:0;display:flex}.fThDlq_entryRow[data-rail=rail]{flex-direction:column-reverse;gap:4px}.fThDlq_updateStatus{margin:0;font-weight:600}.fThDlq_updateDetail{color:var(--dsw-alias-label-secondary);margin:6px 0 0;font-size:13px}.fThDlq_updateError{color:var(--dsw-alias-text-danger,var(--dsw-alias-label-primary));margin:0;font-weight:600}.fThDlq_updateOutput{background:var(--dsw-alias-bg-layer-1);max-height:180px;color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-all;border-radius:10px;margin:10px 0 0;padding:10px 12px;font-size:12px;line-height:18px;overflow:auto}.fThDlq_updateList{flex-direction:column;gap:6px;margin:12px 0 0;padding:0;list-style:none;display:flex}.fThDlq_updateListItem{justify-content:space-between;align-items:baseline;gap:12px;font-size:13px;display:flex}.fThDlq_updateListName{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-primary);overflow:hidden}.fThDlq_updateListVersions{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;flex:none}.fThDlq_updateActions{justify-content:flex-end;margin-top:16px;display:flex}.fThDlq_updateRetry{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);cursor:pointer;border:none;border-radius:10px;align-items:center;gap:6px;padding:8px 14px;font-size:13px;transition:background-color .12s,color .12s;display:inline-flex}.fThDlq_updateRetry:hover{background:var(--dsw-alias-interactive-bg-active)}@media (prefers-reduced-motion:reduce){.fThDlq_updateRetry{transition:none}}";
1381
+ const css$1 = ".fThDlq_overlay{z-index:1000;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.fThDlq_mask{background:var(--dsw-alias-bg-mask-1);backdrop-filter:var(--dsw-mask-blur);position:absolute;inset:0}.fThDlq_trigger{width:36px;height:36px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:50%;flex:none;justify-content:center;align-items:center;padding:0;transition:background-color .12s,color .12s,box-shadow .12s;display:inline-flex;position:relative}.fThDlq_trigger[data-update-available]:after{box-sizing:border-box;border:2px solid var(--dsw-alias-bg-layer-1);background:var(--dsw-alias-brand-primary);content:\"\";pointer-events:none;border-radius:50%;width:8px;height:8px;position:absolute;top:4px;right:4px}.fThDlq_trigger[data-wide=wide]{border-radius:8px;flex:auto;justify-content:flex-start;gap:8px;width:auto;min-width:0;padding:0 10px}.fThDlq_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.fThDlq_trigger:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}.fThDlq_trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);outline:none}.fThDlq_trigger:disabled{opacity:.5;cursor:default}.fThDlq_panel{z-index:1;box-sizing:border-box;background:var(--dsw-alias-bg-layer-2);width:560px;max-width:calc(100vw - 48px);max-height:calc(100vh - 48px);box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);border-radius:24px;flex-direction:column;gap:14px;padding:24px;font-size:14px;line-height:22px;display:flex;position:relative;overflow:auto}.fThDlq_header{align-items:flex-start;gap:12px;display:flex}.fThDlq_heading{flex:1;min-width:0}.fThDlq_title{margin:0;font-size:18px;font-weight:600;line-height:26px}.fThDlq_subtitle{color:var(--dsw-alias-label-secondary);margin:4px 0 0;font-size:13px}.fThDlq_close{width:28px;height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:50%;flex:none;justify-content:center;align-items:center;padding:0;transition:background-color .12s,color .12s,box-shadow .12s;display:inline-flex}.fThDlq_close:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_close:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}.fThDlq_close:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);outline:none}.fThDlq_close:disabled{opacity:.5;cursor:default}.fThDlq_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:16px;flex-direction:column;align-items:center;gap:12px;padding:16px;display:flex}.fThDlq_cardHeader{justify-content:space-between;align-items:center;gap:12px;width:100%;display:flex}.fThDlq_cardTitle{font-weight:500}.fThDlq_badge{white-space:nowrap;border-radius:999px;flex:none;align-items:center;gap:6px;min-width:0;padding:2px 10px;font-size:12px;line-height:18px;display:inline-flex}.fThDlq_badge:before{content:\"\";background:currentColor;border-radius:50%;width:8px;height:8px}.fThDlq_badge-waiting{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badge-connected{color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badge-disconnected{color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badge-stopped{color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badgePublic{color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badges{flex:none;align-items:center;gap:6px;display:inline-flex}.fThDlq_qrWrap{background:var(--dsw-alias-bg-base);border-radius:12px;justify-content:center;align-items:center;padding:12px;display:flex}.fThDlq_qr{display:block}.fThDlq_expired{color:var(--dsw-alias-state-error-primary);margin:0;font-size:13px}.fThDlq_expiry{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px}.fThDlq_hint{color:var(--dsw-alias-label-secondary);margin:0;font-size:13px}.fThDlq_link{text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-caption);font-family:var(--dsw-font-mono,ui-monospace, monospace);margin:0;font-size:12px;display:block;overflow:hidden}.fThDlq_pairLinks{flex-direction:column;gap:8px;display:flex}.fThDlq_pairLinkRow{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:10px;align-items:center;gap:10px;min-width:0;padding:10px 12px;display:flex}.fThDlq_pairLinkText{flex:1;min-width:0}.fThDlq_pairLinkLabel{color:var(--dsw-alias-label-secondary);margin-bottom:3px;font-size:12px;display:block}.fThDlq_copyLink{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-button-elevated-fill);min-height:30px;color:var(--dsw-alias-label-primary);cursor:pointer;border-radius:8px;flex:none;align-items:center;gap:5px;padding:0 10px;display:inline-flex}.fThDlq_oneTimeHint{color:var(--dsw-alias-label-caption);margin:0;font-size:12px}.fThDlq_stoppedHint{color:var(--dsw-alias-state-error-primary);margin:0;font-size:13px}.fThDlq_tunnelNote{color:var(--dsw-alias-label-secondary);margin:0;font-size:13px}.fThDlq_tunnelFailed{color:var(--dsw-alias-state-error-primary);margin:0;font-size:13px}.fThDlq_actions{gap:8px;display:flex}.fThDlq_action{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-button-elevated-fill);height:34px;color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;border-radius:10px;justify-content:center;align-items:center;gap:6px;padding:0 14px;font-size:13px;transition:background-color .12s,border-color .12s,box-shadow .12s;display:inline-flex}.fThDlq_action:hover:not(:disabled){background:var(--dsw-alias-button-floating-hover)}.fThDlq_action:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}.fThDlq_action:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);outline:none}.fThDlq_action:disabled{opacity:.5;cursor:default}.fThDlq_banner{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:16px;padding:16px}.fThDlq_bannerTitle{color:var(--dsw-alias-state-warn-primary);margin:0;font-weight:500}.fThDlq_bannerHint{color:var(--dsw-alias-label-secondary);margin:6px 0 0;font-size:13px}.fThDlq_fencePage{z-index:2000;box-sizing:border-box;background:var(--dsw-alias-bg-base);text-align:center;flex-direction:column;justify-content:center;align-items:center;padding:40px 24px;display:flex;position:fixed;inset:0;overflow:auto}.fThDlq_fenceCard{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);width:min(520px,100%);box-shadow:var(--dsw-shadow-lv3);text-align:center;border-radius:20px;margin-inline:auto;padding:36px 40px}.fThDlq_fenceMark{background:var(--dsw-alias-state-error-secondary);width:44px;height:44px;color:var(--dsw-alias-state-error-primary);border-radius:50%;place-items:center;margin-inline:auto;font-size:24px;line-height:1;display:grid}.fThDlq_fenceEyebrow{color:var(--dsw-alias-state-error-primary);margin:22px 0 8px;font-size:13px;font-weight:600}.fThDlq_fenceTitle{color:var(--dsw-alias-label-primary);margin:0;font-size:24px;line-height:1.35}.fThDlq_fenceDetail{color:var(--dsw-alias-label-secondary);margin:12px 0 0;font-size:14px;line-height:1.65}.fThDlq_fenceSteps{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);text-align:left;border-radius:12px;margin:24px auto 0;padding:20px 20px 20px 42px;font-size:14px;line-height:1.65}.fThDlq_fenceSteps li+li{margin-top:8px}.fThDlq_fenceRetry{border:1px solid var(--dsw-alias-brand-primary);background:var(--dsw-alias-brand-primary);color:#fff;width:100%;font:inherit;cursor:pointer;border-radius:10px;margin-top:24px;padding:11px 16px;font-weight:500}.fThDlq_fenceRetry:hover{filter:brightness(.96)}.fThDlq_fenceRetry:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:3px}.fThDlq_fenceFootnote{color:var(--dsw-alias-label-tertiary);margin:14px 0 0;font-size:12px;line-height:1.55}.fThDlq_addresses{border:none;margin:12px 0 0;padding:0}.fThDlq_addresses legend{color:var(--dsw-alias-label-secondary);padding:0;font-size:13px}.fThDlq_address{color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums;cursor:pointer;border-radius:6px;align-items:center;gap:8px;margin-top:6px;padding:4px 6px;font-size:13px;transition:background-color .12s;display:flex}.fThDlq_address:hover{background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_address input:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);border-radius:50%;outline:none}.fThDlq_addressValue{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-secondary);flex:1;font-size:12px;overflow:hidden}.fThDlq_addressHint{color:var(--dsw-alias-label-tertiary);margin:6px 0 0;font-size:12px}.fThDlq_devices{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:16px;flex-direction:column;gap:8px;padding:12px 16px 14px;display:flex}.fThDlq_devicesTitle{margin:0;font-size:13px;font-weight:500;line-height:20px}.fThDlq_devicesEmpty{color:var(--dsw-alias-label-secondary);margin:0;font-size:13px}.fThDlq_deviceList{flex-direction:column;gap:8px;margin:0;padding:0;list-style:none;display:flex}.fThDlq_deviceRow{justify-content:space-between;align-items:flex-start;gap:12px;display:flex}.fThDlq_deviceMeta{flex-direction:column;gap:2px;min-width:0;display:flex}.fThDlq_deviceName{text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:500;overflow:hidden}.fThDlq_devicePresence{font-size:12px;line-height:18px}.fThDlq_deviceOnline{color:var(--dsw-alias-state-success-primary)}.fThDlq_deviceOffline{color:var(--dsw-alias-label-secondary)}.fThDlq_deviceSeen{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;font-size:12px}.fThDlq_deviceRevoke{color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:8px;flex:none;padding:6px 10px;font-size:12px;transition:background-color .12s,color .12s}.fThDlq_deviceRevoke:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.fThDlq_deviceRevoke:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);outline:none}@media (prefers-reduced-motion:reduce){.fThDlq_trigger,.fThDlq_close,.fThDlq_action,.fThDlq_address,.fThDlq_deviceRevoke{transition:none}}.fThDlq_entryRow{flex:none;align-items:center;gap:6px;min-width:0;display:flex}.fThDlq_entryRow[data-rail=rail]{flex-direction:column-reverse;gap:4px}.fThDlq_updateStatus{margin:0;font-weight:600}.fThDlq_updateDetail{color:var(--dsw-alias-label-secondary);margin:6px 0 0;font-size:13px}.fThDlq_updateError{color:var(--dsw-alias-text-danger,var(--dsw-alias-label-primary));margin:0;font-weight:600}.fThDlq_updateOutput{background:var(--dsw-alias-bg-layer-1);max-height:180px;color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-all;border-radius:10px;margin:10px 0 0;padding:10px 12px;font-size:12px;line-height:18px;overflow:auto}.fThDlq_updateList{flex-direction:column;gap:6px;margin:12px 0 0;padding:0;list-style:none;display:flex}.fThDlq_updateListItem{justify-content:space-between;align-items:baseline;gap:12px;font-size:13px;display:flex}.fThDlq_updateListName{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-primary);overflow:hidden}.fThDlq_updateListVersions{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;flex:none}.fThDlq_updateActions{justify-content:flex-end;margin-top:16px;display:flex}.fThDlq_updateRetry{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);cursor:pointer;border:none;border-radius:10px;align-items:center;gap:6px;padding:8px 14px;font-size:13px;transition:background-color .12s,color .12s;display:inline-flex}.fThDlq_updateRetry:hover{background:var(--dsw-alias-interactive-bg-active)}@media (prefers-reduced-motion:reduce){.fThDlq_updateRetry{transition:none}}";
1348
1382
  const tagId$1 = "@linxin666/dsh-remote-web-ui/remote.module.css";
1349
1383
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$1) + "]") === null) {
1350
1384
  const tag = document.createElement("style");
@@ -1374,18 +1408,42 @@ window.__ModuleLoader__.load({
1374
1408
  "cardHeader": "fThDlq_cardHeader",
1375
1409
  "cardTitle": "fThDlq_cardTitle",
1376
1410
  "close": "fThDlq_close",
1411
+ "copyLink": "fThDlq_copyLink",
1412
+ "deviceList": "fThDlq_deviceList",
1413
+ "deviceMeta": "fThDlq_deviceMeta",
1414
+ "deviceName": "fThDlq_deviceName",
1415
+ "deviceOffline": "fThDlq_deviceOffline",
1416
+ "deviceOnline": "fThDlq_deviceOnline",
1417
+ "devicePresence": "fThDlq_devicePresence",
1418
+ "deviceRevoke": "fThDlq_deviceRevoke",
1419
+ "deviceRow": "fThDlq_deviceRow",
1420
+ "deviceSeen": "fThDlq_deviceSeen",
1421
+ "devices": "fThDlq_devices",
1422
+ "devicesEmpty": "fThDlq_devicesEmpty",
1423
+ "devicesTitle": "fThDlq_devicesTitle",
1377
1424
  "entryRow": "fThDlq_entryRow",
1378
1425
  "expired": "fThDlq_expired",
1379
1426
  "expiry": "fThDlq_expiry",
1427
+ "fenceCard": "fThDlq_fenceCard",
1428
+ "fenceDetail": "fThDlq_fenceDetail",
1429
+ "fenceEyebrow": "fThDlq_fenceEyebrow",
1430
+ "fenceFootnote": "fThDlq_fenceFootnote",
1431
+ "fenceMark": "fThDlq_fenceMark",
1432
+ "fencePage": "fThDlq_fencePage",
1433
+ "fenceRetry": "fThDlq_fenceRetry",
1434
+ "fenceSteps": "fThDlq_fenceSteps",
1435
+ "fenceTitle": "fThDlq_fenceTitle",
1380
1436
  "header": "fThDlq_header",
1381
1437
  "heading": "fThDlq_heading",
1382
1438
  "hint": "fThDlq_hint",
1383
1439
  "link": "fThDlq_link",
1384
1440
  "mask": "fThDlq_mask",
1385
- "notice": "fThDlq_notice",
1386
- "noticeDetail": "fThDlq_noticeDetail",
1387
- "noticeTitle": "fThDlq_noticeTitle",
1441
+ "oneTimeHint": "fThDlq_oneTimeHint",
1388
1442
  "overlay": "fThDlq_overlay",
1443
+ "pairLinkLabel": "fThDlq_pairLinkLabel",
1444
+ "pairLinkRow": "fThDlq_pairLinkRow",
1445
+ "pairLinkText": "fThDlq_pairLinkText",
1446
+ "pairLinks": "fThDlq_pairLinks",
1389
1447
  "panel": "fThDlq_panel",
1390
1448
  "qr": "fThDlq_qr",
1391
1449
  "qrWrap": "fThDlq_qrWrap",
@@ -1444,7 +1502,7 @@ window.__ModuleLoader__.load({
1444
1502
  * @param props - copy, state, and actions.
1445
1503
  * @returns the panel element tree.
1446
1504
  */
1447
- function RemotePanel({ t, state, copied, onClose, onStop, onRefresh, onCopy, onPickAddress, onPickPublic }) {
1505
+ function RemotePanel({ t, state, copied, onClose, onStop, onRefresh, onCopy, onPickAddress, onPickPublic, onRevoke }) {
1448
1506
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1449
1507
  className: remote_module_css_default.panel,
1450
1508
  role: "dialog",
@@ -1499,6 +1557,17 @@ window.__ModuleLoader__.load({
1499
1557
  children: t("status.unreachableHint")
1500
1558
  })]
1501
1559
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1560
+ state.posture !== void 0 && state.posture.hosts.some((host) => host.exposed) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1561
+ className: remote_module_css_default.banner,
1562
+ role: "alert",
1563
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1564
+ className: remote_module_css_default.bannerTitle,
1565
+ children: t("posture.exposed")
1566
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1567
+ className: remote_module_css_default.bannerHint,
1568
+ children: t("posture.exposedHint", { hosts: state.posture.hosts.filter((host) => host.exposed).map((host) => host.host).join(", ") })
1569
+ })]
1570
+ }),
1502
1571
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1503
1572
  className: remote_module_css_default.card,
1504
1573
  children: [
@@ -1542,10 +1611,49 @@ window.__ModuleLoader__.load({
1542
1611
  className: remote_module_css_default.hint,
1543
1612
  children: state.public ? t("pair.publicHint") : t("pair.hint")
1544
1613
  }),
1614
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1615
+ className: remote_module_css_default.pairLinks,
1616
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1617
+ className: remote_module_css_default.pairLinkRow,
1618
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1619
+ className: remote_module_css_default.pairLinkText,
1620
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1621
+ className: remote_module_css_default.pairLinkLabel,
1622
+ children: t("pair.phoneLabel")
1623
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
1624
+ className: remote_module_css_default.link,
1625
+ title: state.url,
1626
+ children: state.url
1627
+ })]
1628
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1629
+ type: "button",
1630
+ className: remote_module_css_default.copyLink,
1631
+ onClick: () => onCopy("phone", state.url),
1632
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCopyOutline16, { size: 14 }), copied === "phone" ? t("action.copied") : t("action.copyPhone")]
1633
+ })]
1634
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1635
+ className: remote_module_css_default.pairLinkRow,
1636
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1637
+ className: remote_module_css_default.pairLinkText,
1638
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1639
+ className: remote_module_css_default.pairLinkLabel,
1640
+ children: t("pair.desktopLabel")
1641
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
1642
+ className: remote_module_css_default.link,
1643
+ title: desktopPairUrl(state.url),
1644
+ children: desktopPairUrl(state.url)
1645
+ })]
1646
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1647
+ type: "button",
1648
+ className: remote_module_css_default.copyLink,
1649
+ onClick: () => onCopy("desktop", desktopPairUrl(state.url)),
1650
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCopyOutline16, { size: 14 }), copied === "desktop" ? t("action.copied") : t("action.copyDesktop")]
1651
+ })]
1652
+ })]
1653
+ }),
1545
1654
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1546
- className: remote_module_css_default.link,
1547
- title: state.url,
1548
- children: state.url
1655
+ className: remote_module_css_default.oneTimeHint,
1656
+ children: t("pair.oneTimeHint")
1549
1657
  }),
1550
1658
  state.phase === "stopped" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1551
1659
  className: remote_module_css_default.stoppedHint,
@@ -1602,26 +1710,59 @@ window.__ModuleLoader__.load({
1602
1710
  }),
1603
1711
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1604
1712
  className: remote_module_css_default.actions,
1605
- children: [
1606
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1607
- type: "button",
1608
- className: remote_module_css_default.action,
1609
- onClick: onStop,
1610
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconStopFill16, { size: 14 }), t("action.stop")]
1611
- }),
1612
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1613
- type: "button",
1614
- className: remote_module_css_default.action,
1615
- onClick: onRefresh,
1616
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutline16, { size: 14 }), t("action.refresh")]
1617
- }),
1618
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1619
- type: "button",
1620
- className: remote_module_css_default.action,
1621
- onClick: onCopy,
1622
- children: [copied ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCopyOutline16, { size: 14 }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconLinkOutline16, { size: 14 }), copied ? t("action.copied") : t("action.copy")]
1623
- })
1624
- ]
1713
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1714
+ type: "button",
1715
+ className: remote_module_css_default.action,
1716
+ onClick: onStop,
1717
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconStopFill16, { size: 14 }), t("action.stop")]
1718
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1719
+ type: "button",
1720
+ className: remote_module_css_default.action,
1721
+ onClick: onRefresh,
1722
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutline16, { size: 14 }), t("action.refresh")]
1723
+ })]
1724
+ }),
1725
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1726
+ className: remote_module_css_default.devices,
1727
+ "data-testid": "remote-devices",
1728
+ "aria-label": t("devices.title"),
1729
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1730
+ className: remote_module_css_default.devicesTitle,
1731
+ children: t("devices.title")
1732
+ }), state.devices.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1733
+ className: remote_module_css_default.devicesEmpty,
1734
+ children: t("devices.empty")
1735
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1736
+ className: remote_module_css_default.deviceList,
1737
+ children: state.devices.map((device) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1738
+ className: remote_module_css_default.deviceRow,
1739
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1740
+ className: remote_module_css_default.deviceMeta,
1741
+ children: [
1742
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1743
+ className: remote_module_css_default.deviceName,
1744
+ children: deviceNameFromUserAgent(device.userAgent) ?? t("devices.unknown")
1745
+ }),
1746
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1747
+ className: clsx(remote_module_css_default.devicePresence, device.online ? remote_module_css_default.deviceOnline : remote_module_css_default.deviceOffline),
1748
+ children: device.online ? t("devices.online") : t("devices.offline")
1749
+ }),
1750
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1751
+ className: remote_module_css_default.deviceSeen,
1752
+ children: t("devices.lastSeen", { time: formatLastSeen(device.lastSeenAt) })
1753
+ })
1754
+ ]
1755
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1756
+ type: "button",
1757
+ className: remote_module_css_default.deviceRevoke,
1758
+ "aria-label": t("devices.revoke.label"),
1759
+ onClick: () => {
1760
+ onRevoke(device.id);
1761
+ },
1762
+ children: t("devices.revoke")
1763
+ })]
1764
+ }, device.id))
1765
+ })]
1625
1766
  })
1626
1767
  ] })]
1627
1768
  });
@@ -2071,7 +2212,9 @@ window.__ModuleLoader__.load({
2071
2212
  phase: frame.phase,
2072
2213
  deviceCount: frame.deviceCount,
2073
2214
  onlineCount: frame.onlineCount,
2074
- ...frame.tunnel !== void 0 ? { tunnel: frame.tunnel } : {}
2215
+ devices: frame.devices ?? [],
2216
+ ...frame.tunnel !== void 0 ? { tunnel: frame.tunnel } : {},
2217
+ ...frame.posture !== void 0 ? { posture: frame.posture } : {}
2075
2218
  };
2076
2219
  }
2077
2220
  /**
@@ -2086,7 +2229,7 @@ window.__ModuleLoader__.load({
2086
2229
  (0, react.useEffect)(() => {
2087
2230
  stateRef.current = state;
2088
2231
  }, [state]);
2089
- const [copied, setCopied] = (0, react.useState)(false);
2232
+ const [copied, setCopied] = (0, react.useState)(void 0);
2090
2233
  const eventSource = (0, react.useRef)(void 0);
2091
2234
  const openSeq = (0, react.useRef)(0);
2092
2235
  const workspaceId = useWorkspaces((s) => s.recentWorkspaceId);
@@ -2115,6 +2258,7 @@ window.__ModuleLoader__.load({
2115
2258
  phase: "waiting",
2116
2259
  deviceCount: 0,
2117
2260
  onlineCount: 0,
2261
+ devices: [],
2118
2262
  public: publicBaseUrl !== void 0 && result.url.startsWith(publicBaseUrl),
2119
2263
  ...publicBaseUrl !== void 0 ? { publicBaseUrl } : {},
2120
2264
  address: address ?? result.lanAddresses[0] ?? "",
@@ -2174,7 +2318,15 @@ window.__ModuleLoader__.load({
2174
2318
  stopPair().catch(() => {});
2175
2319
  setState((previous) => previous.kind === "ready" ? {
2176
2320
  ...previous,
2177
- phase: "stopped"
2321
+ phase: "stopped",
2322
+ devices: []
2323
+ } : previous);
2324
+ }, []);
2325
+ const handleRevoke = (0, react.useCallback)((deviceId) => {
2326
+ revokePair(deviceId).catch(() => {});
2327
+ setState((previous) => previous.kind === "ready" ? {
2328
+ ...previous,
2329
+ devices: previous.devices.filter((device) => device.id !== deviceId)
2178
2330
  } : previous);
2179
2331
  }, []);
2180
2332
  const handleRefresh = (0, react.useCallback)(() => {
@@ -2188,16 +2340,15 @@ window.__ModuleLoader__.load({
2188
2340
  const handlePickPublic = (0, react.useCallback)(() => {
2189
2341
  mint().then(setState);
2190
2342
  }, [mint]);
2191
- const handleCopy = (0, react.useCallback)(() => {
2192
- if (state.kind !== "ready") return;
2193
- copyText(state.url).then((ok) => {
2343
+ const handleCopy = (0, react.useCallback)((target, url) => {
2344
+ copyText(url).then((ok) => {
2194
2345
  if (!ok) return;
2195
- setCopied(true);
2346
+ setCopied(target);
2196
2347
  window.setTimeout(() => {
2197
- setCopied(false);
2348
+ setCopied(void 0);
2198
2349
  }, 1500);
2199
2350
  });
2200
- }, [state]);
2351
+ }, []);
2201
2352
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2202
2353
  className: remote_module_css_default.entryRow,
2203
2354
  "data-rail": wide ? void 0 : "rail",
@@ -2225,7 +2376,8 @@ window.__ModuleLoader__.load({
2225
2376
  onRefresh: handleRefresh,
2226
2377
  onCopy: handleCopy,
2227
2378
  onPickAddress: handlePickAddress,
2228
- onPickPublic: handlePickPublic
2379
+ onPickPublic: handlePickPublic,
2380
+ onRevoke: handleRevoke
2229
2381
  })]
2230
2382
  }), document.body)] });
2231
2383
  }
@@ -2294,7 +2446,7 @@ window.__ModuleLoader__.load({
2294
2446
  }
2295
2447
  //#endregion
2296
2448
  //#region \0dsh-css:packages/dsh-remote-web-ui/src/client/settings-card.module.css.mjs
2297
- const css = ".Kwoi6G_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}.Kwoi6G_card:hover{border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.Kwoi6G_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.Kwoi6G_headerStatic{border-radius:12px;align-items:center;gap:12px;width:100%;padding:14px 16px;display:flex}.Kwoi6G_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.Kwoi6G_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.Kwoi6G_description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}.Kwoi6G_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Kwoi6G_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Kwoi6G_chevronOpen{transform:rotate(180deg)}.Kwoi6G_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.Kwoi6G_readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}.Kwoi6G_notExposed{color:var(--dsw-alias-state-warn-primary);margin:12px 0 0;font-size:12px;line-height:1.5}.Kwoi6G_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.Kwoi6G_failed{min-width:0;color:var(--dsw-alias-label-error);text-overflow:ellipsis;white-space:nowrap;flex:1;margin:0;font-size:12px;line-height:1.5;overflow:hidden}.Kwoi6G_discard,.Kwoi6G_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.Kwoi6G_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.Kwoi6G_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.Kwoi6G_discard:disabled,.Kwoi6G_save:disabled{opacity:.4;cursor:default}.Kwoi6G_discard:focus-visible,.Kwoi6G_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.Kwoi6G_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.Kwoi6G_field+.Kwoi6G_field{border-top:1px solid var(--dsw-alias-border-l2)}.Kwoi6G_head{align-items:center;gap:8px;display:flex}.Kwoi6G_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.Kwoi6G_badges{align-items:center;gap:8px;display:inline-flex}.Kwoi6G_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Kwoi6G_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.Kwoi6G_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.Kwoi6G_reset:disabled{cursor:default}.Kwoi6G_reset:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.Kwoi6G_input,.Kwoi6G_select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Kwoi6G_input:focus-visible,.Kwoi6G_select:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.Kwoi6G_input:disabled,.Kwoi6G_select:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.Kwoi6G_inputInvalid{border:1px solid var(--dsw-alias-label-error);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Kwoi6G_inputInvalid:focus-visible{outline:2px solid var(--dsw-alias-label-error);outline-offset:1px;border-color:var(--dsw-alias-label-error)}.Kwoi6G_selectWrap{position:relative}.Kwoi6G_selectButton{appearance:none;text-align:left;cursor:pointer;justify-content:space-between;align-items:center;gap:8px;width:100%;display:flex}.Kwoi6G_selectLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.Kwoi6G_selectChevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Kwoi6G_selectChevronOpen{transform:rotate(180deg)}.Kwoi6G_selectPopup{z-index:40;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);max-height:240px;box-shadow:0 8px 24px var(--dsw-alias-bg-mask-2);opacity:0;border-radius:8px;flex-direction:column;padding:4px;transition:opacity .1s,transform .1s;display:flex;position:absolute;top:calc(100% + 4px);left:0;right:0;overflow-y:auto;transform:translateY(-4px)}.Kwoi6G_selectPopupOpen{opacity:1;transform:none}.Kwoi6G_selectPopupClose{opacity:0;pointer-events:none;transform:translateY(-4px)}.Kwoi6G_selectOption{color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;text-overflow:ellipsis;border-radius:6px;padding:6px 10px;font-size:13px;line-height:1.5;overflow:hidden}.Kwoi6G_selectOption:hover,.Kwoi6G_selectOptionActive{background:var(--dsw-alias-interactive-bg-hover)}.Kwoi6G_selectOptionSelected{color:var(--dsw-alias-brand-primary);background:color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 10%, transparent);font-weight:500}.Kwoi6G_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}.Kwoi6G_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}@media (prefers-reduced-motion:reduce){.Kwoi6G_card,.Kwoi6G_header,.Kwoi6G_chevron,.Kwoi6G_chevronOpen,.Kwoi6G_discard,.Kwoi6G_save,.Kwoi6G_selectChevron,.Kwoi6G_selectChevronOpen,.Kwoi6G_selectPopup{transition:none}}";
2449
+ const css = ".Kwoi6G_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}.Kwoi6G_card:hover{border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.Kwoi6G_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.Kwoi6G_headerStatic{border-radius:12px;align-items:center;gap:12px;width:100%;padding:14px 16px;display:flex}.Kwoi6G_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.Kwoi6G_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.Kwoi6G_description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}.Kwoi6G_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Kwoi6G_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Kwoi6G_chevronOpen{transform:rotate(180deg)}.Kwoi6G_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.Kwoi6G_readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}.Kwoi6G_notExposed{color:var(--dsw-alias-state-warn-primary);margin:12px 0 0;font-size:12px;line-height:1.5}.Kwoi6G_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.Kwoi6G_failed{min-width:0;color:var(--dsw-alias-label-error);text-overflow:ellipsis;white-space:nowrap;flex:1;margin:0;font-size:12px;line-height:1.5;overflow:hidden}.Kwoi6G_discard,.Kwoi6G_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.Kwoi6G_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.Kwoi6G_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.Kwoi6G_discard:disabled,.Kwoi6G_save:disabled{opacity:.4;cursor:default}.Kwoi6G_discard:focus-visible,.Kwoi6G_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.Kwoi6G_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.Kwoi6G_field+.Kwoi6G_field{border-top:1px solid var(--dsw-alias-border-l2)}.Kwoi6G_head{align-items:center;gap:8px;display:flex}.Kwoi6G_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.Kwoi6G_badges{align-items:center;gap:8px;display:inline-flex}.Kwoi6G_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Kwoi6G_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.Kwoi6G_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.Kwoi6G_reset:disabled{cursor:default}.Kwoi6G_reset:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.Kwoi6G_input,.Kwoi6G_select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Kwoi6G_input:focus-visible,.Kwoi6G_select:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.Kwoi6G_input:disabled,.Kwoi6G_select:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.Kwoi6G_inputInvalid{border:1px solid var(--dsw-alias-label-error);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Kwoi6G_inputInvalid:focus-visible{outline:2px solid var(--dsw-alias-label-error);outline-offset:1px;border-color:var(--dsw-alias-label-error)}.Kwoi6G_selectWrap{position:relative}.Kwoi6G_selectButton{appearance:none;text-align:left;cursor:pointer;justify-content:space-between;align-items:center;gap:8px;width:100%;display:flex}.Kwoi6G_selectLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.Kwoi6G_selectChevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Kwoi6G_selectChevronOpen{transform:rotate(180deg)}.Kwoi6G_selectPopup{z-index:40;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);max-height:240px;box-shadow:0 8px 24px var(--dsw-alias-bg-mask-2);opacity:0;border-radius:8px;flex-direction:column;padding:4px;transition:opacity .1s,transform .1s;display:flex;position:absolute;top:calc(100% + 4px);left:0;right:0;overflow-y:auto;transform:translateY(-4px)}.Kwoi6G_selectPopupOpen{opacity:1;transform:none}.Kwoi6G_selectPopupClose{opacity:0;pointer-events:none;transform:translateY(-4px)}.Kwoi6G_selectOption{color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;text-overflow:ellipsis;border-radius:6px;flex-shrink:0;padding:6px 10px;font-size:13px;line-height:1.5;overflow:hidden}.Kwoi6G_selectOption:hover,.Kwoi6G_selectOptionActive{background:var(--dsw-alias-interactive-bg-hover)}.Kwoi6G_selectOptionSelected{color:var(--dsw-alias-brand-primary);background:color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 10%, transparent);font-weight:500}.Kwoi6G_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}.Kwoi6G_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}@media (prefers-reduced-motion:reduce){.Kwoi6G_card,.Kwoi6G_header,.Kwoi6G_chevron,.Kwoi6G_chevronOpen,.Kwoi6G_discard,.Kwoi6G_save,.Kwoi6G_selectChevron,.Kwoi6G_selectChevronOpen,.Kwoi6G_selectPopup{transition:none}}";
2298
2450
  const tagId = "@linxin666/dsh-remote-web-ui/settings-card.module.css";
2299
2451
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
2300
2452
  const tag = document.createElement("style");
@@ -3046,6 +3198,7 @@ window.__ModuleLoader__.load({
3046
3198
  numberField("tokenTtlMs"),
3047
3199
  numberField("offlineAfterMs"),
3048
3200
  numberField("maxDevices"),
3201
+ numberField("idleExpireMs"),
3049
3202
  textField("cookieName"),
3050
3203
  booleanField("requirePairingForLan"),
3051
3204
  textField("publicBaseUrl"),
@@ -3061,6 +3214,7 @@ window.__ModuleLoader__.load({
3061
3214
  tokenTtlMs: this.form.field("tokenTtlMs"),
3062
3215
  offlineAfterMs: this.form.field("offlineAfterMs"),
3063
3216
  maxDevices: this.form.field("maxDevices"),
3217
+ idleExpireMs: this.form.field("idleExpireMs"),
3064
3218
  cookieName: this.form.field("cookieName"),
3065
3219
  requirePairingForLan: this.form.field("requirePairingForLan"),
3066
3220
  publicBaseUrl: this.form.field("publicBaseUrl"),
@@ -3168,6 +3322,20 @@ window.__ModuleLoader__.load({
3168
3322
  props.resetField("maxDevices");
3169
3323
  }
3170
3324
  }),
3325
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ValueField, {
3326
+ id: "settings-remote-idle-expire",
3327
+ label: t("settings.idleExpireMs"),
3328
+ hint: t("settings.idleExpireMsHint"),
3329
+ numeric: true,
3330
+ ...fieldProps,
3331
+ ...state.idleExpireMs,
3332
+ onEdit: (text) => {
3333
+ props.edit("idleExpireMs", text);
3334
+ },
3335
+ onReset: () => {
3336
+ props.resetField("idleExpireMs");
3337
+ }
3338
+ }),
3171
3339
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ValueField, {
3172
3340
  id: "settings-remote-cookie",
3173
3341
  label: t("settings.cookieName"),
@@ -3248,46 +3416,69 @@ window.__ModuleLoader__.load({
3248
3416
  }
3249
3417
  //#endregion
3250
3418
  //#region src/client/locales.ts
3251
- /** `remote` namespace dictionaries: the mobile remote-control surface copy. */
3419
+ /** `remote` namespace dictionaries: the remote-access surface copy. */
3252
3420
  /** Simplified Chinese dictionary (the key-set source of truth). */
3253
3421
  const zh = {
3254
- "entry.label": "移动端远程控制",
3255
- "title": "移动端远程控制",
3256
- "subtitle": "扫码或在手机上打开链接,即可远程控制当前工作区",
3257
- "card.title": "手机扫码连接",
3422
+ "entry.label": "远程访问",
3423
+ "title": "远程访问",
3424
+ "subtitle": "通过手机或另一台电脑配对,远程访问当前工作区",
3425
+ "card.title": "设备配对",
3258
3426
  "public.badge": "公网",
3259
- "status.waiting": "等待手机连接",
3427
+ "status.waiting": "等待设备连接",
3260
3428
  "status.connected": "已连接 {n} 台设备",
3261
3429
  "status.disconnected": "已配对设备离线",
3262
- "status.stopped": "已停止移动端访问",
3430
+ "status.stopped": "已停止远程访问",
3263
3431
  "status.lanRequired": "此功能需要以 --host 0.0.0.0 启动 dsh web,或配置公网地址才能使用",
3264
3432
  "status.lanRequiredHint": "当前服务仅绑定在 127.0.0.1 且未配置公网地址,手机无法访问。请用 dsh web --host 0.0.0.0 重新启动,或在设置中填写内网穿透的公网地址。",
3265
3433
  "status.loopbackRequired": "配对面板仅限本机使用",
3266
3434
  "status.loopbackRequiredHint": "请通过 http://127.0.0.1 打开此页面后重试;手机请使用配对链接访问。",
3267
3435
  "status.unreachable": "无法连接配对服务",
3268
3436
  "status.unreachableHint": "请刷新页面后重试。",
3269
- "pair.hint": "无法扫码?可以在手机上打开链接",
3270
- "pair.publicHint": "公网链接:手机无需与电脑在同一网络",
3437
+ "pair.hint": "无法扫码?可直接打开下方配对链接",
3438
+ "pair.publicHint": "公网链接:设备无需与本机处于同一网络",
3271
3439
  "address.label": "选择二维码指向的网络",
3272
3440
  "address.public": "公网地址",
3273
3441
  "address.lan": "局域网",
3274
- "address.hint": "公网地址适合手机不在同一网络时使用;局域网地址仅在手机与电脑同一网络时可用。",
3442
+ "address.hint": "远程设备不在同一网络时请使用公网地址;局域网地址仅限同一网络内使用。",
3275
3443
  "pair.expires": "二维码有效至 {time}",
3276
3444
  "pair.expired": "二维码已过期,请刷新",
3445
+ "pair.phoneLabel": "手机配对链接",
3446
+ "pair.desktopLabel": "电脑配对链接",
3447
+ "pair.oneTimeHint": "两条链接共用同一枚一次性令牌;任意设备配对成功后,另一条链接立即失效。",
3277
3448
  "pair.failed.title": "配对失败",
3278
3449
  "pair.failed.detail": "链接无效或已使用,请回到电脑端刷新二维码后重新扫码。",
3450
+ "fence.unpaired.title": "此设备未配对,无法访问工作区数据",
3451
+ "fence.unpaired.eyebrow": "需要设备配对",
3452
+ "fence.unpaired.hint": "为保护工作区、会话与插件数据,远程电脑必须先通过主电脑授权。",
3453
+ "fence.unpaired.stepDesktop": "在主电脑打开 http://127.0.0.1:3080,进入“远程访问”。",
3454
+ "fence.unpaired.stepLink": "在“设备配对”中复制电脑配对链接。",
3455
+ "fence.unpaired.stepOpen": "在当前浏览器打开该链接,完成授权后即可进入。",
3456
+ "fence.unpaired.retry": "重新检测",
3457
+ "fence.unpaired.footnote": "请勿使用他人提供的配对链接;管理员可随时取消此设备的授权。",
3458
+ "posture.exposed": "/api 通道对未配对设备敞开",
3459
+ "posture.exposedHint": "以下来源的请求未经配对即可访问完整桌面 API:{hosts}。请移除对应来源的 --trusted-host(配对机制已覆盖远程访问),或改为仅绑定 127.0.0.1 并使用隧道。",
3279
3460
  "action.stop": "停止",
3280
3461
  "action.refresh": "刷新二维码",
3281
3462
  "action.copy": "复制链接",
3463
+ "action.copyPhone": "复制手机链接",
3464
+ "action.copyDesktop": "复制电脑链接",
3282
3465
  "action.copied": "已复制",
3283
- "stopped.hint": "已停止移动端访问。点击\"刷新二维码\"重新开启。",
3466
+ "devices.title": "已授权设备",
3467
+ "devices.empty": "还没有已配对的设备。扫码或打开链接后会出现在这里。",
3468
+ "devices.unknown": "未知设备",
3469
+ "devices.online": "在线",
3470
+ "devices.offline": "离线",
3471
+ "devices.lastSeen": "最近活动 {time}",
3472
+ "devices.revoke": "取消配对",
3473
+ "devices.revoke.label": "取消配对此设备",
3474
+ "stopped.hint": "已停止远程访问。点击\"刷新二维码\"重新开启。",
3284
3475
  "tunnel.starting": "公网隧道启动中,二维码将自动变为公网链接…",
3285
3476
  "tunnel.failed": "公网隧道启动失败:{error}",
3286
3477
  "tunnel.unknownError": "未知错误",
3287
- "close.label": "关闭移动端远程控制面板",
3288
- "settings.title": "移动端远程控制",
3478
+ "close.label": "关闭远程访问面板",
3479
+ "settings.title": "远程访问设置",
3289
3480
  "settings.description": "配对安全与设备限额。",
3290
- "settings.enabled": "启用移动端远程控制",
3481
+ "settings.enabled": "启用远程访问",
3291
3482
  "settings.enabledHint": "关闭后移除侧边栏入口并停用配对路由与局域网栅栏。",
3292
3483
  "settings.tokenTtlMs": "配对令牌有效期(毫秒)",
3293
3484
  "settings.tokenTtlMsHint": "新生成的二维码链接在此时间后失效。",
@@ -3295,10 +3486,12 @@ window.__ModuleLoader__.load({
3295
3486
  "settings.offlineAfterMsHint": "配对设备超过该时长未上报心跳即视为离线。",
3296
3487
  "settings.maxDevices": "已配对设备上限",
3297
3488
  "settings.maxDevicesHint": "超过上限时淘汰最旧的设备会话。",
3489
+ "settings.idleExpireMs": "空闲过期(毫秒)",
3490
+ "settings.idleExpireMsHint": "超过该时长没有任何心跳或请求的已配对设备会被删除,必须重新扫码。默认 7 天。",
3298
3491
  "settings.cookieName": "设备 Cookie 名",
3299
3492
  "settings.cookieNameHint": "携带已配对设备标识的 Cookie 名称。",
3300
3493
  "settings.requirePairingForLan": "局域网访问要求配对",
3301
- "settings.requirePairingForLanHint": "开启:所有非本机回环的 /api 请求必须携带有效配对 Cookie;关闭:局域网栅栏放开,配对仅用于令牌与状态管理。",
3494
+ "settings.requirePairingForLanHint": "开启:非本机回环的桌面 Web GUI 改走门控的 /remote,必须携带有效配对 Cookie;关闭:桌面继续走普通路径(仅在该来源已被 SDK 信任时有意义),配对只管理令牌与状态。",
3302
3495
  "settings.publicBaseUrl": "公网地址(可选)",
3303
3496
  "settings.publicBaseUrlHint": "内网穿透的公网地址,如 Cloudflare Tunnel:https://xxx.trycloudflare.com。填写后二维码将生成公网链接,手机不在同一网络也能配对;留空则仅局域网可用。",
3304
3497
  "settings.autoTunnel": "自动公网隧道",
@@ -3358,43 +3551,66 @@ window.__ModuleLoader__.load({
3358
3551
  };
3359
3552
  /** English dictionary, checked complete against the zh key set. */
3360
3553
  const en = {
3361
- "entry.label": "Mobile remote control",
3362
- "title": "Mobile remote control",
3363
- "subtitle": "Scan the QR code or open the link on your phone to control this workspace remotely",
3364
- "card.title": "Scan to connect",
3554
+ "entry.label": "Remote access",
3555
+ "title": "Remote access",
3556
+ "subtitle": "Pair a phone or another computer to access this workspace remotely",
3557
+ "card.title": "Pair a device",
3365
3558
  "public.badge": "Public",
3366
- "status.waiting": "Waiting for a phone",
3559
+ "status.waiting": "Waiting for a device",
3367
3560
  "status.connected": "{n} device(s) connected",
3368
3561
  "status.disconnected": "Paired devices offline",
3369
- "status.stopped": "Mobile access stopped",
3562
+ "status.stopped": "Remote access stopped",
3370
3563
  "status.lanRequired": "This feature needs dsh web started with --host 0.0.0.0, or a configured public address",
3371
3564
  "status.lanRequiredHint": "The server is bound to 127.0.0.1 and no public address is configured, so a phone cannot reach it. Restart with dsh web --host 0.0.0.0, or set the tunneled public address in settings.",
3372
3565
  "status.loopbackRequired": "The pairing panel works on this machine only",
3373
3566
  "status.loopbackRequiredHint": "Open this page at http://127.0.0.1 to mint a QR code; phones use the paired link.",
3374
3567
  "status.unreachable": "Cannot reach the pairing service",
3375
3568
  "status.unreachableHint": "Refresh the page and try again.",
3376
- "pair.hint": "Cannot scan? Open the link on your phone",
3377
- "pair.publicHint": "Public link: the phone does not need to be on the same network",
3569
+ "pair.hint": "Cannot scan? Open one of the pairing links below",
3570
+ "pair.publicHint": "Public links work when the device is not on the same network",
3378
3571
  "address.label": "Network the QR code points to",
3379
3572
  "address.public": "Public",
3380
3573
  "address.lan": "LAN",
3381
- "address.hint": "Use the public address when the phone is not on the same network; LAN addresses only work on the same network.",
3574
+ "address.hint": "Use the public address when the remote device is on another network; LAN addresses only work on the same network.",
3382
3575
  "pair.expires": "QR code valid until {time}",
3383
3576
  "pair.expired": "QR code expired — refresh it",
3577
+ "pair.phoneLabel": "Phone pairing link",
3578
+ "pair.desktopLabel": "Computer pairing link",
3579
+ "pair.oneTimeHint": "Both links share one single-use token. Pairing either device invalidates the other link immediately.",
3384
3580
  "pair.failed.title": "Pairing failed",
3385
3581
  "pair.failed.detail": "The link is invalid or was already used. Refresh the QR code on your computer and scan again.",
3582
+ "fence.unpaired.title": "This device is not paired and cannot reach workspace data",
3583
+ "fence.unpaired.eyebrow": "Device pairing required",
3584
+ "fence.unpaired.hint": "To protect workspace, session, and plugin data, a remote computer must first be authorized by the primary computer.",
3585
+ "fence.unpaired.stepDesktop": "On the primary computer, open http://127.0.0.1:3080 and go to Remote access.",
3586
+ "fence.unpaired.stepLink": "Copy the computer pairing link under Pair a device.",
3587
+ "fence.unpaired.stepOpen": "Open that link in this browser. After authorization, the workspace will become available.",
3588
+ "fence.unpaired.retry": "Check again",
3589
+ "fence.unpaired.footnote": "Do not use pairing links from people you do not trust. An administrator can revoke this device at any time.",
3590
+ "posture.exposed": "The /api channel is open to unpaired devices",
3591
+ "posture.exposedHint": "Requests from {hosts} reach the full desktop API without pairing. Remove --trusted-host for them (pairing already covers remote access), or bind loopback only and use the tunnel.",
3386
3592
  "action.stop": "Stop",
3387
3593
  "action.refresh": "Refresh QR",
3388
3594
  "action.copy": "Copy link",
3595
+ "action.copyPhone": "Copy phone link",
3596
+ "action.copyDesktop": "Copy computer link",
3389
3597
  "action.copied": "Copied",
3390
- "stopped.hint": "Mobile access is stopped. Click \"Refresh QR\" to re-enable it.",
3598
+ "devices.title": "Authorized devices",
3599
+ "devices.empty": "No paired devices yet. Scan the QR or open the link to add one.",
3600
+ "devices.unknown": "Unknown device",
3601
+ "devices.online": "Online",
3602
+ "devices.offline": "Offline",
3603
+ "devices.lastSeen": "Last active {time}",
3604
+ "devices.revoke": "Unpair",
3605
+ "devices.revoke.label": "Unpair this device",
3606
+ "stopped.hint": "Remote access is stopped. Click \"Refresh QR\" to re-enable it.",
3391
3607
  "tunnel.starting": "The public tunnel is starting; the QR code will switch to a public link shortly…",
3392
3608
  "tunnel.failed": "The public tunnel failed to start: {error}",
3393
3609
  "tunnel.unknownError": "unknown error",
3394
- "close.label": "Close mobile remote control panel",
3395
- "settings.title": "Mobile remote control",
3610
+ "close.label": "Close remote access panel",
3611
+ "settings.title": "Remote access settings",
3396
3612
  "settings.description": "Pairing security and device limits.",
3397
- "settings.enabled": "Enable mobile remote control",
3613
+ "settings.enabled": "Enable remote access",
3398
3614
  "settings.enabledHint": "When off, the sidebar entry is removed and pairing routes plus the LAN fence stop.",
3399
3615
  "settings.tokenTtlMs": "Pairing token lifetime (ms)",
3400
3616
  "settings.tokenTtlMsHint": "How long a minted QR link stays valid before it dies.",
@@ -3402,10 +3618,12 @@ window.__ModuleLoader__.load({
3402
3618
  "settings.offlineAfterMsHint": "A paired device flips to offline when it has not been seen for this long.",
3403
3619
  "settings.maxDevices": "Paired device cap",
3404
3620
  "settings.maxDevicesHint": "Hard cap on paired device sessions; the oldest is evicted when full.",
3621
+ "settings.idleExpireMs": "Idle expiry (ms)",
3622
+ "settings.idleExpireMsHint": "A paired device with no heartbeat or request for this long is deleted and must scan again. Default is 7 days.",
3405
3623
  "settings.cookieName": "Device cookie name",
3406
3624
  "settings.cookieNameHint": "Cookie that carries the paired device id.",
3407
3625
  "settings.requirePairingForLan": "Require pairing for LAN access",
3408
- "settings.requirePairingForLanHint": "On: every non-loopback /api request must carry a live paired-device cookie. Off: the LAN fence is open and pairing only manages tokens/status.",
3626
+ "settings.requirePairingForLanHint": "On: a desktop Web GUI at a non-loopback origin rides the gated /remote channel and must carry a live paired-device cookie. Off: the desktop stays on its original paths (only useful when that origin is already trusted) and pairing only manages tokens/status.",
3409
3627
  "settings.publicBaseUrl": "Public address (optional)",
3410
3628
  "settings.publicBaseUrlHint": "The public URL of a tunnel in front of this server, e.g. a Cloudflare Tunnel: https://xxx.trycloudflare.com. When set, the QR link is built from it so a phone anywhere can pair; leave blank for LAN-only usage.",
3411
3629
  "settings.autoTunnel": "Auto public tunnel",
@@ -3552,6 +3770,278 @@ window.__ModuleLoader__.load({
3552
3770
  page.replaceState(`${url.pathname}${url.search}${url.hash}`);
3553
3771
  }
3554
3772
  //#endregion
3773
+ //#region src/client/remote-channel.ts
3774
+ /**
3775
+ * The remote desktop channel — browser half. On a non-loopback origin (LAN
3776
+ * address or public tunnel) fenced host routes refuse the request, and
3777
+ * pairing is the real access control — so same-origin traffic the desktop
3778
+ * issues is rewritten onto this plugin's gated `/remote` prefix (host half
3779
+ * in src/remote-api.ts). The host then re-issues the call to 127.0.0.1 so
3780
+ * plugin loopback fences pass.
3781
+ *
3782
+ * The rewrite is deliberately narrow:
3783
+ * - loopback origins are untouched (the desktop at 127.0.0.1 keeps original paths);
3784
+ * - the pairing routes (`/api/pair/*`) stay where they are — accept must
3785
+ * work BEFORE a device is paired;
3786
+ * - the update endpoints (`/api/update/*`) stay loopback-only;
3787
+ * - desktop-launcher create/shutdown (`/api/dsh-desktop-launcher/*`) stay
3788
+ * loopback-only (host-local files and process exit);
3789
+ * - the family settings bridge (`/api/dsh-web-ui-settings/*`) stays
3790
+ * loopback-only (same plane as SDK settings.*);
3791
+ * - `/api/*` (SDK methods and `/api/<plugin>/...` plugin namespaces),
3792
+ * `/sidebar/*`, `/git/*`, and `/pet/*` ride the channel;
3793
+ * - fetch, EventSource, WebSocket, and img/script/iframe `src` are patched;
3794
+ * everything else calls the original unchanged.
3795
+ *
3796
+ * Pure helpers are exported for unit tests; `installRemoteChannel` patches
3797
+ * the given window and returns their restore.
3798
+ */
3799
+ /** The gated mirror prefix (must match src/remote-methods.ts). */
3800
+ const REMOTE_PREFIX = "/remote";
3801
+ const API_PREFIX = "/api/";
3802
+ const PAIR_PREFIX = "/api/pair/";
3803
+ const UPDATE_PREFIX = "/api/update/";
3804
+ const DESKTOP_LAUNCHER_PREFIX = "/api/dsh-desktop-launcher";
3805
+ const SETTINGS_BRIDGE_PREFIX = "/api/dsh-web-ui-settings";
3806
+ const SIDEBAR_PREFIX = "/sidebar/";
3807
+ const GIT_PREFIX = "/git/";
3808
+ const PET_PREFIX = "/pet/";
3809
+ const WS_PATHS = /* @__PURE__ */ new Set([
3810
+ "/api/events.mux",
3811
+ "/api/events.host",
3812
+ "/sidebar/ws/terminal",
3813
+ "/sidebar/ws/agent-terminals",
3814
+ "/api/dsh-ssh/terminal"
3815
+ ]);
3816
+ /**
3817
+ * Browser-safe loopback classification for the page origin (the SDK client
3818
+ * exports its own; this copy keeps the module dependency-free).
3819
+ * @param hostname - a location hostname (IPv6 without brackets).
3820
+ * @returns true for localhost, IPv6 loopback, or any 127/8 literal.
3821
+ */
3822
+ function isLoopbackHostname(hostname) {
3823
+ if (hostname === "localhost" || hostname === "::1") return true;
3824
+ const parts = hostname.split(".");
3825
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
3826
+ }
3827
+ /**
3828
+ * Whether one same-origin path must ride the gated channel (fetch, EventSource,
3829
+ * img/script/iframe src).
3830
+ * @param pathname - the request URL pathname.
3831
+ */
3832
+ function shouldRewriteFetchPath(pathname) {
3833
+ if (pathname.startsWith(PAIR_PREFIX)) return false;
3834
+ if (pathname.startsWith(UPDATE_PREFIX)) return false;
3835
+ if (pathname === DESKTOP_LAUNCHER_PREFIX || pathname.startsWith(`${DESKTOP_LAUNCHER_PREFIX}/`)) return false;
3836
+ if (pathname === SETTINGS_BRIDGE_PREFIX || pathname.startsWith(`${SETTINGS_BRIDGE_PREFIX}/`)) return false;
3837
+ if (pathname.startsWith(API_PREFIX)) return true;
3838
+ if (pathname.startsWith(SIDEBAR_PREFIX) || pathname === "/sidebar") return true;
3839
+ if (pathname.startsWith(GIT_PREFIX) || pathname === "/git") return true;
3840
+ if (pathname.startsWith(PET_PREFIX) || pathname === "/pet") return true;
3841
+ return false;
3842
+ }
3843
+ /**
3844
+ * Whether one WebSocket path must ride the gated channel.
3845
+ * @param pathname - the WebSocket URL pathname.
3846
+ */
3847
+ function shouldRewriteWsPath(pathname) {
3848
+ return WS_PATHS.has(pathname);
3849
+ }
3850
+ /** The gated twin of one fenced path (`/remote` + original pathname). */
3851
+ function rewritePath(pathname) {
3852
+ return `${REMOTE_PREFIX}${pathname}`;
3853
+ }
3854
+ /**
3855
+ * Rewrite one raw URL string when it is same-origin and fenced. Relative
3856
+ * inputs stay relative so resource loaders do not unexpectedly absolutize.
3857
+ */
3858
+ function rewriteRawUrl(raw, baseHref, origin) {
3859
+ let url;
3860
+ try {
3861
+ url = new URL(raw, baseHref);
3862
+ } catch {
3863
+ return raw;
3864
+ }
3865
+ if (url.origin !== origin) return raw;
3866
+ if (!shouldRewriteFetchPath(url.pathname)) return raw;
3867
+ url.pathname = rewritePath(url.pathname);
3868
+ if (raw.startsWith("/") && !raw.startsWith("//")) return `${url.pathname}${url.search}${url.hash}`;
3869
+ return url.href;
3870
+ }
3871
+ /** Read an unpaired code from either the SDK envelope or a plugin JSON body. */
3872
+ function unpairedCodeOf(value) {
3873
+ if (typeof value !== "object" || value === null) return void 0;
3874
+ const record = value;
3875
+ const nested = record.result;
3876
+ if (typeof nested === "object" && nested !== null) {
3877
+ const error = nested.error;
3878
+ if (typeof error === "object" && error !== null && typeof error.code === "string") return error.code;
3879
+ }
3880
+ const error = record.error;
3881
+ if (typeof error === "object" && error !== null && typeof error.code === "string") return error.code;
3882
+ }
3883
+ /**
3884
+ * Whether a gated 403 is the unpaired-device fence (not a loopback-only
3885
+ * method denial, which uses the same status with code `forbidden`).
3886
+ */
3887
+ async function isUnpairedDenied(response) {
3888
+ if (response.status !== 403) return false;
3889
+ try {
3890
+ return unpairedCodeOf(await response.json()) === "unpaired";
3891
+ } catch {
3892
+ return false;
3893
+ }
3894
+ }
3895
+ /**
3896
+ * Wrap a prototype `src` setter so fenced same-origin URLs ride `/remote`.
3897
+ * No-ops when the constructor is missing or `src` is not configurable.
3898
+ */
3899
+ function patchSrcAccessor(ctor, rewrite) {
3900
+ if (ctor === void 0) return () => {};
3901
+ const descriptor = Object.getOwnPropertyDescriptor(ctor.prototype, "src");
3902
+ if (descriptor === void 0 || descriptor.configurable === false) return () => {};
3903
+ if (descriptor.set === void 0) return () => {};
3904
+ const originalSet = descriptor.set;
3905
+ const originalGet = descriptor.get;
3906
+ Object.defineProperty(ctor.prototype, "src", {
3907
+ configurable: true,
3908
+ enumerable: descriptor.enumerable ?? true,
3909
+ get: originalGet,
3910
+ set(value) {
3911
+ originalSet.call(this, rewrite(String(value)));
3912
+ }
3913
+ });
3914
+ return () => {
3915
+ Object.defineProperty(ctor.prototype, "src", descriptor);
3916
+ };
3917
+ }
3918
+ /**
3919
+ * Patch `fetch`, `EventSource`, `WebSocket`, and resource `src` accessors on
3920
+ * one window to route fenced traffic through the gated channel.
3921
+ * @param window - the browser window (or a test double).
3922
+ * @param options - the unpaired callback.
3923
+ * @returns a function restoring the originals.
3924
+ */
3925
+ function installRemoteChannel(window, options = {}) {
3926
+ const originalFetch = window.fetch;
3927
+ const OriginalWebSocket = window.WebSocket;
3928
+ const OriginalEventSource = window.EventSource;
3929
+ const sameOrigin = (url) => url.origin === window.location.origin;
3930
+ const rewrite = (raw) => rewriteRawUrl(raw, window.location.href, window.location.origin);
3931
+ const patchedFetch = (input, init) => {
3932
+ const url = new URL(typeof input === "string" || input instanceof URL ? input.toString() : input.url, window.location.href);
3933
+ if (sameOrigin(url) && shouldRewriteFetchPath(url.pathname)) {
3934
+ const rewritten = new URL(url);
3935
+ rewritten.pathname = rewritePath(url.pathname);
3936
+ const next = typeof input === "string" || input instanceof URL ? rewritten.toString() : new Request(rewritten, input);
3937
+ return Promise.resolve(originalFetch.call(window, next, init)).then(async (response) => {
3938
+ if (await isUnpairedDenied(response.clone())) options.onUnpaired?.();
3939
+ else options.onPaired?.();
3940
+ return response;
3941
+ });
3942
+ }
3943
+ return originalFetch.call(window, input, init);
3944
+ };
3945
+ class PatchedWebSocket extends OriginalWebSocket {
3946
+ constructor(url, protocols) {
3947
+ const parsed = new URL(url.toString(), window.location.href);
3948
+ const wsOrigin = parsed.protocol === "wss:" ? `https://${parsed.host}` : parsed.protocol === "ws:" ? `http://${parsed.host}` : "";
3949
+ if (wsOrigin !== "" && wsOrigin === window.location.origin && shouldRewriteWsPath(parsed.pathname)) {
3950
+ const rewritten = new URL(parsed);
3951
+ rewritten.pathname = rewritePath(parsed.pathname);
3952
+ super(rewritten, protocols);
3953
+ return;
3954
+ }
3955
+ super(url, protocols);
3956
+ }
3957
+ }
3958
+ const restoreSrc = [
3959
+ patchSrcAccessor(window.HTMLImageElement, rewrite),
3960
+ patchSrcAccessor(window.HTMLScriptElement, rewrite),
3961
+ patchSrcAccessor(window.HTMLIFrameElement, rewrite)
3962
+ ];
3963
+ window.fetch = patchedFetch;
3964
+ window.WebSocket = PatchedWebSocket;
3965
+ if (OriginalEventSource !== void 0) {
3966
+ class PatchedEventSource extends OriginalEventSource {
3967
+ constructor(url, eventSourceInitDict) {
3968
+ const parsed = new URL(url.toString(), window.location.href);
3969
+ if (sameOrigin(parsed) && shouldRewriteFetchPath(parsed.pathname)) {
3970
+ const rewritten = new URL(parsed);
3971
+ rewritten.pathname = rewritePath(parsed.pathname);
3972
+ super(rewritten, eventSourceInitDict);
3973
+ return;
3974
+ }
3975
+ super(url, eventSourceInitDict);
3976
+ }
3977
+ }
3978
+ window.EventSource = PatchedEventSource;
3979
+ }
3980
+ return () => {
3981
+ window.fetch = originalFetch;
3982
+ window.WebSocket = OriginalWebSocket;
3983
+ if (OriginalEventSource !== void 0) window.EventSource = OriginalEventSource;
3984
+ for (const restore of restoreSrc) restore();
3985
+ };
3986
+ }
3987
+ //#endregion
3988
+ //#region src/client/FenceNotice.tsx
3989
+ /**
3990
+ * Render the unpaired blocking page.
3991
+ * @param props - localized copy.
3992
+ * @returns the notice element.
3993
+ */
3994
+ function FenceNotice({ t, onRetry }) {
3995
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3996
+ className: remote_module_css_default.fencePage,
3997
+ role: "dialog",
3998
+ "aria-modal": "true",
3999
+ "aria-labelledby": "remote-fence-title",
4000
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("main", {
4001
+ className: remote_module_css_default.fenceCard,
4002
+ "data-dsh-plugin": "remote-web-ui",
4003
+ children: [
4004
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4005
+ className: remote_module_css_default.fenceMark,
4006
+ "aria-hidden": "true",
4007
+ children: "×"
4008
+ }),
4009
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4010
+ className: remote_module_css_default.fenceEyebrow,
4011
+ children: t("fence.unpaired.eyebrow")
4012
+ }),
4013
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h1", {
4014
+ id: "remote-fence-title",
4015
+ className: remote_module_css_default.fenceTitle,
4016
+ children: t("fence.unpaired.title")
4017
+ }),
4018
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4019
+ className: remote_module_css_default.fenceDetail,
4020
+ children: t("fence.unpaired.hint")
4021
+ }),
4022
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("ol", {
4023
+ className: remote_module_css_default.fenceSteps,
4024
+ children: [
4025
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: t("fence.unpaired.stepDesktop") }),
4026
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: t("fence.unpaired.stepLink") }),
4027
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: t("fence.unpaired.stepOpen") })
4028
+ ]
4029
+ }),
4030
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4031
+ className: remote_module_css_default.fenceRetry,
4032
+ type: "button",
4033
+ onClick: onRetry,
4034
+ children: t("fence.unpaired.retry")
4035
+ }),
4036
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4037
+ className: remote_module_css_default.fenceFootnote,
4038
+ children: t("fence.unpaired.footnote")
4039
+ })
4040
+ ]
4041
+ })
4042
+ });
4043
+ }
4044
+ //#endregion
3555
4045
  //#region src/client/index.ts
3556
4046
  /**
3557
4047
  * Mobile remote control — browser half. Registers the `remote` dictionaries,
@@ -3663,6 +4153,51 @@ window.__ModuleLoader__.load({
3663
4153
  };
3664
4154
  settingsScope.subscribe(syncRuntime);
3665
4155
  syncRuntime();
4156
+ let disposeChannel;
4157
+ let fenceNotice;
4158
+ const showFenceNotice = () => {
4159
+ if (fenceNotice !== void 0) return;
4160
+ const node = document.createElement("div");
4161
+ document.body.appendChild(node);
4162
+ const root = (0, react_dom_client.createRoot)(node);
4163
+ root.render((0, react.createElement)(FenceNotice, {
4164
+ t,
4165
+ onRetry: () => {
4166
+ window.location.reload();
4167
+ }
4168
+ }));
4169
+ fenceNotice = {
4170
+ unmount: () => {
4171
+ root.unmount();
4172
+ node.remove();
4173
+ },
4174
+ node
4175
+ };
4176
+ };
4177
+ const hideFenceNotice = () => {
4178
+ fenceNotice?.unmount();
4179
+ fenceNotice = void 0;
4180
+ };
4181
+ const channelActive = () => {
4182
+ if (isLoopbackHostname(window.location.hostname)) return false;
4183
+ const snapshot = settingsScope.getSnapshot();
4184
+ const value = snapshot.status === "ready" ? snapshot.value : void 0;
4185
+ return (value?.enabled ?? true) && (value?.requirePairingForLan ?? true);
4186
+ };
4187
+ const syncChannel = () => {
4188
+ if (channelActive() && disposeChannel === void 0) disposeChannel = ctx.effect(() => {
4189
+ return installRemoteChannel(window, {
4190
+ onUnpaired: showFenceNotice,
4191
+ onPaired: hideFenceNotice
4192
+ });
4193
+ }, "remote-web-ui: remote desktop channel");
4194
+ else if (!channelActive() && disposeChannel !== void 0) {
4195
+ disposeChannel();
4196
+ disposeChannel = void 0;
4197
+ }
4198
+ };
4199
+ settingsScope.subscribe(syncChannel);
4200
+ syncChannel();
3666
4201
  ctx.effect(() => {
3667
4202
  const timer = window.setTimeout(() => {
3668
4203
  if (sessionStorage.getItem("dsh-remote-pair-failed") === null) return;