| 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 |