@linxin666/dsh-remote-web-ui 0.2.3 → 0.2.5

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 (95) 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 +610 -74
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +676 -35
  7. package/lib/mobile.js +10 -7
  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 +83 -33
  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/client/settings-form.d.ts.map +1 -1
  35. package/lib/types/client/settings-form.js +9 -6
  36. package/lib/types/dsh-home.d.ts +17 -0
  37. package/lib/types/dsh-home.d.ts.map +1 -0
  38. package/lib/types/dsh-home.js +34 -0
  39. package/lib/types/gate.d.ts.map +1 -1
  40. package/lib/types/gate.js +5 -2
  41. package/lib/types/index.d.ts +19 -12
  42. package/lib/types/index.d.ts.map +1 -1
  43. package/lib/types/index.js +73 -5
  44. package/lib/types/loopback-proxy.d.ts +28 -0
  45. package/lib/types/loopback-proxy.d.ts.map +1 -0
  46. package/lib/types/loopback-proxy.js +112 -0
  47. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
  48. package/lib/types/mobile/views/SessionListView.js +6 -1
  49. package/lib/types/mobile-api.d.ts.map +1 -1
  50. package/lib/types/mobile-api.js +6 -2
  51. package/lib/types/mobile-routes.js +1 -1
  52. package/lib/types/pairing.d.ts +63 -11
  53. package/lib/types/pairing.d.ts.map +1 -1
  54. package/lib/types/pairing.js +137 -20
  55. package/lib/types/pairing.persist.test.js +60 -5
  56. package/lib/types/posture.d.ts +75 -0
  57. package/lib/types/posture.d.ts.map +1 -0
  58. package/lib/types/posture.js +116 -0
  59. package/lib/types/remote-api.d.ts +60 -0
  60. package/lib/types/remote-api.d.ts.map +1 -0
  61. package/lib/types/remote-api.js +181 -0
  62. package/lib/types/remote-methods.d.ts +33 -0
  63. package/lib/types/remote-methods.d.ts.map +1 -0
  64. package/lib/types/remote-methods.js +54 -0
  65. package/lib/types/routes.d.ts +4 -0
  66. package/lib/types/routes.d.ts.map +1 -1
  67. package/lib/types/routes.js +33 -4
  68. package/lib/types/update.d.ts.map +1 -1
  69. package/lib/types/update.js +10 -2
  70. package/package.json +13 -13
  71. package/src/client/FenceNotice.tsx +43 -0
  72. package/src/client/RemoteEntry.tsx +19 -9
  73. package/src/client/RemotePanel.tsx +83 -10
  74. package/src/client/RemoteSettingsCard.tsx +16 -0
  75. package/src/client/device-name.ts +31 -0
  76. package/src/client/index.ts +46 -0
  77. package/src/client/locales.ts +83 -33
  78. package/src/client/pair-api.ts +60 -0
  79. package/src/client/remote-channel.ts +269 -0
  80. package/src/client/remote.module.css +240 -16
  81. package/src/client/settings-form.ts +8 -6
  82. package/src/dsh-home.ts +36 -0
  83. package/src/gate.ts +5 -2
  84. package/src/index.ts +89 -17
  85. package/src/loopback-proxy.ts +126 -0
  86. package/src/mobile/views/SessionListView.tsx +6 -1
  87. package/src/mobile-api.ts +6 -2
  88. package/src/mobile-routes.ts +1 -1
  89. package/src/pairing.persist.test.ts +64 -5
  90. package/src/pairing.ts +167 -20
  91. package/src/posture.ts +154 -0
  92. package/src/remote-api.ts +217 -0
  93. package/src/remote-methods.ts +62 -0
  94. package/src/routes.ts +33 -4
  95. package/src/update.ts +14 -1
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
  }
