+
+ {/* These manual spaces mirror the non-React parts of the settings screen. */}{' '}
+
+
+
+ {' '}
+ {/* 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. */}
+
+
+ >
+ );
+};
diff --git a/ts/dock_icon.ts b/ts/dock_icon.ts
deleted file mode 100644
index f649e9a57..000000000
--- a/ts/dock_icon.ts
+++ /dev/null
@@ -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();
- }
-}
diff --git a/ts/logging/main_process_logging.ts b/ts/logging/main_process_logging.ts
index 81ac2e55c..4ef1fad9c 100644
--- a/ts/logging/main_process_logging.ts
+++ b/ts/logging/main_process_logging.ts
@@ -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;
diff --git a/ts/test-node/app/SystemTrayService_test.ts b/ts/test-node/app/SystemTrayService_test.ts
new file mode 100644
index 000000000..44a011cfd
--- /dev/null
+++ b/ts/test-node/app/SystemTrayService_test.ts
@@ -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();
+
+ 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$/);
+ });
+});
diff --git a/ts/test-node/app/SystemTraySettingCache_test.ts b/ts/test-node/app/SystemTraySettingCache_test.ts
new file mode 100644
index 000000000..5d28d9e26
--- /dev/null
+++ b/ts/test-node/app/SystemTraySettingCache_test.ts
@@ -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;
+
+ 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);
+ });
+});
diff --git a/ts/test-node/types/Settings_test.ts b/ts/test-node/types/Settings_test.ts
index 0cd414fc1..d1e6cfc3b 100644
--- a/ts/test-node/types/Settings_test.ts
+++ b/ts/test-node/types/Settings_test.ts
@@ -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());
+ });
+ });
});
diff --git a/ts/test-node/types/SystemTraySetting_test.ts b/ts/test-node/types/SystemTraySetting_test.ts
new file mode 100644
index 000000000..b946da934
--- /dev/null
+++ b/ts/test-node/types/SystemTraySetting_test.ts
@@ -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
+ );
+ });
+ });
+});
diff --git a/ts/types/Settings.ts b/ts/types/Settings.ts
index 2adead55b..2e59adade 100644
--- a/ts/types/Settings.ts
+++ b/ts/types/Settings.ts
@@ -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;
diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts
index fab84870e..7385ab133 100644
--- a/ts/types/Storage.d.ts
+++ b/ts/types/Storage.d.ts
@@ -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';
diff --git a/ts/types/SystemTraySetting.ts b/ts/types/SystemTraySetting.ts
new file mode 100644
index 000000000..76a13af0d
--- /dev/null
+++ b/ts/types/SystemTraySetting.ts
@@ -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
+);
diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json
index 646f45514..a7db6d7c1 100644
--- a/ts/util/lint/exceptions.json
+++ b/ts/util/lint/exceptions.json
@@ -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",
diff --git a/ts/window.d.ts b/ts/window.d.ts
index 2961daf6c..954a87a5d 100644
--- a/ts/window.d.ts
+++ b/ts/window.d.ts
@@ -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;