diff --git a/src/main/index.ts b/src/main/index.ts index 5b4f965a93..8e313b08f2 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/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 cleanupTray = await di.inject(initTrayInjectable); onQuitCleanup.push( initMenu(applicationMenuItems), - await initTray(windowManager, trayMenuItems, navigateToPreferences), + cleanupTray, () => ShellSession.cleanup(), ); diff --git a/src/main/tray/create-icon.ts b/src/main/tray/create-icon.ts new file mode 100644 index 0000000000..79a432fa0f --- /dev/null +++ b/src/main/tray/create-icon.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { type NativeImage, nativeImage, nativeTheme, type Tray } from "electron"; +import { JSDOM } from "jsdom"; +import sharp from "sharp"; +import { getOrInsertWithAsync, base64, type Disposer } from "../../common/utils"; +import LogoLens from "../../renderer/components/icon/logo-lens.svg"; + +export 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); + }); +} + +export function createCurrentTrayIcon() { + return createTrayIcon({ + shouldUseDarkColors: nativeTheme.shouldUseDarkColors, + size: 16, + sourceSvg: LogoLens, + }); +} + +export 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); +} diff --git a/src/main/tray/tray-menu-items.injectable.ts b/src/main/tray/tray-menu-items.injectable.ts index 8ee9d25e5e..858f126f99 100644 --- a/src/main/tray/tray-menu-items.injectable.ts +++ b/src/main/tray/tray-menu-items.injectable.ts @@ -8,12 +8,10 @@ import mainExtensionsInjectable from "../../extensions/main-extensions.injectabl const trayItemsInjectable = getInjectable({ id: "tray-items", - instantiate: (di) => { const extensions = di.inject(mainExtensionsInjectable); - return computed(() => - extensions.get().flatMap(extension => extension.trayMenus)); + return computed(() => extensions.get().flatMap(extension => extension.trayMenus)); }, }); diff --git a/src/main/tray/tray-menu.injectable.ts b/src/main/tray/tray-menu.injectable.ts new file mode 100644 index 0000000000..d700a1eed1 --- /dev/null +++ b/src/main/tray/tray-menu.injectable.ts @@ -0,0 +1,84 @@ +/** + * 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 { computed } from "mobx"; +import navigateToPreferencesInjectable from "../../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable"; +import showAboutInjectable from "../menu/show-about.injectable"; +import windowManagerInjectable from "../window-manager.injectable"; +import trayMenuItemsInjectable from "./tray-menu-items.injectable"; +import type { TrayMenuRegistration } from "./tray-menu-registration"; +import { checkForUpdates, isAutoUpdateEnabled } from "../app-updater"; +import logger from "../logger"; +import { productName } from "../../common/vars"; +import { exitApp } from "../exit-app"; +import { TRAY_LOG_PREFIX } from "./tray.injectable"; + +function getMenuItemConstructorOptions(trayItem: TrayMenuRegistration): Electron.MenuItemConstructorOptions { + return { + ...trayItem, + submenu: trayItem.submenu ? trayItem.submenu.map(getMenuItemConstructorOptions) : undefined, + click: trayItem.click ? () => { + trayItem.click(trayItem); + } : undefined, + }; +} + +const trayMenuInjectable = getInjectable({ + id: "tray-menu", + instantiate: (di) => { + const windowManager = di.inject(windowManagerInjectable); + const trayMenuItems = di.inject(trayMenuItemsInjectable); + const navigateToPreferences = di.inject(navigateToPreferencesInjectable); + const showAbout = di.inject(showAboutInjectable); + + return computed((): Electron.MenuItemConstructorOptions[] => [ + { + label: `Open ${productName}`, + click() { + windowManager + .ensureMainWindow() + .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to open lens`, { error })); + }, + }, + { + label: "Preferences", + click() { + navigateToPreferences(); + }, + }, + ...( + isAutoUpdateEnabled() + ? [ + { + label: "Check for updates", + click() { + checkForUpdates() + .then(() => windowManager.ensureMainWindow()); + }, + }, + ] + : [] + ), + ...trayMenuItems.get().map(getMenuItemConstructorOptions), + { + label: `About ${productName}`, + click() { + windowManager.ensureMainWindow() + .then(showAbout) + .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to show Lens About view`, { error })); + }, + }, + { type: "separator" } as const, + { + label: "Quit App", + click() { + exitApp(); + }, + }, + ]); + }, +}); + +export default trayMenuInjectable; diff --git a/src/main/tray/tray.injectable.ts b/src/main/tray/tray.injectable.ts new file mode 100644 index 0000000000..0a0aebe2bd --- /dev/null +++ b/src/main/tray/tray.injectable.ts @@ -0,0 +1,54 @@ +/** + * 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 { reaction } from "mobx"; +import logger from "../logger"; +import { isWindows } from "../../common/vars"; +import type { Disposer } from "../../common/utils"; +import { disposer } from "../../common/utils"; +import { getInjectable } from "@ogre-tools/injectable"; +import windowManagerInjectable from "../window-manager.injectable"; +import trayMenuInjectable from "./tray-menu.injectable"; +import { createCurrentTrayIcon, watchShouldUseDarkColors } from "./create-icon"; + +export const TRAY_LOG_PREFIX = "[TRAY]"; + +const initTrayInjectable = getInjectable({ + id: "init-tray", + instantiate: async (di): Promise => { + const windowManager = di.inject(windowManagerInjectable); + const trayMenu = di.inject(trayMenuInjectable); + + const icon = await createCurrentTrayIcon(); + const tray = new Tray(icon); + + 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( + watchShouldUseDarkColors(tray), + () => tray.destroy(), + reaction( + () => trayMenu.get(), + menuItemOptions => tray.setContextMenu(Menu.buildFromTemplate(menuItemOptions)), + { + fireImmediately: true, + }, + ), + ); + }, +}); + +export default initTrayInjectable; diff --git a/src/main/tray/tray.ts b/src/main/tray/tray.ts deleted file mode 100644 index 8515354dc3..0000000000 --- a/src/main/tray/tray.ts +++ /dev/null @@ -1,188 +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 { 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"; - -import type { ShowAbout } from "../menu/show-about.injectable"; - -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, - showAbout: ShowAbout, -): 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, - showAbout, - ); - - 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, - showAbout: ShowAbout, -): 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(); - }, - }, - ])); -}