Officially support the system tray on Windows
This commit is contained in:
parent
23acbf284e
commit
af1f2ea449
24 changed files with 968 additions and 194 deletions
|
@ -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
233
app/SystemTrayService.ts
Normal 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);
|
||||
}
|
84
app/SystemTraySettingCache.ts
Normal file
84
app/SystemTraySettingCache.ts
Normal 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;
|
||||
}
|
||||
}
|
138
app/tray_icon.ts
138
app/tray_icon.ts
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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
88
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<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');
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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' />
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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'),
|
||||
|
|
99
ts/components/conversation/SystemTraySettingsCheckboxes.tsx
Normal file
99
ts/components/conversation/SystemTraySettingsCheckboxes.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
233
ts/test-node/app/SystemTrayService_test.ts
Normal file
233
ts/test-node/app/SystemTrayService_test.ts
Normal 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$/);
|
||||
});
|
||||
});
|
103
ts/test-node/app/SystemTraySettingCache_test.ts
Normal file
103
ts/test-node/app/SystemTraySettingCache_test.ts
Normal 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);
|
||||
});
|
||||
});
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
55
ts/test-node/types/SystemTraySetting_test.ts
Normal file
55
ts/test-node/types/SystemTraySetting_test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
|
|
2
ts/types/Storage.d.ts
vendored
2
ts/types/Storage.d.ts
vendored
|
@ -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';
|
||||
|
|
22
ts/types/SystemTraySetting.ts
Normal file
22
ts/types/SystemTraySetting.ts
Normal 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
|
||||
);
|
|
@ -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
2
ts/window.d.ts
vendored
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue