Add auto updater
This commit is contained in:
738
electron/update/desktop-updater.ts
Normal file
738
electron/update/desktop-updater.ts
Normal 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
150
electron/update/version.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user