import fs from 'node:fs'; import path from 'node:path'; import type { UpdateChannel, UpdatesConfig } from '../../../types/config'; import type { GitHubRelease } from './release-assets'; import { compareSemverLike, parseReleaseVersion } from './release-assets'; export interface UpdateState { lastAutomaticCheckAt?: number; lastNotifiedVersion?: string; } export type UpdateCheckSource = 'manual' | 'automatic' | 'launcher'; export interface UpdateCheckRequest { source: UpdateCheckSource; force?: boolean; launcherPath?: string; } export type UpdateCheckStatus = | 'up-to-date' | 'update-available' | 'updated' | 'skipped' | 'failed'; export interface UpdateCheckResult { status: UpdateCheckStatus; version?: string; error?: string; } export interface UpdateServiceDeps { getConfig: () => Required; getCurrentVersion: () => string; now: () => number; readState: () => Promise; writeState: (state: UpdateState) => Promise; checkAppUpdate: ( channel: UpdateChannel, ) => Promise<{ available: boolean; version: string; canUpdate?: boolean }>; fetchLatestStableRelease: (channel: UpdateChannel) => Promise; updateLauncher: ( launcherPath?: string, channel?: UpdateChannel, ) => Promise<{ status: string; command?: string }>; showNoUpdateDialog: (version: string) => Promise; showUpdateAvailableDialog: (version: string) => Promise<'update' | 'close'>; showUpdateFailedDialog: (message: string) => Promise; downloadAppUpdate: () => Promise; showRestartDialog: () => Promise<'restart' | 'later'>; quitAndInstall: () => void; notifyUpdateAvailable: (version: string) => Promise; log: (message: string) => void; setTimeout?: (callback: () => void, delayMs: number) => unknown; setInterval?: (callback: () => void, delayMs: number) => unknown; } function getBestLatestVersion( currentVersion: string, appUpdate: { available: boolean; version: string }, release: GitHubRelease | null, ): { available: boolean; version: string } { const releaseVersion = parseReleaseVersion(release); const candidates = [appUpdate.version, releaseVersion].filter( (value): value is string => typeof value === 'string' && value.length > 0, ); const latest = candidates.reduce( (best, candidate) => (compareSemverLike(candidate, best) > 0 ? candidate : best), currentVersion, ); return { available: appUpdate.available || compareSemverLike(latest, currentVersion) > 0, version: latest, }; } function shouldSkipAutomaticCheck( config: Required, state: UpdateState, now: number, ) { if (!config.enabled) return true; if (!state.lastAutomaticCheckAt) return false; const intervalMs = Math.max(1, config.checkIntervalHours) * 60 * 60 * 1000; return now - state.lastAutomaticCheckAt < intervalMs; } function summarizeError(error: unknown): string { const raw = error instanceof Error ? error.message : String(error); const firstLine = raw .split('\n') .map((line) => line.trim()) .find((line) => line.length > 0); return firstLine ?? 'unknown error'; } export function createUpdateService(deps: UpdateServiceDeps) { let inFlight: Promise | null = null; async function runCheck(request: UpdateCheckRequest): Promise { const now = deps.now(); const config = deps.getConfig(); const channel = config.channel; const state = await deps.readState(); const isAutomatic = request.source === 'automatic'; if (isAutomatic && !request.force && shouldSkipAutomaticCheck(config, state, now)) { return { status: 'skipped' }; } try { const [appUpdate, release] = await Promise.all([ deps.checkAppUpdate(channel).catch((error) => { if (isAutomatic) { deps.log(`App update metadata check failed: ${summarizeError(error)}`); } return { available: false, version: deps.getCurrentVersion(), canUpdate: false, }; }), deps.fetchLatestStableRelease(channel).catch((error) => { deps.log(`GitHub release update check failed: ${(error as Error).message}`); return null; }), ]); const currentVersion = deps.getCurrentVersion(); const latest = getBestLatestVersion(currentVersion, appUpdate, release); if (isAutomatic) { const nextState: UpdateState = { ...state, lastAutomaticCheckAt: now, }; if (latest.available && state.lastNotifiedVersion !== latest.version) { await deps.notifyUpdateAvailable(latest.version); nextState.lastNotifiedVersion = latest.version; } await deps.writeState(nextState); } if (!latest.available) { if (!isAutomatic) { await deps.showNoUpdateDialog(currentVersion); } return { status: 'up-to-date', version: currentVersion }; } if (isAutomatic) { return { status: 'update-available', version: latest.version }; } const choice = await deps.showUpdateAvailableDialog(latest.version); if (choice === 'close') { return { status: 'update-available', version: latest.version }; } if (appUpdate.available && appUpdate.canUpdate !== false) { await deps.downloadAppUpdate(); } const launcherResult = await deps.updateLauncher(request.launcherPath, channel); if (launcherResult.status === 'protected' && launcherResult.command) { deps.log(`Launcher update requires manual command: ${launcherResult.command}`); } const restartChoice = await deps.showRestartDialog(); if (restartChoice === 'restart') { deps.quitAndInstall(); } return { status: 'updated', version: latest.version }; } catch (error) { const message = (error as Error).message; if (isAutomatic) { deps.log(`Automatic update check failed: ${message}`); } else { await deps.showUpdateFailedDialog(message); } return { status: 'failed', error: message }; } } return { checkForUpdates(request: UpdateCheckRequest): Promise { if (inFlight) return inFlight; inFlight = runCheck(request).finally(() => { inFlight = null; }); return inFlight; }, startAutomaticChecks(options: { startupDelayMs?: number; pollIntervalMs?: number } = {}): void { const setTimeoutFn = deps.setTimeout ?? setTimeout; const setIntervalFn = deps.setInterval ?? setInterval; const startupDelayMs = options.startupDelayMs ?? 15_000; const pollIntervalMs = options.pollIntervalMs ?? 60 * 60 * 1000; setTimeoutFn(() => { void this.checkForUpdates({ source: 'automatic' }); }, startupDelayMs); setIntervalFn(() => { void this.checkForUpdates({ source: 'automatic' }); }, pollIntervalMs); }, }; } export function createFileUpdateStateStore(statePath: string): { readState: () => Promise; writeState: (state: UpdateState) => Promise; } { return { async readState(): Promise { try { return JSON.parse(await fs.promises.readFile(statePath, 'utf8')) as UpdateState; } catch { return {}; } }, async writeState(state: UpdateState): Promise { await fs.promises.mkdir(path.dirname(statePath), { recursive: true }); await fs.promises.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); }, }; }