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.
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
+
@@ -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
- /** One device exactly as `GET /devices` returns it. Never carries a credential. */
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
- }