// Copyright 2017 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { BrowserWindow, NativeImage } from 'electron'; import { Menu, Tray, app, nativeImage, nativeTheme, screen } from 'electron'; import os from 'node:os'; import { join } from 'node:path'; import { readFileSync } from 'node:fs'; import * as log from '../ts/logging/log'; import type { LocalizerType } from '../ts/types/I18N'; import { SystemThemeType } from '../ts/types/Util'; export type SystemTrayServiceOptionsType = Readonly<{ i18n: LocalizerType; // For testing createTrayInstance?: (icon: NativeImage) => Tray; }>; /** * 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 i18n: LocalizerType; private tray?: Tray; private isEnabled = false; private isQuitting = false; private unreadCount = 0; private boundRender: typeof SystemTrayService.prototype.render; private createTrayInstance: (icon: NativeImage) => Tray; constructor({ i18n, createTrayInstance }: SystemTrayServiceOptionsType) { log.info('System tray service: created'); this.i18n = i18n; this.boundRender = this.render.bind(this); this.createTrayInstance = createTrayInstance || (icon => new Tray(icon)); nativeTheme.on('updated', this.boundRender); } /** * 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(); } /** * Workaround for: https://github.com/electron/electron/issues/32581#issuecomment-1020359931 * * Tray is automatically destroyed when app quits so we shouldn't destroy it * twice when all windows will close. */ markShouldQuit(): void { log.info('System tray service: markShouldQuit'); this.tray = undefined; this.isQuitting = true; } isVisible(): boolean { return this.tray !== undefined; } private render(): void { if (this.isEnabled && this.browserWindow) { this.renderEnabled(); return; } this.renderDisabled(); } private renderEnabled() { if (this.isQuitting) { log.info('System tray service: not rendering the tray, quitting'); return; } log.info('System tray service: rendering the tray'); this.tray ??= this.createTray(); const { browserWindow, tray } = this; try { tray.setImage(getIcon(this.unreadCount)); } catch (err: unknown) { log.warn( 'System tray service: failed to set preferred image. Falling back...' ); tray.setImage(getDefaultIcon()); } // 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.i18n('icu:hide'), 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.i18n('icu:show'), click: () => { log.info( 'System tray service: showing the window from the context menu' ); if (this.browserWindow) { this.browserWindow.show(); focusAndForceToTop(this.browserWindow); } }, }), }, { id: 'quit', label: this.i18n('icu:quit'), 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 = this.createTrayInstance(getDefaultIcon()); // 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.hide(); } else { browserWindow.show(); focusAndForceToTop(browserWindow); } }); result.setToolTip(this.i18n('icu:signalDesktop')); 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; } } const Variant = { Size16: { size: 16, scaleFactor: 1 }, Size32: { size: 32, scaleFactor: 2 }, Size48: { size: 48, scaleFactor: 3 }, Size256: { size: 256, scaleFactor: 16 }, } as const; const Variants = Object.values(Variant); function getDisplaysMaxScaleFactor(): number { const displays = screen.getAllDisplays(); const scaleFactors = displays .map(display => display.scaleFactor) .filter(scaleFactor => Number.isFinite(scaleFactor) && scaleFactor > 1.0); return Math.max(1.0, ...scaleFactors); } function getVariantForScaleFactor(scaleFactor: number) { const match = Variants.find(variant => { return variant.scaleFactor >= scaleFactor; }); return match ?? Variant.Size32; } function getTrayIconImagePath( size: number, theme: SystemThemeType, unreadCount: number ): string { let dirName: string; let fileName: string; if (unreadCount === 0) { dirName = 'base'; fileName = `signal-tray-icon-${size}x${size}-${theme}-base.png`; } else if (unreadCount < 10) { dirName = 'alert'; fileName = `signal-tray-icon-${size}x${size}-${theme}-alert-${unreadCount}.png`; } else { dirName = 'alert'; fileName = `signal-tray-icon-${size}x${size}-${theme}-alert-9+.png`; } const iconPath = join( __dirname, '..', 'images', 'tray-icons', dirName, fileName ); return iconPath; } const TrayIconCache = new Map(); function getIcon(unreadCount: number) { const theme = nativeTheme.shouldUseDarkColors ? SystemThemeType.dark : SystemThemeType.light; const cacheKey = `${theme}-${unreadCount}`; const cached = TrayIconCache.get(cacheKey); if (cached != null) { return cached; } const platform = os.platform(); let image: NativeImage; if (platform === 'linux') { // Linux: Static tray icons // Use a single tray icon for Linux, as it does not support scale factors. // We choose the best icon based on the highest display scale factor. const scaleFactor = getDisplaysMaxScaleFactor(); const variant = getVariantForScaleFactor(scaleFactor); const iconPath = getTrayIconImagePath(variant.size, theme, unreadCount); const buffer = readFileSync(iconPath); image = nativeImage.createFromBuffer(buffer, { scaleFactor: 1.0, // Must be 1.0 for Linux width: variant.size, height: variant.size, }); } else { // Windows/macOS: Responsive tray icons image = nativeImage.createEmpty(); for (const variant of Variants) { const iconPath = getTrayIconImagePath(variant.size, theme, unreadCount); const buffer = readFileSync(iconPath); image.addRepresentation({ buffer, width: variant.size, height: variant.size, scaleFactor: variant.scaleFactor, }); } } TrayIconCache.set(cacheKey, image); return image; } let defaultIcon: undefined | NativeImage; function getDefaultIcon(): NativeImage { defaultIcon ??= getIcon(0); return defaultIcon; } export function focusAndForceToTop(browserWindow: BrowserWindow): void { // 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); }