diff --git a/app/SystemTrayService.ts b/app/SystemTrayService.ts index 73b1067e78..f57b166210 100644 --- a/app/SystemTrayService.ts +++ b/app/SystemTrayService.ts @@ -2,7 +2,14 @@ // SPDX-License-Identifier: AGPL-3.0-only import { join } from 'path'; -import { BrowserWindow, app, Menu, Tray } from 'electron'; +import { + BrowserWindow, + Menu, + NativeImage, + Tray, + app, + nativeImage, +} from 'electron'; import * as log from '../ts/logging/log'; import type { LocaleMessagesType } from '../ts/types/I18N'; @@ -105,7 +112,14 @@ export class SystemTrayService { this.tray = this.tray || this.createTray(); const { browserWindow, tray } = this; - tray.setImage(getIcon(this.unreadCount)); + try { + tray.setImage(getIcon(this.unreadCount)); + } catch (err: unknown) { + log.warn( + 'System tray service: failed to set preferred image. Falling back...' + ); + tray.setImage(getDefaultIcon()); + } // NOTE: we want to have the show/hide entry available in the tray icon // context menu, since the 'click' event may not work on all platforms. @@ -169,7 +183,7 @@ export class SystemTrayService { log.info('System tray service: creating the tray'); // This icon may be swiftly overwritten. - const result = new Tray(getIcon(this.unreadCount)); + const result = new Tray(getDefaultIcon()); // Note: "When app indicator is used on Linux, the click event is ignored." This // doesn't mean that the click event is always ignored on Linux; it depends on how @@ -223,6 +237,12 @@ function getIcon(unreadCount: number) { return join(__dirname, '..', 'images', `icon_${iconSize}.png`); } +let defaultIcon: undefined | NativeImage; +function getDefaultIcon(): NativeImage { + defaultIcon ??= nativeImage.createFromPath(getIcon(0)); + return defaultIcon; +} + function forceOnTop(browserWindow: BrowserWindow) { // On some versions of GNOME the window may not be on top when restored. // This trick should fix it. diff --git a/ts/test-node/app/SystemTrayService_test.ts b/ts/test-node/app/SystemTrayService_test.ts index 44a011cfda..0a5c46bd00 100644 --- a/ts/test-node/app/SystemTrayService_test.ts +++ b/ts/test-node/app/SystemTrayService_test.ts @@ -3,7 +3,7 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; -import { BrowserWindow, MenuItem, Tray } from 'electron'; +import { BrowserWindow, MenuItem, Tray, nativeImage } from 'electron'; import * as path from 'path'; import { SystemTrayService } from '../../../app/SystemTrayService'; @@ -208,9 +208,9 @@ describe('SystemTrayService', () => { // Ideally, there'd be something like `tray.getImage`, but that doesn't exist. We also // can't spy on `Tray.prototype.setImage` because it's not defined that way. So we // spy on the specific instance, just to get the image. - const setContextMenuSpy = sandbox.spy(tray, 'setImage'); + const setImageSpy = sandbox.spy(tray, 'setImage'); const getImagePath = (): string => { - const result = setContextMenuSpy.lastCall?.firstArg; + const result = setImageSpy.lastCall?.firstArg; if (!result) { throw new Error('Expected tray.setImage to be called at least once'); } @@ -230,4 +230,27 @@ describe('SystemTrayService', () => { service.setUnreadCount(0); assert.match(path.parse(getImagePath()).base, /^icon_\d+\.png$/); }); + + it('uses a fallback image if the icon file cannot be found', () => { + const service = newService(); + service.setEnabled(true); + service.setMainWindow(new BrowserWindow({ show: false })); + + const tray = service._getTray(); + if (!tray) { + throw new Error('Test setup failed: expected a tray'); + } + + const setImageStub = sandbox.stub(tray, 'setImage'); + setImageStub.withArgs(sinon.match.string).throws('Failed to load'); + + service.setUnreadCount(4); + + // Electron doesn't export this class, so we have to wrestle it out. + const NativeImage = nativeImage.createEmpty().constructor; + + sinon.assert.calledTwice(setImageStub); + sinon.assert.calledWith(setImageStub, sinon.match.string); + sinon.assert.calledWith(setImageStub, sinon.match.instanceOf(NativeImage)); + }); });