Officially support the system tray on Windows

This commit is contained in:
Evan Hahn 2021-06-29 12:18:03 -05:00 committed by GitHub
parent 23acbf284e
commit af1f2ea449
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 968 additions and 194 deletions

View file

@ -55,6 +55,10 @@ import { ReadReceipts } from './messageModifiers/ReadReceipts';
import { ReadSyncs } from './messageModifiers/ReadSyncs';
import { ViewSyncs } from './messageModifiers/ViewSyncs';
import * as AttachmentDownloads from './messageModifiers/AttachmentDownloads';
import {
SystemTraySetting,
parseSystemTraySetting,
} from './types/SystemTraySetting';
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
@ -464,6 +468,12 @@ export async function startApp(): Promise<void> {
window.setAutoHideMenuBar(value);
window.setMenuBarVisibility(!value);
},
getSystemTraySetting: (): SystemTraySetting =>
parseSystemTraySetting(window.storage.get('system-tray-setting')),
setSystemTraySetting: (value: Readonly<SystemTraySetting>) => {
window.storage.put('system-tray-setting', value);
window.updateSystemTraySetting(value);
},
getNotificationSetting: () =>
window.storage.get('notification-setting', 'message'),

View file

@ -0,0 +1,99 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ChangeEvent, FunctionComponent, useState } from 'react';
import {
SystemTraySetting,
parseSystemTraySetting,
shouldMinimizeToSystemTray,
} from '../../types/SystemTraySetting';
import { LocalizerType } from '../../types/Util';
type PropsType = {
i18n: LocalizerType;
initialValue: string;
isSystemTraySupported: boolean;
onChange: (value: SystemTraySetting) => unknown;
};
// This component is rendered by Backbone, so it deviates from idiomatic React a bit. For
// example, it does not receive its value as a prop.
export const SystemTraySettingsCheckboxes: FunctionComponent<PropsType> = ({
i18n,
initialValue,
isSystemTraySupported,
onChange,
}) => {
const [localValue, setLocalValue] = useState<SystemTraySetting>(
parseSystemTraySetting(initialValue)
);
if (!isSystemTraySupported) {
return null;
}
const setValue = (value: SystemTraySetting): void => {
setLocalValue(oldValue => {
if (oldValue !== value) {
onChange(value);
}
return value;
});
};
const setMinimizeToSystemTray = (event: ChangeEvent<HTMLInputElement>) => {
setValue(
event.target.checked
? SystemTraySetting.MinimizeToSystemTray
: SystemTraySetting.DoNotUseSystemTray
);
};
const setMinimizeToAndStartInSystemTray = (
event: ChangeEvent<HTMLInputElement>
) => {
setValue(
event.target.checked
? SystemTraySetting.MinimizeToAndStartInSystemTray
: SystemTraySetting.MinimizeToSystemTray
);
};
const minimizesToTray = shouldMinimizeToSystemTray(localValue);
const minimizesToAndStartsInSystemTray =
localValue === SystemTraySetting.MinimizeToAndStartInSystemTray;
return (
<>
<div>
<input
checked={minimizesToTray}
id="system-tray-setting-minimize-to-system-tray"
onChange={setMinimizeToSystemTray}
type="checkbox"
/>
{/* These manual spaces mirror the non-React parts of the settings screen. */}{' '}
<label htmlFor="system-tray-setting-minimize-to-system-tray">
{i18n('SystemTraySetting__minimize-to-system-tray')}
</label>
</div>
<div>
<input
checked={minimizesToAndStartsInSystemTray}
disabled={!minimizesToTray}
id="system-tray-setting-minimize-to-and-start-in-system-tray"
onChange={setMinimizeToAndStartInSystemTray}
type="checkbox"
/>{' '}
{/* These styles should live in CSS, but because we intend to rewrite the settings
screen, this inline CSS limits the scope of the future rewrite. */}
<label
htmlFor="system-tray-setting-minimize-to-and-start-in-system-tray"
style={minimizesToTray ? {} : { opacity: 0.75 }}
>
{i18n('SystemTraySetting__minimize-to-and-start-in-system-tray')}
</label>
</div>
</>
);
};

View file

@ -1,16 +0,0 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { app } from 'electron';
export function show(): void {
if (process.platform === 'darwin') {
app.dock.show();
}
}
export function hide(): void {
if (process.platform === 'darwin') {
app.dock.hide();
}
}

View file

