Officially support the system tray on Windows

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

233
app/SystemTrayService.ts Normal file
View file

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

View file

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

View file

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