返回 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 /** Single-action update: download + verify + install + relaunch. */
29 apply: (info: UpdateInfo) => void;
30 openDownload: () => void;
31 reset: () => void;
32 }
33
34 function errMsg(e: unknown): string {
35 return e instanceof Error ? e.message : String(e);
36 }
37
38 export function classifyUpdateError(message: string): UpdateErrorDisposition {
39 const low = message.toLowerCase();
40 if (/pending update already exists|could not safely finish the previous update|handoff backup/.test(low)) {
41 return "recovery";
42 }
43 if (/authorization failed|manual update required|pkexec|sudo apt install/.test(low)) {
44 return "manual";
45 }
46 return "retryable";
47 }
48
49 function updateError(message: string, info?: UpdateInfo): UpdateStatus {
50 return { kind: "error", message, info, disposition: classifyUpdateError(message) };
51 }
52
53 const UpdaterContext = createContext<Updater | null>(null);
54
55 type UpdaterOperationKind = "idle" | "checking" | "ready" | "applying";
56
57 interface UpdaterOperation {
58 epoch: number;
59 requestId: string;
60 channel: "" | "stable" | "preview";
61 expectedVersion: string;
62 kind: UpdaterOperationKind;
63 }
64
65 let updaterRequestSequence = 0;
66 const updaterRequestPrefix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
67
68 function nextUpdaterRequestId(epoch: number): string {
69 updaterRequestSequence += 1;
70 return `web-${updaterRequestPrefix}-${epoch}-${updaterRequestSequence}`;
71 }
72
73 function normalizedChannel(channel: string): "stable" | "preview" {
74 return channel === "preview" ? "preview" : "stable";
75 }
76
77 function isBusyOperation(kind: UpdaterOperationKind): boolean {
78 return kind === "checking" || kind === "applying";
79 }
80
81 function useUpdaterInternal(): Updater {
82 const [status, setStatus] = useState<UpdateStatus>({ kind: "idle" });
83 const operationRef = useRef<UpdaterOperation>({
84 epoch: 0,
85 requestId: "initial",
86 channel: "",
87 expectedVersion: "",
88 kind: "idle",
89 });
90
91 const beginOperation = useCallback((
92 channel: string,
93 kind: UpdaterOperationKind,
94 expectedVersion = "",
95 ): UpdaterOperation => {
96 const epoch = operationRef.current.epoch + 1;
97 const next: UpdaterOperation = {
98 epoch,
99 requestId: nextUpdaterRequestId(epoch),
100 channel: channel ? normalizedChannel(channel) : "",
101 expectedVersion,
102 kind,
103 };
104 operationRef.current = next;
105 return next;
106 }, []);
107
108 const isCurrentOperation = useCallback((operation: UpdaterOperation): boolean => {
109 const current = operationRef.current;
110 return current.epoch === operation.epoch &&
111 current.requestId === operation.requestId &&
112 current.channel === operation.channel &&
113 current.expectedVersion === operation.expectedVersion;
114 }, []);
115
116 const completeOperation = useCallback((operation: UpdaterOperation): void => {
117 if (isCurrentOperation(operation)) {
118 operationRef.current = { ...operationRef.current, kind: "ready" };
119 }
120 }, [isCurrentOperation]);
121
122 // A single long-lived subscription advances the state machine through apply
123 // phases. Channel and operation-kind checks prevent a superseded native call
124 // from publishing into a newly selected channel.
125 useEffect(() => {
126 return onUpdaterProgress((p) => {
127 const operation = operationRef.current;
128 if (
129 !p.requestId ||
130 p.requestId !== operation.requestId ||
131 !p.channel ||
132 normalizedChannel(p.channel) !== operation.channel ||
133 !p.version ||
134 p.version !== operation.expectedVersion
135 ) return;
136 const accepted =
137 operation.kind === "applying" &&
138 (
139 p.phase === "downloading" ||
140 p.phase === "verifying" ||
141 p.phase === "authorizing" ||
142 p.phase === "installing" ||
143 p.phase === "relaunching" ||
144 p.phase === "done" ||
145 p.phase === "error" ||
146 // Tolerate legacy backend phases during the migration window.
147 p.phase === "downloaded" ||
148 p.phase === "recovering"
149 );
150 if (!accepted) return;
151 if (p.phase === "done" || p.phase === "error") {
152 operationRef.current = { ...operation, kind: "ready" };
153 }
154 setStatus((cur) => {
155 const info = "info" in cur ? cur.info : undefined;
156 if (info && normalizedChannel(info.channel) !== operation.channel) return cur;
157 switch (p.phase) {
158 case "downloading":
159 return info ? { kind: "downloading", received: p.received, total: p.total, info } : cur;
160 case "verifying":
161 return info ? { kind: "verifying", info } : cur;
162 case "downloaded":
163 // Intermediate cache-ready signal: keep showing verifying/installing
164 // rather than a separate user action.
165 return info ? { kind: "installing", info } : cur;
166 case "authorizing":
167 return { kind: "authorizing", info };
168 case "recovering":
169 case "installing":
170 return { kind: "installing", info };
171 case "relaunching":
172 return { kind: "relaunching", info };
173 case "done":
174 return { kind: "done" };
175 case "error":
176 return updateError(p.err ?? "update failed", info);
177 default:
178 return cur;
179 }
180 });
181 });
182 }, []);
183
184 const check = useCallback(async () => {
185 const operation = beginOperation("stable", "checking");
186 setStatus({ kind: "checking" });
187 try {
188 const info = await app.CheckUpdate("stable");
189 if (!isCurrentOperation(operation)) return;
190 if (!info) {
191 completeOperation(operation);
192 setStatus({ kind: "upToDate", current: "" });
193 return;
194 }
195 const responseChannel = normalizedChannel(info.channel);
196 if (operation.channel && responseChannel !== operation.channel) {
197 completeOperation(operation);
198 setStatus(updateError(`update check returned ${responseChannel} for requested ${operation.channel} channel`));
199 return;
200 }
201 operation.channel = responseChannel;
202 operation.expectedVersion = info.latest;
203 operationRef.current = { ...operation, kind: "ready" };
204 if (info.err) {
205 setStatus(updateError(info.err, info));
206 return;
207 }
208 if (!info.available) {
209 setStatus({ kind: "upToDate", current: info.current });
210 return;
211 }
212 setStatus({ kind: "available", info });
213 } catch (e) {
214 if (!isCurrentOperation(operation)) return;
215 completeOperation(operation);
216 setStatus(updateError(errMsg(e)));
217 }
218 }, [beginOperation, completeOperation, isCurrentOperation]);
219
220 const apply = useCallback((info: UpdateInfo) => {
221 const selectedChannel = normalizedChannel(info.channel);
222 if (selectedChannel !== "stable") {
223 setStatus(updateError("update check returned a retired release channel"));
224 return;
225 }
226 const active = operationRef.current;
227 if (isBusyOperation(active.kind) || (active.channel && active.channel !== selectedChannel)) return;
228 if (!info.canSelfUpdate) {
229 void app.OpenDownloadPage();
230 return;
231 }
232 const operation = beginOperation(selectedChannel, "applying", info.latest);
233 setStatus(
234 info.requiresElevation || info.installMode === "deb"
235 ? { kind: "authorizing", info }
236 : { kind: "downloading", received: 0, total: info.assetSize, info },
237 );
238 void app.ApplyUpdateRequest(selectedChannel, info.latest, operation.requestId).catch((e) => {
239 if (!isCurrentOperation(operation)) return;
240 const message = errMsg(e);
241 completeOperation(operation);
242 setStatus(updateError(message, info));
243 });
244 }, [beginOperation, completeOperation, isCurrentOperation]);
245
246 const openDownload = useCallback(() => {
247 void app.OpenDownloadPage();
248 }, []);
249
250 const reset = useCallback(() => {
251 const epoch = operationRef.current.epoch + 1;
252 operationRef.current = {
253 epoch,
254 requestId: nextUpdaterRequestId(epoch),
255 channel: "",
256 expectedVersion: "",
257 kind: "idle",
258 };
259 setStatus({ kind: "idle" });
260 }, []);
261
262 return { status, check, apply, openDownload, reset };
263 }
264
265 export function UpdaterProvider({ children }: { children: ReactNode }) {
266 const updater = useUpdaterInternal();
267 return createElement(UpdaterContext.Provider, { value: updater, children });
268 }
269
270 export function useUpdater(): Updater {
271 const updater = useContext(UpdaterContext);
272 if (!updater) throw new Error("useUpdater must be used within an UpdaterProvider");
273 return updater;
274 }
275
275 lines TYPESCRIPT