Add auto updater

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,738 @@
import { app, net } from 'electron';
import { autoUpdater } from 'electron-updater';
import { readDesktopSettings, type AutoUpdateMode } from '../desktop-settings';
import { getMainWindow } from '../window/create-window';
import {
compareSemanticVersions,
normalizeSemanticVersion,
sortSemanticVersionsDescending
} from './version';
const DEFAULT_POLL_INTERVAL_MINUTES = 30;
const MINIMUM_POLL_INTERVAL_MINUTES = 5;
interface ReleaseManifestEntry {
feedUrl: string;
notes?: string;
publishedAt?: string;
version: string;
}
interface UpdateVersionInfo {
version: string;
}
interface ReleaseManifest {
minimumServerVersion: string | null;
pollIntervalMinutes: number;
versions: ReleaseManifestEntry[];
}
export type DesktopUpdateStatus =
| 'idle'
| 'disabled'
| 'checking'
| 'downloading'
| 'up-to-date'
| 'restart-required'
| 'unsupported'
| 'no-manifest'
| 'target-unavailable'
| 'target-older-than-installed'
| 'error';
export type DesktopUpdateServerVersionStatus =
| 'unknown'
| 'reported'
| 'missing'
| 'unavailable';
export interface DesktopUpdateServerContext {
manifestUrls: string[];
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateState {
autoUpdateMode: AutoUpdateMode;
availableVersions: string[];
configuredManifestUrls: string[];
currentVersion: string;
defaultManifestUrls: string[];
isSupported: boolean;
lastCheckedAt: number | null;
latestVersion: string | null;
manifestUrl: string | null;
manifestUrls: string[];
minimumServerVersion: string | null;
preferredVersion: string | null;
restartRequired: boolean;
serverBlocked: boolean;
serverBlockMessage: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
status: DesktopUpdateStatus;
statusMessage: string | null;
targetVersion: string | null;
}
export const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
let currentCheckPromise: Promise<void> | null = null;
let currentContext: DesktopUpdateServerContext = {
manifestUrls: [],
serverVersion: null,
serverVersionStatus: 'unknown'
};
let desktopUpdateState: DesktopUpdateState = createInitialState();
let periodicRefreshTimer: NodeJS.Timeout | null = null;
let refreshGeneration = 0;
let updaterInitialized = false;
function createInitialState(): DesktopUpdateState {
const settings = readDesktopSettings();
return {
autoUpdateMode: settings.autoUpdateMode,
availableVersions: [],
configuredManifestUrls: settings.manifestUrls,
currentVersion: app.getVersion(),
defaultManifestUrls: [],
isSupported: app.isPackaged,
lastCheckedAt: null,
latestVersion: null,
manifestUrl: null,
manifestUrls: settings.manifestUrls,
minimumServerVersion: null,
preferredVersion: settings.preferredVersion,
restartRequired: false,
serverBlocked: false,
serverBlockMessage: null,
serverVersion: null,
serverVersionStatus: 'unknown',
status: 'idle',
statusMessage: null,
targetVersion: null
};
}
function clearPeriodicRefreshTimer(): void {
if (!periodicRefreshTimer) {
return;
}
clearInterval(periodicRefreshTimer);
periodicRefreshTimer = null;
}
function emitState(): void {
const mainWindow = getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.webContents.send(AUTO_UPDATE_STATE_CHANGED_CHANNEL, desktopUpdateState);
}
function setDesktopUpdateState(patch: Partial<DesktopUpdateState>): void {
desktopUpdateState = {
...desktopUpdateState,
...patch,
currentVersion: app.getVersion()
};
emitState();
}
function sanitizeHttpUrl(rawValue: unknown): string | null {
if (typeof rawValue !== 'string') {
return null;
}
const trimmedValue = rawValue.trim();
if (!trimmedValue) {
return null;
}
try {
const parsedUrl = new URL(trimmedValue);
return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:'
? parsedUrl.toString().replace(/\/$/, '')
: null;
} catch {
return null;
}
}
function sanitizeHttpUrls(rawValue: unknown): string[] {
if (!Array.isArray(rawValue)) {
return [];
}
const manifestUrls: string[] = [];
for (const entry of rawValue) {
const manifestUrl = sanitizeHttpUrl(entry);
if (!manifestUrl || manifestUrls.includes(manifestUrl)) {
continue;
}
manifestUrls.push(manifestUrl);
}
return manifestUrls;
}
function getEffectiveManifestUrls(configuredManifestUrls: string[]): string[] {
return configuredManifestUrls.length > 0
? [...configuredManifestUrls]
: [...currentContext.manifestUrls];
}
function coercePollIntervalMinutes(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value)
? Math.max(MINIMUM_POLL_INTERVAL_MINUTES, Math.round(value))
: DEFAULT_POLL_INTERVAL_MINUTES;
}
function parseReleaseManifest(payload: unknown): ReleaseManifest {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('Release manifest must be a JSON object.');
}
const rawManifest = payload as Record<string, unknown>;
const rawVersions = Array.isArray(rawManifest.versions) ? rawManifest.versions : [];
const parsedVersions: ReleaseManifestEntry[] = [];
for (const entry of rawVersions) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
continue;
}
const rawEntry = entry as Record<string, unknown>;
const version = normalizeSemanticVersion(rawEntry.version as string | undefined);
const feedUrl = sanitizeHttpUrl(rawEntry.feedUrl);
if (!version || !feedUrl) {
continue;
}
const notes = typeof rawEntry.notes === 'string' && rawEntry.notes.trim().length > 0
? rawEntry.notes.trim()
: undefined;
const publishedAt = typeof rawEntry.publishedAt === 'string'
&& rawEntry.publishedAt.trim().length > 0
? rawEntry.publishedAt.trim()
: undefined;
parsedVersions.push({
feedUrl,
...(notes ? { notes } : {}),
...(publishedAt ? { publishedAt } : {}),
version
});
}
parsedVersions.sort((left, right) => compareSemanticVersions(right.version, left.version));
const deduplicatedVersions: ReleaseManifestEntry[] = [];
const seenVersions = new Set<string>();
for (const entry of parsedVersions) {
if (seenVersions.has(entry.version)) {
continue;
}
seenVersions.add(entry.version);
deduplicatedVersions.push(entry);
}
if (deduplicatedVersions.length === 0) {
throw new Error('Release manifest does not contain any valid versions.');
}
return {
minimumServerVersion: normalizeSemanticVersion(
rawManifest.minimumServerVersion as string | undefined
),
pollIntervalMinutes: coercePollIntervalMinutes(rawManifest.pollIntervalMinutes),
versions: deduplicatedVersions
};
}
function resolveServerCompatibility(minimumServerVersion: string | null): {
blocked: boolean;
message: string | null;
} {
if (currentContext.serverVersionStatus === 'missing') {
return {
blocked: true,
message: 'The connected server is too old. Update the server project to a version that reports its release metadata.'
};
}
if (
currentContext.serverVersionStatus !== 'reported'
|| !minimumServerVersion
|| !currentContext.serverVersion
) {
return { blocked: false,
message: null };
}
if (compareSemanticVersions(currentContext.serverVersion, minimumServerVersion) >= 0) {
return { blocked: false,
message: null };
}
return {
blocked: true,
message: `This desktop app requires server version ${minimumServerVersion} or newer. The connected server is ${currentContext.serverVersion}.`
};
}
function getManifestMissingMessage(autoUpdateMode: AutoUpdateMode): string {
return autoUpdateMode === 'off'
? 'Automatic updates are turned off.'
: 'No release manifest URLs are configured.';
}
function getTargetUnavailableMessage(
autoUpdateMode: AutoUpdateMode,
preferredVersion: string | null
): string {
if (autoUpdateMode === 'version') {
return preferredVersion
? `The selected version ${preferredVersion} is not present in the release manifest.`
: 'Select a version before enabling pinned updates.';
}
return 'No compatible release was found in the release manifest.';
}
function getFriendlyErrorMessage(error: unknown): string {
const rawMessage = error instanceof Error
? error.message
: typeof error === 'string'
? error
: 'Unknown error';
if (rawMessage.includes('APPIMAGE')) {
return 'Automatic updates on Linux require the packaged AppImage or DEB build.';
}
return rawMessage;
}
function getSelectedRelease(manifest: ReleaseManifest): ReleaseManifestEntry | null {
const settings = readDesktopSettings();
if (settings.autoUpdateMode === 'version') {
const preferredVersion = normalizeSemanticVersion(settings.preferredVersion);
if (!preferredVersion) {
return null;
}
return manifest.versions.find((entry) => entry.version === preferredVersion) ?? null;
}
return manifest.versions[0] ?? null;
}
function refreshSettingsSnapshot(): void {
const settings = readDesktopSettings();
const defaultManifestUrls = [...currentContext.manifestUrls];
const manifestUrls = getEffectiveManifestUrls(settings.manifestUrls);
setDesktopUpdateState({
autoUpdateMode: settings.autoUpdateMode,
configuredManifestUrls: settings.manifestUrls,
currentVersion: app.getVersion(),
defaultManifestUrls,
isSupported: app.isPackaged,
manifestUrls,
preferredVersion: settings.preferredVersion
});
}
function schedulePeriodicRefresh(pollIntervalMinutes: number): void {
clearPeriodicRefreshTimer();
if (!app.isPackaged || desktopUpdateState.autoUpdateMode === 'off' || desktopUpdateState.restartRequired) {
return;
}
periodicRefreshTimer = setInterval(() => {
void refreshDesktopUpdater('scheduled');
}, pollIntervalMinutes * 60_000);
}
async function loadReleaseManifest(manifestUrl: string): Promise<ReleaseManifest> {
const response = await net.fetch(manifestUrl, {
headers: {
accept: 'application/json'
}
});
if (!response.ok) {
throw new Error(`Release manifest request failed with status ${response.status}.`);
}
const payload = await response.json();
return parseReleaseManifest(payload);
}
function formatManifestLoadErrors(errors: string[]): string {
if (errors.length === 0) {
return 'No valid release manifest could be loaded.';
}
if (errors.length === 1) {
return errors[0];
}
const remainingCount = errors.length - 1;
return `${errors[0]} (${remainingCount} more manifest URL${remainingCount === 1 ? '' : 's'} failed.)`;
}
async function loadReleaseManifestFromCandidates(manifestUrls: string[]): Promise<{
manifest: ReleaseManifest;
manifestUrl: string;
}> {
const errors: string[] = [];
for (const manifestUrl of manifestUrls) {
try {
const manifest = await loadReleaseManifest(manifestUrl);
return {
manifest,
manifestUrl
};
} catch (error) {
errors.push(`${manifestUrl}: ${getFriendlyErrorMessage(error)}`);
}
}
throw new Error(formatManifestLoadErrors(errors));
}
async function performUpdateCheck(
targetRelease: ReleaseManifestEntry,
generation: number
): Promise<void> {
if (generation !== refreshGeneration || desktopUpdateState.restartRequired) {
return;
}
if (currentCheckPromise) {
await currentCheckPromise;
return;
}
currentCheckPromise = (async () => {
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.allowDowngrade = false;
autoUpdater.setFeedURL({ provider: 'generic', url: targetRelease.feedUrl });
setDesktopUpdateState({
lastCheckedAt: Date.now(),
status: 'checking',
statusMessage: `Checking for MetoYou ${targetRelease.version}`,
targetVersion: targetRelease.version
});
await autoUpdater.checkForUpdates();
})();
try {
await currentCheckPromise;
} finally {
currentCheckPromise = null;
}
}
async function refreshDesktopUpdater(
_reason: 'context-changed' | 'manual' | 'scheduled' | 'settings-changed'
): Promise<void> {
const generation = ++refreshGeneration;
refreshSettingsSnapshot();
const manifestUrls = [...desktopUpdateState.manifestUrls];
const baseServerCompatibility = resolveServerCompatibility(null);
setDesktopUpdateState({
manifestUrl: null,
serverBlocked: baseServerCompatibility.blocked,
serverBlockMessage: baseServerCompatibility.message,
serverVersion: currentContext.serverVersion,
serverVersionStatus: currentContext.serverVersionStatus
});
if (manifestUrls.length === 0) {
clearPeriodicRefreshTimer();
if (desktopUpdateState.restartRequired) {
setDesktopUpdateState({
availableVersions: [],
latestVersion: null,
manifestUrl: null,
minimumServerVersion: null
});
return;
}
setDesktopUpdateState({
availableVersions: [],
latestVersion: null,
manifestUrl: null,
minimumServerVersion: null,
status: app.isPackaged && desktopUpdateState.autoUpdateMode !== 'off'
? 'no-manifest'
: 'disabled',
statusMessage: getManifestMissingMessage(desktopUpdateState.autoUpdateMode),
targetVersion: null
});
if (!app.isPackaged && desktopUpdateState.autoUpdateMode !== 'off') {
setDesktopUpdateState({
status: 'unsupported',
statusMessage: 'Automatic updates only work in packaged desktop builds.'
});
}
return;
}
try {
const manifestResult = await loadReleaseManifestFromCandidates(manifestUrls);
if (generation !== refreshGeneration) {
return;
}
const { manifest, manifestUrl } = manifestResult;
const availableVersions = sortSemanticVersionsDescending(
manifest.versions.map((entry) => entry.version)
);
const latestVersion = availableVersions[0] ?? null;
const selectedRelease = getSelectedRelease(manifest);
const serverCompatibility = resolveServerCompatibility(
manifest.minimumServerVersion
);
setDesktopUpdateState({
availableVersions,
latestVersion,
manifestUrl,
minimumServerVersion: manifest.minimumServerVersion,
serverBlocked: serverCompatibility.blocked,
serverBlockMessage: serverCompatibility.message,
targetVersion: selectedRelease?.version ?? null
});
if (desktopUpdateState.restartRequired) {
clearPeriodicRefreshTimer();
return;
}
if (!app.isPackaged) {
clearPeriodicRefreshTimer();
setDesktopUpdateState({
status: 'unsupported',
statusMessage: 'Automatic updates only work in packaged desktop builds.'
});
return;
}
if (desktopUpdateState.autoUpdateMode === 'off') {
clearPeriodicRefreshTimer();
setDesktopUpdateState({
status: 'disabled',
statusMessage: 'Automatic updates are turned off.'
});
return;
}
if (!selectedRelease) {
clearPeriodicRefreshTimer();
setDesktopUpdateState({
status: 'target-unavailable',
statusMessage: getTargetUnavailableMessage(
desktopUpdateState.autoUpdateMode,
desktopUpdateState.preferredVersion
)
});
return;
}
if (compareSemanticVersions(selectedRelease.version, app.getVersion()) < 0) {
clearPeriodicRefreshTimer();
setDesktopUpdateState({
status: 'target-older-than-installed',
statusMessage: `MetoYou ${app.getVersion()} is newer than ${selectedRelease.version}. Downgrades are not applied automatically.`,
targetVersion: selectedRelease.version
});
return;
}
schedulePeriodicRefresh(manifest.pollIntervalMinutes);
await performUpdateCheck(selectedRelease, generation);
} catch (error) {
if (generation !== refreshGeneration) {
return;
}
clearPeriodicRefreshTimer();
setDesktopUpdateState({
availableVersions: [],
latestVersion: null,
manifestUrl: null,
minimumServerVersion: null,
status: 'error',
statusMessage: getFriendlyErrorMessage(error),
targetVersion: null
});
}
}
export function getDesktopUpdateState(): DesktopUpdateState {
return desktopUpdateState;
}
export function initializeDesktopUpdater(): void {
if (updaterInitialized) {
return;
}
updaterInitialized = true;
autoUpdater.on('checking-for-update', () => {
if (desktopUpdateState.restartRequired) {
return;
}
setDesktopUpdateState({
status: 'checking',
statusMessage: 'Checking for desktop updates…'
});
});
autoUpdater.on('update-available', (updateInfo: UpdateVersionInfo) => {
const nextVersion = normalizeSemanticVersion(updateInfo.version)
?? desktopUpdateState.targetVersion;
setDesktopUpdateState({
lastCheckedAt: Date.now(),
status: 'downloading',
statusMessage: `Downloading MetoYou ${nextVersion ?? 'update'}`,
targetVersion: nextVersion
});
});
autoUpdater.on('update-not-available', () => {
if (desktopUpdateState.restartRequired) {
return;
}
const isPinnedVersion = desktopUpdateState.autoUpdateMode === 'version'
&& !!desktopUpdateState.targetVersion;
setDesktopUpdateState({
lastCheckedAt: Date.now(),
status: 'up-to-date',
statusMessage: isPinnedVersion
? `MetoYou ${desktopUpdateState.targetVersion} is already installed.`
: 'MetoYou is up to date.'
});
});
autoUpdater.on('update-downloaded', (updateInfo: UpdateVersionInfo) => {
clearPeriodicRefreshTimer();
const nextVersion = normalizeSemanticVersion(updateInfo.version)
?? desktopUpdateState.targetVersion;
setDesktopUpdateState({
lastCheckedAt: Date.now(),
restartRequired: true,
status: 'restart-required',
statusMessage: `MetoYou ${nextVersion ?? 'update'} is ready. Restart the app to finish installing it.`,
targetVersion: nextVersion
});
});
autoUpdater.on('error', (error: unknown) => {
if (desktopUpdateState.restartRequired) {
return;
}
setDesktopUpdateState({
lastCheckedAt: Date.now(),
status: 'error',
statusMessage: getFriendlyErrorMessage(error)
});
});
refreshSettingsSnapshot();
}
export async function configureDesktopUpdaterContext(
context: Partial<DesktopUpdateServerContext>
): Promise<DesktopUpdateState> {
initializeDesktopUpdater();
const manifestUrls = sanitizeHttpUrls(context.manifestUrls);
currentContext = {
manifestUrls,
serverVersion: normalizeSemanticVersion(context.serverVersion),
serverVersionStatus: context.serverVersionStatus ?? 'unknown'
};
await refreshDesktopUpdater('context-changed');
return desktopUpdateState;
}
export async function handleDesktopSettingsChanged(): Promise<void> {
initializeDesktopUpdater();
await refreshDesktopUpdater('settings-changed');
}
export async function checkForDesktopUpdates(): Promise<DesktopUpdateState> {
initializeDesktopUpdater();
await refreshDesktopUpdater('manual');
return desktopUpdateState;
}
export function restartToApplyUpdate(): boolean {
if (!desktopUpdateState.restartRequired) {
return false;
}
autoUpdater.quitAndInstall(false, true);
return true;
}
export function shutdownDesktopUpdater(): void {
clearPeriodicRefreshTimer();
}

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

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

