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.
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user