Add auto updater
This commit is contained in:
72
README.md
72
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
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
|
||||
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
|
||||
import {
|
||||
initializeDatabase,
|
||||
destroyDatabase,
|
||||
@@ -23,6 +24,7 @@ export function registerAppLifecycle(): void {
|
||||
setupCqrsHandlers();
|
||||
setupWindowControlHandlers();
|
||||
setupSystemHandlers();
|
||||
initializeDesktopUpdater();
|
||||
await createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
@@ -39,6 +41,7 @@ export function registerAppLifecycle(): void {
|
||||
app.on('before-quit', async (event) => {
|
||||
if (getDataSource()?.isInitialized) {
|
||||
event.preventDefault();
|
||||
shutdownDesktopUpdater();
|
||||
await cleanupLinuxScreenShareAudioRouting();
|
||||
await destroyDatabase();
|
||||
app.quit();
|
||||
|
||||
@@ -2,8 +2,13 @@ import { app } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export type AutoUpdateMode = 'auto' | 'off' | 'version';
|
||||
|
||||
export interface DesktopSettings {
|
||||
autoUpdateMode: AutoUpdateMode;
|
||||
hardwareAcceleration: boolean;
|
||||
manifestUrls: string[];
|
||||
preferredVersion: string | null;
|
||||
vaapiVideoEncode: boolean;
|
||||
}
|
||||
|
||||
@@ -13,10 +18,45 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
|
||||
}
|
||||
|
||||
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||
autoUpdateMode: 'auto',
|
||||
hardwareAcceleration: true,
|
||||
manifestUrls: [],
|
||||
preferredVersion: null,
|
||||
vaapiVideoEncode: false
|
||||
};
|
||||
|
||||
function normalizeAutoUpdateMode(value: unknown): AutoUpdateMode {
|
||||
return value === 'off' || value === 'version' ? value : 'auto';
|
||||
}
|
||||
|
||||
function normalizePreferredVersion(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function normalizeManifestUrls(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const manifestUrls: string[] = [];
|
||||
|
||||
for (const entry of value) {
|
||||
if (typeof entry !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextUrl = entry.trim();
|
||||
|
||||
if (!nextUrl || manifestUrls.includes(nextUrl)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
manifestUrls.push(nextUrl);
|
||||
}
|
||||
|
||||
return manifestUrls;
|
||||
}
|
||||
|
||||
export function getDesktopSettingsSnapshot(): DesktopSettingsSnapshot {
|
||||
const storedSettings = readDesktopSettings();
|
||||
const runtimeHardwareAcceleration = app.isHardwareAccelerationEnabled();
|
||||
@@ -40,12 +80,15 @@ export function readDesktopSettings(): DesktopSettings {
|
||||
const parsed = JSON.parse(raw) as Partial<DesktopSettings>;
|
||||
|
||||
return {
|
||||
autoUpdateMode: normalizeAutoUpdateMode(parsed.autoUpdateMode),
|
||||
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
|
||||
? parsed.vaapiVideoEncode
|
||||
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
|
||||
hardwareAcceleration: typeof parsed.hardwareAcceleration === 'boolean'
|
||||
? parsed.hardwareAcceleration
|
||||
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration
|
||||
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
||||
manifestUrls: normalizeManifestUrls(parsed.manifestUrls),
|
||||
preferredVersion: normalizePreferredVersion(parsed.preferredVersion)
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_DESKTOP_SETTINGS };
|
||||
@@ -53,10 +96,21 @@ export function readDesktopSettings(): DesktopSettings {
|
||||
}
|
||||
|
||||
export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopSettingsSnapshot {
|
||||
const nextSettings: DesktopSettings = {
|
||||
const mergedSettings = {
|
||||
...readDesktopSettings(),
|
||||
...patch
|
||||
};
|
||||
const nextSettings: DesktopSettings = {
|
||||
autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode),
|
||||
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
|
||||
? mergedSettings.hardwareAcceleration
|
||||
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
||||
manifestUrls: normalizeManifestUrls(mergedSettings.manifestUrls),
|
||||
preferredVersion: normalizePreferredVersion(mergedSettings.preferredVersion),
|
||||
vaapiVideoEncode: typeof mergedSettings.vaapiVideoEncode === 'boolean'
|
||||
? mergedSettings.vaapiVideoEncode
|
||||
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode
|
||||
};
|
||||
const filePath = getDesktopSettingsPath();
|
||||
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
|
||||
@@ -10,7 +10,11 @@ import * as fs from 'fs';
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getDesktopSettingsSnapshot, updateDesktopSettings } from '../desktop-settings';
|
||||
import {
|
||||
getDesktopSettingsSnapshot,
|
||||
updateDesktopSettings,
|
||||
type DesktopSettings
|
||||
} from '../desktop-settings';
|
||||
import {
|
||||
activateLinuxScreenShareAudioRouting,
|
||||
deactivateLinuxScreenShareAudioRouting,
|
||||
@@ -18,6 +22,14 @@ import {
|
||||
startLinuxScreenShareMonitorCapture,
|
||||
stopLinuxScreenShareMonitorCapture
|
||||
} from '../audio/linux-screen-share-routing';
|
||||
import {
|
||||
checkForDesktopUpdates,
|
||||
configureDesktopUpdaterContext,
|
||||
getDesktopUpdateState,
|
||||
handleDesktopSettingsChanged,
|
||||
restartToApplyUpdate,
|
||||
type DesktopUpdateServerContext
|
||||
} from '../update/desktop-updater';
|
||||
|
||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||
const FILE_CLIPBOARD_FORMATS = [
|
||||
@@ -79,14 +91,16 @@ function isSupportedClipboardFileFormat(format: string): boolean {
|
||||
|
||||
function extractClipboardFilePaths(buffer: Buffer, format: string): string[] {
|
||||
const textVariants = new Set<string>();
|
||||
const utf8Text = buffer.toString('utf8').replace(/\0/g, '').trim();
|
||||
const utf8Text = buffer.toString('utf8').replace(/\0/g, '')
|
||||
.trim();
|
||||
|
||||
if (utf8Text) {
|
||||
textVariants.add(utf8Text);
|
||||
}
|
||||
|
||||
if (format.toLowerCase() === 'filenamew') {
|
||||
const utf16Text = buffer.toString('utf16le').replace(/\0/g, '\n').trim();
|
||||
const utf16Text = buffer.toString('utf16le').replace(/\0/g, '\n')
|
||||
.trim();
|
||||
|
||||
if (utf16Text) {
|
||||
textVariants.add(utf16Text);
|
||||
@@ -226,8 +240,23 @@ export function setupSystemHandlers(): void {
|
||||
|
||||
ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot());
|
||||
|
||||
ipcMain.handle('set-desktop-settings', (_event, patch: { hardwareAcceleration?: boolean }) => {
|
||||
return updateDesktopSettings(patch);
|
||||
ipcMain.handle('get-auto-update-state', () => getDesktopUpdateState());
|
||||
|
||||
ipcMain.handle('configure-auto-update-context', async (_event, context: Partial<DesktopUpdateServerContext>) => {
|
||||
return await configureDesktopUpdaterContext(context);
|
||||
});
|
||||
|
||||
ipcMain.handle('check-for-app-updates', async () => {
|
||||
return await checkForDesktopUpdates();
|
||||
});
|
||||
|
||||
ipcMain.handle('restart-to-apply-update', () => restartToApplyUpdate());
|
||||
|
||||
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
|
||||
const snapshot = updateDesktopSettings(patch);
|
||||
|
||||
await handleDesktopSettingsChanged();
|
||||
return snapshot;
|
||||
});
|
||||
|
||||
ipcMain.handle('relaunch-app', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Command, Query } from './cqrs/types';
|
||||
|
||||
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk';
|
||||
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
|
||||
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
||||
|
||||
export interface LinuxScreenShareAudioRoutingInfo {
|
||||
available: boolean;
|
||||
@@ -40,6 +41,48 @@ export interface ClipboardFilePayload {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export type DesktopUpdateServerVersionStatus = 'unknown' | 'reported' | 'missing' | 'unavailable';
|
||||
|
||||
export interface DesktopUpdateServerContext {
|
||||
manifestUrls: string[];
|
||||
serverVersion: string | null;
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateState {
|
||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||
availableVersions: string[];
|
||||
configuredManifestUrls: string[];
|
||||
currentVersion: string;
|
||||
defaultManifestUrls: string[];
|
||||
isSupported: boolean;
|
||||
lastCheckedAt: number | null;
|
||||
latestVersion: string | null;
|
||||
manifestUrl: string | null;
|
||||
manifestUrls: string[];
|
||||
minimumServerVersion: string | null;
|
||||
preferredVersion: string | null;
|
||||
restartRequired: boolean;
|
||||
serverBlocked: boolean;
|
||||
serverBlockMessage: string | null;
|
||||
serverVersion: string | null;
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
status:
|
||||
| 'idle'
|
||||
| 'disabled'
|
||||
| 'checking'
|
||||
| 'downloading'
|
||||
| 'up-to-date'
|
||||
| 'restart-required'
|
||||
| 'unsupported'
|
||||
| 'no-manifest'
|
||||
| 'target-unavailable'
|
||||
| 'target-older-than-installed'
|
||||
| 'error';
|
||||
statusMessage: string | null;
|
||||
targetVersion: string | null;
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
minimizeWindow: () => void;
|
||||
maximizeWindow: () => void;
|
||||
@@ -56,12 +99,29 @@ export interface ElectronAPI {
|
||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
getDesktopSettings: () => Promise<{
|
||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||
hardwareAcceleration: boolean;
|
||||
manifestUrls: string[];
|
||||
preferredVersion: string | null;
|
||||
runtimeHardwareAcceleration: boolean;
|
||||
restartRequired: boolean;
|
||||
}>;
|
||||
setDesktopSettings: (patch: { hardwareAcceleration?: boolean }) => Promise<{
|
||||
getAutoUpdateState: () => Promise<DesktopUpdateState>;
|
||||
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
|
||||
checkForAppUpdates: () => Promise<DesktopUpdateState>;
|
||||
restartToApplyUpdate: () => Promise<boolean>;
|
||||
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
|
||||
setDesktopSettings: (patch: {
|
||||
autoUpdateMode?: 'auto' | 'off' | 'version';
|
||||
hardwareAcceleration?: boolean;
|
||||
manifestUrls?: string[];
|
||||
preferredVersion?: string | null;
|
||||
vaapiVideoEncode?: boolean;
|
||||
}) => Promise<{
|
||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||
hardwareAcceleration: boolean;
|
||||
manifestUrls: string[];
|
||||
preferredVersion: string | null;
|
||||
runtimeHardwareAcceleration: boolean;
|
||||
restartRequired: boolean;
|
||||
}>;
|
||||
@@ -121,6 +181,21 @@ const electronAPI: ElectronAPI = {
|
||||
},
|
||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
||||
getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'),
|
||||
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
|
||||
checkForAppUpdates: () => ipcRenderer.invoke('check-for-app-updates'),
|
||||
restartToApplyUpdate: () => ipcRenderer.invoke('restart-to-apply-update'),
|
||||
onAutoUpdateStateChanged: (listener) => {
|
||||
const wrappedListener = (_event: Electron.IpcRendererEvent, state: DesktopUpdateState) => {
|
||||
listener(state);
|
||||
};
|
||||
|
||||
ipcRenderer.on(AUTO_UPDATE_STATE_CHANGED_CHANNEL, wrappedListener);
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener(AUTO_UPDATE_STATE_CHANGED_CHANNEL, wrappedListener);
|
||||
};
|
||||
},
|
||||
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
|
||||
relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
|
||||
readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'),
|
||||
|
||||
738
electron/update/desktop-updater.ts
Normal file
738
electron/update/desktop-updater.ts
Normal file
@@ -0,0 +1,738 @@
|
||||
import { app, net } from 'electron';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import { readDesktopSettings, type AutoUpdateMode } from '../desktop-settings';
|
||||
import { getMainWindow } from '../window/create-window';
|
||||
import {
|
||||
compareSemanticVersions,
|
||||
normalizeSemanticVersion,
|
||||
sortSemanticVersionsDescending
|
||||
} from './version';
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MINUTES = 30;
|
||||
const MINIMUM_POLL_INTERVAL_MINUTES = 5;
|
||||
|
||||
interface ReleaseManifestEntry {
|
||||
feedUrl: string;
|
||||
notes?: string;
|
||||
publishedAt?: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
interface UpdateVersionInfo {
|
||||
version: string;
|
||||
}
|
||||
|
||||
interface ReleaseManifest {
|
||||
minimumServerVersion: string | null;
|
||||
pollIntervalMinutes: number;
|
||||
versions: ReleaseManifestEntry[];
|
||||
}
|
||||
|
||||
export type DesktopUpdateStatus =
|
||||
| 'idle'
|
||||
| 'disabled'
|
||||
| 'checking'
|
||||
| 'downloading'
|
||||
| 'up-to-date'
|
||||
| 'restart-required'
|
||||
| 'unsupported'
|
||||
| 'no-manifest'
|
||||
| 'target-unavailable'
|
||||
| 'target-older-than-installed'
|
||||
| 'error';
|
||||
|
||||
export type DesktopUpdateServerVersionStatus =
|
||||
| 'unknown'
|
||||
| 'reported'
|
||||
| 'missing'
|
||||
| 'unavailable';
|
||||
|
||||
export interface DesktopUpdateServerContext {
|
||||
manifestUrls: string[];
|
||||
serverVersion: string | null;
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateState {
|
||||
autoUpdateMode: AutoUpdateMode;
|
||||
availableVersions: string[];
|
||||
configuredManifestUrls: string[];
|
||||
currentVersion: string;
|
||||
defaultManifestUrls: string[];
|
||||
isSupported: boolean;
|
||||
lastCheckedAt: number | null;
|
||||
latestVersion: string | null;
|
||||
manifestUrl: string | null;
|
||||
manifestUrls: string[];
|
||||
minimumServerVersion: string | null;
|
||||
preferredVersion: string | null;
|
||||
restartRequired: boolean;
|
||||
serverBlocked: boolean;
|
||||
serverBlockMessage: string | null;
|
||||
serverVersion: string | null;
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
status: DesktopUpdateStatus;
|
||||
statusMessage: string | null;
|
||||
targetVersion: string | null;
|
||||
}
|
||||
|
||||
export const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
||||
|
||||
let currentCheckPromise: Promise<void> | null = null;
|
||||
let currentContext: DesktopUpdateServerContext = {
|
||||
manifestUrls: [],
|
||||
serverVersion: null,
|
||||
serverVersionStatus: 'unknown'
|
||||
};
|
||||
let desktopUpdateState: DesktopUpdateState = createInitialState();
|
||||
let periodicRefreshTimer: NodeJS.Timeout | null = null;
|
||||
let refreshGeneration = 0;
|
||||
let updaterInitialized = false;
|
||||
|
||||
function createInitialState(): DesktopUpdateState {
|
||||
const settings = readDesktopSettings();
|
||||
|
||||
return {
|
||||
autoUpdateMode: settings.autoUpdateMode,
|
||||
availableVersions: [],
|
||||
configuredManifestUrls: settings.manifestUrls,
|
||||
currentVersion: app.getVersion(),
|
||||
defaultManifestUrls: [],
|
||||
isSupported: app.isPackaged,
|
||||
lastCheckedAt: null,
|
||||
latestVersion: null,
|
||||
manifestUrl: null,
|
||||
manifestUrls: settings.manifestUrls,
|
||||
minimumServerVersion: null,
|
||||
preferredVersion: settings.preferredVersion,
|
||||
restartRequired: false,
|
||||
serverBlocked: false,
|
||||
serverBlockMessage: null,
|
||||
serverVersion: null,
|
||||
serverVersionStatus: 'unknown',
|
||||
status: 'idle',
|
||||
statusMessage: null,
|
||||
targetVersion: null
|
||||
};
|
||||
}
|
||||
|
||||
function clearPeriodicRefreshTimer(): void {
|
||||
if (!periodicRefreshTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(periodicRefreshTimer);
|
||||
periodicRefreshTimer = null;
|
||||
}
|
||||
|
||||
function emitState(): void {
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow.webContents.send(AUTO_UPDATE_STATE_CHANGED_CHANNEL, desktopUpdateState);
|
||||
}
|
||||
|
||||
function setDesktopUpdateState(patch: Partial<DesktopUpdateState>): void {
|
||||
desktopUpdateState = {
|
||||
...desktopUpdateState,
|
||||
...patch,
|
||||
currentVersion: app.getVersion()
|
||||
};
|
||||
|
||||
emitState();
|
||||
}
|
||||
|
||||
function sanitizeHttpUrl(rawValue: unknown): string | null {
|
||||
if (typeof rawValue !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmedValue = rawValue.trim();
|
||||
|
||||
if (!trimmedValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(trimmedValue);
|
||||
|
||||
return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:'
|
||||
? parsedUrl.toString().replace(/\/$/, '')
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeHttpUrls(rawValue: unknown): string[] {
|
||||
if (!Array.isArray(rawValue)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const manifestUrls: string[] = [];
|
||||
|
||||
for (const entry of rawValue) {
|
||||
const manifestUrl = sanitizeHttpUrl(entry);
|
||||
|
||||
if (!manifestUrl || manifestUrls.includes(manifestUrl)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
manifestUrls.push(manifestUrl);
|
||||
}
|
||||
|
||||
return manifestUrls;
|
||||
}
|
||||
|
||||
function getEffectiveManifestUrls(configuredManifestUrls: string[]): string[] {
|
||||
return configuredManifestUrls.length > 0
|
||||
? [...configuredManifestUrls]
|
||||
: [...currentContext.manifestUrls];
|
||||
}
|
||||
|
||||
function coercePollIntervalMinutes(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.max(MINIMUM_POLL_INTERVAL_MINUTES, Math.round(value))
|
||||
: DEFAULT_POLL_INTERVAL_MINUTES;
|
||||
}
|
||||
|
||||
function parseReleaseManifest(payload: unknown): ReleaseManifest {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new Error('Release manifest must be a JSON object.');
|
||||
}
|
||||
|
||||
const rawManifest = payload as Record<string, unknown>;
|
||||
const rawVersions = Array.isArray(rawManifest.versions) ? rawManifest.versions : [];
|
||||
const parsedVersions: ReleaseManifestEntry[] = [];
|
||||
|
||||
for (const entry of rawVersions) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawEntry = entry as Record<string, unknown>;
|
||||
const version = normalizeSemanticVersion(rawEntry.version as string | undefined);
|
||||
const feedUrl = sanitizeHttpUrl(rawEntry.feedUrl);
|
||||
|
||||
if (!version || !feedUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const notes = typeof rawEntry.notes === 'string' && rawEntry.notes.trim().length > 0
|
||||
? rawEntry.notes.trim()
|
||||
: undefined;
|
||||
const publishedAt = typeof rawEntry.publishedAt === 'string'
|
||||
&& rawEntry.publishedAt.trim().length > 0
|
||||
? rawEntry.publishedAt.trim()
|
||||
: undefined;
|
||||
|
||||
parsedVersions.push({
|
||||
feedUrl,
|
||||
...(notes ? { notes } : {}),
|
||||
...(publishedAt ? { publishedAt } : {}),
|
||||
version
|
||||
});
|
||||
}
|
||||
|
||||
parsedVersions.sort((left, right) => compareSemanticVersions(right.version, left.version));
|
||||
|
||||
const deduplicatedVersions: ReleaseManifestEntry[] = [];
|
||||
const seenVersions = new Set<string>();
|
||||
|
||||
for (const entry of parsedVersions) {
|
||||
if (seenVersions.has(entry.version)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenVersions.add(entry.version);
|
||||
deduplicatedVersions.push(entry);
|
||||
}
|
||||
|
||||
if (deduplicatedVersions.length === 0) {
|
||||
throw new Error('Release manifest does not contain any valid versions.');
|
||||
}
|
||||
|
||||
return {
|
||||
minimumServerVersion: normalizeSemanticVersion(
|
||||
rawManifest.minimumServerVersion as string | undefined
|
||||
),
|
||||
pollIntervalMinutes: coercePollIntervalMinutes(rawManifest.pollIntervalMinutes),
|
||||
versions: deduplicatedVersions
|
||||
};
|
||||
}
|
||||
|
||||
function resolveServerCompatibility(minimumServerVersion: string | null): {
|
||||
blocked: boolean;
|
||||
message: string | null;
|
||||
} {
|
||||
if (currentContext.serverVersionStatus === 'missing') {
|
||||
return {
|
||||
blocked: true,
|
||||
message: 'The connected server is too old. Update the server project to a version that reports its release metadata.'
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
currentContext.serverVersionStatus !== 'reported'
|
||||
|| !minimumServerVersion
|
||||
|| !currentContext.serverVersion
|
||||
) {
|
||||
return { blocked: false,
|
||||
message: null };
|
||||
}
|
||||
|
||||
if (compareSemanticVersions(currentContext.serverVersion, minimumServerVersion) >= 0) {
|
||||
return { blocked: false,
|
||||
message: null };
|
||||
}
|
||||
|
||||
return {
|
||||
blocked: true,
|
||||
message: `This desktop app requires server version ${minimumServerVersion} or newer. The connected server is ${currentContext.serverVersion}.`
|
||||
};
|
||||
}
|
||||
|
||||
function getManifestMissingMessage(autoUpdateMode: AutoUpdateMode): string {
|
||||
return autoUpdateMode === 'off'
|
||||
? 'Automatic updates are turned off.'
|
||||
: 'No release manifest URLs are configured.';
|
||||
}
|
||||
|
||||
function getTargetUnavailableMessage(
|
||||
autoUpdateMode: AutoUpdateMode,
|
||||
preferredVersion: string | null
|
||||
): string {
|
||||
if (autoUpdateMode === 'version') {
|
||||
return preferredVersion
|
||||
? `The selected version ${preferredVersion} is not present in the release manifest.`
|
||||
: 'Select a version before enabling pinned updates.';
|
||||
}
|
||||
|
||||
return 'No compatible release was found in the release manifest.';
|
||||
}
|
||||
|
||||
function getFriendlyErrorMessage(error: unknown): string {
|
||||
const rawMessage = error instanceof Error
|
||||
? error.message
|
||||
: typeof error === 'string'
|
||||
? error
|
||||
: 'Unknown error';
|
||||
|
||||
if (rawMessage.includes('APPIMAGE')) {
|
||||
return 'Automatic updates on Linux require the packaged AppImage or DEB build.';
|
||||
}
|
||||
|
||||
return rawMessage;
|
||||
}
|
||||
|
||||
function getSelectedRelease(manifest: ReleaseManifest): ReleaseManifestEntry | null {
|
||||
const settings = readDesktopSettings();
|
||||
|
||||
if (settings.autoUpdateMode === 'version') {
|
||||
const preferredVersion = normalizeSemanticVersion(settings.preferredVersion);
|
||||
|
||||
if (!preferredVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return manifest.versions.find((entry) => entry.version === preferredVersion) ?? null;
|
||||
}
|
||||
|
||||
return manifest.versions[0] ?? null;
|
||||
}
|
||||
|
||||
function refreshSettingsSnapshot(): void {
|
||||
const settings = readDesktopSettings();
|
||||
const defaultManifestUrls = [...currentContext.manifestUrls];
|
||||
const manifestUrls = getEffectiveManifestUrls(settings.manifestUrls);
|
||||
|
||||
setDesktopUpdateState({
|
||||
autoUpdateMode: settings.autoUpdateMode,
|
||||
configuredManifestUrls: settings.manifestUrls,
|
||||
currentVersion: app.getVersion(),
|
||||
defaultManifestUrls,
|
||||
isSupported: app.isPackaged,
|
||||
manifestUrls,
|
||||
preferredVersion: settings.preferredVersion
|
||||
});
|
||||
}
|
||||
|
||||
function schedulePeriodicRefresh(pollIntervalMinutes: number): void {
|
||||
clearPeriodicRefreshTimer();
|
||||
|
||||
if (!app.isPackaged || desktopUpdateState.autoUpdateMode === 'off' || desktopUpdateState.restartRequired) {
|
||||
return;
|
||||
}
|
||||
|
||||
periodicRefreshTimer = setInterval(() => {
|
||||
void refreshDesktopUpdater('scheduled');
|
||||
}, pollIntervalMinutes * 60_000);
|
||||
}
|
||||
|
||||
async function loadReleaseManifest(manifestUrl: string): Promise<ReleaseManifest> {
|
||||
const response = await net.fetch(manifestUrl, {
|
||||
headers: {
|
||||
accept: 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Release manifest request failed with status ${response.status}.`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
|
||||
return parseReleaseManifest(payload);
|
||||
}
|
||||
|
||||
function formatManifestLoadErrors(errors: string[]): string {
|
||||
if (errors.length === 0) {
|
||||
return 'No valid release manifest could be loaded.';
|
||||
}
|
||||
|
||||
if (errors.length === 1) {
|
||||
return errors[0];
|
||||
}
|
||||
|
||||
const remainingCount = errors.length - 1;
|
||||
|
||||
return `${errors[0]} (${remainingCount} more manifest URL${remainingCount === 1 ? '' : 's'} failed.)`;
|
||||
}
|
||||
|
||||
async function loadReleaseManifestFromCandidates(manifestUrls: string[]): Promise<{
|
||||
manifest: ReleaseManifest;
|
||||
manifestUrl: string;
|
||||
}> {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const manifestUrl of manifestUrls) {
|
||||
try {
|
||||
const manifest = await loadReleaseManifest(manifestUrl);
|
||||
|
||||
return {
|
||||
manifest,
|
||||
manifestUrl
|
||||
};
|
||||
} catch (error) {
|
||||
errors.push(`${manifestUrl}: ${getFriendlyErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(formatManifestLoadErrors(errors));
|
||||
}
|
||||
|
||||
async function performUpdateCheck(
|
||||
targetRelease: ReleaseManifestEntry,
|
||||
generation: number
|
||||
): Promise<void> {
|
||||
if (generation !== refreshGeneration || desktopUpdateState.restartRequired) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentCheckPromise) {
|
||||
await currentCheckPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
currentCheckPromise = (async () => {
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
autoUpdater.allowDowngrade = false;
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url: targetRelease.feedUrl });
|
||||
|
||||
setDesktopUpdateState({
|
||||
lastCheckedAt: Date.now(),
|
||||
status: 'checking',
|
||||
statusMessage: `Checking for MetoYou ${targetRelease.version}…`,
|
||||
targetVersion: targetRelease.version
|
||||
});
|
||||
|
||||
await autoUpdater.checkForUpdates();
|
||||
})();
|
||||
|
||||
try {
|
||||
await currentCheckPromise;
|
||||
} finally {
|
||||
currentCheckPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDesktopUpdater(
|
||||
_reason: 'context-changed' | 'manual' | 'scheduled' | 'settings-changed'
|
||||
): Promise<void> {
|
||||
const generation = ++refreshGeneration;
|
||||
|
||||
refreshSettingsSnapshot();
|
||||
|
||||
const manifestUrls = [...desktopUpdateState.manifestUrls];
|
||||
const baseServerCompatibility = resolveServerCompatibility(null);
|
||||
|
||||
setDesktopUpdateState({
|
||||
manifestUrl: null,
|
||||
serverBlocked: baseServerCompatibility.blocked,
|
||||
serverBlockMessage: baseServerCompatibility.message,
|
||||
serverVersion: currentContext.serverVersion,
|
||||
serverVersionStatus: currentContext.serverVersionStatus
|
||||
});
|
||||
|
||||
if (manifestUrls.length === 0) {
|
||||
clearPeriodicRefreshTimer();
|
||||
|
||||
if (desktopUpdateState.restartRequired) {
|
||||
setDesktopUpdateState({
|
||||
availableVersions: [],
|
||||
latestVersion: null,
|
||||
manifestUrl: null,
|
||||
minimumServerVersion: null
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setDesktopUpdateState({
|
||||
availableVersions: [],
|
||||
latestVersion: null,
|
||||
manifestUrl: null,
|
||||
minimumServerVersion: null,
|
||||
status: app.isPackaged && desktopUpdateState.autoUpdateMode !== 'off'
|
||||
? 'no-manifest'
|
||||
: 'disabled',
|
||||
statusMessage: getManifestMissingMessage(desktopUpdateState.autoUpdateMode),
|
||||
targetVersion: null
|
||||
});
|
||||
|
||||
if (!app.isPackaged && desktopUpdateState.autoUpdateMode !== 'off') {
|
||||
setDesktopUpdateState({
|
||||
status: 'unsupported',
|
||||
statusMessage: 'Automatic updates only work in packaged desktop builds.'
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const manifestResult = await loadReleaseManifestFromCandidates(manifestUrls);
|
||||
|
||||
if (generation !== refreshGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { manifest, manifestUrl } = manifestResult;
|
||||
const availableVersions = sortSemanticVersionsDescending(
|
||||
manifest.versions.map((entry) => entry.version)
|
||||
);
|
||||
const latestVersion = availableVersions[0] ?? null;
|
||||
const selectedRelease = getSelectedRelease(manifest);
|
||||
const serverCompatibility = resolveServerCompatibility(
|
||||
manifest.minimumServerVersion
|
||||
);
|
||||
|
||||
setDesktopUpdateState({
|
||||
availableVersions,
|
||||
latestVersion,
|
||||
manifestUrl,
|
||||
minimumServerVersion: manifest.minimumServerVersion,
|
||||
serverBlocked: serverCompatibility.blocked,
|
||||
serverBlockMessage: serverCompatibility.message,
|
||||
targetVersion: selectedRelease?.version ?? null
|
||||
});
|
||||
|
||||
if (desktopUpdateState.restartRequired) {
|
||||
clearPeriodicRefreshTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!app.isPackaged) {
|
||||
clearPeriodicRefreshTimer();
|
||||
|
||||
setDesktopUpdateState({
|
||||
status: 'unsupported',
|
||||
statusMessage: 'Automatic updates only work in packaged desktop builds.'
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (desktopUpdateState.autoUpdateMode === 'off') {
|
||||
clearPeriodicRefreshTimer();
|
||||
|
||||
setDesktopUpdateState({
|
||||
status: 'disabled',
|
||||
statusMessage: 'Automatic updates are turned off.'
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedRelease) {
|
||||
clearPeriodicRefreshTimer();
|
||||
|
||||
setDesktopUpdateState({
|
||||
status: 'target-unavailable',
|
||||
statusMessage: getTargetUnavailableMessage(
|
||||
desktopUpdateState.autoUpdateMode,
|
||||
desktopUpdateState.preferredVersion
|
||||
)
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (compareSemanticVersions(selectedRelease.version, app.getVersion()) < 0) {
|
||||
clearPeriodicRefreshTimer();
|
||||
|
||||
setDesktopUpdateState({
|
||||
status: 'target-older-than-installed',
|
||||
statusMessage: `MetoYou ${app.getVersion()} is newer than ${selectedRelease.version}. Downgrades are not applied automatically.`,
|
||||
targetVersion: selectedRelease.version
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
schedulePeriodicRefresh(manifest.pollIntervalMinutes);
|
||||
await performUpdateCheck(selectedRelease, generation);
|
||||
} catch (error) {
|
||||
if (generation !== refreshGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearPeriodicRefreshTimer();
|
||||
|
||||
setDesktopUpdateState({
|
||||
availableVersions: [],
|
||||
latestVersion: null,
|
||||
manifestUrl: null,
|
||||
minimumServerVersion: null,
|
||||
status: 'error',
|
||||
statusMessage: getFriendlyErrorMessage(error),
|
||||
targetVersion: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getDesktopUpdateState(): DesktopUpdateState {
|
||||
return desktopUpdateState;
|
||||
}
|
||||
|
||||
export function initializeDesktopUpdater(): void {
|
||||
if (updaterInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
updaterInitialized = true;
|
||||
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
if (desktopUpdateState.restartRequired) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDesktopUpdateState({
|
||||
status: 'checking',
|
||||
statusMessage: 'Checking for desktop updates…'
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('update-available', (updateInfo: UpdateVersionInfo) => {
|
||||
const nextVersion = normalizeSemanticVersion(updateInfo.version)
|
||||
?? desktopUpdateState.targetVersion;
|
||||
|
||||
setDesktopUpdateState({
|
||||
lastCheckedAt: Date.now(),
|
||||
status: 'downloading',
|
||||
statusMessage: `Downloading MetoYou ${nextVersion ?? 'update'}…`,
|
||||
targetVersion: nextVersion
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
if (desktopUpdateState.restartRequired) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isPinnedVersion = desktopUpdateState.autoUpdateMode === 'version'
|
||||
&& !!desktopUpdateState.targetVersion;
|
||||
|
||||
setDesktopUpdateState({
|
||||
lastCheckedAt: Date.now(),
|
||||
status: 'up-to-date',
|
||||
statusMessage: isPinnedVersion
|
||||
? `MetoYou ${desktopUpdateState.targetVersion} is already installed.`
|
||||
: 'MetoYou is up to date.'
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', (updateInfo: UpdateVersionInfo) => {
|
||||
clearPeriodicRefreshTimer();
|
||||
|
||||
const nextVersion = normalizeSemanticVersion(updateInfo.version)
|
||||
?? desktopUpdateState.targetVersion;
|
||||
|
||||
setDesktopUpdateState({
|
||||
lastCheckedAt: Date.now(),
|
||||
restartRequired: true,
|
||||
status: 'restart-required',
|
||||
statusMessage: `MetoYou ${nextVersion ?? 'update'} is ready. Restart the app to finish installing it.`,
|
||||
targetVersion: nextVersion
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (error: unknown) => {
|
||||
if (desktopUpdateState.restartRequired) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDesktopUpdateState({
|
||||
lastCheckedAt: Date.now(),
|
||||
status: 'error',
|
||||
statusMessage: getFriendlyErrorMessage(error)
|
||||
});
|
||||
});
|
||||
|
||||
refreshSettingsSnapshot();
|
||||
}
|
||||
|
||||
export async function configureDesktopUpdaterContext(
|
||||
context: Partial<DesktopUpdateServerContext>
|
||||
): Promise<DesktopUpdateState> {
|
||||
initializeDesktopUpdater();
|
||||
|
||||
const manifestUrls = sanitizeHttpUrls(context.manifestUrls);
|
||||
|
||||
currentContext = {
|
||||
manifestUrls,
|
||||
serverVersion: normalizeSemanticVersion(context.serverVersion),
|
||||
serverVersionStatus: context.serverVersionStatus ?? 'unknown'
|
||||
};
|
||||
|
||||
await refreshDesktopUpdater('context-changed');
|
||||
return desktopUpdateState;
|
||||
}
|
||||
|
||||
export async function handleDesktopSettingsChanged(): Promise<void> {
|
||||
initializeDesktopUpdater();
|
||||
await refreshDesktopUpdater('settings-changed');
|
||||
}
|
||||
|
||||
export async function checkForDesktopUpdates(): Promise<DesktopUpdateState> {
|
||||
initializeDesktopUpdater();
|
||||
await refreshDesktopUpdater('manual');
|
||||
return desktopUpdateState;
|
||||
}
|
||||
|
||||
export function restartToApplyUpdate(): boolean {
|
||||
if (!desktopUpdateState.restartRequired) {
|
||||
return false;
|
||||
}
|
||||
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shutdownDesktopUpdater(): void {
|
||||
clearPeriodicRefreshTimer();
|
||||
}
|
||||
150
electron/update/version.ts
Normal file
150
electron/update/version.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
interface ParsedSemanticVersion {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
prerelease: string[];
|
||||
}
|
||||
|
||||
function parseNumericIdentifier(value: string): number | null {
|
||||
return /^\d+$/.test(value) ? Number.parseInt(value, 10) : null;
|
||||
}
|
||||
|
||||
function parseSemanticVersion(rawValue: string | null | undefined): ParsedSemanticVersion | null {
|
||||
const normalized = normalizeSemanticVersion(rawValue);
|
||||
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = normalized.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?$/);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prerelease = match[4]
|
||||
? match[4]
|
||||
.split('.')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return {
|
||||
major: Number.parseInt(match[1], 10),
|
||||
minor: Number.parseInt(match[2] ?? '0', 10),
|
||||
patch: Number.parseInt(match[3] ?? '0', 10),
|
||||
prerelease
|
||||
};
|
||||
}
|
||||
|
||||
function comparePrereleaseIdentifiers(left: string, right: string): number {
|
||||
const leftNumeric = parseNumericIdentifier(left);
|
||||
const rightNumeric = parseNumericIdentifier(right);
|
||||
|
||||
if (leftNumeric !== null && rightNumeric !== null) {
|
||||
return leftNumeric - rightNumeric;
|
||||
}
|
||||
|
||||
if (leftNumeric !== null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (rightNumeric !== null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return left.localeCompare(right);
|
||||
}
|
||||
|
||||
function comparePrerelease(left: string[], right: string[]): number {
|
||||
if (left.length === 0 && right.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (left.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (right.length === 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const maxLength = Math.max(left.length, right.length);
|
||||
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
const leftValue = left[index];
|
||||
const rightValue = right[index];
|
||||
|
||||
if (!leftValue) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!rightValue) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const comparison = comparePrereleaseIdentifiers(leftValue, rightValue);
|
||||
|
||||
if (comparison !== 0) {
|
||||
return comparison;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function normalizeSemanticVersion(rawValue: string | null | undefined): string | null {
|
||||
if (typeof rawValue !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmedValue = rawValue.trim();
|
||||
|
||||
if (!trimmedValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmedValue.replace(/^v/i, '').split('+')[0] ?? null;
|
||||
}
|
||||
|
||||
export function compareSemanticVersions(
|
||||
leftVersion: string | null | undefined,
|
||||
rightVersion: string | null | undefined
|
||||
): number {
|
||||
const left = parseSemanticVersion(leftVersion);
|
||||
const right = parseSemanticVersion(rightVersion);
|
||||
|
||||
if (!left && !right) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!left) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!right) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (left.major !== right.major) {
|
||||
return left.major - right.major;
|
||||
}
|
||||
|
||||
if (left.minor !== right.minor) {
|
||||
return left.minor - right.minor;
|
||||
}
|
||||
|
||||
if (left.patch !== right.patch) {
|
||||
return left.patch - right.patch;
|
||||
}
|
||||
|
||||
return comparePrerelease(left.prerelease, right.prerelease);
|
||||
}
|
||||
|
||||
export function sortSemanticVersionsDescending(versions: string[]): string[] {
|
||||
const normalizedVersions = versions
|
||||
.map((version) => normalizeSemanticVersion(version))
|
||||
.filter((version): version is string => !!version);
|
||||
|
||||
return [...new Set(normalizedVersions)].sort((left, right) => compareSemanticVersions(right, left));
|
||||
}
|
||||
13
package.json
13
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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
401
src/app/core/services/desktop-app-update.service.ts
Normal file
401
src/app/core/services/desktop-app-update.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,7 +170,6 @@ export class RoomMembersSyncEffects {
|
||||
oderId: signalingMessage.oderId,
|
||||
displayName: signalingMessage.displayName
|
||||
};
|
||||
|
||||
const members = upsertRoomMember(
|
||||
room.members ?? [],
|
||||
this.buildPresenceMember(room, joinedUser)
|
||||
|
||||
256
tools/generate-release-manifest.js
Normal file
256
tools/generate-release-manifest.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user