diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d39d94ab9..86bb0c29b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -258,6 +258,14 @@ Four settings are deliberate non-choices: `scripts/verifyFuses.js` reads the fuse wire back out of the built artifacts and fails when it does not match the expected posture. The expected set is a constant in that script rather than read from this configuration, so removing the configuration fails the build instead of passing vacuously. The build and publish workflows run it after packaging, because a missing `electronFuses` block is otherwise a silent no-op: electron-builder simply leaves the Electron defaults in place. +#### 🔄 Updates for packaged installations + +Gitify updates itself by default. Automatic updates default to off only where a package manager owns updates: Scoop (detected from its installation metadata), Flatpak, and Snap. + +Package maintainers can select the same default by placing an empty `disable-auto-updates` file in the application's resources directory (Electron's `process.resourcesPath`) or by setting `GITIFY_DISABLE_AUTO_UPDATE=1` in the launcher. Packages that run on a system Electron should use the environment variable. + +Users can override the default under Settings > System > Automatic updates, and the choice persists across restarts. Turning updates off stops scheduled checks, cancels active downloads, and prevents installation on quit. The tray menu then offers **View releases** instead of **Check for updates**. + ### 🖌️ Design Guidelines 1. Use sentence case where possible diff --git a/src/__helpers__/gitify-bridge.ts b/src/__helpers__/gitify-bridge.ts index 80d721394..4a49820a7 100644 --- a/src/__helpers__/gitify-bridge.ts +++ b/src/__helpers__/gitify-bridge.ts @@ -45,7 +45,8 @@ export function createGitifyBridgeApi(platform: TestPlatform = 'macos'): Window[ onZoom: vi.fn(() => vi.fn()), setAutoLaunch: vi.fn(), setKeepWindowOnBlur: vi.fn(), - setShowUpdateNotifications: vi.fn(), + setUpdatePreferences: vi.fn(), + getUpdateManager: vi.fn().mockResolvedValue(null), setUseX11Backend: vi.fn(), applyKeyboardShortcut: vi.fn().mockResolvedValue({ success: true }), nativeNotifications: { diff --git a/src/main/handlers/updater.test.ts b/src/main/handlers/updater.test.ts index 9b1398787..5fb04d83f 100644 --- a/src/main/handlers/updater.test.ts +++ b/src/main/handlers/updater.test.ts @@ -1,44 +1,69 @@ -import { EVENTS } from '../../shared/events'; +import { EVENTS, type UpdateManager } from '../../shared/events'; -import type AppUpdater from '../updater'; +import { handleMainEvent } from '../events'; +import { detectUpdateManager } from '../update-manager'; import { registerUpdaterHandlers } from './updater'; -const onMock = vi.fn(); - -vi.mock('electron', () => ({ - ipcMain: { - on: (...args: unknown[]) => onMock(...args), - } satisfies Pick, +let applyPreferences: (event: unknown, preferences: unknown) => Promise; +vi.mock('../events', () => ({ + handleMainEvent: vi.fn(), + onMainEvent: vi.fn((_event: string, listener: typeof applyPreferences) => { + applyPreferences = listener; + }), })); +vi.mock('../update-manager', () => ({ detectUpdateManager: vi.fn() })); +vi.mock('../../shared/logger', () => ({ logError: vi.fn(), toError: (error: unknown) => error })); -describe('main/handlers/updater.ts', () => { - let appUpdater: AppUpdater; +const appUpdater = { + setNotificationsEnabled: vi.fn(), + setAutomaticUpdatesEnabled: vi.fn().mockResolvedValue(undefined), +}; +describe('updater preferences', () => { beforeEach(() => { - appUpdater = { - setNotificationsEnabled: vi.fn(), - start: vi.fn().mockResolvedValue(undefined), - } as unknown as AppUpdater; + vi.mocked(detectUpdateManager).mockResolvedValue(null); }); - it('registers the update notification preference handler', () => { + it.each<[string, UpdateManager | null, boolean]>([ + ['default', null, true], + ['default', 'Flatpak', false], + ['enabled', 'Scoop', true], + ['disabled', null, false], + ])('applies %s with manager %s as %s', async (automaticUpdates, manager, enabled) => { + vi.mocked(detectUpdateManager).mockResolvedValue(manager); registerUpdaterHandlers(appUpdater); - - expect(onMock.mock.calls.map((call: unknown[]) => call[0])).toContain( - EVENTS.UPDATE_SHOW_UPDATE_NOTIFICATIONS, - ); + await applyPreferences(null, { automaticUpdates, showUpdateNotifications: false }); + expect(appUpdater.setAutomaticUpdatesEnabled).toHaveBeenCalledWith(enabled); + expect(appUpdater.setNotificationsEnabled).toHaveBeenCalledWith(false); + expect(handleMainEvent).toHaveBeenCalledWith(EVENTS.UPDATE_MANAGER, expect.any(Function)); }); - it.each([false, true])('applies the preference and starts update checks for %s', (enabled) => { + it('waits for detection and only applies the latest preference', async () => { + const detection = Promise.withResolvers(); + vi.mocked(detectUpdateManager).mockReturnValue(detection.promise); registerUpdaterHandlers(appUpdater); + const first = applyPreferences(null, { + automaticUpdates: 'default', + showUpdateNotifications: true, + }); + const second = applyPreferences(null, { + automaticUpdates: 'disabled', + showUpdateNotifications: false, + }); + expect(appUpdater.setAutomaticUpdatesEnabled).not.toHaveBeenCalled(); + detection.resolve(null); + await Promise.all([first, second]); + expect(appUpdater.setAutomaticUpdatesEnabled).toHaveBeenCalledExactlyOnceWith(false); + }); - 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); + it.each([ + null, + {}, + { automaticUpdates: 'sometimes', showUpdateNotifications: true }, + { automaticUpdates: 'enabled', showUpdateNotifications: 'yes' }, + ])('rejects malformed preferences %j', async (preferences) => { + registerUpdaterHandlers(appUpdater); + await applyPreferences(null, preferences); + expect(appUpdater.setAutomaticUpdatesEnabled).not.toHaveBeenCalled(); }); }); diff --git a/src/main/handlers/updater.ts b/src/main/handlers/updater.ts index 8f9d3b7f5..698596268 100644 --- a/src/main/handlers/updater.ts +++ b/src/main/handlers/updater.ts @@ -1,6 +1,8 @@ -import { EVENTS } from '../../shared/events'; +import { EVENTS, isAutomaticUpdates } from '../../shared/events'; +import { logError, toError } from '../../shared/logger'; -import { onMainEvent } from '../events'; +import { handleMainEvent, onMainEvent } from '../events'; +import { detectUpdateManager } from '../update-manager'; import type AppUpdater from '../updater'; /** @@ -8,9 +10,39 @@ import type AppUpdater from '../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(); +export function registerUpdaterHandlers( + appUpdater: Pick, +): void { + const manager = detectUpdateManager({ + platform: process.platform, + executablePath: process.execPath, + resourcesPath: process.resourcesPath, + env: process.env, + }).catch((error: unknown) => { + logError('update manager', 'Installation detection failed', toError(error)); + return null; + }); + handleMainEvent(EVENTS.UPDATE_MANAGER, () => manager); + + let revision = 0; + onMainEvent(EVENTS.UPDATE_PREFERENCES, async (_, preferences) => { + if ( + !preferences || + !isAutomaticUpdates(preferences.automaticUpdates) || + typeof preferences.showUpdateNotifications !== 'boolean' + ) { + logError('app updater', 'Invalid update preferences', new Error('Invalid IPC payload')); + return; + } + const currentRevision = ++revision; + const detectedManager = await manager; + if (currentRevision !== revision) { + return; + } + appUpdater.setNotificationsEnabled(preferences.showUpdateNotifications); + await appUpdater.setAutomaticUpdatesEnabled( + preferences.automaticUpdates === 'enabled' || + (preferences.automaticUpdates === 'default' && detectedManager === null), + ); }); } diff --git a/src/main/menu.test.ts b/src/main/menu.test.ts index c4cbbaf70..8d341f2b7 100644 --- a/src/main/menu.test.ts +++ b/src/main/menu.test.ts @@ -1,6 +1,5 @@ import { Menu, shell } from 'electron'; import type { Menubar } from 'electron-menubar'; -import { autoUpdater } from 'electron-updater'; import type { Mock } from 'vite-plus/test'; @@ -21,9 +20,10 @@ const menuItemInstances: Array<{ vi.mock('electron', () => { class MockMenuItem { + label?: string; constructor(opts: Record) { Object.assign(this, opts); - menuItemInstances.push(opts as (typeof menuItemInstances)[number]); + menuItemInstances.push(this); } } return { @@ -37,13 +37,6 @@ vi.mock('electron', () => { }; }); -vi.mock('electron-updater', () => ({ - autoUpdater: { - checkForUpdatesAndNotify: vi.fn(async () => undefined), - quitAndInstall: vi.fn(), - }, -})); - const logErrorMock = vi.fn(); vi.mock('../shared/logger', () => ({ logError: (...args: unknown[]) => logErrorMock(...args), @@ -66,6 +59,7 @@ vi.mock('../shared/platform', () => ({ describe('main/menu.ts', () => { let menubar: Menubar; let menuBuilder: MenuBuilder; + const install = vi.fn(); /** Helper: find MenuItem config captured via our tracking array by label */ const getMenuItemConfigByLabel = (label: string) => @@ -111,10 +105,10 @@ describe('main/menu.ts', () => { describe('checkForUpdatesMenuItem', () => { it('default menu configuration', () => { - const config = getMenuItemConfigByLabel('Check for updates'); + const config = getMenuItemConfigByLabel('View releases'); expect(config).toBeDefined(); - expect(config?.label).toBe('Check for updates'); + expect(config?.label).toBe('View releases'); expect(config?.enabled).toBe(true); expect(config?.click).toEqual(expect.any(Function)); }); @@ -253,22 +247,39 @@ describe('main/menu.ts', () => { }); describe('click handlers', () => { - it('invokes autoUpdater.checkForUpdatesAndNotify when clicking "Check for updates"', () => { + it('runs the update check when clicking "Check for updates"', () => { + const check = vi.fn().mockResolvedValue(undefined); + menuBuilder.setUpdateActions({ check, install }); + menuBuilder.setAutomaticUpdatesEnabled(true); const cfg = getMenuItemConfigByLabel('Check for updates'); expect(cfg).toBeDefined(); cfg?.click?.(); - expect(autoUpdater.checkForUpdatesAndNotify).toHaveBeenCalled(); + expect(check).toHaveBeenCalled(); }); - it('invokes autoUpdater.quitAndInstall when clicking "Restart to install update"', () => { + it('requests installation when clicking "Restart to install update"', () => { + menuBuilder.setUpdateActions({ check: vi.fn().mockResolvedValue(undefined), install }); + menuBuilder.setAutomaticUpdatesEnabled(true); const cfg = getMenuItemConfigByLabel('Restart to install update'); expect(cfg).toBeDefined(); cfg?.click?.(); - expect(autoUpdater.quitAndInstall).toHaveBeenCalled(); + expect(install).toHaveBeenCalled(); + }); + + it('opens releases and blocks installation while automatic updates are off', () => { + const check = vi.fn().mockResolvedValue(undefined); + menuBuilder.setUpdateActions({ check, install }); + getMenuItemConfigByLabel('View releases')?.click?.(); + getMenuItemConfigByLabel('Restart to install update')?.click?.(); + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://github.com/gitify-app/gitify/releases/latest', + ); + expect(check).not.toHaveBeenCalled(); + expect(install).not.toHaveBeenCalled(); }); it('developer submenu click actions execute expected functions', () => { @@ -309,18 +320,15 @@ describe('main/menu.ts', () => { expect(menubar.app.quit).toHaveBeenCalled(); }); - it('logs an error when checking for updates fails', async () => { - const cfg = getMenuItemConfigByLabel('Check for updates'); - vi.mocked(autoUpdater.checkForUpdatesAndNotify).mockRejectedValueOnce( - new Error('update failed'), - ); + it('logs an error when opening releases fails', async () => { + vi.mocked(shell.openExternal).mockRejectedValueOnce(new Error('open failed')); - cfg?.click?.(); + getMenuItemConfigByLabel('View releases')?.click?.(); await vi.waitFor(() => expect(logErrorMock).toHaveBeenCalledWith( 'menu', - 'Failed to check for updates', + 'Failed to check for updates or open releases', expect.any(Error), ), ); diff --git a/src/main/menu.ts b/src/main/menu.ts index 58eb93728..49b7032c8 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -1,6 +1,5 @@ import { Menu, MenuItem, shell } from 'electron'; import type { Menubar } from 'electron-menubar'; -import { autoUpdater } from 'electron-updater'; import { APPLICATION } from '../shared/constants'; import { logError, toError } from '../shared/logger'; @@ -22,6 +21,9 @@ export default class MenuBuilder { private readonly menubar: Menubar; private menu?: Menu; + private automaticUpdatesEnabled = false; + private checkForUpdates = async (): Promise => {}; + private installUpdate = (): void => {}; /** * @param menubar - The menubar instance used for window and app interactions within menu actions. @@ -30,11 +32,16 @@ export default class MenuBuilder { this.menubar = menubar; this.checkForUpdatesMenuItem = new MenuItem({ - label: 'Check for updates', + label: 'View releases', enabled: true, click: () => { - autoUpdater.checkForUpdatesAndNotify().catch((err) => { - logError('menu', 'Failed to check for updates', toError(err)); + const check = this.automaticUpdatesEnabled + ? this.checkForUpdates() + : shell.openExternal( + `${APPLICATION.GITHUB_BASE_URL}/${APPLICATION.REPO_SLUG}/releases/latest`, + ); + void check?.catch((error: unknown) => { + logError('menu', 'Failed to check for updates or open releases', toError(error)); }); }, }); @@ -56,7 +63,9 @@ export default class MenuBuilder { enabled: true, visible: false, click: () => { - autoUpdater.quitAndInstall(); + if (this.automaticUpdatesEnabled) { + this.installUpdate(); + } }, }); @@ -175,6 +184,27 @@ export default class MenuBuilder { this.menubar.refreshContextMenu(); } + /** + * Connect the update menu items to the updater. + * + * @param actions - Callbacks that check for updates and install a downloaded update. + */ + setUpdateActions(actions: { check: () => Promise; install: () => void }) { + this.checkForUpdates = actions.check; + this.installUpdate = actions.install; + } + + /** + * Switch the update menu item between checking for updates and viewing releases. + * + * @param enabled - Whether Gitify handles updates itself. + */ + setAutomaticUpdatesEnabled(enabled: boolean) { + this.automaticUpdatesEnabled = enabled; + this.checkForUpdatesMenuItem.label = enabled ? 'Check for updates' : 'View releases'; + this.refreshMenu(); + } + /** * Enable or disable the "Check for updates" menu item. * Disabled while an update check is in progress. diff --git a/src/main/update-manager.test.ts b/src/main/update-manager.test.ts new file mode 100644 index 000000000..662deba55 --- /dev/null +++ b/src/main/update-manager.test.ts @@ -0,0 +1,71 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { detectUpdateManager } from './update-manager'; + +vi.mock('../shared/logger', () => ({ logError: vi.fn(), toError: (error: unknown) => error })); + +describe('installation update manager', () => { + let directory: string; + beforeEach(async () => { + directory = await mkdtemp(path.join(os.tmpdir(), 'gitify-install-')); + await mkdir(path.join(directory, 'resources')); + await writeFile(path.join(directory, 'Gitify.exe'), ''); + }); + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + const installation = (platform: NodeJS.Platform = 'linux', env: NodeJS.ProcessEnv = {}) => ({ + platform, + env, + executablePath: path.join(directory, 'Gitify.exe'), + resourcesPath: path.join(directory, 'resources'), + }); + + it.each(['scoop-', ''])( + 'detects Scoop using %smetadata beside the running executable', + async (prefix) => { + await writeFile( + path.join(directory, `${prefix}install.json`), + JSON.stringify({ architecture: '64bit', bucket: 'extras' }), + ); + await writeFile( + path.join(directory, `${prefix}manifest.json`), + JSON.stringify({ version: '7.8.0' }), + ); + expect(await detectUpdateManager(installation('win32'))).toBe('Scoop'); + }, + ); + + it('ignores malformed or unrelated Scoop metadata', async () => { + await writeFile(path.join(directory, 'install.json'), '{}'); + await writeFile(path.join(directory, 'manifest.json'), 'not json'); + expect(await detectUpdateManager(installation('win32'))).toBeNull(); + }); + + it.each([ + [{ FLATPAK_ID: 'io.gitify.Gitify' }, 'Flatpak'], + [{ SNAP: '/snap/gitify/current', SNAP_NAME: 'gitify' }, 'Snap'], + [ + { FLATPAK_ID: 'io.gitify.Gitify', SNAP: '/snap/gitify/current', SNAP_NAME: 'gitify' }, + 'Flatpak', + ], + [{ GITIFY_DISABLE_AUTO_UPDATE: '1' }, 'packager'], + ])('detects a managed runtime from %j', async (env, manager) => { + expect(await detectUpdateManager(installation('linux', env))).toBe(manager); + }); + + it('honors a packager marker on any platform', async () => { + await writeFile(path.join(directory, 'resources', 'disable-auto-updates'), ''); + expect(await detectUpdateManager(installation('darwin'))).toBe('packager'); + }); + + it.each(['darwin', 'win32', 'linux'] satisfies NodeJS.Platform[])( + 'keeps the default for an unmanaged %s installation', + async (platform) => { + expect(await detectUpdateManager(installation(platform))).toBeNull(); + }, + ); +}); diff --git a/src/main/update-manager.ts b/src/main/update-manager.ts new file mode 100644 index 000000000..1c48a13a9 --- /dev/null +++ b/src/main/update-manager.ts @@ -0,0 +1,105 @@ +import { access, readFile, realpath } from 'node:fs/promises'; +import path from 'node:path'; + +import type { UpdateManager } from '../shared/events'; +import { logError, toError } from '../shared/logger'; + +interface Installation { + platform: NodeJS.Platform; + executablePath: string; + resourcesPath: string; + env: NodeJS.ProcessEnv; +} + +async function exists(file: string): Promise { + try { + await access(file); + return true; + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) { + logError('update manager', `Cannot inspect ${file}`, toError(error)); + } + return false; + } +} + +async function readMetadata(file: string): Promise { + if (!(await exists(file))) { + return null; + } + try { + return JSON.parse(await readFile(file, 'utf8')); + } catch (error) { + logError('update manager', `Cannot read ${file}`, toError(error)); + return null; + } +} + +async function detectLinuxUpdateManager({ + env, +}: Pick): Promise { + if (env.FLATPAK_ID || (await exists('/.flatpak-info'))) { + return 'Flatpak'; + } + if (env.SNAP && env.SNAP_NAME) { + return 'Snap'; + } + return null; +} + +async function detectWindowsUpdateManager({ + executablePath, +}: Pick): Promise { + const directory = path.dirname(await realpath(executablePath)); + for (const prefix of ['scoop-', '']) { + const install = await readMetadata(path.join(directory, `${prefix}install.json`)); + const manifest = await readMetadata(path.join(directory, `${prefix}manifest.json`)); + if ( + install && + typeof install === 'object' && + 'architecture' in install && + typeof install.architecture === 'string' && + manifest && + typeof manifest === 'object' && + 'version' in manifest && + typeof manifest.version === 'string' + ) { + return 'Scoop'; + } + } + + return null; +} + +/** + * Detect a package manager that owns updates for this installation. + * + * Only Scoop, Flatpak, and Snap are detected; other installations, including + * deb, rpm, and Homebrew packages, keep Gitify's own updater. Packagers can opt + * in with a `disable-auto-updates` resource marker or `GITIFY_DISABLE_AUTO_UPDATE=1`. + * + * @param installation - The running installation's platform, paths, and environment. + * @returns The package manager that owns updates, or `null` when Gitify updates itself. + */ +export async function detectUpdateManager( + installation: Installation, +): Promise { + const { platform, resourcesPath, env } = installation; + + if ( + env.GITIFY_DISABLE_AUTO_UPDATE === '1' || + (await exists(path.join(resourcesPath, 'disable-auto-updates'))) + ) { + return 'packager'; + } + + if (platform === 'linux') { + return detectLinuxUpdateManager(installation); + } + + if (platform === 'win32') { + return detectWindowsUpdateManager(installation); + } + + return null; +} diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 5cc746060..054786f1e 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -3,6 +3,9 @@ import type { Menubar } from 'electron-menubar'; import { APPLICATION } from '../shared/constants'; import { logError, logInfo } from '../shared/logger'; +import { isMacOS } from '../shared/platform'; + +vi.mock('../shared/platform', () => ({ isMacOS: vi.fn() })); vi.mock('../shared/logger', async (importOriginal) => { const actual = await importOriginal(); @@ -26,6 +29,8 @@ type ListenerArgs = UpdateDownloadedEvent | object | undefined; type Listener = (arg: ListenerArgs) => void; type ListenerMap = Record; const listeners: ListenerMap = {}; +const notify = vi.fn(); +let beforeQuit: (event: { preventDefault: () => void }) => void; vi.mock('electron-updater', () => ({ autoUpdater: { @@ -37,7 +42,6 @@ vi.mock('electron-updater', () => ({ return this; }), checkForUpdates: vi.fn().mockResolvedValue(undefined), - checkForUpdatesAndNotify: vi.fn().mockResolvedValue(undefined), quitAndInstall: vi.fn(), }, })); @@ -53,6 +57,9 @@ vi.mock('electron', () => { dialog: { showMessageBox: vi.fn(), } satisfies Pick, + Notification: class { + show = notify; + }, MenuItem, Menu: { buildFromTemplate: vi.fn(), @@ -73,9 +80,12 @@ const emit = (event: string, arg?: ListenerArgs) => { // Re-import autoUpdater after mocking import { autoUpdater } from 'electron-updater'; +import { CancellationToken } from 'electron-updater/out/types'; + describe('main/updater.ts', () => { let menubar: Menubar; class TestMenuBuilder extends MenuBuilder { + public override setAutomaticUpdatesEnabled = vi.fn(); public override setCheckForUpdatesMenuEnabled = vi.fn(); public override setNoUpdateAvailableMenuVisibility = vi.fn(); public override setUpdateAvailableMenuVisibility = vi.fn(); @@ -86,6 +96,7 @@ describe('main/updater.ts', () => { let updater: AppUpdater; beforeEach(() => { + vi.mocked(isMacOS).mockReturnValue(false); for (const k of Object.keys(listeners)) { delete listeners[k]; } @@ -93,8 +104,9 @@ describe('main/updater.ts', () => { menubar = { app: { isPackaged: true, - // updater.initialize is now only called after app is ready externally - on: vi.fn(), + on: vi.fn((_event: string, callback: typeof beforeQuit) => { + beforeQuit = callback; + }), }, tray: { setToolTip: vi.fn() }, } as unknown as Menubar; @@ -103,6 +115,94 @@ describe('main/updater.ts', () => { updater = new AppUpdater(menubar, menuBuilder); }); + afterEach(async () => { + await updater.setAutomaticUpdatesEnabled(false); + }); + + it('keeps updating disabled until preferences arrive', () => { + expect(autoUpdater.autoDownload).toBe(false); + expect(autoUpdater.autoInstallOnAppQuit).toBe(false); + expect(autoUpdater.checkForUpdates).not.toHaveBeenCalled(); + }); + + it('defers macOS native installation until quitting while updates are enabled', async () => { + vi.mocked(isMacOS).mockReturnValue(true); + updater.setNotificationsEnabled(false); + await updater.setAutomaticUpdatesEnabled(true); + expect(autoUpdater.autoInstallOnAppQuit).toBe(false); + emit('update-downloaded', { version: '9.9.9' }); + expect(autoUpdater.quitAndInstall).not.toHaveBeenCalled(); + const event = { preventDefault: vi.fn() }; + beforeQuit(event); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(autoUpdater.quitAndInstall).toHaveBeenCalledOnce(); + expect(autoUpdater.autoRunAppAfterInstall).toBe(false); + beforeQuit(event); + expect(autoUpdater.quitAndInstall).toHaveBeenCalledOnce(); + }); + + it('allows quitting macOS without installing an already downloaded update when disabled', async () => { + vi.mocked(isMacOS).mockReturnValue(true); + updater.setNotificationsEnabled(false); + await updater.setAutomaticUpdatesEnabled(true); + emit('update-downloaded', { version: '9.9.9' }); + await updater.setAutomaticUpdatesEnabled(false); + const event = { preventDefault: vi.fn() }; + beforeQuit(event); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(autoUpdater.quitAndInstall).not.toHaveBeenCalled(); + }); + + it('allows macOS to quit after a native installation failure', async () => { + vi.mocked(isMacOS).mockReturnValue(true); + updater.setNotificationsEnabled(false); + await updater.setAutomaticUpdatesEnabled(true); + emit('update-downloaded', { version: '9.9.9' }); + beforeQuit({ preventDefault: vi.fn() }); + emit('error', new Error('Native install failed')); + const retryQuit = { preventDefault: vi.fn() }; + beforeQuit(retryQuit); + expect(retryQuit.preventDefault).not.toHaveBeenCalled(); + expect(autoUpdater.quitAndInstall).toHaveBeenCalledOnce(); + }); + + it('keeps a downloaded macOS update installable after a later check fails', async () => { + vi.mocked(isMacOS).mockReturnValue(true); + updater.setNotificationsEnabled(false); + await updater.setAutomaticUpdatesEnabled(true); + emit('update-downloaded', { version: '9.9.9' }); + emit('error', new Error('Network unavailable')); + expect(menuBuilder.setUpdateReadyForInstallMenuVisibility).toHaveBeenLastCalledWith(true); + const event = { preventDefault: vi.fn() }; + beforeQuit(event); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(autoUpdater.quitAndInstall).toHaveBeenCalledOnce(); + }); + + it('drops the ready state when a replacement download fails', async () => { + vi.mocked(isMacOS).mockReturnValue(true); + updater.setNotificationsEnabled(false); + await updater.setAutomaticUpdatesEnabled(true); + emit('update-downloaded', { version: '9.9.9' }); + emit('update-available', { version: '10.0.0' }); + emit('error', new Error('Download failed')); + const event = { preventDefault: vi.fn() }; + beforeQuit(event); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(autoUpdater.quitAndInstall).not.toHaveBeenCalled(); + }); + + it('keeps the ready state when a check reuses the cached download', async () => { + vi.mocked(isMacOS).mockReturnValue(true); + updater.setNotificationsEnabled(false); + await updater.setAutomaticUpdatesEnabled(true); + emit('update-downloaded', { version: '9.9.9' }); + emit('update-available', { version: '9.9.9' }); + emit('update-downloaded', { version: '9.9.9' }); + beforeQuit({ preventDefault: vi.fn() }); + expect(autoUpdater.quitAndInstall).toHaveBeenCalledOnce(); + }); + describe('update available dialog', () => { it('shows dialog with expected message and does NOT install when user chooses Later', async () => { vi.mocked(dialog.showMessageBox).mockResolvedValue({ @@ -110,7 +210,7 @@ describe('main/updater.ts', () => { checkboxChecked: false, }); - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); // Simulate update downloaded event const releaseName = 'v1.2.3'; @@ -135,7 +235,7 @@ describe('main/updater.ts', () => { checkboxChecked: false, }); - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); emit('update-downloaded', { releaseName: null, version: '1.2.3' }); @@ -149,11 +249,12 @@ 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(); + await updater.setAutomaticUpdatesEnabled(true); emit('update-downloaded', { releaseName: 'v1.2.3' }); expect(dialog.showMessageBox).not.toHaveBeenCalled(); + expect(notify).not.toHaveBeenCalled(); expect(menuBuilder.setUpdateAvailableMenuVisibility).toHaveBeenCalledWith(false); expect(menuBuilder.setUpdateReadyForInstallMenuVisibility).toHaveBeenCalledWith(true); }); @@ -164,7 +265,7 @@ describe('main/updater.ts', () => { checkboxChecked: false, }); - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); emit('update-downloaded', { releaseName: 'v9.9.9' }); @@ -172,6 +273,7 @@ describe('main/updater.ts', () => { await Promise.resolve(); expect(autoUpdater.quitAndInstall).toHaveBeenCalled(); + expect(autoUpdater.autoRunAppAfterInstall).toBe(true); }); it('does not install when user dismisses the dialog', async () => { @@ -180,7 +282,7 @@ describe('main/updater.ts', () => { checkboxChecked: false, }); - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); emit('update-downloaded', { releaseName: 'v9.9.9' }); @@ -193,7 +295,7 @@ describe('main/updater.ts', () => { it('logs an error when the dialog fails to open', async () => { vi.mocked(dialog.showMessageBox).mockRejectedValueOnce(new Error('dialog failed')); - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); emit('update-downloaded', { releaseName: 'v9.9.9' }); @@ -213,7 +315,7 @@ describe('main/updater.ts', () => { }); vi.mocked(shell.openExternal).mockResolvedValue(); - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); emit('update-downloaded', { releaseName: 'v1.2.3', @@ -242,7 +344,7 @@ describe('main/updater.ts', () => { }); vi.mocked(shell.openExternal).mockResolvedValue(); - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); emit('update-downloaded', { releaseName: 'v1.2.3', version: 'v1.2.3' }); @@ -259,7 +361,7 @@ describe('main/updater.ts', () => { checkboxChecked: false, }); - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); emit('update-downloaded', { releaseName: 'v1.2.3' }); @@ -278,7 +380,7 @@ describe('main/updater.ts', () => { }); vi.mocked(shell.openExternal).mockRejectedValueOnce(new Error('no browser')); - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); emit('update-downloaded', { releaseName: 'v1.2.3', version: '1.2.3' }); @@ -297,7 +399,7 @@ describe('main/updater.ts', () => { checkboxChecked: false, }); - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); emit('update-downloaded', { releaseName: 'v1.2.3', version: '1.2.3' }); emit('update-downloaded', { releaseName: 'v1.2.3', version: '1.2.3' }); @@ -311,10 +413,10 @@ describe('main/updater.ts', () => { checkboxChecked: false, }); - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); emit('update-downloaded', { releaseName: 'v1.2.3', version: '1.2.3' }); - emit('update-available'); + emit('update-available', { version: '1.2.3' }); emit('update-downloaded', { releaseName: 'v1.2.3', version: '1.2.3' }); expect(dialog.showMessageBox).toHaveBeenCalledTimes(1); @@ -326,10 +428,10 @@ describe('main/updater.ts', () => { checkboxChecked: false, }); - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); emit('update-downloaded', { releaseName: 'v1.2.3', version: '1.2.3' }); - emit('update-available'); + emit('update-available', { version: '1.3.0' }); emit('update-downloaded', { releaseName: 'v1.3.0', version: '1.3.0' }); expect(dialog.showMessageBox).toHaveBeenCalledTimes(2); @@ -341,7 +443,7 @@ describe('main/updater.ts', () => { checkboxChecked: false, }); - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); emit('update-downloaded', { releaseName: 'v1.2.3', version: '1.2.3' }); emit('update-cancelled'); @@ -355,28 +457,30 @@ describe('main/updater.ts', () => { it('skips when app is not packaged', async () => { Object.defineProperty(menubar.app, 'isPackaged', { value: false }); - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); expect(logInfo).toHaveBeenCalledWith( 'app updater', 'Skipping updater since app is in development mode', ); - expect(autoUpdater.checkForUpdatesAndNotify).not.toHaveBeenCalled(); + expect(autoUpdater.checkForUpdates).not.toHaveBeenCalled(); }); it('starts only once when settings updates arrive concurrently', async () => { - await Promise.all([updater.start(), updater.start()]); + await Promise.all([ + updater.setAutomaticUpdatesEnabled(true), + updater.setAutomaticUpdatesEnabled(true), + ]); - expect(autoUpdater.checkForUpdatesAndNotify).toHaveBeenCalledTimes(1); + expect(autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1); }); it('checks silently when update notifications are disabled', async () => { updater.setNotificationsEnabled(false); - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); expect(autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1); - expect(autoUpdater.checkForUpdatesAndNotify).not.toHaveBeenCalled(); }); it('keeps silent update checks running on schedule when notifications are disabled', async () => { @@ -384,20 +488,19 @@ describe('main/updater.ts', () => { try { updater.setNotificationsEnabled(false); - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); 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(); + await updater.setAutomaticUpdatesEnabled(true); emit('checking-for-update'); @@ -406,9 +509,9 @@ describe('main/updater.ts', () => { }); it('handles update-available', async () => { - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); - emit('update-available'); + emit('update-available', { version: '1.2.3' }); expect(menuBuilder.setUpdateAvailableMenuVisibility).toHaveBeenCalledWith(true); expect(menubar.tray.setToolTip).toHaveBeenCalledWith( @@ -417,7 +520,7 @@ describe('main/updater.ts', () => { }); it('handles download-progress', async () => { - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); emit('download-progress', { percent: 12.3456 }); @@ -425,7 +528,7 @@ describe('main/updater.ts', () => { }); it('handles update-not-available', async () => { - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); emit('update-not-available'); @@ -438,7 +541,7 @@ describe('main/updater.ts', () => { it('auto-hides "No updates available" after configured timeout', async () => { vi.useFakeTimers(); try { - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); emit('update-not-available'); @@ -456,7 +559,7 @@ describe('main/updater.ts', () => { it('clears pending hide timer when a new check starts', async () => { vi.useFakeTimers(); try { - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); emit('update-not-available'); @@ -478,7 +581,7 @@ describe('main/updater.ts', () => { }); it('handles update-cancelled (reset state)', async () => { - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); emit('update-cancelled'); @@ -487,7 +590,7 @@ describe('main/updater.ts', () => { }); it('handles error (reset + logError)', async () => { - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); const err = new Error('failure'); emit('error', err); @@ -499,17 +602,17 @@ describe('main/updater.ts', () => { it('keeps checking on schedule after an error', async () => { vi.useFakeTimers(); try { - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); // Let the first scheduled check run, which registers the interval await vi.advanceTimersByTimeAsync(APPLICATION.UPDATE_CHECK_INTERVAL_MS); - const callsBeforeError = vi.mocked(autoUpdater.checkForUpdatesAndNotify).mock.calls.length; + const callsBeforeError = vi.mocked(autoUpdater.checkForUpdates).mock.calls.length; emit('error', new Error('offline')); await vi.advanceTimersByTimeAsync(APPLICATION.UPDATE_CHECK_INTERVAL_MS); - expect(vi.mocked(autoUpdater.checkForUpdatesAndNotify).mock.calls.length).toBeGreaterThan( + expect(vi.mocked(autoUpdater.checkForUpdates).mock.calls.length).toBeGreaterThan( callsBeforeError, ); } finally { @@ -517,33 +620,148 @@ describe('main/updater.ts', () => { } }); - it('performs initial check and schedules periodic checks', async () => { + it('stops scheduled checks and suppresses late downloaded events when disabled', async () => { vi.useFakeTimers(); - const originalSetInterval = globalThis.setInterval; - const setIntervalSpy = vi.spyOn(globalThis, 'setInterval').mockImplementation((( - fn: () => void, - ) => { - fn(); - return 0 as unknown as NodeJS.Timeout; - }) as unknown as typeof setInterval); try { - await updater.start(); + await updater.setAutomaticUpdatesEnabled(true); + await updater.setAutomaticUpdatesEnabled(false); + expect(autoUpdater.autoDownload).toBe(false); + expect(autoUpdater.autoInstallOnAppQuit).toBe(false); + emit('update-downloaded', { version: '9.9.9' }); + expect(dialog.showMessageBox).not.toHaveBeenCalled(); + expect(notify).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(APPLICATION.UPDATE_CHECK_INTERVAL_MS * 2); + expect(autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1); + await updater.setAutomaticUpdatesEnabled(true); + await vi.advanceTimersByTimeAsync(APPLICATION.UPDATE_CHECK_INTERVAL_MS); + expect(autoUpdater.checkForUpdates).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); - // At minimum the initial check should have occurred - const callCount = vi.mocked(autoUpdater.checkForUpdatesAndNotify).mock.calls.length; - expect(callCount).toBeGreaterThanOrEqual(1); + it('cancels an active download when disabled', async () => { + const cancellationToken = new CancellationToken(); + vi.spyOn(cancellationToken, 'cancel'); + const download = Promise.withResolvers(); + cancellationToken.on('cancel', () => download.reject(new Error('Cancelled'))); + vi.mocked(autoUpdater.checkForUpdates).mockResolvedValueOnce({ + downloadPromise: download.promise, + cancellationToken, + isUpdateAvailable: true, + updateInfo: { version: '9.9.9', files: [], releaseDate: '', path: '', sha512: '' }, + versionInfo: { version: '9.9.9', files: [], releaseDate: '', path: '', sha512: '' }, + }); + await updater.setAutomaticUpdatesEnabled(true); + await updater.setAutomaticUpdatesEnabled(false); + expect(cancellationToken.cancel).toHaveBeenCalledOnce(); + }); - await vi.advanceTimersByTimeAsync(APPLICATION.UPDATE_CHECK_INTERVAL_MS); + it('does not reschedule or download when disabled during an initial check', async () => { + vi.useFakeTimers(); + try { + const check = + Promise.withResolvers>>(); + vi.mocked(autoUpdater.checkForUpdates).mockReturnValueOnce(check.promise); + const starting = updater.setAutomaticUpdatesEnabled(true); + await updater.setAutomaticUpdatesEnabled(false); + const cancellationToken = new CancellationToken(); + vi.spyOn(cancellationToken, 'cancel'); + check.resolve({ + cancellationToken, + isUpdateAvailable: true, + updateInfo: { version: '9.9.9', files: [], releaseDate: '', path: '', sha512: '' }, + versionInfo: { version: '9.9.9', files: [], releaseDate: '', path: '', sha512: '' }, + }); + await starting; + expect(cancellationToken.cancel).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(APPLICATION.UPDATE_CHECK_INTERVAL_MS * 2); + expect(autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); - expect(setIntervalSpy).toHaveBeenCalledWith( - expect.any(Function), - APPLICATION.UPDATE_CHECK_INTERVAL_MS, - ); + it('adopts a shared in-flight check after rapidly disabling and enabling updates', async () => { + const check = + Promise.withResolvers>>(); + vi.mocked(autoUpdater.checkForUpdates).mockReturnValueOnce(check.promise); + const initial = updater.setAutomaticUpdatesEnabled(true); + await updater.setAutomaticUpdatesEnabled(false); + const resumed = updater.setAutomaticUpdatesEnabled(true); + const cancellationToken = new CancellationToken(); + const download = Promise.withResolvers(); + cancellationToken.on('cancel', () => download.reject(new Error('Cancelled'))); + const updateInfo = { version: '9.9.9', files: [], releaseDate: '', path: '', sha512: '' }; + check.resolve({ + downloadPromise: download.promise, + cancellationToken, + isUpdateAvailable: true, + updateInfo, + versionInfo: updateInfo, + }); + await Promise.all([initial, resumed]); + expect(cancellationToken.cancelled).toBe(false); + expect(autoUpdater.autoDownload).toBe(true); + await updater.setAutomaticUpdatesEnabled(false); + expect(cancellationToken.cancelled).toBe(true); + }); + + it('handles rejection of a cancelled download without reporting an update failure', async () => { + const download = Promise.withResolvers(); + const cancellationToken = new CancellationToken(); + const updateInfo = { version: '9.9.9', files: [], releaseDate: '', path: '', sha512: '' }; + vi.mocked(autoUpdater.checkForUpdates).mockResolvedValueOnce({ + cancellationToken, + isUpdateAvailable: true, + updateInfo, + versionInfo: updateInfo, + downloadPromise: download.promise, + }); + await updater.setAutomaticUpdatesEnabled(true); + await updater.setAutomaticUpdatesEnabled(false); + download.reject(new Error('Cancelled')); + await Promise.resolve(); + expect(logError).not.toHaveBeenCalled(); + }); + + it('retains the active download token across scheduled checks and resumes after cancellation', async () => { + vi.useFakeTimers(); + try { + const download = Promise.withResolvers(); + const cancellationToken = new CancellationToken(); + const updateInfo = { version: '9.9.9', files: [], releaseDate: '', path: '', sha512: '' }; + vi.mocked(autoUpdater.checkForUpdates).mockResolvedValueOnce({ + cancellationToken, + isUpdateAvailable: true, + updateInfo, + versionInfo: updateInfo, + downloadPromise: download.promise, + }); + await updater.setAutomaticUpdatesEnabled(true); + await vi.advanceTimersByTimeAsync(APPLICATION.UPDATE_CHECK_INTERVAL_MS); + expect(autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1); + await updater.setAutomaticUpdatesEnabled(false); + expect(cancellationToken.cancelled).toBe(true); + await updater.setAutomaticUpdatesEnabled(true); + expect(autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1); + download.reject(new Error('Cancelled')); + await vi.advanceTimersByTimeAsync(0); + expect(autoUpdater.checkForUpdates).toHaveBeenCalledTimes(2); } finally { - setIntervalSpy.mockRestore(); - globalThis.setInterval = originalSetInterval; vi.useRealTimers(); } }); + + it('ignores an open restart dialog after updates are disabled', async () => { + const answer = Promise.withResolvers(); + vi.mocked(dialog.showMessageBox).mockReturnValueOnce(answer.promise); + await updater.setAutomaticUpdatesEnabled(true); + emit('update-downloaded', { version: '9.9.9' }); + await updater.setAutomaticUpdatesEnabled(false); + answer.resolve({ response: 0, checkboxChecked: false }); + await answer.promise; + expect(autoUpdater.quitAndInstall).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/main/updater.ts b/src/main/updater.ts index 5c507b8f6..2b39b90aa 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,9 +1,10 @@ -import { dialog, type MessageBoxOptions, shell } from 'electron'; +import { dialog, Notification, type MessageBoxOptions, shell } from 'electron'; import type { Menubar } from 'electron-menubar'; -import { autoUpdater, type UpdateDownloadedEvent } from 'electron-updater'; +import { autoUpdater, type UpdateCheckResult, type UpdateDownloadedEvent } from 'electron-updater'; import { APPLICATION } from '../shared/constants'; import { logError, logInfo, toError } from '../shared/logger'; +import { isMacOS } from '../shared/platform'; import type MenuBuilder from './menu'; @@ -21,6 +22,16 @@ export default class AppUpdater { private readonly menuBuilder: MenuBuilder; private notificationsEnabled = true; private started = false; + private enabled = false; + private revision = 0; + private downloaded = false; + private installing = false; + private periodicCheck?: NodeJS.Timeout; + private checking = false; + private download?: { + promise: Promise; + token: UpdateCheckResult['cancellationToken']; + }; private noUpdateMessageTimeout?: NodeJS.Timeout; private lastPromptedVersion: string | null = null; @@ -31,9 +42,19 @@ export default class AppUpdater { constructor(menubar: Menubar, menuBuilder: MenuBuilder) { this.menubar = menubar; this.menuBuilder = menuBuilder; - // Disable electron-updater's own logging to avoid duplicate log messages - // We'll handle all logging through our event listeners + this.menuBuilder.setUpdateActions({ + check: () => this.performInitialCheck(), + install: () => this.installUpdate(), + }); + this.menubar.app.on('before-quit', (event) => { + if (isMacOS() && this.enabled && this.downloaded && !this.installing) { + event.preventDefault(); + this.installUpdate(false); + } + }); autoUpdater.logger = null; + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = false; } /** @@ -44,26 +65,35 @@ export default class AppUpdater { this.notificationsEnabled = enabled; } - /** - * Start the updater: register event listeners, perform the initial update check, - * and schedule periodic checks. Idempotent — safe to call multiple times. - */ - async start(): Promise { - if (this.started) { - return; // idempotent + async setAutomaticUpdatesEnabled(enabled: boolean): Promise { + if (this.enabled === enabled) { + return; + } + this.enabled = enabled; + this.revision++; + autoUpdater.autoDownload = enabled; + // Squirrel's native handoff cannot be revoked, so macOS waits until an allowed quit. + autoUpdater.autoInstallOnAppQuit = enabled && !isMacOS(); + this.menuBuilder.setAutomaticUpdatesEnabled(enabled); + + if (!enabled) { + clearInterval(this.periodicCheck); + this.periodicCheck = undefined; + this.download?.token?.cancel(); + this.resetState(); + return; } if (!this.menubar.app.isPackaged) { logInfo('app updater', 'Skipping updater since app is in development mode'); return; } - - logInfo('app updater', 'Starting updater'); - - this.started = true; - this.registerListeners(); - await this.performInitialCheck(); + if (!this.started) { + this.started = true; + this.registerListeners(); + } this.schedulePeriodicChecks(); + await this.performInitialCheck(); } /** @@ -71,6 +101,9 @@ export default class AppUpdater { */ private registerListeners() { autoUpdater.on('checking-for-update', () => { + if (!this.enabled) { + return; + } logInfo('auto updater', 'Checking for update'); this.menuBuilder.setCheckForUpdatesMenuEnabled(false); this.menuBuilder.setNoUpdateAvailableMenuVisibility(false); @@ -80,6 +113,12 @@ export default class AppUpdater { }); autoUpdater.on('update-available', () => { + if (!this.enabled) { + return; + } + // A download replaces the cached update even if it fails. A still-valid cache + // re-emits update-downloaded straight away. + this.downloaded = false; logInfo('auto updater', 'Update available'); // Keep the prompt guard for this version: a routine check for the same // pending version must not re-prompt after the user chose Later. A @@ -90,20 +129,35 @@ export default class AppUpdater { }); autoUpdater.on('download-progress', (progressObj) => { + if (!this.enabled) { + return; + } this.setTooltipWithStatus(`Downloading update: ${progressObj.percent.toFixed(2)}%`); }); autoUpdater.on('update-downloaded', (event) => { + if (!this.enabled) { + return; + } + this.downloaded = true; logInfo('auto updater', 'Update downloaded'); this.setTooltipWithStatus('A new update is ready to install'); this.menuBuilder.setUpdateAvailableMenuVisibility(false); this.menuBuilder.setUpdateReadyForInstallMenuVisibility(true); - if (this.notificationsEnabled) { + // The guard in showUpdateReadyDialog also keeps a re-check of the same version quiet. + if (this.notificationsEnabled && event.version !== this.lastPromptedVersion) { + new Notification({ + title: 'A new update is ready to install', + body: `${APPLICATION.NAME} ${event.version} has been downloaded and will be installed on exit.`, + }).show(); this.showUpdateReadyDialog(event); } }); autoUpdater.on('update-not-available', () => { + if (!this.enabled) { + return; + } logInfo('auto updater', 'Update not available'); this.menuBuilder.setCheckForUpdatesMenuEnabled(true); this.menuBuilder.setNoUpdateAvailableMenuVisibility(true); @@ -118,14 +172,25 @@ export default class AppUpdater { }); autoUpdater.on('update-cancelled', () => { + if (!this.enabled) { + return; + } logInfo('auto updater', 'Update cancelled'); this.lastPromptedVersion = null; this.resetState(); }); autoUpdater.on('error', (err) => { + // A failed check leaves the downloaded update installable; a failed install does not. + if (this.installing) { + this.installing = false; + this.downloaded = false; + } logError('auto updater', 'Error checking for update', err); this.resetState(); + if (this.enabled && this.downloaded) { + this.menuBuilder.setUpdateReadyForInstallMenuVisibility(true); + } }); } @@ -154,24 +219,41 @@ export default class AppUpdater { } }; - // Defer the first periodic check until after the interval elapses. - // This avoids an immediate duplicate check on startup. - setTimeout(async () => { - await runScheduledCheck(); - setInterval(runScheduledCheck, APPLICATION.UPDATE_CHECK_INTERVAL_MS); - }, APPLICATION.UPDATE_CHECK_INTERVAL_MS); + this.periodicCheck = setInterval(runScheduledCheck, APPLICATION.UPDATE_CHECK_INTERVAL_MS); } /** - * Check and download updates, using electron-updater's native notification - * only when the user has opted in. + * Track downloads so disabling updates can cancel a check that finishes later. */ private async checkForUpdates() { - if (this.notificationsEnabled) { - return await autoUpdater.checkForUpdatesAndNotify(); + if (!this.enabled || this.checking || this.download) { + return; + } + this.checking = true; + try { + const result = await autoUpdater.checkForUpdates(); + if (result?.downloadPromise) { + const download = { promise: result.downloadPromise, token: result.cancellationToken }; + this.download = download; + void download.promise + .catch((error: unknown) => { + if (!download.token?.cancelled) { + logError('app updater', 'Update download failed', toError(error)); + } + }) + .finally(() => { + this.download = undefined; + if (this.enabled && download.token?.cancelled) { + void this.performInitialCheck(); + } + }); + } + if (!this.enabled) { + result?.cancellationToken?.cancel(); + } + } finally { + this.checking = false; } - - return await autoUpdater.checkForUpdates(); } /** @@ -209,6 +291,19 @@ export default class AppUpdater { this.clearNoUpdateTimeout(); } + /** + * Install a downloaded update while automatic updates are on. + * + * @param relaunch - Whether to start Gitify again after installing; a plain quit does not. + */ + private installUpdate(relaunch = true) { + if (this.enabled && this.downloaded && !this.installing) { + this.installing = true; + autoUpdater.autoRunAppAfterInstall = relaunch; + autoUpdater.quitAndInstall(); + } + } + /** * Show a dialog informing the user that an update is ready to install. * If the user chooses to restart, quitAndInstall is called immediately. @@ -237,11 +332,12 @@ export default class AppUpdater { detail: 'Restart to apply the update. You can also restart later from the tray menu.', }; + const revision = this.revision; dialog .showMessageBox(dialogOpts) .then((returnValue) => { - if (returnValue.response === 0) { - autoUpdater.quitAndInstall(); + if (returnValue.response === 0 && this.enabled && revision === this.revision) { + this.installUpdate(); } else if (returnValue.response === 2 && hasReleaseInfo && event.version) { this.openReleaseNotes(event.version); } diff --git a/src/preload/index.test.ts b/src/preload/index.test.ts index 805dd3654..a1a794378 100644 --- a/src/preload/index.test.ts +++ b/src/preload/index.test.ts @@ -46,7 +46,10 @@ interface TestApi { tray: { updateColor: (n?: number, isOnline?: boolean) => void }; openExternalLink: (u: string, f: boolean) => void; setAutoLaunch: (value: boolean) => void; - setShowUpdateNotifications: (value: boolean) => void; + setUpdatePreferences: (value: { + automaticUpdates: 'default'; + showUpdateNotifications: boolean; + }) => void; setUseX11Backend: (value: boolean) => void; app: { version: () => Promise; show?: () => void; hide?: () => void }; nativeNotifications: { @@ -121,12 +124,15 @@ describe('preload/index', () => { }); }); - it('setShowUpdateNotifications sends the preference to main', () => { + it('setUpdatePreferences sends the preference to main', () => { const api = getExposedApi(); - api.setShowUpdateNotifications(false); + api.setUpdatePreferences({ automaticUpdates: 'default', showUpdateNotifications: false }); - expect(sendMainEventMock).toHaveBeenCalledWith(EVENTS.UPDATE_SHOW_UPDATE_NOTIFICATIONS, false); + expect(sendMainEventMock).toHaveBeenCalledWith(EVENTS.UPDATE_PREFERENCES, { + automaticUpdates: 'default', + showUpdateNotifications: false, + }); }); it('app.version returns dev in development', async () => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 316f05e97..75f02623b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -8,6 +8,7 @@ import type { INativeNotificationRemovePayload, NativeThemeSource, TrayIconAppearance, + UpdatePreferences, ZoomAction, } from '../shared/events'; import { EVENTS } from '../shared/events'; @@ -80,16 +81,9 @@ 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), + setUpdatePreferences: (value: UpdatePreferences) => + sendMainEvent(EVENTS.UPDATE_PREFERENCES, value), + getUpdateManager: () => invokeMainEvent(EVENTS.UPDATE_MANAGER), /** * Persist whether Linux should run under the X11 backend. Applied at startup, diff --git a/src/renderer/__mocks__/state-mocks.ts b/src/renderer/__mocks__/state-mocks.ts index 5506b49e3..6a58984e3 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: 'default', showUpdateNotifications: true, useX11Backend: false, }; diff --git a/src/renderer/components/settings/SystemSettings.test.tsx b/src/renderer/components/settings/SystemSettings.test.tsx index c71a6d3c5..6f360409b 100644 --- a/src/renderer/components/settings/SystemSettings.test.tsx +++ b/src/renderer/components/settings/SystemSettings.test.tsx @@ -45,6 +45,43 @@ describe('renderer/components/settings/SystemSettings.tsx', () => { expect(toggleSettingSpy).toHaveBeenCalledWith(setting); }); + it('explains the managed default and persists explicit update choices', async () => { + vi.mocked(window.gitify.getUpdateManager).mockResolvedValue('Scoop'); + await act(async () => { + renderWithProviders(); + }); + expect(screen.getByText('Off by default. Update Gitify with Scoop.')).toBeInTheDocument(); + await userEvent.click(screen.getByTestId('choice-automaticUpdates-enabled')); + expect(useSettingsStore.getState().automaticUpdates).toBe('enabled'); + expect( + screen.getByText('Gitify downloads updates automatically and installs them when you quit.'), + ).toBeInTheDocument(); + await userEvent.click(screen.getByTestId('choice-automaticUpdates-disabled')); + expect(useSettingsStore.getState().automaticUpdates).toBe('disabled'); + expect( + screen.getByText('Automatic updates are off. View releases from the tray menu.'), + ).toBeInTheDocument(); + await userEvent.click(screen.getByTestId('choice-automaticUpdates-default')); + expect(useSettingsStore.getState().automaticUpdates).toBe('default'); + }); + + it('points a packager opt-in to the package manager', async () => { + vi.mocked(window.gitify.getUpdateManager).mockResolvedValue('packager'); + await act(async () => { + renderWithProviders(); + }); + expect( + screen.getByText('Off by default. Update Gitify with your package manager.'), + ).toBeInTheDocument(); + }); + + it('explains the default for a direct installation', async () => { + await act(async () => { + renderWithProviders(); + }); + expect(screen.getByText('On by default.')).toBeInTheDocument(); + }); + describe('X11 backend switch', () => { // `window.gitify` is rebuilt in a global `beforeEach`, so the mock has to // be read inside each test rather than captured at describe scope. @@ -116,9 +153,11 @@ describe('renderer/components/settings/SystemSettings.tsx', () => { ); }); - it('does not render the shortcut group when the shortcut is disabled', () => { - renderWithProviders(, { - settings: { ...mockSettings, keyboardShortcut: false }, + it('does not render the shortcut group when the shortcut is disabled', async () => { + await act(async () => { + renderWithProviders(, { + settings: { ...mockSettings, keyboardShortcut: false }, + }); }); expect(screen.getByTestId('reveal-shortcutRecorder')).toHaveAttribute('data-state', 'closed'); diff --git a/src/renderer/components/settings/SystemSettings.tsx b/src/renderer/components/settings/SystemSettings.tsx index 2f9a4c7d0..92c58eed8 100644 --- a/src/renderer/components/settings/SystemSettings.tsx +++ b/src/renderer/components/settings/SystemSettings.tsx @@ -5,6 +5,8 @@ import { DeviceDesktopIcon, PencilIcon, SyncIcon } from '@primer/octicons-react' import { Banner, Button, ButtonGroup, IconButton, Stack, Text } from '@primer/react'; import { APPLICATION } from '../../../shared/constants'; +import { isAutomaticUpdates, type UpdateManager } from '../../../shared/events'; +import { logError, toError } from '../../../shared/logger'; import { DEFAULT_SETTINGS_STATE, @@ -30,6 +32,99 @@ import { SettingsToggle } from './controls/SettingsToggle'; const defaultSettings = DEFAULT_SETTINGS_STATE; +function AutomaticUpdatesChoice() { + const { t } = useTranslation(); + const updateSetting = useSettingsStore((s) => s.updateSetting); + const automaticUpdates = useSettingsStore((s) => s.automaticUpdates); + const [updateManager, setUpdateManager] = useState(null); + const [updateManagerError, setUpdateManagerError] = useState(false); + const [updateManagerLoading, setUpdateManagerLoading] = useState(true); + + useEffect(() => { + let active = true; + window.gitify + .getUpdateManager() + .then((manager) => { + if (active) { + setUpdateManager(manager); + setUpdateManagerLoading(false); + } + }) + .catch((error: unknown) => { + logError('settings', 'Unable to detect update manager', toError(error)); + if (active) { + setUpdateManagerError(true); + setUpdateManagerLoading(false); + } + }); + return () => { + active = false; + }; + }, []); + + let automaticUpdatesCaption: string; + if (updateManagerLoading) { + automaticUpdatesCaption = t( + 'settings.system.automatic_updates_detecting', + 'Detecting how {{appName}} was installed…', + { appName: APPLICATION.NAME }, + ); + } else if (updateManagerError) { + automaticUpdatesCaption = t( + 'settings.system.automatic_updates_detection_failed', + 'Unable to detect how {{appName}} was installed. Choose On or Off.', + { appName: APPLICATION.NAME }, + ); + } else if (automaticUpdates === 'enabled') { + automaticUpdatesCaption = t( + 'settings.system.automatic_updates_on', + '{{appName}} downloads updates automatically and installs them when you quit.', + { appName: APPLICATION.NAME }, + ); + } else if (automaticUpdates === 'disabled') { + automaticUpdatesCaption = t( + 'settings.system.automatic_updates_off', + 'Automatic updates are off. View releases from the tray menu.', + ); + } else if (updateManager === 'packager') { + automaticUpdatesCaption = t( + 'settings.system.automatic_updates_default_packager', + 'Off by default. Update {{appName}} with your package manager.', + { appName: APPLICATION.NAME }, + ); + } else if (updateManager) { + automaticUpdatesCaption = t( + 'settings.system.automatic_updates_default_managed', + 'Off by default. Update {{appName}} with {{manager}}.', + { appName: APPLICATION.NAME, manager: updateManager }, + ); + } else { + automaticUpdatesCaption = t('settings.system.automatic_updates_default', 'On by default.'); + } + + return ( + {automaticUpdatesCaption}} + label={t('settings.system.automatic_updates', 'Automatic updates')} + name="automaticUpdates" + onChange={(value) => { + if (isAutomaticUpdates(value)) { + updateSetting('automaticUpdates', value); + } + }} + options={[ + { + label: t('settings.system.automatic_updates_default_option', 'Default'), + value: 'default', + }, + { label: t('settings.system.automatic_updates_on_option', 'On'), value: 'enabled' }, + { label: t('settings.system.automatic_updates_off_option', 'Off'), value: 'disabled' }, + ]} + value={automaticUpdates} + /> + ); +} + export function SystemSettings() { const { t } = useTranslation(); const shortcutRegistrationError = useShortcutRegistrationStore( @@ -352,6 +447,8 @@ export function SystemSettings() { )} visible={isLinux} /> + + {isLinux && ( diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index b5e1479c4..e47b9d567 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -489,6 +489,17 @@ "title": "Reset Settings" }, "system": { + "automatic_updates": "Automatic updates", + "automatic_updates_default": "On by default.", + "automatic_updates_default_managed": "Off by default. Update {{appName}} with {{manager}}.", + "automatic_updates_default_option": "Default", + "automatic_updates_default_packager": "Off by default. Update {{appName}} with your package manager.", + "automatic_updates_detecting": "Detecting how {{appName}} was installed…", + "automatic_updates_detection_failed": "Unable to detect how {{appName}} was installed. Choose On or Off.", + "automatic_updates_off": "Automatic updates are off. View releases from the tray menu.", + "automatic_updates_off_option": "Off", + "automatic_updates_on": "{{appName}} downloads updates automatically and installs them when you quit.", + "automatic_updates_on_option": "On", "current_shortcut": "Current shortcut", "edit_shortcut": "Edit global shortcut", "enable_shortcut": "Enable global shortcut", diff --git a/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-reset-confirmation-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-reset-confirmation-dark-classic-chromium-linux.png index 2b6cf37b4..cb05e31bc 100644 Binary files a/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-reset-confirmation-dark-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-reset-confirmation-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-reset-confirmation-dark-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-reset-confirmation-dark-glass-chromium-linux.png index 4366a43a1..24d49157c 100644 Binary files a/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-reset-confirmation-dark-glass-chromium-linux.png and b/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-reset-confirmation-dark-glass-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-reset-confirmation-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-reset-confirmation-light-classic-chromium-linux.png index b156bca85..bf99022d2 100644 Binary files a/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-reset-confirmation-light-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-reset-confirmation-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-reset-confirmation-light-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-reset-confirmation-light-glass-chromium-linux.png index 4175aa4e0..50b501489 100644 Binary files a/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-reset-confirmation-light-glass-chromium-linux.png and b/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-reset-confirmation-light-glass-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-shortcut-reveal-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-shortcut-reveal-dark-classic-chromium-linux.png index 984ee93c4..32f1a1bad 100644 Binary files a/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-shortcut-reveal-dark-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-shortcut-reveal-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-shortcut-reveal-dark-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-shortcut-reveal-dark-glass-chromium-linux.png index b0878e01c..57ea41826 100644 Binary files a/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-shortcut-reveal-dark-glass-chromium-linux.png and b/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-shortcut-reveal-dark-glass-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-shortcut-reveal-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-shortcut-reveal-light-classic-chromium-linux.png index 0ceaaff0c..39fe26d92 100644 Binary files a/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-shortcut-reveal-light-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-shortcut-reveal-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-shortcut-reveal-light-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-shortcut-reveal-light-glass-chromium-linux.png index 8475b1b13..fea4d4245 100644 Binary files a/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-shortcut-reveal-light-glass-chromium-linux.png and b/src/renderer/routes/__screenshots__/Settings.visual.test.tsx/settings-shortcut-reveal-light-glass-chromium-linux.png differ diff --git a/src/renderer/routes/update-settings.visual.test.tsx b/src/renderer/routes/update-settings.visual.test.tsx new file mode 100644 index 000000000..c144731a0 --- /dev/null +++ b/src/renderer/routes/update-settings.visual.test.tsx @@ -0,0 +1,56 @@ +import { page } from 'vite-plus/test/browser'; + +import { renderRoute } from '../__helpers__/visual-utils'; +import { mockGitHubCloudAccount } from '../__mocks__/account-mocks'; + +import { useSettingsStore } from '../stores'; + +import { DesignLanguage, Theme } from '../types'; + +import { SettingsRoute } from './Settings'; + +async function expectText(text: string) { + // oxlint-disable-next-line typescript/await-thenable -- Vitest types omit `expect.element`'s poll promise + await expect.element(page.getByText(text)).toBeVisible(); +} + +async function choose(value: 'default' | 'enabled' | 'disabled') { + const choice = page.getByTestId(`choice-automaticUpdates-${value}`); + await choice.click(); + // oxlint-disable-next-line typescript/await-thenable -- Vitest types omit `expect.element`'s poll promise + await expect.element(choice).toHaveAttribute('aria-pressed', 'true'); + expect(useSettingsStore.getState().automaticUpdates).toBe(value); +} + +it('lets a managed installation override and restore the automatic update default', async () => { + vi.mocked(window.gitify.getUpdateManager).mockResolvedValue('Scoop'); + renderRoute(, { + theme: Theme.LIGHT, + designLanguage: DesignLanguage.CLASSIC, + initialEntries: ['/settings'], + accounts: [mockGitHubCloudAccount], + }); + + await expectText('Off by default. Update Gitify with Scoop.'); + await choose('enabled'); + await expectText('Gitify downloads updates automatically and installs them when you quit.'); + await choose('disabled'); + await expectText('Automatic updates are off. View releases from the tray menu.'); + await choose('default'); + await expectText('Off by default. Update Gitify with Scoop.'); + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(window.innerWidth); +}); + +it('keeps direct installations on by default and permits disabling updates', async () => { + renderRoute(, { + theme: Theme.DARK, + designLanguage: DesignLanguage.GLASS, + initialEntries: ['/settings'], + accounts: [mockGitHubCloudAccount], + }); + + // oxlint-disable-next-line typescript/await-thenable -- Vitest types omit `expect.element`'s poll promise + await expect.element(page.getByText('On by default.')).toBeVisible(); + await choose('disabled'); + await expectText('Automatic updates are off. View releases from the tray menu.'); +}); diff --git a/src/renderer/stores/defaults.ts b/src/renderer/stores/defaults.ts index b181108ef..9dc82c0f3 100644 --- a/src/renderer/stores/defaults.ts +++ b/src/renderer/stores/defaults.ts @@ -87,6 +87,7 @@ const DEFAULT_SYSTEM_SETTINGS: SystemSettingsState = { notificationVolume: 20 as Percentage, openAtStartup: false, keepWindowOnBlur: false, + automaticUpdates: 'default', showUpdateNotifications: true, useX11Backend: false, }; diff --git a/src/renderer/stores/subscriptions.test.ts b/src/renderer/stores/subscriptions.test.ts index 2cecfc874..97c927c26 100644 --- a/src/renderer/stores/subscriptions.test.ts +++ b/src/renderer/stores/subscriptions.test.ts @@ -13,8 +13,8 @@ import useSettingsStore from './useSettingsStore'; describe('renderer/stores/subscriptions.ts', () => { const setAutoLaunchSpy = vi.spyOn(comms, 'setAutoLaunch').mockImplementation(vi.fn()); const setKeepWindowOnBlurSpy = vi.spyOn(comms, 'setKeepWindowOnBlur').mockImplementation(vi.fn()); - const setShowUpdateNotificationsSpy = vi - .spyOn(comms, 'setShowUpdateNotifications') + const setUpdatePreferencesSpy = vi + .spyOn(comms, 'setUpdatePreferences') .mockImplementation(vi.fn()); const setUseUnreadActiveIconSpy = vi .spyOn(comms, 'setUseUnreadActiveIcon') @@ -38,9 +38,10 @@ describe('renderer/stores/subscriptions.ts', () => { expect(setKeepWindowOnBlurSpy).toHaveBeenCalledWith( useSettingsStore.getState().keepWindowOnBlur, ); - expect(setShowUpdateNotificationsSpy).toHaveBeenCalledWith( - useSettingsStore.getState().showUpdateNotifications, - ); + expect(setUpdatePreferencesSpy).toHaveBeenCalledWith({ + automaticUpdates: 'default', + showUpdateNotifications: useSettingsStore.getState().showUpdateNotifications, + }); expect(setUseUnreadActiveIconSpy).toHaveBeenCalledWith( useSettingsStore.getState().useUnreadActiveIcon, ); @@ -61,7 +62,10 @@ describe('renderer/stores/subscriptions.ts', () => { expect(setKeepWindowOnBlurSpy).toHaveBeenCalledWith(true); useSettingsStore.getState().updateSetting('showUpdateNotifications', false); - expect(setShowUpdateNotificationsSpy).toHaveBeenCalledWith(false); + expect(setUpdatePreferencesSpy).toHaveBeenCalledWith({ + automaticUpdates: 'default', + showUpdateNotifications: false, + }); useSettingsStore.getState().updateSetting('useUnreadActiveIcon', false); expect(setUseUnreadActiveIconSpy).toHaveBeenCalledWith(false); @@ -86,6 +90,16 @@ describe('renderer/stores/subscriptions.ts', () => { expect(useFiltersStore.getState().accounts).toEqual([]); }); + it('forwards automatic update overrides after initialization', () => { + cleanup = initializeStoreSubscriptions(); + vi.clearAllMocks(); + useSettingsStore.getState().updateSetting('automaticUpdates', 'disabled'); + expect(setUpdatePreferencesSpy).toHaveBeenCalledExactlyOnceWith({ + automaticUpdates: 'disabled', + showUpdateNotifications: useSettingsStore.getState().showUpdateNotifications, + }); + }); + it('applies zoom level when zoom percentage changes', () => { cleanup = initializeStoreSubscriptions(); vi.clearAllMocks(); diff --git a/src/renderer/stores/subscriptions.ts b/src/renderer/stores/subscriptions.ts index 30bfc44a1..89e3e815b 100644 --- a/src/renderer/stores/subscriptions.ts +++ b/src/renderer/stores/subscriptions.ts @@ -9,7 +9,7 @@ import { getAccountUUID } from '../utils/auth/utils'; import { setAutoLaunch, setKeepWindowOnBlur, - setShowUpdateNotifications, + setUpdatePreferences, setTrayIconAppearance, setUseUnreadActiveIcon, setUseX11Backend, @@ -39,7 +39,11 @@ export function initializeStoreSubscriptions(): () => void { // ======================================================================== setAutoLaunch(useSettingsStore.getState().openAtStartup); setKeepWindowOnBlur(useSettingsStore.getState().keepWindowOnBlur); - setShowUpdateNotifications(useSettingsStore.getState().showUpdateNotifications); + const applyUpdatePreferences = () => { + const { automaticUpdates, showUpdateNotifications } = useSettingsStore.getState(); + setUpdatePreferences({ automaticUpdates, showUpdateNotifications }); + }; + applyUpdatePreferences(); setUseUnreadActiveIcon(useSettingsStore.getState().useUnreadActiveIcon); setTrayIconAppearance(useSettingsStore.getState().trayIconAppearance); @@ -63,16 +67,11 @@ export function initializeStoreSubscriptions(): () => void { setKeepWindowOnBlur(keepWindowOnBlur); }, ); - unsubscribers.push(unsubKeepWindowOnBlur); - - // Automatic update notifications - const unsubShowUpdateNotifications = useSettingsStore.subscribe( - (state) => state.showUpdateNotifications, - (showUpdateNotifications) => { - setShowUpdateNotifications(showUpdateNotifications); - }, + unsubscribers.push( + unsubKeepWindowOnBlur, + useSettingsStore.subscribe((state) => state.automaticUpdates, applyUpdatePreferences), + useSettingsStore.subscribe((state) => state.showUpdateNotifications, applyUpdatePreferences), ); - unsubscribers.push(unsubShowUpdateNotifications); // Linux X11 backend. Not applied on startup: the main process reads its own // marker file before the renderer exists, so mirroring it here would be diff --git a/src/renderer/stores/useSettingsStore.test.ts b/src/renderer/stores/useSettingsStore.test.ts index 96314783a..1613f4ef6 100644 --- a/src/renderer/stores/useSettingsStore.test.ts +++ b/src/renderer/stores/useSettingsStore.test.ts @@ -118,6 +118,38 @@ describe('renderer/stores/useSettingsStore.ts', () => { }); }); + describe('automatic update persistence', () => { + it.each(['enabled', 'disabled', 'default'] as const)( + 'restores the explicit %s preference', + async (automaticUpdates) => { + act(() => { + useSettingsStore.getState().updateSetting('automaticUpdates', automaticUpdates); + }); + const saved = localStorage.getItem(Constants.STORAGE.SETTINGS); + act(() => { + useSettingsStore.getState().reset(); + }); + localStorage.setItem(Constants.STORAGE.SETTINGS, saved ?? ''); + await act(async () => { + await useSettingsStore.persist.rehydrate(); + }); + expect(useSettingsStore.getState().automaticUpdates).toBe(automaticUpdates); + }, + ); + + it('uses installation detection for existing settings without an update preference', async () => { + localStorage.setItem( + Constants.STORAGE.SETTINGS, + JSON.stringify({ state: { showUpdateNotifications: false }, version: 1 }), + ); + await act(async () => { + await useSettingsStore.persist.rehydrate(); + }); + expect(useSettingsStore.getState().automaticUpdates).toBe('default'); + expect(useSettingsStore.getState().showUpdateNotifications).toBe(false); + }); + }); + describe('Design language (additive migration)', () => { afterEach(() => { localStorage.removeItem(Constants.STORAGE.SETTINGS); diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 7bf199c44..b39080dc9 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -3,7 +3,7 @@ import type { FC } from 'react'; import type { Icon, OcticonProps } from '@primer/octicons-react'; import type { Button } from '@primer/react'; -import type { TrayIconAppearance } from '../shared/events'; +import type { AutomaticUpdates, TrayIconAppearance } from '../shared/events'; // Derived from public @primer/react component props rather than internal types export type VariantType = NonNullable['variant']>; @@ -144,6 +144,7 @@ export interface SystemSettingsState { notificationVolume: Percentage; openAtStartup: boolean; keepWindowOnBlur: boolean; + automaticUpdates: AutomaticUpdates; showUpdateNotifications: boolean; /** Linux only. Runs under X11/XWayland so the popup can be anchored to the tray icon. */ useX11Backend: boolean; diff --git a/src/renderer/utils/system/comms.test.ts b/src/renderer/utils/system/comms.test.ts index c629fbcb1..7f0db21d2 100644 --- a/src/renderer/utils/system/comms.test.ts +++ b/src/renderer/utils/system/comms.test.ts @@ -13,7 +13,7 @@ import { quitApp, setAutoLaunch, setKeepWindowOnBlur, - setShowUpdateNotifications, + setUpdatePreferences, setTrayIconAppearance, setUseX11Backend, showWindow, @@ -131,10 +131,13 @@ describe('renderer/utils/comms.ts', () => { }); it('sets whether update notifications are shown', () => { - setShowUpdateNotifications(false); + setUpdatePreferences({ automaticUpdates: 'default', showUpdateNotifications: false }); - expect(window.gitify.setShowUpdateNotifications).toHaveBeenCalledTimes(1); - expect(window.gitify.setShowUpdateNotifications).toHaveBeenCalledWith(false); + expect(window.gitify.setUpdatePreferences).toHaveBeenCalledTimes(1); + expect(window.gitify.setUpdatePreferences).toHaveBeenCalledWith({ + automaticUpdates: 'default', + showUpdateNotifications: false, + }); }); it('sets the X11 backend preference', () => { diff --git a/src/renderer/utils/system/comms.ts b/src/renderer/utils/system/comms.ts index bfa2fdc12..f482031c2 100644 --- a/src/renderer/utils/system/comms.ts +++ b/src/renderer/utils/system/comms.ts @@ -2,6 +2,7 @@ import type { IGitHubCliTokenResult, ISafeStorageDecryptResult, TrayIconAppearance, + UpdatePreferences, } from '../../../shared/events'; import useSettingsStore from '../../stores/useSettingsStore'; @@ -103,13 +104,8 @@ export function setKeepWindowOnBlur(value: boolean): void { window.gitify.setKeepWindowOnBlur(value); } -/** - * Enables or suppresses automatic update notifications. - * - * @param value - `true` to show update notifications, `false` to suppress them. - */ -export function setShowUpdateNotifications(value: boolean): void { - window.gitify.setShowUpdateNotifications(value); +export function setUpdatePreferences(value: UpdatePreferences): void { + window.gitify.setUpdatePreferences(value); } /** diff --git a/src/shared/events.ts b/src/shared/events.ts index eb87753e6..d364798e1 100644 --- a/src/shared/events.ts +++ b/src/shared/events.ts @@ -16,7 +16,8 @@ export const EVENTS = { UPDATE_KEYBOARD_SHORTCUT: `${P}update-keyboard-shortcut`, UPDATE_AUTO_LAUNCH: `${P}update-auto-launch`, UPDATE_KEEP_WINDOW_ON_BLUR: `${P}update-keep-window-on-blur`, - UPDATE_SHOW_UPDATE_NOTIFICATIONS: `${P}update-show-update-notifications`, + UPDATE_PREFERENCES: `${P}update-preferences`, + UPDATE_MANAGER: `${P}update-manager`, UPDATE_USE_X11_BACKEND: `${P}update-use-x11-backend`, SET_WINDOW_VIBRANCY: `${P}set-window-vibrancy`, SET_NATIVE_THEME: `${P}set-native-theme`, @@ -44,6 +45,23 @@ export type EventType = (typeof EVENTS)[keyof typeof EVENTS]; /** Native appearance source, mirrors Electron's `nativeTheme.themeSource`. */ export type NativeThemeSource = 'system' | 'light' | 'dark'; +export type AutomaticUpdates = 'default' | 'enabled' | 'disabled'; + +export interface UpdatePreferences { + automaticUpdates: AutomaticUpdates; + showUpdateNotifications: boolean; +} + +/** + * A package manager that owns updates for an installation. `packager` means a + * package opted in through the resource marker or environment variable. + */ +export type UpdateManager = 'Scoop' | 'Flatpak' | 'Snap' | 'packager'; + +export function isAutomaticUpdates(value: unknown): value is AutomaticUpdates { + return value === 'default' || value === 'enabled' || value === 'disabled'; +} + export type TrayIconAppearance = 'auto' | 'light' | 'dark'; /** Zoom direction triggered by an application keyboard shortcut. */ @@ -170,8 +188,9 @@ export type EventContracts = AssertEventCoverage<{ request: boolean; response: undefined; }; - [EVENTS.UPDATE_SHOW_UPDATE_NOTIFICATIONS]: { - request: boolean; + [EVENTS.UPDATE_MANAGER]: { request: undefined; response: UpdateManager | null }; + [EVENTS.UPDATE_PREFERENCES]: { + request: UpdatePreferences; response: undefined; }; [EVENTS.UPDATE_USE_X11_BACKEND]: {