2021-06-29 17:18:03 +00:00
|
|
|
// Copyright 2021 Signal Messenger, LLC
|
|
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
|
|
|
import { assert } from 'chai';
|
|
|
|
import * as sinon from 'sinon';
|
2021-10-26 19:15:33 +00:00
|
|
|
import type { MenuItem } from 'electron';
|
|
|
|
import { BrowserWindow, Tray, nativeImage } from 'electron';
|
2021-06-29 17:18:03 +00:00
|
|
|
import * as path from 'path';
|
2021-12-14 18:13:06 +00:00
|
|
|
import { MINUTE } from '../../util/durations';
|
2021-06-29 17:18:03 +00:00
|
|
|
|
2022-01-25 00:18:53 +00:00
|
|
|
import type { SystemTrayServiceOptionsType } from '../../../app/SystemTrayService';
|
2021-06-29 17:18:03 +00:00
|
|
|
import { SystemTrayService } from '../../../app/SystemTrayService';
|
2022-09-27 21:01:06 +00:00
|
|
|
import { setupI18n } from '../../util/setupI18n';
|
|
|
|
|
|
|
|
import enMessages from '../../../_locales/en/messages.json';
|
|
|
|
|
|
|
|
const i18n = setupI18n('en', enMessages);
|
2021-06-29 17:18:03 +00:00
|
|
|
|
2023-10-11 19:06:43 +00:00
|
|
|
describe('SystemTrayService', function (this: Mocha.Suite) {
|
2021-12-14 18:13:06 +00:00
|
|
|
// These tests take more time on CI in some cases, so we increase the timeout.
|
|
|
|
this.timeout(MINUTE);
|
|
|
|
|
2021-06-29 17:18:03 +00:00
|
|
|
let sandbox: sinon.SinonSandbox;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Instantiating an Electron `Tray` has side-effects that we need to clean up. Make sure
|
|
|
|
* to use `newService` instead of `new SystemTrayService` in these tests to ensure that
|
|
|
|
* the tray is cleaned up.
|
|
|
|
*
|
|
|
|
* This only affects these tests, not the "real" code.
|
|
|
|
*/
|
2022-01-25 00:18:53 +00:00
|
|
|
function newService(
|
|
|
|
options?: Partial<SystemTrayServiceOptionsType>
|
|
|
|
): SystemTrayService {
|
2021-06-29 17:18:03 +00:00
|
|
|
const result = new SystemTrayService({
|
2022-09-27 21:01:06 +00:00
|
|
|
i18n,
|
2022-01-25 00:18:53 +00:00
|
|
|
...options,
|
2021-06-29 17:18:03 +00:00
|
|
|
});
|
|
|
|
servicesCreated.add(result);
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
|
|
|
const servicesCreated = new Set<SystemTrayService>();
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
sandbox = sinon.createSandbox();
|
|
|
|
});
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
sandbox.restore();
|
|
|
|
|
|
|
|
servicesCreated.forEach(service => {
|
|
|
|
service._getTray()?.destroy();
|
|
|
|
});
|
|
|
|
servicesCreated.clear();
|
|
|
|
});
|
|
|
|
|
|
|
|
it("doesn't render a tray icon unless (1) we're enabled (2) there's a browser window", () => {
|
|
|
|
const service = newService();
|
|
|
|
assert.isUndefined(service._getTray());
|
|
|
|
|
|
|
|
service.setEnabled(true);
|
|
|
|
assert.isUndefined(service._getTray());
|
|
|
|
|
|
|
|
service.setMainWindow(new BrowserWindow({ show: false }));
|
|
|
|
assert.instanceOf(service._getTray(), Tray);
|
|
|
|
|
|
|
|
service.setEnabled(false);
|
|
|
|
assert.isUndefined(service._getTray());
|
|
|
|
});
|
|
|
|
|
|
|
|
it('renders a "Hide" button when the window is shown and a "Show" button when the window is hidden', () => {
|
|
|
|
// We don't actually want to show a browser window. It's disruptive when you're
|
|
|
|
// running tests and can introduce test-only flakiness. We jump through some hoops
|
|
|
|
// to fake the behavior.
|
|
|
|
let fakeIsVisible = false;
|
|
|
|
const browserWindow = new BrowserWindow({ show: fakeIsVisible });
|
|
|
|
sinon.stub(browserWindow, 'isVisible').callsFake(() => fakeIsVisible);
|
|
|
|
sinon.stub(browserWindow, 'show').callsFake(() => {
|
|
|
|
fakeIsVisible = true;
|
|
|
|
browserWindow.emit('show');
|
|
|
|
});
|
|
|
|
sinon.stub(browserWindow, 'hide').callsFake(() => {
|
|
|
|
fakeIsVisible = false;
|
|
|
|
browserWindow.emit('hide');
|
|
|
|
});
|
|
|
|
|
|
|
|
const service = newService();
|
|
|
|
service.setEnabled(true);
|
|
|
|
service.setMainWindow(browserWindow);
|
|
|
|
|
|
|
|
const tray = service._getTray();
|
|
|
|
if (!tray) {
|
|
|
|
throw new Error('Test setup failed: expected a tray');
|
|
|
|
}
|
|
|
|
|
|
|
|
// Ideally, there'd be something like `tray.getContextMenu`, but that doesn't exist.
|
|
|
|
// We also can't spy on `Tray.prototype.setContextMenu` because it's not defined
|
|
|
|
// that way. So we spy on the specific instance, just to get the context menu.
|
|
|
|
const setContextMenuSpy = sandbox.spy(tray, 'setContextMenu');
|
|
|
|
const getToggleMenuItem = (): undefined | null | MenuItem =>
|
|
|
|
setContextMenuSpy.lastCall?.firstArg?.getMenuItemById(
|
|
|
|
'toggleWindowVisibility'
|
|
|
|
);
|
|
|
|
|
|
|
|
browserWindow.show();
|
|
|
|
assert.strictEqual(getToggleMenuItem()?.label, 'Hide');
|
|
|
|
|
|
|
|
getToggleMenuItem()?.click();
|
|
|
|
assert.strictEqual(getToggleMenuItem()?.label, 'Show');
|
|
|
|
|
|
|
|
getToggleMenuItem()?.click();
|
|
|
|
assert.strictEqual(getToggleMenuItem()?.label, 'Hide');
|
|
|
|
});
|
|
|
|
|
|
|
|
it('destroys the tray when disabling', () => {
|
|
|
|
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');
|
|
|
|
}
|
|
|
|
|
|
|
|
assert.isFalse(tray.isDestroyed());
|
|
|
|
|
|
|
|
service.setEnabled(false);
|
|
|
|
|
|
|
|
assert.isTrue(tray.isDestroyed());
|
|
|
|
});
|
|
|
|
|
|
|
|
it('maintains the same Tray instance when switching browser window instances', () => {
|
|
|
|
const service = newService();
|
|
|
|
service.setEnabled(true);
|
|
|
|
service.setMainWindow(new BrowserWindow({ show: false }));
|
|
|
|
|
|
|
|
const originalTray = service._getTray();
|
|
|
|
|
|
|
|
service.setMainWindow(new BrowserWindow({ show: false }));
|
|
|
|
|
|
|
|
assert.strictEqual(service._getTray(), originalTray);
|
|
|
|
});
|
|
|
|
|
|
|
|
it('removes browser window event listeners when changing browser window instances', () => {
|
|
|
|
const firstBrowserWindow = new BrowserWindow({ show: false });
|
|
|
|
|
|
|
|
const showListenersAtStart = firstBrowserWindow.listenerCount('show');
|
|
|
|
const hideListenersAtStart = firstBrowserWindow.listenerCount('hide');
|
|
|
|
|
|
|
|
const service = newService();
|
|
|
|
service.setEnabled(true);
|
|
|
|
service.setMainWindow(firstBrowserWindow);
|
|
|
|
|
|
|
|
assert.strictEqual(
|
|
|
|
firstBrowserWindow.listenerCount('show'),
|
|
|
|
showListenersAtStart + 1
|
|
|
|
);
|
|
|
|
assert.strictEqual(
|
|
|
|
firstBrowserWindow.listenerCount('hide'),
|
|
|
|
hideListenersAtStart + 1
|
|
|
|
);
|
|
|
|
|
|
|
|
service.setMainWindow(new BrowserWindow({ show: false }));
|
|
|
|
|
|
|
|
assert.strictEqual(
|
|
|
|
firstBrowserWindow.listenerCount('show'),
|
|
|
|
showListenersAtStart
|
|
|
|
);
|
|
|
|
assert.strictEqual(
|
|
|
|
firstBrowserWindow.listenerCount('hide'),
|
|
|
|
hideListenersAtStart
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
it('removes browser window event listeners when removing browser window instances', () => {
|
|
|
|
const browserWindow = new BrowserWindow({ show: false });
|
|
|
|
|
|
|
|
const showListenersAtStart = browserWindow.listenerCount('show');
|
|
|
|
const hideListenersAtStart = browserWindow.listenerCount('hide');
|
|
|
|
|
|
|
|
const service = newService();
|
|
|
|
service.setEnabled(true);
|
|
|
|
service.setMainWindow(browserWindow);
|
|
|
|
|
|
|
|
assert.strictEqual(
|
|
|
|
browserWindow.listenerCount('show'),
|
|
|
|
showListenersAtStart + 1
|
|
|
|
);
|
|
|
|
assert.strictEqual(
|
|
|
|
browserWindow.listenerCount('hide'),
|
|
|
|
hideListenersAtStart + 1
|
|
|
|
);
|
|
|
|
|
|
|
|
service.setMainWindow(undefined);
|
|
|
|
|
|
|
|
assert.strictEqual(
|
|
|
|
browserWindow.listenerCount('show'),
|
|
|
|
showListenersAtStart
|
|
|
|
);
|
|
|
|
assert.strictEqual(
|
|
|
|
browserWindow.listenerCount('hide'),
|
|
|
|
hideListenersAtStart
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
it('updates the icon when the unread count changes', () => {
|
|
|
|
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');
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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.
|
2021-10-07 20:09:56 +00:00
|
|
|
const setImageSpy = sandbox.spy(tray, 'setImage');
|
2021-06-29 17:18:03 +00:00
|
|
|
const getImagePath = (): string => {
|
2021-10-07 20:09:56 +00:00
|
|
|
const result = setImageSpy.lastCall?.firstArg;
|
2021-06-29 17:18:03 +00:00
|
|
|
if (!result) {
|
|
|
|
throw new Error('Expected tray.setImage to be called at least once');
|
|
|
|
}
|
|
|
|
return result;
|
|
|
|
};
|
|
|
|
|
|
|
|
for (let i = 9; i >= 1; i -= 1) {
|
|
|
|
service.setUnreadCount(i);
|
|
|
|
assert.strictEqual(path.parse(getImagePath()).base, `${i}.png`);
|
|
|
|
}
|
|
|
|
|
|
|
|
for (let i = 10; i < 13; i += 1) {
|
|
|
|
service.setUnreadCount(i);
|
|
|
|
assert.strictEqual(path.parse(getImagePath()).base, '10.png');
|
|
|
|
}
|
|
|
|
|
|
|
|
service.setUnreadCount(0);
|
|
|
|
assert.match(path.parse(getImagePath()).base, /^icon_\d+\.png$/);
|
|
|
|
});
|
2021-10-07 20:09:56 +00:00
|
|
|
|
|
|
|
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));
|
|
|
|
});
|
2022-01-25 00:18:53 +00:00
|
|
|
|
|
|
|
it('should not create new Tray after markShouldQuit', () => {
|
|
|
|
const createTrayInstance = sandbox.stub();
|
|
|
|
|
|
|
|
const service = newService({ createTrayInstance });
|
|
|
|
|
|
|
|
service.setMainWindow(new BrowserWindow({ show: false }));
|
|
|
|
service.markShouldQuit();
|
|
|
|
service.setEnabled(true);
|
|
|
|
|
|
|
|
sinon.assert.notCalled(createTrayInstance);
|
|
|
|
});
|
2021-06-29 17:18:03 +00:00
|
|
|
});
|