From bc979b72e6dd4fc89c79032e069926ae5a0e51f0 Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Fri, 6 May 2022 13:17:19 +0300 Subject: [PATCH] Make tray items comply with Open Closed Principle Signed-off-by: Janne Savolainen --- src/main/check-for-updates.injectable.ts | 14 ++ .../about-app-tray-item.injectable.ts | 39 ++++++ .../check-for-updates-tray-item.injectable.ts | 36 +++++ .../open-app-tray-item.injectable.ts | 35 +++++ .../open-preferences-tray-item.injectable.ts | 33 +++++ ...quit-app-separator-tray-item.injectable.ts | 24 ++++ .../quit-app-tray-item.injectable.ts | 34 +++++ .../tray-menu-item-injection-token.ts | 25 ++++ .../tray-menu-item-registrator.injectable.ts | 77 +++++++++++ .../tray-menu-items.injectable.ts | 49 +++++++ src/main/tray/tray.injectable.ts | 14 +- src/main/tray/tray.ts | 125 ++++++++---------- 12 files changed, 422 insertions(+), 83 deletions(-) create mode 100644 src/main/check-for-updates.injectable.ts create mode 100644 src/main/tray/tray-menu-item/implementations/about-app-tray-item.injectable.ts create mode 100644 src/main/tray/tray-menu-item/implementations/check-for-updates-tray-item.injectable.ts create mode 100644 src/main/tray/tray-menu-item/implementations/open-app-tray-item.injectable.ts create mode 100644 src/main/tray/tray-menu-item/implementations/open-preferences-tray-item.injectable.ts create mode 100644 src/main/tray/tray-menu-item/implementations/quit-app-separator-tray-item.injectable.ts create mode 100644 src/main/tray/tray-menu-item/implementations/quit-app-tray-item.injectable.ts create mode 100644 src/main/tray/tray-menu-item/tray-menu-item-injection-token.ts create mode 100644 src/main/tray/tray-menu-item/tray-menu-item-registrator.injectable.ts create mode 100644 src/main/tray/tray-menu-item/tray-menu-items.injectable.ts diff --git a/src/main/check-for-updates.injectable.ts b/src/main/check-for-updates.injectable.ts new file mode 100644 index 0000000000..755b4aa0c4 --- /dev/null +++ b/src/main/check-for-updates.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 { checkForUpdates } from "./app-updater"; + +const checkForUpdatesInjectable = getInjectable({ + id: "check-for-updates", + instantiate: () => checkForUpdates, + causesSideEffects: true, +}); + +export default checkForUpdatesInjectable; diff --git a/src/main/tray/tray-menu-item/implementations/about-app-tray-item.injectable.ts b/src/main/tray/tray-menu-item/implementations/about-app-tray-item.injectable.ts new file mode 100644 index 0000000000..67d5312fd5 --- /dev/null +++ b/src/main/tray/tray-menu-item/implementations/about-app-tray-item.injectable.ts @@ -0,0 +1,39 @@ +/** + * 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 productNameInjectable from "../../../app-paths/app-name/product-name.injectable"; +import showApplicationWindowInjectable from "../../../start-main-application/lens-window/show-application-window.injectable"; +import showAboutInjectable from "../../../menu/show-about.injectable"; +import { trayMenuItemInjectionToken } from "../tray-menu-item-injection-token"; +import { computed } from "mobx"; + +const aboutAppTrayItemInjectable = getInjectable({ + id: "about-app-tray-item", + + instantiate: (di) => { + const productName = di.inject(productNameInjectable); + const showApplicationWindow = di.inject(showApplicationWindowInjectable); + const showAbout = di.inject(showAboutInjectable); + + return { + id: "about-app", + parentId: null, + orderNumber: 140, + label: `About ${productName}`, + enabled: computed(() => true), + visible: computed(() => true), + + click: async () => { + await showApplicationWindow(); + + await showAbout(); + }, + }; + }, + + injectionToken: trayMenuItemInjectionToken, +}); + +export default aboutAppTrayItemInjectable; diff --git a/src/main/tray/tray-menu-item/implementations/check-for-updates-tray-item.injectable.ts b/src/main/tray/tray-menu-item/implementations/check-for-updates-tray-item.injectable.ts new file mode 100644 index 0000000000..7c0c4e9874 --- /dev/null +++ b/src/main/tray/tray-menu-item/implementations/check-for-updates-tray-item.injectable.ts @@ -0,0 +1,36 @@ +/** + * 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 showApplicationWindowInjectable from "../../../start-main-application/lens-window/show-application-window.injectable"; +import checkForUpdatesInjectable from "../../../check-for-updates.injectable"; +import isAutoUpdateEnabledInjectable from "../../../is-auto-update-enabled.injectable"; + +const checkForUpdatesTrayItemInjectable = getInjectable({ + id: "check-for-updates-tray-item", + + instantiate: (di) => { + const showApplicationWindow = di.inject(showApplicationWindowInjectable); + const checkForUpdates = di.inject(checkForUpdatesInjectable); + const isAutoUpdateEnabled = di.inject(isAutoUpdateEnabledInjectable); + + return { + id: "check-for-updates", + parentId: null, + orderNumber: 30, + label: "Check for updates", + enabled: computed(() => true), + visible: computed(() => isAutoUpdateEnabled()), + + click: async () => { + await checkForUpdates(); + + await showApplicationWindow(); + }, + }; + }, +}); + +export default checkForUpdatesTrayItemInjectable; diff --git a/src/main/tray/tray-menu-item/implementations/open-app-tray-item.injectable.ts b/src/main/tray/tray-menu-item/implementations/open-app-tray-item.injectable.ts new file mode 100644 index 0000000000..60a076180b --- /dev/null +++ b/src/main/tray/tray-menu-item/implementations/open-app-tray-item.injectable.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 { getInjectable } from "@ogre-tools/injectable"; +import { trayMenuItemInjectionToken } from "../tray-menu-item-injection-token"; +import productNameInjectable from "../../../app-paths/app-name/product-name.injectable"; +import showApplicationWindowInjectable from "../../../start-main-application/lens-window/show-application-window.injectable"; +import { computed } from "mobx"; + +const openAppTrayItemInjectable = getInjectable({ + id: "open-app-tray-item", + + instantiate: (di) => { + const productName = di.inject(productNameInjectable); + const showApplicationWindow = di.inject(showApplicationWindowInjectable); + + return { + id: "open-app", + parentId: null, + label: `Open ${productName}`, + orderNumber: 10, + enabled: computed(() => true), + visible: computed(() => true), + + click: async () => { + await showApplicationWindow(); + }, + }; + }, + + injectionToken: trayMenuItemInjectionToken, +}); + +export default openAppTrayItemInjectable; diff --git a/src/main/tray/tray-menu-item/implementations/open-preferences-tray-item.injectable.ts b/src/main/tray/tray-menu-item/implementations/open-preferences-tray-item.injectable.ts new file mode 100644 index 0000000000..718ac1fd57 --- /dev/null +++ b/src/main/tray/tray-menu-item/implementations/open-preferences-tray-item.injectable.ts @@ -0,0 +1,33 @@ +/** + * 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 { trayMenuItemInjectionToken } from "../tray-menu-item-injection-token"; +import navigateToPreferencesInjectable from "../../../../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable"; +import { computed } from "mobx"; + +const openPreferencesTrayItemInjectable = getInjectable({ + id: "open-preferences-tray-item", + + instantiate: (di) => { + const navigateToPreferences = di.inject(navigateToPreferencesInjectable); + + return { + id: "open-preferences", + parentId: null, + label: "Preferences", + orderNumber: 20, + enabled: computed(() => true), + visible: computed(() => true), + + click: () => { + navigateToPreferences(); + }, + }; + }, + + injectionToken: trayMenuItemInjectionToken, +}); + +export default openPreferencesTrayItemInjectable; diff --git a/src/main/tray/tray-menu-item/implementations/quit-app-separator-tray-item.injectable.ts b/src/main/tray/tray-menu-item/implementations/quit-app-separator-tray-item.injectable.ts new file mode 100644 index 0000000000..d95b5fd278 --- /dev/null +++ b/src/main/tray/tray-menu-item/implementations/quit-app-separator-tray-item.injectable.ts @@ -0,0 +1,24 @@ +/** + * 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 { trayMenuItemInjectionToken } from "../tray-menu-item-injection-token"; +import { computed } from "mobx"; + +const quitAppSeparatorTrayItemInjectable = getInjectable({ + id: "quit-app-separator-tray-item", + + instantiate: () => ({ + id: "quit-app-sepator", + parentId: null, + orderNumber: 149, + enabled: computed(() => true), + visible: computed(() => true), + separator: true, + }), + + injectionToken: trayMenuItemInjectionToken, +}); + +export default quitAppSeparatorTrayItemInjectable; diff --git a/src/main/tray/tray-menu-item/implementations/quit-app-tray-item.injectable.ts b/src/main/tray/tray-menu-item/implementations/quit-app-tray-item.injectable.ts new file mode 100644 index 0000000000..c8fbc60caf --- /dev/null +++ b/src/main/tray/tray-menu-item/implementations/quit-app-tray-item.injectable.ts @@ -0,0 +1,34 @@ +/** + * 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 { trayMenuItemInjectionToken } from "../tray-menu-item-injection-token"; +import { computed } from "mobx"; +import stopServicesAndExitAppInjectable from "../../../stop-services-and-exit-app.injectable"; + +const quitAppTrayItemInjectable = getInjectable({ + id: "quit-app-tray-item", + + instantiate: (di) => { + const stopServicesAndExitApp = di.inject(stopServicesAndExitAppInjectable); + + return { + id: "quit-app", + parentId: null, + orderNumber: 150, + label: "Quit App", + enabled: computed(() => true), + visible: computed(() => true), + separated: true, + + click: () => { + stopServicesAndExitApp(); + }, + }; + }, + + injectionToken: trayMenuItemInjectionToken, +}); + +export default quitAppTrayItemInjectable; diff --git a/src/main/tray/tray-menu-item/tray-menu-item-injection-token.ts b/src/main/tray/tray-menu-item/tray-menu-item-injection-token.ts new file mode 100644 index 0000000000..ce0392b2a1 --- /dev/null +++ b/src/main/tray/tray-menu-item/tray-menu-item-injection-token.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import type { LensMainExtension } from "../../../extensions/lens-main-extension"; + +export interface TrayMenuItem { + id: string; + parentId: string; + orderNumber: number; + enabled: IComputedValue; + visible: IComputedValue; + + label?: string; + click?: () => Promise | void; + tooltip?: string; + separator?: boolean; + extension?: LensMainExtension; +} + +export const trayMenuItemInjectionToken = getInjectionToken({ + id: "tray-menu-item", +}); diff --git a/src/main/tray/tray-menu-item/tray-menu-item-registrator.injectable.ts b/src/main/tray/tray-menu-item/tray-menu-item-registrator.injectable.ts new file mode 100644 index 0000000000..a3ed2ce04f --- /dev/null +++ b/src/main/tray/tray-menu-item/tray-menu-item-registrator.injectable.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { pipeline } from "@ogre-tools/fp"; +import { flatMap, kebabCase } from "lodash/fp"; +import type { Injectable } from "@ogre-tools/injectable"; +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import { extensionRegistratorInjectionToken } from "../../../extensions/extension-loader/extension-registrator-injection-token"; +import type { LensMainExtension } from "../../../extensions/lens-main-extension"; +import type { TrayMenuItem } from "./tray-menu-item-injection-token"; +import { trayMenuItemInjectionToken } from "./tray-menu-item-injection-token"; +import type { TrayMenuRegistration } from "../tray-menu-registration"; + +const trayMenuItemRegistratorInjectable = getInjectable({ + id: "tray-menu-item-registrator", + + instantiate: (di) => (extension: LensMainExtension, installationCounter) => { + pipeline( + extension.trayMenus, + + flatMap(toItemInjectablesFor(extension, installationCounter)), + + (injectables) => di.register(...injectables), + ); + }, + + injectionToken: extensionRegistratorInjectionToken, +}); + +export default trayMenuItemRegistratorInjectable; + + +const toItemInjectablesFor = (extension: LensMainExtension, installationCounter: number) => { + const _toItemInjectables = (parentId: string | null) => (registration: TrayMenuRegistration): Injectable[] => { + const trayItemId = registration.id || kebabCase(registration.label); + const id = `${trayItemId}-tray-menu-item-for-extension-${extension.sanitizedExtensionId}-instance-${installationCounter}`; + + const parentInjectable = getInjectable({ + id, + + instantiate: () => ({ + id, + parentId, + orderNumber: 100, + + separator: registration.type === "separator", + + label: registration.label, + tooltip: registration.toolTip, + + click: () => { + registration.click?.(registration); + }, + + enabled: computed(() => registration.enabled), + visible: computed(() => true), + }), + + injectionToken: trayMenuItemInjectionToken, + }); + + const childMenuItems = registration.submenu || []; + + const childInjectables = childMenuItems.flatMap(_toItemInjectables(id)); + + return [ + parentInjectable, + ...childInjectables, + ]; + }; + + return _toItemInjectables(null); +}; + + diff --git a/src/main/tray/tray-menu-item/tray-menu-items.injectable.ts b/src/main/tray/tray-menu-item/tray-menu-items.injectable.ts new file mode 100644 index 0000000000..c29482007d --- /dev/null +++ b/src/main/tray/tray-menu-item/tray-menu-items.injectable.ts @@ -0,0 +1,49 @@ +/** + * 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 mainExtensionsInjectable from "../../../extensions/main-extensions.injectable"; +import type { TrayMenuItem } from "./tray-menu-item-injection-token"; +import { trayMenuItemInjectionToken } from "./tray-menu-item-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { filter, overSome, sortBy } from "lodash/fp"; +import type { LensMainExtension } from "../../../extensions/lens-main-extension"; + +const trayMenuItemsInjectable = getInjectable({ + id: "tray-menu-items", + + instantiate: (di) => { + const extensions = di.inject(mainExtensionsInjectable); + + return computed(() => { + const enabledExtensions = extensions.get(); + + return pipeline( + di.injectMany(trayMenuItemInjectionToken), + + filter((item) => + overSome([ + isNonExtensionItem, + isEnabledExtensionItemFor(enabledExtensions), + ])(item), + ), + + filter(item => item.visible.get()), + items => sortBy("orderNumber", items), + ); + }); + }, +}); + +const isNonExtensionItem = (item: TrayMenuItem) => !item.extension; + +const isEnabledExtensionItemFor = + (enabledExtensions: LensMainExtension[]) => (item: TrayMenuItem) => + !!enabledExtensions.find((extension) => extension === item.extension); + + +export default trayMenuItemsInjectable; diff --git a/src/main/tray/tray.injectable.ts b/src/main/tray/tray.injectable.ts index 0e61062d50..75b39bf200 100644 --- a/src/main/tray/tray.injectable.ts +++ b/src/main/tray/tray.injectable.ts @@ -4,13 +4,9 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { initTray } from "./tray"; -import trayMenuItemsInjectable from "./tray-menu-items.injectable"; -import navigateToPreferencesInjectable from "../../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable"; -import stopServicesAndExitAppInjectable from "../stop-services-and-exit-app.injectable"; import { getStartableStoppable } from "../../common/utils/get-startable-stoppable"; -import isAutoUpdateEnabledInjectable from "../is-auto-update-enabled.injectable"; -import showAboutInjectable from "../menu/show-about.injectable"; import showApplicationWindowInjectable from "../start-main-application/lens-window/show-application-window.injectable"; +import trayMenuItemsInjectable from "./tray-menu-item/tray-menu-items.injectable"; import trayIconPathInjectable from "./tray-icon-path.injectable"; const trayInjectable = getInjectable({ @@ -18,21 +14,13 @@ const trayInjectable = getInjectable({ instantiate: (di) => { const trayMenuItems = di.inject(trayMenuItemsInjectable); - const navigateToPreferences = di.inject(navigateToPreferencesInjectable); - const stopServicesAndExitApp = di.inject(stopServicesAndExitAppInjectable); - const isAutoUpdateEnabled = di.inject(isAutoUpdateEnabledInjectable); const showApplicationWindow = di.inject(showApplicationWindowInjectable); - const showAboutPopup = di.inject(showAboutInjectable); const trayIconPath = di.inject(trayIconPathInjectable); return getStartableStoppable("build-of-tray", () => initTray( trayMenuItems, - navigateToPreferences, - stopServicesAndExitApp, - isAutoUpdateEnabled, showApplicationWindow, - showAboutPopup, trayIconPath, ), ); diff --git a/src/main/tray/tray.ts b/src/main/tray/tray.ts index c5d0b47ab1..2dbbb73e54 100644 --- a/src/main/tray/tray.ts +++ b/src/main/tray/tray.ts @@ -7,25 +7,22 @@ import packageInfo from "../../../package.json"; import { Menu, Tray } from "electron"; import type { IComputedValue } from "mobx"; import { autorun } from "mobx"; -import { checkForUpdates } from "../app-updater"; import logger from "../logger"; -import { isWindows, productName } from "../../common/vars"; +import { isWindows } from "../../common/vars"; import type { Disposer } from "../../common/utils"; -import { disposer, toJS } from "../../common/utils"; -import type { TrayMenuRegistration } from "./tray-menu-registration"; +import { disposer } from "../../common/utils"; +import type { TrayMenuItem } from "./tray-menu-item/tray-menu-item-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { filter, isEmpty, map } from "lodash/fp"; -const TRAY_LOG_PREFIX = "[TRAY]"; +export const TRAY_LOG_PREFIX = "[TRAY]"; // note: instance of Tray should be saved somewhere, otherwise it disappears export let tray: Tray | null = null; export function initTray( - trayMenuItems: IComputedValue, - navigateToPreferences: () => void, - stopServicesAndExitApp: () => void, - isAutoUpdateEnabled: () => boolean, + trayMenuItems: IComputedValue, showApplicationWindow: () => Promise, - showAbout: () => void, trayIconPath: string, ): Disposer { tray = new Tray(trayIconPath); @@ -42,7 +39,9 @@ export function initTray( return disposer( autorun(() => { try { - const menu = createTrayMenu(toJS(trayMenuItems.get()), navigateToPreferences, stopServicesAndExitApp, isAutoUpdateEnabled, showApplicationWindow, showAbout); + const options = toTrayMenuOptions(trayMenuItems.get()); + + const menu = Menu.buildFromTemplate(options); tray?.setContextMenu(menu); } catch (error) { @@ -56,66 +55,52 @@ export function initTray( ); } -function getMenuItemConstructorOptions(trayItem: TrayMenuRegistration): Electron.MenuItemConstructorOptions { - return { - ...trayItem, - submenu: trayItem.submenu ? trayItem.submenu.map(getMenuItemConstructorOptions) : undefined, - click: trayItem.click ? () => { - trayItem.click?.(trayItem); - } : undefined, - }; -} +const toTrayMenuOptions = (trayMenuItems: TrayMenuItem[]) => { + const _toTrayMenuOptions = (parentId: string | null) => + pipeline( + trayMenuItems, -function createTrayMenu( - extensionTrayItems: TrayMenuRegistration[], - navigateToPreferences: () => void, - stopServicesAndExitApp: () => void, - isAutoUpdateEnabled: () => boolean, - showApplicationWindow: () => Promise, - showAbout: () => void, -): Menu { - let template: Electron.MenuItemConstructorOptions[] = [ - { - label: `Open ${productName}`, - click() { - showApplicationWindow().catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to open lens`, { error })); - }, - }, - { - label: "Preferences", - click() { - navigateToPreferences(); - }, - }, - ]; + filter((item) => item.parentId === parentId), - if (isAutoUpdateEnabled()) { - template.push({ - label: "Check for updates", - click() { - checkForUpdates() - .then(() => showApplicationWindow()); - }, - }); - } + map( + (trayMenuItem: TrayMenuItem): Electron.MenuItemConstructorOptions => { + if (trayMenuItem.separator) { + return { id: trayMenuItem.id, type: "separator" }; + } - template = template.concat(extensionTrayItems.map(getMenuItemConstructorOptions)); + const childItems = _toTrayMenuOptions(trayMenuItem.id); + + return { + id: trayMenuItem.id, + label: trayMenuItem.label, + enabled: trayMenuItem.enabled.get(), + toolTip: trayMenuItem.tooltip, + + ...(isEmpty(childItems) + ? { + type: "normal", + submenu: _toTrayMenuOptions(trayMenuItem.id), + + click: () => { + try { + trayMenuItem.click?.(); + } catch (error) { + logger.error( + `${TRAY_LOG_PREFIX}: clicking item "${trayMenuItem.id} failed."`, + { error }, + ); + } + }, + } + : { + type: "submenu", + submenu: _toTrayMenuOptions(trayMenuItem.id), + }), + }; + }, + ), + ); + + return _toTrayMenuOptions(null); +}; - return Menu.buildFromTemplate(template.concat([ - { - label: `About ${productName}`, - click() { - showApplicationWindow() - .then(showAbout) - .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to show Lens About view`, { error })); - }, - }, - { type: "separator" }, - { - label: "Quit App", - click() { - stopServicesAndExitApp(); - }, - }, - ])); -}