diff --git a/README.md b/README.md index e160cb5..fb105cc 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,78 @@ If `SSL=true`, run `./generate-cert.sh` once. Server files: - `server/data/variables.json` holds `klipyApiKey` +- `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates + +## Desktop auto updates + +The packaged Electron app now reads a hosted release manifest from the active server's `/api/health` response. + +Release flow: + +1. Build the desktop packages with `npm run electron:build` or the platform-specific Electron Builder commands. +2. Upload one version folder that contains the generated `latest.yml`, `latest-mac.yml`, `latest-linux.yml`, and the matching installers/artifacts. +3. Generate or update the hosted manifest JSON with: + + `npm run release:manifest -- --feed-url https://your-cdn.example.com/metoyou/1.2.3` + +4. Set `releaseManifestUrl` in `server/data/variables.json` to the hosted manifest JSON URL. + +### GitHub / Gitea release assets + +If you publish desktop builds as release assets, use the release download URL as the manifest `feedUrl`. + +Examples: + +- GitHub tag `v1.2.3`: + + `https://github.com/OWNER/REPO/releases/download/v1.2.3` + +- Gitea tag `v1.2.3`: + + `https://gitea.example.com/OWNER/REPO/releases/download/v1.2.3` + +That release must include these assets with their normal Electron Builder names: + +- `latest.yml` +- `latest-mac.yml` +- `latest-linux.yml` +- Windows installer assets (`.exe`, `.blockmap`) +- macOS assets (`.dmg`, `.zip`) +- Linux assets (`.AppImage`, `.deb`) + +You should also upload `release-manifest.json` as a release asset. + +For a stable manifest URL, point the server at the latest-release asset URL: + +- GitHub: + + `https://github.com/OWNER/REPO/releases/latest/download/release-manifest.json` + +- Gitea: use the equivalent latest-release asset URL if your instance supports it, otherwise publish `release-manifest.json` at a separate stable URL. + +If you want the in-app "Specific version" option to list older releases too, keep one cumulative manifest and merge the previous file when generating the next one: + + `npm run release:manifest -- --existing ./release-manifest.json --feed-url https://github.com/OWNER/REPO/releases/download/v1.2.3 --version 1.2.3` + +The manifest format is: + +```json +{ + "schemaVersion": 1, + "generatedAt": "2026-03-10T12:00:00.000Z", + "minimumServerVersion": "1.0.0", + "pollIntervalMinutes": 30, + "versions": [ + { + "version": "1.2.3", + "feedUrl": "https://your-cdn.example.com/metoyou/1.2.3", + "publishedAt": "2026-03-10T12:00:00.000Z" + } + ] +} +``` + +`feedUrl` must point to a directory that contains the Electron Builder update descriptors for Windows, macOS, and Linux. ## Main commands diff --git a/electron/app/lifecycle.ts b/electron/app/lifecycle.ts index 1c7c234..ff7af02 100644 --- a/electron/app/lifecycle.ts +++ b/electron/app/lifecycle.ts @@ -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(); diff --git a/electron/desktop-settings.ts b/electron/desktop-settings.ts index 58b419c..0831e76 100644 --- a/electron/desktop-settings.ts +++ b/electron/desktop-settings.ts @@ -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; 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): 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 }); diff --git a/electron/ipc/system.ts b/electron/ipc/system.ts index ac0c5c3..4f266a1 100644 --- a/electron/ipc/system.ts +++ b/electron/ipc/system.ts @@ -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(); - 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) => { + 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) => { + const snapshot = updateDesktopSettings(patch); + + await handleDesktopSettingsChanged(); + return snapshot; }); ipcMain.handle('relaunch-app', () => { diff --git a/electron/preload.ts b/electron/preload.ts index 6e541ae..07bcef9 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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; getDesktopSettings: () => Promise<{ + autoUpdateMode: 'auto' | 'off' | 'version'; hardwareAcceleration: boolean; + manifestUrls: string[]; + preferredVersion: string | null; runtimeHardwareAcceleration: boolean; restartRequired: boolean; }>; - setDesktopSettings: (patch: { hardwareAcceleration?: boolean }) => Promise<{ + getAutoUpdateState: () => Promise; + configureAutoUpdateContext: (context: Partial) => Promise; + checkForAppUpdates: () => Promise; + restartToApplyUpdate: () => Promise; + 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'), diff --git a/electron/update/desktop-updater.ts b/electron/update/desktop-updater.ts new file mode 100644 index 0000000..e9575e9 --- /dev/null +++ b/electron/update/desktop-updater.ts @@ -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 | null = null; +let currentContext: DesktopUpdateServerContext = { + manifestUrls: [], + serverVersion: null, + serverVersionStatus: 'unknown' +}; +let desktopUpdateState: DesktopUpdateState = createInitialState(); +let periodicRefreshTimer: NodeJS.Timeout | null = null; +let refreshGeneration = 0; +let updaterInitialized = false; + +function createInitialState(): DesktopUpdateState { + const settings = readDesktopSettings(); + + return { + autoUpdateMode: settings.autoUpdateMode, + availableVersions: [], + configuredManifestUrls: settings.manifestUrls, + currentVersion: app.getVersion(), + defaultManifestUrls: [], + isSupported: app.isPackaged, + lastCheckedAt: null, + latestVersion: null, + manifestUrl: null, + manifestUrls: settings.manifestUrls, + minimumServerVersion: null, + preferredVersion: settings.preferredVersion, + restartRequired: false, + serverBlocked: false, + serverBlockMessage: null, + serverVersion: null, + serverVersionStatus: 'unknown', + status: 'idle', + statusMessage: null, + targetVersion: null + }; +} + +function clearPeriodicRefreshTimer(): void { + if (!periodicRefreshTimer) { + return; + } + + clearInterval(periodicRefreshTimer); + periodicRefreshTimer = null; +} + +function emitState(): void { + const mainWindow = getMainWindow(); + + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + + mainWindow.webContents.send(AUTO_UPDATE_STATE_CHANGED_CHANNEL, desktopUpdateState); +} + +function setDesktopUpdateState(patch: Partial): void { + desktopUpdateState = { + ...desktopUpdateState, + ...patch, + currentVersion: app.getVersion() + }; + + emitState(); +} + +function sanitizeHttpUrl(rawValue: unknown): string | null { + if (typeof rawValue !== 'string') { + return null; + } + + const trimmedValue = rawValue.trim(); + + if (!trimmedValue) { + return null; + } + + try { + const parsedUrl = new URL(trimmedValue); + + return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:' + ? parsedUrl.toString().replace(/\/$/, '') + : null; + } catch { + return null; + } +} + +function sanitizeHttpUrls(rawValue: unknown): string[] { + if (!Array.isArray(rawValue)) { + return []; + } + + const manifestUrls: string[] = []; + + for (const entry of rawValue) { + const manifestUrl = sanitizeHttpUrl(entry); + + if (!manifestUrl || manifestUrls.includes(manifestUrl)) { + continue; + } + + manifestUrls.push(manifestUrl); + } + + return manifestUrls; +} + +function getEffectiveManifestUrls(configuredManifestUrls: string[]): string[] { + return configuredManifestUrls.length > 0 + ? [...configuredManifestUrls] + : [...currentContext.manifestUrls]; +} + +function coercePollIntervalMinutes(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) + ? Math.max(MINIMUM_POLL_INTERVAL_MINUTES, Math.round(value)) + : DEFAULT_POLL_INTERVAL_MINUTES; +} + +function parseReleaseManifest(payload: unknown): ReleaseManifest { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('Release manifest must be a JSON object.'); + } + + const rawManifest = payload as Record; + const rawVersions = Array.isArray(rawManifest.versions) ? rawManifest.versions : []; + const parsedVersions: ReleaseManifestEntry[] = []; + + for (const entry of rawVersions) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + continue; + } + + const rawEntry = entry as Record; + const version = normalizeSemanticVersion(rawEntry.version as string | undefined); + const feedUrl = sanitizeHttpUrl(rawEntry.feedUrl); + + if (!version || !feedUrl) { + continue; + } + + const notes = typeof rawEntry.notes === 'string' && rawEntry.notes.trim().length > 0 + ? rawEntry.notes.trim() + : undefined; + const publishedAt = typeof rawEntry.publishedAt === 'string' + && rawEntry.publishedAt.trim().length > 0 + ? rawEntry.publishedAt.trim() + : undefined; + + parsedVersions.push({ + feedUrl, + ...(notes ? { notes } : {}), + ...(publishedAt ? { publishedAt } : {}), + version + }); + } + + parsedVersions.sort((left, right) => compareSemanticVersions(right.version, left.version)); + + const deduplicatedVersions: ReleaseManifestEntry[] = []; + const seenVersions = new Set(); + + for (const entry of parsedVersions) { + if (seenVersions.has(entry.version)) { + continue; + } + + seenVersions.add(entry.version); + deduplicatedVersions.push(entry); + } + + if (deduplicatedVersions.length === 0) { + throw new Error('Release manifest does not contain any valid versions.'); + } + + return { + minimumServerVersion: normalizeSemanticVersion( + rawManifest.minimumServerVersion as string | undefined + ), + pollIntervalMinutes: coercePollIntervalMinutes(rawManifest.pollIntervalMinutes), + versions: deduplicatedVersions + }; +} + +function resolveServerCompatibility(minimumServerVersion: string | null): { + blocked: boolean; + message: string | null; +} { + if (currentContext.serverVersionStatus === 'missing') { + return { + blocked: true, + message: 'The connected server is too old. Update the server project to a version that reports its release metadata.' + }; + } + + if ( + currentContext.serverVersionStatus !== 'reported' + || !minimumServerVersion + || !currentContext.serverVersion + ) { + return { blocked: false, + message: null }; + } + + if (compareSemanticVersions(currentContext.serverVersion, minimumServerVersion) >= 0) { + return { blocked: false, + message: null }; + } + + return { + blocked: true, + message: `This desktop app requires server version ${minimumServerVersion} or newer. The connected server is ${currentContext.serverVersion}.` + }; +} + +function getManifestMissingMessage(autoUpdateMode: AutoUpdateMode): string { + return autoUpdateMode === 'off' + ? 'Automatic updates are turned off.' + : 'No release manifest URLs are configured.'; +} + +function getTargetUnavailableMessage( + autoUpdateMode: AutoUpdateMode, + preferredVersion: string | null +): string { + if (autoUpdateMode === 'version') { + return preferredVersion + ? `The selected version ${preferredVersion} is not present in the release manifest.` + : 'Select a version before enabling pinned updates.'; + } + + return 'No compatible release was found in the release manifest.'; +} + +function getFriendlyErrorMessage(error: unknown): string { + const rawMessage = error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : 'Unknown error'; + + if (rawMessage.includes('APPIMAGE')) { + return 'Automatic updates on Linux require the packaged AppImage or DEB build.'; + } + + return rawMessage; +} + +function getSelectedRelease(manifest: ReleaseManifest): ReleaseManifestEntry | null { + const settings = readDesktopSettings(); + + if (settings.autoUpdateMode === 'version') { + const preferredVersion = normalizeSemanticVersion(settings.preferredVersion); + + if (!preferredVersion) { + return null; + } + + return manifest.versions.find((entry) => entry.version === preferredVersion) ?? null; + } + + return manifest.versions[0] ?? null; +} + +function refreshSettingsSnapshot(): void { + const settings = readDesktopSettings(); + const defaultManifestUrls = [...currentContext.manifestUrls]; + const manifestUrls = getEffectiveManifestUrls(settings.manifestUrls); + + setDesktopUpdateState({ + autoUpdateMode: settings.autoUpdateMode, + configuredManifestUrls: settings.manifestUrls, + currentVersion: app.getVersion(), + defaultManifestUrls, + isSupported: app.isPackaged, + manifestUrls, + preferredVersion: settings.preferredVersion + }); +} + +function schedulePeriodicRefresh(pollIntervalMinutes: number): void { + clearPeriodicRefreshTimer(); + + if (!app.isPackaged || desktopUpdateState.autoUpdateMode === 'off' || desktopUpdateState.restartRequired) { + return; + } + + periodicRefreshTimer = setInterval(() => { + void refreshDesktopUpdater('scheduled'); + }, pollIntervalMinutes * 60_000); +} + +async function loadReleaseManifest(manifestUrl: string): Promise { + const response = await net.fetch(manifestUrl, { + headers: { + accept: 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`Release manifest request failed with status ${response.status}.`); + } + + const payload = await response.json(); + + return parseReleaseManifest(payload); +} + +function formatManifestLoadErrors(errors: string[]): string { + if (errors.length === 0) { + return 'No valid release manifest could be loaded.'; + } + + if (errors.length === 1) { + return errors[0]; + } + + const remainingCount = errors.length - 1; + + return `${errors[0]} (${remainingCount} more manifest URL${remainingCount === 1 ? '' : 's'} failed.)`; +} + +async function loadReleaseManifestFromCandidates(manifestUrls: string[]): Promise<{ + manifest: ReleaseManifest; + manifestUrl: string; +}> { + const errors: string[] = []; + + for (const manifestUrl of manifestUrls) { + try { + const manifest = await loadReleaseManifest(manifestUrl); + + return { + manifest, + manifestUrl + }; + } catch (error) { + errors.push(`${manifestUrl}: ${getFriendlyErrorMessage(error)}`); + } + } + + throw new Error(formatManifestLoadErrors(errors)); +} + +async function performUpdateCheck( + targetRelease: ReleaseManifestEntry, + generation: number +): Promise { + if (generation !== refreshGeneration || desktopUpdateState.restartRequired) { + return; + } + + if (currentCheckPromise) { + await currentCheckPromise; + return; + } + + currentCheckPromise = (async () => { + autoUpdater.autoDownload = true; + autoUpdater.autoInstallOnAppQuit = true; + autoUpdater.allowDowngrade = false; + autoUpdater.setFeedURL({ provider: 'generic', url: targetRelease.feedUrl }); + + setDesktopUpdateState({ + lastCheckedAt: Date.now(), + status: 'checking', + statusMessage: `Checking for MetoYou ${targetRelease.version}…`, + targetVersion: targetRelease.version + }); + + await autoUpdater.checkForUpdates(); + })(); + + try { + await currentCheckPromise; + } finally { + currentCheckPromise = null; + } +} + +async function refreshDesktopUpdater( + _reason: 'context-changed' | 'manual' | 'scheduled' | 'settings-changed' +): Promise { + const generation = ++refreshGeneration; + + refreshSettingsSnapshot(); + + const manifestUrls = [...desktopUpdateState.manifestUrls]; + const baseServerCompatibility = resolveServerCompatibility(null); + + setDesktopUpdateState({ + manifestUrl: null, + serverBlocked: baseServerCompatibility.blocked, + serverBlockMessage: baseServerCompatibility.message, + serverVersion: currentContext.serverVersion, + serverVersionStatus: currentContext.serverVersionStatus + }); + + if (manifestUrls.length === 0) { + clearPeriodicRefreshTimer(); + + if (desktopUpdateState.restartRequired) { + setDesktopUpdateState({ + availableVersions: [], + latestVersion: null, + manifestUrl: null, + minimumServerVersion: null + }); + + return; + } + + setDesktopUpdateState({ + availableVersions: [], + latestVersion: null, + manifestUrl: null, + minimumServerVersion: null, + status: app.isPackaged && desktopUpdateState.autoUpdateMode !== 'off' + ? 'no-manifest' + : 'disabled', + statusMessage: getManifestMissingMessage(desktopUpdateState.autoUpdateMode), + targetVersion: null + }); + + if (!app.isPackaged && desktopUpdateState.autoUpdateMode !== 'off') { + setDesktopUpdateState({ + status: 'unsupported', + statusMessage: 'Automatic updates only work in packaged desktop builds.' + }); + } + + return; + } + + try { + const manifestResult = await loadReleaseManifestFromCandidates(manifestUrls); + + if (generation !== refreshGeneration) { + return; + } + + const { manifest, manifestUrl } = manifestResult; + const availableVersions = sortSemanticVersionsDescending( + manifest.versions.map((entry) => entry.version) + ); + const latestVersion = availableVersions[0] ?? null; + const selectedRelease = getSelectedRelease(manifest); + const serverCompatibility = resolveServerCompatibility( + manifest.minimumServerVersion + ); + + setDesktopUpdateState({ + availableVersions, + latestVersion, + manifestUrl, + minimumServerVersion: manifest.minimumServerVersion, + serverBlocked: serverCompatibility.blocked, + serverBlockMessage: serverCompatibility.message, + targetVersion: selectedRelease?.version ?? null + }); + + if (desktopUpdateState.restartRequired) { + clearPeriodicRefreshTimer(); + return; + } + + if (!app.isPackaged) { + clearPeriodicRefreshTimer(); + + setDesktopUpdateState({ + status: 'unsupported', + statusMessage: 'Automatic updates only work in packaged desktop builds.' + }); + + return; + } + + if (desktopUpdateState.autoUpdateMode === 'off') { + clearPeriodicRefreshTimer(); + + setDesktopUpdateState({ + status: 'disabled', + statusMessage: 'Automatic updates are turned off.' + }); + + return; + } + + if (!selectedRelease) { + clearPeriodicRefreshTimer(); + + setDesktopUpdateState({ + status: 'target-unavailable', + statusMessage: getTargetUnavailableMessage( + desktopUpdateState.autoUpdateMode, + desktopUpdateState.preferredVersion + ) + }); + + return; + } + + if (compareSemanticVersions(selectedRelease.version, app.getVersion()) < 0) { + clearPeriodicRefreshTimer(); + + setDesktopUpdateState({ + status: 'target-older-than-installed', + statusMessage: `MetoYou ${app.getVersion()} is newer than ${selectedRelease.version}. Downgrades are not applied automatically.`, + targetVersion: selectedRelease.version + }); + + return; + } + + schedulePeriodicRefresh(manifest.pollIntervalMinutes); + await performUpdateCheck(selectedRelease, generation); + } catch (error) { + if (generation !== refreshGeneration) { + return; + } + + clearPeriodicRefreshTimer(); + + setDesktopUpdateState({ + availableVersions: [], + latestVersion: null, + manifestUrl: null, + minimumServerVersion: null, + status: 'error', + statusMessage: getFriendlyErrorMessage(error), + targetVersion: null + }); + } +} + +export function getDesktopUpdateState(): DesktopUpdateState { + return desktopUpdateState; +} + +export function initializeDesktopUpdater(): void { + if (updaterInitialized) { + return; + } + + updaterInitialized = true; + + autoUpdater.on('checking-for-update', () => { + if (desktopUpdateState.restartRequired) { + return; + } + + setDesktopUpdateState({ + status: 'checking', + statusMessage: 'Checking for desktop updates…' + }); + }); + + autoUpdater.on('update-available', (updateInfo: UpdateVersionInfo) => { + const nextVersion = normalizeSemanticVersion(updateInfo.version) + ?? desktopUpdateState.targetVersion; + + setDesktopUpdateState({ + lastCheckedAt: Date.now(), + status: 'downloading', + statusMessage: `Downloading MetoYou ${nextVersion ?? 'update'}…`, + targetVersion: nextVersion + }); + }); + + autoUpdater.on('update-not-available', () => { + if (desktopUpdateState.restartRequired) { + return; + } + + const isPinnedVersion = desktopUpdateState.autoUpdateMode === 'version' + && !!desktopUpdateState.targetVersion; + + setDesktopUpdateState({ + lastCheckedAt: Date.now(), + status: 'up-to-date', + statusMessage: isPinnedVersion + ? `MetoYou ${desktopUpdateState.targetVersion} is already installed.` + : 'MetoYou is up to date.' + }); + }); + + autoUpdater.on('update-downloaded', (updateInfo: UpdateVersionInfo) => { + clearPeriodicRefreshTimer(); + + const nextVersion = normalizeSemanticVersion(updateInfo.version) + ?? desktopUpdateState.targetVersion; + + setDesktopUpdateState({ + lastCheckedAt: Date.now(), + restartRequired: true, + status: 'restart-required', + statusMessage: `MetoYou ${nextVersion ?? 'update'} is ready. Restart the app to finish installing it.`, + targetVersion: nextVersion + }); + }); + + autoUpdater.on('error', (error: unknown) => { + if (desktopUpdateState.restartRequired) { + return; + } + + setDesktopUpdateState({ + lastCheckedAt: Date.now(), + status: 'error', + statusMessage: getFriendlyErrorMessage(error) + }); + }); + + refreshSettingsSnapshot(); +} + +export async function configureDesktopUpdaterContext( + context: Partial +): Promise { + initializeDesktopUpdater(); + + const manifestUrls = sanitizeHttpUrls(context.manifestUrls); + + currentContext = { + manifestUrls, + serverVersion: normalizeSemanticVersion(context.serverVersion), + serverVersionStatus: context.serverVersionStatus ?? 'unknown' + }; + + await refreshDesktopUpdater('context-changed'); + return desktopUpdateState; +} + +export async function handleDesktopSettingsChanged(): Promise { + initializeDesktopUpdater(); + await refreshDesktopUpdater('settings-changed'); +} + +export async function checkForDesktopUpdates(): Promise { + initializeDesktopUpdater(); + await refreshDesktopUpdater('manual'); + return desktopUpdateState; +} + +export function restartToApplyUpdate(): boolean { + if (!desktopUpdateState.restartRequired) { + return false; + } + + autoUpdater.quitAndInstall(false, true); + return true; +} + +export function shutdownDesktopUpdater(): void { + clearPeriodicRefreshTimer(); +} diff --git a/electron/update/version.ts b/electron/update/version.ts new file mode 100644 index 0000000..687e0fd --- /dev/null +++ b/electron/update/version.ts @@ -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)); +} diff --git a/package.json b/package.json index f42ae2e..87a4ad0 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "lint:fix": "npm run format && npm run sort:props && eslint . --fix", "format": "prettier --write \"src/app/**/*.html\"", "format:check": "prettier --check \"src/app/**/*.html\"", + "release:manifest": "node tools/generate-release-manifest.js", "sort:props": "node tools/sort-template-properties.js" }, "private": true, @@ -65,6 +66,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cytoscape": "^3.33.1", + "electron-updater": "^6.6.2", "mermaid": "^11.12.3", "ngx-remark": "^0.2.2", "prismjs": "^1.30.0", @@ -134,9 +136,18 @@ "nodeGypRebuild": false, "buildDependenciesFromSource": false, "npmRebuild": false, + "publish": [ + { + "provider": "generic", + "url": "https://updates.metoyou.invalid" + } + ], "mac": { "category": "public.app-category.social-networking", - "target": "dmg", + "target": [ + "dmg", + "zip" + ], "icon": "images/macos/icon.icns" }, "win": { diff --git a/server/src/config/variables.ts b/server/src/config/variables.ts index d90eb54..fadf1cf 100644 --- a/server/src/config/variables.ts +++ b/server/src/config/variables.ts @@ -3,6 +3,7 @@ import path from 'path'; export interface ServerVariablesConfig { klipyApiKey: string; + releaseManifestUrl: string; } const DATA_DIR = path.join(process.cwd(), 'data'); @@ -12,6 +13,10 @@ function normalizeKlipyApiKey(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } +function normalizeReleaseManifestUrl(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + function readRawVariables(): { rawContents: string; parsed: Record } { if (!fs.existsSync(VARIABLES_FILE)) { return { rawContents: '', parsed: {} }; @@ -48,7 +53,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig { const { rawContents, parsed } = readRawVariables(); const normalized = { ...parsed, - klipyApiKey: normalizeKlipyApiKey(parsed.klipyApiKey) + klipyApiKey: normalizeKlipyApiKey(parsed.klipyApiKey), + releaseManifestUrl: normalizeReleaseManifestUrl(parsed.releaseManifestUrl) }; const nextContents = JSON.stringify(normalized, null, 2) + '\n'; @@ -56,7 +62,10 @@ export function ensureVariablesConfig(): ServerVariablesConfig { fs.writeFileSync(VARIABLES_FILE, nextContents, 'utf8'); } - return { klipyApiKey: normalized.klipyApiKey }; + return { + klipyApiKey: normalized.klipyApiKey, + releaseManifestUrl: normalized.releaseManifestUrl + }; } export function getVariablesConfig(): ServerVariablesConfig { @@ -70,3 +79,7 @@ export function getKlipyApiKey(): string { export function hasKlipyApiKey(): boolean { return getKlipyApiKey().length > 0; } + +export function getReleaseManifestUrl(): string { + return getVariablesConfig().releaseManifestUrl; +} diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 6dcf7ac..1eba3a5 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -1,9 +1,26 @@ import { Router } from 'express'; +import fs from 'fs'; +import path from 'path'; import { getAllPublicServers } from '../cqrs'; +import { getReleaseManifestUrl } from '../config/variables'; import { connectedUsers } from '../websocket/state'; const router = Router(); +function getServerProjectVersion(): string { + try { + const packageJsonPath = path.join(process.cwd(), 'package.json'); + const rawContents = fs.readFileSync(packageJsonPath, 'utf8'); + const parsed = JSON.parse(rawContents) as { version?: unknown }; + + return typeof parsed.version === 'string' && parsed.version.trim().length > 0 + ? parsed.version.trim() + : '0.0.0'; + } catch { + return '0.0.0'; + } +} + router.get('/health', async (_req, res) => { const servers = await getAllPublicServers(); @@ -11,7 +28,9 @@ router.get('/health', async (_req, res) => { status: 'ok', timestamp: Date.now(), serverCount: servers.length, - connectedUsers: connectedUsers.size + connectedUsers: connectedUsers.size, + serverVersion: getServerProjectVersion(), + releaseManifestUrl: getReleaseManifestUrl() }); }); diff --git a/src/app/app.html b/src/app/app.html index 8a5b372..05b210e 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -6,6 +6,40 @@
+ + @if (desktopUpdateState().restartRequired) { +
+
+
+
+

Update ready to install

+

+ MetoYou {{ desktopUpdateState().targetVersion || 'update' }} has been downloaded. Restart the app to finish applying it. +

+
+ +
+ + + +
+
+
+
+ } +
@@ -16,6 +50,47 @@
+@if (desktopUpdateState().serverBlocked) { +
+
+

Server update required

+

+ {{ desktopUpdateState().serverBlockMessage || 'The connected server must be updated before this desktop app can continue.' }} +

+ +
+
+

Connected server

+

{{ desktopUpdateState().serverVersion || 'Not reported' }}

+
+ +
+

Required minimum

+

{{ desktopUpdateState().minimumServerVersion || 'Unknown' }}

+
+
+ +
+ + + +
+
+
+} + diff --git a/src/app/app.ts b/src/app/app.ts index 585e195..d43a1ff 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -14,10 +14,12 @@ import { CommonModule } from '@angular/common'; import { Store } from '@ngrx/store'; import { DatabaseService } from './core/services/database.service'; +import { DesktopAppUpdateService } from './core/services/desktop-app-update.service'; import { ServerDirectoryService } from './core/services/server-directory.service'; import { TimeSyncService } from './core/services/time-sync.service'; import { VoiceSessionService } from './core/services/voice-session.service'; import { ExternalLinkService } from './core/services/external-link.service'; +import { SettingsModalService } from './core/services/settings-modal.service'; import { ServersRailComponent } from './features/servers/servers-rail.component'; import { TitleBarComponent } from './features/shell/title-bar.component'; import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component'; @@ -49,10 +51,13 @@ import { export class App implements OnInit { store = inject(Store); currentRoom = this.store.selectSignal(selectCurrentRoom); + desktopUpdates = inject(DesktopAppUpdateService); + desktopUpdateState = this.desktopUpdates.state; private databaseService = inject(DatabaseService); private router = inject(Router); private servers = inject(ServerDirectoryService); + private settingsModal = inject(SettingsModalService); private timeSync = inject(TimeSyncService); private voiceSession = inject(VoiceSessionService); private externalLinks = inject(ExternalLinkService); @@ -63,6 +68,8 @@ export class App implements OnInit { } async ngOnInit(): Promise { + void this.desktopUpdates.initialize(); + await this.databaseService.initialize(); try { @@ -106,4 +113,20 @@ export class App implements OnInit { } }); } + + openNetworkSettings(): void { + this.settingsModal.open('network'); + } + + openUpdatesSettings(): void { + this.settingsModal.open('updates'); + } + + async refreshDesktopUpdateContext(): Promise { + await this.desktopUpdates.refreshServerContext(); + } + + async restartToApplyUpdate(): Promise { + await this.desktopUpdates.restartToApplyUpdate(); + } } diff --git a/src/app/core/services/desktop-app-update.service.ts b/src/app/core/services/desktop-app-update.service.ts new file mode 100644 index 0000000..5648a2b --- /dev/null +++ b/src/app/core/services/desktop-app-update.service.ts @@ -0,0 +1,401 @@ +import { + Injectable, + Injector, + effect, + inject, + signal +} from '@angular/core'; +import { PlatformService } from './platform.service'; +import { ServerDirectoryService, type ServerEndpoint } from './server-directory.service'; + +type AutoUpdateMode = 'auto' | 'off' | 'version'; +type DesktopUpdateStatus = + | 'idle' + | 'disabled' + | 'checking' + | 'downloading' + | 'up-to-date' + | 'restart-required' + | 'unsupported' + | 'no-manifest' + | 'target-unavailable' + | 'target-older-than-installed' + | 'error'; +type DesktopUpdateServerVersionStatus = 'unknown' | 'reported' | 'missing' | 'unavailable'; + +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; +} + +interface DesktopUpdateServerContext { + manifestUrls: string[]; + serverVersion: string | null; + serverVersionStatus: DesktopUpdateServerVersionStatus; +} + +interface DesktopUpdateElectronApi { + checkForAppUpdates?: () => Promise; + configureAutoUpdateContext?: (context: Partial) => Promise; + getAutoUpdateState?: () => Promise; + onAutoUpdateStateChanged?: (listener: (state: DesktopUpdateState) => void) => () => void; + restartToApplyUpdate?: () => Promise; + setDesktopSettings?: (patch: { + autoUpdateMode?: AutoUpdateMode; + manifestUrls?: string[]; + preferredVersion?: string | null; + }) => Promise; +} + +interface ServerHealthResponse { + releaseManifestUrl?: string; + serverVersion?: string; +} + +interface ServerHealthSnapshot { + endpointId: string; + manifestUrl: string | null; + serverVersion: string | null; + serverVersionStatus: DesktopUpdateServerVersionStatus; +} + +type DesktopUpdateWindow = Window & { + electronAPI?: DesktopUpdateElectronApi; +}; + +const SERVER_CONTEXT_REFRESH_INTERVAL_MS = 5 * 60_000; +const SERVER_CONTEXT_TIMEOUT_MS = 5_000; + +function createInitialState(): DesktopUpdateState { + return { + autoUpdateMode: 'auto', + availableVersions: [], + configuredManifestUrls: [], + currentVersion: '0.0.0', + defaultManifestUrls: [], + isSupported: false, + lastCheckedAt: null, + latestVersion: null, + manifestUrl: null, + manifestUrls: [], + minimumServerVersion: null, + preferredVersion: null, + restartRequired: false, + serverBlocked: false, + serverBlockMessage: null, + serverVersion: null, + serverVersionStatus: 'unknown', + status: 'idle', + statusMessage: null, + targetVersion: null + }; +} + +function normalizeOptionalString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function normalizeOptionalHttpUrl(value: unknown): string | null { + const nextValue = normalizeOptionalString(value); + + if (!nextValue) { + return null; + } + + try { + const parsedUrl = new URL(nextValue); + + return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:' + ? parsedUrl.toString().replace(/\/+$/, '') + : null; + } catch { + return null; + } +} + +function normalizeUrlList(values: readonly unknown[]): string[] { + const manifestUrls: string[] = []; + + for (const entry of values) { + const manifestUrl = normalizeOptionalHttpUrl(entry); + + if (!manifestUrl || manifestUrls.includes(manifestUrl)) { + continue; + } + + manifestUrls.push(manifestUrl); + } + + return manifestUrls; +} + +@Injectable({ providedIn: 'root' }) +export class DesktopAppUpdateService { + readonly isElectron = inject(PlatformService).isElectron; + readonly state = signal(createInitialState()); + + private injector = inject(Injector); + private servers = inject(ServerDirectoryService); + private initialized = false; + private refreshTimerId: number | null = null; + private removeStateListener: (() => void) | null = null; + + async initialize(): Promise { + if (!this.isElectron || this.initialized) { + return; + } + + this.initialized = true; + + this.setupServerWatcher(); + this.startRefreshTimer(); + + const api = this.getElectronApi(); + + if (!api) { + return; + } + + try { + const currentState = await api.getAutoUpdateState?.(); + + if (currentState) { + this.state.set(currentState); + } + } catch {} + + if (api.onAutoUpdateStateChanged) { + this.removeStateListener?.(); + this.removeStateListener = api.onAutoUpdateStateChanged((nextState) => { + this.state.set(nextState); + }); + } + + await this.refreshServerContext(); + } + + async refreshServerContext(): Promise { + if (!this.isElectron) { + return; + } + + await this.syncServerHealth(); + } + + async checkForUpdates(): Promise { + const api = this.getElectronApi(); + + if (!api?.checkForAppUpdates) { + return; + } + + try { + const nextState = await api.checkForAppUpdates(); + + this.state.set(nextState); + } catch {} + } + + async saveUpdatePreferences(mode: AutoUpdateMode, preferredVersion: string | null): Promise { + const api = this.getElectronApi(); + + if (!api?.setDesktopSettings) { + return; + } + + try { + await api.setDesktopSettings({ + autoUpdateMode: mode, + preferredVersion: normalizeOptionalString(preferredVersion) + }); + + if (api.getAutoUpdateState) { + const nextState = await api.getAutoUpdateState(); + + this.state.set(nextState); + } + } catch {} + } + + async saveManifestUrls(manifestUrls: string[]): Promise { + const api = this.getElectronApi(); + + if (!api?.setDesktopSettings) { + return; + } + + try { + await api.setDesktopSettings({ + manifestUrls: normalizeUrlList(manifestUrls) + }); + + if (api.getAutoUpdateState) { + const nextState = await api.getAutoUpdateState(); + + this.state.set(nextState); + } + } catch {} + } + + async restartToApplyUpdate(): Promise { + const api = this.getElectronApi(); + + if (!api?.restartToApplyUpdate) { + return; + } + + try { + await api.restartToApplyUpdate(); + } catch {} + } + + private setupServerWatcher(): void { + effect(() => { + this.servers.servers(); + + if (!this.initialized) { + return; + } + + void this.syncServerHealth(); + }, { injector: this.injector }); + } + + private startRefreshTimer(): void { + if (this.refreshTimerId !== null || typeof window === 'undefined') { + return; + } + + this.refreshTimerId = window.setInterval(() => { + void this.refreshServerContext(); + }, SERVER_CONTEXT_REFRESH_INTERVAL_MS); + } + + private async syncServerHealth(): Promise { + const api = this.getElectronApi(); + + if (!api?.configureAutoUpdateContext) { + return; + } + + const endpoints = this.getPrioritizedServers(); + + if (endpoints.length === 0) { + await this.pushContext({ + manifestUrls: [], + serverVersion: null, + serverVersionStatus: 'unknown' + }); + + return; + } + + const healthSnapshots = await Promise.all( + endpoints.map((endpoint) => this.readServerHealth(endpoint)) + ); + const activeEndpoint = this.servers.activeServer() ?? endpoints[0] ?? null; + const activeSnapshot = activeEndpoint + ? healthSnapshots.find((snapshot) => snapshot.endpointId === activeEndpoint.id) ?? null + : null; + + await this.pushContext({ + manifestUrls: normalizeUrlList( + healthSnapshots.map((snapshot) => snapshot.manifestUrl) + ), + serverVersion: activeSnapshot?.serverVersion ?? null, + serverVersionStatus: activeSnapshot?.serverVersionStatus ?? 'unknown' + }); + } + + private getPrioritizedServers(): ServerEndpoint[] { + const endpoints = [...this.servers.servers()]; + const activeServerId = this.servers.activeServer()?.id ?? null; + + return endpoints.sort((left, right) => { + if (left.id === activeServerId) { + return -1; + } + + if (right.id === activeServerId) { + return 1; + } + + return 0; + }); + } + + private async readServerHealth(endpoint: ServerEndpoint): Promise { + const sanitizedServerUrl = endpoint.url.replace(/\/+$/, ''); + + try { + const response = await fetch(`${sanitizedServerUrl}/api/health`, { + method: 'GET', + signal: AbortSignal.timeout(SERVER_CONTEXT_TIMEOUT_MS) + }); + + if (!response.ok) { + return { + endpointId: endpoint.id, + manifestUrl: null, + serverVersion: null, + serverVersionStatus: 'unavailable' + }; + } + + const payload = await response.json() as ServerHealthResponse; + const serverVersion = normalizeOptionalString(payload.serverVersion); + + return { + endpointId: endpoint.id, + manifestUrl: normalizeOptionalHttpUrl(payload.releaseManifestUrl), + serverVersion, + serverVersionStatus: serverVersion ? 'reported' : 'missing' + }; + } catch { + return { + endpointId: endpoint.id, + manifestUrl: null, + serverVersion: null, + serverVersionStatus: 'unavailable' + }; + } + } + + private async pushContext(context: Partial): Promise { + const api = this.getElectronApi(); + + if (!api?.configureAutoUpdateContext) { + return; + } + + try { + const nextState = await api.configureAutoUpdateContext(context); + + this.state.set(nextState); + } catch {} + } + + private getElectronApi(): DesktopUpdateElectronApi | null { + return typeof window !== 'undefined' + ? (window as DesktopUpdateWindow).electronAPI ?? null + : null; + } +} diff --git a/src/app/core/services/settings-modal.service.ts b/src/app/core/services/settings-modal.service.ts index d33b228..82ed72c 100644 --- a/src/app/core/services/settings-modal.service.ts +++ b/src/app/core/services/settings-modal.service.ts @@ -1,5 +1,5 @@ import { Injectable, signal } from '@angular/core'; -export type SettingsPage = 'network' | 'voice' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions'; +export type SettingsPage = 'network' | 'voice' | 'updates' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions'; @Injectable({ providedIn: 'root' }) export class SettingsModalService { diff --git a/src/app/features/settings/settings-modal/settings-modal.component.html b/src/app/features/settings/settings-modal/settings-modal.component.html index c905e33..6e9bb2c 100644 --- a/src/app/features/settings/settings-modal/settings-modal.component.html +++ b/src/app/features/settings/settings-modal/settings-modal.component.html @@ -122,6 +122,9 @@ @case ('voice') { Voice & Audio } + @case ('updates') { + Updates + } @case ('debugging') { Debugging } @@ -160,6 +163,9 @@ @case ('voice') { } + @case ('updates') { + + } @case ('debugging') { } diff --git a/src/app/features/settings/settings-modal/settings-modal.component.ts b/src/app/features/settings/settings-modal/settings-modal.component.ts index 7925d00..ed25b61 100644 --- a/src/app/features/settings/settings-modal/settings-modal.component.ts +++ b/src/app/features/settings/settings-modal/settings-modal.component.ts @@ -15,6 +15,7 @@ import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideX, lucideBug, + lucideDownload, lucideGlobe, lucideAudioLines, lucideSettings, @@ -37,6 +38,7 @@ import { MembersSettingsComponent } from './members-settings/members-settings.co import { BansSettingsComponent } from './bans-settings/bans-settings.component'; import { PermissionsSettingsComponent } from './permissions-settings/permissions-settings.component'; import { DebuggingSettingsComponent } from './debugging-settings/debugging-settings.component'; +import { UpdatesSettingsComponent } from './updates-settings/updates-settings.component'; import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses'; @Component({ @@ -48,6 +50,7 @@ import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-lice NgIcon, NetworkSettingsComponent, VoiceSettingsComponent, + UpdatesSettingsComponent, DebuggingSettingsComponent, ServerSettingsComponent, MembersSettingsComponent, @@ -58,6 +61,7 @@ import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-lice provideIcons({ lucideX, lucideBug, + lucideDownload, lucideGlobe, lucideAudioLines, lucideSettings, @@ -91,6 +95,9 @@ export class SettingsModalComponent { { id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' }, + { id: 'updates', + label: 'Updates', + icon: 'lucideDownload' }, { id: 'debugging', label: 'Debugging', icon: 'lucideBug' } diff --git a/src/app/features/settings/settings-modal/updates-settings/updates-settings.component.html b/src/app/features/settings/settings-modal/updates-settings/updates-settings.component.html new file mode 100644 index 0000000..b5b9c4c --- /dev/null +++ b/src/app/features/settings/settings-modal/updates-settings/updates-settings.component.html @@ -0,0 +1,185 @@ +
+
+
+
+

Desktop app updates

+

+ Use a hosted release manifest to check for new packaged desktop builds and apply them after a restart. +

+
+ + + {{ statusLabel() }} + +
+
+ + @if (!isElectron) { +
+

Automatic updates are only available in the packaged Electron desktop app.

+
+ } @else { +
+
+

Installed

+

{{ state().currentVersion }}

+
+ +
+

Latest in manifest

+

{{ state().latestVersion || 'Unknown' }}

+
+ +
+

Target version

+

{{ state().targetVersion || 'Automatic' }}

+
+ +
+

Last checked

+

+ {{ state().lastCheckedAt ? (state().lastCheckedAt | date: 'medium') : 'Not checked yet' }} +

+
+
+ +
+
+
Update policy
+

+ Choose whether the app tracks the newest release, stays on a specific release, or turns updates off entirely. +

+
+ +
+ + + +
+ +
+

Status

+

+ {{ state().statusMessage || 'Waiting for release information from the active server.' }} +

+
+ +
+ + + @if (state().restartRequired) { + + } +
+
+ +
+
+
Manifest URL priority
+

+ Add one manifest URL per line. The app tries them from top to bottom and falls back to the next URL when a manifest cannot be loaded or is + invalid. +

+
+ +
+

+ {{ isUsingConnectedServerDefaults() ? 'Using connected server defaults' : 'Using saved manifest URLs' }} +

+

When this list is empty, the app automatically uses manifest URLs reported by your configured servers.

+
+ + + + @if (!state().defaultManifestUrls.length && isUsingConnectedServerDefaults()) { +

None of your configured servers currently report a manifest URL.

+ } + +
+ + + +
+
+ + @if (state().serverBlocked) { +
+
Server update required
+

{{ state().serverBlockMessage }}

+
+
+

Connected server

+

{{ state().serverVersion || 'Not reported' }}

+
+ +
+

Required minimum

+

{{ state().minimumServerVersion || 'Unknown' }}

+
+
+
+ } + +
+

Resolved manifest URL

+

{{ state().manifestUrl || 'No working manifest URL has been resolved yet.' }}

+
+ } +
diff --git a/src/app/features/settings/settings-modal/updates-settings/updates-settings.component.ts b/src/app/features/settings/settings-modal/updates-settings/updates-settings.component.ts new file mode 100644 index 0000000..42a3253 --- /dev/null +++ b/src/app/features/settings/settings-modal/updates-settings/updates-settings.component.ts @@ -0,0 +1,142 @@ +import { + Component, + computed, + effect, + inject, + signal +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { DesktopAppUpdateService } from '../../../../core/services/desktop-app-update.service'; + +type AutoUpdateMode = 'auto' | 'off' | 'version'; +type DesktopUpdateStatus = + | 'idle' + | 'disabled' + | 'checking' + | 'downloading' + | 'up-to-date' + | 'restart-required' + | 'unsupported' + | 'no-manifest' + | 'target-unavailable' + | 'target-older-than-installed' + | 'error'; + +@Component({ + selector: 'app-updates-settings', + standalone: true, + imports: [CommonModule], + templateUrl: './updates-settings.component.html' +}) +export class UpdatesSettingsComponent { + readonly updates = inject(DesktopAppUpdateService); + readonly isElectron = this.updates.isElectron; + readonly state = this.updates.state; + readonly hasPendingManifestUrlChanges = signal(false); + readonly manifestUrlsText = signal(''); + readonly statusLabel = computed(() => this.getStatusLabel(this.state().status)); + readonly isUsingConnectedServerDefaults = computed(() => { + return this.state().configuredManifestUrls.length === 0; + }); + + constructor() { + effect(() => { + if (this.hasPendingManifestUrlChanges()) { + return; + } + + this.manifestUrlsText.set(this.stringifyManifestUrls( + this.isUsingConnectedServerDefaults() + ? this.state().defaultManifestUrls + : this.state().configuredManifestUrls + )); + }); + } + + async onModeChange(event: Event): Promise { + const select = event.target as HTMLSelectElement; + const mode = select.value as AutoUpdateMode; + const preferredVersion = mode === 'version' + ? this.state().preferredVersion ?? this.state().availableVersions[0] ?? null + : this.state().preferredVersion; + + await this.updates.saveUpdatePreferences(mode, preferredVersion); + } + + async onVersionChange(event: Event): Promise { + const select = event.target as HTMLSelectElement; + + await this.updates.saveUpdatePreferences('version', select.value || null); + } + + async refreshReleaseInfo(): Promise { + await this.updates.refreshServerContext(); + await this.updates.checkForUpdates(); + } + + onManifestUrlsInput(event: Event): void { + const textarea = event.target as HTMLTextAreaElement; + + this.hasPendingManifestUrlChanges.set(true); + this.manifestUrlsText.set(textarea.value); + } + + async saveManifestUrls(): Promise { + await this.updates.saveManifestUrls( + this.parseManifestUrls(this.manifestUrlsText()) + ); + + this.hasPendingManifestUrlChanges.set(false); + } + + async useConnectedServerDefaults(): Promise { + await this.updates.saveManifestUrls([]); + this.hasPendingManifestUrlChanges.set(false); + } + + async restartNow(): Promise { + await this.updates.restartToApplyUpdate(); + } + + private parseManifestUrls(rawValue: string): string[] { + return [ + ...new Set( + rawValue + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + ) + ]; + } + + private stringifyManifestUrls(manifestUrls: string[]): string { + return manifestUrls.join('\n'); + } + + private getStatusLabel(status: DesktopUpdateStatus): string { + switch (status) { + case 'checking': + return 'Checking'; + case 'downloading': + return 'Downloading'; + case 'restart-required': + return 'Restart required'; + case 'up-to-date': + return 'Up to date'; + case 'disabled': + return 'Disabled'; + case 'unsupported': + return 'Unsupported'; + case 'no-manifest': + return 'Manifest missing'; + case 'target-unavailable': + return 'Version unavailable'; + case 'target-older-than-installed': + return 'Pinned below current'; + case 'error': + return 'Error'; + default: + return 'Idle'; + } + } +} diff --git a/src/app/store/rooms/room-members-sync.effects.ts b/src/app/store/rooms/room-members-sync.effects.ts index fc3ff04..cdfb0a1 100644 --- a/src/app/store/rooms/room-members-sync.effects.ts +++ b/src/app/store/rooms/room-members-sync.effects.ts @@ -170,7 +170,6 @@ export class RoomMembersSyncEffects { oderId: signalingMessage.oderId, displayName: signalingMessage.displayName }; - const members = upsertRoomMember( room.members ?? [], this.buildPresenceMember(room, joinedUser) diff --git a/tools/generate-release-manifest.js b/tools/generate-release-manifest.js new file mode 100644 index 0000000..47946f8 --- /dev/null +++ b/tools/generate-release-manifest.js @@ -0,0 +1,256 @@ +const fs = require('fs'); +const path = require('path'); + +const rootDir = path.resolve(__dirname, '..'); +const defaultManifestPath = path.join(rootDir, 'dist-electron', 'release-manifest.json'); +const descriptorCandidates = ['latest.yml', 'latest-mac.yml', 'latest-linux.yml']; + +function normalizeVersion(rawValue) { + if (typeof rawValue !== 'string') { + return null; + } + + const trimmedValue = rawValue.trim(); + + if (!trimmedValue) { + return null; + } + + return trimmedValue.replace(/^v/i, '').split('+')[0] || null; +} + +function parseVersion(rawValue) { + const normalized = normalizeVersion(rawValue); + + if (!normalized) { + return null; + } + + const match = normalized.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?$/); + + if (!match) { + return null; + } + + return { + major: Number.parseInt(match[1], 10), + minor: Number.parseInt(match[2] || '0', 10), + patch: Number.parseInt(match[3] || '0', 10), + prerelease: match[4] ? match[4].split('.') : [] + }; +} + +function comparePrereleaseIdentifiers(left, right) { + const leftNumeric = /^\d+$/.test(left) ? Number.parseInt(left, 10) : null; + const rightNumeric = /^\d+$/.test(right) ? Number.parseInt(right, 10) : null; + + if (leftNumeric !== null && rightNumeric !== null) { + return leftNumeric - rightNumeric; + } + + if (leftNumeric !== null) { + return -1; + } + + if (rightNumeric !== null) { + return 1; + } + + return left.localeCompare(right); +} + +function compareVersions(leftValue, rightValue) { + const left = parseVersion(leftValue); + const right = parseVersion(rightValue); + + 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; + } + + if (left.prerelease.length === 0 && right.prerelease.length === 0) { + return 0; + } + + if (left.prerelease.length === 0) { + return 1; + } + + if (right.prerelease.length === 0) { + return -1; + } + + const maxLength = Math.max(left.prerelease.length, right.prerelease.length); + + for (let index = 0; index < maxLength; index += 1) { + const leftIdentifier = left.prerelease[index]; + const rightIdentifier = right.prerelease[index]; + + if (!leftIdentifier) { + return -1; + } + + if (!rightIdentifier) { + return 1; + } + + const comparison = comparePrereleaseIdentifiers(leftIdentifier, rightIdentifier); + + if (comparison !== 0) { + return comparison; + } + } + + return 0; +} + +function parseArgs(argv) { + const args = {}; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + + if (!token.startsWith('--')) { + continue; + } + + const key = token.slice(2); + const nextToken = argv[index + 1]; + + if (!nextToken || nextToken.startsWith('--')) { + args[key] = true; + continue; + } + + args[key] = nextToken; + index += 1; + } + + return args; +} + +function ensureHttpUrl(rawValue) { + if (typeof rawValue !== 'string' || rawValue.trim().length === 0) { + throw new Error('A release feed URL is required. Pass it with --feed-url.'); + } + + const parsedUrl = new URL(rawValue.trim()); + + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + throw new Error('The release feed URL must use http:// or https://.'); + } + + return parsedUrl.toString().replace(/\/$/, ''); +} + +function readJsonIfExists(filePath) { + if (!fs.existsSync(filePath)) { + return null; + } + + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function warnIfNoDescriptors(distPath) { + const availableDescriptors = descriptorCandidates.filter((fileName) => { + return fs.existsSync(path.join(distPath, fileName)); + }); + + if (availableDescriptors.length === 0) { + console.warn('[release-manifest] Warning: no latest*.yml descriptor was found in dist-electron/.'); + console.warn('[release-manifest] Upload a feed directory that contains the Electron Builder update descriptors.'); + } +} + +function buildDefaultManifest(version) { + return { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + minimumServerVersion: version, + pollIntervalMinutes: 30, + versions: [] + }; +} + +function sortManifestVersions(versions) { + return [...versions].sort((left, right) => compareVersions(right.version, left.version)); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8')); + const version = normalizeVersion(args.version || packageJson.version); + + if (!version) { + throw new Error('Unable to determine the release version. Pass it with --version.'); + } + + const feedUrl = ensureHttpUrl(args['feed-url']); + const manifestPath = path.resolve(rootDir, args.manifest || defaultManifestPath); + const existingPath = path.resolve(rootDir, args.existing || manifestPath); + const existingManifest = readJsonIfExists(existingPath) || buildDefaultManifest(version); + const distPath = path.join(rootDir, 'dist-electron'); + + if (fs.existsSync(distPath)) { + warnIfNoDescriptors(distPath); + } + + const nextEntry = { + version, + feedUrl, + publishedAt: typeof args['published-at'] === 'string' + ? args['published-at'] + : new Date().toISOString(), + ...(typeof args.notes === 'string' && args.notes.trim().length > 0 + ? { notes: args.notes.trim() } + : {}) + }; + const nextManifest = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + minimumServerVersion: normalizeVersion(args['minimum-server-version']) + || normalizeVersion(existingManifest.minimumServerVersion) + || version, + pollIntervalMinutes: Number.isFinite(Number(args['poll-interval-minutes'])) + ? Math.max(5, Math.round(Number(args['poll-interval-minutes']))) + : Math.max(5, Math.round(Number(existingManifest.pollIntervalMinutes || 30))), + versions: sortManifestVersions( + [...(Array.isArray(existingManifest.versions) ? existingManifest.versions : [])] + .filter((entry) => normalizeVersion(entry && entry.version) !== version) + .concat(nextEntry) + ) + }; + + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); + fs.writeFileSync(manifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8'); + + console.log(`[release-manifest] Wrote ${manifestPath}`); +} + +try { + main(); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + + console.error(`[release-manifest] ${message}`); + process.exitCode = 1; +}