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
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,
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue