From af1f2ea44927d92327acda3f20503e7715d4dd89 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Tue, 29 Jun 2021 12:18:03 -0500 Subject: [PATCH] Officially support the system tray on Windows --- _locales/en/messages.json | 8 + app/SystemTrayService.ts | 233 ++++++++++++++++++ app/SystemTraySettingCache.ts | 84 +++++++ app/tray_icon.ts | 138 ----------- js/modules/signal.js | 4 + js/settings_start.js | 3 +- js/views/settings_view.js | 12 +- main.js | 88 ++++--- preload.js | 8 + settings.html | 1 + settings_preload.js | 2 + ts/background.ts | 10 + .../SystemTraySettingsCheckboxes.tsx | 99 ++++++++ ts/dock_icon.ts | 16 -- ts/logging/main_process_logging.ts | 3 + ts/test-node/app/SystemTrayService_test.ts | 233 ++++++++++++++++++ .../app/SystemTraySettingCache_test.ts | 103 ++++++++ ts/test-node/types/Settings_test.ts | 20 +- ts/test-node/types/SystemTraySetting_test.ts | 55 +++++ ts/types/Settings.ts | 8 + ts/types/Storage.d.ts | 2 + ts/types/SystemTraySetting.ts | 22 ++ ts/util/lint/exceptions.json | 8 + ts/window.d.ts | 2 + 24 files changed, 968 insertions(+), 194 deletions(-) create mode 100644 app/SystemTrayService.ts create mode 100644 app/SystemTraySettingCache.ts delete mode 100644 app/tray_icon.ts create mode 100644 ts/components/conversation/SystemTraySettingsCheckboxes.tsx delete mode 100644 ts/dock_icon.ts create mode 100644 ts/test-node/app/SystemTrayService_test.ts create mode 100644 ts/test-node/app/SystemTraySettingCache_test.ts create mode 100644 ts/test-node/types/SystemTraySetting_test.ts create mode 100644 ts/types/SystemTraySetting.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index cc084abd8..7df3e400f 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/app/SystemTrayService.ts b/app/SystemTrayService.ts new file mode 100644 index 000000000..73b1067e7 --- /dev/null +++ b/app/SystemTrayService.ts @@ -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 . + 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); +} diff --git a/app/SystemTraySettingCache.ts b/app/SystemTraySettingCache.ts new file mode 100644 index 000000000..272e79d57 --- /dev/null +++ b/app/SystemTraySettingCache.ts @@ -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; + + constructor( + private readonly sql: Pick, + private readonly argv: Array + ) {} + + async get(): Promise { + 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 { + 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; + } +} diff --git a/app/tray_icon.ts b/app/tray_icon.ts deleted file mode 100644 index 67544660e..000000000 --- a/app/tray_icon.ts +++ /dev/null @@ -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, - }; -} diff --git a/js/modules/signal.js b/js/modules/signal.js index 15753157a..59ef9be8e 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -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, }, diff --git a/js/settings_start.js b/js/settings_start.js index 9766c9cbc..33a8b2caa 100644 --- a/js/settings_start.js +++ b/js/settings_start.js @@ -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(), diff --git a/js/views/settings_view.js b/js/views/settings_view.js index 13617baf4..9a8b4dc87 100644 --- a/js/views/settings_view.js +++ b/js/views/settings_view.js @@ -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', diff --git a/main.js b/main.js index e95fad95f..0dd710e65 100644 --- a/main.js +++ b/main.js @@ -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 */ +) => { + 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'); diff --git a/preload.js b/preload.js index 01ab1f173..d1e55d47e 100644 --- a/preload.js +++ b/preload.js @@ -157,6 +157,12 @@ try { window.setMenuBarVisibility = visibility => ipc.send('set-menu-bar-visibility', visibility); + window.updateSystemTraySetting = ( + systemTraySetting /* : Readonly */ + ) => { + 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'); diff --git a/settings.html b/settings.html index b5145bf31..637fb25d6 100644 --- a/settings.html +++ b/settings.html @@ -157,6 +157,7 @@ {{ spellCheckDirtyText }}

+
{{ #isAutoLaunchSupported }}
diff --git a/settings_preload.js b/settings_preload.js index 021584e5a..9be26c426 100644 --- a/settings_preload.js +++ b/settings_preload.js @@ -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'); diff --git a/ts/background.ts b/ts/background.ts index 988009c55..3265cab53 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -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 { window.setAutoHideMenuBar(value); window.setMenuBarVisibility(!value); }, + getSystemTraySetting: (): SystemTraySetting => + parseSystemTraySetting(window.storage.get('system-tray-setting')), + setSystemTraySetting: (value: Readonly) => { + window.storage.put('system-tray-setting', value); + window.updateSystemTraySetting(value); + }, getNotificationSetting: () => window.storage.get('notification-setting', 'message'), diff --git a/ts/components/conversation/SystemTraySettingsCheckboxes.tsx b/ts/components/conversation/SystemTraySettingsCheckboxes.tsx new file mode 100644 index 000000000..1c7660f90 --- /dev/null +++ b/ts/components/conversation/SystemTraySettingsCheckboxes.tsx @@ -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 = ({ + i18n, + initialValue, + isSystemTraySupported, + onChange, +}) => { + const [localValue, setLocalValue] = useState( + parseSystemTraySetting(initialValue) + ); + + if (!isSystemTraySupported) { + return null; + } + + const setValue = (value: SystemTraySetting): void => { + setLocalValue(oldValue => { + if (oldValue !== value) { + onChange(value); + } + return value; + }); + }; + + const setMinimizeToSystemTray = (event: ChangeEvent) => { + setValue( + event.target.checked + ? SystemTraySetting.MinimizeToSystemTray + : SystemTraySetting.DoNotUseSystemTray + ); + }; + + const setMinimizeToAndStartInSystemTray = ( + event: ChangeEvent + ) => { + setValue( + event.target.checked + ? SystemTraySetting.MinimizeToAndStartInSystemTray + : SystemTraySetting.MinimizeToSystemTray + ); + }; + + const minimizesToTray = shouldMinimizeToSystemTray(localValue); + const minimizesToAndStartsInSystemTray = + localValue === SystemTraySetting.MinimizeToAndStartInSystemTray; + + return ( + <> +
+ + {/* 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;