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
1 change: 1 addition & 0 deletions src/main/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './app';
export * from './storage';
export * from './system';
export * from './tray';
export * from './updater';
44 changes: 44 additions & 0 deletions src/main/handlers/updater.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { EVENTS } from '../../shared/events';

import type AppUpdater from '../updater';
import { registerUpdaterHandlers } from './updater';

const onMock = vi.fn();

vi.mock('electron', () => ({
ipcMain: {
on: (...args: unknown[]) => onMock(...args),
} satisfies Pick<Electron.IpcMain, 'on'>,
}));

describe('main/handlers/updater.ts', () => {
let appUpdater: AppUpdater;

beforeEach(() => {
appUpdater = {
setNotificationsEnabled: vi.fn(),
start: vi.fn().mockResolvedValue(undefined),
} as unknown as AppUpdater;
});

it('registers the update notification preference handler', () => {
registerUpdaterHandlers(appUpdater);

expect(onMock.mock.calls.map((call: unknown[]) => call[0])).toContain(
EVENTS.UPDATE_SHOW_UPDATE_NOTIFICATIONS,
);
});

it.each([false, true])('applies the preference and starts update checks for %s', (enabled) => {
registerUpdaterHandlers(appUpdater);

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);
});
});
16 changes: 16 additions & 0 deletions src/main/handlers/updater.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { EVENTS } from '../../shared/events';

import { onMainEvent } from '../events';
import type AppUpdater from '../updater';

