diff --git a/package.json b/package.json index 28f1c96af..d1e774d53 100644 --- a/package.json +++ b/package.json @@ -73,8 +73,7 @@ "menubar": "9.5.1", "react": "19.1.1", "react-dom": "19.1.1", - "react-router-dom": "7.8.1", - "update-electron-app": "3.1.1" + "react-router-dom": "7.8.1" }, "devDependencies": { "@biomejs/biome": "2.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72248633b..2f61fa704 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,9 +29,6 @@ importers: react-router-dom: specifier: 7.8.1 version: 7.8.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - update-electron-app: - specifier: 3.1.1 - version: 3.1.1 devDependencies: '@biomejs/biome': specifier: 2.2.0 @@ -2428,9 +2425,6 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} - github-url-to-object@4.0.6: - resolution: {integrity: sha512-NaqbYHMUAlPcmWFdrAB7bcxrNIiiJWJe8s/2+iOc9vlcHlwHqSGrPk+Yi3nu6ebTwgsZEa7igz+NH2vEq3gYwQ==} - glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -2718,9 +2712,6 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} - is-url@1.2.4: - resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} - isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -4512,9 +4503,6 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - update-electron-app@3.1.1: - resolution: {integrity: sha512-7duRr6sYn014tifhKgT/5i8N+6xLzmJVJ8hVtNrHXlIDNP6QbRe6VxZ1hSi2UH5oJPzhor/PH7yKU9em5xjRzQ==} - uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -7367,10 +7355,6 @@ snapshots: get-stream@6.0.1: {} - github-url-to-object@4.0.6: - dependencies: - is-url: 1.2.4 - glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -7666,8 +7650,6 @@ snapshots: is-unicode-supported@0.1.0: {} - is-url@1.2.4: {} - isarray@1.0.0: {} isbinaryfile@4.0.10: {} @@ -9764,11 +9746,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - update-electron-app@3.1.1: - dependencies: - github-url-to-object: 4.0.6 - ms: 2.1.2 - uri-js@4.4.1: dependencies: punycode: 2.3.1 diff --git a/src/main/index.ts b/src/main/index.ts index 9336e2c8d..f8f73d5a0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,11 +5,11 @@ import { menubar } from 'menubar'; import { APPLICATION } from '../shared/constants'; import { namespacedEvent } from '../shared/events'; import { logInfo, logWarn } from '../shared/logger'; -import { isLinux, isMacOS, isWindows } from '../shared/platform'; +import { isLinux, isWindows } from '../shared/platform'; import { onFirstRunMaybe } from './first-run'; import { TrayIcons } from './icons'; import MenuBuilder from './menu'; -import Updater from './updater'; +import AppUpdater from './updater'; log.initialize(); @@ -43,20 +43,15 @@ const protocol = process.env.NODE_ENV === 'development' ? 'gitify-dev' : 'gitify'; app.setAsDefaultProtocolClient(protocol); -if (isMacOS() || isWindows()) { - /** - * Electron Auto Updater only supports macOS and Windows - * https://github.com/electron/update-electron-app - */ - const updater = new Updater(mb, menuBuilder); - updater.initialize(); -} +const appUpdater = new AppUpdater(mb, menuBuilder); let shouldUseAlternateIdleIcon = false; app.whenReady().then(async () => { await onFirstRunMaybe(); + appUpdater.start(); + mb.on('ready', () => { mb.app.setAppUserModelId(APPLICATION.ID); diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts new file mode 100644 index 000000000..3ff570ebd --- /dev/null +++ b/src/main/updater.test.ts @@ -0,0 +1,229 @@ +import { dialog } from 'electron'; +import type { Menubar } from 'menubar'; + +import { APPLICATION } from '../shared/constants'; +import { logError, logInfo } from '../shared/logger'; + +jest.mock('../shared/logger', () => ({ + logInfo: jest.fn(), + logError: jest.fn(), +})); + +import MenuBuilder from './menu'; +import AppUpdater from './updater'; + +// Mock electron-updater with an EventEmitter-like interface +type UpdateDownloadedEvent = { releaseName: string }; +type ListenerArgs = UpdateDownloadedEvent | object | undefined; +type Listener = (arg: ListenerArgs) => void; +type ListenerMap = Record; +const listeners: ListenerMap = {}; + +jest.mock('electron-updater', () => ({ + autoUpdater: { + on: jest.fn((event: string, cb: Listener) => { + if (!listeners[event]) listeners[event] = []; + listeners[event].push(cb); + return this; + }), + checkForUpdatesAndNotify: jest.fn().mockResolvedValue(undefined), + quitAndInstall: jest.fn(), + }, +})); + +// Mock electron (dialog + basic Menu API used by MenuBuilder constructor) +jest.mock('electron', () => { + const MenuItem = jest.fn().mockImplementation((opts: unknown) => opts); + return { + dialog: { showMessageBox: jest.fn() }, + MenuItem, + Menu: { buildFromTemplate: jest.fn() }, + shell: { openExternal: jest.fn() }, + }; +}); + +// Utility to emit mocked autoUpdater events +const emit = (event: string, arg?: ListenerArgs) => { + (listeners[event] || []).forEach((cb) => { + cb(arg); + }); +}; + +// Re-import autoUpdater after mocking +import { autoUpdater } from 'electron-updater'; + +describe('main/updater.ts', () => { + let menubar: Menubar; + class TestMenuBuilder extends MenuBuilder { + public setCheckForUpdatesMenuEnabled = jest.fn(); + public setNoUpdateAvailableMenuVisibility = jest.fn(); + public setUpdateAvailableMenuVisibility = jest.fn(); + public setUpdateReadyForInstallMenuVisibility = jest.fn(); + constructor(mb: Menubar) { + super(mb); + } + } + let menuBuilder: TestMenuBuilder; + let updater: AppUpdater; + + beforeEach(() => { + jest.clearAllMocks(); + for (const k of Object.keys(listeners)) delete listeners[k]; + + menubar = { + app: { + isPackaged: true, + // updater.initialize is now only called after app is ready externally + on: jest.fn(), + }, + tray: { setToolTip: jest.fn() }, + } as unknown as Menubar; + + menuBuilder = new TestMenuBuilder(menubar); + updater = new AppUpdater(menubar, menuBuilder); + }); + + describe('update available dialog', () => { + it('shows dialog with expected message and does NOT install when user chooses Later', async () => { + (dialog.showMessageBox as jest.Mock).mockResolvedValue({ response: 1 }); // "Later" + + await updater.start(); + + // Simulate update downloaded event + const releaseName = 'v1.2.3'; + emit('update-downloaded', { releaseName }); + + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining( + `${APPLICATION.NAME} ${releaseName} has been downloaded`, + ), + buttons: ['Restart', 'Later'], + }), + ); + expect(autoUpdater.quitAndInstall).not.toHaveBeenCalled(); + // Menu state updates invoked + expect(menuBuilder.setUpdateAvailableMenuVisibility).toHaveBeenCalledWith( + false, + ); + expect( + menuBuilder.setUpdateReadyForInstallMenuVisibility, + ).toHaveBeenCalledWith(true); + }); + + it('invokes quitAndInstall when user clicks Restart', async () => { + (dialog.showMessageBox as jest.Mock).mockResolvedValue({ response: 0 }); // "Restart" + + await updater.start(); + emit('update-downloaded', { releaseName: 'v9.9.9' }); + // Allow then() of showMessageBox promise to resolve + await Promise.resolve(); + + expect(autoUpdater.quitAndInstall).toHaveBeenCalled(); + }); + }); + + describe('update event handlers & scheduling', () => { + it('skips when app is not packaged', async () => { + Object.defineProperty(menubar.app, 'isPackaged', { value: false }); + await updater.start(); + expect(logInfo).toHaveBeenCalledWith( + 'app updater', + 'Skipping updater since app is in development mode', + ); + expect(autoUpdater.checkForUpdatesAndNotify).not.toHaveBeenCalled(); + }); + + it('handles checking-for-update', async () => { + await updater.start(); + emit('checking-for-update'); + expect(menuBuilder.setCheckForUpdatesMenuEnabled).toHaveBeenCalledWith( + false, + ); + expect( + menuBuilder.setNoUpdateAvailableMenuVisibility, + ).toHaveBeenCalledWith(false); + }); + + it('handles update-available', async () => { + await updater.start(); + emit('update-available'); + expect(menuBuilder.setUpdateAvailableMenuVisibility).toHaveBeenCalledWith( + true, + ); + expect(menubar.tray.setToolTip).toHaveBeenCalledWith( + expect.stringContaining('A new update is available'), + ); + }); + + it('handles download-progress', async () => { + await updater.start(); + emit('download-progress', { percent: 12.3456 }); + expect(menubar.tray.setToolTip).toHaveBeenCalledWith( + expect.stringContaining('12.35%'), + ); + }); + + it('handles update-not-available', async () => { + await updater.start(); + emit('update-not-available'); + expect(menuBuilder.setCheckForUpdatesMenuEnabled).toHaveBeenCalledWith( + true, + ); + expect( + menuBuilder.setNoUpdateAvailableMenuVisibility, + ).toHaveBeenCalledWith(true); + expect(menuBuilder.setUpdateAvailableMenuVisibility).toHaveBeenCalledWith( + false, + ); + expect( + menuBuilder.setUpdateReadyForInstallMenuVisibility, + ).toHaveBeenCalledWith(false); + }); + + it('handles update-cancelled (reset state)', async () => { + await updater.start(); + emit('update-cancelled'); + expect(menubar.tray.setToolTip).toHaveBeenCalledWith(APPLICATION.NAME); + expect(menuBuilder.setCheckForUpdatesMenuEnabled).toHaveBeenCalledWith( + true, + ); + }); + + it('handles error (reset + logError)', async () => { + await updater.start(); + const err = new Error('failure'); + emit('error', err); + expect(logError).toHaveBeenCalledWith( + 'auto updater', + 'Error checking for update', + err, + ); + expect(menubar.tray.setToolTip).toHaveBeenCalledWith(APPLICATION.NAME); + }); + + it('performs initial check and schedules periodic checks', async () => { + const originalSetInterval = global.setInterval; + const setIntervalSpy = jest + .spyOn(global, 'setInterval') + .mockImplementation(((fn: () => void) => { + fn(); + return 0 as unknown as NodeJS.Timer; + }) as unknown as typeof setInterval); + try { + await updater.start(); + // initial + immediate scheduled invocation + expect( + (autoUpdater.checkForUpdatesAndNotify as jest.Mock).mock.calls.length, + ).toBe(2); + expect(setIntervalSpy).toHaveBeenCalledWith( + expect.any(Function), + APPLICATION.UPDATE_CHECK_INTERVAL_MS, + ); + } finally { + setIntervalSpy.mockRestore(); + global.setInterval = originalSetInterval; + } + }); + }); +}); diff --git a/src/main/updater.ts b/src/main/updater.ts index 2c80fb4b5..c1f5202a8 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,37 +1,64 @@ +import { dialog, type MessageBoxOptions } from 'electron'; import log from 'electron-log'; import { autoUpdater } from 'electron-updater'; import type { Menubar } from 'menubar'; -import { updateElectronApp } from 'update-electron-app'; import { APPLICATION } from '../shared/constants'; import { logError, logInfo } from '../shared/logger'; import type MenuBuilder from './menu'; -export default class Updater { +/** + * Updater class for handling application updates. + * + * Supports scheduled and manual updates for all platforms. + * + * Documentation: https://www.electron.build/auto-update + * + * NOTE: previously used update-electron-app (Squirrel-focused, no Linux + NSIS). electron-updater gives cross-platform support. + * Caller guarantees app is ready before initialize() is invoked. + */ +export default class AppUpdater { private readonly menubar: Menubar; private readonly menuBuilder: MenuBuilder; + private started = false; constructor(menubar: Menubar, menuBuilder: MenuBuilder) { this.menubar = menubar; this.menuBuilder = menuBuilder; + autoUpdater.logger = log; } - initialize(): void { - updateElectronApp({ - updateInterval: '24 hours', - logger: log, - }); + async start(): Promise { + if (this.started) { + return; // idempotent + } + + if (!this.menubar.app.isPackaged) { + logInfo( + 'app updater', + 'Skipping updater since app is in development mode', + ); + return; + } + + logInfo('app updater', 'Starting updater'); + + this.registerListeners(); + await this.performInitialCheck(); + this.schedulePeriodicChecks(); + this.started = true; + } + + private registerListeners() { autoUpdater.on('checking-for-update', () => { logInfo('auto updater', 'Checking for update'); - this.menuBuilder.setCheckForUpdatesMenuEnabled(false); this.menuBuilder.setNoUpdateAvailableMenuVisibility(false); }); autoUpdater.on('update-available', () => { - logInfo('auto updater', 'New update available'); - + logInfo('auto updater', 'Update available'); this.setTooltipWithStatus('A new update is available'); this.menuBuilder.setUpdateAvailableMenuVisibility(true); }); @@ -42,17 +69,16 @@ export default class Updater { ); }); - autoUpdater.on('update-downloaded', () => { + autoUpdater.on('update-downloaded', (event) => { logInfo('auto updater', 'Update downloaded'); - this.setTooltipWithStatus('A new update is ready to install'); this.menuBuilder.setUpdateAvailableMenuVisibility(false); this.menuBuilder.setUpdateReadyForInstallMenuVisibility(true); + this.showUpdateReadyDialog(event.releaseName); }); autoUpdater.on('update-not-available', () => { logInfo('auto updater', 'Update not available'); - this.menuBuilder.setCheckForUpdatesMenuEnabled(true); this.menuBuilder.setNoUpdateAvailableMenuVisibility(true); this.menuBuilder.setUpdateAvailableMenuVisibility(false); @@ -61,17 +87,35 @@ export default class Updater { autoUpdater.on('update-cancelled', () => { logInfo('auto updater', 'Update cancelled'); - this.resetState(); }); autoUpdater.on('error', (err) => { logError('auto updater', 'Error checking for update', err); - this.resetState(); }); } + private async performInitialCheck() { + try { + logInfo('app updater', 'Checking for updates on application launch'); + await autoUpdater.checkForUpdatesAndNotify(); + } catch (e) { + logError('auto updater', 'Initial check failed', e as Error); + } + } + + private schedulePeriodicChecks() { + setInterval(async () => { + try { + logInfo('app updater', 'Checking for updates on a periodic schedule'); + await autoUpdater.checkForUpdatesAndNotify(); + } catch (e) { + logError('auto updater', 'Scheduled check failed', e as Error); + } + }, APPLICATION.UPDATE_CHECK_INTERVAL_MS); + } + private setTooltipWithStatus(status: string) { this.menubar.tray.setToolTip(`${APPLICATION.NAME}\n${status}`); } @@ -83,4 +127,19 @@ export default class Updater { this.menuBuilder.setUpdateAvailableMenuVisibility(false); this.menuBuilder.setUpdateReadyForInstallMenuVisibility(false); } + + private showUpdateReadyDialog(releaseName: string) { + const dialogOpts: MessageBoxOptions = { + type: 'info', + buttons: ['Restart', 'Later'], + title: 'Application Update', + message: `${APPLICATION.NAME} ${releaseName} has been downloaded`, + detail: + 'Restart to apply the update. You can also restart later from the tray menu.', + }; + + dialog.showMessageBox(dialogOpts).then((returnValue) => { + if (returnValue.response === 0) autoUpdater.quitAndInstall(); + }); + } } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index f0133d7fe..84f8754c7 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -8,4 +8,6 @@ export const APPLICATION = { FIRST_RUN_FOLDER: 'gitify-first-run', WEBSITE: 'https://gitify.io', + + UPDATE_CHECK_INTERVAL_MS: 24 * 60 * 60 * 1000, // 24 hours };