import { app, net } from 'electron'; import { autoUpdater } from 'electron-updater'; import { readDesktopSettings, type AutoUpdateMode } from '../desktop-settings'; import { getMainWindow } from '../window/create-window'; import { compareSemanticVersions, normalizeSemanticVersion, sortSemanticVersionsDescending } from './version'; const DEFAULT_POLL_INTERVAL_MINUTES = 30; const MINIMUM_POLL_INTERVAL_MINUTES = 5; interface ReleaseManifestEntry { feedUrl: string; notes?: string; publishedAt?: string; version: string; } interface UpdateVersionInfo { version: string; } interface ReleaseManifest { minimumServerVersion: string | null; pollIntervalMinutes: number; versions: ReleaseManifestEntry[]; } export type DesktopUpdateStatus = | 'idle' | 'disabled' | 'checking' | 'downloading' | 'up-to-date' | 'restart-required' | 'unsupported' | 'no-manifest' | 'target-unavailable' | 'target-older-than-installed' | 'error'; export type DesktopUpdateServerVersionStatus = | 'unknown' | 'reported' | 'missing' | 'unavailable'; export interface DesktopUpdateServerContext { manifestUrls: string[]; serverVersion: string | null; serverVersionStatus: DesktopUpdateServerVersionStatus; } export interface DesktopUpdateState { autoUpdateMode: AutoUpdateMode; availableVersions: string[]; configuredManifestUrls: string[]; currentVersion: string; defaultManifestUrls: string[]; isSupported: boolean; lastCheckedAt: number | null; latestVersion: string | null; manifestUrl: string | null; manifestUrls: string[]; minimumServerVersion: string | null; preferredVersion: string | null; restartRequired: boolean; serverBlocked: boolean; serverBlockMessage: string | null; serverVersion: string | null; serverVersionStatus: DesktopUpdateServerVersionStatus; status: DesktopUpdateStatus; statusMessage: string | null; targetVersion: string | null; } export const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed'; let currentCheckPromise: Promise | null = null; let currentContext: DesktopUpdateServerContext = { manifestUrls: [], serverVersion: null, serverVersionStatus: 'unknown' }; let desktopUpdateState: DesktopUpdateState = createInitialState(); let periodicRefreshTimer: NodeJS.Timeout | null = null; let refreshGeneration = 0; let updaterInitialized = false; function createInitialState(): DesktopUpdateState { const settings = readDesktopSettings(); return { autoUpdateMode: settings.autoUpdateMode, availableVersions: [], configuredManifestUrls: settings.manifestUrls, currentVersion: app.getVersion(), defaultManifestUrls: [], isSupported: app.isPackaged, lastCheckedAt: null, latestVersion: null, manifestUrl: null, manifestUrls: settings.manifestUrls, minimumServerVersion: null, preferredVersion: settings.preferredVersion, restartRequired: false, serverBlocked: false, serverBlockMessage: null, serverVersion: null, serverVersionStatus: 'unknown', status: 'idle', statusMessage: null, targetVersion: null }; } function clearPeriodicRefreshTimer(): void { if (!periodicRefreshTimer) { return; } clearInterval(periodicRefreshTimer); periodicRefreshTimer = null; } function emitState(): void { const mainWindow = getMainWindow(); if (!mainWindow || mainWindow.isDestroyed()) { return; } mainWindow.webContents.send(AUTO_UPDATE_STATE_CHANGED_CHANNEL, desktopUpdateState); } function setDesktopUpdateState(patch: Partial): void { desktopUpdateState = { ...desktopUpdateState, ...patch, currentVersion: app.getVersion() }; emitState(); } function sanitizeHttpUrl(rawValue: unknown): string | null { if (typeof rawValue !== 'string') { return null; } const trimmedValue = rawValue.trim(); if (!trimmedValue) { return null; } try { const parsedUrl = new URL(trimmedValue); return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:' ? parsedUrl.toString().replace(/\/$/, '') : null; } catch { return null; } } function sanitizeHttpUrls(rawValue: unknown): string[] { if (!Array.isArray(rawValue)) { return []; } const manifestUrls: string[] = []; for (const entry of rawValue) { const manifestUrl = sanitizeHttpUrl(entry); if (!manifestUrl || manifestUrls.includes(manifestUrl)) { continue; } manifestUrls.push(manifestUrl); } return manifestUrls; } function getEffectiveManifestUrls(configuredManifestUrls: string[]): string[] { return configuredManifestUrls.length > 0 ? [...configuredManifestUrls] : [...currentContext.manifestUrls]; } function coercePollIntervalMinutes(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) ? Math.max(MINIMUM_POLL_INTERVAL_MINUTES, Math.round(value)) : DEFAULT_POLL_INTERVAL_MINUTES; } function parseReleaseManifest(payload: unknown): ReleaseManifest { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { throw new Error('Release manifest must be a JSON object.'); } const rawManifest = payload as Record; const rawVersions = Array.isArray(rawManifest.versions) ? rawManifest.versions : []; const parsedVersions: ReleaseManifestEntry[] = []; for (const entry of rawVersions) { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { continue; } const rawEntry = entry as Record; const version = normalizeSemanticVersion(rawEntry.version as string | undefined); const feedUrl = sanitizeHttpUrl(rawEntry.feedUrl); if (!version || !feedUrl) { continue; } const notes = typeof rawEntry.notes === 'string' && rawEntry.notes.trim().length > 0 ? rawEntry.notes.trim() : undefined; const publishedAt = typeof rawEntry.publishedAt === 'string' && rawEntry.publishedAt.trim().length > 0 ? rawEntry.publishedAt.trim() : undefined; parsedVersions.push({ feedUrl, ...(notes ? { notes } : {}), ...(publishedAt ? { publishedAt } : {}), version }); } parsedVersions.sort((left, right) => compareSemanticVersions(right.version, left.version)); const deduplicatedVersions: ReleaseManifestEntry[] = []; const seenVersions = new Set(); for (const entry of parsedVersions) { if (seenVersions.has(entry.version)) { continue; } seenVersions.add(entry.version); deduplicatedVersions.push(entry); } if (deduplicatedVersions.length === 0) { throw new Error('Release manifest does not contain any valid versions.'); } return { minimumServerVersion: normalizeSemanticVersion( rawManifest.minimumServerVersion as string | undefined ), pollIntervalMinutes: coercePollIntervalMinutes(rawManifest.pollIntervalMinutes), versions: deduplicatedVersions }; } function resolveServerCompatibility(minimumServerVersion: string | null): { blocked: boolean; message: string | null; } { if (currentContext.serverVersionStatus === 'missing') { return { blocked: true, message: 'The connected server is too old. Update the server project to a version that reports its release metadata.' }; } if ( currentContext.serverVersionStatus !== 'reported' || !minimumServerVersion || !currentContext.serverVersion ) { return { blocked: false, message: null }; } if (compareSemanticVersions(currentContext.serverVersion, minimumServerVersion) >= 0) { return { blocked: false, message: null }; } return { blocked: true, message: `This desktop app requires server version ${minimumServerVersion} or newer. The connected server is ${currentContext.serverVersion}.` }; } function getManifestMissingMessage(autoUpdateMode: AutoUpdateMode): string { return autoUpdateMode === 'off' ? 'Automatic updates are turned off.' : 'No release manifest URLs are configured.'; } function getTargetUnavailableMessage( autoUpdateMode: AutoUpdateMode, preferredVersion: string | null ): string { if (autoUpdateMode === 'version') { return preferredVersion ? `The selected version ${preferredVersion} is not present in the release manifest.` : 'Select a version before enabling pinned updates.'; } return 'No compatible release was found in the release manifest.'; } function getFriendlyErrorMessage(error: unknown): string { const rawMessage = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'; if (rawMessage.includes('APPIMAGE')) { return 'Automatic updates on Linux require the packaged AppImage or DEB build.'; } return rawMessage; } function getSelectedRelease(manifest: ReleaseManifest): ReleaseManifestEntry | null { const settings = readDesktopSettings(); if (settings.autoUpdateMode === 'version') { const preferredVersion = normalizeSemanticVersion(settings.preferredVersion); if (!preferredVersion) { return null; } return manifest.versions.find((entry) => entry.version === preferredVersion) ?? null; } return manifest.versions[0] ?? null; } function refreshSettingsSnapshot(): void { const settings = readDesktopSettings(); const defaultManifestUrls = [...currentContext.manifestUrls]; const manifestUrls = getEffectiveManifestUrls(settings.manifestUrls); setDesktopUpdateState({ autoUpdateMode: settings.autoUpdateMode, configuredManifestUrls: settings.manifestUrls, currentVersion: app.getVersion(), defaultManifestUrls, isSupported: app.isPackaged, manifestUrls, preferredVersion: settings.preferredVersion }); } function schedulePeriodicRefresh(pollIntervalMinutes: number): void { clearPeriodicRefreshTimer(); if (!app.isPackaged || desktopUpdateState.autoUpdateMode === 'off' || desktopUpdateState.restartRequired) { return; } periodicRefreshTimer = setInterval(() => { void refreshDesktopUpdater('scheduled'); }, pollIntervalMinutes * 60_000); } async function loadReleaseManifest(manifestUrl: string): Promise { const response = await net.fetch(manifestUrl, { headers: { accept: 'application/json' } }); if (!response.ok) { throw new Error(`Release manifest request failed with status ${response.status}.`); } const payload = await response.json(); return parseReleaseManifest(payload); } function formatManifestLoadErrors(errors: string[]): string { if (errors.length === 0) { return 'No valid release manifest could be loaded.'; } if (errors.length === 1) { return errors[0]; } const remainingCount = errors.length - 1; return `${errors[0]} (${remainingCount} more manifest URL${remainingCount === 1 ? '' : 's'} failed.)`; } async function loadReleaseManifestFromCandidates(manifestUrls: string[]): Promise<{ manifest: ReleaseManifest; manifestUrl: string; }> { const errors: string[] = []; for (const manifestUrl of manifestUrls) { try { const manifest = await loadReleaseManifest(manifestUrl); return { manifest, manifestUrl }; } catch (error) { errors.push(`${manifestUrl}: ${getFriendlyErrorMessage(error)}`); } } throw new Error(formatManifestLoadErrors(errors)); } async function performUpdateCheck( targetRelease: ReleaseManifestEntry, generation: number ): Promise { if (generation !== refreshGeneration || desktopUpdateState.restartRequired) { return; } if (currentCheckPromise) { await currentCheckPromise; return; } currentCheckPromise = (async () => { autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = true; autoUpdater.allowDowngrade = false; autoUpdater.setFeedURL({ provider: 'generic', url: targetRelease.feedUrl }); setDesktopUpdateState({ lastCheckedAt: Date.now(), status: 'checking', statusMessage: `Checking for MetoYou ${targetRelease.version}…`, targetVersion: targetRelease.version }); await autoUpdater.checkForUpdates(); })(); try { await currentCheckPromise; } finally { currentCheckPromise = null; } } async function refreshDesktopUpdater( _reason: 'context-changed' | 'manual' | 'scheduled' | 'settings-changed' ): Promise { const generation = ++refreshGeneration; refreshSettingsSnapshot(); const manifestUrls = [...desktopUpdateState.manifestUrls]; const baseServerCompatibility = resolveServerCompatibility(null); setDesktopUpdateState({ manifestUrl: null, serverBlocked: baseServerCompatibility.blocked, serverBlockMessage: baseServerCompatibility.message, serverVersion: currentContext.serverVersion, serverVersionStatus: currentContext.serverVersionStatus }); if (manifestUrls.length === 0) { clearPeriodicRefreshTimer(); if (desktopUpdateState.restartRequired) { setDesktopUpdateState({ availableVersions: [], latestVersion: null, manifestUrl: null, minimumServerVersion: null }); return; } setDesktopUpdateState({ availableVersions: [], latestVersion: null, manifestUrl: null, minimumServerVersion: null, status: app.isPackaged && desktopUpdateState.autoUpdateMode !== 'off' ? 'no-manifest' : 'disabled', statusMessage: getManifestMissingMessage(desktopUpdateState.autoUpdateMode), targetVersion: null }); if (!app.isPackaged && desktopUpdateState.autoUpdateMode !== 'off') { setDesktopUpdateState({ status: 'unsupported', statusMessage: 'Automatic updates only work in packaged desktop builds.' }); } return; } try { const manifestResult = await loadReleaseManifestFromCandidates(manifestUrls); if (generation !== refreshGeneration) { return; } const { manifest, manifestUrl } = manifestResult; const availableVersions = sortSemanticVersionsDescending( manifest.versions.map((entry) => entry.version) ); const latestVersion = availableVersions[0] ?? null; const selectedRelease = getSelectedRelease(manifest); const serverCompatibility = resolveServerCompatibility( manifest.minimumServerVersion ); setDesktopUpdateState({ availableVersions, latestVersion, manifestUrl, minimumServerVersion: manifest.minimumServerVersion, serverBlocked: serverCompatibility.blocked, serverBlockMessage: serverCompatibility.message, targetVersion: selectedRelease?.version ?? null }); if (desktopUpdateState.restartRequired) { clearPeriodicRefreshTimer(); return; } if (!app.isPackaged) { clearPeriodicRefreshTimer(); setDesktopUpdateState({ status: 'unsupported', statusMessage: 'Automatic updates only work in packaged desktop builds.' }); return; } if (desktopUpdateState.autoUpdateMode === 'off') { clearPeriodicRefreshTimer(); setDesktopUpdateState({ status: 'disabled', statusMessage: 'Automatic updates are turned off.' }); return; } if (!selectedRelease) { clearPeriodicRefreshTimer(); setDesktopUpdateState({ status: 'target-unavailable', statusMessage: getTargetUnavailableMessage( desktopUpdateState.autoUpdateMode, desktopUpdateState.preferredVersion ) }); return; } if (compareSemanticVersions(selectedRelease.version, app.getVersion()) < 0) { clearPeriodicRefreshTimer(); setDesktopUpdateState({ status: 'target-older-than-installed', statusMessage: `MetoYou ${app.getVersion()} is newer than ${selectedRelease.version}. Downgrades are not applied automatically.`, targetVersion: selectedRelease.version }); return; } schedulePeriodicRefresh(manifest.pollIntervalMinutes); await performUpdateCheck(selectedRelease, generation); } catch (error) { if (generation !== refreshGeneration) { return; } clearPeriodicRefreshTimer(); setDesktopUpdateState({ availableVersions: [], latestVersion: null, manifestUrl: null, minimumServerVersion: null, status: 'error', statusMessage: getFriendlyErrorMessage(error), targetVersion: null }); } } export function getDesktopUpdateState(): DesktopUpdateState { return desktopUpdateState; } export function initializeDesktopUpdater(): void { if (updaterInitialized) { return; } updaterInitialized = true; autoUpdater.on('checking-for-update', () => { if (desktopUpdateState.restartRequired) { return; } setDesktopUpdateState({ status: 'checking', statusMessage: 'Checking for desktop updates…' }); }); autoUpdater.on('update-available', (updateInfo: UpdateVersionInfo) => { const nextVersion = normalizeSemanticVersion(updateInfo.version) ?? desktopUpdateState.targetVersion; setDesktopUpdateState({ lastCheckedAt: Date.now(), status: 'downloading', statusMessage: `Downloading MetoYou ${nextVersion ?? 'update'}…`, targetVersion: nextVersion }); }); autoUpdater.on('update-not-available', () => { if (desktopUpdateState.restartRequired) { return; } const isPinnedVersion = desktopUpdateState.autoUpdateMode === 'version' && !!desktopUpdateState.targetVersion; setDesktopUpdateState({ lastCheckedAt: Date.now(), status: 'up-to-date', statusMessage: isPinnedVersion ? `MetoYou ${desktopUpdateState.targetVersion} is already installed.` : 'MetoYou is up to date.' }); }); autoUpdater.on('update-downloaded', (updateInfo: UpdateVersionInfo) => { clearPeriodicRefreshTimer(); const nextVersion = normalizeSemanticVersion(updateInfo.version) ?? desktopUpdateState.targetVersion; setDesktopUpdateState({ lastCheckedAt: Date.now(), restartRequired: true, status: 'restart-required', statusMessage: `MetoYou ${nextVersion ?? 'update'} is ready. Restart the app to finish installing it.`, targetVersion: nextVersion }); }); autoUpdater.on('error', (error: unknown) => { if (desktopUpdateState.restartRequired) { return; } setDesktopUpdateState({ lastCheckedAt: Date.now(), status: 'error', statusMessage: getFriendlyErrorMessage(error) }); }); refreshSettingsSnapshot(); } export async function configureDesktopUpdaterContext( context: Partial ): Promise { initializeDesktopUpdater(); const manifestUrls = sanitizeHttpUrls(context.manifestUrls); currentContext = { manifestUrls, serverVersion: normalizeSemanticVersion(context.serverVersion), serverVersionStatus: context.serverVersionStatus ?? 'unknown' }; await refreshDesktopUpdater('context-changed'); return desktopUpdateState; } export async function handleDesktopSettingsChanged(): Promise { initializeDesktopUpdater(); await refreshDesktopUpdater('settings-changed'); } export async function checkForDesktopUpdates(): Promise { initializeDesktopUpdater(); await refreshDesktopUpdater('manual'); return desktopUpdateState; } export function restartToApplyUpdate(): boolean { if (!desktopUpdateState.restartRequired) { return false; } autoUpdater.quitAndInstall(false, true); return true; } export function shutdownDesktopUpdater(): void { clearPeriodicRefreshTimer(); }