diff --git a/apps/desktop/src/main/app-updater.ts b/apps/desktop/src/main/app-updater.ts index ac0465e745..489c6f9805 100644 --- a/apps/desktop/src/main/app-updater.ts +++ b/apps/desktop/src/main/app-updater.ts @@ -27,6 +27,7 @@ import { IPC_CHANNELS } from '../shared/constants'; import type { AppUpdateInfo } from '../shared/types'; import { compareVersions } from './updater/version-manager'; import { isMacOS } from './platform'; +import { readSettingsFile, writeSettingsFile } from './settings-utils'; // GitHub repo info for API calls const GITHUB_OWNER = 'AndyMik90'; @@ -155,6 +156,59 @@ function formatReleaseNotes(releaseNotes: UpdateInfo['releaseNotes']): string | return undefined; } +/** + * Read update suppression preferences from settings. + */ +function getUpdatePreferences(): { skippedVersion: string | null; remindAfter: number | null } { + const settings = readSettingsFile(); + return { + skippedVersion: (settings?.skippedUpdateVersion as string) || null, + remindAfter: (settings?.updateRemindAfter as number) || null, + }; +} + +/** + * Check if notifications for a version should be suppressed. + * Returns true if the user has skipped this exact version, or snoozed and the snooze hasn't expired. + */ +export function shouldSuppressUpdate(version: string): boolean { + const prefs = getUpdatePreferences(); + if (prefs.skippedVersion === version) return true; + if (prefs.remindAfter && Date.now() < prefs.remindAfter) return true; + return false; +} + +/** + * Persist a request to skip a specific version permanently. + * That version will never trigger notifications again unless a newer version arrives. + */ +export function skipUpdateVersion(version: string): void { + const settings = readSettingsFile() || {}; + settings.skippedUpdateVersion = version; + delete settings.updateRemindAfter; + writeSettingsFile(settings); + console.warn('[app-updater] Skipped version:', version); +} + +/** + * Snooze update notifications for 24 hours. + */ +export function snoozeUpdate(): void { + const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; + const settings = readSettingsFile() || {}; + settings.updateRemindAfter = Date.now() + TWENTY_FOUR_HOURS; + writeSettingsFile(settings); + console.warn('[app-updater] Snoozed updates until:', new Date(settings.updateRemindAfter as number).toISOString()); +} + +/** + * Check if update notifications are currently suppressed for a given version. + * Used by the renderer via IPC to avoid showing stale banners. + */ +export function isUpdateSuppressed(version: string): boolean { + return shouldSuppressUpdate(version); +} + /** * Set the update channel for electron-updater. * - 'latest': Only receive stable releases (default) @@ -233,6 +287,16 @@ export function initializeAppUpdater(window: BrowserWindow, betaUpdates = false) return; } + // Check if user has skipped or snoozed this version + if (shouldSuppressUpdate(info.version)) { + console.warn('[app-updater] Update suppressed (skipped or snoozed):', info.version); + // Still download silently so it's ready when the suppression expires + autoUpdater.downloadUpdate().catch((error) => { + console.error('[app-updater] Failed to download update:', error.message); + }); + return; + } + if (mainWindow) { mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_AVAILABLE, { version: info.version, @@ -265,9 +329,14 @@ export function initializeAppUpdater(window: BrowserWindow, betaUpdates = false) releaseNotes: formatReleaseNotes(info.releaseNotes), releaseDate: info.releaseDate }; - if (mainWindow) { - // Reuse downloadedUpdateInfo instead of calling formatReleaseNotes again - mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_DOWNLOADED, downloadedUpdateInfo); + + // Only notify renderer if update is not suppressed + if (!shouldSuppressUpdate(info.version)) { + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_DOWNLOADED, downloadedUpdateInfo); + } + } else { + console.warn('[app-updater] Downloaded update suppressed (skipped or snoozed):', info.version); } }); diff --git a/apps/desktop/src/main/ipc-handlers/app-update-handlers.ts b/apps/desktop/src/main/ipc-handlers/app-update-handlers.ts index 04bd39498d..def0829cbf 100644 --- a/apps/desktop/src/main/ipc-handlers/app-update-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers/app-update-handlers.ts @@ -14,7 +14,10 @@ import { downloadStableVersion, quitAndInstall, getCurrentVersion, - getDownloadedUpdateInfo + getDownloadedUpdateInfo, + skipUpdateVersion, + snoozeUpdate, + shouldSuppressUpdate } from '../app-updater'; /** @@ -151,5 +154,58 @@ export function registerAppUpdateHandlers(): void { } ); + /** + * APP_UPDATE_SKIP_VERSION: Permanently skip a specific version + * That version will never show notifications again until a newer version arrives + */ + ipcMain.handle( + IPC_CHANNELS.APP_UPDATE_SKIP_VERSION, + async (_event, version: string): Promise => { + try { + // Validate version format (semver-like, max 50 chars) + if (!version || typeof version !== 'string' || version.length > 50 || !/^[\d\w.-]+$/.test(version)) { + return { success: false, error: 'Invalid version format' }; + } + skipUpdateVersion(version); + return { success: true }; + } catch (error) { + console.error('[app-update-handlers] Skip version failed:', error); + return { success: false, error: error instanceof Error ? error.message : 'Failed to skip version' }; + } + } + ); + + /** + * APP_UPDATE_SNOOZE: Snooze update notifications for 24 hours + */ + ipcMain.handle( + IPC_CHANNELS.APP_UPDATE_SNOOZE, + async (): Promise => { + try { + snoozeUpdate(); + return { success: true }; + } catch (error) { + console.error('[app-update-handlers] Snooze update failed:', error); + return { success: false, error: error instanceof Error ? error.message : 'Failed to snooze update' }; + } + } + ); + + /** + * APP_UPDATE_IS_SUPPRESSED: Check if update notifications are suppressed for a version + * Used by the renderer to avoid showing stale banners after a skip/snooze + */ + ipcMain.handle( + IPC_CHANNELS.APP_UPDATE_IS_SUPPRESSED, + async (_event, version: string): Promise> => { + try { + return { success: true, data: shouldSuppressUpdate(version) }; + } catch (error) { + console.error('[app-update-handlers] Check suppression failed:', error); + return { success: false, error: error instanceof Error ? error.message : 'Failed to check suppression' }; + } + } + ); + console.warn('[IPC] App update handlers registered successfully'); } diff --git a/apps/desktop/src/preload/api/app-update-api.ts b/apps/desktop/src/preload/api/app-update-api.ts index 23cebac859..0f76280c62 100644 --- a/apps/desktop/src/preload/api/app-update-api.ts +++ b/apps/desktop/src/preload/api/app-update-api.ts @@ -21,6 +21,9 @@ export interface AppUpdateAPI { installAppUpdate: () => void; getAppVersion: () => Promise; getDownloadedAppUpdate: () => Promise>; + skipAppUpdate: (version: string) => Promise; + snoozeAppUpdate: () => Promise; + isAppUpdateSuppressed: (version: string) => Promise>; // Event Listeners onAppUpdateAvailable: ( @@ -69,6 +72,15 @@ export const createAppUpdateAPI = (): AppUpdateAPI => ({ getDownloadedAppUpdate: (): Promise> => invokeIpc(IPC_CHANNELS.APP_UPDATE_GET_DOWNLOADED), + skipAppUpdate: (version: string): Promise => + invokeIpc(IPC_CHANNELS.APP_UPDATE_SKIP_VERSION, version), + + snoozeAppUpdate: (): Promise => + invokeIpc(IPC_CHANNELS.APP_UPDATE_SNOOZE), + + isAppUpdateSuppressed: (version: string): Promise> => + invokeIpc(IPC_CHANNELS.APP_UPDATE_IS_SUPPRESSED, version), + // Event Listeners onAppUpdateAvailable: ( callback: (info: AppUpdateAvailableEvent) => void diff --git a/apps/desktop/src/renderer/components/AppUpdateNotification.tsx b/apps/desktop/src/renderer/components/AppUpdateNotification.tsx index 97fe859ab8..0d59f4f846 100644 --- a/apps/desktop/src/renderer/components/AppUpdateNotification.tsx +++ b/apps/desktop/src/renderer/components/AppUpdateNotification.tsx @@ -165,6 +165,26 @@ export function AppUpdateNotification() { setIsOpen(false); }; + const handleSkipVersion = async () => { + try { + if (updateInfo) { + await window.electronAPI.skipAppUpdate(updateInfo.version); + } + } catch { + // Skip is best-effort; close the dialog regardless + } + setIsOpen(false); + }; + + const handleRemindLater = async () => { + try { + await window.electronAPI.snoozeAppUpdate(); + } catch { + // Snooze is best-effort; close the dialog regardless + } + setIsOpen(false); + }; + if (!updateInfo) { return null; } @@ -298,7 +318,12 @@ export function AppUpdateNotification() { - + )} +