diff --git a/src/main/electron/native-theme.injectable.ts b/src/main/electron/native-theme.injectable.ts new file mode 100644 index 0000000000..a2d29d8835 --- /dev/null +++ b/src/main/electron/native-theme.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { nativeTheme } from "electron"; + +const nativeThemeInjectable = getInjectable({ + id: "native-theme", + instantiate: () => nativeTheme, + causesSideEffects: true, +}); + +export default nativeThemeInjectable; diff --git a/src/main/index.ts b/src/main/index.ts index 5b4f965a93..4823c43252 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -39,7 +39,7 @@ import { initializeSentryReporting } from "../common/sentry"; import { ensureDir } from "fs-extra"; import { initMenu } from "./menu/menu"; import { kubeApiUpgradeRequest } from "./proxy-functions"; -import { initTray } from "./tray/tray"; +import initTrayInjectable from "./tray/init-tray.injectable"; import { ShellSession } from "./shell-session/shell-session"; import { getDi } from "./getDi"; import extensionLoaderInjectable from "../extensions/extension-loader/extension-loader.injectable"; @@ -53,10 +53,8 @@ import clusterStoreInjectable from "../common/cluster-store/cluster-store.inject import routerInjectable from "./router/router.injectable"; import shellApiRequestInjectable from "./proxy-functions/shell-api-request/shell-api-request.injectable"; import userStoreInjectable from "../common/user-store/user-store.injectable"; -import trayMenuItemsInjectable from "./tray/tray-menu-items.injectable"; import { broadcastNativeThemeOnUpdate } from "./native-theme"; import windowManagerInjectable from "./window-manager.injectable"; -import navigateToPreferencesInjectable from "../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable"; import syncGeneralCatalogEntitiesInjectable from "./catalog-sources/sync-general-catalog-entities.injectable"; import hotbarStoreInjectable from "../common/hotbar-store.injectable"; import applicationMenuItemsInjectable from "./menu/application-menu-items.injectable"; @@ -300,12 +298,11 @@ async function main(di: DiContainer) { const windowManager = di.inject(windowManagerInjectable); const applicationMenuItems = di.inject(applicationMenuItemsInjectable); - const trayMenuItems = di.inject(trayMenuItemsInjectable); - const navigateToPreferences = di.inject(navigateToPreferencesInjectable); + const initTray = di.inject(initTrayInjectable); onQuitCleanup.push( initMenu(applicationMenuItems), - await initTray(windowManager, trayMenuItems, navigateToPreferences), + await initTray(), () => ShellSession.cleanup(), ); diff --git a/src/main/tray/create-current-tray-icon.injectable.ts b/src/main/tray/create-current-tray-icon.injectable.ts new file mode 100644 index 0000000000..6a50b973c0 --- /dev/null +++ b/src/main/tray/create-current-tray-icon.injectable.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import nativeThemeInjectable from "../electron/native-theme.injectable"; +import { createTrayIcon } from "./create-tray-icon"; +import LogoLens from "../../renderer/components/icon/logo-lens.svg"; + +const createCurrentTrayIconInjectable = getInjectable({ + id: "create-current-tray-icon", + instantiate: (di) => { + const nativeTheme = di.inject(nativeThemeInjectable); + + return () => createTrayIcon({ + shouldUseDarkColors: nativeTheme.shouldUseDarkColors, + size: 16, + sourceSvg: LogoLens, + }); + }, +}); + +export default createCurrentTrayIconInjectable; diff --git a/src/main/tray/create-tray-icon.ts b/src/main/tray/create-tray-icon.ts new file mode 100644 index 0000000000..e56871b2c4 --- /dev/null +++ b/src/main/tray/create-tray-icon.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { nativeImage } from "electron"; +import type { NativeImage } from "electron"; +import { base64, getOrInsertWithAsync } from "../../common/utils"; +import sharp from "sharp"; +import { JSDOM } from "jsdom"; + +export interface CreateTrayIconArgs { + shouldUseDarkColors: boolean; + size: number; + sourceSvg: string; +} + +const trayIcons = new Map(); + +export async function createTrayIcon({ shouldUseDarkColors, size, sourceSvg }: CreateTrayIconArgs): Promise { + return getOrInsertWithAsync(trayIcons, shouldUseDarkColors, async () => { + const trayIconColor = shouldUseDarkColors ? "white" : "black"; // Invert to show contrast + const parsedSvg = base64.decode(sourceSvg.split("base64,")[1]); + const svgDom = new JSDOM(`${parsedSvg}`); + const svgRoot = svgDom.window.document.body.getElementsByTagName("svg")[0]; + + svgRoot.innerHTML += ``; + + const iconBuffer = await sharp(Buffer.from(svgRoot.outerHTML)) + .resize({ width: size, height: size }) + .png() + .toBuffer(); + + return nativeImage.createFromBuffer(iconBuffer); + }); +} diff --git a/src/main/tray/init-tray.injectable.ts b/src/main/tray/init-tray.injectable.ts new file mode 100644 index 0000000000..f70ddff171 --- /dev/null +++ b/src/main/tray/init-tray.injectable.ts @@ -0,0 +1,130 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import packageInfo from "../../../package.json"; +import { Menu, Tray } from "electron"; +import { autorun } from "mobx"; +import { showAbout } from "../menu/menu"; +import { checkForUpdates, isAutoUpdateEnabled } from "../app-updater"; +import type { WindowManager } from "../window-manager"; +import logger from "../logger"; +import { isWindows, productName } from "../../common/vars"; +import { exitApp } from "../exit-app"; +import type { Disposer } from "../../common/utils"; +import { disposer, toJS } from "../../common/utils"; +import type { TrayMenuRegistration } from "./tray-menu-registration"; +import { getInjectable } from "@ogre-tools/injectable"; +import windowManagerInjectable from "../window-manager.injectable"; +import navigateToPreferencesInjectable from "../../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable"; +import trayMenuItemsInjectable from "./tray-menu-items.injectable"; +import createCurrentTrayIconInjectable from "./create-current-tray-icon.injectable"; +import trayIconUpdaterInjectable from "./tray-icon-updater.injectable"; + +const initTrayInjectable = getInjectable({ + id: "init-tray", + instantiate: (di) => { + const windowManager = di.inject(windowManagerInjectable); + const trayMenuItems = di.inject(trayMenuItemsInjectable); + const navigateToPreferences = di.inject(navigateToPreferencesInjectable); + const createCurrentTrayIcon = di.inject(createCurrentTrayIconInjectable); + const trayIconUpdater = di.inject(trayIconUpdaterInjectable); + + return async (): Promise => { + const tray = new Tray(await createCurrentTrayIcon()); + + tray.setToolTip(packageInfo.description); + tray.setIgnoreDoubleClickEvents(true); + + if (isWindows) { + tray.on("click", () => { + windowManager + .ensureMainWindow() + .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to open lens`, { error })); + }); + } + + return disposer( + trayIconUpdater(tray), + autorun(() => { + try { + const menu = createTrayMenu(windowManager, toJS(trayMenuItems.get()), navigateToPreferences); + + tray.setContextMenu(menu); + } catch (error) { + logger.error(`${TRAY_LOG_PREFIX}: building failed`, { error }); + } + }), + () => tray.destroy(), + ); + }; + }, +}); + +export default initTrayInjectable; + +const TRAY_LOG_PREFIX = "[TRAY]"; + +function getMenuItemConstructorOptions(trayItem: TrayMenuRegistration): Electron.MenuItemConstructorOptions { + return { + ...trayItem, + submenu: trayItem.submenu ? trayItem.submenu.map(getMenuItemConstructorOptions) : undefined, + click: trayItem.click ? () => { + trayItem.click(trayItem); + } : undefined, + }; +} + +function createTrayMenu( + windowManager: WindowManager, + extensionTrayItems: TrayMenuRegistration[], + navigateToPreferences: () => void, +): Menu { + let template: Electron.MenuItemConstructorOptions[] = [ + { + label: `Open ${productName}`, + click() { + windowManager + .ensureMainWindow() + .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to open lens`, { error })); + }, + }, + { + label: "Preferences", + click() { + navigateToPreferences(); + }, + }, + ]; + + if (isAutoUpdateEnabled()) { + template.push({ + label: "Check for updates", + click() { + checkForUpdates() + .then(() => windowManager.ensureMainWindow()); + }, + }); + } + + template = template.concat(extensionTrayItems.map(getMenuItemConstructorOptions)); + + return Menu.buildFromTemplate(template.concat([ + { + label: `About ${productName}`, + click() { + windowManager.ensureMainWindow() + .then(showAbout) + .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to show Lens About view`, { error })); + }, + }, + { type: "separator" }, + { + label: "Quit App", + click() { + exitApp(); + }, + }, + ])); +} diff --git a/src/main/tray/tray-icon-updater.injectable.ts b/src/main/tray/tray-icon-updater.injectable.ts new file mode 100644 index 0000000000..da4728df76 --- /dev/null +++ b/src/main/tray/tray-icon-updater.injectable.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { Tray } from "electron"; +import type { Disposer } from "../../common/utils"; +import nativeThemeInjectable from "../electron/native-theme.injectable"; +import createCurrentTrayIconInjectable from "./create-current-tray-icon.injectable"; + +export type TrayIconUpdater = (tray: Tray) => Disposer; + +const trayIconUpdaterInjectable = getInjectable({ + id: "tray-icon-updater", + instantiate: (di): TrayIconUpdater => { + const nativeTheme = di.inject(nativeThemeInjectable); + const createCurrentTrayIcon = di.inject(createCurrentTrayIconInjectable); + + return (tray) => { + let prevShouldUseDarkColors = nativeTheme.shouldUseDarkColors; + const onUpdated = () => { + if (prevShouldUseDarkColors !== nativeTheme.shouldUseDarkColors) { + const localShouldUseDarkColors = prevShouldUseDarkColors = nativeTheme.shouldUseDarkColors; + + createCurrentTrayIcon() + .then(img => { + // This guards against rapid changes back and forth + if (localShouldUseDarkColors === prevShouldUseDarkColors) { + tray.setImage(img); + } + }); + } + }; + + nativeTheme.on("updated", onUpdated); + + return () => { + nativeTheme.off("updated", onUpdated); + }; + }; + }, +}); + +export default trayIconUpdaterInjectable; diff --git a/src/main/tray/tray.ts b/src/main/tray/tray.ts deleted file mode 100644 index af129ac27a..0000000000 --- a/src/main/tray/tray.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import packageInfo from "../../../package.json"; -import type { NativeImage } from "electron"; -import { Menu, nativeImage, nativeTheme, Tray } from "electron"; -import type { IComputedValue } from "mobx"; -import { autorun } from "mobx"; -import { showAbout } from "../menu/menu"; -import { checkForUpdates, isAutoUpdateEnabled } from "../app-updater"; -import type { WindowManager } from "../window-manager"; -import logger from "../logger"; -import { isWindows, productName } from "../../common/vars"; -import { exitApp } from "../exit-app"; -import type { Disposer } from "../../common/utils"; -import { base64, disposer, getOrInsertWithAsync, toJS } from "../../common/utils"; -import type { TrayMenuRegistration } from "./tray-menu-registration"; -import sharp from "sharp"; -import LogoLens from "../../renderer/components/icon/logo-lens.svg"; -import { JSDOM } from "jsdom"; - - -const TRAY_LOG_PREFIX = "[TRAY]"; - -// note: instance of Tray should be saved somewhere, otherwise it disappears -export let tray: Tray; - -interface CreateTrayIconArgs { - shouldUseDarkColors: boolean; - size: number; - sourceSvg: string; -} - -const trayIcons = new Map(); - -async function createTrayIcon({ shouldUseDarkColors, size, sourceSvg }: CreateTrayIconArgs): Promise { - return getOrInsertWithAsync(trayIcons, shouldUseDarkColors, async () => { - const trayIconColor = shouldUseDarkColors ? "white" : "black"; // Invert to show contrast - const parsedSvg = base64.decode(sourceSvg.split("base64,")[1]); - const svgDom = new JSDOM(`${parsedSvg}`); - const svgRoot = svgDom.window.document.body.getElementsByTagName("svg")[0]; - - svgRoot.innerHTML += ``; - - const iconBuffer = await sharp(Buffer.from(svgRoot.outerHTML)) - .resize({ width: size, height: size }) - .png() - .toBuffer(); - - return nativeImage.createFromBuffer(iconBuffer); - }); -} - -function createCurrentTrayIcon() { - return createTrayIcon({ - shouldUseDarkColors: nativeTheme.shouldUseDarkColors, - size: 16, - sourceSvg: LogoLens, - }); -} - -function watchShouldUseDarkColors(tray: Tray): Disposer { - let prevShouldUseDarkColors = nativeTheme.shouldUseDarkColors; - const onUpdated = () => { - if (prevShouldUseDarkColors !== nativeTheme.shouldUseDarkColors) { - prevShouldUseDarkColors = nativeTheme.shouldUseDarkColors; - createCurrentTrayIcon() - .then(img => tray.setImage(img)); - } - }; - - nativeTheme.on("updated", onUpdated); - - return () => nativeTheme.off("updated", onUpdated); -} - -export async function initTray( - windowManager: WindowManager, - trayMenuItems: IComputedValue, - navigateToPreferences: () => void, -): Promise { - const icon = await createCurrentTrayIcon(); - const dispose = disposer(); - - tray = new Tray(icon); - tray.setToolTip(packageInfo.description); - tray.setIgnoreDoubleClickEvents(true); - - dispose.push(watchShouldUseDarkColors(tray)); - - if (isWindows) { - tray.on("click", () => { - windowManager - .ensureMainWindow() - .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to open lens`, { error })); - }); - } - - dispose.push( - autorun(() => { - try { - const menu = createTrayMenu(windowManager, toJS(trayMenuItems.get()), navigateToPreferences); - - tray.setContextMenu(menu); - } catch (error) { - logger.error(`${TRAY_LOG_PREFIX}: building failed`, { error }); - } - }), - () => { - tray?.destroy(); - tray = null; - }, - ); - - return dispose; -} - -function getMenuItemConstructorOptions(trayItem: TrayMenuRegistration): Electron.MenuItemConstructorOptions { - return { - ...trayItem, - submenu: trayItem.submenu ? trayItem.submenu.map(getMenuItemConstructorOptions) : undefined, - click: trayItem.click ? () => { - trayItem.click(trayItem); - } : undefined, - }; -} - -function createTrayMenu( - windowManager: WindowManager, - extensionTrayItems: TrayMenuRegistration[], - navigateToPreferences: () => void, -): Menu { - let template: Electron.MenuItemConstructorOptions[] = [ - { - label: `Open ${productName}`, - click() { - windowManager - .ensureMainWindow() - .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to open lens`, { error })); - }, - }, - { - label: "Preferences", - click() { - navigateToPreferences(); - }, - }, - ]; - - if (isAutoUpdateEnabled()) { - template.push({ - label: "Check for updates", - click() { - checkForUpdates() - .then(() => windowManager.ensureMainWindow()); - }, - }); - } - - template = template.concat(extensionTrayItems.map(getMenuItemConstructorOptions)); - - return Menu.buildFromTemplate(template.concat([ - { - label: `About ${productName}`, - click() { - windowManager.ensureMainWindow() - .then(showAbout) - .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to show Lens About view`, { error })); - }, - }, - { type: "separator" }, - { - label: "Quit App", - click() { - exitApp(); - }, - }, - ])); -}