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

@@ -1,5 +1,6 @@
import { app, BrowserWindow } from 'electron';
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
import {
initializeDatabase,
destroyDatabase,
@@ -23,6 +24,7 @@ export function registerAppLifecycle(): void {
setupCqrsHandlers();
setupWindowControlHandlers();
setupSystemHandlers();
initializeDesktopUpdater();
await createWindow();
app.on('activate', () => {
@@ -39,6 +41,7 @@ export function registerAppLifecycle(): void {
app.on('before-quit', async (event) => {
if (getDataSource()?.isInitialized) {
event.preventDefault();
shutdownDesktopUpdater();
await cleanupLinuxScreenShareAudioRouting();
await destroyDatabase();
app.quit();

View File

@@ -2,8 +2,13 @@ import { app } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
export type AutoUpdateMode = 'auto' | 'off' | 'version';
export interface DesktopSettings {
autoUpdateMode: AutoUpdateMode;
hardwareAcceleration: boolean;
manifestUrls: string[];
preferredVersion: string | null;
vaapiVideoEncode: boolean;
}
@@ -13,10 +18,45 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
}
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
autoUpdateMode: 'auto',
hardwareAcceleration: true,
manifestUrls: [],
preferredVersion: null,
vaapiVideoEncode: false
};
function normalizeAutoUpdateMode(value: unknown): AutoUpdateMode {
return value === 'off' || value === 'version' ? value : 'auto';
}
function normalizePreferredVersion(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function normalizeManifestUrls(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const manifestUrls: string[] = [];
for (const entry of value) {
if (typeof entry !== 'string') {
continue;
}
const nextUrl = entry.trim();
if (!nextUrl || manifestUrls.includes(nextUrl)) {
continue;
}
manifestUrls.push(nextUrl);
}
return manifestUrls;
}
export function getDesktopSettingsSnapshot(): DesktopSettingsSnapshot {
const storedSettings = readDesktopSettings();
const runtimeHardwareAcceleration = app.isHardwareAccelerationEnabled();
@@ -40,12 +80,15 @@ export function readDesktopSettings(): DesktopSettings {
const parsed = JSON.parse(raw) as Partial<DesktopSettings>;
return {
autoUpdateMode: normalizeAutoUpdateMode(parsed.autoUpdateMode),
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
? parsed.vaapiVideoEncode
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
hardwareAcceleration: typeof parsed.hardwareAcceleration === 'boolean'
? parsed.hardwareAcceleration
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
manifestUrls: normalizeManifestUrls(parsed.manifestUrls),
preferredVersion: normalizePreferredVersion(parsed.preferredVersion)
};
} catch {
return { ...DEFAULT_DESKTOP_SETTINGS };
@@ -53,10 +96,21 @@ export function readDesktopSettings(): DesktopSettings {
}
export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopSettingsSnapshot {
const nextSettings: DesktopSettings = {
const mergedSettings = {
...readDesktopSettings(),
...patch
};
const nextSettings: DesktopSettings = {
autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode),
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
? mergedSettings.hardwareAcceleration
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
manifestUrls: normalizeManifestUrls(mergedSettings.manifestUrls),
preferredVersion: normalizePreferredVersion(mergedSettings.preferredVersion),
vaapiVideoEncode: typeof mergedSettings.vaapiVideoEncode === 'boolean'
? mergedSettings.vaapiVideoEncode
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode
};
const filePath = getDesktopSettingsPath();
fs.mkdirSync(path.dirname(filePath), { recursive: true });

View File

@@ -10,7 +10,11 @@ import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { getDesktopSettingsSnapshot, updateDesktopSettings } from '../desktop-settings';
import {
getDesktopSettingsSnapshot,
updateDesktopSettings,
type DesktopSettings
} from '../desktop-settings';
import {
activateLinuxScreenShareAudioRouting,
deactivateLinuxScreenShareAudioRouting,
@@ -18,6 +22,14 @@ import {
startLinuxScreenShareMonitorCapture,
stopLinuxScreenShareMonitorCapture
} from '../audio/linux-screen-share-routing';
import {
checkForDesktopUpdates,
configureDesktopUpdaterContext,
getDesktopUpdateState,
handleDesktopSettingsChanged,
restartToApplyUpdate,
type DesktopUpdateServerContext
} from '../update/desktop-updater';
const DEFAULT_MIME_TYPE = 'application/octet-stream';
const FILE_CLIPBOARD_FORMATS = [
@@ -79,14 +91,16 @@ function isSupportedClipboardFileFormat(format: string): boolean {
function extractClipboardFilePaths(buffer: Buffer, format: string): string[] {
const textVariants = new Set<string>();
const utf8Text = buffer.toString('utf8').replace(/\0/g, '').trim();
const utf8Text = buffer.toString('utf8').replace(/\0/g, '')
.trim();
if (utf8Text) {
textVariants.add(utf8Text);
}
if (format.toLowerCase() === 'filenamew') {
const utf16Text = buffer.toString('utf16le').replace(/\0/g, '\n').trim();
const utf16Text = buffer.toString('utf16le').replace(/\0/g, '\n')
.trim();
if (utf16Text) {
textVariants.add(utf16Text);
@@ -226,8 +240,23 @@ export function setupSystemHandlers(): void {
ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot());
ipcMain.handle('set-desktop-settings', (_event, patch: { hardwareAcceleration?: boolean }) => {
return updateDesktopSettings(patch);
ipcMain.handle('get-auto-update-state', () => getDesktopUpdateState());
ipcMain.handle('configure-auto-update-context', async (_event, context: Partial<DesktopUpdateServerContext>) => {
return await configureDesktopUpdaterContext(context);
});
ipcMain.handle('check-for-app-updates', async () => {
return await checkForDesktopUpdates();
});
ipcMain.handle('restart-to-apply-update', () => restartToApplyUpdate());
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
const snapshot = updateDesktopSettings(patch);
await handleDesktopSettingsChanged();
return snapshot;
});
ipcMain.handle('relaunch-app', () => {

View File

@@ -3,6 +3,7 @@ import { Command, Query } from './cqrs/types';
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk';
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
export interface LinuxScreenShareAudioRoutingInfo {
available: boolean;
@@ -40,6 +41,48 @@ export interface ClipboardFilePayload {
path?: string;
}
export type DesktopUpdateServerVersionStatus = 'unknown' | 'reported' | 'missing' | 'unavailable';
export interface DesktopUpdateServerContext {
manifestUrls: string[];
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateState {
autoUpdateMode: 'auto' | 'off' | 'version';
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:
| 'idle'
| 'disabled'
| 'checking'
| 'downloading'
| 'up-to-date'
| 'restart-required'
| 'unsupported'
| 'no-manifest'
| 'target-unavailable'
| 'target-older-than-installed'
| 'error';
statusMessage: string | null;
targetVersion: string | null;
}
export interface ElectronAPI {
minimizeWindow: () => void;
maximizeWindow: () => void;
@@ -56,12 +99,29 @@ export interface ElectronAPI {
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
getAppDataPath: () => Promise<string>;
getDesktopSettings: () => Promise<{
autoUpdateMode: 'auto' | 'off' | 'version';
hardwareAcceleration: boolean;
manifestUrls: string[];
preferredVersion: string | null;
runtimeHardwareAcceleration: boolean;
restartRequired: boolean;
}>;
setDesktopSettings: (patch: { hardwareAcceleration?: boolean }) => Promise<{
getAutoUpdateState: () => Promise<DesktopUpdateState>;
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
checkForAppUpdates: () => Promise<DesktopUpdateState>;
restartToApplyUpdate: () => Promise<boolean>;
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
setDesktopSettings: (patch: {
autoUpdateMode?: 'auto' | 'off' | 'version';
hardwareAcceleration?: boolean;
manifestUrls?: string[];
preferredVersion?: string | null;
vaapiVideoEncode?: boolean;
}) => Promise<{
autoUpdateMode: 'auto' | 'off' | 'version';
hardwareAcceleration: boolean;
manifestUrls: string[];
preferredVersion: string | null;
runtimeHardwareAcceleration: boolean;
restartRequired: boolean;
}>;
@@ -121,6 +181,21 @@ const electronAPI: ElectronAPI = {
},
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'),
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
checkForAppUpdates: () => ipcRenderer.invoke('check-for-app-updates'),
restartToApplyUpdate: () => ipcRenderer.invoke('restart-to-apply-update'),
onAutoUpdateStateChanged: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, state: DesktopUpdateState) => {
listener(state);
};
ipcRenderer.on(AUTO_UPDATE_STATE_CHANGED_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(AUTO_UPDATE_STATE_CHANGED_CHANNEL, wrappedListener);
};
},
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'),

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));
}