/**
* Register IPC handlers for the application updater.
*
* @param appUpdater - The updater instance 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();
});
}
4 changes: 2 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
registerStorageHandlers,
registerSystemHandlers,
registerTrayHandlers,
registerUpdaterHandlers,
} from './handlers';
import { TrayIcons } from './icons';
import {
Expand Down Expand Up @@ -53,8 +54,6 @@ const appUpdater = new AppUpdater(mb, menuBuilder);
app.whenReady().then(async () => {
await onFirstRunMaybe();

appUpdater.start();

initializeAppLifecycle(mb, contextMenu, protocol);

// Configure window event handlers (Escape key, DevTools resize)
Expand All @@ -65,6 +64,7 @@ app.whenReady().then(async () => {
registerSystemHandlers(mb);
registerStorageHandlers();
registerAppHandlers(mb);
registerUpdaterHandlers(appUpdater);
});

// Handle gitify:// custom protocol URL events for OAuth 2.0 callback
Expand Down
45 changes: 45 additions & 0 deletions src/main/updater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ vi.mock('electron-updater', () => ({
listeners[event].push(cb);
return this;
}),
checkForUpdates: vi.fn().mockResolvedValue(undefined),
checkForUpdatesAndNotify: vi.fn().mockResolvedValue(undefined),
quitAndInstall: vi.fn(),
},
Expand Down Expand Up @@ -141,6 +142,18 @@ 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();

emit('update-downloaded', { releaseName: 'v1.2.3' });

expect(dialog.showMessageBox).not.toHaveBeenCalled();
expect(menuBuilder.setUpdateAvailableMenuVisibility).toHaveBeenCalledWith(false);
expect(menuBuilder.setUpdateReadyForInstallMenuVisibility).toHaveBeenCalledWith(true);
});

it('invokes quitAndInstall when user clicks Restart', async () => {
vi.mocked(dialog.showMessageBox).mockResolvedValue({
response: 0, // "Restart" button index
Expand Down Expand Up @@ -187,6 +200,38 @@ describe('main/updater.ts', () => {
expect(autoUpdater.checkForUpdatesAndNotify).not.toHaveBeenCalled();
});

it('starts only once when settings updates arrive concurrently', async () => {
await Promise.all([updater.start(), updater.start()]);

expect(autoUpdater.checkForUpdatesAndNotify).toHaveBeenCalledTimes(1);
});

it('checks silently when update notifications are disabled', async () => {
updater.setNotificationsEnabled(false);

await updater.start();

expect(autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1);
expect(autoUpdater.checkForUpdatesAndNotify).not.toHaveBeenCalled();
});

it('keeps silent update checks running on schedule when notifications are disabled', async () => {
vi.useFakeTimers();
try {
updater.setNotificationsEnabled(false);

await updater.start();
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();

Expand Down
36 changes: 31 additions & 5 deletions src/main/updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,14 @@ import type MenuBuilder from './menu';
export default class AppUpdater {
private readonly menubar: Menubar;
private readonly menuBuilder: MenuBuilder;
private notificationsEnabled = true;
private started = false;
private noUpdateMessageTimeout?: NodeJS.Timeout;

/**
* @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.
*/
constructor(menubar: Menubar, menuBuilder: MenuBuilder) {
this.menubar = menubar;
this.menuBuilder = menuBuilder;
Expand All @@ -30,6 +35,14 @@ export default class AppUpdater {
autoUpdater.logger = null;
}

/**
* Enable or suppress update notifications without changing update checks,
* downloads, or menubar state.
*/
setNotificationsEnabled(enabled: boolean): void {
this.notificationsEnabled = enabled;
}

/**
* Start the updater: register event listeners, perform the initial update check,
* and schedule periodic checks. Idempotent — safe to call multiple times.
Expand All @@ -46,11 +59,10 @@ export default class AppUpdater {

logInfo('app updater', 'Starting updater');

this.started = true;
this.registerListeners();
await this.performInitialCheck();
this.schedulePeriodicChecks();

this.started = true;
}

/**
Expand Down Expand Up @@ -81,7 +93,9 @@ export default class AppUpdater {
this.setTooltipWithStatus('A new update is ready to install');
this.menuBuilder.setUpdateAvailableMenuVisibility(false);
this.menuBuilder.setUpdateReadyForInstallMenuVisibility(true);
this.showUpdateReadyDialog(event.releaseName ?? event.version);
if (this.notificationsEnabled) {
this.showUpdateReadyDialog(event.releaseName ?? event.version);
}
});

autoUpdater.on('update-not-available', () => {
Expand Down Expand Up @@ -115,7 +129,7 @@ export default class AppUpdater {
private async performInitialCheck() {
try {
logInfo('app updater', 'Checking for updates on application launch');
await autoUpdater.checkForUpdatesAndNotify();
await this.checkForUpdates();
} catch (err) {
logError('auto updater', 'Initial check failed', toError(err));
}
Expand All @@ -128,7 +142,7 @@ export default class AppUpdater {
const runScheduledCheck = async () => {
try {
logInfo('app updater', 'Checking for updates on a periodic schedule');
await autoUpdater.checkForUpdatesAndNotify();
await this.checkForUpdates();
} catch (e) {
logError('auto updater', 'Scheduled check failed', toError(e));
}
Expand All @@ -142,6 +156,18 @@ export default class AppUpdater {
}, APPLICATION.UPDATE_CHECK_INTERVAL_MS);
}

/**
* Check and download updates, using electron-updater's native notification
* only when the user has opted in.
*/
private async checkForUpdates() {
if (this.notificationsEnabled) {
return await autoUpdater.checkForUpdatesAndNotify();
}

return await autoUpdater.checkForUpdates();
}

/**
* Update the tray tooltip to show the application name alongside a status message.
*
Expand Down
9 changes: 9 additions & 0 deletions src/preload/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class MockNotification {
interface TestApi {
tray: { updateColor: (n?: number, isOnline?: boolean) => void };
openExternalLink: (u: string, f: boolean) => void;
setShowUpdateNotifications: (value: boolean) => void;
setUseX11Backend: (value: boolean) => void;
app: { version: () => Promise<string>; show?: () => void; hide?: () => void };
raiseNativeNotification: (t: string, b: string, u?: string) => unknown;
Expand Down Expand Up @@ -119,6 +120,14 @@ describe('preload/index', () => {
});
});

it('setShowUpdateNotifications sends the preference to main', () => {
const api = getExposedApi();

api.setShowUpdateNotifications(false);

expect(sendMainEventMock).toHaveBeenCalledWith(EVENTS.UPDATE_SHOW_UPDATE_NOTIFICATIONS, false);
});

it('app.version returns dev in development', async () => {
vi.stubEnv('NODE_ENV', 'development');
const api = getExposedApi();
Expand Down
11 changes: 11 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ 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),

/**
* Persist whether Linux should run under the X11 backend. Applied at startup,
* so the change only takes effect after the app is restarted.
Expand Down
1 change: 1 addition & 0 deletions src/renderer/__helpers__/visual.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ function createGitifyBridgeApi(): Window['gitify'] {
onSystemWake: vi.fn(() => vi.fn()),
setAutoLaunch: vi.fn(),
setKeepWindowOnBlur: vi.fn(),
setShowUpdateNotifications: vi.fn(),
setUseX11Backend: vi.fn(),
applyKeyboardShortcut: vi.fn().mockResolvedValue({ success: true }),
raiseNativeNotification: vi.fn(),
Expand Down
1 change: 1 addition & 0 deletions src/renderer/__helpers__/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ function createGitifyBridgeApi(): Window['gitify'] {
onSystemWake: vi.fn(() => vi.fn()),
setAutoLaunch: vi.fn(),
setKeepWindowOnBlur: vi.fn(),
setShowUpdateNotifications: vi.fn(),
setUseX11Backend: vi.fn(),
applyKeyboardShortcut: vi.fn().mockResolvedValue({ success: true }),
raiseNativeNotification: vi.fn(),
Expand Down
1 change: 1 addition & 0 deletions src/renderer/__mocks__/state-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const mockSystemSettings: SystemSettingsState = {
notificationVolume: 20 as Percentage,
openAtStartup: false,
keepWindowOnBlur: false,
showUpdateNotifications: true,
useX11Backend: false,
};

Expand Down
1 change: 1 addition & 0 deletions src/renderer/components/settings/SystemSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ describe('renderer/components/settings/SystemSettings.tsx', () => {
it.each([
['checkbox-keyboardShortcut', 'keyboardShortcut'],
['checkbox-showNotifications', 'showNotifications'],
['checkbox-showUpdateNotifications', 'showUpdateNotifications'],
['checkbox-openAtStartup', 'openAtStartup'],
['checkbox-keepWindowOnBlur', 'keepWindowOnBlur'],
] as const)('should toggle %s checkbox', async (testId, setting) => {
Expand Down
14 changes: 14 additions & 0 deletions src/renderer/components/settings/SystemSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const SystemSettings: FC = () => {
const notificationVolume = useSettingsStore((s) => s.notificationVolume);
const keepWindowOnBlur = useSettingsStore((s) => s.keepWindowOnBlur);
const openAtStartup = useSettingsStore((s) => s.openAtStartup);
const showUpdateNotifications = useSettingsStore((s) => s.showUpdateNotifications);
const useX11Backend = useSettingsStore((s) => s.useX11Backend);

const [recordingShortcut, setRecordingShortcut] = useState(false);
Expand Down Expand Up @@ -345,6 +346,19 @@ export const SystemSettings: FC = () => {
visible={!window.gitify.platform.isLinux()}
/>

<Checkbox
checked={showUpdateNotifications}
label="Show update notifications"
name="showUpdateNotifications"
onChange={() => toggleSetting('showUpdateNotifications')}
tooltip={
<Text>
Show a notification when a {APPLICATION.NAME} update is ready. Updates will still be
checked and shown in the menu bar.
</Text>
}
/>

<Checkbox
checked={useX11Backend}
label="Use X11 backend (restart required)"
Expand Down
Loading
Loading