Skip to content

Commit 54cf67a

Browse files
feat(updater): add setting to opt-out of update notifications (#3201)
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
1 parent d7df927 commit 54cf67a

21 files changed

Lines changed: 278 additions & 13 deletions

File tree

‎src/main/handlers/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './app';
22
export * from './storage';
33
export * from './system';
44
export * from './tray';
5+
export * from './updater';

‎src/main/handlers/updater.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { EVENTS } from '../../shared/events';
2+
3+
import type AppUpdater from '../updater';
4+
import { registerUpdaterHandlers } from './updater';
5+
6+
const onMock = vi.fn();
7+
8+
vi.mock('electron', () => ({
9+
ipcMain: {
10+
on: (...args: unknown[]) => onMock(...args),
11+
} satisfies Pick<Electron.IpcMain, 'on'>,
12+
}));
13+
14+
describe('main/handlers/updater.ts', () => {
15+
let appUpdater: AppUpdater;
16+
17+
beforeEach(() => {
18+
appUpdater = {
19+
setNotificationsEnabled: vi.fn(),
20+
start: vi.fn().mockResolvedValue(undefined),
21+
} as unknown as AppUpdater;
22+
});
23+
24+
it('registers the update notification preference handler', () => {
25+
registerUpdaterHandlers(appUpdater);
26+
27+
expect(onMock.mock.calls.map((call: unknown[]) => call[0])).toContain(
28+
EVENTS.UPDATE_SHOW_UPDATE_NOTIFICATIONS,
29+
);
30+
});
31+
32+
it.each([false, true])('applies the preference and starts update checks for %s', (enabled) => {
33+
registerUpdaterHandlers(appUpdater);
34+
35+
const listener = onMock.mock.calls.find(
36+
(call: unknown[]) => call[0] === EVENTS.UPDATE_SHOW_UPDATE_NOTIFICATIONS,
37+
)?.[1] as (event: unknown, enabled: boolean) => void;
38+
39+
listener(null, enabled);
40+
41+
expect(appUpdater.setNotificationsEnabled).toHaveBeenCalledWith(enabled);
42+
expect(appUpdater.start).toHaveBeenCalledTimes(1);
43+
});
44+
});

‎src/main/handlers/updater.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { EVENTS } from '../../shared/events';
2+
3+
import { onMainEvent } from '../events';
4+
import type AppUpdater from '../updater';
5+
6+
/**
7+
* Register IPC handlers for the application updater.
8+
*
9+
* @param appUpdater - The updater instance configured by the renderer's persisted settings.
10+
*/
11+
export function registerUpdaterHandlers(appUpdater: AppUpdater): void {
12+
onMainEvent(EVENTS.UPDATE_SHOW_UPDATE_NOTIFICATIONS, (_, enabled) => {
13+
appUpdater.setNotificationsEnabled(enabled);
14+
void appUpdater.start();
15+
});
16+
}

‎src/main/index.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
registerStorageHandlers,
99
registerSystemHandlers,
1010
registerTrayHandlers,
11+
registerUpdaterHandlers,
1112
} from './handlers';
1213
import { TrayIcons } from './icons';
1314
import {
@@ -53,8 +54,6 @@ const appUpdater = new AppUpdater(mb, menuBuilder);
5354
app.whenReady().then(async () => {
5455
await onFirstRunMaybe();
5556

56-
appUpdater.start();
57-
5857
initializeAppLifecycle(mb, contextMenu, protocol);
5958

6059
// Configure window event handlers (Escape key, DevTools resize)
@@ -65,6 +64,7 @@ app.whenReady().then(async () => {
6564
registerSystemHandlers(mb);
6665
registerStorageHandlers();
6766
registerAppHandlers(mb);
67+
registerUpdaterHandlers(appUpdater);
6868
});
6969

7070
// Handle gitify:// custom protocol URL events for OAuth 2.0 callback

‎src/main/updater.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ vi.mock('electron-updater', () => ({
3232
listeners[event].push(cb);
3333
return this;
3434
}),
35+
checkForUpdates: vi.fn().mockResolvedValue(undefined),
3536
checkForUpdatesAndNotify: vi.fn().mockResolvedValue(undefined),
3637
quitAndInstall: vi.fn(),
3738
},
@@ -141,6 +142,18 @@ describe('main/updater.ts', () => {
141142
);
142143
});
143144

145+
it('reports a downloaded update in the menu without showing a dialog when notifications are disabled', async () => {
146+
updater.setNotificationsEnabled(false);
147+
148+
await updater.start();
149+
150+
emit('update-downloaded', { releaseName: 'v1.2.3' });
151+
152+
expect(dialog.showMessageBox).not.toHaveBeenCalled();
153+
expect(menuBuilder.setUpdateAvailableMenuVisibility).toHaveBeenCalledWith(false);
154+
expect(menuBuilder.setUpdateReadyForInstallMenuVisibility).toHaveBeenCalledWith(true);
155+
});
156+
144157
it('invokes quitAndInstall when user clicks Restart', async () => {
145158
vi.mocked(dialog.showMessageBox).mockResolvedValue({
146159
response: 0, // "Restart" button index
@@ -187,6 +200,38 @@ describe('main/updater.ts', () => {
187200
expect(autoUpdater.checkForUpdatesAndNotify).not.toHaveBeenCalled();
188201
});
189202

203+
it('starts only once when settings updates arrive concurrently', async () => {
204+
await Promise.all([updater.start(), updater.start()]);
205+
206+
expect(autoUpdater.checkForUpdatesAndNotify).toHaveBeenCalledTimes(1);
207+
});
208+
209+
it('checks silently when update notifications are disabled', async () => {
210+
updater.setNotificationsEnabled(false);
211+
212+
await updater.start();
213+
214+
expect(autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1);
215+
expect(autoUpdater.checkForUpdatesAndNotify).not.toHaveBeenCalled();
216+
});
217+
218+
it('keeps silent update checks running on schedule when notifications are disabled', async () => {
219+
vi.useFakeTimers();
220+
try {
221+
updater.setNotificationsEnabled(false);
222+
223+
await updater.start();
224+
expect(autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1);
225+
226+
await vi.advanceTimersByTimeAsync(APPLICATION.UPDATE_CHECK_INTERVAL_MS);
227+
228+
expect(autoUpdater.checkForUpdates).toHaveBeenCalledTimes(2);
229+
expect(autoUpdater.checkForUpdatesAndNotify).not.toHaveBeenCalled();
230+
} finally {
231+
vi.useRealTimers();
232+
}
233+
});
234+
190235
it('handles checking-for-update', async () => {
191236
await updater.start();
192237

‎src/main/updater.ts‎

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,14 @@ import type MenuBuilder from './menu';
1919
export default class AppUpdater {
2020
private readonly menubar: Menubar;
2121
private readonly menuBuilder: MenuBuilder;
22+
private notificationsEnabled = true;
2223
private started = false;
2324
private noUpdateMessageTimeout?: NodeJS.Timeout;
2425

26+
/**
27+
* @param menubar - The menubar instance whose tray and window the updater reports status through.
28+
* @param menuBuilder - The menu builder whose update menu items track the update state.
29+
*/
2530
constructor(menubar: Menubar, menuBuilder: MenuBuilder) {
2631
this.menubar = menubar;
2732
this.menuBuilder = menuBuilder;
@@ -30,6 +35,14 @@ export default class AppUpdater {
3035
autoUpdater.logger = null;
3136
}
3237

38+
/**
39+
* Enable or suppress update notifications without changing update checks,
40+
* downloads, or menubar state.
41+
*/
42+
setNotificationsEnabled(enabled: boolean): void {
43+
this.notificationsEnabled = enabled;
44+
}
45+
3346
/**
3447
* Start the updater: register event listeners, perform the initial update check,
3548
* and schedule periodic checks. Idempotent — safe to call multiple times.
@@ -46,11 +59,10 @@ export default class AppUpdater {
4659

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

62+
this.started = true;
4963
this.registerListeners();
5064
await this.performInitialCheck();
5165
this.schedulePeriodicChecks();
52-
53-
this.started = true;
5466
}
5567

5668
/**
@@ -81,7 +93,9 @@ export default class AppUpdater {
8193
this.setTooltipWithStatus('A new update is ready to install');
8294
this.menuBuilder.setUpdateAvailableMenuVisibility(false);
8395
this.menuBuilder.setUpdateReadyForInstallMenuVisibility(true);
84-
this.showUpdateReadyDialog(event.releaseName ?? event.version);
96+
if (this.notificationsEnabled) {
97+
this.showUpdateReadyDialog(event.releaseName ?? event.version);
98+
}
8599
});
86100

87101
autoUpdater.on('update-not-available', () => {
@@ -115,7 +129,7 @@ export default class AppUpdater {
115129
private async performInitialCheck() {
116130
try {
117131
logInfo('app updater', 'Checking for updates on application launch');
118-
await autoUpdater.checkForUpdatesAndNotify();
132+
await this.checkForUpdates();
119133
} catch (err) {
120134
logError('auto updater', 'Initial check failed', toError(err));
121135
}
@@ -128,7 +142,7 @@ export default class AppUpdater {
128142
const runScheduledCheck = async () => {
129143
try {
130144
logInfo('app updater', 'Checking for updates on a periodic schedule');
131-
await autoUpdater.checkForUpdatesAndNotify();
145+
await this.checkForUpdates();
132146
} catch (e) {
133147
logError('auto updater', 'Scheduled check failed', toError(e));
134148
}
@@ -142,6 +156,18 @@ export default class AppUpdater {
142156
}, APPLICATION.UPDATE_CHECK_INTERVAL_MS);
143157
}
144158

159+
/**
160+
* Check and download updates, using electron-updater's native notification
161+
* only when the user has opted in.
162+
*/
163+
private async checkForUpdates() {
164+
if (this.notificationsEnabled) {
165+
return await autoUpdater.checkForUpdatesAndNotify();
166+
}
167+
168+
return await autoUpdater.checkForUpdates();
169+
}
170+
145171
/**
146172
* Update the tray tooltip to show the application name alongside a status message.
147173
*

‎src/preload/index.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class MockNotification {
6363
interface TestApi {
6464
tray: { updateColor: (n?: number, isOnline?: boolean) => void };
6565
openExternalLink: (u: string, f: boolean) => void;
66+
setShowUpdateNotifications: (value: boolean) => void;
6667
setUseX11Backend: (value: boolean) => void;
6768
app: { version: () => Promise<string>; show?: () => void; hide?: () => void };
6869
raiseNativeNotification: (t: string, b: string, u?: string) => unknown;
@@ -119,6 +120,14 @@ describe('preload/index', () => {
119120
});
120121
});
121122

123+
it('setShowUpdateNotifications sends the preference to main', () => {
124+
const api = getExposedApi();
125+
126+
api.setShowUpdateNotifications(false);
127+
128+
expect(sendMainEventMock).toHaveBeenCalledWith(EVENTS.UPDATE_SHOW_UPDATE_NOTIFICATIONS, false);
129+
});
130+
122131
it('app.version returns dev in development', async () => {
123132
vi.stubEnv('NODE_ENV', 'development');
124133
const api = getExposedApi();

‎src/preload/index.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@ export const api = {
6363
*/
6464
setKeepWindowOnBlur: (value: boolean) => sendMainEvent(EVENTS.UPDATE_KEEP_WINDOW_ON_BLUR, value),
6565

66+
/**
67+
* Enable or suppress automatic update notifications.
68+
*
69+
* Update checks, downloads, and menubar status continue when notifications
70+
* are suppressed.
71+
*
72+
* @param value - `true` to show update notifications, `false` to suppress them.
73+
*/
74+
setShowUpdateNotifications: (value: boolean) =>
75+
sendMainEvent(EVENTS.UPDATE_SHOW_UPDATE_NOTIFICATIONS, value),
76+
6677
/**
6778
* Persist whether Linux should run under the X11 backend. Applied at startup,
6879
* so the change only takes effect after the app is restarted.

‎src/renderer/__helpers__/visual.setup.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ function createGitifyBridgeApi(): Window['gitify'] {
109109
onSystemWake: vi.fn(() => vi.fn()),
110110
setAutoLaunch: vi.fn(),
111111
setKeepWindowOnBlur: vi.fn(),
112+
setShowUpdateNotifications: vi.fn(),
112113
setUseX11Backend: vi.fn(),
113114
applyKeyboardShortcut: vi.fn().mockResolvedValue({ success: true }),
114115
raiseNativeNotification: vi.fn(),

‎src/renderer/__helpers__/vitest.setup.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ function createGitifyBridgeApi(): Window['gitify'] {
107107
onSystemWake: vi.fn(() => vi.fn()),
108108
setAutoLaunch: vi.fn(),
109109
setKeepWindowOnBlur: vi.fn(),
110+
setShowUpdateNotifications: vi.fn(),
110111
setUseX11Backend: vi.fn(),
111112
applyKeyboardShortcut: vi.fn().mockResolvedValue({ success: true }),
112113
raiseNativeNotification: vi.fn(),

0 commit comments

Comments
 (0)