diff --git a/apps/frontend/src/main/app-updater.ts b/apps/frontend/src/main/app-updater.ts index 98f1f824..8bdbd7fa 100644 --- a/apps/frontend/src/main/app-updater.ts +++ b/apps/frontend/src/main/app-updater.ts @@ -47,6 +47,9 @@ type UpdateChannel = 'latest' | 'beta'; */ export function setUpdateChannel(channel: UpdateChannel): void { autoUpdater.channel = channel; + // Clear any downloaded update info when channel changes to prevent showing + // an Install button for an update from a different channel + downloadedUpdateInfo = null; console.warn(`[app-updater] Update channel set to: ${channel}`); } @@ -62,6 +65,9 @@ if (DEBUG_UPDATER) { let mainWindow: BrowserWindow | null = null; +// Track downloaded update state so it persists across Settings page navigations +let downloadedUpdateInfo: AppUpdateInfo | null = null; + /** * Initialize the app updater system * @@ -107,6 +113,13 @@ export function initializeAppUpdater(window: BrowserWindow, betaUpdates = false) // Update downloaded - ready to install autoUpdater.on('update-downloaded', (info) => { console.warn('[app-updater] Update downloaded:', info.version); + // Store downloaded update info so it persists across Settings page navigations + // releaseNotes can be string | ReleaseNoteInfo[] | null | undefined, only use if string + downloadedUpdateInfo = { + version: info.version, + releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined, + releaseDate: info.releaseDate + }; if (mainWindow) { mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_DOWNLOADED, { version: info.version, @@ -215,9 +228,10 @@ export async function checkForUpdates(): Promise { return null; } + // releaseNotes can be string | ReleaseNoteInfo[] | null | undefined, only use if string return { version: result.updateInfo.version, - releaseNotes: result.updateInfo.releaseNotes as string | undefined, + releaseNotes: typeof result.updateInfo.releaseNotes === 'string' ? result.updateInfo.releaseNotes : undefined, releaseDate: result.updateInfo.releaseDate }; } catch (error) { @@ -256,6 +270,15 @@ export function getCurrentVersion(): string { return autoUpdater.currentVersion.version; } +/** + * Get downloaded update info if an update has been downloaded and is ready to install. + * This allows the UI to show "Install and Restart" even if the user opens Settings + * after the download completed in the background. + */ +export function getDownloadedUpdateInfo(): AppUpdateInfo | null { + return downloadedUpdateInfo; +} + /** * Check if a version string represents a prerelease (beta, alpha, rc, etc.) */ @@ -422,6 +445,9 @@ export async function setUpdateChannelWithDowngradeCheck( triggerDowngradeCheck = false ): Promise { autoUpdater.channel = channel; + // Clear any downloaded update info when channel changes to prevent showing + // an Install button for an update from a different channel + downloadedUpdateInfo = null; console.warn(`[app-updater] Update channel set to: ${channel}`); // If switching to stable and downgrade check requested, look for stable version diff --git a/apps/frontend/src/main/ipc-handlers/app-update-handlers.ts b/apps/frontend/src/main/ipc-handlers/app-update-handlers.ts index 66c7f3ee..33f4d6b1 100644 --- a/apps/frontend/src/main/ipc-handlers/app-update-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/app-update-handlers.ts @@ -13,7 +13,8 @@ import { downloadUpdate, downloadStableVersion, quitAndInstall, - getCurrentVersion + getCurrentVersion, + getDownloadedUpdateInfo } from '../app-updater'; /** @@ -123,5 +124,28 @@ export function registerAppUpdateHandlers(): void { } ); + /** + * APP_UPDATE_GET_DOWNLOADED: Get downloaded update info + * Returns info about a downloaded update that's ready to install, + * or null if no update has been downloaded yet. + * This allows the UI to show "Install and Restart" even if the user + * opens Settings after the download completed in the background. + */ + ipcMain.handle( + IPC_CHANNELS.APP_UPDATE_GET_DOWNLOADED, + async (): Promise> => { + try { + const downloadedInfo = getDownloadedUpdateInfo(); + return { success: true, data: downloadedInfo }; + } catch (error) { + console.error('[app-update-handlers] Get downloaded update info failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get downloaded update info' + }; + } + } + ); + console.warn('[IPC] App update handlers registered successfully'); } diff --git a/apps/frontend/src/preload/api/app-update-api.ts b/apps/frontend/src/preload/api/app-update-api.ts index 313c16ed..489fa98b 100644 --- a/apps/frontend/src/preload/api/app-update-api.ts +++ b/apps/frontend/src/preload/api/app-update-api.ts @@ -19,6 +19,7 @@ export interface AppUpdateAPI { downloadStableUpdate: () => Promise; installAppUpdate: () => void; getAppVersion: () => Promise; + getDownloadedAppUpdate: () => Promise>; // Event Listeners onAppUpdateAvailable: ( @@ -56,6 +57,9 @@ export const createAppUpdateAPI = (): AppUpdateAPI => ({ getAppVersion: (): Promise => invokeIpc(IPC_CHANNELS.APP_UPDATE_GET_VERSION), + getDownloadedAppUpdate: (): Promise> => + invokeIpc(IPC_CHANNELS.APP_UPDATE_GET_DOWNLOADED), + // Event Listeners onAppUpdateAvailable: ( callback: (info: AppUpdateAvailableEvent) => void diff --git a/apps/frontend/src/renderer/components/settings/AdvancedSettings.tsx b/apps/frontend/src/renderer/components/settings/AdvancedSettings.tsx index ac3d05fa..441fe627 100644 --- a/apps/frontend/src/renderer/components/settings/AdvancedSettings.tsx +++ b/apps/frontend/src/renderer/components/settings/AdvancedSettings.tsx @@ -80,11 +80,60 @@ export function AdvancedSettings({ settings, onSettingsChange, section, version // Stable downgrade state (shown when user turns off beta while on prerelease) const [stableDowngradeInfo, setStableDowngradeInfo] = useState(null); - // Check for updates on mount + // Check for updates on mount, including any already-downloaded updates useEffect(() => { - if (section === 'updates') { - checkForAppUpdates(); + if (section !== 'updates') { + return; } + + let isCancelled = false; + + // First check if an update was already downloaded, then check for new updates + (async () => { + // Check if an update was already downloaded (e.g., auto-downloaded in background) + try { + const result = await window.electronAPI.getDownloadedAppUpdate(); + + // Skip state updates if component unmounted or section changed + if (isCancelled) return; + + if (result.success && result.data) { + // An update was already downloaded - show "Install and Restart" button + setAppUpdateInfo(result.data); + setIsAppUpdateDownloaded(true); + console.log('[AdvancedSettings] Found already-downloaded update:', result.data.version); + return; // Don't check for new updates if we already have one downloaded + } + } catch (err) { + console.error('Failed to check for downloaded update:', err); + if (isCancelled) return; + } + + // Only check for available updates if no update is already downloaded + // (electron-updater reports no available update when one is already downloaded, + // which would clear our appUpdateInfo and lose the version metadata) + // Inline the update check with cancellation support + setIsCheckingAppUpdate(true); + try { + const result = await window.electronAPI.checkAppUpdate(); + if (isCancelled) return; + if (result.success && result.data) { + setAppUpdateInfo(result.data); + } else { + setAppUpdateInfo(null); + } + } catch (err) { + console.error('Failed to check for app updates:', err); + } finally { + if (!isCancelled) { + setIsCheckingAppUpdate(false); + } + } + })(); + + return () => { + isCancelled = true; + }; }, [section]); // Listen for app update events diff --git a/apps/frontend/src/renderer/lib/mocks/settings-mock.ts b/apps/frontend/src/renderer/lib/mocks/settings-mock.ts index 97c83313..88c78c35 100644 --- a/apps/frontend/src/renderer/lib/mocks/settings-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/settings-mock.ts @@ -38,6 +38,7 @@ export const settingsMock = { downloadAppUpdate: async () => ({ success: true }), downloadStableUpdate: async () => ({ success: true }), installAppUpdate: () => { console.warn('[browser-mock] installAppUpdate called'); }, + getDownloadedAppUpdate: async () => ({ success: true, data: null }), // App Update Event Listeners (no-op in browser mode) onAppUpdateAvailable: () => () => {}, diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index 4928a513..c3b75a16 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -480,6 +480,7 @@ export const IPC_CHANNELS = { APP_UPDATE_DOWNLOAD_STABLE: 'app-update:download-stable', // Download stable version (for downgrade from beta) APP_UPDATE_INSTALL: 'app-update:install', APP_UPDATE_GET_VERSION: 'app-update:get-version', + APP_UPDATE_GET_DOWNLOADED: 'app-update:get-downloaded', // Get downloaded update info (for showing Install button on Settings open) // App auto-update events (main -> renderer) APP_UPDATE_AVAILABLE: 'app-update:available', diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index fe54cc40..adddf0c9 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -593,6 +593,7 @@ export interface ElectronAPI { downloadAppUpdate: () => Promise; downloadStableUpdate: () => Promise; installAppUpdate: () => void; + getDownloadedAppUpdate: () => Promise>; // Electron app update event listeners onAppUpdateAvailable: (