Files
paseo/packages/desktop/src/features/app-update-service.ts
T
Mohamed Boudra eca0a5bf67 Let manual update checks bypass rollout
Automatic desktop update checks still respect rollout admission. Manual checks carry an explicit intent through the app and desktop updater service, and the up-to-date state now shows when the last check completed.
2026-06-09 15:47:30 +07:00

304 lines
8.0 KiB
TypeScript

import {
rolloutManifestSchema,
shouldAdmitAppUpdate,
type AppReleaseChannel,
type AppUpdateCheckIntent,
} from "./app-update-rollout.js";
export interface AppUpdateCheckResult {
hasUpdate: boolean;
readyToInstall: boolean;
currentVersion: string;
latestVersion: string;
body: string | null;
date: string | null;
}
export interface AppUpdateInstallResult {
installed: boolean;
version: string | null;
message: string;
}
export interface RuntimeUpdateInfo {
version: string;
releaseNotes?: unknown;
releaseDate?: unknown;
rolloutHours?: unknown;
}
export interface RuntimeUpdateCheckResult {
isUpdateAvailable: boolean;
updateInfo: RuntimeUpdateInfo;
}
export interface AppUpdateRuntimeConfiguration {
releaseChannel: AppReleaseChannel;
shouldAdmitUpdate(info: RuntimeUpdateInfo): boolean | Promise<boolean>;
onUpdateAvailable(info: RuntimeUpdateInfo): void;
onUpdateDownloaded(info: RuntimeUpdateInfo): void;
onUpdateNotAvailable(): void;
onError(error: unknown): void;
}
export interface AppUpdateRuntime {
configure(input: AppUpdateRuntimeConfiguration): void;
checkForUpdates(): Promise<RuntimeUpdateCheckResult | null>;
downloadUpdate(): Promise<unknown>;
quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void;
}
export interface AppUpdateService {
checkForAppUpdate(input: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
intent: AppUpdateCheckIntent;
}): Promise<AppUpdateCheckResult>;
downloadAndInstallUpdate(
input: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
},
onBeforeQuit?: () => Promise<void>,
): Promise<AppUpdateInstallResult>;
}
export interface AppUpdateServiceDeps {
runtime: AppUpdateRuntime;
isPackaged(): boolean;
now(): number;
bucket(): Promise<number>;
reportCheckError?(error: unknown): void;
reportRuntimeError?(error: unknown): void;
reportInstallError?(message: string): void;
}
function buildCheckResult(input: {
currentVersion: string;
hasUpdate: boolean;
readyToInstall: boolean;
info?: RuntimeUpdateInfo | null;
}): AppUpdateCheckResult {
const { currentVersion, hasUpdate, readyToInstall, info } = input;
return {
hasUpdate,
readyToInstall,
currentVersion,
latestVersion: info?.version ?? currentVersion,
body: typeof info?.releaseNotes === "string" ? info.releaseNotes : null,
date: typeof info?.releaseDate === "string" ? info.releaseDate : null,
};
}
async function performQuitAndInstall(
runtime: AppUpdateRuntime,
onBeforeQuit?: () => Promise<void>,
): Promise<void> {
if (onBeforeQuit) await onBeforeQuit();
runtime.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
}
export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateService {
let cachedUpdateInfo: RuntimeUpdateInfo | null = null;
let downloadedUpdateVersion: string | null = null;
let downloading = false;
let configuredReleaseChannel: AppReleaseChannel | null = null;
function isReadyToInstallVersion(version: string): boolean {
return downloadedUpdateVersion === version;
}
function clearUpdateState(): void {
cachedUpdateInfo = null;
downloadedUpdateVersion = null;
downloading = false;
}
function configureRuntime(releaseChannel: AppReleaseChannel, intent: AppUpdateCheckIntent): void {
if (configuredReleaseChannel !== releaseChannel) {
clearUpdateState();
configuredReleaseChannel = releaseChannel;
}
deps.runtime.configure({
releaseChannel,
shouldAdmitUpdate: async (info) => {
const parsed = rolloutManifestSchema.parse(info);
return shouldAdmitAppUpdate({
channel: releaseChannel,
intent,
rolloutHours: parsed.rolloutHours,
releaseDate: parsed.releaseDate,
now: deps.now(),
bucket: await deps.bucket(),
});
},
onUpdateAvailable(info) {
cachedUpdateInfo = info;
downloadedUpdateVersion = null;
downloading = true;
},
onUpdateDownloaded(info) {
cachedUpdateInfo = info;
downloadedUpdateVersion = info.version;
downloading = false;
},
onUpdateNotAvailable() {
clearUpdateState();
},
onError(error) {
downloading = false;
deps.reportRuntimeError?.(error);
},
});
}
async function checkForAppUpdate({
currentVersion,
releaseChannel,
intent,
}: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
intent: AppUpdateCheckIntent;
}): Promise<AppUpdateCheckResult> {
if (!deps.isPackaged()) {
return buildCheckResult({
currentVersion,
hasUpdate: false,
readyToInstall: false,
});
}
configureRuntime(releaseChannel, intent);
const cachedVersion = cachedUpdateInfo?.version ?? null;
if (cachedVersion && cachedVersion !== currentVersion) {
return buildCheckResult({
currentVersion,
hasUpdate: true,
readyToInstall: isReadyToInstallVersion(cachedVersion),
info: cachedUpdateInfo,
});
}
try {
const result = await deps.runtime.checkForUpdates();
if (!result || !result.updateInfo || !result.isUpdateAvailable) {
clearUpdateState();
return buildCheckResult({
currentVersion,
hasUpdate: false,
readyToInstall: false,
});
}
const info = result.updateInfo;
const latestVersion = info.version;
const hasUpdate = latestVersion !== currentVersion;
if (hasUpdate) {
cachedUpdateInfo = info;
downloading = !isReadyToInstallVersion(latestVersion);
return buildCheckResult({
currentVersion,
hasUpdate: true,
readyToInstall: isReadyToInstallVersion(latestVersion),
info,
});
}
clearUpdateState();
return buildCheckResult({
currentVersion,
hasUpdate: false,
readyToInstall: false,
});
} catch (error) {
deps.reportCheckError?.(error);
return buildCheckResult({
currentVersion,
hasUpdate: false,
readyToInstall: false,
});
}
}
async function downloadAndInstallUpdate(
{
currentVersion,
releaseChannel,
}: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
},
onBeforeQuit?: () => Promise<void>,
): Promise<AppUpdateInstallResult> {
if (!deps.isPackaged()) {
return {
installed: false,
version: currentVersion,
message: "Auto-update is not available in development mode.",
};
}
if (!cachedUpdateInfo) {
return {
installed: false,
version: currentVersion,
message: "No update available. Check for updates first.",
};
}
configureRuntime(releaseChannel, "manual");
const readyVersion = cachedUpdateInfo.version;
if (isReadyToInstallVersion(readyVersion)) {
await performQuitAndInstall(deps.runtime, onBeforeQuit);
return {
installed: true,
version: readyVersion,
message: "Update downloaded. The app will restart shortly.",
};
}
if (downloading) {
return {
installed: false,
version: currentVersion,
message: "Update is still being prepared. Try again in a moment.",
};
}
downloading = true;
try {
await deps.runtime.downloadUpdate();
downloadedUpdateVersion = readyVersion;
downloading = false;
await performQuitAndInstall(deps.runtime, onBeforeQuit);
return {
installed: true,
version: readyVersion,
message: "Update downloaded. The app will restart shortly.",
};
} catch (error) {
downloading = false;
const message = error instanceof Error ? error.message : String(error);
deps.reportInstallError?.(message);
return {
installed: false,
version: currentVersion,
message: `Update failed: ${message}`,
};
}
}
return {
checkForAppUpdate,
downloadAndInstallUpdate,
};
}