evOo-local/apps/desktop/src/auto-update.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

427 lines
14 KiB
TypeScript

import type { DesktopUpdateState } from "@rakazo/contracts";
/** Long enough that a cold launch is never competing with the update feed for bandwidth. */
export const LAUNCH_CHECK_DELAY_MS = 8_000;
/** A connected server drives the renderer, so a manual check cannot become a request loop. */
export const MIN_CHECK_INTERVAL_MS = 60_000;
export interface UpdaterEnvironment {
packaged: boolean;
version: string;
disabled?: boolean;
}
export function updaterSupport(env: UpdaterEnvironment): { supported: boolean; reason: string } {
if (env.disabled === true) {
return { supported: false, reason: "Automatic updates are turned off for this install." };
}
if (!env.packaged) {
return { supported: false, reason: "Automatic updates only run in an installed build." };
}
return { supported: true, reason: "" };
}
export function initialUpdateState(env: UpdaterEnvironment): DesktopUpdateState {
const support = updaterSupport(env);
return {
phase: support.supported ? "idle" : "unsupported",
currentVersion: env.version,
availableVersion: null,
percent: null,
message: support.supported ? null : support.reason,
checkedAt: null,
};
}
export type UpdaterFailure =
| { kind: "no-releases"; message: string }
| { kind: "offline"; message: null }
| { kind: "signature"; message: string }
| { kind: "other"; message: string };
const NO_RELEASES = [
"404 not found",
"no published versions",
"cannot find latest.yml",
"cannot find latest-mac.yml",
"unable to find latest version",
];
const OFFLINE = [
"enotfound",
"econnrefused",
"econnreset",
"etimedout",
"eai_again",
"enetunreach",
"net::err_",
"getaddrinfo",
];
const SIGNATURE = ["code sign", "signature", "not signed", "notariz"];
/** Error text can contain local paths or URLs, so renderer-visible messages stay generic. */
export function classifyUpdaterFailure(error: unknown): UpdaterFailure {
const text = (error instanceof Error ? error.message : String(error)).trim().toLowerCase();
if (NO_RELEASES.some((needle) => text.includes(needle))) {
return {
kind: "no-releases",
message: "No desktop releases are published for this build yet.",
};
}
if (OFFLINE.some((needle) => text.includes(needle))) {
return { kind: "offline", message: null };
}
if (SIGNATURE.some((needle) => text.includes(needle))) {
return {
kind: "signature",
message: "This update could not be verified. Reinstall Rakazo from a trusted download.",
};
}
return {
kind: "other",
message: "The update could not be completed. Try again later.",
};
}
export type UpdaterEvent =
| { type: "check-start" }
| { type: "available"; version: string }
| { type: "not-available" }
| { type: "download-start" }
| { type: "progress"; percent: number }
| { type: "downloaded"; version: string }
| { type: "failed"; error: unknown; userInitiated: boolean; installFailed?: boolean };
export function reduceUpdateState(
state: DesktopUpdateState,
event: UpdaterEvent,
now: string,
): DesktopUpdateState {
if (state.phase === "unsupported") return state;
switch (event.type) {
case "check-start":
// A verified download in flight must not be reset by a stray updater event.
if (state.phase === "available" || state.phase === "downloading" || state.phase === "ready") {
return state;
}
return { ...state, phase: "checking", percent: null, message: null };
case "available":
if (state.phase === "downloading" || state.phase === "ready") return state;
return {
...state,
phase: "available",
availableVersion: event.version,
percent: null,
message: null,
checkedAt: now,
};
case "not-available":
if (state.phase === "downloading" || state.phase === "ready") return state;
return {
...state,
phase: "idle",
availableVersion: null,
percent: null,
message: null,
checkedAt: now,
};
case "download-start":
if (state.phase === "ready") return state;
return { ...state, phase: "downloading", percent: 0, message: null };
case "progress":
// Only advance while a download is expected; late progress after error/idle
// must not force the UI back into downloading.
if (state.phase !== "available" && state.phase !== "downloading") return state;
return {
...state,
phase: "downloading",
percent: Math.max(0, Math.min(100, Math.round(event.percent))),
};
case "downloaded":
return {
...state,
phase: "ready",
availableVersion: event.version,
percent: 100,
message: null,
};
case "failed": {
// electron-updater can emit late errors after a verified download; keep installable
// state. A synchronous install failure can retry the same verified download.
if (state.phase === "ready" && event.installFailed !== true) return state;
const failure = classifyUpdaterFailure(event.error);
if (state.phase === "ready" && event.installFailed === true) {
return {
...state,
message: failure.message ?? "The update could not be completed. Try again later.",
};
}
if (failure.kind === "no-releases" && state.phase === "checking") {
return {
...state,
phase: "unsupported",
availableVersion: null,
percent: null,
message: failure.message,
checkedAt: now,
};
}
if (failure.kind === "no-releases") {
return {
...state,
phase: "error",
percent: null,
message: "The update could not be completed. Try again later.",
checkedAt: now,
};
}
if (failure.kind === "offline" && event.installFailed !== true) {
return {
...state,
phase: "idle",
percent: null,
message: event.userInitiated ? "Could not reach the update server." : null,
checkedAt: now,
};
}
return { ...state, phase: "error", percent: null, message: failure.message, checkedAt: now };
}
}
}
export interface ShouldCheckOptions {
/** Manual checks may retry after a prior empty feed; unpackaged installs stay frozen. */
userInitiated?: boolean;
environmentSupportsUpdates?: boolean;
}
/** Checks never replace an offer that is already downloading or ready to install. */
export function shouldCheck(
state: DesktopUpdateState,
now: number,
lastCheck: number,
options: ShouldCheckOptions = {},
): boolean {
if (state.phase === "unsupported") {
return options.userInitiated === true && options.environmentSupportsUpdates === true;
}
if (state.phase !== "idle" && state.phase !== "error") return false;
return now - lastCheck >= MIN_CHECK_INTERVAL_MS || lastCheck === 0;
}
export interface ElectronAutoUpdater {
autoDownload: boolean;
autoInstallOnAppQuit: boolean;
allowDowngrade: boolean;
allowPrerelease: boolean;
disableWebInstaller: boolean;
on: (event: string, listener: (payload: unknown) => void) => unknown;
checkForUpdates: () => Promise<unknown>;
downloadUpdate: () => Promise<unknown>;
quitAndInstall: () => void;
}
interface UpdateClock {
now: () => number;
iso: () => string;
}
const systemClock: UpdateClock = {
now: Date.now,
iso: () => new Date().toISOString(),
};
function versionFrom(payload: unknown): string | null {
if (typeof payload !== "object" || payload === null || !("version" in payload)) return null;
return typeof payload.version === "string" && payload.version.trim() !== ""
? payload.version
: null;
}
function percentFrom(payload: unknown): number | null {
if (typeof payload !== "object" || payload === null || !("percent" in payload)) return null;
return typeof payload.percent === "number" && Number.isFinite(payload.percent)
? payload.percent
: null;
}
/** Owns updater configuration and serializes renderer and launch-time operations. */
export class DesktopUpdateController {
private current: DesktopUpdateState;
private lastCheck = 0;
private updaterPromise: Promise<ElectronAutoUpdater | null> | null = null;
private checkPromise: Promise<DesktopUpdateState> | null = null;
private downloadPromise: Promise<DesktopUpdateState> | null = null;
private checkWasRequested = false;
private installStarted = false;
constructor(
private readonly environment: UpdaterEnvironment,
private readonly loadUpdater: () => Promise<ElectronAutoUpdater>,
private readonly clock: UpdateClock = systemClock,
private readonly onInstallFailure?: () => void,
) {
this.current = initialUpdateState(environment);
}
state() {
return this.current;
}
private push(event: UpdaterEvent) {
this.current = reduceUpdateState(this.current, event, this.clock.iso());
if (event.type === "not-available" || event.type === "downloaded" || event.type === "failed") {
this.checkWasRequested = false;
}
}
private async updater(): Promise<ElectronAutoUpdater | null> {
if (!updaterSupport(this.environment).supported) return null;
if (this.updaterPromise !== null) return this.updaterPromise;
const loading = this.loadUpdater()
.then((updater) => {
updater.autoDownload = false;
updater.autoInstallOnAppQuit = true;
updater.allowPrerelease = false;
updater.allowDowngrade = false;
updater.disableWebInstaller = true;
updater.on("checking-for-update", () => this.push({ type: "check-start" }));
updater.on("update-available", (payload) => {
const version = versionFrom(payload);
if (version === null) {
this.push({
type: "failed",
error: new Error("The update feed did not include a version."),
userInitiated: this.checkWasRequested,
});
return;
}
this.push({ type: "available", version });
void this.download();
});
updater.on("update-not-available", () => this.push({ type: "not-available" }));
updater.on("download-progress", (payload) => {
const percent = percentFrom(payload);
if (percent !== null) this.push({ type: "progress", percent });
});
updater.on("update-downloaded", (payload) => {
const version = versionFrom(payload);
if (version !== null) {
this.push({ type: "downloaded", version });
} else {
this.push({
type: "failed",
error: new Error("The downloaded update did not include a version."),
userInitiated: this.checkWasRequested,
});
}
});
updater.on("error", (error) => this.fail(error));
return updater;
})
.catch((error: unknown) => {
this.push({ type: "failed", error, userInitiated: this.checkWasRequested });
if (this.updaterPromise === loading) this.updaterPromise = null;
return null;
});
this.updaterPromise = loading;
return loading;
}
check(userInitiated: boolean) {
if (userInitiated) this.checkWasRequested = true;
if (this.checkPromise !== null) return this.checkPromise;
const checking = this.runCheck().finally(() => {
if (this.checkPromise === checking) this.checkPromise = null;
});
this.checkPromise = checking;
return checking;
}
private async runCheck() {
const checkOptions = {
userInitiated: this.checkWasRequested,
environmentSupportsUpdates: updaterSupport(this.environment).supported,
};
let now = this.clock.now();
if (!shouldCheck(this.current, now, this.lastCheck, checkOptions)) return this.current;
// A prior empty feed freezes automatic checks; a manual retry clears that freeze.
if (this.current.phase === "unsupported" && checkOptions.environmentSupportsUpdates) {
this.current = {
...this.current,
phase: "idle",
availableVersion: null,
percent: null,
message: null,
};
// Do not make the user wait out the launch-check interval to retry.
this.lastCheck = 0;
}
const updater = await this.updater();
if (updater === null) return this.current;
now = this.clock.now();
if (!shouldCheck(this.current, now, this.lastCheck, checkOptions)) return this.current;
this.lastCheck = now;
try {
await updater.checkForUpdates();
} catch (error) {
if (this.current.phase === "idle" || this.current.phase === "checking") {
this.push({ type: "failed", error, userInitiated: this.checkWasRequested });
}
}
return this.current;
}
download() {
if (this.downloadPromise !== null) return this.downloadPromise;
const downloading = this.runDownload().finally(() => {
if (this.downloadPromise === downloading) this.downloadPromise = null;
});
this.downloadPromise = downloading;
return downloading;
}
private async runDownload() {
if (this.current.phase !== "available") return this.current;
const updater = await this.updater();
if (updater === null || this.current.phase !== "available") return this.current;
this.push({ type: "download-start" });
try {
await updater.downloadUpdate();
} catch (error) {
if (this.state().phase === "downloading") {
this.push({ type: "failed", error, userInitiated: this.checkWasRequested });
}
}
return this.current;
}
private fail(error: unknown) {
const installFailed = this.installStarted;
if (installFailed) this.installStarted = false;
this.push({
type: "failed",
error,
userInitiated: this.checkWasRequested || installFailed,
installFailed,
});
if (installFailed) this.onInstallFailure?.();
}
async install() {
if (this.installStarted || this.current.phase !== "ready") return this.current;
const updater = await this.updater();
if (updater === null || this.installStarted || this.current.phase !== "ready") {
return this.current;
}
this.installStarted = true;
this.current = { ...this.current, message: null };
try {
updater.quitAndInstall();
} catch (error) {
this.fail(error);
}
return this.current;
}
}