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:
@@ -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',
|
||||
|
||||
@@ -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: (
|
||||
|
||||
Reference in New Issue
Block a user