@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
@@ -7,11 +7,19 @@
7
7
  import clsx from 'clsx'
8
8
  import { QRCodeSVG } from 'qrcode.react'
9
9
  import {
10
- IconCloseOutline16, IconCopyOutline16, IconLinkOutline16, IconRefreshOutline16, IconStopFill16,
10
+ IconCloseOutline16, IconCopyOutline16, IconRefreshOutline16, IconStopFill16,
11
11
  } from '@deepseek-ai/dsh-client-ui-primitives'
12
12
  import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
13
13
  import type { PairingPhase } from '../pairing.ts'
14
- import { formatClock, type TunnelStatusFrame } from './pair-api.ts'
14
+ import {
15
+ desktopPairUrl,
16
+ formatClock,
17
+ formatLastSeen,
18
+ type DeviceFrame,
19
+ type PostureFrame,
20
+ type TunnelStatusFrame,
21
+ } from './pair-api.ts'
22
+ import { deviceNameFromUserAgent } from './device-name.ts'
15
23
  import css from './remote.module.css'
16
24
 
17
25
  /** The panel's view state, owned by the entry component. */
@@ -27,6 +35,8 @@ export type PanelState =
27
35
  phase: PairingPhase
28
36
  deviceCount: number
29
37
  onlineCount: number
38
+ /** Authorized devices for the roster under the QR card. */
39
+ devices: DeviceFrame[]
30
40
  /** The LAN literal the current QR was built from. */
31
41
  address: string
32
42
  /** Every constructible LAN literal (interface order). */
@@ -37,21 +47,25 @@ export type PanelState =
37
47
  publicBaseUrl?: string
38
48
  /** Auto-tunnel status, while the auto-tunnel feature is active. */
39
49
  tunnel?: TunnelStatusFrame
50
+ /** Latest /api posture probe, once a round has completed. */
51
+ posture?: PostureFrame
40
52
  }
41
53
 
42
54
  /** Full panel props: copy + view state + actions. */
43
55
  export interface RemotePanelProps {
44
56
  t: TranslateNS<'remote'>
45
57
  state: PanelState
46
- copied: boolean
58
+ copied: 'phone' | 'desktop' | undefined
47
59
  onClose(): void
48
60
  onStop(): void
49
61
  onRefresh(): void
50
- onCopy(): void
62
+ onCopy(target: 'phone' | 'desktop', url: string): void
51
63
  /** Re-mint the QR against a different LAN address. */
52
64
  onPickAddress(address: string): void
53
65
  /** Re-mint the QR against the configured public (tunneled) base. */
54
66
  onPickPublic(): void
67
+ /** Revoke one paired device. */
68
+ onRevoke(deviceId: string): void
55
69
  }
56
70
 
57
71
  /** Badge text + tone per phase (ready states only). */