@ -17,6 +17,7 @@ import { read as readLastLines } from 'read-last-lines';
import rimraf from 'rimraf';
import { createStream } from 'rotating-file-stream';
import { setLogAtLevel } from './log';
import { Environment, getEnvironment } from '../environment';
import {
@ -327,6 +328,8 @@ function isProbablyObjectHasBeenDestroyedError(err: unknown): boolean {
// This blows up using mocha --watch, so we ensure it is run just once
if (!console._log) {
setLogAtLevel(logAtLevel);
console._log = console.log;
console.log = _.partial(logAtLevel, LogLevel.Info);
console._error = console.error;

View file

@ -0,0 +1,233 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import * as sinon from 'sinon';
import { BrowserWindow, MenuItem, Tray } from 'electron';
import * as path from 'path';
import { SystemTrayService } from '../../../app/SystemTrayService';
describe('SystemTrayService', () => {
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.
*/
function newService(): SystemTrayService {
const result = new SystemTrayService({
messages: {
hide: { message: 'Hide' },
quit: { message: 'Quit' },
show: { message: 'Show' },
signalDesktop: { message: 'Signal' },
},
});
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.
const setContextMenuSpy = sandbox.spy(tray, 'setImage');
const getImagePath = (): string => {
const result = setContextMenuSpy.lastCall?.firstArg;
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$/);
});
});

View file

@ -0,0 +1,103 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import * as sinon from 'sinon';
import { MainSQL } from '../../sql/main';
import { SystemTraySetting } from '../../types/SystemTraySetting';
import { SystemTraySettingCache } from '../../../app/SystemTraySettingCache';
describe('SystemTraySettingCache', () => {
let sandbox: sinon.SinonSandbox;
let sqlCallStub: sinon.SinonStub;
let sql: Pick<MainSQL, 'sqlCall'>;
beforeEach(() => {
sandbox = sinon.createSandbox();
sqlCallStub = sandbox.stub().resolves();
sql = { sqlCall: sqlCallStub };
});
afterEach(() => {
sandbox.restore();
});
it('returns MinimizeToAndStartInSystemTray if passed the --start-in-tray argument', async () => {
const justOneArg = new SystemTraySettingCache(sql, ['--start-in-tray']);
assert.strictEqual(
await justOneArg.get(),
SystemTraySetting.MinimizeToAndStartInSystemTray
);
const bothArgs = new SystemTraySettingCache(sql, [
'--start-in-tray',
'--use-tray-icon',
]);
assert.strictEqual(
await bothArgs.get(),
SystemTraySetting.MinimizeToAndStartInSystemTray
);
sinon.assert.notCalled(sqlCallStub);
});
it('returns MinimizeToSystemTray if passed the --use-tray-icon argument', async () => {
const cache = new SystemTraySettingCache(sql, ['--use-tray-icon']);
assert.strictEqual(
await cache.get(),
SystemTraySetting.MinimizeToSystemTray
);
sinon.assert.notCalled(sqlCallStub);
});
it('returns DoNotUseSystemTray if system tray is supported but no preference is stored', async () => {
sandbox.stub(process, 'platform').value('win32');
const cache = new SystemTraySettingCache(sql, []);
assert.strictEqual(await cache.get(), SystemTraySetting.DoNotUseSystemTray);
});
it('returns DoNotUseSystemTray if system tray is supported but the stored preference is invalid', async () => {
sandbox.stub(process, 'platform').value('win32');
sqlCallStub.resolves({ value: 'garbage' });
const cache = new SystemTraySettingCache(sql, []);
assert.strictEqual(await cache.get(), SystemTraySetting.DoNotUseSystemTray);
});
it('returns the stored preference if system tray is supported and something is stored', async () => {
sandbox.stub(process, 'platform').value('win32');
sqlCallStub.resolves({ value: 'MinimizeToSystemTray' });
const cache = new SystemTraySettingCache(sql, []);
assert.strictEqual(
await cache.get(),
SystemTraySetting.MinimizeToSystemTray
);
});
it('only kicks off one request to the database if multiple sources ask at once', async () => {
sandbox.stub(process, 'platform').value('win32');
const cache = new SystemTraySettingCache(sql, []);
await Promise.all([cache.get(), cache.get(), cache.get()]);
sinon.assert.calledOnce(sqlCallStub);
});
it('returns DoNotUseSystemTray if system tray is unsupported and there are no CLI flags', async () => {
sandbox.stub(process, 'platform').value('darwin');
const cache = new SystemTraySettingCache(sql, []);
assert.strictEqual(await cache.get(), SystemTraySetting.DoNotUseSystemTray);
sinon.assert.notCalled(sqlCallStub);
});
});

View file

@ -1,4 +1,4 @@
// Copyright 2018-2020 Signal Messenger, LLC
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import os from 'os';
@ -167,4 +167,22 @@ describe('Settings', () => {
assert.isTrue(Settings.isDrawAttentionSupported());
});
});
describe('isSystemTraySupported', () => {
it('returns false on macOS', () => {
sandbox.stub(process, 'platform').value('darwin');
assert.isFalse(Settings.isSystemTraySupported());
});
it('returns true on Windows 8', () => {
sandbox.stub(process, 'platform').value('win32');
sandbox.stub(os, 'release').returns('8.0.0');
assert.isTrue(Settings.isSystemTraySupported());
});
it('returns false on Linux', () => {
sandbox.stub(process, 'platform').value('linux');
assert.isFalse(Settings.isSystemTraySupported());
});
});
});

