@linxin666/dsh-remote-web-ui 0.1.1
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/LICENSE +28 -0
- package/README.md +346 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +2817 -0
- package/lib/index.js +1455 -0
- package/lib/invariant.js +21 -0
- package/lib/types/client/PairFailedNotice.d.ts +12 -0
- package/lib/types/client/PairFailedNotice.d.ts.map +1 -0
- package/lib/types/client/PhoneIcon.d.ts +19 -0
- package/lib/types/client/PhoneIcon.d.ts.map +1 -0
- package/lib/types/client/PluginSettingsCard.d.ts +78 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
- package/lib/types/client/RemoteEntry.d.ts +10 -0
- package/lib/types/client/RemoteEntry.d.ts.map +1 -0
- package/lib/types/client/RemotePanel.d.ts +50 -0
- package/lib/types/client/RemotePanel.d.ts.map +1 -0
- package/lib/types/client/RemoteSettingsCard.d.ts +75 -0
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -0
- package/lib/types/client/deep-link.d.ts +40 -0
- package/lib/types/client/deep-link.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +54 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +144 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/pair-api.d.ts +100 -0
- package/lib/types/client/pair-api.d.ts.map +1 -0
- package/lib/types/client/settings-form.d.ts +119 -0
- package/lib/types/client/settings-form.d.ts.map +1 -0
- package/lib/types/gate.d.ts +58 -0
- package/lib/types/gate.d.ts.map +1 -0
- package/lib/types/index.d.ts +78 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/lan.d.ts +15 -0
- package/lib/types/lan.d.ts.map +1 -0
- package/lib/types/mobile/api.d.ts +64 -0
- package/lib/types/mobile/api.d.ts.map +1 -0
- package/lib/types/mobile/index.d.ts +10 -0
- package/lib/types/mobile/index.d.ts.map +1 -0
- package/lib/types/mobile/messages.d.ts +103 -0
- package/lib/types/mobile/messages.d.ts.map +1 -0
- package/lib/types/mobile/messages.test.d.ts +2 -0
- package/lib/types/mobile/messages.test.d.ts.map +1 -0
- package/lib/types/mobile/mobile-styles.d.ts +6 -0
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -0
- package/lib/types/mobile/mobile-theme.d.ts +22 -0
- package/lib/types/mobile/mobile-theme.d.ts.map +1 -0
- package/lib/types/mobile/mobile-theme.test.d.ts +2 -0
- package/lib/types/mobile/mobile-theme.test.d.ts.map +1 -0
- package/lib/types/mobile/mux.d.ts +52 -0
- package/lib/types/mobile/mux.d.ts.map +1 -0
- package/lib/types/mobile/rpc.d.ts +38 -0
- package/lib/types/mobile/rpc.d.ts.map +1 -0
- package/lib/types/mobile/theme-toggle.d.ts +12 -0
- package/lib/types/mobile/theme-toggle.d.ts.map +1 -0
- package/lib/types/mobile/views/App.d.ts +59 -0
- package/lib/types/mobile/views/App.d.ts.map +1 -0
- package/lib/types/mobile/views/ChatView.d.ts +39 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -0
- package/lib/types/mobile/views/ChatView.test.d.ts +2 -0
- package/lib/types/mobile/views/ChatView.test.d.ts.map +1 -0
- package/lib/types/mobile/views/SessionListView.d.ts +27 -0
- package/lib/types/mobile/views/SessionListView.d.ts.map +1 -0
- package/lib/types/mobile/views/SessionListView.test.d.ts +2 -0
- package/lib/types/mobile/views/SessionListView.test.d.ts.map +1 -0
- package/lib/types/mobile/views/WorkspaceView.d.ts +19 -0
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -0
- package/lib/types/mobile-api.d.ts +41 -0
- package/lib/types/mobile-api.d.ts.map +1 -0
- package/lib/types/mobile-routes.d.ts +16 -0
- package/lib/types/mobile-routes.d.ts.map +1 -0
- package/lib/types/pairing.d.ts +215 -0
- package/lib/types/pairing.d.ts.map +1 -0
- package/lib/types/routes.d.ts +58 -0
- package/lib/types/routes.d.ts.map +1 -0
- package/lib/types/tunnel.d.ts +99 -0
- package/lib/types/tunnel.d.ts.map +1 -0
- package/package.json +104 -0
- package/src/client/PairFailedNotice.tsx +33 -0
- package/src/client/PhoneIcon.tsx +31 -0
- package/src/client/PluginSettingsCard.tsx +207 -0
- package/src/client/RemoteEntry.tsx +202 -0
- package/src/client/RemotePanel.tsx +186 -0
- package/src/client/RemoteSettingsCard.tsx +220 -0
- package/src/client/deep-link.ts +137 -0
- package/src/client/index.ts +167 -0
- package/src/client/locales.ts +149 -0
- package/src/client/pair-api.ts +176 -0
- package/src/client/remote.module.css +422 -0
- package/src/client/settings-card.module.css +296 -0
- package/src/client/settings-form.ts +294 -0
- package/src/css-modules.d.ts +6 -0
- package/src/gate.ts +121 -0
- package/src/index.ts +299 -0
- package/src/invariant.ts +32 -0
- package/src/lan.ts +21 -0
- package/src/mobile/api.ts +106 -0
- package/src/mobile/index.tsx +27 -0
- package/src/mobile/messages.test.ts +222 -0
- package/src/mobile/messages.ts +555 -0
- package/src/mobile/mobile-styles.ts +853 -0
- package/src/mobile/mobile-theme.test.ts +84 -0
- package/src/mobile/mobile-theme.ts +76 -0
- package/src/mobile/mux.ts +126 -0
- package/src/mobile/rpc.ts +90 -0
- package/src/mobile/theme-toggle.tsx +47 -0
- package/src/mobile/views/App.tsx +152 -0
- package/src/mobile/views/ChatView.test.tsx +243 -0
- package/src/mobile/views/ChatView.tsx +650 -0
- package/src/mobile/views/SessionListView.test.tsx +139 -0
- package/src/mobile/views/SessionListView.tsx +186 -0
- package/src/mobile/views/WorkspaceView.tsx +108 -0
- package/src/mobile-api.ts +271 -0
- package/src/mobile-routes.ts +77 -0
- package/src/pairing.ts +390 -0
- package/src/routes.ts +346 -0
- package/src/tunnel.ts +264 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sidebar remote-control seat: the phone-icon trigger beside the
|
|
3
|
+
* settings button plus the pairing panel modal. Owns the panel behavior —
|
|
4
|
+
* token minting on open, the status SSE subscription, stop/refresh/copy —
|
|
5
|
+
* and renders the pure {@link RemotePanel} body. Component-local state per
|
|
6
|
+
* the client stack rules: nothing here survives remounts or crosses
|
|
7
|
+
* entries.
|
|
8
|
+
*/
|
|
9
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
10
|
+
import { createPortal } from 'react-dom'
|
|
11
|
+
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
12
|
+
import type { PairingPhase } from '../pairing.ts'
|
|
13
|
+
import { RemotePanel, type PanelState } from './RemotePanel.tsx'
|
|
14
|
+
import { copyText, issuePair, stopPair, type IssueResponse, type PairStateFrame, type TunnelStatusFrame } from './pair-api.ts'
|
|
15
|
+
import { PhoneIcon } from './PhoneIcon.tsx'
|
|
16
|
+
import css from './remote.module.css'
|
|
17
|
+
|
|
18
|
+
/** Entry props: the sidebar column state + the standard locale seat. */
|
|
19
|
+
export type RemoteEntryProps = PropsRuntime<'sidebar.remote'> & PropsLocale<'remote'>
|
|
20
|
+
|
|
21
|
+
/** Apply one status frame onto the current ready state. */
|
|
22
|
+
function mergeFrame(state: PanelState, frame: PairStateFrame): PanelState {
|
|
23
|
+
if (state.kind !== 'ready') return state
|
|
24
|
+
return {
|
|
25
|
+
...state,
|
|
26
|
+
phase: frame.phase,
|
|
27
|
+
deviceCount: frame.deviceCount,
|
|
28
|
+
onlineCount: frame.onlineCount,
|
|
29
|
+
...(frame.tunnel !== undefined ? { tunnel: frame.tunnel as TunnelStatusFrame } : {}),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Render the remote-control trigger and panel.
|
|
35
|
+
* @param props - composed slot props (contract in this package).
|
|
36
|
+
* @returns the entry element tree.
|
|
37
|
+
*/
|
|
38
|
+
export function RemoteEntry({ wide, useWorkspaces, t }: RemoteEntryProps) {
|
|
39
|
+
const [open, setOpen] = useState(false)
|
|
40
|
+
const [state, setState] = useState<PanelState>({ kind: 'lan-required' })
|
|
41
|
+
const [copied, setCopied] = useState(false)
|
|
42
|
+
const eventSource = useRef<EventSource | undefined>(undefined)
|
|
43
|
+
|
|
44
|
+
// The current workspace (the recent-workspace projection the shell's New
|
|
45
|
+
// Session flow targets) — the deep-link target for the phone.
|
|
46
|
+
const workspaceId = useWorkspaces(s => s.recentWorkspaceId)
|
|
47
|
+
|
|
48
|
+
const closeEventSource = useCallback(() => {
|
|
49
|
+
eventSource.current?.close()
|
|
50
|
+
eventSource.current = undefined
|
|
51
|
+
}, [])
|
|
52
|
+
|
|
53
|
+
const mint = useCallback(async (address?: string): Promise<PanelState> => {
|
|
54
|
+
let result: IssueResponse
|
|
55
|
+
try {
|
|
56
|
+
result = await issuePair(workspaceId, address)
|
|
57
|
+
} catch {
|
|
58
|
+
// Fetch/network failure: show an explicit state instead of silently
|
|
59
|
+
// leaving the panel on its initial banner.
|
|
60
|
+
return { kind: 'unreachable' }
|
|
61
|
+
}
|
|
62
|
+
if (!result.ok) {
|
|
63
|
+
// 403 is the loopback-only fence refusing a LAN origin (the panel is a
|
|
64
|
+
// desktop control endpoint); 409 means the server never bound 0.0.0.0;
|
|
65
|
+
// 400 means the requested LAN literal is no longer constructible.
|
|
66
|
+
if (result.code === 'forbidden') return { kind: 'loopback-required' }
|
|
67
|
+
if (result.code === 'unknown-address') return { kind: 'unreachable' }
|
|
68
|
+
return { kind: 'lan-required' }
|
|
69
|
+
}
|
|
70
|
+
const publicBaseUrl = result.publicBaseUrl
|
|
71
|
+
return {
|
|
72
|
+
kind: 'ready',
|
|
73
|
+
url: result.url,
|
|
74
|
+
expiresAt: result.expiresAt,
|
|
75
|
+
expired: Date.now() > result.expiresAt,
|
|
76
|
+
phase: 'waiting',
|
|
77
|
+
deviceCount: 0,
|
|
78
|
+
onlineCount: 0,
|
|
79
|
+
// Whether this QR is built on the configured public (tunneled) base.
|
|
80
|
+
public: publicBaseUrl !== undefined && result.url.startsWith(publicBaseUrl),
|
|
81
|
+
...(publicBaseUrl !== undefined ? { publicBaseUrl } : {}),
|
|
82
|
+
// The issued URL names the requested (or default first) literal; the
|
|
83
|
+
// public link has no LAN literal, so no radio row is selected then.
|
|
84
|
+
address: address ?? result.lanAddresses[0] ?? '',
|
|
85
|
+
lanAddresses: result.lanAddresses,
|
|
86
|
+
}
|
|
87
|
+
}, [workspaceId])
|
|
88
|
+
|
|
89
|
+
const openPanel = useCallback(async (): Promise<void> => {
|
|
90
|
+
setOpen(true)
|
|
91
|
+
const next = await mint()
|
|
92
|
+
setState(next)
|
|
93
|
+
// Live status: the desktop panel mirrors the pairing service state. The
|
|
94
|
+
// stream only makes sense in the ready state — on a failure banner the
|
|
95
|
+
// events endpoint is unreachable too (loopback fence), so opening it
|
|
96
|
+
// would just start a doomed reconnect loop.
|
|
97
|
+
if (next.kind !== 'ready') return
|
|
98
|
+
const source = new EventSource('/api/pair/events')
|
|
99
|
+
eventSource.current = source
|
|
100
|
+
source.onmessage = (event) => {
|
|
101
|
+
try {
|
|
102
|
+
const frame = JSON.parse(event.data as string) as PairStateFrame
|
|
103
|
+
if (frame.type !== 'state') return
|
|
104
|
+
setState(previous => mergeFrame(previous, frame))
|
|
105
|
+
} catch {
|
|
106
|
+
// Malformed frames are dropped; the snapshot on open is authoritative.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}, [mint])
|
|
110
|
+
|
|
111
|
+
const closePanel = useCallback(() => {
|
|
112
|
+
closeEventSource()
|
|
113
|
+
setOpen(false)
|
|
114
|
+
}, [closeEventSource])
|
|
115
|
+
|
|
116
|
+
// Expiry flip: one timeout per token lifetime (reset by refresh).
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (state.kind !== 'ready') return
|
|
119
|
+
if (state.expired) return
|
|
120
|
+
const delay = state.expiresAt - Date.now()
|
|
121
|
+
if (delay <= 0) {
|
|
122
|
+
setState(previous => previous.kind === 'ready' ? { ...previous, expired: true } : previous)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
const timer = window.setTimeout(() => {
|
|
126
|
+
setState(previous => previous.kind === 'ready' ? { ...previous, expired: true } : previous)
|
|
127
|
+
}, delay)
|
|
128
|
+
return () => { window.clearTimeout(timer) }
|
|
129
|
+
}, [state])
|
|
130
|
+
|
|
131
|
+
// Unmount safety: never leave the stream open.
|
|
132
|
+
useEffect(() => closeEventSource, [closeEventSource])
|
|
133
|
+
|
|
134
|
+
const handleStop = useCallback(() => {
|
|
135
|
+
// A failed stop request is harmless: the optimistic phase flip below
|
|
136
|
+
// keeps the UI honest, and the status stream confirms the stopped phase.
|
|
137
|
+
void stopPair().catch(() => {})
|
|
138
|
+
// Optimistic fallback; the status stream confirms with the stopped phase.
|
|
139
|
+
setState(previous => previous.kind === 'ready' ? { ...previous, phase: 'stopped' as PairingPhase } : previous)
|
|
140
|
+
}, [])
|
|
141
|
+
|
|
142
|
+
const handleRefresh = useCallback(() => {
|
|
143
|
+
void mint().then(setState)
|
|
144
|
+
}, [mint])
|
|
145
|
+
|
|
146
|
+
/** Re-mint against another LAN literal (multi-homed machines). */
|
|
147
|
+
const handlePickAddress = useCallback((address: string) => {
|
|
148
|
+
void mint(address).then(setState)
|
|
149
|
+
}, [mint])
|
|
150
|
+
|
|
151
|
+
/** Re-mint against the configured public (tunneled) base. */
|
|
152
|
+
const handlePickPublic = useCallback(() => {
|
|
153
|
+
void mint().then(setState)
|
|
154
|
+
}, [mint])
|
|
155
|
+
|
|
156
|
+
const handleCopy = useCallback(() => {
|
|
157
|
+
if (state.kind !== 'ready') return
|
|
158
|
+
void copyText(state.url).then((ok) => {
|
|
159
|
+
if (!ok) return
|
|
160
|
+
setCopied(true)
|
|
161
|
+
window.setTimeout(() => { setCopied(false) }, 1500)
|
|
162
|
+
})
|
|
163
|
+
}, [state])
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<>
|
|
167
|
+
<TooltipAnchor wide={wide} label={t('entry.label')} onClick={openPanel} />
|
|
168
|
+
{open && createPortal((
|
|
169
|
+
<div className={css.overlay} role="presentation">
|
|
170
|
+
<div className={css.mask} aria-hidden="true" onClick={closePanel} />
|
|
171
|
+
<RemotePanel
|
|
172
|
+
t={t}
|
|
173
|
+
state={state}
|
|
174
|
+
copied={copied}
|
|
175
|
+
onClose={closePanel}
|
|
176
|
+
onStop={handleStop}
|
|
177
|
+
onRefresh={handleRefresh}
|
|
178
|
+
onCopy={handleCopy}
|
|
179
|
+
onPickAddress={handlePickAddress}
|
|
180
|
+
onPickPublic={handlePickPublic}
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
), document.body)}
|
|
184
|
+
</>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The trigger: an icon button matching the settings rail/row geometry. */
|
|
189
|
+
function TooltipAnchor({ wide, label, onClick }: { wide: boolean; label: string; onClick: () => void }) {
|
|
190
|
+
return (
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
className={css.trigger}
|
|
194
|
+
data-wide={wide ? undefined : 'rail'}
|
|
195
|
+
aria-label={label}
|
|
196
|
+
title={label}
|
|
197
|
+
onClick={onClick}
|
|
198
|
+
>
|
|
199
|
+
<PhoneIcon size={wide ? 16 : 18} />
|
|
200
|
+
</button>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mobile remote-control panel body: status card (state text + badge),
|
|
3
|
+
* the QR code, the open-on-phone hint with the link text, and the three
|
|
4
|
+
* actions (stop / refresh / copy). Pure presentation — all state and
|
|
5
|
+
* actions arrive through props from the entry's behavior component.
|
|
6
|
+
*/
|
|
7
|
+
import clsx from 'clsx'
|
|
8
|
+
import { QRCodeSVG } from 'qrcode.react'
|
|
9
|
+
import {
|
|
10
|
+
IconCloseOutline16, IconCopyOutline16, IconLinkOutline16, IconRefreshOutline16, IconStopFill16,
|
|
11
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
12
|
+
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
13
|
+
import type { PairingPhase } from '../pairing.ts'
|
|
14
|
+
import { formatClock, type TunnelStatusFrame } from './pair-api.ts'
|
|
15
|
+
import css from './remote.module.css'
|
|
16
|
+
|
|
17
|
+
/** The panel's view state, owned by the entry component. */
|
|
18
|
+
export type PanelState =
|
|
19
|
+
| { kind: 'lan-required' }
|
|
20
|
+
| { kind: 'loopback-required' }
|
|
21
|
+
| { kind: 'unreachable' }
|
|
22
|
+
| {
|
|
23
|
+
kind: 'ready'
|
|
24
|
+
url: string
|
|
25
|
+
expiresAt: number
|
|
26
|
+
expired: boolean
|
|
27
|
+
phase: PairingPhase
|
|
28
|
+
deviceCount: number
|
|
29
|
+
onlineCount: number
|
|
30
|
+
/** The LAN literal the current QR was built from. */
|
|
31
|
+
address: string
|
|
32
|
+
/** Every constructible LAN literal (interface order). */
|
|
33
|
+
lanAddresses: string[]
|
|
34
|
+
/** Whether this QR is built on the configured public (tunneled) base. */
|
|
35
|
+
public: boolean
|
|
36
|
+
/** The configured public (tunneled) base URL, when present. */
|
|
37
|
+
publicBaseUrl?: string
|
|
38
|
+
/** Auto-tunnel status, while the auto-tunnel feature is active. */
|
|
39
|
+
tunnel?: TunnelStatusFrame
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Full panel props: copy + view state + actions. */
|
|
43
|
+
export interface RemotePanelProps {
|
|
44
|
+
t: TranslateNS<'remote'>
|
|
45
|
+
state: PanelState
|
|
46
|
+
copied: boolean
|
|
47
|
+
onClose(): void
|
|
48
|
+
onStop(): void
|
|
49
|
+
onRefresh(): void
|
|
50
|
+
onCopy(): void
|
|
51
|
+
/** Re-mint the QR against a different LAN address. */
|
|
52
|
+
onPickAddress(address: string): void
|
|
53
|
+
/** Re-mint the QR against the configured public (tunneled) base. */
|
|
54
|
+
onPickPublic(): void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Badge text + tone per phase (ready states only). */
|
|
58
|
+
function statusOf(
|
|
59
|
+
t: TranslateNS<'remote'>,
|
|
60
|
+
state: Extract<PanelState, { kind: 'ready' }>,
|
|
61
|
+
): { text: string; tone: 'waiting' | 'connected' | 'disconnected' | 'stopped' } {
|
|
62
|
+
switch (state.phase) {
|
|
63
|
+
case 'connected': return { text: t('status.connected', { n: state.onlineCount }), tone: 'connected' }
|
|
64
|
+
case 'disconnected': return { text: t('status.disconnected'), tone: 'disconnected' }
|
|
65
|
+
case 'stopped': return { text: t('status.stopped'), tone: 'stopped' }
|
|
66
|
+
case 'lan-required': return { text: t('status.lanRequired'), tone: 'stopped' }
|
|
67
|
+
case 'waiting': return { text: t('status.waiting'), tone: 'waiting' }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Render the pairing panel.
|
|
73
|
+
* @param props - copy, state, and actions.
|
|
74
|
+
* @returns the panel element tree.
|
|
75
|
+
*/
|
|
76
|
+
export function RemotePanel({ t, state, copied, onClose, onStop, onRefresh, onCopy, onPickAddress, onPickPublic }: RemotePanelProps) {
|
|
77
|
+
return (
|
|
78
|
+
<div className={css.panel} role="dialog" aria-modal="true" aria-label={t('title')}>
|
|
79
|
+
<div className={css.header}>
|
|
80
|
+
<div className={css.heading}>
|
|
81
|
+
<h2 className={css.title}>{t('title')}</h2>
|
|
82
|
+
<p className={css.subtitle}>{t('subtitle')}</p>
|
|
83
|
+
</div>
|
|
84
|
+
<button type="button" className={css.close} aria-label={t('close.label')} onClick={onClose}>
|
|
85
|
+
<IconCloseOutline16 size={14} />
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{state.kind === 'lan-required' ? (
|
|
90
|
+
<div className={css.banner} role="alert">
|
|
91
|
+
<p className={css.bannerTitle}>{t('status.lanRequired')}</p>
|
|
92
|
+
<p className={css.bannerHint}>{t('status.lanRequiredHint')}</p>
|
|
93
|
+
</div>
|
|
94
|
+
) : state.kind === 'loopback-required' ? (
|
|
95
|
+
<div className={css.banner} role="alert">
|
|
96
|
+
<p className={css.bannerTitle}>{t('status.loopbackRequired')}</p>
|
|
97
|
+
<p className={css.bannerHint}>{t('status.loopbackRequiredHint')}</p>
|
|
98
|
+
</div>
|
|
99
|
+
) : state.kind === 'unreachable' ? (
|
|
100
|
+
<div className={css.banner} role="alert">
|
|
101
|
+
<p className={css.bannerTitle}>{t('status.unreachable')}</p>
|
|
102
|
+
<p className={css.bannerHint}>{t('status.unreachableHint')}</p>
|
|
103
|
+
</div>
|
|
104
|
+
) : (
|
|
105
|
+
<>
|
|
106
|
+
<div className={css.card}>
|
|
107
|
+
<div className={css.cardHeader}>
|
|
108
|
+
<span className={css.cardTitle}>{t('card.title')}</span>
|
|
109
|
+
<span className={css.badges}>
|
|
110
|
+
{state.public && <span className={clsx(css.badge, css.badgePublic)}>{t('public.badge')}</span>}
|
|
111
|
+
<span className={clsx(css.badge, css[`badge-${statusOf(t, state).tone}`])}>
|
|
112
|
+
{statusOf(t, state).text}
|
|
113
|
+
</span>
|
|
114
|
+
</span>
|
|
115
|
+
</div>
|
|
116
|
+
<div className={css.qrWrap} data-testid="remote-qr">
|
|
117
|
+
<QRCodeSVG value={state.url} size={184} level="M" marginSize={1} className={css.qr} />
|
|
118
|
+
</div>
|
|
119
|
+
{state.expired
|
|
120
|
+
? <p className={css.expired}>{t('pair.expired')}</p>
|
|
121
|
+
: <p className={css.expiry}>{t('pair.expires', { time: formatClock(state.expiresAt) })}</p>}
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<p className={css.hint}>{state.public ? t('pair.publicHint') : t('pair.hint')}</p>
|
|
125
|
+
<p className={css.link} title={state.url}>{state.url}</p>
|
|
126
|
+
{state.phase === 'stopped' && <p className={css.stoppedHint}>{t('stopped.hint')}</p>}
|
|
127
|
+
{state.tunnel !== undefined && state.tunnel.state !== 'running' && (
|
|
128
|
+
<p className={state.tunnel.state === 'failed' ? css.tunnelFailed : css.tunnelNote} role="status">
|
|
129
|
+
{state.tunnel.state === 'failed'
|
|
130
|
+
? t('tunnel.failed', { error: state.tunnel.error ?? t('tunnel.unknownError') })
|
|
131
|
+
: t('tunnel.starting')}
|
|
132
|
+
</p>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
{(state.publicBaseUrl !== undefined || state.lanAddresses.length > 1) && (
|
|
136
|
+
<fieldset className={css.addresses}>
|
|
137
|
+
<legend>{t('address.label')}</legend>
|
|
138
|
+
{state.publicBaseUrl !== undefined && (
|
|
139
|
+
<label key="public" className={css.address}>
|
|
140
|
+
<input
|
|
141
|
+
type="radio"
|
|
142
|
+
name="lan-address"
|
|
143
|
+
aria-label={t('address.public')}
|
|
144
|
+
checked={state.public}
|
|
145
|
+
onChange={onPickPublic}
|
|
146
|
+
/>
|
|
147
|
+
<span>{t('address.public')}</span>
|
|
148
|
+
<code className={css.addressValue}>{state.publicBaseUrl}</code>
|
|
149
|
+
</label>
|
|
150
|
+
)}
|
|
151
|
+
{state.lanAddresses.map(address => (
|
|
152
|
+
<label key={address} className={css.address}>
|
|
153
|
+
<input
|
|
154
|
+
type="radio"
|
|
155
|
+
name="lan-address"
|
|
156
|
+
aria-label={address}
|
|
157
|
+
checked={!state.public && address === state.address}
|
|
158
|
+
onChange={() => onPickAddress(address)}
|
|
159
|
+
/>
|
|
160
|
+
<span>{t('address.lan')}</span>
|
|
161
|
+
<code className={css.addressValue}>{address}</code>
|
|
162
|
+
</label>
|
|
163
|
+
))}
|
|
164
|
+
<p className={css.addressHint}>{t('address.hint')}</p>
|
|
165
|
+
</fieldset>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
<div className={css.actions}>
|
|
169
|
+
<button type="button" className={css.action} onClick={onStop}>
|
|
170
|
+
<IconStopFill16 size={14} />
|
|
171
|
+
{t('action.stop')}
|
|
172
|
+
</button>
|
|
173
|
+
<button type="button" className={css.action} onClick={onRefresh}>
|
|
174
|
+
<IconRefreshOutline16 size={14} />
|
|
175
|
+
{t('action.refresh')}
|
|
176
|
+
</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
|
+
</div>
|
|
182
|
+
</>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The remote-control settings card: pairing security and device limits.
|
|
3
|
+
* Registers into the `settings.plugin.item` slot the plugin-configuration
|
|
4
|
+
* section renders, bound to the `remote-web-ui` settings namespace.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
8
|
+
import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
9
|
+
import { PluginSettingsCard, ValueField, BooleanField } from './PluginSettingsCard.tsx'
|
|
10
|
+
import { CardForm, booleanField, numberField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
|
|
11
|
+
|
|
12
|
+
/** The remote-control fields this card edits (the namespace's full schema). */
|
|
13
|
+
export interface RemoteSettings {
|
|
14
|
+
/** Master switch for the plugin. */
|
|
15
|
+
enabled?: boolean
|
|
16
|
+
/** Token lifetime in ms; the QR link dies after this. */
|
|
17
|
+
tokenTtlMs?: number
|
|
18
|
+
/** A device is "online" while its lastSeenAt is newer than this (ms). */
|
|
19
|
+
offlineAfterMs?: number
|
|
20
|
+
/** Hard cap on paired device sessions (oldest evicted when full). */
|
|
21
|
+
maxDevices?: number
|
|
22
|
+
/** Cookie name carrying the paired device id. */
|
|
23
|
+
cookieName?: string
|
|
24
|
+
/** Fence flag: whether non-loopback /api requests must carry a live paired-device cookie. */
|
|
25
|
+
requirePairingForLan?: boolean
|
|
26
|
+
/** Public (tunneled) base URL the QR link is built from when set. */
|
|
27
|
+
publicBaseUrl?: string
|
|
28
|
+
/** When on, the plugin runs its own Cloudflare quick tunnel automatically. */
|
|
29
|
+
autoTunnel?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What the remote-control card renders. */
|
|
33
|
+
export interface RemoteSettingsCardState extends CardShell {
|
|
34
|
+
/** Master switch. */
|
|
35
|
+
enabled: CardFieldState
|
|
36
|
+
/** Token lifetime. */
|
|
37
|
+
tokenTtlMs: CardFieldState
|
|
38
|
+
/** Device offline threshold. */
|
|
39
|
+
offlineAfterMs: CardFieldState
|
|
40
|
+
/** Paired-device cap. */
|
|
41
|
+
maxDevices: CardFieldState
|
|
42
|
+
/** Device cookie name. */
|
|
43
|
+
cookieName: CardFieldState
|
|
44
|
+
/** LAN fence flag. */
|
|
45
|
+
requirePairingForLan: CardFieldState
|
|
46
|
+
/** Public (tunneled) base URL. */
|
|
47
|
+
publicBaseUrl: CardFieldState
|
|
48
|
+
/** Auto public tunnel switch. */
|
|
49
|
+
autoTunnel: CardFieldState
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The registration-side face the card's slot entry injects. */
|
|
53
|
+
export interface RemoteSettingsCardFace extends CardActions {
|
|
54
|
+
hooks: {
|
|
55
|
+
/** Card snapshot bound by the renderer as useRemoteSettingsCard. */
|
|
56
|
+
remoteSettingsCard: SnapshotStore<RemoteSettingsCardState>
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Bridges the `remote-web-ui` scope onto the card's staged form. */
|
|
61
|
+
export class RemoteSettingsCardController {
|
|
62
|
+
private readonly form: CardForm<RemoteSettings>
|
|
63
|
+
private readonly store: SnapshotStore<RemoteSettingsCardState>
|
|
64
|
+
|
|
65
|
+
/** @param scope - the bound settings scope for the `remote-web-ui` namespace. */
|
|
66
|
+
constructor(scope: SettingsScope<RemoteSettings>) {
|
|
67
|
+
this.form = new CardForm(scope, [
|
|
68
|
+
booleanField('enabled'),
|
|
69
|
+
numberField('tokenTtlMs'),
|
|
70
|
+
numberField('offlineAfterMs'),
|
|
71
|
+
numberField('maxDevices'),
|
|
72
|
+
textField('cookieName'),
|
|
73
|
+
booleanField('requirePairingForLan'),
|
|
74
|
+
textField('publicBaseUrl'),
|
|
75
|
+
booleanField('autoTunnel'),
|
|
76
|
+
])
|
|
77
|
+
this.store = this.form.bind(() => this.projection())
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private projection(): RemoteSettingsCardState {
|
|
81
|
+
return {
|
|
82
|
+
...this.form.shell(),
|
|
83
|
+
enabled: this.form.field('enabled'),
|
|
84
|
+
tokenTtlMs: this.form.field('tokenTtlMs'),
|
|
85
|
+
offlineAfterMs: this.form.field('offlineAfterMs'),
|
|
86
|
+
maxDevices: this.form.field('maxDevices'),
|
|
87
|
+
cookieName: this.form.field('cookieName'),
|
|
88
|
+
requirePairingForLan: this.form.field('requirePairingForLan'),
|
|
89
|
+
publicBaseUrl: this.form.field('publicBaseUrl'),
|
|
90
|
+
autoTunnel: this.form.field('autoTunnel'),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build the face the card's slot registration injects.
|
|
96
|
+
* @returns the card's snapshot and its form actions.
|
|
97
|
+
*/
|
|
98
|
+
inject(): RemoteSettingsCardFace {
|
|
99
|
+
return { hooks: { remoteSettingsCard: this.store }, ...this.form.actions() }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Props the renderer binds for the remote-control card. */
|
|
104
|
+
export type RemoteSettingsCardProps =
|
|
105
|
+
PropsRuntime<'web-ui.plugin.item'>
|
|
106
|
+
& PropsLocale<'remote'>
|
|
107
|
+
& InjectFace<RemoteSettingsCardFace>
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Render the remote-control card.
|
|
111
|
+
* @param props - locale copy, the card snapshot, and its form actions.
|
|
112
|
+
* @returns the card.
|
|
113
|
+
*/
|
|
114
|
+
export function RemoteSettingsCard(props: RemoteSettingsCardProps) {
|
|
115
|
+
const { t } = props
|
|
116
|
+
const state = props.useRemoteSettingsCard(snapshot => snapshot)
|
|
117
|
+
const disabled = !state.writable
|
|
118
|
+
const fieldProps = {
|
|
119
|
+
overriddenLabel: t('settings.overridden'),
|
|
120
|
+
resetLabel: t('settings.reset'),
|
|
121
|
+
invalidLabel: t('settings.invalidNumber'),
|
|
122
|
+
disabled,
|
|
123
|
+
}
|
|
124
|
+
return (
|
|
125
|
+
<PluginSettingsCard
|
|
126
|
+
t={t}
|
|
127
|
+
titleKey="settings.title"
|
|
128
|
+
descriptionKey="settings.description"
|
|
129
|
+
state={state}
|
|
130
|
+
onSave={props.save}
|
|
131
|
+
onDiscard={props.discard}
|
|
132
|
+
>
|
|
133
|
+
<BooleanField
|
|
134
|
+
id="settings-remote-enabled"
|
|
135
|
+
label={t('settings.enabled')}
|
|
136
|
+
hint={t('settings.enabledHint')}
|
|
137
|
+
inheritLabel={t('settings.inherit')}
|
|
138
|
+
onLabel={t('settings.on')}
|
|
139
|
+
offLabel={t('settings.off')}
|
|
140
|
+
{...fieldProps}
|
|
141
|
+
{...state.enabled}
|
|
142
|
+
onEdit={(text) => { props.edit('enabled', text) }}
|
|
143
|
+
onReset={() => { props.resetField('enabled') }}
|
|
144
|
+
/>
|
|
145
|
+
<ValueField
|
|
146
|
+
id="settings-remote-token-ttl"
|
|
147
|
+
label={t('settings.tokenTtlMs')}
|
|
148
|
+
hint={t('settings.tokenTtlMsHint')}
|
|
149
|
+
numeric
|
|
150
|
+
{...fieldProps}
|
|
151
|
+
{...state.tokenTtlMs}
|
|
152
|
+
onEdit={(text) => { props.edit('tokenTtlMs', text) }}
|
|
153
|
+
onReset={() => { props.resetField('tokenTtlMs') }}
|
|
154
|
+
/>
|
|
155
|
+
<ValueField
|
|
156
|
+
id="settings-remote-offline"
|
|
157
|
+
label={t('settings.offlineAfterMs')}
|
|
158
|
+
hint={t('settings.offlineAfterMsHint')}
|
|
159
|
+
numeric
|
|
160
|
+
{...fieldProps}
|
|
161
|
+
{...state.offlineAfterMs}
|
|
162
|
+
onEdit={(text) => { props.edit('offlineAfterMs', text) }}
|
|
163
|
+
onReset={() => { props.resetField('offlineAfterMs') }}
|
|
164
|
+
/>
|
|
165
|
+
<ValueField
|
|
166
|
+
id="settings-remote-max-devices"
|
|
167
|
+
label={t('settings.maxDevices')}
|
|
168
|
+
hint={t('settings.maxDevicesHint')}
|
|
169
|
+
numeric
|
|
170
|
+
{...fieldProps}
|
|
171
|
+
{...state.maxDevices}
|
|
172
|
+
onEdit={(text) => { props.edit('maxDevices', text) }}
|
|
173
|
+
onReset={() => { props.resetField('maxDevices') }}
|
|
174
|
+
/>
|
|
175
|
+
<ValueField
|
|
176
|
+
id="settings-remote-cookie"
|
|
177
|
+
label={t('settings.cookieName')}
|
|
178
|
+
hint={t('settings.cookieNameHint')}
|
|
179
|
+
{...fieldProps}
|
|
180
|
+
{...state.cookieName}
|
|
181
|
+
onEdit={(text) => { props.edit('cookieName', text) }}
|
|
182
|
+
onReset={() => { props.resetField('cookieName') }}
|
|
183
|
+
/>
|
|
184
|
+
<BooleanField
|
|
185
|
+
id="settings-remote-fence"
|
|
186
|
+
label={t('settings.requirePairingForLan')}
|
|
187
|
+
hint={t('settings.requirePairingForLanHint')}
|
|
188
|
+
inheritLabel={t('settings.inherit')}
|
|
189
|
+
onLabel={t('settings.on')}
|
|
190
|
+
offLabel={t('settings.off')}
|
|
191
|
+
{...fieldProps}
|
|
192
|
+
{...state.requirePairingForLan}
|
|
193
|
+
onEdit={(text) => { props.edit('requirePairingForLan', text) }}
|
|
194
|
+
onReset={() => { props.resetField('requirePairingForLan') }}
|
|
195
|
+
/>
|
|
196
|
+
<ValueField
|
|
197
|
+
id="settings-remote-public-base"
|
|
198
|
+
label={t('settings.publicBaseUrl')}
|
|
199
|
+
hint={t('settings.publicBaseUrlHint')}
|
|
200
|
+
placeholder="https://example.trycloudflare.com"
|
|
201
|
+
{...fieldProps}
|
|
202
|
+
{...state.publicBaseUrl}
|
|
203
|
+
onEdit={(text) => { props.edit('publicBaseUrl', text) }}
|
|
204
|
+
onReset={() => { props.resetField('publicBaseUrl') }}
|
|
205
|
+
/>
|
|
206
|
+
<BooleanField
|
|
207
|
+
id="settings-remote-auto-tunnel"
|
|
208
|
+
label={t('settings.autoTunnel')}
|
|
209
|
+
hint={t('settings.autoTunnelHint')}
|
|
210
|
+
inheritLabel={t('settings.inherit')}
|
|
211
|
+
onLabel={t('settings.on')}
|
|
212
|
+
offLabel={t('settings.off')}
|
|
213
|
+
{...fieldProps}
|
|
214
|
+
{...state.autoTunnel}
|
|
215
|
+
onEdit={(text) => { props.edit('autoTunnel', text) }}
|
|
216
|
+
onReset={() => { props.resetField('autoTunnel') }}
|
|
217
|
+
/>
|
|
218
|
+
</PluginSettingsCard>
|
|
219
|
+
)
|
|
220
|
+
}
|