dsh-cherry-bridge 0.2.0 → 0.2.2
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 +7 -7
- package/README.md +6 -5
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +808 -47
- package/docs/e2e-checklist.md +72 -1
- package/lib/client.js +264 -31
- package/package.json +1 -1
- package/src/AGENTS.md +106 -22
- package/src/connection.ts +187 -2
- package/src/deviceStore.ts +314 -1
- package/src/diagnostics.ts +6 -5
- package/src/httpApi.ts +234 -27
- package/src/index.ts +18 -0
- package/src/subscriptions.ts +201 -5
- package/src/tools.ts +158 -14
- package/ui/AGENTS.md +21 -0
- package/ui/src/App.tsx +26 -10
- package/ui/src/DeviceCard.tsx +106 -4
- package/ui/src/PairingWizard.tsx +80 -5
- package/ui/src/ToolScopePanel.tsx +19 -6
- package/ui/src/api.ts +111 -17
- package/ui/src/i18n.ts +39 -0
- package/ui/src/styles.css +24 -0
- package/ui/src/types.ts +7 -0
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(
|
|
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
|
-
{
|
|
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">
|
package/ui/src/DeviceCard.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
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(
|
|
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>
|
|
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' && (
|
package/ui/src/PairingWizard.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
{
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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' })
|