Add auto updater

This commit is contained in:
2026-03-10 23:38:57 +01:00
parent e8e5c24600
commit c3fbd7d4fe
20 changed files with 2272 additions and 14 deletions

View File

@@ -0,0 +1,738 @@
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<void> | 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<DesktopUpdateState>): 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<string, unknown>;
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<string, unknown>;
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<string>();
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<ReleaseManifest> {
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<void> {
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<void> {
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<DesktopUpdateServerContext>
): Promise<DesktopUpdateState> {
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<void> {
initializeDesktopUpdater();
await refreshDesktopUpdater('settings-changed');
}
export async function checkForDesktopUpdates(): Promise<DesktopUpdateState> {
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();
}

150
electron/update/version.ts Normal file
View File

@@ -0,0 +1,150 @@
interface ParsedSemanticVersion {
major: number;
minor: number;
patch: number;
prerelease: string[];
}
function parseNumericIdentifier(value: string): number | null {
return /^\d+$/.test(value) ? Number.parseInt(value, 10) : null;
}
function parseSemanticVersion(rawValue: string | null | undefined): ParsedSemanticVersion | null {
const normalized = normalizeSemanticVersion(rawValue);
if (!normalized) {
return null;
}
const match = normalized.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?$/);
if (!match) {
return null;
}
const prerelease = match[4]
? match[4]
.split('.')
.map((part) => part.trim())
.filter(Boolean)
: [];
return {
major: Number.parseInt(match[1], 10),
minor: Number.parseInt(match[2] ?? '0', 10),
patch: Number.parseInt(match[3] ?? '0', 10),
prerelease
};
}
function comparePrereleaseIdentifiers(left: string, right: string): number {
const leftNumeric = parseNumericIdentifier(left);
const rightNumeric = parseNumericIdentifier(right);
if (leftNumeric !== null && rightNumeric !== null) {
return leftNumeric - rightNumeric;
}
if (leftNumeric !== null) {
return -1;
}
if (rightNumeric !== null) {
return 1;
}
return left.localeCompare(right);
}
function comparePrerelease(left: string[], right: string[]): number {
if (left.length === 0 && right.length === 0) {
return 0;
}
if (left.length === 0) {
return 1;
}
if (right.length === 0) {
return -1;
}
const maxLength = Math.max(left.length, right.length);
for (let index = 0; index < maxLength; index += 1) {
const leftValue = left[index];
const rightValue = right[index];
if (!leftValue) {
return -1;
}
if (!rightValue) {
return 1;
}
const comparison = comparePrereleaseIdentifiers(leftValue, rightValue);
if (comparison !== 0) {
return comparison;
}
}
return 0;
}
export function normalizeSemanticVersion(rawValue: string | null | undefined): string | null {
if (typeof rawValue !== 'string') {
return null;
}
const trimmedValue = rawValue.trim();
if (!trimmedValue) {
return null;
}
return trimmedValue.replace(/^v/i, '').split('+')[0] ?? null;
}
export function compareSemanticVersions(
leftVersion: string | null | undefined,
rightVersion: string | null | undefined
): number {
const left = parseSemanticVersion(leftVersion);
const right = parseSemanticVersion(rightVersion);
if (!left && !right) {
return 0;
}
if (!left) {
return -1;
}
if (!right) {
return 1;
}
if (left.major !== right.major) {
return left.major - right.major;
}
if (left.minor !== right.minor) {
return left.minor - right.minor;
}
if (left.patch !== right.patch) {
return left.patch - right.patch;
}
return comparePrerelease(left.prerelease, right.prerelease);
}
export function sortSemanticVersionsDescending(versions: string[]): string[] {
const normalizedVersions = versions
.map((version) => normalizeSemanticVersion(version))
.filter((version): version is string => !!version);
return [...new Set(normalizedVersions)].sort((left, right) => compareSemanticVersions(right, left));
}