fix(app-update): persist downloaded update state for Install button visibility (#992)

* fix(app-update): persist downloaded update state for Install button visibility

When updates auto-download in background, users miss the update-downloaded
event if not on Settings page. This causes "Install and Restart" button
to never appear.

Changes:
- Add downloadedUpdateInfo state in app-updater.ts to persist downloaded info
- Add APP_UPDATE_GET_DOWNLOADED IPC handler to query downloaded state
- Add getDownloadedAppUpdate API method in preload
- Update AdvancedSettings to check for already-downloaded updates on mount

Now when user opens Settings after background download, the component
queries persisted state and shows "Install and Restart" correctly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(app-update): resolve race condition and type safety issues

- Fix race condition where checkForAppUpdates() could overwrite downloaded
  update info with null, causing 'Unknown' version display
- Add proper type guard for releaseNotes (can be string | array | null)
  instead of unsafe type assertion

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(app-update): clear downloaded update state on channel change and add useEffect cleanup

- Clear downloadedUpdateInfo when update channel changes to prevent showing
  Install button for updates from a different channel (e.g., beta update
  showing after switching to stable channel)
- Add isCancelled flag to useEffect async operations in AdvancedSettings
  to prevent React state updates on unmounted components

Addresses CodeRabbit review findings.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-13 10:13:39 +01:00
committed by AndyMik90
parent 14982e6696
commit 6f059bb5e4
7 changed files with 111 additions and 5 deletions
+27 -1
View File
@@ -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<AppUpdateInfo | null> {
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<AppUpdateInfo | null> {
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
@@ -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<IPCResult<AppUpdateInfo | null>> => {
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');
}
@@ -19,6 +19,7 @@ export interface AppUpdateAPI {
downloadStableUpdate: () => Promise<IPCResult>;
installAppUpdate: () => void;
getAppVersion: () => Promise<string>;
getDownloadedAppUpdate: () => Promise<IPCResult<AppUpdateInfo | null>>;
// Event Listeners
onAppUpdateAvailable: (
@@ -56,6 +57,9 @@ export const createAppUpdateAPI = (): AppUpdateAPI => ({
getAppVersion: (): Promise<string> =>
invokeIpc(IPC_CHANNELS.APP_UPDATE_GET_VERSION),
getDownloadedAppUpdate: (): Promise<IPCResult<AppUpdateInfo | null>> =>
invokeIpc(IPC_CHANNELS.APP_UPDATE_GET_DOWNLOADED),
// Event Listeners
onAppUpdateAvailable: (
callback: (info: AppUpdateAvailableEvent) => void
@@ -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<AppUpdateInfo | null>(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
@@ -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: () => () => {},
@@ -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',
+1
View File
@@ -593,6 +593,7 @@ export interface ElectronAPI {
downloadAppUpdate: () => Promise<IPCResult>;
downloadStableUpdate: () => Promise<IPCResult>;
installAppUpdate: () => void;
getDownloadedAppUpdate: () => Promise<IPCResult<AppUpdateInfo | null>>;
// Electron app update event listeners
onAppUpdateAvailable: (