diff --git a/src/main/index.ts b/src/main/index.ts index 11eb6c3be..512f3b799 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,6 +17,7 @@ import { onFirstRunMaybe, } from './lifecycle'; import MenuBuilder from './menu'; +import { detectPackageManager } from './packageManager'; import AppUpdater from './updater'; import { isDevMode } from './utils'; @@ -36,14 +37,18 @@ const mb = menubar({ escapeToHide: true, // Hide the window when Escape is pressed. }); -const menuBuilder = new MenuBuilder(mb); +// A package manager that installed the app also owns its updates, which shapes +// both the update menu items and whether the updater runs at all +const packageManager = detectPackageManager(); + +const menuBuilder = new MenuBuilder(mb, packageManager); const contextMenu = menuBuilder.buildMenu(); // Register your app as the handler for a custom protocol const protocol = isDevMode() ? 'gitify-dev' : 'gitify'; app.setAsDefaultProtocolClient(protocol); -const appUpdater = new AppUpdater(mb, menuBuilder); +const appUpdater = new AppUpdater(mb, menuBuilder, packageManager); app.whenReady().then(async () => { await onFirstRunMaybe(); diff --git a/src/main/menu.test.ts b/src/main/menu.test.ts index 66b78a791..5e29197fd 100644 --- a/src/main/menu.test.ts +++ b/src/main/menu.test.ts @@ -100,7 +100,7 @@ describe('main/menu.ts', () => { setContextMenu: vi.fn(), }, } as unknown as Menubar; - menuBuilder = new MenuBuilder(menubar); + menuBuilder = new MenuBuilder(menubar, null); }); describe('checkForUpdatesMenuItem', () => { @@ -210,6 +210,34 @@ describe('main/menu.ts', () => { }); }); + describe('package managed updates', () => { + beforeEach(() => { + menuItemInstances.length = 0; + menuBuilder = new MenuBuilder(menubar, 'Homebrew'); + }); + + it('names the package manager that owns updates', () => { + menuBuilder.buildMenu(); + + const config = getMenuItemConfigByLabel('Updates are managed by Homebrew'); + + expect(config).toBeDefined(); + expect(config?.enabled).toBe(false); + expect(config?.click).toBeUndefined(); + }); + + it('replaces the update menu items with the notice', () => { + const template = buildAndGetTemplate(); + const labels = template.map((item) => item?.label); + + expect(labels).toContain('Updates are managed by Homebrew'); + expect(labels).not.toContain('Check for updates'); + expect(labels).not.toContain('No updates available'); + expect(labels).not.toContain('An update is available'); + expect(labels).not.toContain('Restart to install update'); + }); + }); + describe('windowVisibilityMenuItems', () => { it('show item is visible by default; hide item is not', () => { const showCfg = getMenuItemConfigByLabel(`Show ${APPLICATION.NAME}`); @@ -334,9 +362,12 @@ describe('main/menu.ts', () => { menuItemInstances.length = 0; (Menu.buildFromTemplate as Mock).mockClear(); - const mb = new MenuBuilder({ - app: { quit: vi.fn() }, - } as unknown as Menubar); + const mb = new MenuBuilder( + { + app: { quit: vi.fn() }, + } as unknown as Menubar, + null, + ); mb.buildMenu(); const template = (Menu.buildFromTemplate as Mock).mock.calls.slice( @@ -353,9 +384,12 @@ describe('main/menu.ts', () => { menuItemInstances.length = 0; (Menu.buildFromTemplate as Mock).mockClear(); - const mb = new MenuBuilder({ - app: { quit: vi.fn() }, - } as unknown as Menubar); + const mb = new MenuBuilder( + { + app: { quit: vi.fn() }, + } as unknown as Menubar, + null, + ); mb.buildMenu(); const template = (Menu.buildFromTemplate as Mock).mock.calls.slice( diff --git a/src/main/menu.ts b/src/main/menu.ts index e14535ff0..3f4f48632 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -6,6 +6,7 @@ import { APPLICATION } from '../shared/constants'; import { isMacOS } from '../shared/platform'; import { resetApp } from './lifecycle/reset'; +import type { PackageManager } from './packageManager'; import { openLogsDirectory, takeScreenshot } from './utils'; /** @@ -20,13 +21,16 @@ export default class MenuBuilder { private readonly hideWindowMenuItem: MenuItem; private readonly menubar: Menubar; + private readonly packageManager: PackageManager | null; private menu?: Menu; /** * @param menubar - The menubar instance used for window and app interactions within menu actions. + * @param packageManager - The package manager that owns this install, or `null` when the app was installed manually. */ - constructor(menubar: Menubar) { + constructor(menubar: Menubar, packageManager: PackageManager | null) { this.menubar = menubar; + this.packageManager = packageManager; this.checkForUpdatesMenuItem = new MenuItem({ label: 'Check for updates', @@ -82,10 +86,7 @@ export default class MenuBuilder { this.showWindowMenuItem, this.hideWindowMenuItem, { type: 'separator' }, - this.checkForUpdatesMenuItem, - this.noUpdateAvailableMenuItem, - this.updateAvailableMenuItem, - this.updateReadyForInstallMenuItem, + ...this.buildUpdateMenuItems(), { type: 'separator' }, { label: 'Developer', @@ -140,6 +141,30 @@ export default class MenuBuilder { return this.menu; } + /** + * Build the update section of the menu. + * + * A package managed install never checks for updates, so it gets a note naming + * the package manager to update through instead of controls that do nothing. + */ + private buildUpdateMenuItems(): MenuItem[] { + if (this.packageManager) { + return [ + new MenuItem({ + label: `Updates are managed by ${this.packageManager}`, + enabled: false, + }), + ]; + } + + return [ + this.checkForUpdatesMenuItem, + this.noUpdateAvailableMenuItem, + this.updateAvailableMenuItem, + this.updateReadyForInstallMenuItem, + ]; + } + /** * Reflect the current window visibility in the Show / Hide menu items. * `electron-menubar` re-publishes the menu to the SNI host on every diff --git a/src/main/packageManager.test.ts b/src/main/packageManager.test.ts new file mode 100644 index 000000000..946a60a2f --- /dev/null +++ b/src/main/packageManager.test.ts @@ -0,0 +1,120 @@ +import { isMacOS } from '../shared/platform'; + +const APP_BUNDLE_PATH = '/Applications/Gitify.app'; +const APP_EXE_PATH = `${APP_BUNDLE_PATH}/Contents/MacOS/Gitify`; + +/** Fake file system: directory listings and the paths that symlinks resolve to */ +const fileSystem: { + directories: Record; + resolvedPaths: Record; +} = { + directories: {}, + resolvedPaths: {}, +}; + +const readdirSync = (directoryPath: string) => { + const entries = fileSystem.directories[directoryPath]; + + if (!entries) { + throw new Error(`ENOENT: no such directory, scandir '${directoryPath}'`); + } + + return entries; +}; + +const realpathSync = (targetPath: string) => { + const resolvedPath = fileSystem.resolvedPaths[targetPath]; + + if (!resolvedPath) { + throw new Error(`ENOENT: no such file or directory, lstat '${targetPath}'`); + } + + return resolvedPath; +}; + +vi.mock('node:fs', () => ({ + default: { + readdirSync: (directoryPath: string) => readdirSync(directoryPath), + realpathSync: (targetPath: string) => realpathSync(targetPath), + }, +})); + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => APP_EXE_PATH), + } satisfies Pick, +})); + +vi.mock('../shared/platform', () => ({ + isMacOS: vi.fn(), +})); + +import { detectPackageManager } from './packageManager'; + +describe('main/packageManager.ts', () => { + /** Install the cask metadata that a `brew install --cask gitify` leaves behind */ + const givenHomebrewCaskInstall = (prefix: string, bundlePath = APP_BUNDLE_PATH) => { + fileSystem.directories[`${prefix}/Caskroom/gitify`] = ['.metadata', '7.4.0']; + fileSystem.resolvedPaths[`${prefix}/Caskroom/gitify/7.4.0/Gitify.app`] = bundlePath; + }; + + beforeEach(() => { + fileSystem.directories = {}; + fileSystem.resolvedPaths = { [APP_BUNDLE_PATH]: APP_BUNDLE_PATH }; + + vi.stubEnv('HOMEBREW_PREFIX', ''); + vi.mocked(isMacOS).mockReturnValue(true); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe('detectPackageManager', () => { + it('returns null for a manual install', () => { + expect(detectPackageManager()).toBeNull(); + }); + + it.each(['/opt/homebrew', '/usr/local'])( + 'detects a Homebrew cask install under %s', + (prefix) => { + givenHomebrewCaskInstall(prefix); + + expect(detectPackageManager()).toBe('Homebrew'); + }, + ); + + it('detects a Homebrew cask install under a custom prefix', () => { + vi.stubEnv('HOMEBREW_PREFIX', '/custom/homebrew'); + givenHomebrewCaskInstall('/custom/homebrew'); + + expect(detectPackageManager()).toBe('Homebrew'); + }); + + it('returns null when the cask owns a different copy of the app', () => { + givenHomebrewCaskInstall('/opt/homebrew', '/Users/test/Applications/Gitify.app'); + + expect(detectPackageManager()).toBeNull(); + }); + + it('returns null when the cask is no longer installed', () => { + fileSystem.directories['/opt/homebrew/Caskroom/gitify'] = ['.metadata', '7.4.0']; + + expect(detectPackageManager()).toBeNull(); + }); + + it('returns null when the app bundle cannot be resolved', () => { + fileSystem.resolvedPaths = {}; + givenHomebrewCaskInstall('/opt/homebrew'); + + expect(detectPackageManager()).toBeNull(); + }); + + it('skips Homebrew detection when not running on macOS', () => { + vi.mocked(isMacOS).mockReturnValue(false); + givenHomebrewCaskInstall('/opt/homebrew'); + + expect(detectPackageManager()).toBeNull(); + }); + }); +}); diff --git a/src/main/packageManager.ts b/src/main/packageManager.ts new file mode 100644 index 000000000..fd0526217 --- /dev/null +++ b/src/main/packageManager.ts @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { app } from 'electron'; + +import { APPLICATION } from '../shared/constants'; +import { isMacOS } from '../shared/platform'; + +/** Package managers that we can detect as the owner of the installed app. */ +export type PackageManager = 'Homebrew'; + +/** Homebrew cask that installs Gitify: https://formulae.brew.sh/cask/gitify */ +const HOMEBREW_CASK_TOKEN = 'gitify'; + +/** Default Homebrew prefixes, for Apple Silicon and Intel respectively. */ +const HOMEBREW_PREFIXES = ['/opt/homebrew', '/usr/local']; + +/** + * Detect the package manager that installed the running app, if any. + * + * @returns The package manager that owns this install, or `null` when the app was installed manually. + */ +export function detectPackageManager(): PackageManager | null { + if (isMacOS() && isHomebrewCask()) { + return 'Homebrew'; + } + + return null; +} + +/** + * Determine whether the running app bundle was installed by the Homebrew cask. + * + * A cask keeps `/Caskroom/gitify//Gitify.app` as a symlink to the + * bundle it installed (`/Applications/Gitify.app` by default). Resolving those + * symlinks tells us whether Homebrew owns *this* copy of the app, rather than a + * manually installed one running alongside a cask install. + */ +function isHomebrewCask(): boolean { + const bundlePath = appBundlePath(); + + if (!bundlePath) { + return false; + } + + for (const prefix of homebrewPrefixes()) { + const caskPath = path.join(prefix, 'Caskroom', HOMEBREW_CASK_TOKEN); + + for (const version of readDirectory(caskPath)) { + const caskBundlePath = path.join(caskPath, version, `${APPLICATION.NAME}.app`); + + if (resolvePath(caskBundlePath) === bundlePath) { + return true; + } + } + } + + return false; +} + +/** + * The Homebrew prefixes to search for a cask install. + * + * `HOMEBREW_PREFIX` covers a custom prefix, though it is only set when the app was + * launched from a shell that exported it - hence the standard prefixes as well. + */ +function homebrewPrefixes(): string[] { + const customPrefix = process.env.HOMEBREW_PREFIX; + + return customPrefix ? [customPrefix, ...HOMEBREW_PREFIXES] : HOMEBREW_PREFIXES; +} + +/** + * The resolved path of the running application bundle, ie `/Applications/Gitify.app`. + * + * @returns The bundle path, or `null` if it cannot be resolved. + */ +function appBundlePath(): string | null { + // `Gitify.app/Contents/MacOS/Gitify` -> `Gitify.app` + return resolvePath(path.resolve(app.getPath('exe'), '..', '..', '..')); +} + +/** + * Read the entries of a directory, treating an unreadable directory as empty. + */ +function readDirectory(directoryPath: string): string[] { + try { + return fs.readdirSync(directoryPath); + } catch { + return []; + } +} + +/** + * Resolve a path to its canonical location, following any symlinks. + * + * @returns The canonical path, or `null` if the path does not exist. + */ +function resolvePath(targetPath: string): string | null { + try { + return fs.realpathSync(targetPath); + } catch { + return null; + } +} diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 43c9babd0..08cc81c78 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -94,8 +94,8 @@ describe('main/updater.ts', () => { tray: { setToolTip: vi.fn() }, } as unknown as Menubar; - menuBuilder = new TestMenuBuilder(menubar); - updater = new AppUpdater(menubar, menuBuilder); + menuBuilder = new TestMenuBuilder(menubar, null); + updater = new AppUpdater(menubar, menuBuilder, null); }); describe('update available dialog', () => { @@ -187,6 +187,18 @@ describe('main/updater.ts', () => { expect(autoUpdater.checkForUpdatesAndNotify).not.toHaveBeenCalled(); }); + it('skips when the app was installed by a package manager', async () => { + updater = new AppUpdater(menubar, menuBuilder, 'Homebrew'); + + await updater.start(); + + expect(logInfo).toHaveBeenCalledWith( + 'app updater', + 'Skipping updater since app was installed via Homebrew', + ); + expect(autoUpdater.checkForUpdatesAndNotify).not.toHaveBeenCalled(); + }); + it('handles checking-for-update', async () => { await updater.start(); diff --git a/src/main/updater.ts b/src/main/updater.ts index a41eacd9f..d03d50af7 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -6,11 +6,13 @@ import { APPLICATION } from '../shared/constants'; import { logError, logInfo, toError } from '../shared/logger'; import type MenuBuilder from './menu'; +import type { PackageManager } from './packageManager'; /** * Updater class for handling application updates. * - * Supports scheduled and manual updates for all platforms. + * Supports scheduled and manual updates for all platforms, except for installs + * owned by a package manager - those update through the package manager instead. * * Documentation: https://www.electron.build/auto-update * @@ -19,12 +21,19 @@ import type MenuBuilder from './menu'; export default class AppUpdater { private readonly menubar: Menubar; private readonly menuBuilder: MenuBuilder; + private readonly packageManager: PackageManager | null; private started = false; private noUpdateMessageTimeout?: NodeJS.Timeout; - constructor(menubar: Menubar, menuBuilder: MenuBuilder) { + /** + * @param menubar - The menubar instance whose tray and window the updater reports status through. + * @param menuBuilder - The menu builder whose update menu items track the update state. + * @param packageManager - The package manager that owns this install, or `null` when the app was installed manually. + */ + constructor(menubar: Menubar, menuBuilder: MenuBuilder, packageManager: PackageManager | null) { this.menubar = menubar; this.menuBuilder = menuBuilder; + this.packageManager = packageManager; // Disable electron-updater's own logging to avoid duplicate log messages // We'll handle all logging through our event listeners autoUpdater.logger = null; @@ -44,6 +53,11 @@ export default class AppUpdater { return; } + if (this.packageManager) { + logInfo('app updater', `Skipping updater since app was installed via ${this.packageManager}`); + return; + } + logInfo('app updater', 'Starting updater'); this.registerListeners();