mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-03 10:28:43 +02:00
feat: migrate packaging and auto updates
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+9
-1
@@ -3,6 +3,7 @@ import type { BrowserWindow as BrowserWindowType } from 'electron';
|
||||
|
||||
import { registerIpcHandlers } from '@main/ipc/registerIpcHandlers';
|
||||
import { AryxAppService } from '@main/AryxAppService';
|
||||
import { AutoUpdateService } from '@main/services/autoUpdater';
|
||||
import { createMainWindow } from '@main/windows/createMainWindow';
|
||||
import { applyTitleBarTheme } from '@main/windows/titleBarTheme';
|
||||
import { SystemTray, setupCloseToTray, showAndFocusWindow } from '@main/services/systemTray';
|
||||
@@ -12,12 +13,15 @@ const { app, BrowserWindow } = electron;
|
||||
let mainWindow: BrowserWindowType | undefined;
|
||||
let appService: AryxAppService | undefined;
|
||||
let systemTray: SystemTray | undefined;
|
||||
let autoUpdateService: AutoUpdateService | undefined;
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
appService = new AryxAppService();
|
||||
autoUpdateService?.dispose();
|
||||
autoUpdateService = new AutoUpdateService({ isPackaged: app.isPackaged });
|
||||
|
||||
mainWindow = createMainWindow();
|
||||
registerIpcHandlers(mainWindow, appService);
|
||||
registerIpcHandlers(mainWindow, appService, autoUpdateService);
|
||||
|
||||
// Apply persisted theme to the title bar overlay
|
||||
const workspace = await appService.loadWorkspace();
|
||||
@@ -49,6 +53,8 @@ async function bootstrap(): Promise<void> {
|
||||
if (!app.isPackaged) {
|
||||
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||||
}
|
||||
|
||||
autoUpdateService.start();
|
||||
}
|
||||
|
||||
app.whenReady().then(bootstrap);
|
||||
@@ -73,6 +79,8 @@ app.on('activate', async () => {
|
||||
});
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
autoUpdateService?.dispose();
|
||||
autoUpdateService = undefined;
|
||||
systemTray?.dispose();
|
||||
await appService?.dispose();
|
||||
});
|
||||
|
||||
@@ -37,12 +37,18 @@ import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
|
||||
import type { AppearanceTheme } from '@shared/domain/tooling';
|
||||
|
||||
import { AryxAppService } from '@main/AryxAppService';
|
||||
import { AutoUpdateService } from '@main/services/autoUpdater';
|
||||
import { createDesktopNotificationHandler } from '@main/services/desktopNotifications';
|
||||
import { applyTitleBarTheme } from '@main/windows/titleBarTheme';
|
||||
import type { UpdateStatus } from '@shared/contracts/ipc';
|
||||
|
||||
const { ipcMain } = electron;
|
||||
|
||||
export function registerIpcHandlers(window: BrowserWindow, service: AryxAppService): void {
|
||||
export function registerIpcHandlers(
|
||||
window: BrowserWindow,
|
||||
service: AryxAppService,
|
||||
autoUpdateService: AutoUpdateService,
|
||||
): void {
|
||||
ipcMain.handle(ipcChannels.describeSidecarCapabilities, () => service.describeSidecarCapabilities());
|
||||
ipcMain.handle(ipcChannels.refreshSidecarCapabilities, () => service.refreshSidecarCapabilities());
|
||||
ipcMain.handle(ipcChannels.loadWorkspace, () => service.loadWorkspace());
|
||||
@@ -96,6 +102,10 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
|
||||
ipcChannels.setMinimizeToTray,
|
||||
(_event, enabled: boolean) => service.setMinimizeToTray(enabled),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.checkForUpdates, () => autoUpdateService.checkForUpdates());
|
||||
ipcMain.handle(ipcChannels.installUpdate, () => {
|
||||
autoUpdateService.installUpdate();
|
||||
});
|
||||
ipcMain.handle(ipcChannels.saveMcpServer, (_event, input: SaveMcpServerInput) =>
|
||||
service.saveMcpServer(input.server),
|
||||
);
|
||||
@@ -204,6 +214,16 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
|
||||
);
|
||||
service.on('session-event', handleNotification);
|
||||
|
||||
const sendUpdateStatus = (status: UpdateStatus) => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send(ipcChannels.updateStatus, status);
|
||||
}
|
||||
};
|
||||
autoUpdateService.onStatus(sendUpdateStatus);
|
||||
window.webContents.on('did-finish-load', () => {
|
||||
sendUpdateStatus(autoUpdateService.getStatus());
|
||||
});
|
||||
|
||||
service.on('terminal-data', (data) => {
|
||||
window.webContents.send(ipcChannels.terminalData, data);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import electronUpdater from 'electron-updater';
|
||||
|
||||
import type {
|
||||
UpdateDownloadProgress,
|
||||
UpdateStatus,
|
||||
} from '@shared/contracts/ipc';
|
||||
|
||||
interface AutoUpdateInfoLike {
|
||||
version?: string | null;
|
||||
releaseDate?: string | null;
|
||||
releaseNotes?: unknown;
|
||||
}
|
||||
|
||||
interface AutoUpdateProgressLike {
|
||||
bytesPerSecond: number;
|
||||
percent: number;
|
||||
total: number;
|
||||
transferred: number;
|
||||
}
|
||||
|
||||
type AutoUpdateListener = (...args: any[]) => void;
|
||||
|
||||
interface AutoUpdaterLike {
|
||||
autoDownload: boolean;
|
||||
autoInstallOnAppQuit: boolean;
|
||||
on(event: string, listener: AutoUpdateListener): this;
|
||||
removeListener(event: string, listener: AutoUpdateListener): this;
|
||||
checkForUpdates(): Promise<unknown>;
|
||||
quitAndInstall(): void;
|
||||
}
|
||||
|
||||
export interface AutoUpdateScheduler {
|
||||
setTimeout(callback: () => void, delayMs: number): unknown;
|
||||
clearTimeout(handle: unknown): void;
|
||||
setInterval(callback: () => void, delayMs: number): unknown;
|
||||
clearInterval(handle: unknown): void;
|
||||
}
|
||||
|
||||
export interface AutoUpdateServiceOptions {
|
||||
isPackaged: boolean;
|
||||
startupDelayMs?: number;
|
||||
recheckIntervalMs?: number;
|
||||
updater?: AutoUpdaterLike;
|
||||
scheduler?: AutoUpdateScheduler;
|
||||
}
|
||||
|
||||
const DEFAULT_STARTUP_DELAY_MS = 10_000;
|
||||
const DEFAULT_RECHECK_INTERVAL_MS = 4 * 60 * 60 * 1000;
|
||||
|
||||
const defaultScheduler: AutoUpdateScheduler = {
|
||||
setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs),
|
||||
clearTimeout: (handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>),
|
||||
setInterval: (callback, delayMs) => globalThis.setInterval(callback, delayMs),
|
||||
clearInterval: (handle) => globalThis.clearInterval(handle as ReturnType<typeof setInterval>),
|
||||
};
|
||||
|
||||
function normalizeOptionalString(value: string | null | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeReleaseNotes(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') {
|
||||
return normalizeOptionalString(value);
|
||||
}
|
||||
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const notes = value
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return normalizeOptionalString(item);
|
||||
}
|
||||
|
||||
if (!item || typeof item !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const record = item as { note?: unknown; version?: unknown };
|
||||
const version = typeof record.version === 'string' ? normalizeOptionalString(record.version) : undefined;
|
||||
const note = typeof record.note === 'string' ? normalizeOptionalString(record.note) : undefined;
|
||||
if (version && note) {
|
||||
return `${version}\n${note}`;
|
||||
}
|
||||
|
||||
return note ?? version;
|
||||
})
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
|
||||
return notes.length > 0 ? notes.join('\n\n') : undefined;
|
||||
}
|
||||
|
||||
function normalizeProgress(progress: AutoUpdateProgressLike): UpdateDownloadProgress {
|
||||
return {
|
||||
bytesPerSecond: progress.bytesPerSecond,
|
||||
percent: progress.percent,
|
||||
total: progress.total,
|
||||
transferred: progress.transferred,
|
||||
};
|
||||
}
|
||||
|
||||
function createStatusFromInfo(
|
||||
state: Extract<UpdateStatus['state'], 'available' | 'downloaded'>,
|
||||
info: AutoUpdateInfoLike,
|
||||
): UpdateStatus {
|
||||
return {
|
||||
state,
|
||||
version: normalizeOptionalString(info.version),
|
||||
releaseDate: normalizeOptionalString(info.releaseDate),
|
||||
releaseNotes: normalizeReleaseNotes(info.releaseNotes),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
return 'Unknown update error.';
|
||||
}
|
||||
|
||||
export class AutoUpdateService {
|
||||
private readonly updater: AutoUpdaterLike;
|
||||
|
||||
private readonly scheduler: AutoUpdateScheduler;
|
||||
|
||||
private readonly listeners = new Set<(status: UpdateStatus) => void>();
|
||||
|
||||
private status: UpdateStatus = { state: 'idle' };
|
||||
|
||||
private started = false;
|
||||
|
||||
private initialCheckHandle?: unknown;
|
||||
|
||||
private periodicCheckHandle?: unknown;
|
||||
|
||||
private pendingCheck?: Promise<UpdateStatus>;
|
||||
|
||||
private readonly checkingListener = () => {
|
||||
this.publishStatus({ state: 'checking' });
|
||||
};
|
||||
|
||||
private readonly availableListener = (info: AutoUpdateInfoLike) => {
|
||||
this.publishStatus(createStatusFromInfo('available', info));
|
||||
};
|
||||
|
||||
private readonly notAvailableListener = () => {
|
||||
this.publishStatus({ state: 'idle' });
|
||||
};
|
||||
|
||||
private readonly progressListener = (progress: AutoUpdateProgressLike) => {
|
||||
this.publishStatus({
|
||||
...this.status,
|
||||
state: 'downloading',
|
||||
downloadProgress: normalizeProgress(progress),
|
||||
});
|
||||
};
|
||||
|
||||
private readonly downloadedListener = (info: AutoUpdateInfoLike) => {
|
||||
this.publishStatus(createStatusFromInfo('downloaded', info));
|
||||
};
|
||||
|
||||
private readonly errorListener = (error: unknown) => {
|
||||
this.publishStatus({
|
||||
...this.status,
|
||||
state: 'error',
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
};
|
||||
|
||||
constructor(private readonly options: AutoUpdateServiceOptions) {
|
||||
this.updater = options.updater
|
||||
?? (electronUpdater as { autoUpdater: AutoUpdaterLike }).autoUpdater;
|
||||
this.scheduler = options.scheduler ?? defaultScheduler;
|
||||
this.updater.autoDownload = true;
|
||||
this.updater.autoInstallOnAppQuit = false;
|
||||
|
||||
this.updater.on('checking-for-update', this.checkingListener);
|
||||
this.updater.on('update-available', this.availableListener);
|
||||
this.updater.on('update-not-available', this.notAvailableListener);
|
||||
this.updater.on('download-progress', this.progressListener);
|
||||
this.updater.on('update-downloaded', this.downloadedListener);
|
||||
this.updater.on('error', this.errorListener);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.started || !this.options.isPackaged) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.started = true;
|
||||
this.initialCheckHandle = this.scheduler.setTimeout(() => {
|
||||
void this.checkForUpdates();
|
||||
}, this.options.startupDelayMs ?? DEFAULT_STARTUP_DELAY_MS);
|
||||
this.periodicCheckHandle = this.scheduler.setInterval(() => {
|
||||
void this.checkForUpdates();
|
||||
}, this.options.recheckIntervalMs ?? DEFAULT_RECHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
getStatus(): UpdateStatus {
|
||||
return this.cloneStatus(this.status);
|
||||
}
|
||||
|
||||
onStatus(listener: (status: UpdateStatus) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async checkForUpdates(): Promise<UpdateStatus> {
|
||||
if (!this.options.isPackaged) {
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
if (this.pendingCheck) {
|
||||
return this.pendingCheck;
|
||||
}
|
||||
|
||||
const request = this.updater.checkForUpdates()
|
||||
.catch((error) => {
|
||||
this.errorListener(error);
|
||||
})
|
||||
.then(() => this.getStatus())
|
||||
.finally(() => {
|
||||
if (this.pendingCheck === request) {
|
||||
this.pendingCheck = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
this.pendingCheck = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
installUpdate(): void {
|
||||
if (this.status.state !== 'downloaded') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updater.quitAndInstall();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.initialCheckHandle !== undefined) {
|
||||
this.scheduler.clearTimeout(this.initialCheckHandle);
|
||||
this.initialCheckHandle = undefined;
|
||||
}
|
||||
|
||||
if (this.periodicCheckHandle !== undefined) {
|
||||
this.scheduler.clearInterval(this.periodicCheckHandle);
|
||||
this.periodicCheckHandle = undefined;
|
||||
}
|
||||
|
||||
this.updater.removeListener('checking-for-update', this.checkingListener);
|
||||
this.updater.removeListener('update-available', this.availableListener);
|
||||
this.updater.removeListener('update-not-available', this.notAvailableListener);
|
||||
this.updater.removeListener('download-progress', this.progressListener);
|
||||
this.updater.removeListener('update-downloaded', this.downloadedListener);
|
||||
this.updater.removeListener('error', this.errorListener);
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
private publishStatus(status: UpdateStatus): void {
|
||||
this.status = this.cloneStatus(status);
|
||||
for (const listener of this.listeners) {
|
||||
listener(this.cloneStatus(this.status));
|
||||
}
|
||||
}
|
||||
|
||||
private cloneStatus(status: UpdateStatus): UpdateStatus {
|
||||
return status.downloadProgress
|
||||
? { ...status, downloadProgress: { ...status.downloadProgress } }
|
||||
: { ...status };
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ const api: ElectronApi = {
|
||||
setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input),
|
||||
setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled),
|
||||
setMinimizeToTray: (enabled) => ipcRenderer.invoke(ipcChannels.setMinimizeToTray, enabled),
|
||||
checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates),
|
||||
installUpdate: () => ipcRenderer.invoke(ipcChannels.installUpdate),
|
||||
saveMcpServer: (input) => ipcRenderer.invoke(ipcChannels.saveMcpServer, input),
|
||||
deleteMcpServer: (serverId) => ipcRenderer.invoke(ipcChannels.deleteMcpServer, serverId),
|
||||
saveLspProfile: (input) => ipcRenderer.invoke(ipcChannels.saveLspProfile, input),
|
||||
@@ -97,6 +99,13 @@ const api: ElectronApi = {
|
||||
ipcRenderer.on(ipcChannels.sessionEvent, handler);
|
||||
return () => ipcRenderer.off(ipcChannels.sessionEvent, handler);
|
||||
},
|
||||
onUpdateStatus: (listener) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, status: Parameters<typeof listener>[0]) =>
|
||||
listener(status);
|
||||
|
||||
ipcRenderer.on(ipcChannels.updateStatus, handler);
|
||||
return () => ipcRenderer.off(ipcChannels.updateStatus, handler);
|
||||
},
|
||||
onTrayCreateScratchpad: (listener) => {
|
||||
const handler = () => listener();
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ export const ipcChannels = {
|
||||
setTerminalHeight: 'settings:set-terminal-height',
|
||||
setNotificationsEnabled: 'settings:set-notifications-enabled',
|
||||
setMinimizeToTray: 'settings:set-minimize-to-tray',
|
||||
checkForUpdates: 'app:check-for-updates',
|
||||
installUpdate: 'app:install-update',
|
||||
saveMcpServer: 'tooling:mcp:save',
|
||||
deleteMcpServer: 'tooling:mcp:delete',
|
||||
saveLspProfile: 'tooling:lsp:save',
|
||||
@@ -55,6 +57,7 @@ export const ipcChannels = {
|
||||
terminalExit: 'terminal:exit',
|
||||
workspaceUpdated: 'workspace:updated',
|
||||
sessionEvent: 'sessions:event',
|
||||
updateStatus: 'app:update-status',
|
||||
getQuota: 'sidecar:get-quota',
|
||||
trayCreateScratchpad: 'tray:create-scratchpad',
|
||||
} as const;
|
||||
|
||||
@@ -157,6 +157,24 @@ export interface SetTerminalHeightInput {
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export type UpdateStatusState = 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'error';
|
||||
|
||||
export interface UpdateDownloadProgress {
|
||||
bytesPerSecond: number;
|
||||
percent: number;
|
||||
total: number;
|
||||
transferred: number;
|
||||
}
|
||||
|
||||
export interface UpdateStatus {
|
||||
state: UpdateStatusState;
|
||||
version?: string;
|
||||
releaseDate?: string;
|
||||
releaseNotes?: string;
|
||||
downloadProgress?: UpdateDownloadProgress;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ElectronApi {
|
||||
describeSidecarCapabilities(): Promise<SidecarCapabilities>;
|
||||
refreshSidecarCapabilities(): Promise<SidecarCapabilities>;
|
||||
@@ -202,6 +220,8 @@ export interface ElectronApi {
|
||||
setTerminalHeight(input: SetTerminalHeightInput): Promise<WorkspaceState>;
|
||||
setNotificationsEnabled(enabled: boolean): Promise<WorkspaceState>;
|
||||
setMinimizeToTray(enabled: boolean): Promise<WorkspaceState>;
|
||||
checkForUpdates(): Promise<UpdateStatus>;
|
||||
installUpdate(): Promise<void>;
|
||||
describeTerminal(): Promise<TerminalSnapshot | undefined>;
|
||||
createTerminal(): Promise<TerminalSnapshot>;
|
||||
restartTerminal(): Promise<TerminalSnapshot>;
|
||||
@@ -215,6 +235,7 @@ export interface ElectronApi {
|
||||
onTerminalExit(listener: (info: TerminalExitInfo) => void): () => void;
|
||||
onWorkspaceUpdated(listener: (workspace: WorkspaceState) => void): () => void;
|
||||
onSessionEvent(listener: (event: SessionEventRecord) => void): () => void;
|
||||
onUpdateStatus(listener: (status: UpdateStatus) => void): () => void;
|
||||
onTrayCreateScratchpad(listener: () => void): () => void;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user