agent-ecosystem/src/main/services/infrastructure/UpdaterService.ts

344 lines
10 KiB
TypeScript

/**
* UpdaterService - Wraps electron-updater's autoUpdater for OTA updates.
*
* Forwards update lifecycle events to the renderer via IPC.
* Auto-download is disabled so users must confirm before downloading.
*
* Before notifying the renderer about a new version, verifies that the
* platform-specific installer asset actually exists in the GitHub release.
* This prevents showing "update available" while CI is still uploading
* artifacts for the current platform.
*/
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import {
formatUpdaterReleaseNotes,
getUpdaterReleaseNoteForVersion,
} from '@shared/utils/releaseNotes';
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
import { app, net } from 'electron';
import electronUpdater from 'electron-updater';
import {
getExpectedReleaseAssetUrls,
getLatestMacMetadataUrls,
getReleaseApiUrls,
isLatestMacMetadataCompatible,
shouldSkipReleaseForUpdater,
} from './updaterReleaseMetadata';
import type { GithubReleaseMetadata } from './updaterReleaseMetadata';
import type { UpdaterStatus } from '@shared/types';
import type { BrowserWindow } from 'electron';
const logger = createLogger('UpdaterService');
const { autoUpdater } = electronUpdater;
function shouldSkipDevUpdateCheck(): boolean {
return (
!app.isPackaged &&
(autoUpdater as { forceDevUpdateConfig?: boolean }).forceDevUpdateConfig !== true
);
}
/**
* Check if a remote URL exists using a HEAD request.
* Follows redirects (GitHub releases use 302 → S3).
*/
async function assetExists(url: string): Promise<boolean> {
try {
const response = await net.fetch(url, { method: 'HEAD' });
return response.ok;
} catch {
return false;
}
}
async function assetExistsInAnyRepo(urls: readonly string[]): Promise<boolean> {
for (const url of urls) {
if (await assetExists(url)) {
return true;
}
}
return false;
}
async function fetchText(url: string): Promise<string | null> {
try {
const response = await net.fetch(url, { method: 'GET' });
if (!response.ok) {
return null;
}
return await response.text();
} catch {
return null;
}
}
async function fetchJson<T>(url: string): Promise<T | null> {
try {
const response = await net.fetch(url, {
method: 'GET',
headers: { Accept: 'application/vnd.github+json' },
});
if (!response.ok) {
return null;
}
return (await response.json()) as T;
} catch {
return null;
}
}
export class UpdaterService {
private mainWindow: BrowserWindow | null = null;
private periodicTimer: ReturnType<typeof setInterval> | null = null;
private downloadedVersion: string | null = null;
private beforeQuitAndInstall: (() => Promise<void>) | null = null;
constructor() {
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.fullChangelog = true;
this.bindEvents();
}
/**
* Set the main window reference for sending status events.
*/
setMainWindow(window: BrowserWindow | null): void {
this.mainWindow = window;
}
setBeforeQuitAndInstall(handler: (() => Promise<void>) | null): void {
this.beforeQuitAndInstall = handler;
}
/**
* Check for available updates.
*/
async checkForUpdates(): Promise<void> {
if (shouldSkipDevUpdateCheck()) {
return;
}
try {
await autoUpdater.checkForUpdates();
} catch (error) {
logger.error('Check for updates failed:', getErrorMessage(error));
this.sendStatus({ type: 'error', error: getErrorMessage(error) });
}
}
/**
* Download the available update.
*/
async downloadUpdate(): Promise<void> {
try {
await autoUpdater.downloadUpdate();
} catch (error) {
logger.error('Download update failed:', getErrorMessage(error));
this.sendStatus({ type: 'error', error: getErrorMessage(error) });
}
}
/**
* Quit the app and install the downloaded update.
* On Windows (NSIS): isSilent=true runs the installer with /S (no wizard);
* isForceRunAfter=true launches the app after install. Other platforms ignore these.
*/
async quitAndInstall(): Promise<void> {
if (!this.downloadedVersion || !this.isNewerThanCurrent(this.downloadedVersion)) {
logger.warn(
`Refusing to install non-newer update. current=${app.getVersion()} downloaded=${this.downloadedVersion ?? 'unknown'}`
);
this.sendStatus({
type: 'error',
error: 'Refused to install a non-newer app version.',
});
return;
}
await this.beforeQuitAndInstall?.();
autoUpdater.quitAndInstall(true, true);
}
/**
* Start periodic update checks at the given interval (default: 1 hour).
* Uses unref() so the timer does not prevent process exit.
*/
startPeriodicCheck(intervalMs: number = 3_600_000): void {
this.stopPeriodicCheck();
this.periodicTimer = setInterval(() => void this.checkForUpdates(), intervalMs);
this.periodicTimer.unref();
logger.info(`Periodic update check started (interval: ${Math.round(intervalMs / 60_000)}min)`);
}
/**
* Stop periodic update checks.
*/
stopPeriodicCheck(): void {
if (this.periodicTimer !== null) {
clearInterval(this.periodicTimer);
this.periodicTimer = null;
}
}
private sendStatus(status: UpdaterStatus): void {
safeSendToRenderer(this.mainWindow, 'updater:status', status);
}
private isNewerThanCurrent(candidateVersion: string): boolean {
return isVersionOlder(normalizeVersion(app.getVersion()), normalizeVersion(candidateVersion));
}
private async hasCompatibleMacFeed(version: string): Promise<boolean> {
if (process.platform !== 'darwin') {
return true;
}
if (process.arch !== 'arm64' && process.arch !== 'x64') {
return false;
}
const metadataUrls = getLatestMacMetadataUrls(version);
for (const metadataUrl of metadataUrls) {
const metadataText = await fetchText(metadataUrl);
if (metadataText && isLatestMacMetadataCompatible(metadataText, version, process.arch)) {
return true;
}
}
logger.warn(`latest-mac.yml is not compatible or available for ${version}`);
return false;
}
private async isSkippedRelease(version: string): Promise<boolean> {
const metadataUrls = getReleaseApiUrls(version);
for (const metadataUrl of metadataUrls) {
const release = await fetchJson<GithubReleaseMetadata>(metadataUrl);
if (!release) {
continue;
}
return shouldSkipReleaseForUpdater(release);
}
logger.warn(`GitHub release metadata is not available for ${version}, allowing updater check`);
return false;
}
/**
* Verify that the platform-specific asset exists before notifying the renderer.
* If CI hasn't finished uploading the artifact for this OS yet, suppress the
* notification — the next periodic check will retry.
*/
private async verifyAndNotify(info: {
version: string;
releaseName?: unknown;
releaseNotes?: unknown;
}): Promise<void> {
if (!this.isNewerThanCurrent(info.version)) {
logger.warn(
`Suppressing non-newer update notification. current=${app.getVersion()} candidate=${info.version}`
);
return;
}
const latestReleaseNote = getUpdaterReleaseNoteForVersion(info.releaseNotes, info.version);
const releaseNotes = formatUpdaterReleaseNotes(info.releaseNotes);
if (
shouldSkipReleaseForUpdater({
tag_name: `v${info.version}`,
name: typeof info.releaseName === 'string' ? info.releaseName : undefined,
body: latestReleaseNote,
})
) {
logger.warn(`Suppressing updater notification for locally marked release ${info.version}`);
return;
}
if (await this.isSkippedRelease(info.version)) {
logger.warn(`Suppressing updater notification for skipped release ${info.version}`);
return;
}
const urls = getExpectedReleaseAssetUrls(info.version, process.platform, process.arch);
if (urls.length > 0) {
const exists = await assetExistsInAnyRepo(urls);
if (!exists) {
logger.warn(
`Asset not yet available for ${process.platform}/${process.arch}, suppressing update notification`
);
return;
}
}
if (!(await this.hasCompatibleMacFeed(info.version))) {
logger.warn(
`latest-mac.yml does not match ${process.platform}/${process.arch}, suppressing update notification`
);
return;
}
this.sendStatus({
type: 'available',
version: info.version,
releaseNotes,
});
}
private bindEvents(): void {
autoUpdater.on('checking-for-update', () => {
logger.info('Checking for update...');
this.sendStatus({ type: 'checking' });
});
autoUpdater.on('update-available', (info) => {
logger.info('Update available:', info.version);
void this.verifyAndNotify(info);
});
autoUpdater.on('update-not-available', () => {
logger.info('No update available');
this.sendStatus({ type: 'not-available' });
});
autoUpdater.on('download-progress', (progress) => {
this.sendStatus({
type: 'downloading',
progress: {
percent: progress.percent,
transferred: progress.transferred,
total: progress.total,
},
});
});
autoUpdater.on('update-downloaded', (info) => {
if (!this.isNewerThanCurrent(info.version)) {
logger.warn(
`Ignoring downloaded non-newer update. current=${app.getVersion()} downloaded=${info.version}`
);
return;
}
this.downloadedVersion = info.version;
logger.info('Update downloaded:', info.version);
this.sendStatus({
type: 'downloaded',
version: info.version,
});
});
autoUpdater.on('error', (error) => {
logger.error('Updater error:', getErrorMessage(error));
this.sendStatus({
type: 'error',
error: getErrorMessage(error),
});
});
}
}