@remit/web-client 0.0.61 → 0.0.63
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/package.json +1 -1
- package/src/components/mail/AutoMovedIndicator.tsx +2 -1
- package/src/components/self-update/SelfUpdateOverlay.tsx +45 -0
- package/src/components/settings/AdvancedNavIcon.tsx +23 -0
- package/src/components/settings/SelfUpdatePanel.tsx +47 -0
- package/src/hooks/use-system-update.test.ts +342 -0
- package/src/hooks/use-system-update.ts +215 -0
- package/src/hooks/useAutoMovedBadge.ts +40 -9
- package/src/lib/auto-moved.test.ts +135 -49
- package/src/lib/auto-moved.ts +45 -19
- package/src/lib/self-update-state.test.ts +517 -0
- package/src/lib/self-update-state.ts +564 -0
- package/src/routes/__root.tsx +13 -8
- package/src/routes/settings/advanced.tsx +7 -4
- package/src/routes/settings.tsx +3 -9
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The self-update state machine, isolated from React.
|
|
3
|
+
*
|
|
4
|
+
* `GET /system/update` returns three independent things: the running version,
|
|
5
|
+
* the outcome of the last manifest check, and the state of the current or last
|
|
6
|
+
* run. This module folds that response — plus a run id the client persisted
|
|
7
|
+
* before the server restarted, plus the clock — into the single `SelfUpdateState`
|
|
8
|
+
* the `@remit/ui` components render, and into the full-window blocking overlay.
|
|
9
|
+
*
|
|
10
|
+
* The design constraints (RFC 037 Interface, issue #135) live here:
|
|
11
|
+
* - A held run id turns a failed request into `applying`, never `unreachable`.
|
|
12
|
+
* - Once the apply budget plus a margin has passed with no answer, the same
|
|
13
|
+
* failure becomes "the server never came back" — a state that never claims
|
|
14
|
+
* the rollback ran, because from a dead connection the client cannot know.
|
|
15
|
+
* - The check block and the run block are independent: a check that cannot
|
|
16
|
+
* reach the update source is a failed check, never a failed update.
|
|
17
|
+
*/
|
|
18
|
+
import type {
|
|
19
|
+
RemitImapSystemUpdateOutcome,
|
|
20
|
+
RemitImapSystemUpdatePhase,
|
|
21
|
+
RemitImapSystemUpdateResponse,
|
|
22
|
+
RemitImapSystemUpdateRun,
|
|
23
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
24
|
+
import type { ReleaseInfo, SelfUpdateState, UpdatePhase } from "@remit/ui";
|
|
25
|
+
import { getErrorStatus } from "./error-classifier";
|
|
26
|
+
|
|
27
|
+
/** localStorage key holding the run the client is resuming across a restart. */
|
|
28
|
+
export const SELF_UPDATE_RUN_KEY = "remit.self-update.run";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The longest an apply can plausibly take — pull, snapshot, stop, start, gate —
|
|
32
|
+
* before a silent server is treated as gone rather than still working. A five
|
|
33
|
+
* minute margin is added on top before the client gives up entirely.
|
|
34
|
+
*/
|
|
35
|
+
export const APPLY_BUDGET_SECONDS = 600;
|
|
36
|
+
export const NEVER_CAME_BACK_MARGIN_SECONDS = 300;
|
|
37
|
+
|
|
38
|
+
/** Shown on the dead-connection screens, where no server-authored command exists. */
|
|
39
|
+
export const FALLBACK_LOGS_COMMAND = "remit logs";
|
|
40
|
+
|
|
41
|
+
const RELEASE_TAG_BASE = "https://github.com/remit-mail/reader/releases/tag/";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The record the client persists the moment it asks for an update, so a reload
|
|
45
|
+
* or a second tab can resume watching a run that outlives the page that started
|
|
46
|
+
* it. Everything needed to render the blocking screens without a reachable
|
|
47
|
+
* server is here.
|
|
48
|
+
*/
|
|
49
|
+
export interface HeldRun {
|
|
50
|
+
runId: string;
|
|
51
|
+
attemptedVersion: string;
|
|
52
|
+
previousVersion: string;
|
|
53
|
+
/** Epoch millis when the client began holding this run. */
|
|
54
|
+
startedAt: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type UpdateOverlay =
|
|
58
|
+
| { kind: "none" }
|
|
59
|
+
| {
|
|
60
|
+
kind: "applying";
|
|
61
|
+
target: string;
|
|
62
|
+
phase: UpdatePhase;
|
|
63
|
+
elapsedSeconds: number;
|
|
64
|
+
}
|
|
65
|
+
| {
|
|
66
|
+
kind: "neverCameBack";
|
|
67
|
+
attemptedVersion: string;
|
|
68
|
+
previousVersion: string;
|
|
69
|
+
elapsedSeconds: number;
|
|
70
|
+
logsCommand: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type UpdateSurface =
|
|
74
|
+
| { status: "absent" }
|
|
75
|
+
| { status: "loading" }
|
|
76
|
+
| { status: "ready"; section: SelfUpdateState; overlay: UpdateOverlay };
|
|
77
|
+
|
|
78
|
+
export interface DeriveInput {
|
|
79
|
+
data: RemitImapSystemUpdateResponse | undefined;
|
|
80
|
+
isError: boolean;
|
|
81
|
+
error: unknown;
|
|
82
|
+
isFetching: boolean;
|
|
83
|
+
held: HeldRun | null;
|
|
84
|
+
dismissedRunId: string | null;
|
|
85
|
+
checkRequested: boolean;
|
|
86
|
+
now: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface DeriveResult {
|
|
90
|
+
surface: UpdateSurface;
|
|
91
|
+
/** The persisted resume token should be removed from localStorage. */
|
|
92
|
+
clearStoredRun: boolean;
|
|
93
|
+
/** The in-memory held run should be dropped — the run is fully resolved. */
|
|
94
|
+
releaseHeld: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const API_PHASE_TO_UI: Record<RemitImapSystemUpdatePhase, UpdatePhase> = {
|
|
98
|
+
checking: "preparing",
|
|
99
|
+
pulling: "preparing",
|
|
100
|
+
snapshotting: "preparing",
|
|
101
|
+
stopping: "restarting",
|
|
102
|
+
starting: "restarting",
|
|
103
|
+
verifying: "reconnecting",
|
|
104
|
+
committing: "reconnecting",
|
|
105
|
+
rollingBack: "reconnecting",
|
|
106
|
+
recovering: "reconnecting",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export function mapUpdatePhase(phase: RemitImapSystemUpdatePhase): UpdatePhase {
|
|
110
|
+
return API_PHASE_TO_UI[phase] ?? "reconnecting";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The surface has no entry point for this caller: `404` (no manifest URL
|
|
115
|
+
* configured), `403` (authenticated but not the instance owner), or `401` (not
|
|
116
|
+
* authenticated). All three render nothing and stop the poll, so a probe cannot
|
|
117
|
+
* tell an off surface from a forbidden one.
|
|
118
|
+
*/
|
|
119
|
+
export function isSurfaceAbsent(error: unknown): boolean {
|
|
120
|
+
const status = getErrorStatus(error);
|
|
121
|
+
return status === 404 || status === 403 || status === 401;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The available release from the check block, or undefined when the check
|
|
126
|
+
* reports none. Independent of what the pane is currently showing, so a retry
|
|
127
|
+
* offered from a failed run can still open consent.
|
|
128
|
+
*/
|
|
129
|
+
export function releaseFromCheck(
|
|
130
|
+
data: RemitImapSystemUpdateResponse | undefined,
|
|
131
|
+
now: number,
|
|
132
|
+
): ReleaseInfo | undefined {
|
|
133
|
+
const check = data?.check;
|
|
134
|
+
if (check?.status !== "ok") return undefined;
|
|
135
|
+
if (!check.updateAvailable || !check.latestVersion) return undefined;
|
|
136
|
+
return {
|
|
137
|
+
version: check.latestVersion,
|
|
138
|
+
releasedAt: parseIso(check.publishedAt) ?? now,
|
|
139
|
+
releaseNotesUrl:
|
|
140
|
+
check.releaseNotesUrl ?? releaseNotesUrl(check.latestVersion),
|
|
141
|
+
summary: check.summary ?? "",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Whether installing the latest release runs a schema migration during the
|
|
147
|
+
* offline window. Derived in the client from the two versions the surface
|
|
148
|
+
* carries — the API deliberately ships the versions, never a computed boolean.
|
|
149
|
+
* Silent unless both are present and the release is on a higher schema.
|
|
150
|
+
*/
|
|
151
|
+
export function appliesSchemaMigration(
|
|
152
|
+
data: RemitImapSystemUpdateResponse | undefined,
|
|
153
|
+
): boolean {
|
|
154
|
+
const target = data?.check.schemaVersion;
|
|
155
|
+
const current = data?.currentSchemaVersion;
|
|
156
|
+
if (target === undefined || current === undefined) return false;
|
|
157
|
+
return target > current;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function loadHeldRun(): HeldRun | null {
|
|
161
|
+
try {
|
|
162
|
+
const raw = localStorage.getItem(SELF_UPDATE_RUN_KEY);
|
|
163
|
+
if (!raw) return null;
|
|
164
|
+
const parsed: unknown = JSON.parse(raw);
|
|
165
|
+
if (!isHeldRun(parsed)) return null;
|
|
166
|
+
return parsed;
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function saveHeldRun(run: HeldRun): void {
|
|
173
|
+
try {
|
|
174
|
+
localStorage.setItem(SELF_UPDATE_RUN_KEY, JSON.stringify(run));
|
|
175
|
+
} catch {
|
|
176
|
+
// Best effort: private mode or quota. A lost token degrades resume, not
|
|
177
|
+
// correctness — the server remains the authority on the run.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function clearStoredRun(): void {
|
|
182
|
+
try {
|
|
183
|
+
localStorage.removeItem(SELF_UPDATE_RUN_KEY);
|
|
184
|
+
} catch {
|
|
185
|
+
// Best effort, as above.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isHeldRun(value: unknown): value is HeldRun {
|
|
190
|
+
if (!value || typeof value !== "object") return false;
|
|
191
|
+
const candidate = value as Record<string, unknown>;
|
|
192
|
+
return (
|
|
193
|
+
typeof candidate.runId === "string" &&
|
|
194
|
+
typeof candidate.attemptedVersion === "string" &&
|
|
195
|
+
typeof candidate.previousVersion === "string" &&
|
|
196
|
+
typeof candidate.startedAt === "number"
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function budgetLimitSeconds(): number {
|
|
201
|
+
return APPLY_BUDGET_SECONDS + NEVER_CAME_BACK_MARGIN_SECONDS;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function elapsedSince(startMillis: number, now: number): number {
|
|
205
|
+
return Math.max(0, Math.floor((now - startMillis) / 1000));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function parseIso(value: string | undefined): number | undefined {
|
|
209
|
+
if (!value) return undefined;
|
|
210
|
+
const parsed = Date.parse(value);
|
|
211
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function releaseNotesUrl(version: string): string {
|
|
215
|
+
return `${RELEASE_TAG_BASE}${version}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function applyingSection(
|
|
219
|
+
runId: string,
|
|
220
|
+
version: string,
|
|
221
|
+
target: string,
|
|
222
|
+
phase: UpdatePhase,
|
|
223
|
+
elapsedSeconds: number,
|
|
224
|
+
): SelfUpdateState {
|
|
225
|
+
return { status: "applying", runId, version, target, phase, elapsedSeconds };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function terminalSection(
|
|
229
|
+
data: RemitImapSystemUpdateResponse,
|
|
230
|
+
run: RemitImapSystemUpdateRun,
|
|
231
|
+
outcome: RemitImapSystemUpdateOutcome,
|
|
232
|
+
): SelfUpdateState {
|
|
233
|
+
switch (outcome) {
|
|
234
|
+
case "succeeded":
|
|
235
|
+
return {
|
|
236
|
+
status: "succeeded",
|
|
237
|
+
runId: run.runId,
|
|
238
|
+
version: run.targetVersion,
|
|
239
|
+
previousVersion: run.fromVersion,
|
|
240
|
+
releaseNotesUrl:
|
|
241
|
+
data.check.releaseNotesUrl ?? releaseNotesUrl(run.targetVersion),
|
|
242
|
+
};
|
|
243
|
+
case "rolledBack":
|
|
244
|
+
return {
|
|
245
|
+
status: "rolledBack",
|
|
246
|
+
runId: run.runId,
|
|
247
|
+
version: run.fromVersion,
|
|
248
|
+
attemptedVersion: run.targetVersion,
|
|
249
|
+
reason: run.message,
|
|
250
|
+
logsCommand: run.logCommand,
|
|
251
|
+
};
|
|
252
|
+
case "rollbackFailed":
|
|
253
|
+
return {
|
|
254
|
+
status: "rollbackFailed",
|
|
255
|
+
runId: run.runId,
|
|
256
|
+
attemptedVersion: run.targetVersion,
|
|
257
|
+
previousVersion: run.fromVersion,
|
|
258
|
+
reason: run.message,
|
|
259
|
+
logsCommand: run.logCommand,
|
|
260
|
+
};
|
|
261
|
+
case "abandoned":
|
|
262
|
+
return {
|
|
263
|
+
status: "abandoned",
|
|
264
|
+
runId: run.runId,
|
|
265
|
+
version: run.fromVersion,
|
|
266
|
+
attemptedVersion: run.targetVersion,
|
|
267
|
+
reason: run.message,
|
|
268
|
+
logsCommand: run.logCommand,
|
|
269
|
+
};
|
|
270
|
+
default: {
|
|
271
|
+
const exhaustive: never = outcome;
|
|
272
|
+
return exhaustive;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function checkSection(
|
|
278
|
+
data: RemitImapSystemUpdateResponse,
|
|
279
|
+
isChecking: boolean,
|
|
280
|
+
now: number,
|
|
281
|
+
): SelfUpdateState {
|
|
282
|
+
const check = data.check;
|
|
283
|
+
if (isChecking) return { status: "checking", version: data.currentVersion };
|
|
284
|
+
|
|
285
|
+
if (check.status === "failed") {
|
|
286
|
+
return {
|
|
287
|
+
status: "checkFailed",
|
|
288
|
+
version: data.currentVersion,
|
|
289
|
+
reason: check.error ?? "Remit could not reach the update service.",
|
|
290
|
+
lastCheckedAt: parseIso(check.lastCheckedAt),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// A configured surface that has not run its first check yet reads as looking,
|
|
295
|
+
// not as up to date — the client has no basis to claim the latest.
|
|
296
|
+
if (check.status === "disabled") {
|
|
297
|
+
return { status: "checking", version: data.currentVersion };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const release = releaseFromCheck(data, now);
|
|
301
|
+
if (release) {
|
|
302
|
+
return { status: "available", version: data.currentVersion, release };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
status: "upToDate",
|
|
307
|
+
version: data.currentVersion,
|
|
308
|
+
checkedAt: parseIso(check.lastCheckedAt) ?? now,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function ready(
|
|
313
|
+
section: SelfUpdateState,
|
|
314
|
+
overlay: UpdateOverlay,
|
|
315
|
+
clearStoredRun: boolean,
|
|
316
|
+
releaseHeld: boolean,
|
|
317
|
+
): DeriveResult {
|
|
318
|
+
return {
|
|
319
|
+
surface: { status: "ready", section, overlay },
|
|
320
|
+
clearStoredRun,
|
|
321
|
+
releaseHeld,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* A held run resolves to one of: still applying, gave up ("never came back"),
|
|
327
|
+
* recovered but unaccountable, or — returning `null` — resolved terminally on
|
|
328
|
+
* the server, in which case the caller renders the outcome from the response.
|
|
329
|
+
*/
|
|
330
|
+
function deriveHeld(
|
|
331
|
+
held: HeldRun,
|
|
332
|
+
data: RemitImapSystemUpdateResponse | undefined,
|
|
333
|
+
run: RemitImapSystemUpdateRun | null,
|
|
334
|
+
isError: boolean,
|
|
335
|
+
now: number,
|
|
336
|
+
): DeriveResult | null {
|
|
337
|
+
const elapsedSeconds = elapsedSince(held.startedAt, now);
|
|
338
|
+
const currentVersion = data?.currentVersion ?? held.previousVersion;
|
|
339
|
+
const matched = !isError && run !== null && run.runId === held.runId;
|
|
340
|
+
|
|
341
|
+
if (matched && run !== null && run.outcome !== null) return null;
|
|
342
|
+
|
|
343
|
+
if (matched && run !== null) {
|
|
344
|
+
const phase = mapUpdatePhase(run.phase);
|
|
345
|
+
return ready(
|
|
346
|
+
applyingSection(
|
|
347
|
+
run.runId,
|
|
348
|
+
currentVersion,
|
|
349
|
+
run.targetVersion,
|
|
350
|
+
phase,
|
|
351
|
+
elapsedSeconds,
|
|
352
|
+
),
|
|
353
|
+
{ kind: "applying", target: run.targetVersion, phase, elapsedSeconds },
|
|
354
|
+
false,
|
|
355
|
+
false,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (isError) {
|
|
360
|
+
const section = applyingSection(
|
|
361
|
+
held.runId,
|
|
362
|
+
held.previousVersion,
|
|
363
|
+
held.attemptedVersion,
|
|
364
|
+
"reconnecting",
|
|
365
|
+
elapsedSeconds,
|
|
366
|
+
);
|
|
367
|
+
if (elapsedSeconds > budgetLimitSeconds()) {
|
|
368
|
+
return {
|
|
369
|
+
surface: {
|
|
370
|
+
status: "ready",
|
|
371
|
+
section,
|
|
372
|
+
overlay: {
|
|
373
|
+
kind: "neverCameBack",
|
|
374
|
+
attemptedVersion: held.attemptedVersion,
|
|
375
|
+
previousVersion: held.previousVersion,
|
|
376
|
+
elapsedSeconds,
|
|
377
|
+
logsCommand: FALLBACK_LOGS_COMMAND,
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
clearStoredRun: true,
|
|
381
|
+
releaseHeld: false,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
return ready(
|
|
385
|
+
section,
|
|
386
|
+
{
|
|
387
|
+
kind: "applying",
|
|
388
|
+
target: held.attemptedVersion,
|
|
389
|
+
phase: "reconnecting",
|
|
390
|
+
elapsedSeconds,
|
|
391
|
+
},
|
|
392
|
+
false,
|
|
393
|
+
false,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// No answer yet — the resume request is still in flight. Stay applying; only
|
|
398
|
+
// a real error or a real answer moves off it, never a pending first poll.
|
|
399
|
+
if (data === undefined) {
|
|
400
|
+
return ready(
|
|
401
|
+
applyingSection(
|
|
402
|
+
held.runId,
|
|
403
|
+
currentVersion,
|
|
404
|
+
held.attemptedVersion,
|
|
405
|
+
"preparing",
|
|
406
|
+
elapsedSeconds,
|
|
407
|
+
),
|
|
408
|
+
{
|
|
409
|
+
kind: "applying",
|
|
410
|
+
target: held.attemptedVersion,
|
|
411
|
+
phase: "preparing",
|
|
412
|
+
elapsedSeconds,
|
|
413
|
+
},
|
|
414
|
+
false,
|
|
415
|
+
false,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// The server answered, but not with our run. If we have been gone longer than
|
|
420
|
+
// the budget, we cannot reconcile the silence — say so and point at the log.
|
|
421
|
+
if (elapsedSeconds > budgetLimitSeconds()) {
|
|
422
|
+
return {
|
|
423
|
+
surface: {
|
|
424
|
+
status: "ready",
|
|
425
|
+
section: {
|
|
426
|
+
status: "unreachable",
|
|
427
|
+
runId: held.runId,
|
|
428
|
+
previousVersion: held.previousVersion,
|
|
429
|
+
attemptedVersion: held.attemptedVersion,
|
|
430
|
+
elapsedSeconds,
|
|
431
|
+
logsCommand: run?.logCommand ?? FALLBACK_LOGS_COMMAND,
|
|
432
|
+
},
|
|
433
|
+
overlay: { kind: "none" },
|
|
434
|
+
},
|
|
435
|
+
clearStoredRun: true,
|
|
436
|
+
releaseHeld: true,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Early: the server is up but has not written our run yet. Still applying.
|
|
441
|
+
const phase =
|
|
442
|
+
run !== null && run.outcome === null
|
|
443
|
+
? mapUpdatePhase(run.phase)
|
|
444
|
+
: "preparing";
|
|
445
|
+
return ready(
|
|
446
|
+
applyingSection(
|
|
447
|
+
held.runId,
|
|
448
|
+
currentVersion,
|
|
449
|
+
held.attemptedVersion,
|
|
450
|
+
phase,
|
|
451
|
+
elapsedSeconds,
|
|
452
|
+
),
|
|
453
|
+
{ kind: "applying", target: held.attemptedVersion, phase, elapsedSeconds },
|
|
454
|
+
false,
|
|
455
|
+
false,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function displayFromData(input: DeriveInput): {
|
|
460
|
+
surface: UpdateSurface;
|
|
461
|
+
clearStoredRun: boolean;
|
|
462
|
+
} {
|
|
463
|
+
const { data, isError, isFetching, dismissedRunId, checkRequested, now } =
|
|
464
|
+
input;
|
|
465
|
+
|
|
466
|
+
if (isError) {
|
|
467
|
+
return {
|
|
468
|
+
surface: {
|
|
469
|
+
status: "ready",
|
|
470
|
+
section: {
|
|
471
|
+
status: "checkFailed",
|
|
472
|
+
version: data?.currentVersion ?? "the current version",
|
|
473
|
+
reason: "Remit could not reach the update service.",
|
|
474
|
+
},
|
|
475
|
+
overlay: { kind: "none" },
|
|
476
|
+
},
|
|
477
|
+
clearStoredRun: true,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (!data) {
|
|
482
|
+
return { surface: { status: "loading" }, clearStoredRun: false };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const run = data.run;
|
|
486
|
+
const dismissed =
|
|
487
|
+
run !== null && dismissedRunId !== null && run.runId === dismissedRunId;
|
|
488
|
+
|
|
489
|
+
if (run !== null && !dismissed && run.outcome !== null) {
|
|
490
|
+
return {
|
|
491
|
+
surface: {
|
|
492
|
+
status: "ready",
|
|
493
|
+
section: terminalSection(data, run, run.outcome),
|
|
494
|
+
overlay: { kind: "none" },
|
|
495
|
+
},
|
|
496
|
+
clearStoredRun: true,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (run !== null && !dismissed && run.outcome === null) {
|
|
501
|
+
const elapsedSeconds = elapsedSince(parseIso(run.startedAt) ?? now, now);
|
|
502
|
+
const phase = mapUpdatePhase(run.phase);
|
|
503
|
+
return {
|
|
504
|
+
surface: {
|
|
505
|
+
status: "ready",
|
|
506
|
+
section: applyingSection(
|
|
507
|
+
run.runId,
|
|
508
|
+
run.fromVersion,
|
|
509
|
+
run.targetVersion,
|
|
510
|
+
phase,
|
|
511
|
+
elapsedSeconds,
|
|
512
|
+
),
|
|
513
|
+
overlay: {
|
|
514
|
+
kind: "applying",
|
|
515
|
+
target: run.targetVersion,
|
|
516
|
+
phase,
|
|
517
|
+
elapsedSeconds,
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
clearStoredRun: false,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
surface: {
|
|
526
|
+
status: "ready",
|
|
527
|
+
section: checkSection(data, checkRequested && isFetching, now),
|
|
528
|
+
overlay: { kind: "none" },
|
|
529
|
+
},
|
|
530
|
+
clearStoredRun: false,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export function deriveUpdateSurface(input: DeriveInput): DeriveResult {
|
|
535
|
+
const { data, isError, error, held, now } = input;
|
|
536
|
+
const run = data?.run ?? null;
|
|
537
|
+
|
|
538
|
+
if (isSurfaceAbsent(error) && !held) {
|
|
539
|
+
return {
|
|
540
|
+
surface: { status: "absent" },
|
|
541
|
+
clearStoredRun: true,
|
|
542
|
+
releaseHeld: true,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (held) {
|
|
547
|
+
const heldResult = deriveHeld(held, data, run, isError, now);
|
|
548
|
+
if (heldResult) return heldResult;
|
|
549
|
+
// Our run resolved terminally — render it from the response and let go.
|
|
550
|
+
const resolved = displayFromData(input);
|
|
551
|
+
return {
|
|
552
|
+
surface: resolved.surface,
|
|
553
|
+
clearStoredRun: true,
|
|
554
|
+
releaseHeld: true,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const display = displayFromData(input);
|
|
559
|
+
return {
|
|
560
|
+
surface: display.surface,
|
|
561
|
+
clearStoredRun: display.clearStoredRun,
|
|
562
|
+
releaseHeld: false,
|
|
563
|
+
};
|
|
564
|
+
}
|
package/src/routes/__root.tsx
CHANGED
|
@@ -7,11 +7,13 @@ import { Suspense } from "react";
|
|
|
7
7
|
import { useTranslation } from "react-i18next";
|
|
8
8
|
import { ComposeProvider } from "@/components/compose/ComposeProvider";
|
|
9
9
|
import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
|
|
10
|
+
import { SelfUpdateOverlay } from "@/components/self-update/SelfUpdateOverlay";
|
|
10
11
|
import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
|
|
11
12
|
import {
|
|
12
13
|
FatalErrorOverlay,
|
|
13
14
|
FatalErrorScreen,
|
|
14
15
|
} from "@/components/ui/FatalErrorOverlay";
|
|
16
|
+
import { SelfUpdateProvider } from "@/hooks/use-system-update";
|
|
15
17
|
import { isServerError } from "@/lib/error-classifier";
|
|
16
18
|
import { reportFatalError } from "@/lib/fatal-error";
|
|
17
19
|
import type { RouterContext } from "@/router";
|
|
@@ -55,14 +57,17 @@ const SkipLink = () => {
|
|
|
55
57
|
function RootLayout() {
|
|
56
58
|
return (
|
|
57
59
|
<ErrorBannerProvider>
|
|
58
|
-
<
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
<
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
<SelfUpdateProvider>
|
|
61
|
+
<ComposeProvider>
|
|
62
|
+
<SkipLink />
|
|
63
|
+
<main id="main-content" className="h-dvh overflow-hidden">
|
|
64
|
+
<Suspense fallback={<AppShellSkeleton />}>
|
|
65
|
+
<Outlet />
|
|
66
|
+
</Suspense>
|
|
67
|
+
</main>
|
|
68
|
+
</ComposeProvider>
|
|
69
|
+
<SelfUpdateOverlay />
|
|
70
|
+
</SelfUpdateProvider>
|
|
66
71
|
<FatalErrorOverlay />
|
|
67
72
|
</ErrorBannerProvider>
|
|
68
73
|
);
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Advanced settings —
|
|
3
|
-
*
|
|
2
|
+
* Advanced settings. Updates live here — and only here — with a full pane in
|
|
3
|
+
* Settings › Advanced. Notification rules, export, and diagnostics are future
|
|
4
|
+
* scope.
|
|
4
5
|
*/
|
|
5
6
|
import { SettingsShell } from "@remit/ui";
|
|
6
7
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
7
8
|
import { useState } from "react";
|
|
9
|
+
import { SelfUpdatePanel } from "@/components/settings/SelfUpdatePanel";
|
|
8
10
|
import { AppVersion } from "@/components/ui/AppVersion";
|
|
9
11
|
import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
|
|
10
12
|
|
|
@@ -42,9 +44,10 @@ function AdvancedSettings() {
|
|
|
42
44
|
onSelect={handleSelectNav}
|
|
43
45
|
onBackToMail={() => void navigate({ to: "/mail" })}
|
|
44
46
|
>
|
|
47
|
+
<SelfUpdatePanel />
|
|
45
48
|
<p className="text-sm text-fg-muted">
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
Notification rules, data export, and raw sync diagnostics are coming in
|
|
50
|
+
a future release.
|
|
48
51
|
</p>
|
|
49
52
|
<div className="border-t border-line pt-4 mt-4">
|
|
50
53
|
<p className="text-sm font-medium text-fg mb-1">About</p>
|
package/src/routes/settings.tsx
CHANGED
|
@@ -10,14 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { SettingsNavItem } from "@remit/ui";
|
|
12
12
|
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
FolderTree,
|
|
16
|
-
Inbox,
|
|
17
|
-
Palette,
|
|
18
|
-
Users,
|
|
19
|
-
Wrench,
|
|
20
|
-
} from "lucide-react";
|
|
13
|
+
import { Filter, FolderTree, Inbox, Palette, Users } from "lucide-react";
|
|
14
|
+
import { AdvancedNavIcon } from "@/components/settings/AdvancedNavIcon";
|
|
21
15
|
|
|
22
16
|
/* ------------------------------------------------------------------ */
|
|
23
17
|
/* Nav items — single source of truth shared by all settings pages */
|
|
@@ -45,7 +39,7 @@ export const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
|
|
|
45
39
|
label: "Appearance",
|
|
46
40
|
icon: <Palette className="size-4" />,
|
|
47
41
|
},
|
|
48
|
-
{ id: "advanced", label: "Advanced", icon: <
|
|
42
|
+
{ id: "advanced", label: "Advanced", icon: <AdvancedNavIcon /> },
|
|
49
43
|
];
|
|
50
44
|
|
|
51
45
|
export const SETTINGS_ID_TO_PATH: Record<string, string> = {
|