From 8d048dd8169a0e34c1e6247d18b2b7345467bb15 Mon Sep 17 00:00:00 2001 From: Nicholas Ochoa Date: Sun, 16 Aug 2026 17:19:50 -0500 Subject: [PATCH 1/2] feat(updater): add setting to disable automatic updates --- src/main/handlers/index.ts | 1 + src/main/handlers/updater.test.ts | 44 +++++++ src/main/handlers/updater.ts | 21 +++ src/main/index.ts | 6 +- src/main/menu.test.ts | 42 ++++++ src/main/menu.ts | 28 +++- src/main/updater.test.ts | 123 +++++++++++++++--- src/main/updater.ts | 87 ++++++++++++- src/preload/index.ts | 11 ++ src/renderer/__helpers__/visual.setup.ts | 1 + src/renderer/__helpers__/vitest.setup.ts | 1 + src/renderer/__mocks__/state-mocks.ts | 1 + .../settings/SystemSettings.test.tsx | 1 + .../components/settings/SystemSettings.tsx | 11 ++ .../__snapshots__/Settings.test.tsx.snap | 63 ++++++++- src/renderer/stores/defaults.ts | 1 + src/renderer/stores/subscriptions.test.ts | 7 + src/renderer/stores/subscriptions.ts | 11 ++ src/renderer/types.ts | 1 + src/renderer/utils/system/comms.ts | 9 ++ src/shared/events.ts | 5 + 21 files changed, 443 insertions(+), 32 deletions(-) create mode 100644 src/main/handlers/updater.test.ts create mode 100644 src/main/handlers/updater.ts 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..0b52553e7 --- /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 = { setEnabled: vi.fn() } as unknown as AppUpdater; + }); + + describe('registerUpdaterHandlers', () => { + it('registers expected updater IPC event handlers', () => { + registerUpdaterHandlers(appUpdater); + + const registeredEvents = onMock.mock.calls.map((call: unknown[]) => call[0]); + + expect(registeredEvents).toContain(EVENTS.UPDATE_AUTOMATIC_UPDATES); + }); + + it('toggles the updater when the renderer reports the setting', () => { + registerUpdaterHandlers(appUpdater); + + const listener = onMock.mock.calls.find( + (call: unknown[]) => call[0] === EVENTS.UPDATE_AUTOMATIC_UPDATES, + )?.[1] as (event: unknown, enabled: boolean) => void; + + listener(null, false); + expect(appUpdater.setEnabled).toHaveBeenCalledWith(false); + + listener(null, true); + expect(appUpdater.setEnabled).toHaveBeenCalledWith(true); + }); + }); +}); diff --git a/src/main/handlers/updater.ts b/src/main/handlers/updater.ts new file mode 100644 index 000000000..a6cfed5d0 --- /dev/null +++ b/src/main/handlers/updater.ts @@ -0,0 +1,21 @@ +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 driven by the renderer's automatic updates setting. + */ +export function registerUpdaterHandlers(appUpdater: AppUpdater): void { + /** + * Enable or disable automatic update checks and update notifications. + * + * The renderer sends the current value on startup, which is what starts the + * updater in the first place. + */ + onMainEvent(EVENTS.UPDATE_AUTOMATIC_UPDATES, (_, enabled: boolean) => { + appUpdater.setEnabled(enabled); + }); +} diff --git a/src/main/index.ts b/src/main/index.ts index 11eb6c3be..577046c49 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 { @@ -48,8 +49,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) @@ -60,6 +59,9 @@ app.whenReady().then(async () => { registerSystemHandlers(mb); registerStorageHandlers(); registerAppHandlers(mb); + + // The updater starts once the renderer reports the automatic updates setting + registerUpdaterHandlers(appUpdater); }); // Handle gitify:// custom protocol URL events for OAuth 2.0 callback diff --git a/src/main/menu.test.ts b/src/main/menu.test.ts index 66b78a791..5a8d05288 100644 --- a/src/main/menu.test.ts +++ b/src/main/menu.test.ts @@ -210,6 +210,48 @@ describe('main/menu.ts', () => { }); }); + describe('updateMenuVisibility', () => { + it('hides the update section and its separator', () => { + menuBuilder.setUpdateAvailableMenuVisibility(true); + + menuBuilder.setUpdateMenuVisibility(false); + + // oxlint-disable-next-line dot-notation -- This is a test + expect(menuBuilder['updatesSeparatorMenuItem'].visible).toBe(false); + // oxlint-disable-next-line dot-notation -- This is a test + expect(menuBuilder['checkForUpdatesMenuItem'].visible).toBe(false); + // oxlint-disable-next-line dot-notation -- This is a test + expect(menuBuilder['noUpdateAvailableMenuItem'].visible).toBe(false); + // oxlint-disable-next-line dot-notation -- This is a test + expect(menuBuilder['updateAvailableMenuItem'].visible).toBe(false); + // oxlint-disable-next-line dot-notation -- This is a test + expect(menuBuilder['updateReadyForInstallMenuItem'].visible).toBe(false); + }); + + it('shows the update section without revealing the status items', () => { + menuBuilder.setUpdateMenuVisibility(false); + + menuBuilder.setUpdateMenuVisibility(true); + + // oxlint-disable-next-line dot-notation -- This is a test + expect(menuBuilder['updatesSeparatorMenuItem'].visible).toBe(true); + // oxlint-disable-next-line dot-notation -- This is a test + expect(menuBuilder['checkForUpdatesMenuItem'].visible).toBe(true); + // oxlint-disable-next-line dot-notation -- This is a test + expect(menuBuilder['noUpdateAvailableMenuItem'].visible).toBe(false); + // oxlint-disable-next-line dot-notation -- This is a test + expect(menuBuilder['updateAvailableMenuItem'].visible).toBe(false); + // oxlint-disable-next-line dot-notation -- This is a test + expect(menuBuilder['updateReadyForInstallMenuItem'].visible).toBe(false); + }); + + it('republishes the menu so Linux picks up the visibility change', () => { + menuBuilder.setUpdateMenuVisibility(false); + + expect(menubar.refreshContextMenu).toHaveBeenCalled(); + }); + }); + describe('windowVisibilityMenuItems', () => { it('show item is visible by default; hide item is not', () => { const showCfg = getMenuItemConfigByLabel(`Show ${APPLICATION.NAME}`); diff --git a/src/main/menu.ts b/src/main/menu.ts index e14535ff0..de3b34794 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -12,6 +12,7 @@ import { openLogsDirectory, takeScreenshot } from './utils'; * MenuBuilder constructs the right-click context menu for the tray icon and provides methods to update menu item states. */ export default class MenuBuilder { + private readonly updatesSeparatorMenuItem: MenuItem; private readonly checkForUpdatesMenuItem: MenuItem; private readonly noUpdateAvailableMenuItem: MenuItem; private readonly updateAvailableMenuItem: MenuItem; @@ -28,6 +29,10 @@ export default class MenuBuilder { constructor(menubar: Menubar) { this.menubar = menubar; + this.updatesSeparatorMenuItem = new MenuItem({ + type: 'separator', + }); + this.checkForUpdatesMenuItem = new MenuItem({ label: 'Check for updates', enabled: true, @@ -81,7 +86,7 @@ export default class MenuBuilder { this.menu = Menu.buildFromTemplate([ this.showWindowMenuItem, this.hideWindowMenuItem, - { type: 'separator' }, + this.updatesSeparatorMenuItem, this.checkForUpdatesMenuItem, this.noUpdateAvailableMenuItem, this.updateAvailableMenuItem, @@ -204,4 +209,25 @@ export default class MenuBuilder { this.updateReadyForInstallMenuItem.visible = isVisible; this.refreshMenu(); } + + /** + * Show or hide the whole update section, including its separator. + * + * Showing the section only restores "Check for updates" — the status items + * stay hidden until an update event reveals them. + * + * @param isVisible - Whether the update section should be visible. + */ + setUpdateMenuVisibility(isVisible: boolean) { + this.updatesSeparatorMenuItem.visible = isVisible; + this.checkForUpdatesMenuItem.visible = isVisible; + + if (!isVisible) { + this.noUpdateAvailableMenuItem.visible = false; + this.updateAvailableMenuItem.visible = false; + this.updateReadyForInstallMenuItem.visible = false; + } + + this.refreshMenu(); + } } diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 43c9babd0..08a8fae51 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -34,6 +34,7 @@ vi.mock('electron-updater', () => ({ }), checkForUpdatesAndNotify: vi.fn().mockResolvedValue(undefined), quitAndInstall: vi.fn(), + autoInstallOnAppQuit: true, }, })); @@ -66,7 +67,7 @@ const emit = (event: string, arg?: ListenerArgs) => { }; // Re-import autoUpdater after mocking -import { autoUpdater } from 'electron-updater'; +import { autoUpdater, type UpdateCheckResult } from 'electron-updater'; describe('main/updater.ts', () => { let menubar: Menubar; @@ -75,6 +76,7 @@ describe('main/updater.ts', () => { public override setNoUpdateAvailableMenuVisibility = vi.fn(); public override setUpdateAvailableMenuVisibility = vi.fn(); public override setUpdateReadyForInstallMenuVisibility = vi.fn(); + public override setUpdateMenuVisibility = vi.fn(); } let menuBuilder: TestMenuBuilder; @@ -105,7 +107,7 @@ describe('main/updater.ts', () => { checkboxChecked: false, }); - await updater.start(); + await updater.setEnabled(true); // Simulate update downloaded event const releaseName = 'v1.2.3'; @@ -130,7 +132,7 @@ describe('main/updater.ts', () => { checkboxChecked: false, }); - await updater.start(); + await updater.setEnabled(true); emit('update-downloaded', { releaseName: null, version: '1.2.3' }); @@ -147,7 +149,7 @@ describe('main/updater.ts', () => { checkboxChecked: false, }); - await updater.start(); + await updater.setEnabled(true); emit('update-downloaded', { releaseName: 'v9.9.9' }); @@ -163,7 +165,7 @@ describe('main/updater.ts', () => { checkboxChecked: false, }); - await updater.start(); + await updater.setEnabled(true); emit('update-downloaded', { releaseName: 'v9.9.9' }); @@ -178,7 +180,7 @@ describe('main/updater.ts', () => { it('skips when app is not packaged', async () => { Object.defineProperty(menubar.app, 'isPackaged', { value: false }); - await updater.start(); + await updater.setEnabled(true); expect(logInfo).toHaveBeenCalledWith( 'app updater', @@ -188,7 +190,7 @@ describe('main/updater.ts', () => { }); it('handles checking-for-update', async () => { - await updater.start(); + await updater.setEnabled(true); emit('checking-for-update'); @@ -197,7 +199,7 @@ describe('main/updater.ts', () => { }); it('handles update-available', async () => { - await updater.start(); + await updater.setEnabled(true); emit('update-available'); @@ -208,7 +210,7 @@ describe('main/updater.ts', () => { }); it('handles download-progress', async () => { - await updater.start(); + await updater.setEnabled(true); emit('download-progress', { percent: 12.3456 }); @@ -216,7 +218,7 @@ describe('main/updater.ts', () => { }); it('handles update-not-available', async () => { - await updater.start(); + await updater.setEnabled(true); emit('update-not-available'); @@ -229,7 +231,7 @@ describe('main/updater.ts', () => { it('auto-hides "No updates available" after configured timeout', async () => { vi.useFakeTimers(); try { - await updater.start(); + await updater.setEnabled(true); emit('update-not-available'); @@ -247,7 +249,7 @@ describe('main/updater.ts', () => { it('clears pending hide timer when a new check starts', async () => { vi.useFakeTimers(); try { - await updater.start(); + await updater.setEnabled(true); emit('update-not-available'); @@ -269,7 +271,7 @@ describe('main/updater.ts', () => { }); it('handles update-cancelled (reset state)', async () => { - await updater.start(); + await updater.setEnabled(true); emit('update-cancelled'); @@ -278,7 +280,7 @@ describe('main/updater.ts', () => { }); it('handles error (reset + logError)', async () => { - await updater.start(); + await updater.setEnabled(true); const err = new Error('failure'); emit('error', err); @@ -290,7 +292,7 @@ describe('main/updater.ts', () => { it('keeps checking on schedule after an error', async () => { vi.useFakeTimers(); try { - await updater.start(); + await updater.setEnabled(true); // Let the first scheduled check run, which registers the interval await vi.advanceTimersByTimeAsync(APPLICATION.UPDATE_CHECK_INTERVAL_MS); @@ -317,7 +319,7 @@ describe('main/updater.ts', () => { return 0 as unknown as NodeJS.Timeout; }) as unknown as typeof setInterval); try { - await updater.start(); + await updater.setEnabled(true); // At minimum the initial check should have occurred const callCount = vi.mocked(autoUpdater.checkForUpdatesAndNotify).mock.calls.length; @@ -336,4 +338,93 @@ describe('main/updater.ts', () => { } }); }); + + describe('enabling and disabling', () => { + it('does not check for updates until enabled', () => { + expect(autoUpdater.checkForUpdatesAndNotify).not.toHaveBeenCalled(); + }); + + it('stops scheduled checks once disabled', async () => { + vi.useFakeTimers(); + try { + await updater.setEnabled(true); + + // Let the deferred first periodic check run, which registers the interval + await vi.advanceTimersByTimeAsync(APPLICATION.UPDATE_CHECK_INTERVAL_MS); + const callsWhileEnabled = vi.mocked(autoUpdater.checkForUpdatesAndNotify).mock.calls.length; + + await updater.setEnabled(false); + await vi.advanceTimersByTimeAsync(APPLICATION.UPDATE_CHECK_INTERVAL_MS * 3); + + expect(vi.mocked(autoUpdater.checkForUpdatesAndNotify).mock.calls.length).toBe( + callsWhileEnabled, + ); + } finally { + vi.useRealTimers(); + } + }); + + it('cancels an in-flight download when disabled', async () => { + const cancel = vi.fn(); + vi.mocked(autoUpdater.checkForUpdatesAndNotify).mockResolvedValueOnce({ + cancellationToken: { cancel }, + } as unknown as UpdateCheckResult); + + await updater.setEnabled(true); + await updater.setEnabled(false); + + expect(cancel).toHaveBeenCalled(); + }); + + it('prevents an already downloaded update from installing on quit when disabled', async () => { + await updater.setEnabled(true); + expect(autoUpdater.autoInstallOnAppQuit).toBe(true); + + await updater.setEnabled(false); + expect(autoUpdater.autoInstallOnAppQuit).toBe(false); + + await updater.setEnabled(true); + expect(autoUpdater.autoInstallOnAppQuit).toBe(true); + }); + + it('opts out of install on quit even when the updater never started', async () => { + await updater.setEnabled(false); + + expect(autoUpdater.autoInstallOnAppQuit).toBe(false); + expect(logInfo).not.toHaveBeenCalledWith('app updater', 'Stopping updater'); + }); + + it('hides the update menu section when disabled', async () => { + await updater.setEnabled(false); + expect(menuBuilder.setUpdateMenuVisibility).toHaveBeenCalledWith(false); + + await updater.setEnabled(true); + expect(menuBuilder.setUpdateMenuVisibility).toHaveBeenCalledWith(true); + }); + + it('clears update menu state when disabled', async () => { + await updater.setEnabled(true); + await updater.setEnabled(false); + + expect(menubar.tray.setToolTip).toHaveBeenCalledWith(APPLICATION.NAME); + expect(menuBuilder.setCheckForUpdatesMenuEnabled).toHaveBeenCalledWith(true); + expect(menuBuilder.setUpdateAvailableMenuVisibility).toHaveBeenCalledWith(false); + expect(menuBuilder.setUpdateReadyForInstallMenuVisibility).toHaveBeenCalledWith(false); + }); + + it('does not register duplicate listeners when re-enabled', async () => { + await updater.setEnabled(true); + await updater.setEnabled(false); + await updater.setEnabled(true); + + vi.mocked(dialog.showMessageBox).mockResolvedValue({ + response: 1, + checkboxChecked: false, + }); + + emit('update-downloaded', { releaseName: 'v1.2.3' }); + + expect(dialog.showMessageBox).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/main/updater.ts b/src/main/updater.ts index a41eacd9f..1613884b2 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,6 +1,6 @@ import { dialog, type MessageBoxOptions } from 'electron'; import type { Menubar } from 'electron-menubar'; -import { autoUpdater } from 'electron-updater'; +import { autoUpdater, type UpdateCheckResult } from 'electron-updater'; import { APPLICATION } from '../shared/constants'; import { logError, logInfo, toError } from '../shared/logger'; @@ -12,6 +12,8 @@ import type MenuBuilder from './menu'; * * Supports scheduled and manual updates for all platforms. * + * The updater stays idle until the renderer reports the "Automatic updates" setting via `setEnabled` + * * Documentation: https://www.electron.build/auto-update * * NOTE: previously we tried update-electron-app (Squirrel-focused, no Linux + NSIS) before migrating to electron-updater for cross-platform support. @@ -19,8 +21,13 @@ import type MenuBuilder from './menu'; export default class AppUpdater { private readonly menubar: Menubar; private readonly menuBuilder: MenuBuilder; + private enabled = false; private started = false; + private listenersRegistered = false; private noUpdateMessageTimeout?: NodeJS.Timeout; + private periodicCheckStartTimeout?: NodeJS.Timeout; + private periodicCheckInterval?: NodeJS.Timeout; + private updateCheckResult: UpdateCheckResult | null = null; constructor(menubar: Menubar, menuBuilder: MenuBuilder) { this.menubar = menubar; @@ -30,11 +37,27 @@ export default class AppUpdater { autoUpdater.logger = null; } + /** + * Enable or disable automatic update checks and update notifications. + * + * @param enabled - `true` to start checking for updates, `false` to stop. + */ + async setEnabled(enabled: boolean): Promise { + this.enabled = enabled; + this.menuBuilder.setUpdateMenuVisibility(enabled); + + if (enabled) { + await this.start(); + } else { + this.stop(); + } + } + /** * Start the updater: register event listeners, perform the initial update check, * and schedule periodic checks. Idempotent — safe to call multiple times. */ - async start(): Promise { + private async start(): Promise { if (this.started) { return; // idempotent } @@ -47,16 +70,55 @@ export default class AppUpdater { logInfo('app updater', 'Starting updater'); this.registerListeners(); + autoUpdater.autoInstallOnAppQuit = true; + this.started = true; + await this.performInitialCheck(); + + // The setting can be turned off while the initial check is in flight + if (!this.enabled) { + this.stop(); + return; + } + this.schedulePeriodicChecks(); + } - this.started = true; + /** + * Stop the updater: cancel scheduled checks, abort any in-flight download and + * clear all update-related UI state + */ + private stop(): void { + // Applied unconditionally: an update downloaded before this point would + // otherwise still install itself the next time the app quits. + autoUpdater.autoInstallOnAppQuit = false; + this.updateCheckResult?.cancellationToken?.cancel(); + this.updateCheckResult = null; + + if (!this.started) { + return; + } + + logInfo('app updater', 'Stopping updater'); + + clearTimeout(this.periodicCheckStartTimeout); + clearInterval(this.periodicCheckInterval); + this.periodicCheckStartTimeout = undefined; + this.periodicCheckInterval = undefined; + + this.resetState(); + this.started = false; } /** * Attach all electron-updater event listeners and wire them to menu state setters. */ private registerListeners() { + if (this.listenersRegistered) { + return; + } + this.listenersRegistered = true; + autoUpdater.on('checking-for-update', () => { logInfo('auto updater', 'Checking for update'); this.menuBuilder.setCheckForUpdatesMenuEnabled(false); @@ -115,7 +177,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 +190,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)); } @@ -136,12 +198,23 @@ export default class AppUpdater { // Defer the first periodic check until after the interval elapses. // This avoids an immediate duplicate check on startup. - setTimeout(async () => { + this.periodicCheckStartTimeout = setTimeout(async () => { await runScheduledCheck(); - setInterval(runScheduledCheck, APPLICATION.UPDATE_CHECK_INTERVAL_MS); + this.periodicCheckInterval = setInterval( + runScheduledCheck, + APPLICATION.UPDATE_CHECK_INTERVAL_MS, + ); }, APPLICATION.UPDATE_CHECK_INTERVAL_MS); } + /** + * Check for updates, retaining the result so an in-flight download can be + * cancelled if automatic updates are turned off. + */ + private async checkForUpdates() { + this.updateCheckResult = (await autoUpdater.checkForUpdatesAndNotify()) ?? null; + } + /** * Update the tray tooltip to show the application name alongside a status message. * diff --git a/src/preload/index.ts b/src/preload/index.ts index 123527307..cec724645 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 disable automatic update checks and update notifications. + * + * The main process owns the updater but has no access to the renderer's + * persisted settings, so the renderer pushes this value on startup and on + * every change. Nothing is checked until the renderer enables it. + * + * @param value - `true` to allow automatic updates, `false` to disable them. + */ + setAutomaticUpdates: (value: boolean) => sendMainEvent(EVENTS.UPDATE_AUTOMATIC_UPDATES, value), + /** * Enable or disable the macOS window vibrancy material for Glass. Resolves once * the material has been applied so the renderer can order the visual switch. diff --git a/src/renderer/__helpers__/visual.setup.ts b/src/renderer/__helpers__/visual.setup.ts index cff711fa5..01eb4e78b 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(), + setAutomaticUpdates: 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 0b4e1bb2c..d6ae31e1b 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(), + setAutomaticUpdates: 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 4e0a7eea3..a456b8a5b 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, + automaticUpdates: true, }; export const mockSettings: SettingsState = { diff --git a/src/renderer/components/settings/SystemSettings.test.tsx b/src/renderer/components/settings/SystemSettings.test.tsx index 26faabd26..7ac675d76 100644 --- a/src/renderer/components/settings/SystemSettings.test.tsx +++ b/src/renderer/components/settings/SystemSettings.test.tsx @@ -35,6 +35,7 @@ describe('renderer/components/settings/SystemSettings.tsx', () => { ['checkbox-showNotifications', 'showNotifications'], ['checkbox-openAtStartup', 'openAtStartup'], ['checkbox-keepWindowOnBlur', 'keepWindowOnBlur'], + ['checkbox-automaticUpdates', 'automaticUpdates'], ] as const)('should toggle %s checkbox', async (testId, setting) => { await act(async () => { renderWithProviders(); diff --git a/src/renderer/components/settings/SystemSettings.tsx b/src/renderer/components/settings/SystemSettings.tsx index 3f06b3ee0..65abb56cc 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 automaticUpdates = useSettingsStore((s) => s.automaticUpdates); const [recordingShortcut, setRecordingShortcut] = useState(false); const [liveModifierAccelerator, setLiveModifierAccelerator] = useState(''); @@ -343,6 +344,16 @@ export const SystemSettings: FC = () => { tooltip={Launch {APPLICATION.NAME} automatically at startup.} visible={!window.gitify.platform.isLinux()} /> + + toggleSetting('automaticUpdates')} + tooltip={ + Check for new {APPLICATION.NAME} releases and install them automatically. + } + /> ); diff --git a/src/renderer/routes/__snapshots__/Settings.test.tsx.snap b/src/renderer/routes/__snapshots__/Settings.test.tsx.snap index 7e6fdccca..f7959034d 100644 --- a/src/renderer/routes/__snapshots__/Settings.test.tsx.snap +++ b/src/renderer/routes/__snapshots__/Settings.test.tsx.snap @@ -2242,6 +2242,57 @@ exports[`renderer/routes/Settings.tsx > should render itself & its children 1`] +
+ + + +
should render itself & its children 1`] data-wrap="nowrap" >
-
- - - -
should render itself & its children 1`] data-wrap="nowrap" >