View file

@ -0,0 +1,55 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import {
SystemTraySetting,
parseSystemTraySetting,
shouldMinimizeToSystemTray,
} from '../../types/SystemTraySetting';
describe('system tray setting utilities', () => {
describe('shouldMinimizeToSystemTray', () => {
it('returns false if the system tray is disabled', () => {
assert.isFalse(
shouldMinimizeToSystemTray(SystemTraySetting.DoNotUseSystemTray)
);
});
it('returns true if the system tray is enabled', () => {
assert.isTrue(
shouldMinimizeToSystemTray(SystemTraySetting.MinimizeToSystemTray)
);
assert.isTrue(
shouldMinimizeToSystemTray(
SystemTraySetting.MinimizeToAndStartInSystemTray
)
);
});
});
describe('parseSystemTraySetting', () => {
it('parses valid strings into their enum values', () => {
assert.strictEqual(
parseSystemTraySetting('DoNotUseSystemTray'),
SystemTraySetting.DoNotUseSystemTray
);
assert.strictEqual(
parseSystemTraySetting('MinimizeToSystemTray'),
SystemTraySetting.MinimizeToSystemTray
);
assert.strictEqual(
parseSystemTraySetting('MinimizeToAndStartInSystemTray'),
SystemTraySetting.MinimizeToAndStartInSystemTray
);
});
it('parses invalid strings to DoNotUseSystemTray', () => {
assert.strictEqual(
parseSystemTraySetting('garbage'),
SystemTraySetting.DoNotUseSystemTray
);
});
});
});

View file

@ -49,3 +49,11 @@ export enum TitleBarVisibility {
// This should match the "logic" in `stylesheets/_global.scss`.
export const getTitleBarVisibility = (): TitleBarVisibility =>
OS.isMacOS() ? TitleBarVisibility.Hidden : TitleBarVisibility.Visible;
/**
* Returns `true` if you can minimize the app to the system tray. Users can override this
* option with a command line flag, but that is not officially supported.
*
* We may add support for Linux in the future.
*/
export const isSystemTraySupported = OS.isWindows;

View file

@ -10,6 +10,7 @@ import type { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverabil
import type { PhoneNumberSharingMode } from '../util/phoneNumberSharingMode';
import type { RetryItemType } from '../util/retryPlaceholders';
import type { ConfigMapType as RemoteConfigType } from '../RemoteConfig';
import { SystemTraySetting } from './SystemTraySetting';
import type { GroupCredentialType } from '../textsecure/WebAPI';
import type {
@ -32,6 +33,7 @@ export type StorageAccessType = {
'call-ringtone-notification': boolean;
'call-system-notification': boolean;
'hide-menu-bar': boolean;
'system-tray-setting': SystemTraySetting;
'incoming-call-notification': boolean;
'notification-draw-attention': boolean;
'notification-setting': 'message' | 'name' | 'count' | 'off';

View file

@ -0,0 +1,22 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { makeEnumParser } from '../util/enum';
// Be careful when changing these values, as they are persisted.
export enum SystemTraySetting {
DoNotUseSystemTray = 'DoNotUseSystemTray',
MinimizeToSystemTray = 'MinimizeToSystemTray',
MinimizeToAndStartInSystemTray = 'MinimizeToAndStartInSystemTray',
}
export const shouldMinimizeToSystemTray = (
setting: SystemTraySetting
): boolean =>
setting === SystemTraySetting.MinimizeToSystemTray ||
setting === SystemTraySetting.MinimizeToAndStartInSystemTray;
export const parseSystemTraySetting = makeEnumParser(
SystemTraySetting,
SystemTraySetting.DoNotUseSystemTray
);

View file

@ -1059,6 +1059,14 @@
"updated": "2021-05-27T01:33:06.541Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " el: this.$('.system-tray-setting-container'),",
"reasonCategory": "usageTrusted",
"updated": "2021-06-24T23:16:24.537Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-append(",
"path": "js/views/settings_view.js",

2
ts/window.d.ts vendored
View file

@ -121,6 +121,7 @@ import { ConversationColorType, CustomColorType } from './types/Colors';
import { MessageController } from './util/MessageController';
import { isValidGuid } from './util/isValidGuid';
import { StateType } from './state/reducer';
import { SystemTraySetting } from './types/SystemTraySetting';
export { Long } from 'long';
@ -251,6 +252,7 @@ declare global {
setAutoHideMenuBar: (value: WhatIsThis) => void;
setBadgeCount: (count: number) => void;
setMenuBarVisibility: (value: WhatIsThis) => void;
updateSystemTraySetting: (value: SystemTraySetting) => void;
showConfirmationDialog: (options: ConfirmationDialogViewProps) => void;
showKeyboardShortcuts: () => void;
storage: Storage;