Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/__helpers__/gitify-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
81 changes: 53 additions & 28 deletions src/main/handlers/updater.test.ts
Original file line number Diff line number Diff line change
@@ -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<Electron.IpcMain, 'on'>,
let applyPreferences: (event: unknown, preferences: unknown) => Promise<void>;
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<UpdateManager | null>();
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();
});
});
44 changes: 38 additions & 6 deletions src/main/handlers/updater.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,48 @@
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';

/**
* Register IPC handlers for the application updater.
*
* @param appUpdater - The updater instance configured by the renderer's persisted settings.
*/
export function registerUpdaterHandlers(appUpdater: AppUpdater): void {
onMainEvent(EVENTS.UPDATE_SHOW_UPDATE_NOTIFICATIONS, (_, enabled) => {
appUpdater.setNotificationsEnabled(enabled);
void appUpdater.start();
export function registerUpdaterHandlers(
appUpdater: Pick<AppUpdater, 'setNotificationsEnabled' | 'setAutomaticUpdatesEnabled'>,
): 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),
);
});
}
52 changes: 30 additions & 22 deletions src/main/menu.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -21,9 +20,10 @@ const menuItemInstances: Array<{

vi.mock('electron', () => {
class MockMenuItem {
label?: string;
constructor(opts: Record<string, unknown>) {
Object.assign(this, opts);
menuItemInstances.push(opts as (typeof menuItemInstances)[number]);
menuItemInstances.push(this);
}
}
return {
Expand All @@ -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),
Expand All @@ -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) =>
Expand Down Expand Up @@ -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));
});
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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),
),
);
Expand Down
40 changes: 35 additions & 5 deletions src/main/menu.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -22,6 +21,9 @@ export default class MenuBuilder {

private readonly menubar: Menubar;
private menu?: Menu;
private automaticUpdatesEnabled = false;
private checkForUpdates = async (): Promise<void> => {};
private installUpdate = (): void => {};

/**
* @param menubar - The menubar instance used for window and app interactions within menu actions.
Expand All @@ -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));
});
},
});
Expand All @@ -56,7 +63,9 @@ export default class MenuBuilder {
enabled: true,
visible: false,
click: () => {
autoUpdater.quitAndInstall();
if (this.automaticUpdatesEnabled) {
this.installUpdate();
}
},
});

Expand Down Expand Up @@ -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<void>; 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.
Expand Down
Loading
Loading