@@ -73,7 +87,7 @@ function statusOf(
73
87
  * @param props - copy, state, and actions.
74
88
  * @returns the panel element tree.
75
89
  */
76
- export function RemotePanel({ t, state, copied, onClose, onStop, onRefresh, onCopy, onPickAddress, onPickPublic }: RemotePanelProps) {
90
+ export function RemotePanel({ t, state, copied, onClose, onStop, onRefresh, onCopy, onPickAddress, onPickPublic, onRevoke }: RemotePanelProps) {
77
91
  return (
78
92
  <div className={css.panel} role="dialog" aria-modal="true" aria-label={t('title')}>
79
93
  <div className={css.header}>
@@ -103,6 +117,14 @@ export function RemotePanel({ t, state, copied, onClose, onStop, onRefresh, onCo
103
117
  </div>
104
118
  ) : (
105
119
  <>
120
+ {state.posture !== undefined && state.posture.hosts.some(host => host.exposed) && (
121
+ <div className={css.banner} role="alert">
122
+ <p className={css.bannerTitle}>{t('posture.exposed')}</p>
123
+ <p className={css.bannerHint}>
124
+ {t('posture.exposedHint', { hosts: state.posture.hosts.filter(host => host.exposed).map(host => host.host).join(', ') })}
125
+ </p>
126
+ </div>
127
+ )}
106
128
  <div className={css.card}>
107
129
  <div className={css.cardHeader}>
108
130
  <span className={css.cardTitle}>{t('card.title')}</span>
@@ -122,7 +144,29 @@ export function RemotePanel({ t, state, copied, onClose, onStop, onRefresh, onCo
122
144
  </div>
123
145
 
124
146
  <p className={css.hint}>{state.public ? t('pair.publicHint') : t('pair.hint')}</p>
125
- <p className={css.link} title={state.url}>{state.url}</p>
147
+ <div className={css.pairLinks}>
148
+ <div className={css.pairLinkRow}>
149
+ <div className={css.pairLinkText}>
150
+ <span className={css.pairLinkLabel}>{t('pair.phoneLabel')}</span>
151
+ <code className={css.link} title={state.url}>{state.url}</code>
152
+ </div>
153
+ <button type="button" className={css.copyLink} onClick={() => onCopy('phone', state.url)}>
154
+ <IconCopyOutline16 size={14} />
155
+ {copied === 'phone' ? t('action.copied') : t('action.copyPhone')}
156
+ </button>
157
+ </div>
158
+ <div className={css.pairLinkRow}>
159
+ <div className={css.pairLinkText}>
160
+ <span className={css.pairLinkLabel}>{t('pair.desktopLabel')}</span>
161
+ <code className={css.link} title={desktopPairUrl(state.url)}>{desktopPairUrl(state.url)}</code>
162
+ </div>
163
+ <button type="button" className={css.copyLink} onClick={() => onCopy('desktop', desktopPairUrl(state.url))}>
164
+ <IconCopyOutline16 size={14} />
165
+ {copied === 'desktop' ? t('action.copied') : t('action.copyDesktop')}
166
+ </button>
167
+ </div>
168
+ </div>
169
+ <p className={css.oneTimeHint}>{t('pair.oneTimeHint')}</p>
126
170
  {state.phase === 'stopped' && <p className={css.stoppedHint}>{t('stopped.hint')}</p>}
127
171
  {state.tunnel !== undefined && state.tunnel.state !== 'running' && (
128
172
  <p className={state.tunnel.state === 'failed' ? css.tunnelFailed : css.tunnelNote} role="status">
@@ -174,11 +218,40 @@ export function RemotePanel({ t, state, copied, onClose, onStop, onRefresh, onCo
174
218
  <IconRefreshOutline16 size={14} />
175
219
  {t('action.refresh')}
176
220
  </button>
177
- <button type="button" className={css.action} onClick={onCopy}>
178
- {copied ? <IconCopyOutline16 size={14} /> : <IconLinkOutline16 size={14} />}
179
- {copied ? t('action.copied') : t('action.copy')}
180
- </button>
181
221
  </div>
222
+
223
+ <section className={css.devices} data-testid="remote-devices" aria-label={t('devices.title')}>
224
+ <h3 className={css.devicesTitle}>{t('devices.title')}</h3>
225
+ {state.devices.length === 0 ? (
226
+ <p className={css.devicesEmpty}>{t('devices.empty')}</p>
227
+ ) : (
228
+ <ul className={css.deviceList}>
229
+ {state.devices.map(device => (
230
+ <li key={device.id} className={css.deviceRow}>
231
+ <div className={css.deviceMeta}>
232
+ <span className={css.deviceName}>
233
+ {deviceNameFromUserAgent(device.userAgent) ?? t('devices.unknown')}
234
+ </span>
235
+ <span className={clsx(css.devicePresence, device.online ? css.deviceOnline : css.deviceOffline)}>
236
+ {device.online ? t('devices.online') : t('devices.offline')}
237
+ </span>
238
+ <span className={css.deviceSeen}>
239
+ {t('devices.lastSeen', { time: formatLastSeen(device.lastSeenAt) })}
240
+ </span>
241
+ </div>
242
+ <button
243
+ type="button"
244
+ className={css.deviceRevoke}
245
+ aria-label={t('devices.revoke.label')}
246
+ onClick={() => { onRevoke(device.id) }}
247
+ >
248
+ {t('devices.revoke')}
249
+ </button>
250
+ </li>
251
+ ))}
252
+ </ul>
253
+ )}
254
+ </section>
182
255
  </>
183
256
  )}
184
257
  </div>
@@ -19,6 +19,8 @@ export interface RemoteSettings {
19
19
  offlineAfterMs?: number
20
20
  /** Hard cap on paired device sessions (oldest evicted when full). */
21
21
  maxDevices?: number
22
+ /** Idle sessions older than this (ms) are deleted. */
23
+ idleExpireMs?: number
22
24
  /** Cookie name carrying the paired device id. */
23
25
  cookieName?: string
24
26
  /** Fence flag: whether non-loopback /api requests must carry a live paired-device cookie. */
@@ -41,6 +43,8 @@ export interface RemoteSettingsCardState extends CardShell {
41
43
  offlineAfterMs: CardFieldState
42
44
  /** Paired-device cap. */
43
45
  maxDevices: CardFieldState
46
+ /** Idle-expiry window. */
47
+ idleExpireMs: CardFieldState
44
48
  /** Device cookie name. */
45
49
  cookieName: CardFieldState
46
50
  /** LAN fence flag. */
@@ -73,6 +77,7 @@ export class RemoteSettingsCardController {
73
77
  numberField('tokenTtlMs'),
74
78
  numberField('offlineAfterMs'),
75
79
  numberField('maxDevices'),
80
+ numberField('idleExpireMs'),
76
81
  textField('cookieName'),
77
82
  booleanField('requirePairingForLan'),
78
83
  textField('publicBaseUrl'),
@@ -89,6 +94,7 @@ export class RemoteSettingsCardController {
89
94
  tokenTtlMs: this.form.field('tokenTtlMs'),
90
95
  offlineAfterMs: this.form.field('offlineAfterMs'),
91
96
  maxDevices: this.form.field('maxDevices'),
97
+ idleExpireMs: this.form.field('idleExpireMs'),
92
98
  cookieName: this.form.field('cookieName'),
93
99
  requirePairingForLan: this.form.field('requirePairingForLan'),
94
100
  publicBaseUrl: this.form.field('publicBaseUrl'),
@@ -187,6 +193,16 @@ export function RemoteSettingsCard(props: RemoteSettingsCardProps) {
187
193
  onEdit={(text) => { props.edit('maxDevices', text) }}
188
194
  onReset={() => { props.resetField('maxDevices') }}
189
195
  />
196
+ <ValueField
197
+ id="settings-remote-idle-expire"
198
+ label={t('settings.idleExpireMs')}
199
+ hint={t('settings.idleExpireMsHint')}
200
+ numeric
201
+ {...fieldProps}
202
+ {...state.idleExpireMs}
203
+ onEdit={(text) => { props.edit('idleExpireMs', text) }}
204
+ onReset={() => { props.resetField('idleExpireMs') }}
205
+ />
190
206
  <ValueField
191
207
  id="settings-remote-cookie"
192
208
  label={t('settings.cookieName')}
@@ -0,0 +1,31 @@
1
+ /** Derive a short, non-sensitive device label from a browser User-Agent. */
2
+ export function deviceNameFromUserAgent(userAgent?: string): string | undefined {
3
+ if (userAgent === undefined || userAgent.trim() === '') return undefined
4
+
5
+ const os = /Windows NT/i.test(userAgent)
6
+ ? 'Windows'
7
+ : /Android/i.test(userAgent)
8
+ ? 'Android'
9
+ : /iPhone|iPad|iPod/i.test(userAgent)
10
+ ? 'iOS'
11
+ : /Macintosh|Mac OS X/i.test(userAgent)
12
+ ? 'macOS'
13
+ : /Linux/i.test(userAgent)
14
+ ? 'Linux'
15
+ : undefined
16
+
17
+ const browser = /Edg(?:A|iOS)?\//i.test(userAgent)
18
+ ? 'Edge'
19
+ : /(?:OPR|Opera)\//i.test(userAgent)
20
+ ? 'Opera'
21
+ : /(?:Chrome|CriOS)\//i.test(userAgent)
22
+ ? 'Chrome'
23
+ : /(?:Firefox|FxiOS)\//i.test(userAgent)
24
+ ? 'Firefox'
25
+ : /Safari\//i.test(userAgent) && /Version\//i.test(userAgent)
26
+ ? 'Safari'
27
+ : undefined
28
+
29
+ if (os !== undefined && browser !== undefined) return `${os} · ${browser}`
30
+ return os ?? browser
31
+ }
@@ -24,6 +24,8 @@ import { RemoteSettingsCard, RemoteSettingsCardController, type RemoteSettings }
24
24
  import { en, zh, type RemoteKey } from './locales.ts'
25
25
  import { PAIR_FAILED_MARKER, runPairBootFlow } from './deep-link.ts'
26
26
  import { sendHeartbeat } from './pair-api.ts'
27
+ import { installRemoteChannel, isLoopbackHostname } from './remote-channel.ts'
28
+ import { FenceNotice } from './FenceNotice.tsx'
27
29
 
28
30
  export type { RemoteEntryProps } from './RemoteEntry.tsx'
29
31
  export type { PanelState, RemotePanelProps } from './RemotePanel.tsx'
@@ -193,6 +195,50 @@ export function apply(ctx: ClientContext): void {
193
195
  settingsScope.subscribe(syncRuntime)
194
196
  syncRuntime()
195
197
 
198
+ // Remote desktop channel: on a non-loopback origin (LAN address or public
199
+ // tunnel) the connection plugin's /api fence refuses this desktop Web GUI,
200
+ // and pairing is the access control — so the SDK client's /api traffic is
201
+ // rewritten onto this plugin's gated /remote/api prefix (remote-channel.ts)
202
+ // while the fence setting demands it. Loopback origins are untouched.
203
+ let disposeChannel: (() => void) | undefined
204
+ let fenceNotice: { unmount: () => void; node: HTMLElement } | undefined
205
+ const showFenceNotice = (): void => {
206
+ if (fenceNotice !== undefined) return
207
+ const node = document.createElement('div')
208
+ document.body.appendChild(node)
209
+ const root = createRoot(node)
210
+ root.render(createElement(FenceNotice, { t, onRetry: () => { window.location.reload() } }))
211
+ fenceNotice = { unmount: () => { root.unmount(); node.remove() }, node }
212
+ }
213
+ const hideFenceNotice = (): void => {
214
+ fenceNotice?.unmount()
215
+ fenceNotice = undefined
216
+ }
217
+ const channelActive = (): boolean => {
218
+ if (isLoopbackHostname(window.location.hostname)) return false
219
+ // A not-yet-loaded (or unavailable) settings snapshot falls back to the
220
+ // schema defaults — enabled and requirePairingForLan are both true, and
221
+ // on a remote origin the snapshot may never load (its own transport is
222
+ // what the channel gates), so waiting for 'ready' here would deadlock
223
+ // the channel off exactly where it is needed most.
224
+ const snapshot = settingsScope.getSnapshot()
225
+ const value = snapshot.status === 'ready' ? snapshot.value : undefined
226
+ return (value?.enabled ?? true) && (value?.requirePairingForLan ?? true)
227
+ }
228
+ const syncChannel = (): void => {
229
+ if (channelActive() && disposeChannel === undefined) {
230
+ disposeChannel = ctx.effect(() => {
231
+ const restore = installRemoteChannel(window, { onUnpaired: showFenceNotice, onPaired: hideFenceNotice })
232
+ return restore
233
+ }, 'remote-web-ui: remote desktop channel')
234
+ } else if (!channelActive() && disposeChannel !== undefined) {
235
+ disposeChannel()
236
+ disposeChannel = undefined
237
+ }
238
+ }
239
+ settingsScope.subscribe(syncChannel)
240
+ syncChannel()
241
+
196
242
  // One-time failed-pair toast. The accept result lands asynchronously, so
197
243
  // the marker check is deferred past the accept round trip.
198
244
  ctx.effect(() => {
@@ -1,45 +1,68 @@
1
- /** `remote` namespace dictionaries: the mobile remote-control surface copy. */
1
+ /** `remote` namespace dictionaries: the remote-access surface copy. */
2
2
 
3
3
  /** Simplified Chinese dictionary (the key-set source of truth). */
4
4
  export const zh = {
5
- 'entry.label': '移动端远程控制',
6
- 'title': '移动端远程控制',
7
- 'subtitle': '扫码或在手机上打开链接,即可远程控制当前工作区',
8
- 'card.title': '手机扫码连接',
5
+ 'entry.label': '远程访问',
6
+ 'title': '远程访问',
7
+ 'subtitle': '通过手机或另一台电脑配对,远程访问当前工作区',
8
+ 'card.title': '设备配对',
9
9
  'public.badge': '公网',
10
- 'status.waiting': '等待手机连接',
10
+ 'status.waiting': '等待设备连接',
11
11
  'status.connected': '已连接 {n} 台设备',
12
12
  'status.disconnected': '已配对设备离线',
13
- 'status.stopped': '已停止移动端访问',
13
+ 'status.stopped': '已停止远程访问',
14
14
  'status.lanRequired': '此功能需要以 --host 0.0.0.0 启动 dsh web,或配置公网地址才能使用',
15
15
  'status.lanRequiredHint': '当前服务仅绑定在 127.0.0.1 且未配置公网地址,手机无法访问。请用 dsh web --host 0.0.0.0 重新启动,或在设置中填写内网穿透的公网地址。',
16
16
  'status.loopbackRequired': '配对面板仅限本机使用',
17
17
  'status.loopbackRequiredHint': '请通过 http://127.0.0.1 打开此页面后重试;手机请使用配对链接访问。',
18
18
  'status.unreachable': '无法连接配对服务',
19
19
  'status.unreachableHint': '请刷新页面后重试。',
20
- 'pair.hint': '无法扫码?可以在手机上打开链接',
21
- 'pair.publicHint': '公网链接:手机无需与电脑在同一网络',
20
+ 'pair.hint': '无法扫码?可直接打开下方配对链接',
21
+ 'pair.publicHint': '公网链接:设备无需与本机处于同一网络',
22
22
  'address.label': '选择二维码指向的网络',
23
23
  'address.public': '公网地址',
24
24
  'address.lan': '局域网',
25
- 'address.hint': '公网地址适合手机不在同一网络时使用;局域网地址仅在手机与电脑同一网络时可用。',
25
+ 'address.hint': '远程设备不在同一网络时请使用公网地址;局域网地址仅限同一网络内使用。',
26
26
  'pair.expires': '二维码有效至 {time}',
27
27
  'pair.expired': '二维码已过期,请刷新',
28
+ 'pair.phoneLabel': '手机配对链接',
29
+ 'pair.desktopLabel': '电脑配对链接',
30
+ 'pair.oneTimeHint': '两条链接共用同一枚一次性令牌;任意设备配对成功后,另一条链接立即失效。',
28
31
  'pair.failed.title': '配对失败',
29
32
  'pair.failed.detail': '链接无效或已使用,请回到电脑端刷新二维码后重新扫码。',
33
+ 'fence.unpaired.title': '此设备未配对,无法访问工作区数据',
34
+ 'fence.unpaired.eyebrow': '需要设备配对',
35
+ 'fence.unpaired.hint': '为保护工作区、会话与插件数据,远程电脑必须先通过主电脑授权。',
36
+ 'fence.unpaired.stepDesktop': '在主电脑打开 http://127.0.0.1:3080,进入“远程访问”。',
37
+ 'fence.unpaired.stepLink': '在“设备配对”中复制电脑配对链接。',
38
+ 'fence.unpaired.stepOpen': '在当前浏览器打开该链接,完成授权后即可进入。',
39
+ 'fence.unpaired.retry': '重新检测',
40
+ 'fence.unpaired.footnote': '请勿使用他人提供的配对链接;管理员可随时取消此设备的授权。',
41
+ 'posture.exposed': '/api 通道对未配对设备敞开',
42
+ 'posture.exposedHint': '以下来源的请求未经配对即可访问完整桌面 API:{hosts}。请移除对应来源的 --trusted-host(配对机制已覆盖远程访问),或改为仅绑定 127.0.0.1 并使用隧道。',
30
43
  'action.stop': '停止',
31
44
  'action.refresh': '刷新二维码',
32
45
  'action.copy': '复制链接',
46
+ 'action.copyPhone': '复制手机链接',
47
+ 'action.copyDesktop': '复制电脑链接',
33
48
  'action.copied': '已复制',
34
- 'stopped.hint': '已停止移动端访问。点击"刷新二维码"重新开启。',
49
+ 'devices.title': '已授权设备',
50
+ 'devices.empty': '还没有已配对的设备。扫码或打开链接后会出现在这里。',
51
+ 'devices.unknown': '未知设备',
52
+ 'devices.online': '在线',
53
+ 'devices.offline': '离线',
54
+ 'devices.lastSeen': '最近活动 {time}',
55
+ 'devices.revoke': '取消配对',
56
+ 'devices.revoke.label': '取消配对此设备',
57
+ 'stopped.hint': '已停止远程访问。点击"刷新二维码"重新开启。',
35
58
  'tunnel.starting': '公网隧道启动中,二维码将自动变为公网链接…',
36
59
  'tunnel.failed': '公网隧道启动失败:{error}',
37
60
  'tunnel.unknownError': '未知错误',
38
- 'close.label': '关闭移动端远程控制面板',
61
+ 'close.label': '关闭远程访问面板',
39
62
  // 插件设置卡片(settings.plugin.item 席位)。
40
- 'settings.title': '移动端远程控制',
63
+ 'settings.title': '远程访问设置',
41
64
  'settings.description': '配对安全与设备限额。',
42
- 'settings.enabled': '启用移动端远程控制',
65
+ 'settings.enabled': '启用远程访问',
43
66
  'settings.enabledHint': '关闭后移除侧边栏入口并停用配对路由与局域网栅栏。',
44
67
  'settings.tokenTtlMs': '配对令牌有效期(毫秒)',
45
68
  'settings.tokenTtlMsHint': '新生成的二维码链接在此时间后失效。',
@@ -47,10 +70,12 @@ export const zh = {
47
70
  'settings.offlineAfterMsHint': '配对设备超过该时长未上报心跳即视为离线。',
48
71
  'settings.maxDevices': '已配对设备上限',
49
72
  'settings.maxDevicesHint': '超过上限时淘汰最旧的设备会话。',
73
+ 'settings.idleExpireMs': '空闲过期(毫秒)',
74
+ 'settings.idleExpireMsHint': '超过该时长没有任何心跳或请求的已配对设备会被删除,必须重新扫码。默认 7 天。',
50
75
  'settings.cookieName': '设备 Cookie 名',
51
76
  'settings.cookieNameHint': '携带已配对设备标识的 Cookie 名称。',
52
77
  'settings.requirePairingForLan': '局域网访问要求配对',
53
- 'settings.requirePairingForLanHint': '开启:所有非本机回环的 /api 请求必须携带有效配对 Cookie;关闭:局域网栅栏放开,配对仅用于令牌与状态管理。',
78
+ 'settings.requirePairingForLanHint': '开启:非本机回环的桌面 Web GUI 改走门控的 /remote,必须携带有效配对 Cookie;关闭:桌面继续走普通路径(仅在该来源已被 SDK 信任时有意义),配对只管理令牌与状态。',
54
79
  'settings.publicBaseUrl': '公网地址(可选)',
55
80
  'settings.publicBaseUrlHint': '内网穿透的公网地址,如 Cloudflare Tunnel:https://xxx.trycloudflare.com。填写后二维码将生成公网链接,手机不在同一网络也能配对;留空则仅局域网可用。',
56
81
  'settings.autoTunnel': '自动公网隧道',
@@ -89,7 +114,7 @@ export const zh = {
89
114
  'update.doneDetail': '所有组件已更新到最新版本。',
90
115
  'update.restartHint': '请重启 dsh web 使新版本生效:在终端按 Ctrl+C 停止后重新运行 dsh web。',
91
116
  'update.linkMode': '当前为本地开发模式',
92
- 'update.linkModeDetail': 'dsh-web-ui 通过本地链接安装(开发模式),无法自动更新。npm 上最新版本:{version}。请在仓库中执行 git pull 更新。',
117
+ 'update.linkModeDetail': 'dsh-web-ui 或其组件通过本地链接安装(开发模式),无法自动更新。npm 上最新版本:{version}。请同步本地仓库,或移除与全家桶重复的独立链接。',
93
118
  'update.missing': '未找到 dsh-web-ui 安装',
94
119
  'update.missingDetail': '无法定位已安装的 dsh-web-ui 聚合包。请先运行 dsh plugin --profile <profile> add @linxin666/dsh-web-ui-all。',
95
120
  'update.offline': '无法连接更新源',
@@ -103,7 +128,7 @@ export const zh = {
103
128
  'update.error.pnpmMissing': '未找到 pnpm,已尝试 pnpm / corepack / npx 仍不可用。请安装 pnpm 后重启应用(Windows: npm install -g pnpm 或 corepack enable;macOS: brew install pnpm 或 corepack enable)。',
104
129
  'update.error.timeout': '更新超时,已终止安装进程,请重试。',
105
130
  'update.error.notFound': '未找到 dsh-web-ui 聚合包安装,无法更新。',
106
- 'update.error.link': '当前为本地开发模式(链接安装),无法自动更新。',
131
+ 'update.error.link': 'dsh-web-ui 或其组件使用本地链接,无法自动更新;请同步本地仓库,或移除与全家桶重复的独立链接。',
107
132
  'update.error.pnpmFailed': '更新执行失败(pnpm 退出码 {code}),详见下方输出。',
108
133
  'update.error.stale': '更新命令已执行,但安装版本未变化。常见原因:pnpm 11 的 minimumReleaseAge 门禁会静默跳过发布不足 24 小时的新版本。请在 profile 目录(~/.dsh/profiles/<profile>)的 pnpm-workspace.yaml 中配置 minimumReleaseAgeExclude(例如 "@linxin666/*")或 minimumReleaseAge: 0 后重新点击更新。',
109
134
  'update.error.verifyFailed': '更新命令已执行,但无法核对安装后的版本(可能是 registry 探测失败或安装路径变化)。请查看下方输出,或稍后重试。',
@@ -118,44 +143,67 @@ export type SettingsCardKey = RemoteKey
118
143
 
119
144
  /** English dictionary, checked complete against the zh key set. */
120
145
  export const en = {
121
- 'entry.label': 'Mobile remote control',
122
- 'title': 'Mobile remote control',
123
- 'subtitle': 'Scan the QR code or open the link on your phone to control this workspace remotely',
124
- 'card.title': 'Scan to connect',
146
+ 'entry.label': 'Remote access',
147
+ 'title': 'Remote access',
148
+ 'subtitle': 'Pair a phone or another computer to access this workspace remotely',
149
+ 'card.title': 'Pair a device',
125
150
  'public.badge': 'Public',
126
- 'status.waiting': 'Waiting for a phone',
151
+ 'status.waiting': 'Waiting for a device',
127
152
  'status.connected': '{n} device(s) connected',
128
153
  'status.disconnected': 'Paired devices offline',
129
- 'status.stopped': 'Mobile access stopped',
154
+ 'status.stopped': 'Remote access stopped',
130
155
  'status.lanRequired': 'This feature needs dsh web started with --host 0.0.0.0, or a configured public address',
131
156
  '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.',
132
157
  'status.loopbackRequired': 'The pairing panel works on this machine only',
133
158
  'status.loopbackRequiredHint': 'Open this page at http://127.0.0.1 to mint a QR code; phones use the paired link.',
134
159
  'status.unreachable': 'Cannot reach the pairing service',
135
160
  'status.unreachableHint': 'Refresh the page and try again.',
136
- 'pair.hint': 'Cannot scan? Open the link on your phone',
137
- 'pair.publicHint': 'Public link: the phone does not need to be on the same network',
161
+ 'pair.hint': 'Cannot scan? Open one of the pairing links below',
162
+ 'pair.publicHint': 'Public links work when the device is not on the same network',
138
163
  'address.label': 'Network the QR code points to',
139
164
  'address.public': 'Public',
140
165
  'address.lan': 'LAN',
141
- 'address.hint': 'Use the public address when the phone is not on the same network; LAN addresses only work on the same network.',
166
+ 'address.hint': 'Use the public address when the remote device is on another network; LAN addresses only work on the same network.',
142
167
  'pair.expires': 'QR code valid until {time}',
143
168
  'pair.expired': 'QR code expired — refresh it',
169
+ 'pair.phoneLabel': 'Phone pairing link',
170
+ 'pair.desktopLabel': 'Computer pairing link',
171
+ 'pair.oneTimeHint': 'Both links share one single-use token. Pairing either device invalidates the other link immediately.',
144
172
  'pair.failed.title': 'Pairing failed',
145
173
  'pair.failed.detail': 'The link is invalid or was already used. Refresh the QR code on your computer and scan again.',
174
+ 'fence.unpaired.title': 'This device is not paired and cannot reach workspace data',
175
+ 'fence.unpaired.eyebrow': 'Device pairing required',
176
+ 'fence.unpaired.hint': 'To protect workspace, session, and plugin data, a remote computer must first be authorized by the primary computer.',
177
+ 'fence.unpaired.stepDesktop': 'On the primary computer, open http://127.0.0.1:3080 and go to Remote access.',
178
+ 'fence.unpaired.stepLink': 'Copy the computer pairing link under Pair a device.',
179
+ 'fence.unpaired.stepOpen': 'Open that link in this browser. After authorization, the workspace will become available.',
180
+ 'fence.unpaired.retry': 'Check again',
181
+ 'fence.unpaired.footnote': 'Do not use pairing links from people you do not trust. An administrator can revoke this device at any time.',
182
+ 'posture.exposed': 'The /api channel is open to unpaired devices',
183
+ '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.',
146
184
  'action.stop': 'Stop',
147
185
  'action.refresh': 'Refresh QR',
148
186
  'action.copy': 'Copy link',
187
+ 'action.copyPhone': 'Copy phone link',
188
+ 'action.copyDesktop': 'Copy computer link',
149
189
  'action.copied': 'Copied',
150
- 'stopped.hint': 'Mobile access is stopped. Click "Refresh QR" to re-enable it.',
190
+ 'devices.title': 'Authorized devices',
191
+ 'devices.empty': 'No paired devices yet. Scan the QR or open the link to add one.',
192
+ 'devices.unknown': 'Unknown device',
193
+ 'devices.online': 'Online',
194
+ 'devices.offline': 'Offline',
195
+ 'devices.lastSeen': 'Last active {time}',
196
+ 'devices.revoke': 'Unpair',
197
+ 'devices.revoke.label': 'Unpair this device',
198
+ 'stopped.hint': 'Remote access is stopped. Click "Refresh QR" to re-enable it.',
151
199
  'tunnel.starting': 'The public tunnel is starting; the QR code will switch to a public link shortly…',
152
200
  'tunnel.failed': 'The public tunnel failed to start: {error}',
153
201
  'tunnel.unknownError': 'unknown error',
154
- 'close.label': 'Close mobile remote control panel',
202
+ 'close.label': 'Close remote access panel',
155
203
  // Plugin settings card (the `settings.plugin.item` seat).
156
- 'settings.title': 'Mobile remote control',
204
+ 'settings.title': 'Remote access settings',
157
205
  'settings.description': 'Pairing security and device limits.',
158
- 'settings.enabled': 'Enable mobile remote control',
206
+ 'settings.enabled': 'Enable remote access',
159
207
  'settings.enabledHint': 'When off, the sidebar entry is removed and pairing routes plus the LAN fence stop.',
160
208
  'settings.tokenTtlMs': 'Pairing token lifetime (ms)',
161
209
  'settings.tokenTtlMsHint': 'How long a minted QR link stays valid before it dies.',
@@ -163,10 +211,12 @@ export const en = {
163
211
  'settings.offlineAfterMsHint': 'A paired device flips to offline when it has not been seen for this long.',
164
212
  'settings.maxDevices': 'Paired device cap',
165
213
  'settings.maxDevicesHint': 'Hard cap on paired device sessions; the oldest is evicted when full.',
214
+ 'settings.idleExpireMs': 'Idle expiry (ms)',
215
+ 'settings.idleExpireMsHint': 'A paired device with no heartbeat or request for this long is deleted and must scan again. Default is 7 days.',
166
216
  'settings.cookieName': 'Device cookie name',
167
217
  'settings.cookieNameHint': 'Cookie that carries the paired device id.',
168
218
  'settings.requirePairingForLan': 'Require pairing for LAN access',
169
- '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.',
219
+ '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.',
170
220
  'settings.publicBaseUrl': 'Public address (optional)',
171
221
  '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.',
172
222
  'settings.autoTunnel': 'Auto public tunnel',
@@ -205,7 +255,7 @@ export const en = {
205
255
  'update.doneDetail': 'All components are up to date.',
206
256
  '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.',
207
257
  'update.linkMode': 'Local development mode',
208
- '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.',
258
+ '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.',
209
259
  'update.missing': 'dsh-web-ui not found',
210
260
  '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',
211
261
  'update.offline': 'Cannot reach the update source',
@@ -219,7 +269,7 @@ export const en = {
219
269
  '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).',
220
270
  'update.error.timeout': 'The update timed out and the install process was killed; try again.',
221
271
  'update.error.notFound': 'The dsh-web-ui aggregate package is not installed; nothing to update.',
222
- 'update.error.link': 'Local development mode (link install) — automatic update is unavailable.',
272
+ '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.',
223
273
  'update.error.pnpmFailed': 'The update failed (pnpm exited with code {code}); see the output below.',
224
274
  '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.',
225
275
  '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.',
@@ -46,6 +46,12 @@ export interface TunnelStatusFrame {
46
46
  error?: string
47
47
  }
48
48
 
49
+ /** One /api posture frame (host half probe; see src/posture.ts). */
50
+ export interface PostureFrame {
51
+ checkedAt: number
52
+ hosts: { host: string; exposed: boolean }[]
53
+ }
54
+
49
55
  /** One /api/pair/events frame. */
50
56
  export interface PairStateFrame {
51
57
  type: 'state'
@@ -55,8 +61,21 @@ export interface PairStateFrame {
55
61
  tokenExpiresAt?: number
56
62
  deviceCount: number
57
63
  onlineCount: number
64
+ /** Per-device roster (loopback events only). */
65
+ devices?: DeviceFrame[]
58
66
  /** Auto-tunnel status, while the auto-tunnel feature is active. */
59
67
  tunnel?: TunnelStatusFrame
68
+ /** Latest /api fence posture probe, once a round has completed. */
69
+ posture?: PostureFrame
70
+ }
71
+
72
+ /** One authorized-device row from the loopback status stream. */
73
+ export interface DeviceFrame {
74
+ id: string
75
+ createdAt: number
76
+ lastSeenAt: number
77
+ online: boolean
78
+ userAgent?: string
60
79
  }
61
80
 
62
81
  /**
@@ -112,6 +131,20 @@ export async function stopPair(): Promise<void> {
112
131
  if (!response.ok) throw new Error(`remote-web-ui: stop failed with ${String(response.status)}`)
113
132
  }
114
133
 
134
+ /**
135
+ * Revoke one paired device from the loopback panel.
136
+ * @param deviceId - the session id of the row to drop.
137
+ */
138
+ export async function revokePair(deviceId: string): Promise<void> {
139
+ const response = await fetch('/api/pair/revoke', {
140
+ method: 'POST',
141
+ headers: { 'content-type': 'application/json' },
142
+ body: JSON.stringify({ deviceId }),
143
+ })
144
+ if (response.status === 404) return
145
+ if (!response.ok) throw new Error(`remote-web-ui: revoke failed with ${String(response.status)}`)
146
+ }
147
+
115
148
  /** Presence heartbeat from a paired phone (unpaired heartbeats 401 harmlessly). */
116
149
  export async function sendHeartbeat(): Promise<void> {
117
150
  await fetch('/api/pair/heartbeat', { method: 'POST' })
@@ -128,6 +161,24 @@ export function readPairParams(search: string): { pair?: string; workspace?: str
128
161
  }
129
162
  }
130
163
 
164
+ /**
165
+ * Strip one query parameter from the current URL without reloading.
166
+ * @param name - the parameter to remove.
167
+ * @returns the new search string ('' when empty).
168
+ */
169
+ export function stripParam(name: string): string {
170
+ const url = new URL(window.location.href)
171
+ url.searchParams.delete(name)
172
+ return url.search
173
+ }
174
+
175
+ /** Convert an issued `/m/` link into the desktop pairing form. */
176
+ export function desktopPairUrl(mobileUrl: string): string {
177
+ const url = new URL(mobileUrl)
178
+ url.pathname = '/'
179
+ return url.href
180
+ }
181
+
131
182
  /** Human-readable expiry clock, e.g. "10:35". */
132
183
  export function formatClock(epochMs: number): string {
133
184
  const date = new Date(epochMs)
@@ -136,6 +187,15 @@ export function formatClock(epochMs: number): string {
136
187
  return `${hours}:${minutes}`
137
188
  }
138
189
 
190
+ /** Calendar + clock for last-seen timestamps, e.g. "2026-08-19 10:35". */
191
+ export function formatLastSeen(epochMs: number): string {
192
+ const date = new Date(epochMs)
193
+ const year = String(date.getFullYear())
194
+ const month = String(date.getMonth() + 1).padStart(2, '0')
195
+ const day = String(date.getDate()).padStart(2, '0')
196
+ return `${year}-${month}-${day} ${formatClock(epochMs)}`
197
+ }
198
+
139
199
  /**
140
200
  * Copy text to the clipboard with a fallback for insecure contexts
141
201
  * (plain-HTTP LAN origins lack navigator.clipboard).