@linxin666/dsh-remote-web-ui 0.2.4 → 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.
- package/README.i18n.yaml +2 -2
- package/README.md +148 -77
- package/README.zh.md +32 -26
- package/lib/client.js +602 -67
- package/lib/client.js.map +1 -1
- package/lib/index.js +667 -32
- package/lib/mobile.js +3 -1
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/FenceNotice.d.ts +21 -0
- package/lib/types/client/FenceNotice.d.ts.map +1 -0
- package/lib/types/client/FenceNotice.js +10 -0
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +18 -11
- package/lib/types/client/RemotePanel.d.ts +10 -4
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +7 -6
- package/lib/types/client/RemoteSettingsCard.d.ts +4 -0
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +3 -1
- package/lib/types/client/device-name.d.ts +3 -0
- package/lib/types/client/device-name.d.ts.map +1 -0
- package/lib/types/client/device-name.js +30 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +48 -0
- package/lib/types/client/locales.d.ts +51 -1
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +79 -29
- package/lib/types/client/pair-api.d.ts +35 -0
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +39 -0
- package/lib/types/client/remote-channel.d.ts +93 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -0
- package/lib/types/client/remote-channel.js +246 -0
- package/lib/types/dsh-home.d.ts +17 -0
- package/lib/types/dsh-home.d.ts.map +1 -0
- package/lib/types/dsh-home.js +34 -0
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +5 -2
- package/lib/types/index.d.ts +19 -12
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +73 -5
- package/lib/types/loopback-proxy.d.ts +28 -0
- package/lib/types/loopback-proxy.d.ts.map +1 -0
- package/lib/types/loopback-proxy.js +112 -0
- package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
- package/lib/types/mobile/views/SessionListView.js +6 -1
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +6 -2
- package/lib/types/mobile-routes.js +1 -1
- package/lib/types/pairing.d.ts +63 -11
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +137 -20
- package/lib/types/pairing.persist.test.js +60 -5
- package/lib/types/posture.d.ts +75 -0
- package/lib/types/posture.d.ts.map +1 -0
- package/lib/types/posture.js +116 -0
- package/lib/types/remote-api.d.ts +60 -0
- package/lib/types/remote-api.d.ts.map +1 -0
- package/lib/types/remote-api.js +181 -0
- package/lib/types/remote-methods.d.ts +33 -0
- package/lib/types/remote-methods.d.ts.map +1 -0
- package/lib/types/remote-methods.js +54 -0
- package/lib/types/routes.d.ts +4 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +33 -4
- package/package.json +2 -2
- package/src/client/FenceNotice.tsx +43 -0
- package/src/client/RemoteEntry.tsx +19 -9
- package/src/client/RemotePanel.tsx +83 -10
- package/src/client/RemoteSettingsCard.tsx +16 -0
- package/src/client/device-name.ts +31 -0
- package/src/client/index.ts +46 -0
- package/src/client/locales.ts +79 -29
- package/src/client/pair-api.ts +60 -0
- package/src/client/remote-channel.ts +269 -0
- package/src/client/remote.module.css +240 -16
- package/src/dsh-home.ts +36 -0
- package/src/gate.ts +5 -2
- package/src/index.ts +89 -17
- package/src/loopback-proxy.ts +126 -0
- package/src/mobile/views/SessionListView.tsx +6 -1
- package/src/mobile-api.ts +6 -2
- package/src/mobile-routes.ts +1 -1
- package/src/pairing.persist.test.ts +64 -5
- package/src/pairing.ts +167 -20
- package/src/posture.ts +154 -0
- package/src/remote-api.ts +217 -0
- package/src/remote-methods.ts +62 -0
- package/src/routes.ts +33 -4
|
@@ -7,11 +7,19 @@
|
|
|
7
7
|
import clsx from 'clsx'
|
|
8
8
|
import { QRCodeSVG } from 'qrcode.react'
|
|
9
9
|
import {
|
|
10
|
-
IconCloseOutline16, IconCopyOutline16,
|
|
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 {
|
|
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:
|
|
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
|
-
<
|
|
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
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -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(() => {
|
package/src/client/locales.ts
CHANGED
|
@@ -1,45 +1,68 @@
|
|
|
1
|
-
/** `remote` namespace dictionaries: the
|
|
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
|
-
'
|
|
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': '
|
|
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': '自动公网隧道',
|
|
@@ -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': '
|
|
122
|
-
'title': '
|
|
123
|
-
'subtitle': '
|
|
124
|
-
'card.title': '
|
|
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
|
|
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': '
|
|
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
|
|
137
|
-
'pair.publicHint': 'Public
|
|
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
|
|
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
|
-
'
|
|
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
|
|
202
|
+
'close.label': 'Close remote access panel',
|
|
155
203
|
// Plugin settings card (the `settings.plugin.item` seat).
|
|
156
|
-
'settings.title': '
|
|
204
|
+
'settings.title': 'Remote access settings',
|
|
157
205
|
'settings.description': 'Pairing security and device limits.',
|
|
158
|
-
'settings.enabled': 'Enable
|
|
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:
|
|
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',
|
package/src/client/pair-api.ts
CHANGED
|
@@ -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).
|