dsh-cherry-bridge 0.1.10 → 0.2.0
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.en.md +12 -5
- package/README.md +11 -5
- package/dist/index.d.mts +31 -3
- package/dist/index.mjs +1363 -170
- package/docs/e2e-checklist.md +9 -2
- package/lib/client.js +47 -0
- package/package.json +4 -3
- package/src/AGENTS.md +860 -0
- package/src/connection.ts +114 -10
- package/src/deviceStore.ts +204 -5
- package/src/diagnostics.ts +30 -0
- package/src/httpApi.ts +129 -8
- package/src/index.ts +190 -8
- package/src/pairing.ts +504 -112
- package/src/rpc/jsonRpcClient.ts +19 -1
- package/src/subscriptions.ts +10 -2
- package/src/text.ts +158 -0
- package/src/tools.ts +803 -56
- package/static/settings-devices.jpg +0 -0
- package/static/settings-pairing-scope.jpg +0 -0
- package/ui/AGENTS.md +23 -0
- package/ui/src/DeviceCard.tsx +35 -0
- package/ui/src/i18n.ts +10 -0
- package/ui/src/types.ts +9 -1
- package/src/urlHost.ts +0 -15
|
Binary file
|
|
Binary file
|
package/ui/AGENTS.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# AGENTS.md — `ui/`
|
|
2
|
+
|
|
3
|
+
The settings page: pairing wizard, device cards and the tool-scope panel. It
|
|
4
|
+
builds to `lib/client.js` (`npm run build:client`) and that bundle is COMMITTED
|
|
5
|
+
with its source. Loaded automatically when a file tool touches `ui/**`.
|
|
6
|
+
|
|
7
|
+
- **One view, no router.** `App.tsx` owns the two things that outlive both
|
|
8
|
+
children: the SSE stream and the device map.
|
|
9
|
+
- **The stream opens ONCE on mount and closes on unmount, in that order.** The
|
|
10
|
+
verification code exists only as a frame with no replay, so the stream must
|
|
11
|
+
exist before the wizard can `POST /pair` — which is why the submit button
|
|
12
|
+
stays disabled until `subscribed` flips true.
|
|
13
|
+
- **`device-state` is a FIRST-FRAME contract.** The REST list supplies the
|
|
14
|
+
static fields; a card's light shows only a state the server actually sent.
|
|
15
|
+
- **Every user-visible string goes through `t()`** (`i18n.ts`, zh and en in the
|
|
16
|
+
same change). A token that must never render — the missing-`EventSource`
|
|
17
|
+
marker — is a constant, not a sentence, and the component translates it.
|
|
18
|
+
- **HTTP lives in `api.ts`** (same-origin `/cherry-remote/api`); a component
|
|
19
|
+
never fetches directly. Both seams are injected for tests
|
|
20
|
+
(`eventSourceFactory`, `scopeClient`), so no test stands up a server.
|
|
21
|
+
- **jsdom only.** `tests/unit/ui.test.ts` and `uiScopePanel.test.ts` render the
|
|
22
|
+
page; a browser-only API needs a fake in the test, not a guard in the page.
|
|
23
|
+
|
package/ui/src/DeviceCard.tsx
CHANGED
|
@@ -42,6 +42,22 @@ function parseHosts(raw: string): string[] {
|
|
|
42
42
|
.filter((entry) => entry !== '')
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** The dial port for one host: its own stored port, else the record-level one. */
|
|
46
|
+
function dialPort(device: DeviceSummary, host: string): number {
|
|
47
|
+
return device.ports?.[host] ?? device.port
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `host:port` as one readable address.
|
|
52
|
+
*
|
|
53
|
+
* An IPv6 literal contains colons, so `fe80::1:4343` reads as an ambiguous
|
|
54
|
+
* host; stored hosts are bare (`normalizeHost` strips the brackets), so the
|
|
55
|
+
* brackets are added here, for display only.
|
|
56
|
+
*/
|
|
57
|
+
function formatAddress(host: string, port: number): string {
|
|
58
|
+
return host.includes(':') ? `[${host}]:${port}` : `${host}:${port}`
|
|
59
|
+
}
|
|
60
|
+
|
|
45
61
|
export function DeviceCard({ device, state, onSaved, onRemoved }: DeviceCardProps): ReturnType<typeof Object> {
|
|
46
62
|
const t = useT()
|
|
47
63
|
const [editor, setEditor] = useState<Editor>('none')
|
|
@@ -141,6 +157,13 @@ export function DeviceCard({ device, state, onSaved, onRemoved }: DeviceCardProp
|
|
|
141
157
|
})
|
|
142
158
|
}
|
|
143
159
|
|
|
160
|
+
// The record-level `port` alone would claim every host dials it, which is
|
|
161
|
+
// false for a record whose map disagrees. `mixedPorts` decides whether the
|
|
162
|
+
// card has anything to say beyond that single number; `addressList` is the
|
|
163
|
+
// per-host truth (map value, record port as the fallback).
|
|
164
|
+
const mixedPorts = device.hosts.some((host) => dialPort(device, host) !== device.port)
|
|
165
|
+
const addressList = device.hosts.map((host) => formatAddress(host, dialPort(device, host))).join(', ')
|
|
166
|
+
|
|
144
167
|
// `?? state` keeps an unknown future state transparent — the enum is the only
|
|
145
168
|
// honest thing left to show for one this build has no copy for — while every
|
|
146
169
|
// state it DOES know renders through the dictionary.
|
|
@@ -183,10 +206,18 @@ export function DeviceCard({ device, state, onSaved, onRemoved }: DeviceCardProp
|
|
|
183
206
|
<dd>{device.hosts.join(', ')}</dd>
|
|
184
207
|
<dt>{t('device.field.port')}</dt>
|
|
185
208
|
<dd>{device.port}</dd>
|
|
209
|
+
{mixedPorts && (
|
|
210
|
+
<>
|
|
211
|
+
<dt>{t('device.field.ports')}</dt>
|
|
212
|
+
<dd className="cherry-mono">{addressList}</dd>
|
|
213
|
+
</>
|
|
214
|
+
)}
|
|
186
215
|
<dt>{t('device.field.id')}</dt>
|
|
187
216
|
<dd className="cherry-mono">{device.deviceId}</dd>
|
|
188
217
|
</dl>
|
|
189
218
|
|
|
219
|
+
{mixedPorts && <p className="cherry-hint">{t('device.ports.hint')}</p>}
|
|
220
|
+
|
|
190
221
|
{editor === 'rename' && (
|
|
191
222
|
<form className="cherry-inline-form" onSubmit={saveName}>
|
|
192
223
|
<label htmlFor={`name-${device.deviceId}`}>{t('device.field.name')}</label>
|
|
@@ -210,6 +241,9 @@ export function DeviceCard({ device, state, onSaved, onRemoved }: DeviceCardProp
|
|
|
210
241
|
onChange={(event: ChangeEvent<HTMLInputElement>) => setHostsText(event.target.value)}
|
|
211
242
|
/>
|
|
212
243
|
<span className="cherry-hint">{t('device.hosts.hint')}</span>
|
|
244
|
+
<span className="cherry-hint">
|
|
245
|
+
{t('device.ports.current')} <span className="cherry-mono">{addressList}</span>
|
|
246
|
+
</span>
|
|
213
247
|
<label htmlFor={`port-${device.deviceId}`}>{t('device.field.port')}</label>
|
|
214
248
|
<input
|
|
215
249
|
id={`port-${device.deviceId}`}
|
|
@@ -217,6 +251,7 @@ export function DeviceCard({ device, state, onSaved, onRemoved }: DeviceCardProp
|
|
|
217
251
|
value={portText}
|
|
218
252
|
onChange={(event: ChangeEvent<HTMLInputElement>) => setPortText(event.target.value)}
|
|
219
253
|
/>
|
|
254
|
+
<span className="cherry-hint">{t('device.port.hint')}</span>
|
|
220
255
|
<button type="submit" disabled={busy}>
|
|
221
256
|
{t('device.save.addresses')}
|
|
222
257
|
</button>
|
package/ui/src/i18n.ts
CHANGED
|
@@ -86,6 +86,10 @@ const zh: Dict = {
|
|
|
86
86
|
'device.field.name': '设备名称',
|
|
87
87
|
'device.field.hosts': '主机地址',
|
|
88
88
|
'device.hosts.hint': '多个候选地址用逗号分隔(整体替换)',
|
|
89
|
+
'device.field.ports': '每地址端口',
|
|
90
|
+
'device.ports.hint': '部分候选地址的端口与上方的端口不同;在「地址维护」里改动端口会把所有候选地址改为同一端口。',
|
|
91
|
+
'device.ports.current': '当前端口映射:',
|
|
92
|
+
'device.port.hint': '保持当前值会保留各地址自己的端口;改成新值会把所有候选地址重指到该端口。',
|
|
89
93
|
'device.save.name': '保存名称',
|
|
90
94
|
'device.save.addresses': '保存地址',
|
|
91
95
|
'device.remove.confirm': '确认移除',
|
|
@@ -236,6 +240,12 @@ const en: Dict = {
|
|
|
236
240
|
'device.field.name': 'Device name',
|
|
237
241
|
'device.field.hosts': 'Host addresses',
|
|
238
242
|
'device.hosts.hint': 'Separate candidate addresses with commas (the whole list is replaced)',
|
|
243
|
+
'device.field.ports': 'Ports per address',
|
|
244
|
+
'device.ports.hint':
|
|
245
|
+
'Some candidate addresses dial a different port than the one above; changing the port in address maintenance moves every candidate address to that one port.',
|
|
246
|
+
'device.ports.current': 'Current port map:',
|
|
247
|
+
'device.port.hint':
|
|
248
|
+
'Keeping the current value preserves each address’s own port; a new value re-points every candidate address.',
|
|
239
249
|
'device.save.name': 'Save name',
|
|
240
250
|
'device.save.addresses': 'Save addresses',
|
|
241
251
|
'device.remove.confirm': 'Confirm removal',
|
package/ui/src/types.ts
CHANGED
|
@@ -23,7 +23,14 @@ export const API_BASE = '/cherry-remote/api'
|
|
|
23
23
|
/** The five device states the connection layer reports. */
|
|
24
24
|
export type DeviceState = 'connected' | 'connecting' | 'offline' | 'disabled' | 'identity-mismatch'
|
|
25
25
|
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* One device exactly as `GET /devices` returns it. Never carries a credential.
|
|
28
|
+
*
|
|
29
|
+
* `ports` is the per-HOST dial port map: each candidate address dials its own
|
|
30
|
+
* port, and the record-level `port` is the fallback for a host the map does not
|
|
31
|
+
* carry (a stored v4 record has an entry for every host, so the card's `??` arm
|
|
32
|
+
* only fires on a malformed map). Required — `GET /devices` always emits it.
|
|
33
|
+
*/
|
|
27
34
|
export interface DeviceSummary {
|
|
28
35
|
deviceId: string
|
|
29
36
|
displayName: string
|
|
@@ -31,6 +38,7 @@ export interface DeviceSummary {
|
|
|
31
38
|
enabled: boolean
|
|
32
39
|
hosts: string[]
|
|
33
40
|
port: number
|
|
41
|
+
ports: Record<string, number>
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
/** A `PATCH /devices/:id` body; every field optional, `hosts` is whole-replace. */
|
package/src/urlHost.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Renders a host as a URL authority component. The device archive and pairing
|
|
3
|
-
* flow store BARE hosts (plan-mandated normalization), so a bare IPv6 literal
|
|
4
|
-
* must be bracketed before it is spliced into a URL — `ws://::1:35543/...`
|
|
5
|
-
* is not a valid URL and `new WebSocket(...)` throws synchronously on it.
|
|
6
|
-
*
|
|
7
|
-
* Shared home for the dial sites: both `connection.ts` (per-device dial) and
|
|
8
|
-
* `pairing.ts` (candidate dial) splice hosts into URLs and must bracket the
|
|
9
|
-
* same way.
|
|
10
|
-
*/
|
|
11
|
-
export function urlHost(host: string): string {
|
|
12
|
-
// A colon can only mean IPv6 here: a `host:port` pair never reaches this
|
|
13
|
-
// layer (the port travels separately).
|
|
14
|
-
return host.includes(':') ? `[${host}]` : host
|
|
15
|
-
}
|