@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.
Files changed (118) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +346 -0
  3. package/cordis.patch.yml +10 -0
  4. package/lib/client.js +2817 -0
  5. package/lib/index.js +1455 -0
  6. package/lib/invariant.js +21 -0
  7. package/lib/types/client/PairFailedNotice.d.ts +12 -0
  8. package/lib/types/client/PairFailedNotice.d.ts.map +1 -0
  9. package/lib/types/client/PhoneIcon.d.ts +19 -0
  10. package/lib/types/client/PhoneIcon.d.ts.map +1 -0
  11. package/lib/types/client/PluginSettingsCard.d.ts +78 -0
  12. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  13. package/lib/types/client/RemoteEntry.d.ts +10 -0
  14. package/lib/types/client/RemoteEntry.d.ts.map +1 -0
  15. package/lib/types/client/RemotePanel.d.ts +50 -0
  16. package/lib/types/client/RemotePanel.d.ts.map +1 -0
  17. package/lib/types/client/RemoteSettingsCard.d.ts +75 -0
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -0
  19. package/lib/types/client/deep-link.d.ts +40 -0
  20. package/lib/types/client/deep-link.d.ts.map +1 -0
  21. package/lib/types/client/index.d.ts +54 -0
  22. package/lib/types/client/index.d.ts.map +1 -0
  23. package/lib/types/client/locales.d.ts +144 -0
  24. package/lib/types/client/locales.d.ts.map +1 -0
  25. package/lib/types/client/pair-api.d.ts +100 -0
  26. package/lib/types/client/pair-api.d.ts.map +1 -0
  27. package/lib/types/client/settings-form.d.ts +119 -0
  28. package/lib/types/client/settings-form.d.ts.map +1 -0
  29. package/lib/types/gate.d.ts +58 -0
  30. package/lib/types/gate.d.ts.map +1 -0
  31. package/lib/types/index.d.ts +78 -0
  32. package/lib/types/index.d.ts.map +1 -0
  33. package/lib/types/invariant.d.ts +16 -0
  34. package/lib/types/invariant.d.ts.map +1 -0
  35. package/lib/types/lan.d.ts +15 -0
  36. package/lib/types/lan.d.ts.map +1 -0
  37. package/lib/types/mobile/api.d.ts +64 -0
  38. package/lib/types/mobile/api.d.ts.map +1 -0
  39. package/lib/types/mobile/index.d.ts +10 -0
  40. package/lib/types/mobile/index.d.ts.map +1 -0
  41. package/lib/types/mobile/messages.d.ts +103 -0
  42. package/lib/types/mobile/messages.d.ts.map +1 -0
  43. package/lib/types/mobile/messages.test.d.ts +2 -0
  44. package/lib/types/mobile/messages.test.d.ts.map +1 -0
  45. package/lib/types/mobile/mobile-styles.d.ts +6 -0
  46. package/lib/types/mobile/mobile-styles.d.ts.map +1 -0
  47. package/lib/types/mobile/mobile-theme.d.ts +22 -0
  48. package/lib/types/mobile/mobile-theme.d.ts.map +1 -0
  49. package/lib/types/mobile/mobile-theme.test.d.ts +2 -0
  50. package/lib/types/mobile/mobile-theme.test.d.ts.map +1 -0
  51. package/lib/types/mobile/mux.d.ts +52 -0
  52. package/lib/types/mobile/mux.d.ts.map +1 -0
  53. package/lib/types/mobile/rpc.d.ts +38 -0
  54. package/lib/types/mobile/rpc.d.ts.map +1 -0
  55. package/lib/types/mobile/theme-toggle.d.ts +12 -0
  56. package/lib/types/mobile/theme-toggle.d.ts.map +1 -0
  57. package/lib/types/mobile/views/App.d.ts +59 -0
  58. package/lib/types/mobile/views/App.d.ts.map +1 -0
  59. package/lib/types/mobile/views/ChatView.d.ts +39 -0
  60. package/lib/types/mobile/views/ChatView.d.ts.map +1 -0
  61. package/lib/types/mobile/views/ChatView.test.d.ts +2 -0
  62. package/lib/types/mobile/views/ChatView.test.d.ts.map +1 -0
  63. package/lib/types/mobile/views/SessionListView.d.ts +27 -0
  64. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -0
  65. package/lib/types/mobile/views/SessionListView.test.d.ts +2 -0
  66. package/lib/types/mobile/views/SessionListView.test.d.ts.map +1 -0
  67. package/lib/types/mobile/views/WorkspaceView.d.ts +19 -0
  68. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -0
  69. package/lib/types/mobile-api.d.ts +41 -0
  70. package/lib/types/mobile-api.d.ts.map +1 -0
  71. package/lib/types/mobile-routes.d.ts +16 -0
  72. package/lib/types/mobile-routes.d.ts.map +1 -0
  73. package/lib/types/pairing.d.ts +215 -0
  74. package/lib/types/pairing.d.ts.map +1 -0
  75. package/lib/types/routes.d.ts +58 -0
  76. package/lib/types/routes.d.ts.map +1 -0
  77. package/lib/types/tunnel.d.ts +99 -0
  78. package/lib/types/tunnel.d.ts.map +1 -0
  79. package/package.json +104 -0
  80. package/src/client/PairFailedNotice.tsx +33 -0
  81. package/src/client/PhoneIcon.tsx +31 -0
  82. package/src/client/PluginSettingsCard.tsx +207 -0
  83. package/src/client/RemoteEntry.tsx +202 -0
  84. package/src/client/RemotePanel.tsx +186 -0
  85. package/src/client/RemoteSettingsCard.tsx +220 -0
  86. package/src/client/deep-link.ts +137 -0
  87. package/src/client/index.ts +167 -0
  88. package/src/client/locales.ts +149 -0
  89. package/src/client/pair-api.ts +176 -0
  90. package/src/client/remote.module.css +422 -0
  91. package/src/client/settings-card.module.css +296 -0
  92. package/src/client/settings-form.ts +294 -0
  93. package/src/css-modules.d.ts +6 -0
  94. package/src/gate.ts +121 -0
  95. package/src/index.ts +299 -0
  96. package/src/invariant.ts +32 -0
  97. package/src/lan.ts +21 -0
  98. package/src/mobile/api.ts +106 -0
  99. package/src/mobile/index.tsx +27 -0
  100. package/src/mobile/messages.test.ts +222 -0
  101. package/src/mobile/messages.ts +555 -0
  102. package/src/mobile/mobile-styles.ts +853 -0
  103. package/src/mobile/mobile-theme.test.ts +84 -0
  104. package/src/mobile/mobile-theme.ts +76 -0
  105. package/src/mobile/mux.ts +126 -0
  106. package/src/mobile/rpc.ts +90 -0
  107. package/src/mobile/theme-toggle.tsx +47 -0
  108. package/src/mobile/views/App.tsx +152 -0
  109. package/src/mobile/views/ChatView.test.tsx +243 -0
  110. package/src/mobile/views/ChatView.tsx +650 -0
  111. package/src/mobile/views/SessionListView.test.tsx +139 -0
  112. package/src/mobile/views/SessionListView.tsx +186 -0
  113. package/src/mobile/views/WorkspaceView.tsx +108 -0
  114. package/src/mobile-api.ts +271 -0
  115. package/src/mobile-routes.ts +77 -0
  116. package/src/pairing.ts +390 -0
  117. package/src/routes.ts +346 -0
  118. 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
+ }