Add auto updater
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
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