View File

@@ -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": {

View File

@@ -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<string, unknown> } {
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;
}

View File

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

View File

@@ -6,6 +6,40 @@
<main class="flex-1 min-w-0 relative overflow-hidden">
<!-- Custom draggable title bar -->
<app-title-bar />
@if (desktopUpdateState().restartRequired) {
<div class="absolute inset-x-0 top-10 z-20 px-4 pt-4 pointer-events-none">
<div class="pointer-events-auto mx-auto max-w-4xl rounded-xl border border-primary/30 bg-primary/10 p-4 shadow-2xl backdrop-blur-sm">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-sm font-semibold text-foreground">Update ready to install</p>
<p class="mt-1 text-sm text-muted-foreground">
MetoYou {{ desktopUpdateState().targetVersion || 'update' }} has been downloaded. Restart the app to finish applying it.
</p>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
(click)="openUpdatesSettings()"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Update settings
</button>
<button
type="button"
(click)="restartToApplyUpdate()"
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Restart now
</button>
</div>
</div>
</div>
</div>
}
<!-- Content area fills below the title bar without global scroll -->
<div class="absolute inset-x-0 top-10 bottom-0 overflow-auto">
<router-outlet />
@@ -16,6 +50,47 @@
<app-floating-voice-controls />
</div>
@if (desktopUpdateState().serverBlocked) {
<div class="fixed inset-0 z-[80] flex items-center justify-center bg-background/95 px-6 py-10 backdrop-blur-sm">
<div class="w-full max-w-xl rounded-2xl border border-red-500/30 bg-card p-6 shadow-2xl">
<h2 class="text-xl font-semibold text-foreground">Server update required</h2>
<p class="mt-3 text-sm text-muted-foreground">
{{ desktopUpdateState().serverBlockMessage || 'The connected server must be updated before this desktop app can continue.' }}
</p>
<div class="mt-5 grid gap-4 rounded-xl border border-border bg-secondary/20 p-4 text-sm text-muted-foreground sm:grid-cols-2">
<div>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Connected server</p>
<p class="mt-2 text-foreground">{{ desktopUpdateState().serverVersion || 'Not reported' }}</p>
</div>
<div>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Required minimum</p>
<p class="mt-2 text-foreground">{{ desktopUpdateState().minimumServerVersion || 'Unknown' }}</p>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-3">
<button
type="button"
(click)="refreshDesktopUpdateContext()"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Retry
</button>
<button
type="button"
(click)="openNetworkSettings()"
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Open network settings
</button>
</div>
</div>
</div>
}
<!-- Unified Settings Modal -->
<app-settings-modal />