@@ -2917,7 +3069,8 @@ window.__ModuleLoader__.load({
2917
3069
  const valid = plan.filter((item) => item.run !== void 0);
2918
3070
  if (plan.length === 0 || this.saving || valid.length !== plan.length) return;
2919
3071
  const plannedWrites = valid.map((item) => item.op);
2920
- const fields = new Set(plan.map((item) => item.field));
3072
+ const pending = /* @__PURE__ */ new Map();
3073
+ for (const item of plan) pending.set(item.field, this.staged.get(item.field));
2921
3074
  this.saving = true;
2922
3075
  this.failed = false;
2923
3076
  this.failedReason = void 0;
@@ -2930,9 +3083,9 @@ window.__ModuleLoader__.load({
2930
3083
  for (const field of result.fields) if (field.landed) landed.add(field.field);
2931
3084
  } else this.failedReason = result.message;
2932
3085
  } else for (const item of valid) if (await item.run()) landed.add(item.field);
2933
- for (const field of fields) if (landed.has(field)) this.staged.delete(field);
3086
+ for (const [field, before] of pending) if (landed.has(field) && this.staged.get(field) === before) this.staged.delete(field);
2934
3087
  this.saving = false;
2935
- this.failed = landed.size !== fields.size;
3088
+ this.failed = landed.size !== pending.size;
2936
3089
  this.publish();
2937
3090
  }
2938
3091
  /** The scope's batch surface when it supports one; undefined conservatively otherwise. */
@@ -3045,6 +3198,7 @@ window.__ModuleLoader__.load({
3045
3198
  numberField("tokenTtlMs"),
3046
3199
  numberField("offlineAfterMs"),
3047
3200
  numberField("maxDevices"),
3201
+ numberField("idleExpireMs"),
3048
3202
  textField("cookieName"),
3049
3203
  booleanField("requirePairingForLan"),
3050
3204
  textField("publicBaseUrl"),
@@ -3060,6 +3214,7 @@ window.__ModuleLoader__.load({
3060
3214
  tokenTtlMs: this.form.field("tokenTtlMs"),
3061
3215
  offlineAfterMs: this.form.field("offlineAfterMs"),
3062
3216
  maxDevices: this.form.field("maxDevices"),
3217
+ idleExpireMs: this.form.field("idleExpireMs"),
3063
3218
  cookieName: this.form.field("cookieName"),
3064
3219
  requirePairingForLan: this.form.field("requirePairingForLan"),
3065
3220
  publicBaseUrl: this.form.field("publicBaseUrl"),
@@ -3167,6 +3322,20 @@ window.__ModuleLoader__.load({
3167
3322
  props.resetField("maxDevices");
3168
3323
  }
3169
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
+ }),
3170
3339
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ValueField, {
3171
3340
  id: "settings-remote-cookie",
3172
3341
  label: t("settings.cookieName"),
@@ -3247,46 +3416,69 @@ window.__ModuleLoader__.load({
3247
3416
  }
3248
3417
  //#endregion
3249
3418
  //#region src/client/locales.ts
3250
- /** `remote` namespace dictionaries: the mobile remote-control surface copy. */
3419
+ /** `remote` namespace dictionaries: the remote-access surface copy. */
3251
3420
  /** Simplified Chinese dictionary (the key-set source of truth). */
3252
3421
  const zh = {
3253
- "entry.label": "移动端远程控制",
3254
- "title": "移动端远程控制",
3255
- "subtitle": "扫码或在手机上打开链接,即可远程控制当前工作区",
3256
- "card.title": "手机扫码连接",
3422
+ "entry.label": "远程访问",
3423
+ "title": "远程访问",
3424
+ "subtitle": "通过手机或另一台电脑配对,远程访问当前工作区",
3425
+ "card.title": "设备配对",
3257
3426
  "public.badge": "公网",
3258
- "status.waiting": "等待手机连接",
3427
+ "status.waiting": "等待设备连接",
3259
3428
  "status.connected": "已连接 {n} 台设备",
3260
3429
  "status.disconnected": "已配对设备离线",
3261
- "status.stopped": "已停止移动端访问",
3430
+ "status.stopped": "已停止远程访问",
3262
3431
  "status.lanRequired": "此功能需要以 --host 0.0.0.0 启动 dsh web,或配置公网地址才能使用",
3263
3432
  "status.lanRequiredHint": "当前服务仅绑定在 127.0.0.1 且未配置公网地址,手机无法访问。请用 dsh web --host 0.0.0.0 重新启动,或在设置中填写内网穿透的公网地址。",
3264
3433
  "status.loopbackRequired": "配对面板仅限本机使用",
3265
3434
  "status.loopbackRequiredHint": "请通过 http://127.0.0.1 打开此页面后重试;手机请使用配对链接访问。",
3266
3435
  "status.unreachable": "无法连接配对服务",
3267
3436
  "status.unreachableHint": "请刷新页面后重试。",
3268
- "pair.hint": "无法扫码?可以在手机上打开链接",
3269
- "pair.publicHint": "公网链接:手机无需与电脑在同一网络",
3437
+ "pair.hint": "无法扫码?可直接打开下方配对链接",
3438
+ "pair.publicHint": "公网链接:设备无需与本机处于同一网络",
3270
3439
  "address.label": "选择二维码指向的网络",
3271
3440
  "address.public": "公网地址",
3272
3441
  "address.lan": "局域网",
3273
- "address.hint": "公网地址适合手机不在同一网络时使用;局域网地址仅在手机与电脑同一网络时可用。",
3442
+ "address.hint": "远程设备不在同一网络时请使用公网地址;局域网地址仅限同一网络内使用。",
3274
3443
  "pair.expires": "二维码有效至 {time}",
3275
3444
  "pair.expired": "二维码已过期,请刷新",
3445
+ "pair.phoneLabel": "手机配对链接",
3446
+ "pair.desktopLabel": "电脑配对链接",
3447
+ "pair.oneTimeHint": "两条链接共用同一枚一次性令牌;任意设备配对成功后,另一条链接立即失效。",
3276
3448
  "pair.failed.title": "配对失败",
3277
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 并使用隧道。",
3278
3460
  "action.stop": "停止",
3279
3461
  "action.refresh": "刷新二维码",
3280
3462
  "action.copy": "复制链接",
3463
+ "action.copyPhone": "复制手机链接",
3464
+ "action.copyDesktop": "复制电脑链接",
3281
3465
  "action.copied": "已复制",
3282
- "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": "已停止远程访问。点击\"刷新二维码\"重新开启。",
3283
3475
  "tunnel.starting": "公网隧道启动中,二维码将自动变为公网链接…",
3284
3476
  "tunnel.failed": "公网隧道启动失败:{error}",
3285
3477
  "tunnel.unknownError": "未知错误",
3286
- "close.label": "关闭移动端远程控制面板",
3287
- "settings.title": "移动端远程控制",
3478
+ "close.label": "关闭远程访问面板",
3479
+ "settings.title": "远程访问设置",
3288
3480
  "settings.description": "配对安全与设备限额。",
3289
- "settings.enabled": "启用移动端远程控制",
3481
+ "settings.enabled": "启用远程访问",
3290
3482
  "settings.enabledHint": "关闭后移除侧边栏入口并停用配对路由与局域网栅栏。",
3291
3483
  "settings.tokenTtlMs": "配对令牌有效期(毫秒)",
3292
3484
  "settings.tokenTtlMsHint": "新生成的二维码链接在此时间后失效。",
@@ -3294,10 +3486,12 @@ window.__ModuleLoader__.load({
3294
3486
  "settings.offlineAfterMsHint": "配对设备超过该时长未上报心跳即视为离线。",
3295
3487
  "settings.maxDevices": "已配对设备上限",
3296
3488
  "settings.maxDevicesHint": "超过上限时淘汰最旧的设备会话。",
3489
+ "settings.idleExpireMs": "空闲过期(毫秒)",
3490
+ "settings.idleExpireMsHint": "超过该时长没有任何心跳或请求的已配对设备会被删除,必须重新扫码。默认 7 天。",
3297
3491
  "settings.cookieName": "设备 Cookie 名",
3298
3492
  "settings.cookieNameHint": "携带已配对设备标识的 Cookie 名称。",
3299
3493
  "settings.requirePairingForLan": "局域网访问要求配对",
3300
- "settings.requirePairingForLanHint": "开启:所有非本机回环的 /api 请求必须携带有效配对 Cookie;关闭:局域网栅栏放开,配对仅用于令牌与状态管理。",
3494
+ "settings.requirePairingForLanHint": "开启:非本机回环的桌面 Web GUI 改走门控的 /remote,必须携带有效配对 Cookie;关闭:桌面继续走普通路径(仅在该来源已被 SDK 信任时有意义),配对只管理令牌与状态。",
3301
3495
  "settings.publicBaseUrl": "公网地址(可选)",
3302
3496
  "settings.publicBaseUrlHint": "内网穿透的公网地址,如 Cloudflare Tunnel:https://xxx.trycloudflare.com。填写后二维码将生成公网链接,手机不在同一网络也能配对;留空则仅局域网可用。",
3303
3497
  "settings.autoTunnel": "自动公网隧道",
@@ -3335,7 +3529,7 @@ window.__ModuleLoader__.load({
3335
3529
  "update.doneDetail": "所有组件已更新到最新版本。",
3336
3530
  "update.restartHint": "请重启 dsh web 使新版本生效:在终端按 Ctrl+C 停止后重新运行 dsh web。",
3337
3531
  "update.linkMode": "当前为本地开发模式",
3338
- "update.linkModeDetail": "dsh-web-ui 通过本地链接安装(开发模式),无法自动更新。npm 上最新版本:{version}。请在仓库中执行 git pull 更新。",
3532
+ "update.linkModeDetail": "dsh-web-ui 或其组件通过本地链接安装(开发模式),无法自动更新。npm 上最新版本:{version}。请同步本地仓库,或移除与全家桶重复的独立链接。",
3339
3533
  "update.missing": "未找到 dsh-web-ui 安装",
3340
3534
  "update.missingDetail": "无法定位已安装的 dsh-web-ui 聚合包。请先运行 dsh plugin --profile <profile> add @linxin666/dsh-web-ui-all。",
3341
3535
  "update.offline": "无法连接更新源",
@@ -3349,7 +3543,7 @@ window.__ModuleLoader__.load({
3349
3543
  "update.error.pnpmMissing": "未找到 pnpm,已尝试 pnpm / corepack / npx 仍不可用。请安装 pnpm 后重启应用(Windows: npm install -g pnpm 或 corepack enable;macOS: brew install pnpm 或 corepack enable)。",
3350
3544
  "update.error.timeout": "更新超时,已终止安装进程,请重试。",
3351
3545
  "update.error.notFound": "未找到 dsh-web-ui 聚合包安装,无法更新。",
3352
- "update.error.link": "当前为本地开发模式(链接安装),无法自动更新。",
3546
+ "update.error.link": "dsh-web-ui 或其组件使用本地链接,无法自动更新;请同步本地仓库,或移除与全家桶重复的独立链接。",
3353
3547
  "update.error.pnpmFailed": "更新执行失败(pnpm 退出码 {code}),详见下方输出。",
3354
3548
  "update.error.stale": "更新命令已执行,但安装版本未变化。常见原因:pnpm 11 的 minimumReleaseAge 门禁会静默跳过发布不足 24 小时的新版本。请在 profile 目录(~/.dsh/profiles/<profile>)的 pnpm-workspace.yaml 中配置 minimumReleaseAgeExclude(例如 \"@linxin666/*\")或 minimumReleaseAge: 0 后重新点击更新。",
3355
3549
  "update.error.verifyFailed": "更新命令已执行,但无法核对安装后的版本(可能是 registry 探测失败或安装路径变化)。请查看下方输出,或稍后重试。",
@@ -3357,43 +3551,66 @@ window.__ModuleLoader__.load({
3357
3551
  };
3358
3552
  /** English dictionary, checked complete against the zh key set. */
3359
3553
  const en = {
3360
- "entry.label": "Mobile remote control",
3361
- "title": "Mobile remote control",
3362
- "subtitle": "Scan the QR code or open the link on your phone to control this workspace remotely",
3363
- "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",
3364
3558
  "public.badge": "Public",
3365
- "status.waiting": "Waiting for a phone",
3559
+ "status.waiting": "Waiting for a device",
3366
3560
  "status.connected": "{n} device(s) connected",
3367
3561
  "status.disconnected": "Paired devices offline",
3368
- "status.stopped": "Mobile access stopped",
3562
+ "status.stopped": "Remote access stopped",
3369
3563
  "status.lanRequired": "This feature needs dsh web started with --host 0.0.0.0, or a configured public address",
3370
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.",
3371
3565
  "status.loopbackRequired": "The pairing panel works on this machine only",
3372
3566
  "status.loopbackRequiredHint": "Open this page at http://127.0.0.1 to mint a QR code; phones use the paired link.",
3373
3567
  "status.unreachable": "Cannot reach the pairing service",
3374
3568
  "status.unreachableHint": "Refresh the page and try again.",
3375
- "pair.hint": "Cannot scan? Open the link on your phone",
3376
- "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",
3377
3571
  "address.label": "Network the QR code points to",
3378
3572
  "address.public": "Public",
3379
3573
  "address.lan": "LAN",
3380
- "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.",
3381
3575
  "pair.expires": "QR code valid until {time}",
3382
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.",
3383
3580
  "pair.failed.title": "Pairing failed",
3384
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.",
3385
3592
  "action.stop": "Stop",
3386
3593
  "action.refresh": "Refresh QR",
3387
3594
  "action.copy": "Copy link",
3595
+ "action.copyPhone": "Copy phone link",
3596
+ "action.copyDesktop": "Copy computer link",
3388
3597
  "action.copied": "Copied",
3389
- "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.",
3390
3607
  "tunnel.starting": "The public tunnel is starting; the QR code will switch to a public link shortly…",
3391
3608
  "tunnel.failed": "The public tunnel failed to start: {error}",
3392
3609
  "tunnel.unknownError": "unknown error",
3393
- "close.label": "Close mobile remote control panel",
3394
- "settings.title": "Mobile remote control",
3610
+ "close.label": "Close remote access panel",
3611
+ "settings.title": "Remote access settings",
3395
3612
  "settings.description": "Pairing security and device limits.",
3396
- "settings.enabled": "Enable mobile remote control",
3613
+ "settings.enabled": "Enable remote access",
3397
3614
  "settings.enabledHint": "When off, the sidebar entry is removed and pairing routes plus the LAN fence stop.",
3398
3615
  "settings.tokenTtlMs": "Pairing token lifetime (ms)",
3399
3616
  "settings.tokenTtlMsHint": "How long a minted QR link stays valid before it dies.",
@@ -3401,10 +3618,12 @@ window.__ModuleLoader__.load({
3401
3618
  "settings.offlineAfterMsHint": "A paired device flips to offline when it has not been seen for this long.",
3402
3619
  "settings.maxDevices": "Paired device cap",
3403
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.",
3404
3623
  "settings.cookieName": "Device cookie name",
3405
3624
  "settings.cookieNameHint": "Cookie that carries the paired device id.",
3406
3625
  "settings.requirePairingForLan": "Require pairing for LAN access",
3407
- "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.",
3408
3627
  "settings.publicBaseUrl": "Public address (optional)",
3409
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.",
3410
3629
  "settings.autoTunnel": "Auto public tunnel",
@@ -3442,7 +3661,7 @@ window.__ModuleLoader__.load({
3442
3661
  "update.doneDetail": "All components are up to date.",
3443
3662
  "update.restartHint": "Restart dsh web for the new version to take effect: stop it with Ctrl+C in the terminal, then run dsh web again.",
3444
3663
  "update.linkMode": "Local development mode",
3445
- "update.linkModeDetail": "dsh-web-ui is installed via a local link (development mode) and cannot be updated remotely. Latest npm release: {version}. Run git pull in the repository to update.",
3664
+ "update.linkModeDetail": "dsh-web-ui or one of its components is installed via a local link and cannot be updated remotely. Latest npm release: {version}. Sync the local checkout, or remove a standalone link duplicated by the aggregate.",
3446
3665
  "update.missing": "dsh-web-ui not found",
3447
3666
  "update.missingDetail": "Could not locate the installed dsh-web-ui aggregate package. Install it with: dsh plugin --profile <profile> add @linxin666/dsh-web-ui-all",
3448
3667
  "update.offline": "Cannot reach the update source",
@@ -3456,7 +3675,7 @@ window.__ModuleLoader__.load({
3456
3675
  "update.error.pnpmMissing": "pnpm was not found (tried pnpm / corepack / npx). Install pnpm and restart the app (Windows: npm install -g pnpm or corepack enable; macOS: brew install pnpm or corepack enable).",
3457
3676
  "update.error.timeout": "The update timed out and the install process was killed; try again.",
3458
3677
  "update.error.notFound": "The dsh-web-ui aggregate package is not installed; nothing to update.",
3459
- "update.error.link": "Local development mode (link install) — automatic update is unavailable.",
3678
+ "update.error.link": "dsh-web-ui or one of its components uses a local link. Sync the checkout, or remove a standalone link duplicated by the aggregate.",
3460
3679
  "update.error.pnpmFailed": "The update failed (pnpm exited with code {code}); see the output below.",
3461
3680
  "update.error.stale": "The update command ran but the installed versions did not change. Likely cause: the pnpm 11 minimumReleaseAge gate silently skips releases published less than 24 hours ago. Add minimumReleaseAgeExclude (e.g. \"@linxin666/*\") or set minimumReleaseAge: 0 in pnpm-workspace.yaml under the profile directory (~/.dsh/profiles/<profile>), then run the update again.",
3462
3681
  "update.error.verifyFailed": "The update command ran but the installed versions could not be verified afterwards (registry probe failed or the install path changed). Inspect the output below, or retry later.",
@@ -3551,6 +3770,278 @@ window.__ModuleLoader__.load({
3551
3770
  page.replaceState(`${url.pathname}${url.search}${url.hash}`);
3552
3771
  }
3553
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
3554
4045
  //#region src/client/index.ts
3555
4046
  /**
3556
4047
  * Mobile remote control — browser half. Registers the `remote` dictionaries,
@@ -3662,6 +4153,51 @@ window.__ModuleLoader__.load({
3662
4153
  };
3663
4154
  settingsScope.subscribe(syncRuntime);
3664
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();
3665
4201
  ctx.effect(() => {
3666
4202
  const timer = window.setTimeout(() => {
3667
4203
  if (sessionStorage.getItem("dsh-remote-pair-failed") === null) return;