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

@ -1527,6 +1527,14 @@
"message": "Spell check will be disabled the next time Signal starts.",
"description": "Shown when the user disables spellcheck to indicate that they must restart Signal."
},
"SystemTraySetting__minimize-to-system-tray": {
"message": "Minimize to system tray",
"description": "In the settings, shown next to the checkbox option for minimizing to the system tray"
},
"SystemTraySetting__minimize-to-and-start-in-system-tray": {
"message": "Start minimized to tray",
"description": "In the settings, shown next to the checkbox option for starting in the system tray"
},
"autoLaunchDescription": {
"message": "Open at computer login",
"description": "Description for the automatic launch setting"

233
app/SystemTrayService.ts Normal file
View file

@ -0,0 +1,233 @@
// Copyright 2017-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { join } from 'path';
import { BrowserWindow, app, Menu, Tray } from 'electron';
import * as log from '../ts/logging/log';
import type { LocaleMessagesType } from '../ts/types/I18N';
/**
* A class that manages an [Electron `Tray` instance][0]. It's responsible for creating
* and destroying a `Tray`, and listening to the associated `BrowserWindow`'s visibility
* state.
*
* [0]: https://www.electronjs.org/docs/api/tray
*/
export class SystemTrayService {
private browserWindow?: BrowserWindow;
private readonly messages: LocaleMessagesType;
private tray?: Tray;
private isEnabled = false;
private unreadCount = 0;
private boundRender: typeof SystemTrayService.prototype.render;
constructor({ messages }: Readonly<{ messages: LocaleMessagesType }>) {
log.info('System tray service: created');
this.messages = messages;
this.boundRender = this.render.bind(this);
}
/**
* Update or clear the associated `BrowserWindow`. This is used for the hide/show
* functionality. It attaches event listeners to the window to manage the hide/show
* toggle in the tray's context menu.
*/
setMainWindow(newBrowserWindow: undefined | BrowserWindow): void {
const oldBrowserWindow = this.browserWindow;
if (oldBrowserWindow === newBrowserWindow) {
return;
}
log.info(
`System tray service: updating main window. Previously, there was ${
oldBrowserWindow ? '' : 'not '
}a window, and now there is${newBrowserWindow ? '' : ' not'}`
);
if (oldBrowserWindow) {
oldBrowserWindow.off('show', this.boundRender);
oldBrowserWindow.off('hide', this.boundRender);
}
if (newBrowserWindow) {
newBrowserWindow.on('show', this.boundRender);
newBrowserWindow.on('hide', this.boundRender);
}
this.browserWindow = newBrowserWindow;
this.render();
}
/**
* Enable or disable the tray icon. Note: if there is no associated browser window (see
* `setMainWindow`), the tray icon will not be shown, even if enabled.
*/
setEnabled(isEnabled: boolean): void {
if (this.isEnabled === isEnabled) {
return;
}
log.info(`System tray service: ${isEnabled ? 'enabling' : 'disabling'}`);
this.isEnabled = isEnabled;
this.render();
}
/**
* Update the unread count, which updates the tray icon if it's visible.
*/
setUnreadCount(unreadCount: number): void {
if (this.unreadCount === unreadCount) {
return;
}
log.info(`System tray service: setting unread count to ${unreadCount}`);
this.unreadCount = unreadCount;
this.render();
}
private render(): void {
if (this.isEnabled && this.browserWindow) {
this.renderEnabled();
return;
}
this.renderDisabled();
}
private renderEnabled() {
log.info('System tray service: rendering the tray');
this.tray = this.tray || this.createTray();
const { browserWindow, tray } = this;
tray.setImage(getIcon(this.unreadCount));
// 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.
// For details please refer to:
// https://github.com/electron/electron/blob/master/docs/api/tray.md.
tray.setContextMenu(
Menu.buildFromTemplate([
{
id: 'toggleWindowVisibility',
...(browserWindow?.isVisible()
? {
label: this.messages.hide.message,
click: () => {
log.info(
'System tray service: hiding the window from the context menu'
);
// We re-fetch `this.browserWindow` here just in case the browser window
// has changed while the context menu was open. Same applies in the
// "show" case below.
this.browserWindow?.hide();
},
}
: {
label: this.messages.show.message,
click: () => {
log.info(
'System tray service: showing the window from the context menu'
);
if (this.browserWindow) {
this.browserWindow.show();
forceOnTop(this.browserWindow);
}
},
}),
},
{
id: 'quit',
label: this.messages.quit.message,
click: () => {
log.info(
'System tray service: quitting the app from the context menu'
);
app.quit();
},
},
])
);
}
private renderDisabled() {
log.info('System tray service: rendering no tray');
if (!this.tray) {
return;
}
this.tray.destroy();
this.tray = undefined;
}
private createTray(): Tray {
log.info('System tray service: creating the tray');
// This icon may be swiftly overwritten.
const result = new Tray(getIcon(this.unreadCount));
// 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
// the app indicator is set up.
//
// See <https://github.com/electron/electron/blob/v13.1.3/docs/api/tray.md#class-tray>.
result.on('click', () => {
const { browserWindow } = this;
if (!browserWindow) {
return;
}
if (!browserWindow.isVisible()) {
browserWindow.show();
}
forceOnTop(browserWindow);
});
result.setToolTip(this.messages.signalDesktop.message);
return result;
}
/**
* This is exported for testing, because Electron doesn't have any easy way to hook
* into the existing tray instances. It should not be used by "real" code.
*/
_getTray(): undefined | Tray {
return this.tray;
}
}
function getIcon(unreadCount: number) {
let iconSize: string;
switch (process.platform) {
case 'darwin':
iconSize = '16';
break;
case 'win32':
iconSize = '32';
break;
default:
iconSize = '256';
break;
}
if (unreadCount > 0) {
const filename = `${String(unreadCount >= 10 ? 10 : unreadCount)}.png`;
return join(__dirname, '..', 'images', 'alert', iconSize, filename);
}
return join(__dirname, '..', 'images', `icon_${iconSize}.png`);
}
function forceOnTop(browserWindow: BrowserWindow) {
// On some versions of GNOME the window may not be on top when restored.
// This trick should fix it.
// Thanks to: https://github.com/Enrico204/Whatsapp-Desktop/commit/6b0dc86b64e481b455f8fce9b4d797e86d000dc1
browserWindow.setAlwaysOnTop(true);
browserWindow.focus();
browserWindow.setAlwaysOnTop(false);
}

View file

@ -0,0 +1,84 @@
// Copyright 2017-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as log from '../ts/logging/log';
import {
parseSystemTraySetting,
SystemTraySetting,
} from '../ts/types/SystemTraySetting';
import { isSystemTraySupported } from '../ts/types/Settings';
import type { MainSQL } from '../ts/sql/main';
/**
* A small helper class to get and cache the `system-tray-setting` preference in the main
* process.
*/
export class SystemTraySettingCache {
private cachedValue: undefined | SystemTraySetting;
private getPromise: undefined | Promise<SystemTraySetting>;
constructor(
private readonly sql: Pick<MainSQL, 'sqlCall'>,
private readonly argv: Array<string>
) {}
async get(): Promise<SystemTraySetting> {
if (this.cachedValue !== undefined) {
return this.cachedValue;
}
this.getPromise = this.getPromise || this.doFirstGet();
return this.getPromise;
}
set(value: SystemTraySetting): void {
this.cachedValue = value;
}
private async doFirstGet(): Promise<SystemTraySetting> {
let result: SystemTraySetting;
// These command line flags are not officially supported, but many users rely on them.
// Be careful when removing them or making changes.
if (this.argv.some(arg => arg === '--start-in-tray')) {
result = SystemTraySetting.MinimizeToAndStartInSystemTray;
log.info(
`getSystemTraySetting saw --start-in-tray flag. Returning ${result}`
);
} else if (this.argv.some(arg => arg === '--use-tray-icon')) {
result = SystemTraySetting.MinimizeToSystemTray;
log.info(
`getSystemTraySetting saw --use-tray-icon flag. Returning ${result}`
);
} else if (isSystemTraySupported()) {
const { value } = (await this.sql.sqlCall('getItemById', [
'system-tray-setting',
])) || { value: undefined };
if (value !== undefined) {
result = parseSystemTraySetting(value);
log.info(
`getSystemTraySetting returning value from database, ${result}`
);
} else {
result = SystemTraySetting.DoNotUseSystemTray;
log.info(
`getSystemTraySetting got no value from database, returning ${result}`
);
}
} else {
result = SystemTraySetting.DoNotUseSystemTray;
log.info(
`getSystemTraySetting had no flags and did no DB lookups. Returning ${result}`
);
}
// If there's a value in the cache, someone has updated the value "out from under us",
// so we should return that because it's newer.
this.cachedValue =
this.cachedValue === undefined ? result : this.cachedValue;
return this.cachedValue;
}
}

View file

@ -1,138 +0,0 @@
// Copyright 2017-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { join } from 'path';
import { existsSync } from 'fs';
import { BrowserWindow, app, Menu, Tray } from 'electron';
import * as DockIcon from '../ts/dock_icon';
import { LocaleMessagesType } from '../ts/types/I18N';
let trayContextMenu = null;
let tray: Tray | undefined;
export default function createTrayIcon(
getMainWindow: () => BrowserWindow | undefined,
messages: LocaleMessagesType
): { updateContextMenu: () => void; updateIcon: (count: number) => void } {
let iconSize: string;
switch (process.platform) {
case 'darwin':
iconSize = '16';
break;
case 'win32':
iconSize = '32';
break;
default:
iconSize = '256';
break;
}
const iconNoNewMessages = join(
__dirname,
'..',
'images',
`icon_${iconSize}.png`
);
tray = new Tray(iconNoNewMessages);
const forceOnTop = (mainWindow: BrowserWindow) => {
if (mainWindow) {
// On some versions of GNOME the window may not be on top when restored.
// This trick should fix it.
// Thanks to: https://github.com/Enrico204/Whatsapp-Desktop/commit/6b0dc86b64e481b455f8fce9b4d797e86d000dc1
mainWindow.setAlwaysOnTop(true);
mainWindow.focus();
mainWindow.setAlwaysOnTop(false);
}
};
const toggleWindowVisibility = () => {
const mainWindow = getMainWindow();
if (mainWindow) {
if (mainWindow.isVisible()) {
mainWindow.hide();
DockIcon.hide();
} else {
mainWindow.show();
DockIcon.show();
forceOnTop(mainWindow);
}
}
updateContextMenu();
};
const showWindow = () => {
const mainWindow = getMainWindow();
if (mainWindow) {
if (!mainWindow.isVisible()) {
mainWindow.show();
}
forceOnTop(mainWindow);
}
updateContextMenu();
};
const updateContextMenu = () => {
const mainWindow = getMainWindow();
// 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.
// For details please refer to:
// https://github.com/electron/electron/blob/master/docs/api/tray.md.
trayContextMenu = Menu.buildFromTemplate([
{
id: 'toggleWindowVisibility',
label:
messages[mainWindow && mainWindow.isVisible() ? 'hide' : 'show']
.message,
click: toggleWindowVisibility,
},
{
id: 'quit',
label: messages.quit.message,
click: app.quit.bind(app),
},
]);
tray?.setContextMenu(trayContextMenu);
};
const updateIcon = (unreadCount: number) => {
let image;
if (unreadCount > 0) {
const filename = `${String(unreadCount >= 10 ? 10 : unreadCount)}.png`;
image = join(__dirname, '..', 'images', 'alert', iconSize, filename);
} else {
image = iconNoNewMessages;
}
if (!existsSync(image)) {
console.log('tray.updateIcon: Image for tray update does not exist!');
return;
}
try {
tray?.setImage(image);
} catch (error) {
console.log(
'tray.setImage error:',
error && error.stack ? error.stack : error
);
}
};
tray.on('click', showWindow);
tray.setToolTip(messages.signalDesktop.message);
updateContextMenu();
return {
updateContextMenu,
updateIcon,
};
}

View file

@ -57,6 +57,9 @@ const {
const {
DisappearingTimeDialog,
} = require('../../ts/components/DisappearingTimeDialog');
const {
SystemTraySettingsCheckboxes,
} = require('../../ts/components/conversation/SystemTraySettingsCheckboxes');
// State
const { createTimeline } = require('../../ts/state/roots/createTimeline');
@ -341,6 +344,7 @@ exports.setup = (options = {}) => {
ProgressModal,
StagedLinkPreview,
DisappearingTimeDialog,
SystemTraySettingsCheckboxes,
Types: {
Message: MediaGalleryMessage,
},

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
/* global $, Whisper */
@ -29,6 +29,7 @@ const getInitialData = async () => ({
themeSetting: await window.getThemeSetting(),
hideMenuBar: await window.getHideMenuBar(),
systemTray: await window.getSystemTraySetting(),
notificationSetting: await window.getNotificationSetting(),
audioNotification: await window.getAudioNotification(),

View file

@ -1,4 +1,4 @@
// Copyright 2016-2020 Signal Messenger, LLC
// Copyright 2016-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* global i18n: false */
@ -274,6 +274,16 @@
setFn: window.setHideMenuBar,
});
}
new window.Whisper.ReactWrapperView({
el: this.$('.system-tray-setting-container'),
Component: window.Signal.Components.SystemTraySettingsCheckboxes,
props: {
i18n,
initialValue: window.initialData.systemTray,
isSystemTraySupported: Settings.isSystemTraySupported(),
onChange: window.setSystemTraySetting,
},
});
new CheckboxView({
el: this.$('.always-relay-calls-setting'),
name: 'always-relay-calls-setting',

88
main.js
View file

@ -65,12 +65,6 @@ function getMainWindow() {
return mainWindow;
}
// Tray icon and related objects
let tray = null;
const startInTray = process.argv.some(arg => arg === '--start-in-tray');
const usingTrayIcon =
startInTray || process.argv.some(arg => arg === '--use-tray-icon');
const config = require('./app/config').default;
// Very important to put before the single instance check, since it is based on the
@ -91,8 +85,13 @@ const attachments = require('./app/attachments');
const attachmentChannel = require('./app/attachment_channel');
const bounce = require('./ts/services/bounce');
const updater = require('./ts/updater/index');
const createTrayIcon = require('./app/tray_icon').default;
const dockIcon = require('./ts/dock_icon');
const { SystemTrayService } = require('./app/SystemTrayService');
const { SystemTraySettingCache } = require('./app/SystemTraySettingCache');
const {
SystemTraySetting,
shouldMinimizeToSystemTray,
parseSystemTraySetting,
} = require('./ts/types/SystemTraySetting');
const ephemeralConfig = require('./app/ephemeral_config');
const logging = require('./ts/logging/main_process_logging');
const { MainSQL } = require('./ts/sql/main');
@ -127,6 +126,10 @@ const { PowerChannel } = require('./ts/main/powerChannel');
const { maybeParseUrl, setUrlSearchParams } = require('./ts/util/url');
const sql = new MainSQL();
let systemTrayService;
const systemTraySettingCache = new SystemTraySettingCache(sql, process.argv);
const challengeHandler = new ChallengeMainHandler();
let sqlInitTimeStart = 0;
@ -174,14 +177,6 @@ function showWindow() {
} else {
mainWindow.show();
}
// toggle the visibility of the show/hide tray icon menu entries
if (tray) {
tray.updateContextMenu();
}
// show the app on the Dock in case it was hidden before
dockIcon.show();
}
if (!process.mas) {
@ -394,6 +389,10 @@ async function createWindow() {
delete windowOptions.autoHideMenuBar;
}
const startInTray =
(await systemTraySettingCache.get()) ===
SystemTraySetting.MinimizeToAndStartInSystemTray;
const visibleOnAnyScreen = _.some(screen.getAllDisplays(), display => {
if (!_.isNumber(windowOptions.x) || !_.isNumber(windowOptions.y)) {
return false;
@ -416,12 +415,15 @@ async function createWindow() {
mainWindow = new BrowserWindow(windowOptions);
mainWindowCreated = true;
setupSpellChecker(mainWindow, locale.messages);
if (!usingTrayIcon && windowConfig && windowConfig.maximized) {
if (!startInTray && windowConfig) {
mainWindow.maximize();
}
if (!usingTrayIcon && windowConfig && windowConfig.fullscreen) {
if (!startInTray && windowConfig && windowConfig.fullscreen) {
mainWindow.setFullScreen(true);
}
if (systemTrayService) {
systemTrayService.setMainWindow(mainWindow);
}
function captureAndSaveWindowStats() {
if (!mainWindow) {
@ -533,17 +535,10 @@ async function createWindow() {
// On Mac, or on other platforms when the tray icon is in use, the window
// should be only hidden, not closed, when the user clicks the close button
const usingTrayIcon = shouldMinimizeToSystemTray(
await systemTraySettingCache.get()
);
if (!windowState.shouldQuit() && (usingTrayIcon || OS.isMacOS())) {
// toggle the visibility of the show/hide tray icon menu entries
if (tray) {
tray.updateContextMenu();
}
// hide the app from the Dock on macOS if the tray icon is enabled
if (usingTrayIcon) {
dockIcon.hide();
}
return;
}
@ -560,7 +555,10 @@ async function createWindow() {
// Dereference the window object, usually you would store windows
// in an array if your app supports multi windows, this is the time
// when you should delete the corresponding element.
mainWindow = null;
mainWindow = undefined;
if (systemTrayService) {
systemTrayService.setMainWindow(mainWindow);
}
});
mainWindow.on('enter-full-screen', () => {
@ -580,7 +578,6 @@ async function createWindow() {
return;
}
// allow to start minimised in tray
if (!startInTray) {
console.log('showing main window');
mainWindow.show();
@ -1360,12 +1357,14 @@ app.on('ready', async () => {
ready = true;
if (usingTrayIcon) {
tray = createTrayIcon(getMainWindow, locale.messages);
}
setupMenu();
systemTrayService = new SystemTrayService({ messages: locale.messages });
systemTrayService.setMainWindow(mainWindow);
systemTrayService.setEnabled(
shouldMinimizeToSystemTray(await systemTraySettingCache.get())
);
ensureFilePermissions([
'config.json',
'sql/db.sqlite',
@ -1561,6 +1560,19 @@ ipc.on('set-menu-bar-visibility', (event, visibility) => {
}
});
ipc.on('update-system-tray-setting', (
_event,
rawSystemTraySetting /* : Readonly<unknown> */
) => {
const systemTraySetting = parseSystemTraySetting(rawSystemTraySetting);
systemTraySettingCache.set(systemTraySetting);
if (systemTrayService) {
const isEnabled = shouldMinimizeToSystemTray(systemTraySetting);
systemTrayService.setEnabled(isEnabled);
}
});
ipc.on('close-about', () => {
if (aboutWindow) {
aboutWindow.close();
@ -1583,9 +1595,9 @@ ipc.on('show-screen-share', (event, sourceName) => {
showScreenShareWindow(sourceName);
});
ipc.on('update-tray-icon', (event, unreadCount) => {
if (tray) {
tray.updateIcon(unreadCount);
ipc.on('update-tray-icon', (_event, unreadCount) => {
if (systemTrayService) {
systemTrayService.setUnreadCount(unreadCount);
}
});
@ -1645,6 +1657,8 @@ installSettingsGetter('theme-setting');
installSettingsSetter('theme-setting');
installSettingsGetter('hide-menu-bar');
installSettingsSetter('hide-menu-bar');
installSettingsGetter('system-tray-setting');
installSettingsSetter('system-tray-setting');
installSettingsGetter('notification-setting');
installSettingsSetter('notification-setting');

View file

@ -157,6 +157,12 @@ try {
window.setMenuBarVisibility = visibility =>
ipc.send('set-menu-bar-visibility', visibility);
window.updateSystemTraySetting = (
systemTraySetting /* : Readonly<SystemTraySetting> */
) => {
ipc.send('update-system-tray-setting', systemTraySetting);
};
window.restart = () => {
window.log.info('restart');
ipc.send('restart');
@ -231,6 +237,8 @@ try {
installSetter('theme-setting', 'setThemeSetting');
installGetter('hide-menu-bar', 'getHideMenuBar');
installSetter('hide-menu-bar', 'setHideMenuBar');
installGetter('system-tray-setting', 'getSystemTraySetting');
installSetter('system-tray-setting', 'setSystemTraySetting');
installGetter('notification-setting', 'getNotificationSetting');
installSetter('notification-setting', 'setNotificationSetting');

View file

@ -157,6 +157,7 @@
{{ spellCheckDirtyText }}
</p>
</div>
<div class="system-tray-setting-container"></div>
{{ #isAutoLaunchSupported }}
<div class='auto-launch-setting'>
<input type='checkbox' name='auto-launch-setting' id='auto-launch-setting' />

View file

@ -64,6 +64,8 @@ window.getThemeSetting = makeGetter('theme-setting');
window.setThemeSetting = makeSetter('theme-setting');
window.getHideMenuBar = makeGetter('hide-menu-bar');
window.setHideMenuBar = makeSetter('hide-menu-bar');
window.getSystemTraySetting = makeGetter('system-tray-setting');
window.setSystemTraySetting = makeSetter('system-tray-setting');
window.getSpellCheck = makeGetter('spell-check');
window.setSpellCheck = makeSetter('spell-check');

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;