返回 DeepSeek-Reasonix
useUpdater.ts
根目录 / desktop / frontend / src / lib / useUpdater.ts
1 import { createContext, createElement, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
2 import { app, onUpdaterProgress } from "./bridge";
3 import type { UpdateInfo } from "./types";
4
5 // useUpdater drives the auto-update state machine shared by the top banner and the
6 // Settings panel. v1.20+ uses a single "update and restart" action that downloads,
7 // verifies, installs, and relaunches. There is no durable cross-restart pending
8 // state: failures leave the current version running and the user simply retries.
9
10 export type UpdateStatus =
11 | { kind: "idle" }
12 | { kind: "checking" }
13 | { kind: "upToDate"; current: string }
14 | { kind: "available"; info: UpdateInfo }
15 | { kind: "downloading"; received: number; total: number; info: UpdateInfo }
16 | { kind: "verifying"; info: UpdateInfo }
17 | { kind: "authorizing"; info?: UpdateInfo }
18 | { kind: "installing"; info?: UpdateInfo }
19 | { kind: "relaunching"; info?: UpdateInfo }
20 | { kind: "done" }
21 | { kind: "error"; message: string; info?: UpdateInfo; disposition: UpdateErrorDisposition };
22
23 export type UpdateErrorDisposition = "retryable" | "recovery" | "manual";
24
25 export interface Updater {
26 status: UpdateStatus;
27 check: () => Promise<void>;
28 /** Refresh an idle updater without superseding an active operation. */
29 refresh: () => Promise<void>;
30 /** Single-action update: download + verify + install + relaunch. */
31 apply: (info: UpdateInfo) => void;
32 openDownload: () => void;
33 /** Discard a stuck previous update transaction so the next install can proceed. */
34 abandonPending: () => Promise<void>;
35 reset: () => void;
36 }
37
38 function errMsg(e: unknown): string {
39 return e instanceof Error ? e.message : String(e);
40 }
41
42 export function classifyUpdateError(message: string): UpdateErrorDisposition {
43 const low = message.toLowerCase();
44 if (/pending update already exists|could not safely finish the previous update|handoff backup|awaiting startup health|discard the previous update|previous update is still completing/.test(low)) {
45 return "recovery";
46 }
47 // An unknown install_layout is a deliberate migration boundary: retrying
48 // never helps, only the full package from the download page does.
49 if (/authorization failed|manual update required|pkexec|sudo apt install|unsupported install_layout/.test(low)) {
50 return "manual";
51 }
52 return "retryable";
53 }
54
55 function updateError(message: string, info?: UpdateInfo): UpdateStatus {
56 return { kind: "error", message, info, disposition: classifyUpdateError(message) };
57 }
58
59 const UpdaterContext = createContext<Updater | null>(null);
60
61 type UpdaterOperationKind = "idle" | "checking" | "ready" | "applying" | "abandoning";
62
63 interface UpdaterOperation {
64 epoch: number;
65 requestId: string;
66 channel: "" | "stable" | "preview";
67 expectedVersion: string;
68 kind: UpdaterOperationKind;
69 }
70
71 let updaterRequestSequence = 0;
72 const updaterRequestPrefix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
73 export const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
74 export const UPDATE_CHECK_STORAGE_KEY = "reasonix-updater-last-check-v1";
75 let processLastUpdateCheckAttemptMs: number | null = null;
76
77 function updateCheckStorage(): Storage | null {
78 try {
79 return typeof window === "undefined" ? null : window.localStorage;
80 } catch {
81 return null;
82 }
83 }
84
85 function validUpdateCheckTimestamp(value: unknown, now: number): number | null {
86 const timestamp = typeof value === "number" ? value : Number(value);
87 return Number.isFinite(timestamp) && timestamp >= 0 && timestamp <= now ? timestamp : null;
88 }
89
90 function lastUpdateCheckAttempt(now: number): number | null {
91 const memoryTimestamp = validUpdateCheckTimestamp(processLastUpdateCheckAttemptMs, now);
92 if (memoryTimestamp === null) processLastUpdateCheckAttemptMs = null;
93
94 const storage = updateCheckStorage();
95 if (!storage) return memoryTimestamp;
96 try {
97 const raw = storage.getItem(UPDATE_CHECK_STORAGE_KEY);
98 if (raw === null) return memoryTimestamp;
99 const storedTimestamp = validUpdateCheckTimestamp(raw, now);
100 if (storedTimestamp === null) {
101 storage.removeItem(UPDATE_CHECK_STORAGE_KEY);
102 return memoryTimestamp;
103 }
104 return memoryTimestamp === null ? storedTimestamp : Math.max(memoryTimestamp, storedTimestamp);
105 } catch {
106 return memoryTimestamp;
107 }
108 }
109
110 function recordUpdateCheckAttempt(now = Date.now()): void {
111 processLastUpdateCheckAttemptMs = now;
112 try {
113 updateCheckStorage()?.setItem(UPDATE_CHECK_STORAGE_KEY, String(now));
114 } catch {
115 // The process-local timestamp still prevents duplicate checks this run.
116 }
117 }
118
119 function automaticUpdateCheckDue(now = Date.now()): boolean {
120 const lastAttempt = lastUpdateCheckAttempt(now);
121 return lastAttempt === null || now - lastAttempt >= UPDATE_CHECK_INTERVAL_MS;
122 }
123
124 export function __resetUpdaterCheckScheduleForTests(): void {
125 processLastUpdateCheckAttemptMs = null;
126 try {
127 updateCheckStorage()?.removeItem(UPDATE_CHECK_STORAGE_KEY);
128 } catch {
129 // Tests also exercise storage-denied environments.
130 }
131 }
132
133 function nextUpdaterRequestId(epoch: number): string {
134 updaterRequestSequence += 1;
135 return `web-${updaterRequestPrefix}-${epoch}-${updaterRequestSequence}`;
136 }
137
138 function normalizedChannel(channel: string): "stable" | "preview" {
139 return channel === "preview" ? "preview" : "stable";
140 }
141
142 function isBusyOperation(kind: UpdaterOperationKind): boolean {
143 return kind === "checking" || kind === "applying" || kind === "abandoning";
144 }
145
146 function useUpdaterInternal(): Updater {
147 const [status, setStatus] = useState<UpdateStatus>({ kind: "idle" });
148 const operationRef = useRef<UpdaterOperation>({
149 epoch: 0,
150 requestId: "initial",
151 channel: "",
152 expectedVersion: "",
153 kind: "idle",
154 });
155
156 const beginOperation = useCallback((
157 channel: string,
158 kind: UpdaterOperationKind,
159 expectedVersion = "",
160 ): UpdaterOperation => {
161 const epoch = operationRef.current.epoch + 1;
162 const next: UpdaterOperation = {
163 epoch,
164 requestId: nextUpdaterRequestId(epoch),
165 channel: channel ? normalizedChannel(channel) : "",
166 expectedVersion,
167 kind,
168 };
169 operationRef.current = next;
170 return next;
171 }, []);
172
173 const isCurrentOperation = useCallback((operation: UpdaterOperation): boolean => {
174 const current = operationRef.current;
175 return current.epoch === operation.epoch &&
176 current.requestId === operation.requestId &&
177 current.channel === operation.channel &&
178 current.expectedVersion === operation.expectedVersion;
179 }, []);
180
181 const completeOperation = useCallback((operation: UpdaterOperation): void => {
182 if (isCurrentOperation(operation)) {
183 operationRef.current = { ...operationRef.current, kind: "ready" };
184 }
185 }, [isCurrentOperation]);
186
187 // A single long-lived subscription advances the state machine through apply
188 // phases. Channel and operation-kind checks prevent a superseded native call
189 // from publishing into a newly selected channel.
190 useEffect(() => {
191 return onUpdaterProgress((p) => {
192 const operation = operationRef.current;
193 if (
194 !p.requestId ||
195 p.requestId !== operation.requestId ||
196 !p.channel ||
197 normalizedChannel(p.channel) !== operation.channel ||
198 !p.version ||
199 p.version !== operation.expectedVersion
200 ) return;
201 const accepted =
202 operation.kind === "applying" &&
203 (
204 p.phase === "downloading" ||
205 p.phase === "verifying" ||
206 p.phase === "authorizing" ||
207 p.phase === "installing" ||
208 p.phase === "relaunching" ||
209 p.phase === "done" ||
210 p.phase === "error" ||
211 // Tolerate legacy backend phases during the migration window.
212 p.phase === "downloaded" ||
213 p.phase === "recovering"
214 );
215 if (!accepted) return;
216 if (p.phase === "done" || p.phase === "error") {
217 operationRef.current = { ...operation, kind: "ready" };
218 }
219 setStatus((cur) => {
220 const info = "info" in cur ? cur.info : undefined;
221 if (info && normalizedChannel(info.channel) !== operation.channel) return cur;
222 switch (p.phase) {
223 case "downloading":
224 return info ? { kind: "downloading", received: p.received, total: p.total, info } : cur;
225 case "verifying":
226 return info ? { kind: "verifying", info } : cur;
227 case "downloaded":
228 // Intermediate cache-ready signal: keep showing verifying/installing
229 // rather than a separate user action.
230 return info ? { kind: "installing", info } : cur;
231 case "authorizing":
232 return { kind: "authorizing", info };
233 case "recovering":
234 case "installing":
235 return { kind: "installing", info };
236 case "relaunching":
237 return { kind: "relaunching", info };
238 case "done":
239 return { kind: "done" };
240 case "error":
241 return updateError(p.err ?? "update failed", info);
242 default:
243 return cur;
244 }
245 });
246 });
247 }, []);
248
249 const check = useCallback(async () => {
250 // A newer check may supersede an in-flight check (and historically may
251 // interrupt apply). Discard owns exclusive recovery work and must not be
252 // epoch-stolen by Check/Retry while AbandonPendingUpdate is outstanding.
253 if (operationRef.current.kind === "abandoning") return;
254 recordUpdateCheckAttempt();
255 const operation = beginOperation("stable", "checking");
256 setStatus({ kind: "checking" });
257 try {
258 const info = await app.CheckUpdate("stable");
259 if (!isCurrentOperation(operation)) return;
260 if (!info) {
261 completeOperation(operation);
262 setStatus({ kind: "upToDate", current: "" });
263 return;
264 }
265 const responseChannel = normalizedChannel(info.channel);
266 if (operation.channel && responseChannel !== operation.channel) {
267 completeOperation(operation);
268 setStatus(updateError(`update check returned ${responseChannel} for requested ${operation.channel} channel`));
269 return;
270 }
271 operation.channel = responseChannel;
272 operation.expectedVersion = info.latest;
273 operationRef.current = { ...operation, kind: "ready" };
274 if (info.err) {
275 setStatus(updateError(info.err, info));
276 return;
277 }
278 if (!info.available) {
279 setStatus({ kind: "upToDate", current: info.current });
280 return;
281 }
282 setStatus({ kind: "available", info });
283 } catch (e) {
284 if (!isCurrentOperation(operation)) return;
285 completeOperation(operation);
286 setStatus(updateError(errMsg(e)));
287 }
288 }, [beginOperation, completeOperation, isCurrentOperation]);
289
290 const refresh = useCallback(async () => {
291 if (isBusyOperation(operationRef.current.kind)) return;
292 if (!automaticUpdateCheckDue()) return;
293 await check();
294 }, [check]);
295
296 const apply = useCallback((info: UpdateInfo) => {
297 const selectedChannel = normalizedChannel(info.channel);
298 if (selectedChannel !== "stable") {
299 setStatus(updateError("update check returned a retired release channel"));
300 return;
301 }
302 const active = operationRef.current;
303 if (isBusyOperation(active.kind) || (active.channel && active.channel !== selectedChannel)) return;
304 if (!info.canSelfUpdate) {
305 void app.OpenDownloadPage();
306 return;
307 }
308 const operation = beginOperation(selectedChannel, "applying", info.latest);
309 setStatus(
310 info.requiresElevation || info.installMode === "deb"
311 ? { kind: "authorizing", info }
312 : { kind: "downloading", received: 0, total: info.assetSize, info },
313 );
314 void app.ApplyUpdateRequest(selectedChannel, info.latest, operation.requestId).catch((e) => {
315 if (!isCurrentOperation(operation)) return;
316 const message = errMsg(e);
317 completeOperation(operation);
318 setStatus(updateError(message, info));
319 });
320 }, [beginOperation, completeOperation, isCurrentOperation]);
321
322 const openDownload = useCallback(() => {
323 void app.OpenDownloadPage();
324 }, []);
325
326 const abandonPending = useCallback(async () => {
327 const active = operationRef.current;
328 if (isBusyOperation(active.kind)) return;
329 // Publish busy UI immediately so Settings/Banner disable Retry/Check while
330 // the discard promise is outstanding. Kind "abandoning" is distinct from
331 // "checking" so a concurrent check cannot supersede the discard epoch.
332 const operation = beginOperation(active.channel || "stable", "abandoning");
333 setStatus({ kind: "checking" });
334 try {
335 if (typeof app.AbandonPendingUpdate === "function") {
336 await app.AbandonPendingUpdate();
337 }
338 if (!isCurrentOperation(operation)) return;
339 completeOperation(operation);
340 setStatus({ kind: "idle" });
341 } catch (e) {
342 if (!isCurrentOperation(operation)) return;
343 completeOperation(operation);
344 setStatus(updateError(errMsg(e)));
345 }
346 }, [beginOperation, completeOperation, isCurrentOperation]);
347
348 const reset = useCallback(() => {
349 const epoch = operationRef.current.epoch + 1;
350 operationRef.current = {
351 epoch,
352 requestId: nextUpdaterRequestId(epoch),
353 channel: "",
354 expectedVersion: "",
355 kind: "idle",
356 };
357 setStatus({ kind: "idle" });
358 }, []);
359
360 return { status, check, refresh, apply, openDownload, abandonPending, reset };
361 }
362
363 export function UpdaterProvider({ children }: { children: ReactNode }) {
364 const updater = useUpdaterInternal();
365 return createElement(UpdaterContext.Provider, { value: updater, children });
366 }
367
368 export function useUpdater(): Updater {
369 const updater = useContext(UpdaterContext);
370 if (!updater) throw new Error("useUpdater must be used within an UpdaterProvider");
371 return updater;
372 }
373
373 lines TYPESCRIPT