dsh-cherry-bridge 0.2.0 → 0.2.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/ui/src/App.tsx CHANGED
@@ -20,8 +20,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
20
20
  import { DeviceCard } from './DeviceCard'
21
21
  import { PairingWizard } from './PairingWizard'
22
22
  import { ToolScopePanel } from './ToolScopePanel'
23
- import { listDevices } from './api'
23
+ import { REQUEST_TIMEOUT, listDevices } from './api'
24
24
  import { useT } from './i18n'
25
+ import type { T } from './i18n'
25
26
  import { API_BASE } from './types'
26
27
  import type {
27
28
  DeviceState,
@@ -40,6 +41,8 @@ export interface AppProps {
40
41
  eventSourceFactory?: EventSourceFactory
41
42
  /** Transient verification-code lifetime, forwarded to the wizard. */
42
43
  codeLifetimeMs?: number
44
+ /** Run deadline (the exit for a lost terminal frame), forwarded to the wizard. */
45
+ runLifetimeMs?: number
43
46
  /**
44
47
  * The scope panel's HTTP seam. Defaults to the real same-origin client;
45
48
  * injected by tests in the same style as `eventSourceFactory`, so no test has
@@ -69,6 +72,19 @@ const defaultEventSourceFactory: EventSourceFactory = (url) => {
69
72
  */
70
73
  const EVENT_SOURCE_UNAVAILABLE = 'event-source-unavailable'
71
74
 
75
+ /**
76
+ * One description for a cause, shared by the two catches below so they cannot
77
+ * drift. Two markers get the dictionary's own sentence — the missing
78
+ * `EventSource` and a request that never answered (`api.ts`'s ceiling) — because
79
+ * both are INTERNAL tokens that must never render; everything else is the
80
+ * foreign message, interpolated verbatim as data.
81
+ */
82
+ function describeCause(t: T, cause: unknown): string {
83
+ if (cause instanceof Error && cause.message === EVENT_SOURCE_UNAVAILABLE) return t('app.eventSourceUnavailable')
84
+ if (cause instanceof Error && cause.message === REQUEST_TIMEOUT) return t('app.error.timeout')
85
+ return cause instanceof Error ? cause.message : String(cause)
86
+ }
87
+
72
88
  /** Parses one SSE frame payload, ignoring a malformed one rather than crashing. */
73
89
  function parseFrame<T>(data: string): T | undefined {
74
90
  try {
@@ -81,6 +97,7 @@ function parseFrame<T>(data: string): T | undefined {
81
97
  export function App({
82
98
  eventSourceFactory = defaultEventSourceFactory,
83
99
  codeLifetimeMs,
100
+ runLifetimeMs,
84
101
  scopeClient,
85
102
  }: AppProps): ReturnType<typeof Object> {
86
103
  const t = useT()
@@ -101,13 +118,7 @@ export function App({
101
118
  // The opener's own token means "no EventSource here", which has a
102
119
  // sentence of its own in both dictionaries; anything else is a foreign
103
120
  // message and is interpolated verbatim (data, not copy).
104
- setError(
105
- cause instanceof Error && cause.message === EVENT_SOURCE_UNAVAILABLE
106
- ? t('app.eventSourceUnavailable')
107
- : cause instanceof Error
108
- ? cause.message
109
- : String(cause),
110
- )
121
+ setError(describeCause(t, cause))
111
122
  return undefined
112
123
  }
113
124
 
@@ -142,7 +153,7 @@ export function App({
142
153
  setError(undefined)
143
154
  },
144
155
  (cause: unknown) => {
145
- setError(cause instanceof Error ? cause.message : String(cause))
156
+ setError(describeCause(t, cause))
146
157
  setListed(true)
147
158
  },
148
159
  )
@@ -211,6 +222,7 @@ export function App({
211
222
  subscribed={subscribed}
212
223
  progress={progress}
213
224
  {...(codeLifetimeMs === undefined ? {} : { codeLifetimeMs })}
225
+ {...(runLifetimeMs === undefined ? {} : { runLifetimeMs })}
214
226
  onRunStart={startRun}
215
227
  onSettled={onSettled}
216
228
  />
@@ -221,7 +233,11 @@ export function App({
221
233
 
222
234
  <section className="cherry-panel" aria-labelledby="cherry-devices-title">
223
235
  <h3 id="cherry-devices-title">{t('app.devices.heading')}</h3>
224
- {sorted.length === 0 && (
236
+ {/* Three states, not two: the list is EMPTY only once a read said so.
237
+ After a failed read it is UNKNOWN, and claiming 「暂无已配对设备」 beside
238
+ the error that says the read failed would contradict that error — so the
239
+ error line speaks alone. */}
240
+ {sorted.length === 0 && error === undefined && (
225
241
  <p className="cherry-hint">{listed ? t('app.devices.empty') : t('app.devices.loading')}</p>
226
242
  )}
227
243
  <ul className="cherry-cards">
@@ -10,10 +10,20 @@
10
10
  * `onSaved`, so the card shows what the archive holds rather than what the
11
11
  * browser hoped for. The live `state` keeps coming from SSE; the patch response
12
12
  * only seeds it.
13
+ *
14
+ * The addresses row is also where an address is FORGOTTEN (issue #204), and the
15
+ * marks it leaves are listed under their own label with a 恢复 each. One click
16
+ * is one committed action, never a draft: the two controls are INERT while the
17
+ * address editor is open, because that form PATCHes the whole host list and its
18
+ * draft is unconditionally sacred (see the resync effect) — a mark chosen
19
+ * underneath it would be re-sent by the next 保存地址, and the PATCH that names a
20
+ * forgotten host clears its mark in the same write. Freezing the controls is
21
+ * what keeps "commit or discard the edit first" true; it is not a stylistic
22
+ * preference.
13
23
  */
14
24
  import { useEffect, useState } from 'react'
15
25
  import type { ChangeEvent, FormEvent } from 'react'
16
- import { patchDevice, removeDevice } from './api'
26
+ import { ApiError, REQUEST_TIMEOUT, forgetHosts, patchDevice, rememberHosts, removeDevice } from './api'
17
27
  import { useT } from './i18n'
18
28
  import { STATE_KEYS } from './types'
19
29
  import type { DeviceState, DeviceSummary } from './types'
@@ -98,19 +108,63 @@ export function DeviceCard({ device, state, onSaved, onRemoved }: DeviceCardProp
98
108
  setPortText(String(device.port))
99
109
  }, [device.displayName, device.hosts, device.port, editor])
100
110
 
101
- /** Runs one mutation, mapping a refusal into the card's error line. */
111
+ /**
112
+ * Runs one mutation, mapping a refusal into the card's error line.
113
+ *
114
+ * A request that never answered arrives as `api.ts`'s internal marker — the
115
+ * page-wide ceiling (issue #180) — and a marker must never render. Every card
116
+ * action is a bounded call (停用 / 重命名 / 移除 are PATCH or DELETE), so this
117
+ * surface translates it like the other three do instead of echoing
118
+ * `cherry-remote.request-timeout` onto the page.
119
+ */
102
120
  const run = async (action: () => Promise<void>): Promise<void> => {
103
121
  setBusy(true)
104
122
  setError(undefined)
105
123
  try {
106
124
  await action()
107
125
  } catch (cause) {
108
- setError(cause instanceof Error ? cause.message : String(cause))
126
+ setError(describeCause(cause))
109
127
  } finally {
110
128
  setBusy(false)
111
129
  }
112
130
  }
113
131
 
132
+ /**
133
+ * One refusal for a cause, so the two markers and the one translated code
134
+ * cannot drift between this card's arms.
135
+ *
136
+ * `ApiError` renders as `<code>: <server message>`, which is right for a
137
+ * diagnostic and wrong for `last-address`: forgetting the only candidate is a
138
+ * thing the operator can fix here, and the server's own prose is English on a
139
+ * Chinese page. Everything else keeps the server's verdict verbatim — that is
140
+ * what tells `invalid-payload` from `unreachable`.
141
+ */
142
+ const describeCause = (cause: unknown): string => {
143
+ if (cause instanceof ApiError && cause.code === 'last-address') return t('device.error.lastAddress')
144
+ if (cause instanceof Error && cause.message === REQUEST_TIMEOUT) return t('device.error.timeout')
145
+ return cause instanceof Error ? cause.message : String(cause)
146
+ }
147
+
148
+ /** Forgets ONE address: the store drops it and records the mark in one write. */
149
+ const forget = (host: string): void => {
150
+ void run(async () => {
151
+ onSaved(await forgetHosts(device.deviceId, [host]))
152
+ })
153
+ }
154
+
155
+ /**
156
+ * Lifts ONE mark. The server also drops the pooled connection on a change, so
157
+ * the very handshake that follows re-learns the address if the desktop still
158
+ * advertises it — without that, a desktop that stays connected would not
159
+ * re-handshake for as long as the link holds, and this button would look
160
+ * inert.
161
+ */
162
+ const remember = (host: string): void => {
163
+ void run(async () => {
164
+ onSaved(await rememberHosts(device.deviceId, [host]))
165
+ })
166
+ }
167
+
114
168
  const toggleEnabled = (): void => {
115
169
  const enabled = !device.enabled
116
170
  void run(async () => {
@@ -157,6 +211,14 @@ export function DeviceCard({ device, state, onSaved, onRemoved }: DeviceCardProp
157
211
  })
158
212
  }
159
213
 
214
+ /**
215
+ * A defensive empty list, the same choice `dialPort` makes for the required
216
+ * `ports` map: `GET /devices` always emits the key, but this card is one row
217
+ * in a list, and a record missing one field must not take the whole page down
218
+ * with a render throw — it shows a card without the mark instead.
219
+ */
220
+ const forgottenHosts = device.forgottenHosts ?? []
221
+
160
222
  // The record-level `port` alone would claim every host dials it, which is
161
223
  // false for a record whose map disagrees. `mixedPorts` decides whether the
162
224
  // card has anything to say beyond that single number; `addressList` is the
@@ -203,7 +265,45 @@ export function DeviceCard({ device, state, onSaved, onRemoved }: DeviceCardProp
203
265
 
204
266
  <dl className="cherry-card-facts">
205
267
  <dt>{t('device.field.addresses')}</dt>
206
- <dd>{device.hosts.join(', ')}</dd>
268
+ <dd>
269
+ <ul className="cherry-address-list">
270
+ {device.hosts.map((host) => (
271
+ <li key={host}>
272
+ <span>{host}</span>
273
+ <button
274
+ type="button"
275
+ onClick={() => forget(host)}
276
+ disabled={busy || editor === 'address'}
277
+ >
278
+ {t('device.forget')}
279
+ </button>
280
+ </li>
281
+ ))}
282
+ </ul>
283
+ </dd>
284
+ {/* Only when the archive actually holds a mark: an empty row on every
285
+ card would be noise, and absence is the honest "none". */}
286
+ {forgottenHosts.length > 0 && (
287
+ <>
288
+ <dt>{t('device.field.forgotten')}</dt>
289
+ <dd>
290
+ <ul className="cherry-address-list">
291
+ {forgottenHosts.map((host) => (
292
+ <li key={host}>
293
+ <span>{host}</span>
294
+ <button
295
+ type="button"
296
+ onClick={() => remember(host)}
297
+ disabled={busy || editor === 'address'}
298
+ >
299
+ {t('device.remember')}
300
+ </button>
301
+ </li>
302
+ ))}
303
+ </ul>
304
+ </dd>
305
+ </>
306
+ )}
207
307
  <dt>{t('device.field.port')}</dt>
208
308
  <dd>{device.port}</dd>
209
309
  {mixedPorts && (
@@ -216,6 +316,8 @@ export function DeviceCard({ device, state, onSaved, onRemoved }: DeviceCardProp
216
316
  <dd className="cherry-mono">{device.deviceId}</dd>
217
317
  </dl>
218
318
 
319
+ {forgottenHosts.length > 0 && <p className="cherry-hint">{t('device.forgotten.hint')}</p>}
320
+
219
321
  {mixedPorts && <p className="cherry-hint">{t('device.ports.hint')}</p>}
220
322
 
221
323
  {editor === 'rename' && (
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { useEffect, useState } from 'react'
18
18
  import type { ChangeEvent, FormEvent } from 'react'
19
- import { startPairing } from './api'
19
+ import { REQUEST_TIMEOUT, startPairing } from './api'
20
20
  import { useT } from './i18n'
21
21
  import { APPROVED_STAGE, STAGE_KEYS, TERMINAL_STAGES, VERIFICATION_CODE_STAGE } from './types'
22
22
  import type { PairingPhase, PairingProgressEvent } from './types'
@@ -24,6 +24,28 @@ import type { PairingPhase, PairingProgressEvent } from './types'
24
24
  /** The countdown the transient code display gets, in seconds. */
25
25
  export const DEFAULT_CODE_LIFETIME_MS = 120_000
26
26
 
27
+ /**
28
+ * How long one run may sit in `waiting` before the wizard gives the form back.
29
+ *
30
+ * The server publishes a terminal stage on EVERY outcome (`src/httpApi.ts`
31
+ * `startPairing`), but the event stream carries no replay, so a single dropped
32
+ * frame loses the verdict for good — and nothing else clears `busy`: only a
33
+ * terminal frame or a refused POST does, and there is no cancel control. Without
34
+ * this bound a lost frame parks the wizard on 「配对进行中」 until a page reload.
35
+ *
36
+ * The bound must be comfortably past a REALISTIC live run: `src/pairing.ts`
37
+ * bounds the candidate walk to 30 s (`dialBudgetMs`) and the claim/poll phase to
38
+ * a FRESH 120 s anchored at the claim, so 150 s plus 30 s of margin.
39
+ *
40
+ * It is NOT past a pathological one, and it says so rather than pretending: two
41
+ * round trips inside that walk (`hello`, then the claim) are bounded only by
42
+ * JsonRpcClient's 60 s default request timeout, so a peer that stalls each just
43
+ * under it can keep a run legitimately working for ~270 s. A premature alarm is
44
+ * acceptable because it is not a verdict — it points at the device list, and a
45
+ * terminal frame arriving later replaces it (see `claimId` below).
46
+ */
47
+ export const DEFAULT_RUN_LIFETIME_MS = 180_000
48
+
27
49
  export interface PairingWizardProps {
28
50
  /** True while a stream is open; submitting with no stream would lose the code. */
29
51
  subscribed: boolean
@@ -31,6 +53,8 @@ export interface PairingWizardProps {
31
53
  progress: PairingProgressEvent | undefined
32
54
  /** How long the verification code stays on screen. Injectable for tests. */
33
55
  codeLifetimeMs?: number
56
+ /** How long a run may wait for its terminal frame. Injectable for tests. */
57
+ runLifetimeMs?: number
34
58
  /**
35
59
  * Called when a NEW run is submitted, before `POST /pair`: the app retires the
36
60
  * previous run's frame here, so the settled render never has to compete with a
@@ -45,6 +69,7 @@ export function PairingWizard({
45
69
  subscribed,
46
70
  progress,
47
71
  codeLifetimeMs = DEFAULT_CODE_LIFETIME_MS,
72
+ runLifetimeMs = DEFAULT_RUN_LIFETIME_MS,
48
73
  onRunStart,
49
74
  onSettled,
50
75
  }: PairingWizardProps): ReturnType<typeof Object> {
@@ -66,6 +91,11 @@ export function PairingWizard({
66
91
  // unreachable. The verdict must be an INPUT to the settled render rather than
67
92
  // derived from a frame that is being cleared underneath it.
68
93
  const [verdict, setVerdict] = useState<string | undefined>(undefined)
94
+ // Set ONLY by the run deadline: `phase === 'failed'` with no stage behind it.
95
+ // Its own flag rather than an empty verdict, because the settled paragraph must
96
+ // NOT read as a verdict — the run may have been approved while its frame was
97
+ // lost, and the device list right below would contradict a "配对失败".
98
+ const [timedOut, setTimedOut] = useState(false)
69
99
 
70
100
  const stage = progress?.stage
71
101
  const frameClaimId = progress?.claimId
@@ -94,6 +124,10 @@ export function PairingWizard({
94
124
  // Terminal frame: the code is a transient display and goes away with it.
95
125
  if ((TERMINAL_STAGES as readonly string[]).includes(stage)) {
96
126
  setCode(undefined)
127
+ // A LATE frame outranks the deadline: `POST /pair` is fire-and-forget, so a
128
+ // frame can still arrive after the run deadline and the stage it carries is
129
+ // the truth — retiring the flag here is what lets that copy win the render.
130
+ setTimedOut(false)
97
131
  // Capture FIRST: `onSettled` is the app's chance to clear the frame, and
98
132
  // everything below must already be independent of it.
99
133
  setVerdict(stage)
@@ -111,6 +145,28 @@ export function PairingWizard({
111
145
  return () => clearTimeout(timer)
112
146
  }, [code, codeLifetimeMs])
113
147
 
148
+ useEffect(() => {
149
+ if (phase !== 'waiting') return undefined
150
+ // The countdown above bounds the CODE DISPLAY; this bounds the RUN. Same
151
+ // failure mode, different casualty: the code goes away on time, but `busy`
152
+ // would stay true forever and leave the submit button disabled with no
153
+ // cancel control. Everything the terminal branch does to the form is done
154
+ // here too, minus the verdict — and `claimId` is deliberately KEPT, so a
155
+ // terminal frame that arrives after the deadline still replaces this message
156
+ // with the truth instead of being filtered out as a stranger's.
157
+ const timer = setTimeout(() => {
158
+ setCode(undefined)
159
+ setTimedOut(true)
160
+ setPhase('failed')
161
+ setBusy(false)
162
+ // The same callback a terminal frame runs: it re-reads the device list,
163
+ // which is what makes "look below" honest — an approved pairing whose
164
+ // frame was lost shows up there.
165
+ onSettled?.()
166
+ }, runLifetimeMs)
167
+ return () => clearTimeout(timer)
168
+ }, [phase, runLifetimeMs, onSettled])
169
+
114
170
  const submit = (event: FormEvent): void => {
115
171
  event.preventDefault()
116
172
  const trimmed = payload.trim()
@@ -126,6 +182,7 @@ export function PairingWizard({
126
182
  // when the frame arrives. Clearing it at start (rather than relying on the
127
183
  // frame going away) keeps the settled paragraph correct for exactly one run.
128
184
  setVerdict(undefined)
185
+ setTimedOut(false)
129
186
  // ...and the APP retires the previous run's frame here rather than on settle,
130
187
  // so a batched terminal commit cannot take the label away with it.
131
188
  onRunStart?.()
@@ -137,7 +194,20 @@ export function PairingWizard({
137
194
  (cause: unknown) => {
138
195
  // A refusal leaves the wizard open with the payload intact: the user
139
196
  // fixes the paste rather than starting over.
140
- setError(cause instanceof Error ? cause.message : String(cause))
197
+ //
198
+ // A HUNG request is not a refusal the user can read: its marker is an
199
+ // internal token, so it becomes the dictionary's own sentence (the same
200
+ // split as `app.eventSourceUnavailable`). Everything else is the
201
+ // server's or the transport's message, interpolated verbatim. The
202
+ // ceiling lives in `api.ts` `request()`, so the token is shared with the
203
+ // other surfaces; only this catch can see it for the pairing POST.
204
+ setError(
205
+ cause instanceof Error && cause.message === REQUEST_TIMEOUT
206
+ ? t('wizard.error.timeout')
207
+ : cause instanceof Error
208
+ ? cause.message
209
+ : String(cause),
210
+ )
141
211
  setPhase('idle')
142
212
  setBusy(false)
143
213
  },
@@ -206,9 +276,14 @@ export function PairingWizard({
206
276
  paragraph renders (see the `verdict` state above). */}
207
277
  {phase === 'failed' && (
208
278
  <p className="cherry-error" role="alert">
209
- {t('wizard.failed', {
210
- stage: STAGE_KEYS[verdict ?? ''] === undefined ? t('wizard.unknownError') : t(STAGE_KEYS[verdict ?? '']!),
211
- })}
279
+ {timedOut
280
+ ? t('wizard.timedOut')
281
+ : t('wizard.failed', {
282
+ stage:
283
+ STAGE_KEYS[verdict ?? ''] === undefined
284
+ ? t('wizard.unknownError')
285
+ : t(STAGE_KEYS[verdict ?? '']!),
286
+ })}
212
287
  </p>
213
288
  )}
214
289
  {error !== undefined && (
@@ -29,7 +29,7 @@
29
29
  * no tools" that changes nothing and triggers no write.
30
30
  */
31
31
  import { useCallback, useEffect, useMemo, useState } from 'react'
32
- import { ApiError, defaultScopeClient } from './api'
32
+ import { ApiError, REQUEST_TIMEOUT, defaultScopeClient } from './api'
33
33
  import { useT } from './i18n'
34
34
  import type { T } from './i18n'
35
35
  import type {
@@ -148,7 +148,20 @@ interface SaveFailure {
148
148
  detail?: string
149
149
  }
150
150
 
151
- const describeCause = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause))
151
+ /**
152
+ * The panel's own description of a failure.
153
+ *
154
+ * A request that never answered arrives as `api.ts`'s internal marker (the
155
+ * page-wide ceiling, issue #180), which must become a sentence before it reaches
156
+ * any of the three places this feeds — the read error, the save failure and the
157
+ * re-read error. Everything else is the server's own message, interpolated.
158
+ */
159
+ const describeCause = (t: T, cause: unknown): string =>
160
+ cause instanceof Error && cause.message === REQUEST_TIMEOUT
161
+ ? t('scope.error.timeout')
162
+ : cause instanceof Error
163
+ ? cause.message
164
+ : String(cause)
152
165
 
153
166
  /**
154
167
  * D11's empty state for the current draft, or `undefined` when nothing may be
@@ -227,7 +240,7 @@ export function ToolScopePanel({ client = defaultScopeClient }: ToolScopePanelPr
227
240
  seedFromDocument(response)
228
241
  },
229
242
  (cause: unknown) => {
230
- if (live) setLoadError(describeCause(cause))
243
+ if (live) setLoadError(describeCause(t, cause))
231
244
  },
232
245
  )
233
246
  return () => {
@@ -310,8 +323,8 @@ export function ToolScopePanel({ client = defaultScopeClient }: ToolScopePanelPr
310
323
  // An unknown code therefore falls to `SAVE_FAILED_KEY`'s template with
311
324
  // `{cause}` filled, not to a bare `保存失败:`.
312
325
  label: code === undefined
313
- ? t(SAVE_FAILED_KEY, { cause: describeCause(cause) })
314
- : t(SAVE_FAILURE_KEYS[code] ?? SAVE_FAILED_KEY, { cause: describeCause(cause) }),
326
+ ? t(SAVE_FAILED_KEY, { cause: describeCause(t, cause) })
327
+ : t(SAVE_FAILURE_KEYS[code] ?? SAVE_FAILED_KEY, { cause: describeCause(t, cause) }),
315
328
  detail: status === undefined ? undefined : t('scope.serverSaid', { code: code!, status }),
316
329
  })
317
330
  },
@@ -344,7 +357,7 @@ export function ToolScopePanel({ client = defaultScopeClient }: ToolScopePanelPr
344
357
  },
345
358
  (cause: unknown) => {
346
359
  setBusy(false)
347
- setLoadError(describeCause(cause))
360
+ setLoadError(describeCause(t, cause))
348
361
  },
349
362
  )
350
363
  }, [client, seedFromDocument])
package/ui/src/api.ts CHANGED
@@ -34,6 +34,35 @@ export class ApiError extends Error {
34
34
  }
35
35
  }
36
36
 
37
+ /**
38
+ * The marker a request that never answered throws with.
39
+ *
40
+ * Internal token, NEVER copy: each surface renders its own sentence for it —
41
+ * `app.error.timeout`, `wizard.error.timeout`, `scope.error.timeout` — and this
42
+ * string only travels from `request()` to the catch block that recognizes it, the
43
+ * same split as `app.eventSourceUnavailable` (see `App.tsx`). That is what keeps
44
+ * an English literal off a Chinese page.
45
+ */
46
+ export const REQUEST_TIMEOUT = 'cherry-remote.request-timeout'
47
+
48
+ /**
49
+ * How long ANY call on this page may stay open, in milliseconds.
50
+ *
51
+ * None of these calls is slow: they are same-origin requests to the plugin's own
52
+ * HTTP face, and the one that looks slowest — a pairing START — does no I/O
53
+ * before it answers (`src/httpApi.ts` `startPairing` writes its 202 at once).
54
+ * A request still open after this long is a WEDGED transport, not a busy server,
55
+ * and it must not be allowed to strand a control: without a ceiling the pairing
56
+ * wizard's submit button (issue #173), a device card's buttons, the scope panel's
57
+ * save and the device list's "loading" state each wait for the life of the page
58
+ * (issue #180).
59
+ *
60
+ * So the ceiling lives in `request()`, where every call passes, rather than on
61
+ * one call site. Each surface turns the {@link REQUEST_TIMEOUT} marker into its
62
+ * own sentence.
63
+ */
64
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 10_000
65
+
37
66
  /** Reads a JSON body without throwing on an empty (204) or non-JSON response. */
38
67
  async function readBody(response: Response): Promise<unknown> {
39
68
  const text = await response.text()
@@ -45,19 +74,41 @@ async function readBody(response: Response): Promise<unknown> {
45
74
  }
46
75
  }
47
76
 
48
- async function request(url: string, init?: RequestInit): Promise<unknown> {
49
- const response = await fetch(url, {
50
- ...init,
51
- headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) },
52
- })
53
- const body = await readBody(response)
54
- if (!response.ok) {
55
- const record = (body ?? {}) as { error?: unknown; message?: unknown }
56
- const code = typeof record.error === 'string' ? record.error : `http-${response.status}`
57
- const message = typeof record.message === 'string' ? record.message : code
58
- throw new ApiError(response.status, code, message)
77
+ /**
78
+ * One bounded request. `timeoutMs` exists so a test can prove the ceiling
79
+ * without waiting for it; production always takes {@link DEFAULT_REQUEST_TIMEOUT_MS}.
80
+ */
81
+ async function request(
82
+ url: string,
83
+ init?: RequestInit,
84
+ timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
85
+ ): Promise<unknown> {
86
+ const controller = new AbortController()
87
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
88
+ try {
89
+ const response = await fetch(url, {
90
+ ...init,
91
+ headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) },
92
+ // We own this signal: no caller supplies one. An abort tears the body read
93
+ // down with the transport, which is why it sits inside the same `try`.
94
+ signal: controller.signal,
95
+ })
96
+ const body = await readBody(response)
97
+ if (!response.ok) {
98
+ const record = (body ?? {}) as { error?: unknown; message?: unknown }
99
+ const code = typeof record.error === 'string' ? record.error : `http-${response.status}`
100
+ const message = typeof record.message === 'string' ? record.message : code
101
+ throw new ApiError(response.status, code, message)
102
+ }
103
+ return body
104
+ } catch (cause) {
105
+ // An abort THIS function caused. A refusal arrives with the signal still
106
+ // clear and keeps its `ApiError` code, so nothing is masked as a timeout.
107
+ if (controller.signal.aborted) throw new Error(REQUEST_TIMEOUT)
108
+ throw cause
109
+ } finally {
110
+ clearTimeout(timer)
59
111
  }
60
- return body
61
112
  }
62
113
 
63
114
  /** `GET /devices` — the full card list, states projected server-side. */
@@ -72,12 +123,22 @@ export async function listDevices(): Promise<DeviceSummary[]> {
72
123
  * It deliberately does NOT wait for approval: the 6-digit code and the verdict
73
124
  * arrive as `pairing-progress` SSE frames, which is why the caller must already
74
125
  * be subscribed when this resolves.
126
+ *
127
+ * Bounded like every other call (see {@link DEFAULT_REQUEST_TIMEOUT_MS});
128
+ * `timeoutMs` exists only so a test can prove the ceiling without waiting.
75
129
  */
76
- export async function startPairing(payload: string, name?: string): Promise<{ claimId?: string }> {
77
- const body = await request(`${API_BASE}/pair`, {
78
- method: 'POST',
79
- body: JSON.stringify(name === undefined || name === '' ? { payload } : { payload, name }),
80
- })
130
+ export async function startPairing(
131
+ payload: string,
132
+ options: { name?: string; timeoutMs?: number } = {},
133
+ ): Promise<{ claimId?: string }> {
134
+ const { name } = options
135
+ // `undefined` deliberately falls through to `request`'s own default, so the
136
+ // production ceiling is exercised by the same parameter every other call uses.
137
+ const body = await request(
138
+ `${API_BASE}/pair`,
139
+ { method: 'POST', body: JSON.stringify(name === undefined || name === '' ? { payload } : { payload, name }) },
140
+ options.timeoutMs,
141
+ )
81
142
  return (body ?? {}) as { claimId?: string }
82
143
  }
83
144
 
@@ -90,6 +151,39 @@ export async function patchDevice(deviceId: string, patch: DevicePatch): Promise
90
151
  return body as DeviceSummary
91
152
  }
92
153
 
154
+ /**
155
+ * `POST /devices/:id/forget` — stop dialing these addresses AND record them so
156
+ * the desktop cannot advertise them back (issue #185).
157
+ *
158
+ * The answer is the SAME record shape {@link patchDevice} returns, which is why
159
+ * the card hands either body to the same `onSaved`. The action is its own
160
+ * request on purpose: folding it into the PATCH would make "replace the dial
161
+ * list" and "forget one address" one body with an ordering to define.
162
+ */
163
+ export async function forgetHosts(deviceId: string, hosts: string[]): Promise<DeviceSummary> {
164
+ const body = await request(`${API_BASE}/devices/${encodeURIComponent(deviceId)}/forget`, {
165
+ method: 'POST',
166
+ body: JSON.stringify({ hosts }),
167
+ })
168
+ return body as DeviceSummary
169
+ }
170
+
171
+ /**
172
+ * `POST /devices/:id/remember` — lift the marks.
173
+ *
174
+ * It does NOT dial the address again: the record's `hosts` is untouched, and
175
+ * the address returns when the desktop next advertises it. Putting it back into
176
+ * the dial list immediately stays the PATCH's job (that PATCH clears the mark in
177
+ * the same write).
178
+ */
179
+ export async function rememberHosts(deviceId: string, hosts: string[]): Promise<DeviceSummary> {
180
+ const body = await request(`${API_BASE}/devices/${encodeURIComponent(deviceId)}/remember`, {
181
+ method: 'POST',
182
+ body: JSON.stringify({ hosts }),
183
+ })
184
+ return body as DeviceSummary
185
+ }
186
+
93
187
  /** `DELETE /devices/:id` — 204, no body. */
94
188
  export async function removeDevice(deviceId: string): Promise<void> {
95
189
  await request(`${API_BASE}/devices/${encodeURIComponent(deviceId)}`, { method: 'DELETE' })