diff --git a/src/main/handlers/index.ts b/src/main/handlers/index.ts index efd0182ff..29c36d0e5 100644 --- a/src/main/handlers/index.ts +++ b/src/main/handlers/index.ts @@ -2,3 +2,4 @@ export * from './app'; export * from './storage'; export * from './system'; export * from './tray'; +export * from './updater'; diff --git a/src/main/handlers/updater.test.ts b/src/main/handlers/updater.test.ts new file mode 100644 index 000000000..9b1398787 --- /dev/null +++ b/src/main/handlers/updater.test.ts @@ -0,0 +1,44 @@ +import { EVENTS } from '../../shared/events'; + +import type AppUpdater from '../updater'; +import { registerUpdaterHandlers } from './updater'; + +const onMock = vi.fn(); + +vi.mock('electron', () => ({ + ipcMain: { + on: (...args: unknown[]) => onMock(...args), + } satisfies Pick, +})); + +describe('main/handlers/updater.ts', () => { + let appUpdater: AppUpdater; + + beforeEach(() => { + appUpdater = { + setNotificationsEnabled: vi.fn(), + start: vi.fn().mockResolvedValue(undefined), + } as unknown as AppUpdater; + }); + + it('registers the update notification preference handler', () => { + registerUpdaterHandlers(appUpdater); + + expect(onMock.mock.calls.map((call: unknown[]) => call[0])).toContain( + EVENTS.UPDATE_SHOW_UPDATE_NOTIFICATIONS, + ); + }); + + it.each([false, true])('applies the preference and starts update checks for %s', (enabled) => { + registerUpdaterHandlers(appUpdater); + + const listener = onMock.mock.calls.find( + (call: unknown[]) => call[0] === EVENTS.UPDATE_SHOW_UPDATE_NOTIFICATIONS, + )?.[1] as (event: unknown, enabled: boolean) => void; + + listener(null, enabled); + + expect(appUpdater.setNotificationsEnabled).toHaveBeenCalledWith(enabled); + expect(appUpdater.start).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/main/handlers/updater.ts b/src/main/handlers/updater.ts new file mode 100644 index 000000000..8f9d3b7f5 --- /dev/null +++ b/src/main/handlers/updater.ts @@ -0,0 +1,16 @@ +import { EVENTS } from '../../shared/events'; + +import { onMainEvent } from '../events'; +import type AppUpdater from '../updater'; + +/** + * Register IPC handlers for the application updater. + * + * @param appUpdater - The updater instance configured by the renderer's persisted settings. + */ +export function registerUpdaterHandlers(appUpdater: AppUpdater): void { + onMainEvent(EVENTS.UPDATE_SHOW_UPDATE_NOTIFICATIONS, (_, enabled) => { + appUpdater.setNotificationsEnabled(enabled); + void appUpdater.start(); + }); +} diff --git a/src/main/index.ts b/src/main/index.ts index c17891d54..3d403932c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -8,6 +8,7 @@ import { registerStorageHandlers, registerSystemHandlers, registerTrayHandlers, + registerUpdaterHandlers, } from './handlers'; import { TrayIcons } from './icons'; import { @@ -53,8 +54,6 @@ const appUpdater = new AppUpdater(mb, menuBuilder); app.whenReady().then(async () => { await onFirstRunMaybe(); - appUpdater.start(); - initializeAppLifecycle(mb, contextMenu, protocol); // Configure window event handlers (Escape key, DevTools resize) @@ -65,6 +64,7 @@ app.whenReady().then(async () => { registerSystemHandlers(mb); registerStorageHandlers(); registerAppHandlers(mb); + registerUpdaterHandlers(appUpdater); }); // Handle gitify:// custom protocol URL events for OAuth 2.0 callback diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 43c9babd0..dcd949a5d 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -32,6 +32,7 @@ vi.mock('electron-updater', () => ({ listeners[event].push(cb); return this; }), + checkForUpdates: vi.fn().mockResolvedValue(undefined), checkForUpdatesAndNotify: vi.fn().mockResolvedValue(undefined), quitAndInstall: vi.fn(), }, @@ -141,6 +142,18 @@ describe('main/updater.ts', () => { ); }); + it('reports a downloaded update in the menu without showing a dialog when notifications are disabled', async () => { + updater.setNotificationsEnabled(false); + + await updater.start(); + + emit('update-downloaded', { releaseName: 'v1.2.3' }); + + expect(dialog.showMessageBox).not.toHaveBeenCalled(); + expect(menuBuilder.setUpdateAvailableMenuVisibility).toHaveBeenCalledWith(false); + expect(menuBuilder.setUpdateReadyForInstallMenuVisibility).toHaveBeenCalledWith(true); + }); + it('invokes quitAndInstall when user clicks Restart', async () => { vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 0, // "Restart" button index @@ -187,6 +200,38 @@ describe('main/updater.ts', () => { expect(autoUpdater.checkForUpdatesAndNotify).not.toHaveBeenCalled(); }); + it('starts only once when settings updates arrive concurrently', async () => { + await Promise.all([updater.start(), updater.start()]); + + expect(autoUpdater.checkForUpdatesAndNotify).toHaveBeenCalledTimes(1); + }); + + it('checks silently when update notifications are disabled', async () => { + updater.setNotificationsEnabled(false); + + await updater.start(); + + expect(autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1); + expect(autoUpdater.checkForUpdatesAndNotify).not.toHaveBeenCalled(); + }); + + it('keeps silent update checks running on schedule when notifications are disabled', async () => { + vi.useFakeTimers(); + try { + updater.setNotificationsEnabled(false); + + await updater.start(); + expect(autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(APPLICATION.UPDATE_CHECK_INTERVAL_MS); + + expect(autoUpdater.checkForUpdates).toHaveBeenCalledTimes(2); + expect(autoUpdater.checkForUpdatesAndNotify).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it('handles checking-for-update', async () => { await updater.start(); diff --git a/src/main/updater.ts b/src/main/updater.ts index a41eacd9f..de85912fc 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -19,9 +19,14 @@ import type MenuBuilder from './menu'; export default class AppUpdater { private readonly menubar: Menubar; private readonly menuBuilder: MenuBuilder; + private notificationsEnabled = true; private started = false; private noUpdateMessageTimeout?: NodeJS.Timeout; + /** + * @param menubar - The menubar instance whose tray and window the updater reports status through. + * @param menuBuilder - The menu builder whose update menu items track the update state. + */ constructor(menubar: Menubar, menuBuilder: MenuBuilder) { this.menubar = menubar; this.menuBuilder = menuBuilder; @@ -30,6 +35,14 @@ export default class AppUpdater { autoUpdater.logger = null; } + /** + * Enable or suppress update notifications without changing update checks, + * downloads, or menubar state. + */ + setNotificationsEnabled(enabled: boolean): void { + this.notificationsEnabled = enabled; + } + /** * Start the updater: register event listeners, perform the initial update check, * and schedule periodic checks. Idempotent — safe to call multiple times. @@ -46,11 +59,10 @@ export default class AppUpdater { logInfo('app updater', 'Starting updater'); + this.started = true; this.registerListeners(); await this.performInitialCheck(); this.schedulePeriodicChecks(); - - this.started = true; } /** @@ -81,7 +93,9 @@ export default class AppUpdater { this.setTooltipWithStatus('A new update is ready to install'); this.menuBuilder.setUpdateAvailableMenuVisibility(false); this.menuBuilder.setUpdateReadyForInstallMenuVisibility(true); - this.showUpdateReadyDialog(event.releaseName ?? event.version); + if (this.notificationsEnabled) { + this.showUpdateReadyDialog(event.releaseName ?? event.version); + } }); autoUpdater.on('update-not-available', () => { @@ -115,7 +129,7 @@ export default class AppUpdater { private async performInitialCheck() { try { logInfo('app updater', 'Checking for updates on application launch'); - await autoUpdater.checkForUpdatesAndNotify(); + await this.checkForUpdates(); } catch (err) { logError('auto updater', 'Initial check failed', toError(err)); } @@ -128,7 +142,7 @@ export default class AppUpdater { const runScheduledCheck = async () => { try { logInfo('app updater', 'Checking for updates on a periodic schedule'); - await autoUpdater.checkForUpdatesAndNotify(); + await this.checkForUpdates(); } catch (e) { logError('auto updater', 'Scheduled check failed', toError(e)); } @@ -142,6 +156,18 @@ export default class AppUpdater { }, APPLICATION.UPDATE_CHECK_INTERVAL_MS); } + /** + * Check and download updates, using electron-updater's native notification + * only when the user has opted in. + */ + private async checkForUpdates() { + if (this.notificationsEnabled) { + return await autoUpdater.checkForUpdatesAndNotify(); + } + + return await autoUpdater.checkForUpdates(); + } + /** * Update the tray tooltip to show the application name alongside a status message. * diff --git a/src/preload/index.test.ts b/src/preload/index.test.ts index 994008279..954844cc2 100644 --- a/src/preload/index.test.ts +++ b/src/preload/index.test.ts @@ -63,6 +63,7 @@ class MockNotification { interface TestApi { tray: { updateColor: (n?: number, isOnline?: boolean) => void }; openExternalLink: (u: string, f: boolean) => void; + setShowUpdateNotifications: (value: boolean) => void; setUseX11Backend: (value: boolean) => void; app: { version: () => Promise; show?: () => void; hide?: () => void }; raiseNativeNotification: (t: string, b: string, u?: string) => unknown; @@ -119,6 +120,14 @@ describe('preload/index', () => { }); }); + it('setShowUpdateNotifications sends the preference to main', () => { + const api = getExposedApi(); + + api.setShowUpdateNotifications(false); + + expect(sendMainEventMock).toHaveBeenCalledWith(EVENTS.UPDATE_SHOW_UPDATE_NOTIFICATIONS, false); + }); + it('app.version returns dev in development', async () => { vi.stubEnv('NODE_ENV', 'development'); const api = getExposedApi(); diff --git a/src/preload/index.ts b/src/preload/index.ts index 6364cbd50..0160095e8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -63,6 +63,17 @@ export const api = { */ setKeepWindowOnBlur: (value: boolean) => sendMainEvent(EVENTS.UPDATE_KEEP_WINDOW_ON_BLUR, value), + /** + * Enable or suppress automatic update notifications. + * + * Update checks, downloads, and menubar status continue when notifications + * are suppressed. + * + * @param value - `true` to show update notifications, `false` to suppress them. + */ + setShowUpdateNotifications: (value: boolean) => + sendMainEvent(EVENTS.UPDATE_SHOW_UPDATE_NOTIFICATIONS, value), + /** * Persist whether Linux should run under the X11 backend. Applied at startup, * so the change only takes effect after the app is restarted. diff --git a/src/renderer/__helpers__/visual.setup.ts b/src/renderer/__helpers__/visual.setup.ts index 693759d46..3e05ceb49 100644 --- a/src/renderer/__helpers__/visual.setup.ts +++ b/src/renderer/__helpers__/visual.setup.ts @@ -109,6 +109,7 @@ function createGitifyBridgeApi(): Window['gitify'] { onSystemWake: vi.fn(() => vi.fn()), setAutoLaunch: vi.fn(), setKeepWindowOnBlur: vi.fn(), + setShowUpdateNotifications: vi.fn(), setUseX11Backend: vi.fn(), applyKeyboardShortcut: vi.fn().mockResolvedValue({ success: true }), raiseNativeNotification: vi.fn(), diff --git a/src/renderer/__helpers__/vitest.setup.ts b/src/renderer/__helpers__/vitest.setup.ts index 48535f3bc..b172ce9d5 100644 --- a/src/renderer/__helpers__/vitest.setup.ts +++ b/src/renderer/__helpers__/vitest.setup.ts @@ -107,6 +107,7 @@ function createGitifyBridgeApi(): Window['gitify'] { onSystemWake: vi.fn(() => vi.fn()), setAutoLaunch: vi.fn(), setKeepWindowOnBlur: vi.fn(), + setShowUpdateNotifications: vi.fn(), setUseX11Backend: vi.fn(), applyKeyboardShortcut: vi.fn().mockResolvedValue({ success: true }), raiseNativeNotification: vi.fn(), diff --git a/src/renderer/__mocks__/state-mocks.ts b/src/renderer/__mocks__/state-mocks.ts index 1f3de0afe..2bf7c5162 100644 --- a/src/renderer/__mocks__/state-mocks.ts +++ b/src/renderer/__mocks__/state-mocks.ts @@ -63,6 +63,7 @@ const mockSystemSettings: SystemSettingsState = { notificationVolume: 20 as Percentage, openAtStartup: false, keepWindowOnBlur: false, + showUpdateNotifications: true, useX11Backend: false, }; diff --git a/src/renderer/components/settings/SystemSettings.test.tsx b/src/renderer/components/settings/SystemSettings.test.tsx index 7d9b32e50..2a7dbb5e8 100644 --- a/src/renderer/components/settings/SystemSettings.test.tsx +++ b/src/renderer/components/settings/SystemSettings.test.tsx @@ -33,6 +33,7 @@ describe('renderer/components/settings/SystemSettings.tsx', () => { it.each([ ['checkbox-keyboardShortcut', 'keyboardShortcut'], ['checkbox-showNotifications', 'showNotifications'], + ['checkbox-showUpdateNotifications', 'showUpdateNotifications'], ['checkbox-openAtStartup', 'openAtStartup'], ['checkbox-keepWindowOnBlur', 'keepWindowOnBlur'], ] as const)('should toggle %s checkbox', async (testId, setting) => { diff --git a/src/renderer/components/settings/SystemSettings.tsx b/src/renderer/components/settings/SystemSettings.tsx index 57fd54f93..9df9823c7 100644 --- a/src/renderer/components/settings/SystemSettings.tsx +++ b/src/renderer/components/settings/SystemSettings.tsx @@ -51,6 +51,7 @@ export const SystemSettings: FC = () => { const notificationVolume = useSettingsStore((s) => s.notificationVolume); const keepWindowOnBlur = useSettingsStore((s) => s.keepWindowOnBlur); const openAtStartup = useSettingsStore((s) => s.openAtStartup); + const showUpdateNotifications = useSettingsStore((s) => s.showUpdateNotifications); const useX11Backend = useSettingsStore((s) => s.useX11Backend); const [recordingShortcut, setRecordingShortcut] = useState(false); @@ -345,6 +346,19 @@ export const SystemSettings: FC = () => { visible={!window.gitify.platform.isLinux()} /> + toggleSetting('showUpdateNotifications')} + tooltip={ + + Show a notification when a {APPLICATION.NAME} update is ready. Updates will still be + checked and shown in the menu bar. + + } + /> + should render itself & its children 1`] +
+ + + +
should render itself & its children 1`] data-wrap="nowrap" >