View File

@@ -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> {
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<void> {
await this.desktopUpdates.refreshServerContext();
}
async restartToApplyUpdate(): Promise<void> {
await this.desktopUpdates.restartToApplyUpdate();
}
}

View File

@@ -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<DesktopUpdateState>;
configureAutoUpdateContext?: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
getAutoUpdateState?: () => Promise<DesktopUpdateState>;
onAutoUpdateStateChanged?: (listener: (state: DesktopUpdateState) => void) => () => void;
restartToApplyUpdate?: () => Promise<boolean>;
setDesktopSettings?: (patch: {
autoUpdateMode?: AutoUpdateMode;
manifestUrls?: string[];
preferredVersion?: string | null;
}) => Promise<unknown>;
}
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<DesktopUpdateState>(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<void> {
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<void> {
if (!this.isElectron) {
return;
}
await this.syncServerHealth();
}
async checkForUpdates(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<ServerHealthSnapshot> {
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<DesktopUpdateServerContext>): Promise<void> {
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;
}
}

View File

@@ -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 {

View File

@@ -122,6 +122,9 @@
@case ('voice') {
Voice & Audio
}
@case ('updates') {
Updates
}
@case ('debugging') {
Debugging
}
@@ -160,6 +163,9 @@
@case ('voice') {
<app-voice-settings />
}
@case ('updates') {
<app-updates-settings />
}
@case ('debugging') {
<app-debugging-settings />
}

View File

@@ -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' }

View File

@@ -0,0 +1,185 @@
<div class="space-y-6">
<section class="rounded-xl border border-border bg-card/60 p-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h4 class="text-base font-semibold text-foreground">Desktop app updates</h4>
<p class="mt-1 text-sm text-muted-foreground">
Use a hosted release manifest to check for new packaged desktop builds and apply them after a restart.
</p>
</div>
<span class="inline-flex items-center rounded-full border border-primary/30 bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
{{ statusLabel() }}
</span>
</div>
</section>
@if (!isElectron) {
<section class="rounded-xl border border-border bg-secondary/30 p-5">
<p class="text-sm text-muted-foreground">Automatic updates are only available in the packaged Electron desktop app.</p>
</section>
} @else {
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Installed</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().currentVersion }}</p>
</div>
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Latest in manifest</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().latestVersion || 'Unknown' }}</p>
</div>
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Target version</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().targetVersion || 'Automatic' }}</p>
</div>
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Last checked</p>
<p class="mt-2 text-sm font-medium text-foreground">
{{ state().lastCheckedAt ? (state().lastCheckedAt | date: 'medium') : 'Not checked yet' }}
</p>
</div>
</section>
<section class="space-y-4 rounded-xl border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Update policy</h5>
<p class="mt-1 text-sm text-muted-foreground">
Choose whether the app tracks the newest release, stays on a specific release, or turns updates off entirely.
</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Mode</span>
<select
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
[value]="state().autoUpdateMode"
(change)="onModeChange($event)"
>
<option value="auto">Newest release</option>
<option value="version">Specific version</option>
<option value="off">Turn off auto updates</option>
</select>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Pinned version</span>
<select
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-60"
[disabled]="state().autoUpdateMode !== 'version' || state().availableVersions.length === 0"
[value]="state().preferredVersion || ''"
(change)="onVersionChange($event)"
>
<option value="">Choose a release…</option>
@for (version of state().availableVersions; track version) {
<option [value]="version">{{ version }}</option>
}
</select>
</label>
</div>
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-sm font-medium text-foreground">Status</p>
<p class="mt-1 text-sm text-muted-foreground">
{{ state().statusMessage || 'Waiting for release information from the active server.' }}
</p>
</div>
<div class="flex flex-wrap gap-3">
<button
type="button"
(click)="refreshReleaseInfo()"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Refresh release info
</button>
@if (state().restartRequired) {
<button
type="button"
(click)="restartNow()"
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Restart to update
</button>
}
</div>
</section>
<section class="space-y-4 rounded-xl border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Manifest URL priority</h5>
<p class="mt-1 text-sm text-muted-foreground">
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.
</p>
</div>
<div class="rounded-xl border border-border bg-secondary/20 p-4 text-sm text-muted-foreground">
<p class="font-medium text-foreground">
{{ isUsingConnectedServerDefaults() ? 'Using connected server defaults' : 'Using saved manifest URLs' }}
</p>
<p class="mt-1">When this list is empty, the app automatically uses manifest URLs reported by your configured servers.</p>
</div>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Manifest URLs</span>
<textarea
rows="6"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
[value]="manifestUrlsText()"
(input)="onManifestUrlsInput($event)"
placeholder="https://example.com/releases/latest/download/release-manifest.json"
></textarea>
</label>
@if (!state().defaultManifestUrls.length && isUsingConnectedServerDefaults()) {
<p class="text-sm text-muted-foreground">None of your configured servers currently report a manifest URL.</p>
}
<div class="flex flex-wrap gap-3">
<button
type="button"
(click)="saveManifestUrls()"
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Save manifest URLs
</button>
<button
type="button"
(click)="useConnectedServerDefaults()"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Use connected server defaults
</button>
</div>
</section>
@if (state().serverBlocked) {
<section class="rounded-xl border border-red-500/30 bg-red-500/10 p-5">
<h5 class="text-sm font-semibold text-foreground">Server update required</h5>
<p class="mt-1 text-sm text-muted-foreground">{{ state().serverBlockMessage }}</p>
<div class="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
<div>
<p class="font-semibold uppercase tracking-wider text-muted-foreground/70">Connected server</p>
<p class="mt-1">{{ state().serverVersion || 'Not reported' }}</p>
</div>
<div>
<p class="font-semibold uppercase tracking-wider text-muted-foreground/70">Required minimum</p>
<p class="mt-1">{{ state().minimumServerVersion || 'Unknown' }}</p>
</div>
</div>
</section>
}
<section class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Resolved manifest URL</p>
<p class="mt-2 break-all text-sm text-muted-foreground">{{ state().manifestUrl || 'No working manifest URL has been resolved yet.' }}</p>
</section>
}
</div>

View File

@@ -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<void> {
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<void> {
const select = event.target as HTMLSelectElement;
await this.updates.saveUpdatePreferences('version', select.value || null);
}
async refreshReleaseInfo(): Promise<void> {
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<void> {
await this.updates.saveManifestUrls(
this.parseManifestUrls(this.manifestUrlsText())
);
this.hasPendingManifestUrlChanges.set(false);
}
async useConnectedServerDefaults(): Promise<void> {
await this.updates.saveManifestUrls([]);
this.hasPendingManifestUrlChanges.set(false);
}
async restartNow(): Promise<void> {
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';
}
}
}

View File

@@ -170,7 +170,6 @@ export class RoomMembersSyncEffects {
oderId: signalingMessage.oderId,
displayName: signalingMessage.displayName
};
const members = upsertRoomMember(
room.members ?? [],
this.buildPresenceMember(room, joinedUser)

View File

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