@krapwoo/ds-viewer 0.9.0 → 0.9.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/dist/cli/dev.js +49 -5
- package/dist/cli/workspace.js +37 -1
- package/native/catalog/UpdatePanel.tsx +507 -49
- package/native/catalog/updatePanelState.ts +173 -0
- package/package.json +1 -1
package/dist/cli/dev.js
CHANGED
|
@@ -6,7 +6,7 @@ import { execFile } from 'node:child_process';
|
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
7
|
import { resolveConfig } from "./config.js";
|
|
8
8
|
import { sync } from "./sync.js";
|
|
9
|
-
import { clearDevLock, liveDevLock, missingWorkspaceFiles, workspacePath, writeDevLock, writeWorkspace } from "./workspace.js";
|
|
9
|
+
import { clearDevLock, liveDevLock, missingWorkspaceFiles, readEndpointRecord, workspacePath, writeDevLock, writeEndpointRecord, writeWorkspace } from "./workspace.js";
|
|
10
10
|
import { findFreePort } from "./port.js";
|
|
11
11
|
import { globBaseFolder } from "./glob.js";
|
|
12
12
|
import { buildVersionStatus, createVersionController, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck, runUpdateCheck, statusFromCheck, writeUpdateFile, writeVersionFile, } from "./updateCheck.js";
|
|
@@ -362,6 +362,44 @@ export function repairWorkspaceIfMissing(deps) {
|
|
|
362
362
|
sync(config);
|
|
363
363
|
return { missing, repaired: true };
|
|
364
364
|
}
|
|
365
|
+
function listenOnce(server, port) {
|
|
366
|
+
return new Promise((resolve, reject) => {
|
|
367
|
+
const onError = (error) => {
|
|
368
|
+
server.removeListener('listening', onListening);
|
|
369
|
+
reject(error);
|
|
370
|
+
};
|
|
371
|
+
const onListening = () => {
|
|
372
|
+
server.removeListener('error', onError);
|
|
373
|
+
resolve(server.address().port);
|
|
374
|
+
};
|
|
375
|
+
server.once('error', onError);
|
|
376
|
+
server.once('listening', onListening);
|
|
377
|
+
server.listen(port, '127.0.0.1');
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
/** Resolves the update endpoint's real port: the stored record's port when it's actually free, a
|
|
381
|
+
* fresh OS-assigned one otherwise. Tries the real bind directly, once — a separate "is this free?"
|
|
382
|
+
* probe followed by a real bind would leave a window for something else to take the port in
|
|
383
|
+
* between, and could never tell "a foreign process holds it" from "nothing does" anyway, which
|
|
384
|
+
* Part 1 says this must never try to resolve: a bind failure for any reason falls back the same
|
|
385
|
+
* way, never killing, probing, or assuming ownership of whatever holds it. Mutates
|
|
386
|
+
* `secretHolder.secret` to a fresh value only on that fallback path — reusing the stored *port*
|
|
387
|
+
* without the *secret* minted for it would pair a stale secret with an address nobody recorded. */
|
|
388
|
+
export async function bindEndpointServer(server, secretHolder, storedPort) {
|
|
389
|
+
if (storedPort !== undefined) {
|
|
390
|
+
try {
|
|
391
|
+
const port = await listenOnce(server, storedPort);
|
|
392
|
+
return { port, reused: true };
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
// Taken — by a foreign process, a second catalog, anything. Fall through to a fresh port and
|
|
396
|
+
// secret below; never retry the same port or investigate what's holding it.
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
secretHolder.secret = randomBytes(24).toString('hex');
|
|
400
|
+
const port = await listenOnce(server, 0);
|
|
401
|
+
return { port, reused: false };
|
|
402
|
+
}
|
|
365
403
|
export async function dev(initialConfig, devOptions = {}) {
|
|
366
404
|
assertLocalInstall(initialConfig.projectRoot);
|
|
367
405
|
const ownVersion = readOwnVersion(import.meta.dirname);
|
|
@@ -480,14 +518,20 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
480
518
|
let updateEndpoint;
|
|
481
519
|
let endpointServer;
|
|
482
520
|
if (!devOptions.noUpdateEndpoint) {
|
|
521
|
+
// Reused across restarts so an already-open page's baked-in endpoint keeps working: the
|
|
522
|
+
// stored record's secret is the candidate here, replaced by `bindEndpointServer` itself only
|
|
523
|
+
// if the stored port turns out to be taken.
|
|
524
|
+
const stored = readEndpointRecord(config);
|
|
525
|
+
endpointDeps.secret = stored?.secret ?? secret;
|
|
483
526
|
endpointServer = createUpdateEndpoint(endpointDeps);
|
|
484
|
-
|
|
485
|
-
|
|
527
|
+
const binding = await bindEndpointServer(endpointServer, endpointDeps, stored?.port);
|
|
528
|
+
if (!binding.reused)
|
|
529
|
+
writeEndpointRecord(config, { port: binding.port, secret: endpointDeps.secret });
|
|
486
530
|
// Printed for the same reason the Metro port above is printed: a developer (or Task 20's own
|
|
487
531
|
// verification) checking the endpoint directly (e.g. with curl) needs its port — the secret
|
|
488
532
|
// itself is never printed; the endpoint rejects anything that doesn't already have it.
|
|
489
|
-
console.log(`Update endpoint listening on 127.0.0.1:${
|
|
490
|
-
updateEndpoint = { baseUrl: `http://127.0.0.1:${
|
|
533
|
+
console.log(`Update endpoint listening on 127.0.0.1:${binding.port}`);
|
|
534
|
+
updateEndpoint = { baseUrl: `http://127.0.0.1:${binding.port}`, secret: endpointDeps.secret };
|
|
491
535
|
}
|
|
492
536
|
else {
|
|
493
537
|
console.log('Update endpoint disabled for this run (--no-update-endpoint).');
|
package/dist/cli/workspace.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { globBaseFolder } from "./glob.js";
|
|
4
4
|
import { DEFAULT_WORKSPACE_DIR } from "./types.js";
|
|
@@ -151,6 +151,42 @@ export function liveDevLock(config, ownPid = process.pid) {
|
|
|
151
151
|
return undefined;
|
|
152
152
|
return lock;
|
|
153
153
|
}
|
|
154
|
+
const ENDPOINT_RECORD_FILE = 'endpoint.json';
|
|
155
|
+
/** `bindEndpointServer` never writes a secret shorter than this (`randomBytes(24).toString('hex')`
|
|
156
|
+
* is 48 characters), so a shorter one can only come from a hand-written record — but a
|
|
157
|
+
* hand-written `{"secret":""}` is reused verbatim and `secretMatches` in `cli/endpoint.ts`
|
|
158
|
+
* compares two zero-length buffers as equal, authenticating every request. Minor finding,
|
|
159
|
+
* correction pass 4: treated the same as any other invalid record field below. */
|
|
160
|
+
const MIN_SECRET_LENGTH = 32;
|
|
161
|
+
/** The endpoint address a previous `dev` run bound, so a restart can reuse it instead of minting a
|
|
162
|
+
* fresh one — otherwise an already-open page's baked-in endpoint goes stale on every restart.
|
|
163
|
+
* Missing, corrupt, or partially-written is all treated the same: absent, so the caller falls
|
|
164
|
+
* back to generating a fresh pair exactly as if `dev` had never run before. */
|
|
165
|
+
export function readEndpointRecord(config) {
|
|
166
|
+
const recordPath = workspacePath(config, ENDPOINT_RECORD_FILE);
|
|
167
|
+
if (!existsSync(recordPath))
|
|
168
|
+
return undefined;
|
|
169
|
+
try {
|
|
170
|
+
const record = JSON.parse(readFileSync(recordPath, 'utf8'));
|
|
171
|
+
if (typeof record.port !== 'number' || record.port <= 0 || typeof record.secret !== 'string' || record.secret.length < MIN_SECRET_LENGTH)
|
|
172
|
+
return undefined;
|
|
173
|
+
return { port: record.port, secret: record.secret };
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return undefined; // A partial write from a process that crashed mid-write owns nothing.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Written with mode 0600 — not a new exposure (the same secret already lands in cleartext inside
|
|
180
|
+
* the generated `entry.tsx`, see `writeWorkspace`), but it must not be any more exposed than
|
|
181
|
+
* that. `chmodSync` runs in addition to `writeFileSync`'s own `mode` option: that option only
|
|
182
|
+
* applies when the file is newly created, so a pre-existing record left with looser permissions
|
|
183
|
+
* would otherwise keep them across every later rewrite. */
|
|
184
|
+
export function writeEndpointRecord(config, record) {
|
|
185
|
+
mkdirSync(workspacePath(config), { recursive: true });
|
|
186
|
+
const recordPath = workspacePath(config, ENDPOINT_RECORD_FILE);
|
|
187
|
+
writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
|
|
188
|
+
chmodSync(recordPath, 0o600);
|
|
189
|
+
}
|
|
154
190
|
/** The files `writeWorkspace` always writes: the ones that define the workspace to Expo and Metro.
|
|
155
191
|
* `babel.config.js` is conditional and the update files are written separately, so neither counts
|
|
156
192
|
* as missing here. */
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { Platform, Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
|
|
2
|
+
import { ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
|
|
3
3
|
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
|
|
4
4
|
import { CatalogButton } from './CatalogButton';
|
|
5
5
|
import { DS_VIEWER_SECRET_HEADER } from './catalogNavigation';
|
|
6
6
|
import {
|
|
7
|
-
autoCheckHelp, bumpLabel, checkedAgoLabel, resultNote, daysAgoLabel, isAlreadyUpToDateError, isRemoteOrigin,
|
|
7
|
+
acceptsDeliveredStatus, autoCheckHelp, backgroundPlanFetchOutcome, bumpLabel, checkedAgoLabel, resultNote, classifyRejectedFetch, daysAgoLabel, isAlreadyUpToDateError, isRemoteOrigin,
|
|
8
|
+
nextWaitingAttempt, PLAN_FETCH_ERROR, planShowsError, reachabilityNote, recoveredReachabilityState, remoteOriginLocalErrorMessage,
|
|
9
|
+
requestOrUnreachable, resolvePanelPhase, restartReachability, statusPollFailureOutcome, versionOverview,
|
|
10
|
+
type ReachabilityState, type UpdatePlanView, type UpdateStatusView,
|
|
8
11
|
} from './updatePanelState';
|
|
9
12
|
import type { UpdateNotice, VersionStatus } from './types';
|
|
10
13
|
|
|
@@ -19,6 +22,18 @@ function remoteOrigin(endpoint: Endpoint | undefined): boolean {
|
|
|
19
22
|
return isRemoteOrigin(window.location.protocol, window.location.hostname);
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
/** Minor finding, Fable correction pass: bounds the `waiting` poller's own endpoint retry so a hung
|
|
26
|
+
* request (a firewall `DROP`, not a clean refusal) still counts as a failure instead of blocking
|
|
27
|
+
* it — and every later retry behind it — forever. */
|
|
28
|
+
const PROBE_TIMEOUT_MS = 3000;
|
|
29
|
+
|
|
30
|
+
/** Minor finding, correction pass 5: a single rejected `/update/status` while `updating` enters
|
|
31
|
+
* `restarting` even when the server never actually went down (a one-off network hiccup, not a
|
|
32
|
+
* real restart) — the Metro-origin probe below then never sees a failure, so it never reloads,
|
|
33
|
+
* and `restarting` is otherwise terminal in-app. Bounds how long the page waits before offering a
|
|
34
|
+
* manual way out, long enough for a real restart's own install-and-relaunch window. */
|
|
35
|
+
const RESTART_RELOAD_TIMEOUT_MS = 15000;
|
|
36
|
+
|
|
22
37
|
type Endpoint = { baseUrl: string; secret: string };
|
|
23
38
|
|
|
24
39
|
async function authedFetch(endpoint: Endpoint, path: string, init: RequestInit = {}): Promise<Response> {
|
|
@@ -67,11 +82,42 @@ const NOTE_BOX_VARIANT = {
|
|
|
67
82
|
ok: { box: { backgroundColor: CATALOG_COLOR.successSubtle }, text: { color: CATALOG_COLOR.success } },
|
|
68
83
|
} as const;
|
|
69
84
|
|
|
70
|
-
|
|
85
|
+
/** `spinner` is the approved mockup's own `.row .spin` treatment (waiting only) — a leading
|
|
86
|
+
* `ActivityIndicator`, the same primitive `CatalogButton`'s own `busy` state already uses, so
|
|
87
|
+
* this isn't a new component or colour. */
|
|
88
|
+
function NoteBox({
|
|
89
|
+
variant,
|
|
90
|
+
spinner,
|
|
91
|
+
live,
|
|
92
|
+
hint,
|
|
93
|
+
children,
|
|
94
|
+
}: {
|
|
95
|
+
variant: keyof typeof NOTE_BOX_VARIANT;
|
|
96
|
+
spinner?: boolean;
|
|
97
|
+
/** Only the reachability notes (`waiting`/`recovered`/`stuck`) appear, change and disappear with
|
|
98
|
+
* no user action — `ResultNoteBox` already establishes the `role="status"` pattern for its own
|
|
99
|
+
* note at :209. Every other `NoteBox` stays silent to a screen reader, same as today. */
|
|
100
|
+
live?: boolean;
|
|
101
|
+
/** Minor finding, Fable correction pass #2: a recovery command (e.g. `npx ds-viewer dev`) used
|
|
102
|
+
* to render outside this box, so a screen reader announced the problem via `live` but never
|
|
103
|
+
* the fix sitting right next to it. Rendered inside the same `role="status"` container so both
|
|
104
|
+
* are announced together. */
|
|
105
|
+
hint?: React.ReactNode;
|
|
106
|
+
children: React.ReactNode;
|
|
107
|
+
}) {
|
|
71
108
|
const v = NOTE_BOX_VARIANT[variant];
|
|
109
|
+
const text = <Text style={[styles.noteBoxText, v.text]}>{children}</Text>;
|
|
72
110
|
return (
|
|
73
|
-
<View style={[styles.noteBox, v.box]}>
|
|
74
|
-
|
|
111
|
+
<View style={[styles.noteBox, v.box]} {...(live ? { role: 'status' as const } : {})}>
|
|
112
|
+
{spinner ? (
|
|
113
|
+
<View style={styles.noteBoxRow}>
|
|
114
|
+
<ActivityIndicator size="small" color={CATALOG_COLOR.accent} />
|
|
115
|
+
{text}
|
|
116
|
+
</View>
|
|
117
|
+
) : (
|
|
118
|
+
text
|
|
119
|
+
)}
|
|
120
|
+
{hint}
|
|
75
121
|
</View>
|
|
76
122
|
);
|
|
77
123
|
}
|
|
@@ -133,6 +179,44 @@ function Code({ children }: { children: string }) {
|
|
|
133
179
|
return <Text style={styles.code}>{children}</Text>;
|
|
134
180
|
}
|
|
135
181
|
|
|
182
|
+
/** Replaces this page's normal content for `waiting`/`stuck` (approved mockup states 1 and
|
|
183
|
+
* "Edge"): the version line stays, but the action goes unavailable rather than offering a press
|
|
184
|
+
* that cannot work (decision 3), except `stuck`'s own `Reconnect`. */
|
|
185
|
+
function ReachabilityOverride({
|
|
186
|
+
reachability,
|
|
187
|
+
header,
|
|
188
|
+
actionLabel,
|
|
189
|
+
onReconnect,
|
|
190
|
+
}: {
|
|
191
|
+
reachability: ReachabilityState;
|
|
192
|
+
header: React.ReactNode;
|
|
193
|
+
actionLabel: string;
|
|
194
|
+
onReconnect: () => void;
|
|
195
|
+
}) {
|
|
196
|
+
const note = reachabilityNote(reachability.phase);
|
|
197
|
+
return (
|
|
198
|
+
<>
|
|
199
|
+
{header}
|
|
200
|
+
<NoteBox
|
|
201
|
+
variant={note.variant}
|
|
202
|
+
spinner={note.spinner}
|
|
203
|
+
live
|
|
204
|
+
hint={note.hint && <Text style={styles.muted}>{note.hint.prefix}<Code>{note.hint.command}</Code></Text>}
|
|
205
|
+
>
|
|
206
|
+
{note.bold ? <Text style={styles.noteBoxBold}>{note.bold}</Text> : null}
|
|
207
|
+
{note.text}
|
|
208
|
+
</NoteBox>
|
|
209
|
+
<View style={styles.actions}>
|
|
210
|
+
{reachability.phase === 'stuck' ? (
|
|
211
|
+
<CatalogButton label="Reconnect" variant="secondary" onPress={onReconnect} />
|
|
212
|
+
) : (
|
|
213
|
+
<CatalogButton label={actionLabel} disabled />
|
|
214
|
+
)}
|
|
215
|
+
</View>
|
|
216
|
+
</>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
136
220
|
|
|
137
221
|
|
|
138
222
|
/**
|
|
@@ -150,6 +234,11 @@ function ResultNoteBox({
|
|
|
150
234
|
status: Extract<UpdateStatusView, { phase: 'success' | 'failure' }>;
|
|
151
235
|
current: string;
|
|
152
236
|
latest?: string;
|
|
237
|
+
/** Named for its one legitimate "already running" caller (`noteBusy`/`starting`), but also true
|
|
238
|
+
* while the panel is reachability-unavailable (decision 3) — a press can't work for either
|
|
239
|
+
* reason, so the button renders the same flat-grey/muted-text treatment
|
|
240
|
+
* `CatalogButton.disabled` already uses for both (Minor finding, Fable correction pass #2),
|
|
241
|
+
* rather than the full-colour fill at reduced opacity that used to read as merely pressed. */
|
|
153
242
|
busy: boolean;
|
|
154
243
|
onAction: (kind: 'dismiss' | 'retry' | 'resume') => void;
|
|
155
244
|
}) {
|
|
@@ -178,9 +267,13 @@ function ResultNoteBox({
|
|
|
178
267
|
disabled={busy}
|
|
179
268
|
accessibilityRole="button"
|
|
180
269
|
accessibilityState={{ disabled: busy }}
|
|
181
|
-
style={({ pressed }) => [
|
|
270
|
+
style={({ pressed }) => [
|
|
271
|
+
styles.resultButton,
|
|
272
|
+
busy ? styles.resultButtonDisabled : { backgroundColor: tone },
|
|
273
|
+
pressed && !busy && styles.resultButtonPressed,
|
|
274
|
+
]}
|
|
182
275
|
>
|
|
183
|
-
<Text style={styles.resultButtonText}>{note.action.label}</Text>
|
|
276
|
+
<Text style={[styles.resultButtonText, busy && styles.resultButtonTextDisabled]}>{note.action.label}</Text>
|
|
184
277
|
</Pressable>
|
|
185
278
|
{note.hasLog && status.phase === 'failure' && (
|
|
186
279
|
<Pressable onPress={() => setShowLog((v) => !v)} accessibilityRole="button" accessibilityState={{ expanded: showLog }}>
|
|
@@ -204,12 +297,20 @@ function VersionDetails({
|
|
|
204
297
|
endpoint,
|
|
205
298
|
onStatus,
|
|
206
299
|
onCheck,
|
|
300
|
+
onUnreachable,
|
|
301
|
+
onReachable,
|
|
207
302
|
}: {
|
|
208
303
|
status: VersionStatus;
|
|
209
304
|
endpoint?: Endpoint;
|
|
210
305
|
onStatus: (next: VersionStatus) => void;
|
|
211
306
|
/** Called as Check now starts (clears an update result note). */
|
|
212
307
|
onCheck?: () => void;
|
|
308
|
+
/** `authedFetch` itself rejected (the endpoint socket is gone) — the page-wide reachability flow
|
|
309
|
+
* takes over; this component shows nothing further for the attempt that triggered it. */
|
|
310
|
+
onUnreachable?: () => void;
|
|
311
|
+
/** The endpoint answered, whatever it said — clears a reachability note left over from an
|
|
312
|
+
* earlier unreachable attempt, same as every other endpoint call on this page. */
|
|
313
|
+
onReachable?: () => void;
|
|
213
314
|
}) {
|
|
214
315
|
const [checking, setChecking] = useState(false);
|
|
215
316
|
const [justChecked, setJustChecked] = useState(false);
|
|
@@ -225,19 +326,37 @@ function VersionDetails({
|
|
|
225
326
|
setChecking(true);
|
|
226
327
|
setJustChecked(false);
|
|
227
328
|
setLocalError(undefined);
|
|
329
|
+
// `requestOrUnreachable` separates a rejected fetch (the endpoint socket is gone) from an
|
|
330
|
+
// actual response (401/409/502 included) — only the former goes to the shared reachability
|
|
331
|
+
// flow below; a real HTTP response keeps this `try` block's existing handling untouched.
|
|
332
|
+
const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/version/check', { method: 'POST' }));
|
|
333
|
+
if (outcome.kind === 'unreachable') {
|
|
334
|
+
// Critical finding, Fable correction pass (C4): a rejected `Origin` (no CORS headers at
|
|
335
|
+
// all) rejects `fetch` the same way a dead socket does — self-healing is meaningless for a
|
|
336
|
+
// page that can never use the endpoint, so this must keep the preserved remote-origin/
|
|
337
|
+
// tunnel message below instead of entering the page-wide reachability flow.
|
|
338
|
+
if (classifyRejectedFetch(remoteOrigin(endpoint)) === 'remote-origin') {
|
|
339
|
+
setLocalError(remoteOriginLocalErrorMessage());
|
|
340
|
+
} else {
|
|
341
|
+
onUnreachable?.();
|
|
342
|
+
}
|
|
343
|
+
setChecking(false);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
228
346
|
try {
|
|
229
|
-
const res =
|
|
347
|
+
const res = outcome.response;
|
|
230
348
|
if (!res.ok) throw new Error(String(res.status));
|
|
231
349
|
const next = (await res.json()) as VersionStatus;
|
|
232
350
|
// A newer version moves the whole page into the update flow (the parent re-renders with it).
|
|
233
351
|
setJustChecked(next.lastOutcome === 'ok' && !next.update);
|
|
234
352
|
onStatus(next);
|
|
353
|
+
onReachable?.();
|
|
235
354
|
} catch {
|
|
236
355
|
setLocalError(remoteOrigin(endpoint)
|
|
237
356
|
// The endpoint only answers its own origin, and it can install packages and restart the
|
|
238
357
|
// server — so saying "restart the viewer" here would send the reader after a fault that
|
|
239
358
|
// isn't there. The page itself is fine over a tunnel; only its actions are local.
|
|
240
|
-
?
|
|
359
|
+
? remoteOriginLocalErrorMessage()
|
|
241
360
|
: 'Couldn’t reach the viewer’s local server. If this keeps happening, restart npx ds-viewer dev.');
|
|
242
361
|
} finally {
|
|
243
362
|
setChecking(false);
|
|
@@ -248,14 +367,25 @@ function VersionDetails({
|
|
|
248
367
|
if (!endpoint || saving) return;
|
|
249
368
|
setSaving(true);
|
|
250
369
|
setLocalError(undefined);
|
|
370
|
+
const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/version/auto-check', {
|
|
371
|
+
method: 'POST',
|
|
372
|
+
headers: { 'Content-Type': 'application/json' },
|
|
373
|
+
body: JSON.stringify({ enabled }),
|
|
374
|
+
}));
|
|
375
|
+
if (outcome.kind === 'unreachable') {
|
|
376
|
+
// Same reasoning as `checkNow` above.
|
|
377
|
+
if (classifyRejectedFetch(remoteOrigin(endpoint)) === 'remote-origin') {
|
|
378
|
+
setLocalError(remoteOriginLocalErrorMessage());
|
|
379
|
+
} else {
|
|
380
|
+
onUnreachable?.();
|
|
381
|
+
}
|
|
382
|
+
setSaving(false);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
251
385
|
try {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
body: JSON.stringify({ enabled }),
|
|
256
|
-
});
|
|
257
|
-
if (!res.ok) throw new Error(String(res.status));
|
|
258
|
-
onStatus((await res.json()) as VersionStatus);
|
|
386
|
+
if (!outcome.response.ok) throw new Error(String(outcome.response.status));
|
|
387
|
+
onStatus((await outcome.response.json()) as VersionStatus);
|
|
388
|
+
onReachable?.();
|
|
259
389
|
} catch {
|
|
260
390
|
setLocalError('Couldn’t save the setting. Try again.');
|
|
261
391
|
} finally {
|
|
@@ -358,6 +488,13 @@ export function UpdatePanel({
|
|
|
358
488
|
headingRef?: React.Ref<View>;
|
|
359
489
|
}) {
|
|
360
490
|
const [status, setStatus] = useState<UpdateStatusView>({ phase: 'idle' });
|
|
491
|
+
// Critical finding, Fable correction pass (C6): mirrors `status`, read instead of a value
|
|
492
|
+
// assigned inside a `setStatus` updater — updaters run during React's render phase, not at
|
|
493
|
+
// dispatch, so a read-after-write inside one can see stale or re-invoked state.
|
|
494
|
+
const statusRef = useRef<UpdateStatusView>(status);
|
|
495
|
+
useEffect(() => {
|
|
496
|
+
statusRef.current = status;
|
|
497
|
+
}, [status]);
|
|
361
498
|
const [planResult, setPlanResult] = useState<UpdatePlanView | { error: string } | undefined>(undefined);
|
|
362
499
|
// Critical finding, Fable correction pass: while this is true, the endpoint's own `/update` POST
|
|
363
500
|
// is in flight (re-planning before it can even start) — **Update now** must disable itself for
|
|
@@ -366,31 +503,175 @@ export function UpdatePanel({
|
|
|
366
503
|
const [starting, setStarting] = useState(false);
|
|
367
504
|
const pollRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
|
368
505
|
|
|
506
|
+
// Important finding, Fable correction pass (I4): the catalog is a single-page app, so a probe
|
|
507
|
+
// started before the user navigates away can still resolve after this component unmounts — set
|
|
508
|
+
// once, on unmount, and checked after every `await` below before any state is dispatched or the
|
|
509
|
+
// page is reloaded out from under whatever's there now.
|
|
510
|
+
const cancelledRef = useRef(false);
|
|
511
|
+
useEffect(() => () => {
|
|
512
|
+
cancelledRef.current = true;
|
|
513
|
+
}, []);
|
|
514
|
+
|
|
515
|
+
// The page-wide reachability flow (approved `.hermes-task/approved-unreachable-states.html`):
|
|
516
|
+
// shared by every endpoint call below and by `VersionDetails`' own calls, since the two UI modes
|
|
517
|
+
// are never visible at once. `undefined` means "nothing wrong" — the same convention
|
|
518
|
+
// `UpdateStatusView`'s `idle` phase already uses for "nothing in flight".
|
|
519
|
+
const [reachability, setReachability] = useState<ReachabilityState | undefined>(undefined);
|
|
520
|
+
const reachabilityRef = useRef<ReachabilityState | undefined>(undefined);
|
|
521
|
+
useEffect(() => {
|
|
522
|
+
reachabilityRef.current = reachability;
|
|
523
|
+
}, [reachability]);
|
|
524
|
+
|
|
525
|
+
// Critical finding, Fable correction pass (C4): a page that can never use the endpoint at all
|
|
526
|
+
// (a tunnel, or a plain `127.0.0.1` the endpoint's own Origin check rejects) gets no
|
|
527
|
+
// self-healing — see `routeUnreachable` below. Kept apart from `reachability` because the two
|
|
528
|
+
// conditions are mutually exclusive and need entirely different, non-retrying treatment.
|
|
529
|
+
const [remoteOriginError, setRemoteOriginError] = useState(false);
|
|
530
|
+
|
|
531
|
+
// Called by `routeUnreachable` below for a rejected fetch this page can actually recover from.
|
|
532
|
+
// Final slice: a stable endpoint address means the `waiting` poller's own direct endpoint retry
|
|
533
|
+
// (below) is the whole recovery mechanism, so this just advances the bounded attempt counter —
|
|
534
|
+
// no origin probe, no reload, no marker.
|
|
535
|
+
const markUnreachable = useCallback(() => {
|
|
536
|
+
if (!isWeb()) return;
|
|
537
|
+
setReachability((prev) => nextWaitingAttempt(prev ?? { phase: 'waiting', attempts: 0 }));
|
|
538
|
+
}, []);
|
|
539
|
+
|
|
540
|
+
// Critical finding, Fable correction pass (C4): the one place every rejected-fetch call site on
|
|
541
|
+
// this page routes through. `cli/endpoint.ts` answers a rejected `Origin` with no CORS headers
|
|
542
|
+
// at all, so the browser blocks the response and `fetch` rejects exactly like a dead socket —
|
|
543
|
+
// without this check first, that rejection used to enter the reachability flow and reload, then
|
|
544
|
+
// sit there "still not answering" forever, for a page that was never going to be able to act
|
|
545
|
+
// regardless.
|
|
546
|
+
const routeUnreachable = useCallback(() => {
|
|
547
|
+
if (classifyRejectedFetch(remoteOrigin(endpoint)) === 'remote-origin') {
|
|
548
|
+
setRemoteOriginError(true);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
markUnreachable();
|
|
552
|
+
}, [endpoint, markUnreachable]);
|
|
553
|
+
|
|
554
|
+
// Important finding, Fable correction pass #2: a background poll (`fetchStatus`/`fetchPlan`,
|
|
555
|
+
// both run unprompted on mount and on a plain interval) must never latch `remoteOriginError` —
|
|
556
|
+
// that replaces the whole page with a red error for every non-`localhost` page (a tunnel, LAN
|
|
557
|
+
// address, reverse proxy, or plain `127.0.0.1`) the instant it loads, something the user never
|
|
558
|
+
// triggered. Only a user-initiated call (`startUpdate`, `dismissResult`, `resumeResult`, and
|
|
559
|
+
// `VersionDetails`' own handlers, which already own their local copy of the message) may show
|
|
560
|
+
// that error, via `routeUnreachable` above. A genuinely unreachable endpoint (not a
|
|
561
|
+
// remote-origin refusal) still enters the reachability flow either way.
|
|
562
|
+
// Correction pass 3: reports back whether it actually did anything (`true` once it has entered
|
|
563
|
+
// the reachability flow, `false` when it declined to for a remote-origin page) — a background
|
|
564
|
+
// caller that can't just swallow a refusal (`fetchPlan` below) needs to know so it can apply its
|
|
565
|
+
// own fallback instead of leaving required state unset.
|
|
566
|
+
const routeUnreachableInBackground = useCallback((): boolean => {
|
|
567
|
+
if (classifyRejectedFetch(remoteOrigin(endpoint)) === 'remote-origin') return false;
|
|
568
|
+
markUnreachable();
|
|
569
|
+
return true;
|
|
570
|
+
}, [endpoint, markUnreachable]);
|
|
571
|
+
|
|
572
|
+
// Called by any endpoint call on this page that got a real response — clears a reachability
|
|
573
|
+
// note left over from an earlier unreachable attempt (a no-op the vast majority of the time).
|
|
574
|
+
const markReachable = useCallback(() => {
|
|
575
|
+
if (!isWeb()) return;
|
|
576
|
+
// Important finding, Fable correction pass (I2): computed from the ref, not from inside the
|
|
577
|
+
// `setReachability` updater — a re-invoked updater could otherwise read the state it just set
|
|
578
|
+
// on a prior invocation and settle back on "nothing to recover," silently losing the approved
|
|
579
|
+
// "Reconnected." note.
|
|
580
|
+
const next = recoveredReachabilityState(reachabilityRef.current);
|
|
581
|
+
if (next) setReachability(next);
|
|
582
|
+
}, []);
|
|
583
|
+
|
|
584
|
+
// "Recovered: … auto-dismissing" (approved design) — a plain timeout, cleared on unmount or on
|
|
585
|
+
// leaving the state early (e.g. another unreachable attempt right after recovering).
|
|
586
|
+
useEffect(() => {
|
|
587
|
+
if (reachability?.phase !== 'recovered') return;
|
|
588
|
+
const timeout = setTimeout(() => setReachability(undefined), 3000);
|
|
589
|
+
return () => clearTimeout(timeout);
|
|
590
|
+
}, [reachability?.phase]);
|
|
591
|
+
|
|
592
|
+
// Part 3 (final slice): one explicit, user-initiated reload — the only reload left anywhere in
|
|
593
|
+
// this flow. Restarts the bounded cycle first so that if `window.location.reload()` turns out
|
|
594
|
+
// to be a no-op (some embedded webviews block it), the page falls back to retrying the endpoint
|
|
595
|
+
// in place instead of sitting on `stuck` with a press that silently did nothing.
|
|
596
|
+
const reconnect = useCallback(() => {
|
|
597
|
+
if (!isWeb()) return;
|
|
598
|
+
setReachability(restartReachability());
|
|
599
|
+
window.location.reload();
|
|
600
|
+
}, []);
|
|
601
|
+
|
|
369
602
|
const fetchStatus = useCallback(async () => {
|
|
370
603
|
if (!endpoint || !isWeb()) return;
|
|
604
|
+
const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/update/status'));
|
|
605
|
+
if (outcome.kind === 'unreachable') {
|
|
606
|
+
// Errata 2a: a failed poll while an update was in flight or already restarting means "still
|
|
607
|
+
// restarting," not unreachable — the dedicated restart-handoff effect below already owns
|
|
608
|
+
// that recovery, so this page-wide flow only takes over for every other phase.
|
|
609
|
+
const fallback = statusPollFailureOutcome(statusRef.current.phase);
|
|
610
|
+
if (fallback) {
|
|
611
|
+
setStatus(fallback);
|
|
612
|
+
} else {
|
|
613
|
+
routeUnreachableInBackground();
|
|
614
|
+
}
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
371
617
|
try {
|
|
372
|
-
|
|
373
|
-
|
|
618
|
+
// Critical finding, Fable correction pass (C1): a delivered-but-unusable response (a stale
|
|
619
|
+
// secret on a reused port answers 401 `text/plain`, with CORS headers, so it's delivered and
|
|
620
|
+
// `.json()` rejects) is not "unreachable" — the endpoint answered — so it must not enter the
|
|
621
|
+
// reachability flow; fall back the same way a failed poll mid-update already does.
|
|
622
|
+
if (!outcome.response.ok) throw new Error(String(outcome.response.status));
|
|
623
|
+
const next = (await outcome.response.json()) as UpdateStatusView;
|
|
624
|
+
// Correction pass 4 (Critical): once restarted, this poll reaches the server on the same
|
|
625
|
+
// reused address and succeeds before Metro has served the new bundle — only the Metro-origin
|
|
626
|
+
// reload poller below may move the page out of `restarting`.
|
|
627
|
+
if (acceptsDeliveredStatus(statusRef.current.phase, next.phase)) setStatus(next);
|
|
628
|
+
markReachable();
|
|
374
629
|
} catch {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
setStatus((prev) => (prev.phase === 'updating' || prev.phase === 'restarting' ? { phase: 'restarting' } : prev));
|
|
630
|
+
const fallback = statusPollFailureOutcome(statusRef.current.phase);
|
|
631
|
+
if (fallback) setStatus(fallback);
|
|
378
632
|
}
|
|
379
|
-
}, [endpoint]);
|
|
633
|
+
}, [endpoint, routeUnreachableInBackground, markReachable]);
|
|
380
634
|
|
|
381
|
-
|
|
382
|
-
|
|
635
|
+
// Part 4 (final slice): returns whether the endpoint was actually reached, so a caller that's
|
|
636
|
+
// about to claim "Reconnected. Everything below is current." (the waiting poller's success
|
|
637
|
+
// branch, below) can gate that claim on this fetch having actually succeeded, instead of
|
|
638
|
+
// assuming it did just because an earlier call in the same tick did.
|
|
639
|
+
// Correction pass 5 (Important): also returns the plan it just stored, taken from the fetch
|
|
640
|
+
// itself rather than left for the caller to read back from a ref — a ref filled in by a passive
|
|
641
|
+
// effect only updates once React flushes effects after this render, which is always later than
|
|
642
|
+
// the very next line of an async caller, so that read would deterministically see the prior
|
|
643
|
+
// plan, never this one.
|
|
644
|
+
const fetchPlan = useCallback(async (): Promise<{ reached: boolean; result?: UpdatePlanView | { error: string } }> => {
|
|
645
|
+
if (!endpoint || !isWeb()) return { reached: true };
|
|
383
646
|
// Important finding, Fable correction pass: an unhandled rejection here (the endpoint
|
|
384
647
|
// unreachable, CORS-blocked, or mid-restart) previously left `planResult` `undefined` forever
|
|
385
648
|
// — the panel sat on "Checking…" with no way out. The same error shape `buildUpdatePlan`
|
|
386
|
-
// itself returns on failure drives the existing `offline` phase.
|
|
649
|
+
// itself returns on failure drives the existing `offline` phase. A genuinely unreachable
|
|
650
|
+
// endpoint (the socket itself is gone) now goes through the shared reachability flow instead.
|
|
651
|
+
const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/plan', { method: 'POST' }));
|
|
652
|
+
if (outcome.kind === 'unreachable') {
|
|
653
|
+
// Correction pass 3: a remote-origin page's refusal leaves `routeUnreachableInBackground`
|
|
654
|
+
// reporting `false` — apply the same offline plan-error outcome the `catch` below already
|
|
655
|
+
// shows for every other plan failure, rather than leaving `planResult` (and the panel) stuck
|
|
656
|
+
// on `undefined`/"Checking…" forever.
|
|
657
|
+
const fallback = backgroundPlanFetchOutcome(routeUnreachableInBackground());
|
|
658
|
+
if (fallback) setPlanResult(fallback);
|
|
659
|
+
return { reached: false };
|
|
660
|
+
}
|
|
387
661
|
try {
|
|
388
|
-
|
|
389
|
-
|
|
662
|
+
// Critical finding, Fable correction pass (C1): see `fetchStatus` above — a delivered 401
|
|
663
|
+
// (stale secret) must fall back to the pre-existing offline plan-error note, with a working
|
|
664
|
+
// **Try again**, instead of hanging forever on a rejected `.json()`.
|
|
665
|
+
if (!outcome.response.ok) throw new Error(String(outcome.response.status));
|
|
666
|
+
const result = (await outcome.response.json()) as UpdatePlanView | { error: string };
|
|
667
|
+
setPlanResult(result);
|
|
668
|
+
markReachable();
|
|
669
|
+
return { reached: true, result };
|
|
390
670
|
} catch {
|
|
391
|
-
setPlanResult(
|
|
671
|
+
setPlanResult(PLAN_FETCH_ERROR);
|
|
672
|
+
return { reached: true, result: PLAN_FETCH_ERROR };
|
|
392
673
|
}
|
|
393
|
-
}, [endpoint]);
|
|
674
|
+
}, [endpoint, routeUnreachableInBackground, markReachable]);
|
|
394
675
|
|
|
395
676
|
useEffect(() => {
|
|
396
677
|
// Errata 1c: never call `fetchPlan` when there is no update to plan for.
|
|
@@ -399,6 +680,74 @@ export function UpdatePanel({
|
|
|
399
680
|
});
|
|
400
681
|
}, [fetchStatus, fetchPlan, update]);
|
|
401
682
|
|
|
683
|
+
// Final slice: with a stable endpoint address, this direct retry is the whole recovery
|
|
684
|
+
// mechanism — no origin probe, no reload. A restart lands on the same port and secret, so this
|
|
685
|
+
// keeps failing only while the viewer is actually down, and succeeds the moment it's back.
|
|
686
|
+
useEffect(() => {
|
|
687
|
+
if (!isWeb() || !endpoint || reachability?.phase !== 'waiting') return;
|
|
688
|
+
// Important finding, Fable correction pass (I4): stacked probes (if one tick's probe is still
|
|
689
|
+
// in flight when the next fires) are refused rather than piling up.
|
|
690
|
+
let inFlight = false;
|
|
691
|
+
const interval = setInterval(async () => {
|
|
692
|
+
if (inFlight) return;
|
|
693
|
+
inFlight = true;
|
|
694
|
+
try {
|
|
695
|
+
// Minor finding, Fable correction pass #2: this retry sits behind the `inFlight` latch
|
|
696
|
+
// above with no timeout of its own — a hung request (a firewall `DROP` on the stale port,
|
|
697
|
+
// not a clean refusal) would otherwise pin `inFlight` forever, blocking every later probe
|
|
698
|
+
// and never reaching `stuck`.
|
|
699
|
+
const controller = new AbortController();
|
|
700
|
+
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
701
|
+
let outcome: Awaited<ReturnType<typeof requestOrUnreachable>>;
|
|
702
|
+
try {
|
|
703
|
+
outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/update/status', { signal: controller.signal }));
|
|
704
|
+
} finally {
|
|
705
|
+
clearTimeout(timeout);
|
|
706
|
+
}
|
|
707
|
+
if (cancelledRef.current) return;
|
|
708
|
+
if (outcome.kind === 'ok' && outcome.response.ok) {
|
|
709
|
+
// Important finding, Fable correction pass #2: `markReachable` only flips the
|
|
710
|
+
// reachability phase to `recovered` — it never refreshes `status` or re-runs
|
|
711
|
+
// `fetchPlan`, so the note's "Everything below is current" was false by construction,
|
|
712
|
+
// and a page still waiting on its first plan (`planResult === undefined` at mount)
|
|
713
|
+
// was left stranded on "Checking…" with no way out. Feed the response through, and
|
|
714
|
+
// refresh the plan too, *before* calling `markReachable` — so the claim is true by
|
|
715
|
+
// the time it's shown.
|
|
716
|
+
try {
|
|
717
|
+
setStatus((await outcome.response.json()) as UpdateStatusView);
|
|
718
|
+
} catch {
|
|
719
|
+
// The regular poll (once `phase` leaves `waiting`) retries `/update/status` on its
|
|
720
|
+
// own; `markReachable` below still ends the reachability flow cleanly.
|
|
721
|
+
}
|
|
722
|
+
// Part 4: a delivered status does not, by itself, make "Everything below is current"
|
|
723
|
+
// true — the plan refresh it's paired with must also have actually reached the
|
|
724
|
+
// endpoint. Without this gate, a plan fetch that failed right after a successful status
|
|
725
|
+
// poll still showed the recovered note while the endpoint was gone, tore the poller
|
|
726
|
+
// down, and could strand the panel on "Checking…" with no button.
|
|
727
|
+
const planOutcome = update ? await fetchPlan() : { reached: true };
|
|
728
|
+
if (cancelledRef.current) return;
|
|
729
|
+
// Minor finding, correction pass 4: `/plan` answers 200 with an `{ error }` body for the
|
|
730
|
+
// offline case, so a reached-but-erroring plan still leaves this reached — showing
|
|
731
|
+
// "Reconnected. Everything below is current." directly above the red offline note it
|
|
732
|
+
// contradicts. Suppress the note instead of changing the gate above: leave the page on
|
|
733
|
+
// its `offline` state (with a working **Try again**) rather than reporting a reachability
|
|
734
|
+
// failure, which would distort the waiting poller's own bounded-attempt counter.
|
|
735
|
+
// Correction pass 5 (Important): `planShowsError` reads `planOutcome.result` — the value
|
|
736
|
+
// this very `fetchPlan()` call just stored — never a ref a passive effect fills in later.
|
|
737
|
+
if (planOutcome.reached) {
|
|
738
|
+
if (planShowsError(planOutcome.result)) setReachability(undefined);
|
|
739
|
+
else markReachable();
|
|
740
|
+
}
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
setReachability((prev) => (prev && prev.phase === 'waiting' ? nextWaitingAttempt(prev) : prev));
|
|
744
|
+
} finally {
|
|
745
|
+
inFlight = false;
|
|
746
|
+
}
|
|
747
|
+
}, 500);
|
|
748
|
+
return () => clearInterval(interval);
|
|
749
|
+
}, [reachability?.phase, endpoint, markReachable, fetchPlan, update]);
|
|
750
|
+
|
|
402
751
|
// A finished update (success or failure) shows as a note at the top; the page under it behaves
|
|
403
752
|
// as if idle, so Check now, the switch and a newer update all keep working.
|
|
404
753
|
const result = status.phase === 'success' || status.phase === 'failure' ? status : undefined;
|
|
@@ -406,13 +755,36 @@ export function UpdatePanel({
|
|
|
406
755
|
const [noteBusy, setNoteBusy] = useState(false);
|
|
407
756
|
const dismissResult = useCallback(async () => {
|
|
408
757
|
if (!endpoint || !isWeb()) return;
|
|
758
|
+
// Important finding, Fable correction pass (I3): routed through the same reachability flow as
|
|
759
|
+
// every other endpoint call, instead of silently swallowing a rejected fetch with **Got it**
|
|
760
|
+
// left doing nothing visible.
|
|
761
|
+
const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/update/dismiss', { method: 'POST' }));
|
|
762
|
+
if (outcome.kind === 'unreachable') {
|
|
763
|
+
routeUnreachable();
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
409
766
|
try {
|
|
410
|
-
|
|
411
|
-
|
|
767
|
+
if (!outcome.response.ok) throw new Error(String(outcome.response.status));
|
|
768
|
+
setStatus((await outcome.response.json()) as UpdateStatusView);
|
|
769
|
+
markReachable();
|
|
412
770
|
} catch {
|
|
413
771
|
// The note stays; the next status poll or reload settles it.
|
|
414
772
|
}
|
|
415
|
-
}, [endpoint]);
|
|
773
|
+
}, [endpoint, routeUnreachable, markReachable]);
|
|
774
|
+
|
|
775
|
+
// Important finding, Fable correction pass (I3): **Finish migration** / **Rerun doctor** had no
|
|
776
|
+
// guard at all — a rejected fetch here did nothing visible. Routed through the same reachability
|
|
777
|
+
// flow as every other endpoint call.
|
|
778
|
+
const resumeResult = useCallback(async () => {
|
|
779
|
+
if (!endpoint || !isWeb()) return;
|
|
780
|
+
const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/update/resume', { method: 'POST' }));
|
|
781
|
+
if (outcome.kind === 'unreachable') {
|
|
782
|
+
routeUnreachable();
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
markReachable();
|
|
786
|
+
await fetchStatus();
|
|
787
|
+
}, [endpoint, routeUnreachable, markReachable, fetchStatus]);
|
|
416
788
|
|
|
417
789
|
// Polls `/update/status` at a plain interval while an update is in flight — design §5 never
|
|
418
790
|
// asks for push/websocket progress, only "the panel shows progress."
|
|
@@ -422,6 +794,19 @@ export function UpdatePanel({
|
|
|
422
794
|
return () => clearInterval(pollRef.current);
|
|
423
795
|
}, [phase, fetchStatus]);
|
|
424
796
|
|
|
797
|
+
// Minor finding, correction pass 5: offers a manual way out of `restarting` once it's held for
|
|
798
|
+
// longer than a real restart should take — the one case the bounded Metro-origin probe below
|
|
799
|
+
// cannot resolve on its own (the server never actually went down, so it never sees a failure).
|
|
800
|
+
const [restartTimedOut, setRestartTimedOut] = useState(false);
|
|
801
|
+
useEffect(() => {
|
|
802
|
+
if (phase !== 'restarting') {
|
|
803
|
+
setRestartTimedOut(false);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const timeout = setTimeout(() => setRestartTimedOut(true), RESTART_RELOAD_TIMEOUT_MS);
|
|
807
|
+
return () => clearTimeout(timeout);
|
|
808
|
+
}, [phase]);
|
|
809
|
+
|
|
425
810
|
// Restart handoff step 4 (design §5): once the server reports "restarting", Metro itself (not
|
|
426
811
|
// this endpoint — the endpoint's own process is about to exit) is polled on the *page's own
|
|
427
812
|
// origin* until it answers again, then the page reloads onto the new version. Errata 2b: reload
|
|
@@ -433,6 +818,9 @@ export function UpdatePanel({
|
|
|
433
818
|
const interval = setInterval(() => {
|
|
434
819
|
fetch(window.location.origin, { method: 'HEAD' })
|
|
435
820
|
.then((res) => {
|
|
821
|
+
// Important finding, Fable correction pass (I4): the catalog is a single-page app — a
|
|
822
|
+
// probe started before the user navigated away can still resolve after unmount.
|
|
823
|
+
if (cancelledRef.current) return;
|
|
436
824
|
if (res.ok && hadFailure) {
|
|
437
825
|
clearInterval(interval);
|
|
438
826
|
window.location.reload();
|
|
@@ -448,25 +836,31 @@ export function UpdatePanel({
|
|
|
448
836
|
const startUpdate = useCallback(async () => {
|
|
449
837
|
if (!endpoint) return;
|
|
450
838
|
setStarting(true);
|
|
839
|
+
// Critical finding, Fable correction pass (C2): `try`/`finally` so a rejection from
|
|
840
|
+
// `fetchPlan()`/`fetchStatus()` below can never leave **Update now** disabled on "Starting…"
|
|
841
|
+
// until a manual reload.
|
|
451
842
|
try {
|
|
452
|
-
|
|
843
|
+
// Important finding, Fable correction pass: a failed `POST /update` must not leave the panel
|
|
844
|
+
// stuck mid-click with no way forward — a genuinely unreachable endpoint now goes through the
|
|
845
|
+
// shared reachability flow instead of the offline plan-error text.
|
|
846
|
+
const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/update', { method: 'POST' }));
|
|
847
|
+
if (outcome.kind === 'unreachable') {
|
|
848
|
+
routeUnreachable();
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
453
851
|
// A non-202 (409 dirty/already-running, or 502 "couldn't prepare") means a file was dirtied
|
|
454
852
|
// — or the plan went stale — after this panel's own `ready` plan was fetched; re-fetching the
|
|
455
853
|
// plan surfaces the `dirty`/`offline` phase instead of silently staying on `ready` forever
|
|
456
854
|
// (Minor finding, Fable correction pass).
|
|
457
|
-
if (
|
|
855
|
+
if (outcome.response.status !== 202) {
|
|
458
856
|
await fetchPlan();
|
|
459
|
-
|
|
857
|
+
} else {
|
|
858
|
+
await fetchStatus();
|
|
460
859
|
}
|
|
461
|
-
await fetchStatus();
|
|
462
|
-
} catch {
|
|
463
|
-
// Important finding, Fable correction pass: same reasoning as `fetchPlan` above — a failed
|
|
464
|
-
// `POST /update` must not leave the panel stuck mid-click with no way forward.
|
|
465
|
-
setPlanResult({ error: "Couldn't prepare the update. You may be offline, or npm didn't respond. Nothing was changed." });
|
|
466
860
|
} finally {
|
|
467
861
|
setStarting(false);
|
|
468
862
|
}
|
|
469
|
-
}, [endpoint, fetchPlan, fetchStatus]);
|
|
863
|
+
}, [endpoint, fetchPlan, fetchStatus, routeUnreachable]);
|
|
470
864
|
|
|
471
865
|
const plan = planResult && !('error' in planResult) ? planResult : undefined;
|
|
472
866
|
|
|
@@ -477,11 +871,53 @@ export function UpdatePanel({
|
|
|
477
871
|
// once the footer link existed). `content` is computed per phase below, then wrapped once.
|
|
478
872
|
let content: React.ReactNode = null;
|
|
479
873
|
|
|
480
|
-
|
|
874
|
+
// Takes priority over every other phase below — it replaces whichever of the two UI modes
|
|
875
|
+
// (an update pending, or just the installed version) was about to render, since neither can be
|
|
876
|
+
// trusted while the endpoint itself is unreachable. Never shown mid-restart: that recovery is
|
|
877
|
+
// already owned by the dedicated restart-handoff effect above (see `fetchStatus`).
|
|
878
|
+
const reachabilityOverride = reachability && reachability.phase !== 'recovered' && phase !== 'updating' && phase !== 'restarting';
|
|
879
|
+
|
|
880
|
+
if (remoteOriginError) {
|
|
881
|
+
// Critical finding, Fable correction pass (C4): a page that can never use the endpoint gets
|
|
882
|
+
// no self-healing at all — just the preserved remote-origin/tunnel message, with nothing to
|
|
883
|
+
// retry (there is nothing a reload or a probe could fix here).
|
|
884
|
+
content = (
|
|
885
|
+
<>
|
|
886
|
+
{update ? (
|
|
887
|
+
<VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
|
|
888
|
+
) : versionStatus ? (
|
|
889
|
+
<View style={styles.versionsRow}><Text style={styles.versionsText}>{versionStatus.current}</Text></View>
|
|
890
|
+
) : null}
|
|
891
|
+
<NoteBox variant="err">{remoteOriginLocalErrorMessage()}</NoteBox>
|
|
892
|
+
</>
|
|
893
|
+
);
|
|
894
|
+
} else if (reachabilityOverride && reachability) {
|
|
895
|
+
content = (
|
|
896
|
+
<ReachabilityOverride
|
|
897
|
+
reachability={reachability}
|
|
898
|
+
header={update ? (
|
|
899
|
+
<VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
|
|
900
|
+
) : versionStatus ? (
|
|
901
|
+
// Minor finding, Fable correction pass: without `versionStatus` there's no version to
|
|
902
|
+
// show at all — fall back to the note-only layout instead of an empty 2xl row.
|
|
903
|
+
<View style={styles.versionsRow}><Text style={styles.versionsText}>{versionStatus.current}</Text></View>
|
|
904
|
+
) : null}
|
|
905
|
+
actionLabel={update ? 'Update now' : 'Check now'}
|
|
906
|
+
onReconnect={reconnect}
|
|
907
|
+
/>
|
|
908
|
+
);
|
|
909
|
+
} else if (!update) {
|
|
481
910
|
// Errata 1c: no update known (disabled, offline, or genuinely up to date). `status` still
|
|
482
911
|
// reflects a just-finished update read from the (surviving) endpoint across the reload.
|
|
483
912
|
content = versionStatus ? (
|
|
484
|
-
<VersionDetails
|
|
913
|
+
<VersionDetails
|
|
914
|
+
status={versionStatus}
|
|
915
|
+
endpoint={endpoint}
|
|
916
|
+
onStatus={onVersionStatus ?? (() => {})}
|
|
917
|
+
onCheck={result ? dismissResult : undefined}
|
|
918
|
+
onUnreachable={markUnreachable}
|
|
919
|
+
onReachable={markReachable}
|
|
920
|
+
/>
|
|
485
921
|
) : (
|
|
486
922
|
<NoteBox variant="ok">You’re already up to date.</NoteBox>
|
|
487
923
|
);
|
|
@@ -583,11 +1019,18 @@ export function UpdatePanel({
|
|
|
583
1019
|
{' This page reloads by itself.'}
|
|
584
1020
|
</NoteBox>
|
|
585
1021
|
<Text style={styles.muted}>If it doesn’t come back, run <Code>npx ds-viewer dev</Code> in your terminal.</Text>
|
|
1022
|
+
{restartTimedOut && (
|
|
1023
|
+
<View style={styles.actions}>
|
|
1024
|
+
<CatalogButton label="Reload" variant="secondary" onPress={() => isWeb() && window.location.reload()} />
|
|
1025
|
+
</View>
|
|
1026
|
+
)}
|
|
586
1027
|
</>
|
|
587
1028
|
);
|
|
588
1029
|
|
|
589
1030
|
}
|
|
590
1031
|
|
|
1032
|
+
const recoveredNote = reachability?.phase === 'recovered' ? reachabilityNote('recovered') : undefined;
|
|
1033
|
+
|
|
591
1034
|
return (
|
|
592
1035
|
<View style={styles.wrap}>
|
|
593
1036
|
<Text style={styles.crumb}>{`${appName} / DS Viewer`}</Text>
|
|
@@ -601,23 +1044,32 @@ export function UpdatePanel({
|
|
|
601
1044
|
status={result}
|
|
602
1045
|
current={update?.current ?? versionStatus?.current ?? ''}
|
|
603
1046
|
latest={update?.latest}
|
|
604
|
-
|
|
1047
|
+
// Important finding, Fable correction pass (I3): disabled while unreachable (or on a
|
|
1048
|
+
// page that can never be) — otherwise the calm reachability note and a live, enabled
|
|
1049
|
+
// **Retry update** / **Finish migration** / **Rerun doctor** show together, for the
|
|
1050
|
+
// panel's most consequential buttons, contradicting decision 3.
|
|
1051
|
+
busy={noteBusy || starting || remoteOriginError || Boolean(reachability && reachability.phase !== 'recovered')}
|
|
605
1052
|
onAction={async (kind) => {
|
|
606
1053
|
if (!endpoint) return;
|
|
607
1054
|
setNoteBusy(true);
|
|
608
1055
|
try {
|
|
609
1056
|
if (kind === 'dismiss') await dismissResult();
|
|
610
1057
|
else if (kind === 'retry') await startUpdate();
|
|
611
|
-
else
|
|
612
|
-
await authedFetch(endpoint, '/update/resume', { method: 'POST' });
|
|
613
|
-
await fetchStatus();
|
|
614
|
-
}
|
|
1058
|
+
else await resumeResult();
|
|
615
1059
|
} finally {
|
|
616
1060
|
setNoteBusy(false);
|
|
617
1061
|
}
|
|
618
1062
|
}}
|
|
619
1063
|
/>
|
|
620
1064
|
)}
|
|
1065
|
+
{recoveredNote && (
|
|
1066
|
+
// Minor finding, Fable correction pass: reads `variant`/`spinner` from the same
|
|
1067
|
+
// `reachabilityNote('recovered')` the copy comes from, instead of hardcoding `variant="ok"`
|
|
1068
|
+
// separately, so the approved copy and its treatment have one source.
|
|
1069
|
+
<NoteBox variant={recoveredNote.variant} spinner={recoveredNote.spinner} live>
|
|
1070
|
+
{recoveredNote.text}
|
|
1071
|
+
</NoteBox>
|
|
1072
|
+
)}
|
|
621
1073
|
{content}
|
|
622
1074
|
</View>
|
|
623
1075
|
);
|
|
@@ -635,7 +1087,12 @@ const styles = StyleSheet.create({
|
|
|
635
1087
|
// The catalog's smallest control size (the major banner's close button), filled in the note's colour.
|
|
636
1088
|
resultButton: { height: 32, paddingHorizontal: 14, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
|
|
637
1089
|
resultButtonPressed: { opacity: 0.8 },
|
|
1090
|
+
// Minor finding, Fable correction pass #2: the same flat-grey treatment `CatalogButton.disabled`
|
|
1091
|
+
// uses, so "unavailable" doesn't read as merely "pressed" (the note's own full-colour fill at
|
|
1092
|
+
// reduced opacity).
|
|
1093
|
+
resultButtonDisabled: { backgroundColor: CATALOG_COLOR.controlDisabled },
|
|
638
1094
|
resultButtonText: { color: '#ffffff', fontSize: CATALOG_TYPE.sm, fontWeight: '700' },
|
|
1095
|
+
resultButtonTextDisabled: { color: CATALOG_COLOR.controlDisabledText },
|
|
639
1096
|
resultLink: { fontSize: CATALOG_TYPE.panelHeading, fontWeight: '700', textDecorationLine: 'underline' },
|
|
640
1097
|
crumb: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
|
|
641
1098
|
headingTarget: { marginTop: 4, marginBottom: 6 },
|
|
@@ -681,7 +1138,8 @@ const styles = StyleSheet.create({
|
|
|
681
1138
|
fileReasonDirty: { color: CATALOG_COLOR.warning, fontWeight: '700' },
|
|
682
1139
|
actions: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.md, marginTop: 20 },
|
|
683
1140
|
noteBox: { borderRadius: CATALOG_RADIUS.md, padding: 12, marginTop: 18 },
|
|
684
|
-
|
|
1141
|
+
noteBoxRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 9 },
|
|
1142
|
+
noteBoxText: { fontSize: CATALOG_TYPE.md, flexShrink: 1 },
|
|
685
1143
|
noteBoxBold: { fontWeight: '700' },
|
|
686
1144
|
steps: { marginTop: 12 },
|
|
687
1145
|
stepRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 6 },
|
|
@@ -219,3 +219,176 @@ export function resultNote(
|
|
|
219
219
|
export function isRemoteOrigin(protocol: string, hostname: string): boolean {
|
|
220
220
|
return !(protocol === 'http:' && hostname === 'localhost');
|
|
221
221
|
}
|
|
222
|
+
|
|
223
|
+
/** `VersionDetails`' own near-miss message, when the endpoint answered with a non-ok status (not
|
|
224
|
+
* a rejected fetch) and the page is open from a tunnelled/remote origin. Pulled out as its own
|
|
225
|
+
* function only so a test can pin it exactly — the approved design leaves this condition alone
|
|
226
|
+
* ("Keep the existing distinct remote-origin/tunnel message exactly as is"). */
|
|
227
|
+
export function remoteOriginLocalErrorMessage(): string {
|
|
228
|
+
return 'This page is open from another address, and the viewer only takes actions from the machine it runs on. Open the catalog on that machine, or run npx ds-viewer update there.';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The result of one endpoint call, classified the one way that matters for self-healing: did
|
|
232
|
+
* `fetch` itself reject (the socket is simply gone — *unreachable*, see the implementation
|
|
233
|
+
* brief), or did it resolve with a response at all, however unhappy (401/409/502 included — the
|
|
234
|
+
* endpoint answered, so the caller's existing status/body handling is unchanged and untouched by
|
|
235
|
+
* this module). Every endpoint call on the update page routes through this one function so "one
|
|
236
|
+
* condition has one treatment" instead of each call site re-deriving it. */
|
|
237
|
+
export type EndpointOutcome = { kind: 'ok'; response: Response } | { kind: 'unreachable' };
|
|
238
|
+
|
|
239
|
+
export async function requestOrUnreachable(doFetch: () => Promise<Response>): Promise<EndpointOutcome> {
|
|
240
|
+
try {
|
|
241
|
+
return { kind: 'ok', response: await doFetch() };
|
|
242
|
+
} catch {
|
|
243
|
+
return { kind: 'unreachable' };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Critical finding, Fable correction pass: `cli/endpoint.ts` answers a rejected `Origin` with
|
|
248
|
+
* 403 and *no* CORS headers, so the browser blocks the response and the page's own `fetch`
|
|
249
|
+
* rejects — exactly like a genuinely dead socket. Without this check, that rejection was routed
|
|
250
|
+
* straight into the page-wide reachability flow, which reloads and then polls forever for a
|
|
251
|
+
* server that was never down; self-healing is meaningless for a page that can never use the
|
|
252
|
+
* endpoint in the first place (a tunnel, or `isRemoteOrigin`'s own `127.0.0.1` case). Every call
|
|
253
|
+
* site with a rejected fetch must check this *before* calling into the reachability flow, and
|
|
254
|
+
* keep showing `remoteOriginLocalErrorMessage()` instead. */
|
|
255
|
+
export function classifyRejectedFetch(isRemoteOriginPage: boolean): 'remote-origin' | 'unreachable' {
|
|
256
|
+
return isRemoteOriginPage ? 'remote-origin' : 'unreachable';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The pre-existing offline plan-error outcome (`fetchPlan`'s own `catch`) — pulled out so the one
|
|
260
|
+
* other place that needs it (below) can't drift from this wording. */
|
|
261
|
+
export const PLAN_FETCH_ERROR: { error: string } = { error: "Couldn't prepare the update. You may be offline, or npm didn't respond. Nothing was changed." };
|
|
262
|
+
|
|
263
|
+
/** Correction pass 3: a background `fetchPlan` whose fetch was rejected routes through
|
|
264
|
+
* `routeUnreachableInBackground`, which refuses to act for a remote-origin page (it must never
|
|
265
|
+
* latch the page-wide `remoteOriginError` state for a call the user didn't make — correction pass
|
|
266
|
+
* #2). That refusal used to be the end of it: `planResult` stayed `undefined` forever, and
|
|
267
|
+
* `resolvePanelPhase` reads that as `'checking'` with no button and no way out. `handled` is
|
|
268
|
+
* whatever that route reports back — `true` once it has entered the reachability flow (which owns
|
|
269
|
+
* the page from there), `false` when it declined to act. Only the `false` case needs anything
|
|
270
|
+
* here: fall back to the exact same plan-error note (and working **Try again**) the pre-existing
|
|
271
|
+
* `catch` already shows for every other kind of plan failure. */
|
|
272
|
+
export function backgroundPlanFetchOutcome(handled: boolean): { error: string } | undefined {
|
|
273
|
+
return handled ? undefined : PLAN_FETCH_ERROR;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Critical finding, Fable correction pass: the restart-handoff rule ("a failed `/update/status`
|
|
277
|
+
* poll while an update was already in flight or restarting means 'still restarting,' not
|
|
278
|
+
* unreachable") used to be decided by reading a value assigned inside the very `setStatus`
|
|
279
|
+
* updater that decided it — updaters run during React's render phase, not at dispatch, so under
|
|
280
|
+
* any pending update the read could see stale or re-invoked state. Pulled out as a pure function
|
|
281
|
+
* of the *already-committed* phase (read from a ref, never from inside an updater) so the
|
|
282
|
+
* decision is both correct and independently testable. `undefined` means "not mid-update": the
|
|
283
|
+
* caller must still classify the failure itself (unreachable vs. remote-origin, vs. a delivered
|
|
284
|
+
* but unusable response). */
|
|
285
|
+
export function statusPollFailureOutcome(currentPhase: UpdateStatusView['phase']): UpdateStatusView | undefined {
|
|
286
|
+
return currentPhase === 'updating' || currentPhase === 'restarting' ? { phase: 'restarting' } : undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Correction pass 4 (Critical): a stable endpoint address means the restarted server answers the
|
|
290
|
+
* already-open page's poll *before* Metro has served the new bundle — same host, same pinned
|
|
291
|
+
* Metro origin, same reused secret, so the poll succeeds and would otherwise accept the delivered
|
|
292
|
+
* `{ phase: 'success' }` onto the still-old page. `restarting` must be terminal for the page: only
|
|
293
|
+
* the Metro-origin reload poller (which owns the actual reload) may leave it, by delivering
|
|
294
|
+
* `'restarting'` right back — every other delivered phase while `restarting` is stale and must be
|
|
295
|
+
* ignored. */
|
|
296
|
+
export function acceptsDeliveredStatus(current: UpdateStatusView['phase'], next: UpdateStatusView['phase']): boolean {
|
|
297
|
+
return current !== 'restarting' || next === 'restarting';
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Correction pass 5 (Important): whether a just-fetched plan is the offline error note rather
|
|
301
|
+
* than a usable plan — `/plan` answers 200 with an `{ error }` body for that case, so a reached
|
|
302
|
+
* plan fetch alone doesn't mean "Everything below is current." Takes the plan straight from the
|
|
303
|
+
* fetch that produced it, never from a ref a passive effect fills in later, so this reads the
|
|
304
|
+
* value that was actually just stored. */
|
|
305
|
+
export function planShowsError(result?: UpdatePlanView | { error: string }): boolean {
|
|
306
|
+
return result !== undefined && 'error' in result && !isAlreadyUpToDateError(result);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The approved design's unreachable-recovery states (`waiting`/`stuck` replace this page's normal
|
|
310
|
+
* content; `recovered` is a transient banner over content that's already current). Never
|
|
311
|
+
* `'ok'`/absent here — the panel represents "nothing wrong" as no `ReachabilityState` at all, the
|
|
312
|
+
* same way `UpdateStatusView`'s `idle` phase represents nothing in flight.
|
|
313
|
+
*
|
|
314
|
+
* Final slice: a stable endpoint address (`cli/dev.ts` now reuses its port and secret across
|
|
315
|
+
* restarts) means an ordinary restart recovers through the `waiting` poller's own direct endpoint
|
|
316
|
+
* retry alone — no reload is needed, so there is no `reconnecting` phase anymore, and nothing in
|
|
317
|
+
* this module ever calls `window.location.reload()`. The only reload left is the user's own
|
|
318
|
+
* explicit press of **Reconnect** on `stuck`, handled entirely in `UpdatePanel.tsx`. */
|
|
319
|
+
export type ReachabilityPhase = 'waiting' | 'recovered' | 'stuck';
|
|
320
|
+
|
|
321
|
+
export interface ReachabilityState {
|
|
322
|
+
phase: ReachabilityPhase;
|
|
323
|
+
/** Consecutive failed endpoint retries while `waiting` — the bound `nextWaitingAttempt` gives up
|
|
324
|
+
* at. Meaningless (and left unchanged) for every other phase. */
|
|
325
|
+
attempts: number;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** ~20 seconds of 500ms retries (the same interval the existing restart flow already polls at) —
|
|
329
|
+
* long enough for someone to actually type `npx ds-viewer dev`, short enough that the page
|
|
330
|
+
* doesn't spin forever before offering the manual `Reconnect`. */
|
|
331
|
+
export const REACHABILITY_MAX_ATTEMPTS = 40;
|
|
332
|
+
|
|
333
|
+
/** One more failed attempt while `waiting`: advances the bounded counter and gives up once it
|
|
334
|
+
* reaches `REACHABILITY_MAX_ATTEMPTS`, exactly as the approved design's "Edge · Still not back"
|
|
335
|
+
* state requires. The caller (the `waiting` poller) only calls this when its own direct retry
|
|
336
|
+
* against the endpoint has already failed; a retry that succeeds recovers in place and never
|
|
337
|
+
* reaches this function at all. */
|
|
338
|
+
export function nextWaitingAttempt(current: ReachabilityState): ReachabilityState {
|
|
339
|
+
const attempts = current.attempts + 1;
|
|
340
|
+
return { phase: attempts >= REACHABILITY_MAX_ATTEMPTS ? 'stuck' : 'waiting', attempts };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The manual **Reconnect** button on the gave-up state: starts the same bounded cycle over from
|
|
344
|
+
* zero, exactly like the first time an endpoint call found the viewer unreachable. `UpdatePanel.tsx`
|
|
345
|
+
* pairs this with its own, separate `window.location.reload()` — one press, one reload, initiated
|
|
346
|
+
* by the user, so no marker or loop guard is needed here or anywhere else. */
|
|
347
|
+
export function restartReachability(): ReachabilityState {
|
|
348
|
+
return { phase: 'waiting', attempts: 0 };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Called on every endpoint call that actually succeeds. Most of the time there was nothing to
|
|
352
|
+
* recover from (`current` is `undefined`) and this is a no-op — the ordinary case, which must not
|
|
353
|
+
* show a "Reconnected" banner on every routine poll. Otherwise the panel shows the approved
|
|
354
|
+
* design's auto-dismissing `recovered` note while its normal content — already current, since the
|
|
355
|
+
* endpoint itself just answered — resumes underneath. */
|
|
356
|
+
export function recoveredReachabilityState(current: ReachabilityState | undefined): ReachabilityState | undefined {
|
|
357
|
+
if (current === undefined) return undefined;
|
|
358
|
+
return { phase: 'recovered', attempts: 0 };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export interface ReachabilityNote {
|
|
362
|
+
variant: 'muted' | 'ok';
|
|
363
|
+
spinner: boolean;
|
|
364
|
+
bold?: string;
|
|
365
|
+
text: string;
|
|
366
|
+
/** A recovery command to render in `Code`, split from its lead-in text so the component can
|
|
367
|
+
* place the two in separate `Text`/`Code` nodes. */
|
|
368
|
+
hint?: { prefix: string; command: string };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The exact approved copy (`.hermes-task/approved-unreachable-states.html`) for each reachability
|
|
372
|
+
* phase — never invent wording beyond this. */
|
|
373
|
+
export function reachabilityNote(phase: ReachabilityPhase): ReachabilityNote {
|
|
374
|
+
switch (phase) {
|
|
375
|
+
case 'waiting':
|
|
376
|
+
return {
|
|
377
|
+
variant: 'muted',
|
|
378
|
+
spinner: true,
|
|
379
|
+
bold: 'The viewer isn’t running.',
|
|
380
|
+
text: ' Nothing was changed. This page reconnects by itself as soon as it’s back.',
|
|
381
|
+
hint: { prefix: 'Start it with ', command: 'npx ds-viewer dev' },
|
|
382
|
+
};
|
|
383
|
+
case 'recovered':
|
|
384
|
+
return { variant: 'ok', spinner: false, text: 'Reconnected. Everything below is current.' };
|
|
385
|
+
case 'stuck':
|
|
386
|
+
return {
|
|
387
|
+
variant: 'muted',
|
|
388
|
+
spinner: false,
|
|
389
|
+
bold: 'The viewer still isn’t answering.',
|
|
390
|
+
text: ' Nothing was changed.',
|
|
391
|
+
hint: { prefix: 'or start it with ', command: 'npx ds-viewer dev' },
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
}
|