Skip to content

Commit ef3811b

Browse files
authored
feat: auto updates for all platforms (#2178)
* feat: auto updates for all platforms Signed-off-by: Adam Setch <[email protected]> * feat: auto updates for all platforms Signed-off-by: Adam Setch <[email protected]> --------- Signed-off-by: Adam Setch <[email protected]>
1 parent 1c48ade commit ef3811b

File tree

6 files changed

+311
-50
lines changed

6 files changed

+311
-50
lines changed

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,7 @@
7373
"menubar": "9.5.1",
7474
"react": "19.1.1",
7575
"react-dom": "19.1.1",
76-
"react-router-dom": "7.8.1",
77-
"update-electron-app": "3.1.1"
76+
"react-router-dom": "7.8.1"
7877
},
7978
"devDependencies": {
8079
"@biomejs/biome": "2.2.0",

pnpm-lock.yaml

Lines changed: 0 additions & 23 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/main/index.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import { menubar } from 'menubar';
55
import { APPLICATION } from '../shared/constants';
66
import { namespacedEvent } from '../shared/events';
77
import { logInfo, logWarn } from '../shared/logger';
8-
import { isLinux, isMacOS, isWindows } from '../shared/platform';
8+
import { isLinux, isWindows } from '../shared/platform';
99
import { onFirstRunMaybe } from './first-run';
1010
import { TrayIcons } from './icons';
1111
import MenuBuilder from './menu';
12-
import Updater from './updater';
12+
import AppUpdater from './updater';
1313

1414
log.initialize();
1515

@@ -43,20 +43,15 @@ const protocol =
4343
process.env.NODE_ENV === 'development' ? 'gitify-dev' : 'gitify';
4444
app.setAsDefaultProtocolClient(protocol);
4545

46-
if (isMacOS() || isWindows()) {
47-
/**
48-
* Electron Auto Updater only supports macOS and Windows
49-
* https://github.com/electron/update-electron-app
50-
*/
51-
const updater = new Updater(mb, menuBuilder);
52-
updater.initialize();
53-
}
46+
const appUpdater = new AppUpdater(mb, menuBuilder);
5447

5548
let shouldUseAlternateIdleIcon = false;
5649

5750
app.whenReady().then(async () => {
5851
await onFirstRunMaybe();
5952

53+
appUpdater.start();
54+
6055
mb.on('ready', () => {
6156
mb.app.setAppUserModelId(APPLICATION.ID);
6257

src/main/updater.test.ts

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { dialog } from 'electron';
2+
import type { Menubar } from 'menubar';
3+
4+
import { APPLICATION } from '../shared/constants';
5+
import { logError, logInfo } from '../shared/logger';
6+
7+
jest.mock('../shared/logger', () => ({
8+
logInfo: jest.fn(),
9+
logError: jest.fn(),
10+
}));
11+
12+
import MenuBuilder from './menu';
13+
import AppUpdater from './updater';
14+
15+
// Mock electron-updater with an EventEmitter-like interface
16+
type UpdateDownloadedEvent = { releaseName: string };
17+
type ListenerArgs = UpdateDownloadedEvent | object | undefined;
18+
type Listener = (arg: ListenerArgs) => void;
19+
type ListenerMap = Record<string, Listener[]>;
20+
const listeners: ListenerMap = {};
21+
22+
jest.mock('electron-updater', () => ({
23+
autoUpdater: {
24+
on: jest.fn((event: string, cb: Listener) => {
25+
if (!listeners[event]) listeners[event] = [];
26+
listeners[event].push(cb);
27+
return this;
28+
}),
29+
checkForUpdatesAndNotify: jest.fn().mockResolvedValue(undefined),
30+
quitAndInstall: jest.fn(),
31+
},
32+
}));
33+
34+
// Mock electron (dialog + basic Menu API used by MenuBuilder constructor)
35+
jest.mock('electron', () => {
36+
const MenuItem = jest.fn().mockImplementation((opts: unknown) => opts);
37+
return {
38+
dialog: { showMessageBox: jest.fn() },
39+
MenuItem,
40+
Menu: { buildFromTemplate: jest.fn() },
41+
shell: { openExternal: jest.fn() },
42+
};
43+
});
44+
45+
// Utility to emit mocked autoUpdater events
46+
const emit = (event: string, arg?: ListenerArgs) => {
47+
(listeners[event] || []).forEach((cb) => {
48+
cb(arg);
49+
});
50+
};
51+
52+
// Re-import autoUpdater after mocking
53+
import { autoUpdater } from 'electron-updater';
54+
55+
describe('main/updater.ts', () => {
56+
let menubar: Menubar;
57+
class TestMenuBuilder extends MenuBuilder {
58+
public setCheckForUpdatesMenuEnabled = jest.fn();
59+
public setNoUpdateAvailableMenuVisibility = jest.fn();
60+
public setUpdateAvailableMenuVisibility = jest.fn();
61+
public setUpdateReadyForInstallMenuVisibility = jest.fn();
62+
constructor(mb: Menubar) {
63+
super(mb);
64+
}
65+
}
66+
let menuBuilder: TestMenuBuilder;
67+
let updater: AppUpdater;
68+
69+
beforeEach(() => {
70+
jest.clearAllMocks();
71+
for (const k of Object.keys(listeners)) delete listeners[k];
72+
73+
menubar = {
74+
app: {
75+
isPackaged: true,
76+
// updater.initialize is now only called after app is ready externally
77+
on: jest.fn(),
78+
},
79+
tray: { setToolTip: jest.fn() },
80+
} as unknown as Menubar;
81+
82+
menuBuilder = new TestMenuBuilder(menubar);
83+
updater = new AppUpdater(menubar, menuBuilder);
84+
});
85+
86+
describe('update available dialog', () => {
87+
it('shows dialog with expected message and does NOT install when user chooses Later', async () => {
88+
(dialog.showMessageBox as jest.Mock).mockResolvedValue({ response: 1 }); // "Later"
89+
90+
await updater.start();
91+
92+
// Simulate update downloaded event
93+
const releaseName = 'v1.2.3';
94+
emit('update-downloaded', { releaseName });
95+
96+
expect(dialog.showMessageBox).toHaveBeenCalledWith(
97+
expect.objectContaining({
98+
message: expect.stringContaining(
99+
`${APPLICATION.NAME} ${releaseName} has been downloaded`,
100+
),
101+
buttons: ['Restart', 'Later'],
102+
}),
103+
);
104+
expect(autoUpdater.quitAndInstall).not.toHaveBeenCalled();
105+
// Menu state updates invoked
106+
expect(menuBuilder.setUpdateAvailableMenuVisibility).toHaveBeenCalledWith(
107+
false,
108+
);
109+
expect(
110+
menuBuilder.setUpdateReadyForInstallMenuVisibility,
111+
).toHaveBeenCalledWith(true);
112+
});
113+
114+
it('invokes quitAndInstall when user clicks Restart', async () => {
115+
(dialog.showMessageBox as jest.Mock).mockResolvedValue({ response: 0 }); // "Restart"
116+
117+
await updater.start();
118+
emit('update-downloaded', { releaseName: 'v9.9.9' });
119+
// Allow then() of showMessageBox promise to resolve
120+
await Promise.resolve();
121+
122+
expect(autoUpdater.quitAndInstall).toHaveBeenCalled();
123+
});
124+
});
125+
126+
describe('update event handlers & scheduling', () => {
127+
it('skips when app is not packaged', async () => {
128+
Object.defineProperty(menubar.app, 'isPackaged', { value: false });
129+
await updater.start();
130+
expect(logInfo).toHaveBeenCalledWith(
131+
'app updater',
132+
'Skipping updater since app is in development mode',
133+
);
134+
expect(autoUpdater.checkForUpdatesAndNotify).not.toHaveBeenCalled();
135+
});
136+
137+
it('handles checking-for-update', async () => {
138+
await updater.start();
139+
emit('checking-for-update');
140+
expect(menuBuilder.setCheckForUpdatesMenuEnabled).toHaveBeenCalledWith(
141+
false,
142+
);
143+
expect(
144+
menuBuilder.setNoUpdateAvailableMenuVisibility,
145+
).toHaveBeenCalledWith(false);
146+
});
147+
148+
it('handles update-available', async () => {
149+
await updater.start();
150+
emit('update-available');
151+
expect(menuBuilder.setUpdateAvailableMenuVisibility).toHaveBeenCalledWith(
152+
true,
153+
);
154+
expect(menubar.tray.setToolTip).toHaveBeenCalledWith(
155+
expect.stringContaining('A new update is available'),
156+
);
157+
});
158+
159+
it('handles download-progress', async () => {
160+
await updater.start();
161+
emit('download-progress', { percent: 12.3456 });
162+
expect(menubar.tray.setToolTip).toHaveBeenCalledWith(
163+
expect.stringContaining('12.35%'),
164+
);
165+
});
166+
167+
it('handles update-not-available', async () => {
168+
await updater.start();
169+
emit('update-not-available');
170+
expect(menuBuilder.setCheckForUpdatesMenuEnabled).toHaveBeenCalledWith(
171+
true,
172+
);
173+
expect(
174+
menuBuilder.setNoUpdateAvailableMenuVisibility,
175+
).toHaveBeenCalledWith(true);
176+
expect(menuBuilder.setUpdateAvailableMenuVisibility).toHaveBeenCalledWith(
177+
false,
178+
);
179+
expect(
180+
menuBuilder.setUpdateReadyForInstallMenuVisibility,
181+
).toHaveBeenCalledWith(false);
182+
});
183+
184+
it('handles update-cancelled (reset state)', async () => {
185+
await updater.start();
186+
emit('update-cancelled');
187+
expect(menubar.tray.setToolTip).toHaveBeenCalledWith(APPLICATION.NAME);
188+
expect(menuBuilder.setCheckForUpdatesMenuEnabled).toHaveBeenCalledWith(
189+
true,
190+
);
191+
});
192+
193+
it('handles error (reset + logError)', async () => {
194+
await updater.start();
195+
const err = new Error('failure');
196+
emit('error', err);
197+
expect(logError).toHaveBeenCalledWith(
198+
'auto updater',
199+
'Error checking for update',
200+
err,
201+
);
202+
expect(menubar.tray.setToolTip).toHaveBeenCalledWith(APPLICATION.NAME);
203+
});
204+
205+
it('performs initial check and schedules periodic checks', async () => {
206+
const originalSetInterval = global.setInterval;
207+
const setIntervalSpy = jest
208+
.spyOn(global, 'setInterval')
209+
.mockImplementation(((fn: () => void) => {
210+
fn();
211+
return 0 as unknown as NodeJS.Timer;
212+
}) as unknown as typeof setInterval);
213+
try {
214+
await updater.start();
215+
// initial + immediate scheduled invocation
216+
expect(
217+
(autoUpdater.checkForUpdatesAndNotify as jest.Mock).mock.calls.length,
218+
).toBe(2);
219+
expect(setIntervalSpy).toHaveBeenCalledWith(
220+
expect.any(Function),
221+
APPLICATION.UPDATE_CHECK_INTERVAL_MS,
222+
);
223+
} finally {
224+
setIntervalSpy.mockRestore();
225+
global.setInterval = originalSetInterval;
226+
}
227+
});
228+
});
229+
});

0 commit comments

Comments
 (0)