@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.
- package/README.i18n.yaml +2 -2
- package/README.md +148 -77
- package/README.zh.md +32 -26
- package/lib/client.js +610 -74
- package/lib/client.js.map +1 -1
- package/lib/index.js +676 -35
- package/lib/mobile.js +10 -7
- 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 +83 -33
- 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/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +9 -6
- 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/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +10 -2
- package/package.json +13 -13
- 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 +83 -33
- 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/client/settings-form.ts +8 -6
- 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
- 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,
|
|
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': '自动公网隧道',
|
|
@@ -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
|
|
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': '
|
|
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',
|
|
@@ -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
|
|
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': '
|
|
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.',
|
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).
|