diff --git a/Makefile b/Makefile index 48ce768766..9dfb2cf512 100644 --- a/Makefile +++ b/Makefile @@ -56,6 +56,7 @@ integration: build build: node_modules binaries/client yarn run npm:fix-build-version $(MAKE) build-extensions -B + yarn run build:tray-icons yarn run compile ifeq "$(DETECTED_OS)" "Windows" # https://github.com/ukoloff/win-ca#clear-pem-folder-on-publish diff --git a/build/generate-tray-icons.ts b/build/generate-tray-icons.ts index a7ab3bd48b..ed90d27832 100644 --- a/build/generate-tray-icons.ts +++ b/build/generate-tray-icons.ts @@ -3,8 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { readFileSync } from "fs"; -import { ensureDirSync } from "fs-extra"; +import { ensureDir, readFile } from "fs-extra"; import { JSDOM } from "jsdom"; import path from "path"; import sharp from "sharp"; @@ -12,39 +11,120 @@ import sharp from "sharp"; const size = Number(process.env.OUTPUT_SIZE || "16"); const outputFolder = process.env.OUTPUT_DIR || "./build/tray"; const inputFile = process.env.INPUT_SVG_PATH || "./src/renderer/components/icon/logo-lens.svg"; +const noticeFile = process.env.NOTICE_SVG_PATH || "./src/renderer/components/icon/notice.svg"; -const svgData = readFileSync(inputFile, { encoding: "utf-8" }); -const svgDom = new JSDOM(`${svgData}`); -const svgRoot = svgDom.window.document.body.getElementsByTagName("svg")[0]; +async function ensureOutputFoler() { + await ensureDir(outputFolder); +} -svgRoot.innerHTML += ``; -const lightTemplate = svgRoot.outerHTML; +function getSvgStyling(colouring: "dark" | "light"): string { + return ` + + `; +} -svgRoot.innerHTML += ``; +type TargetSystems = "macos" | "windows-or-linux"; -const darkTemplate = svgRoot.outerHTML; +async function getBaseIconImage(system: TargetSystems) { + const svgData = await readFile(inputFile, { encoding: "utf-8" }); + const dom = new JSDOM(`${svgData}`); + const root = dom.window.document.body.getElementsByTagName("svg")[0]; -console.log("Generating tray icon pngs"); + root.innerHTML += getSvgStyling(system === "macos" ? "light" : "dark"); -ensureDirSync(outputFolder); + return Buffer.from(root.outerHTML); +} -Promise.all([ - sharp(Buffer.from(lightTemplate)) +async function generateImage(image: Buffer, size: number, namePrefix: string) { + sharp(image) .resize({ width: size, height: size }) .png() - .toFile(path.join(outputFolder, "trayIconDarkTemplate.png")), - sharp(Buffer.from(lightTemplate)) - .resize({ width: size*2, height: size*2 }) - .png() - .toFile(path.join(outputFolder, "trayIconDarkTemplate@2x.png")), - sharp(Buffer.from(darkTemplate)) - .resize({ width: size, height: size }) - .png() - .toFile(path.join(outputFolder, "trayIconTemplate.png")), - sharp(Buffer.from(darkTemplate)) - .resize({ width: size*2, height: size*2 }) - .png() - .toFile(path.join(outputFolder, "trayIconTemplate@2x.png")), -]) - .then((resolutions) => console.log(`Generated ${resolutions.length} images`)) - .catch(console.error); + .toFile(path.join(outputFolder, `${namePrefix}.png`)); +} + +async function generateImages(image: Buffer, size: number, name: string) { + await Promise.all([ + generateImage(image, size, name), + generateImage(image, size*2, `${name}@2x`), + generateImage(image, size*3, `${name}@3x`), + generateImage(image, size*4, `${name}@4x`), + ]); +} + +async function generateUpdateAvailableImages(baseImage: Buffer, system: TargetSystems) { + const noticeIconImage = await getNoticeIconImage(system); + const circleBuffer = await sharp(Buffer.from(` + + + + `)) + .toBuffer(); + + return sharp(baseImage) + .resize({ width: 128, height: 128 }) + .composite([ + { + input: circleBuffer, + top: 64, + left: 64, + blend: "dest-out", + }, + { + input: ( + await sharp(noticeIconImage) + .resize({ + width: 60, + height: 60, + }) + .toBuffer() + ), + top: 66, + left: 66, + }, + ]) + .toBuffer(); +} + +async function getNoticeIconImage(system: TargetSystems) { + const svgData = await readFile(noticeFile, { encoding: "utf-8" }); + const root = new JSDOM(svgData).window.document.getElementsByTagName("svg")[0]; + + root.innerHTML += getSvgStyling(system === "macos" ? "light" : "dark"); + + return Buffer.from(root.outerHTML); +} + +async function generateTrayIcons() { + try { + console.log("Generating tray icon pngs"); + await ensureOutputFoler(); + + const baseIconTemplateImage = await getBaseIconImage("macos"); + const updateAvailableTemplateImage = await generateUpdateAvailableImages(baseIconTemplateImage, "macos"); + const baseIconImage = await getBaseIconImage("windows-or-linux"); + const updateAvailableImage = await generateUpdateAvailableImages(baseIconImage, "windows-or-linux"); + + await Promise.all([ + // Templates are for macOS only + generateImages(baseIconTemplateImage, size, "trayIconTemplate"), + generateImages(updateAvailableTemplateImage, size, "trayIconUpdateAvailableTemplate"), + + // Non-templates are for windows and linux + generateImages(baseIconImage, size, "trayIcon"), + generateImages(updateAvailableImage, size, "trayIconUpdateAvailable"), + ]); + + console.log("Generated all images"); + } catch (error) { + console.error(error); + } +} + +generateTrayIcons(); diff --git a/build/tray/trayIconDarkTemplate.png b/build/tray/trayIcon.png similarity index 100% rename from build/tray/trayIconDarkTemplate.png rename to build/tray/trayIcon.png diff --git a/build/tray/trayIconDarkTemplate@2x.png b/build/tray/trayIcon@2x.png similarity index 100% rename from build/tray/trayIconDarkTemplate@2x.png rename to build/tray/trayIcon@2x.png diff --git a/build/tray/trayIcon@3x.png b/build/tray/trayIcon@3x.png new file mode 100644 index 0000000000..c706ec9b3a Binary files /dev/null and b/build/tray/trayIcon@3x.png differ diff --git a/build/tray/trayIcon@4x.png b/build/tray/trayIcon@4x.png new file mode 100644 index 0000000000..22b1c50c28 Binary files /dev/null and b/build/tray/trayIcon@4x.png differ diff --git a/build/tray/trayIconTemplate@3x.png b/build/tray/trayIconTemplate@3x.png new file mode 100644 index 0000000000..2e06ee1a7d Binary files /dev/null and b/build/tray/trayIconTemplate@3x.png differ diff --git a/build/tray/trayIconTemplate@4x.png b/build/tray/trayIconTemplate@4x.png new file mode 100644 index 0000000000..58567e118a Binary files /dev/null and b/build/tray/trayIconTemplate@4x.png differ diff --git a/build/tray/trayIconUpdateAvailable.png b/build/tray/trayIconUpdateAvailable.png new file mode 100644 index 0000000000..88dd098ce7 Binary files /dev/null and b/build/tray/trayIconUpdateAvailable.png differ diff --git a/build/tray/trayIconUpdateAvailable@2x.png b/build/tray/trayIconUpdateAvailable@2x.png new file mode 100644 index 0000000000..b4c1167c04 Binary files /dev/null and b/build/tray/trayIconUpdateAvailable@2x.png differ diff --git a/build/tray/trayIconUpdateAvailable@3x.png b/build/tray/trayIconUpdateAvailable@3x.png new file mode 100644 index 0000000000..30af0bb440 Binary files /dev/null and b/build/tray/trayIconUpdateAvailable@3x.png differ diff --git a/build/tray/trayIconUpdateAvailable@4x.png b/build/tray/trayIconUpdateAvailable@4x.png new file mode 100644 index 0000000000..42d2effc9e Binary files /dev/null and b/build/tray/trayIconUpdateAvailable@4x.png differ diff --git a/build/tray/trayIconUpdateAvailableTemplate.png b/build/tray/trayIconUpdateAvailableTemplate.png new file mode 100644 index 0000000000..72fd9a8cf7 Binary files /dev/null and b/build/tray/trayIconUpdateAvailableTemplate.png differ diff --git a/build/tray/trayIconUpdateAvailableTemplate@2x.png b/build/tray/trayIconUpdateAvailableTemplate@2x.png new file mode 100644 index 0000000000..eed819c648 Binary files /dev/null and b/build/tray/trayIconUpdateAvailableTemplate@2x.png differ diff --git a/build/tray/trayIconUpdateAvailableTemplate@3x.png b/build/tray/trayIconUpdateAvailableTemplate@3x.png new file mode 100644 index 0000000000..4fed4a6d09 Binary files /dev/null and b/build/tray/trayIconUpdateAvailableTemplate@3x.png differ diff --git a/build/tray/trayIconUpdateAvailableTemplate@4x.png b/build/tray/trayIconUpdateAvailableTemplate@4x.png new file mode 100644 index 0000000000..4d1342ea24 Binary files /dev/null and b/build/tray/trayIconUpdateAvailableTemplate@4x.png differ diff --git a/src/behaviours/application-update/__snapshots__/installing-update-using-tray.test.ts.snap b/src/behaviours/application-update/__snapshots__/installing-update-using-tray.test.ts.snap index 32e6cb1cb1..5c5ebac6e4 100644 --- a/src/behaviours/application-update/__snapshots__/installing-update-using-tray.test.ts.snap +++ b/src/behaviours/application-update/__snapshots__/installing-update-using-tray.test.ts.snap @@ -44,7 +44,7 @@ exports[`installing update using tray when started when user checks for updates > { let applicationBuilder: ApplicationBuilder; let checkForPlatformUpdatesMock: AsyncFnMock; let downloadPlatformUpdateMock: AsyncFnMock; let showApplicationWindowMock: jest.Mock; + let trayIconPaths: TrayIconPaths; beforeEach(() => { applicationBuilder = getApplicationBuilder(); @@ -44,6 +47,7 @@ describe("installing update using tray", () => { mainDi.override(electronUpdaterIsActiveInjectable, () => true); mainDi.override(publishIsConfiguredInjectable, () => true); + trayIconPaths = mainDi.inject(trayIconPathsInjectable); }); }); @@ -58,22 +62,29 @@ describe("installing update using tray", () => { expect(rendered.baseElement).toMatchSnapshot(); }); + it("should use the normal tray icon", () => { + expect(applicationBuilder.tray.getIconPath()).toBe(trayIconPaths.normal); + }); + it("user cannot install update yet", () => { - expect(applicationBuilder.tray.get("install-update")).toBeUndefined(); + expect(applicationBuilder.tray.get("install-update")).toBeNull(); }); describe("when user checks for updates using tray", () => { let processCheckingForUpdatesPromise: Promise; beforeEach(async () => { - processCheckingForUpdatesPromise = - applicationBuilder.tray.click("check-for-updates"); + processCheckingForUpdatesPromise = applicationBuilder.tray.click("check-for-updates"); }); it("does not show application window yet", () => { expect(showApplicationWindowMock).not.toHaveBeenCalled(); }); + it("should still use the normal tray icon", () => { + expect(applicationBuilder.tray.getIconPath()).toBe(trayIconPaths.normal); + }); + it("user cannot check for updates again", () => { expect( applicationBuilder.tray.get("check-for-updates")?.enabled.get(), @@ -87,7 +98,7 @@ describe("installing update using tray", () => { }); it("user cannot install update yet", () => { - expect(applicationBuilder.tray.get("install-update")).toBeUndefined(); + expect(applicationBuilder.tray.get("install-update")).toBeNull(); }); it("renders", () => { @@ -107,8 +118,12 @@ describe("installing update using tray", () => { expect(showApplicationWindowMock).toHaveBeenCalled(); }); + it("should still use the normal tray icon", () => { + expect(applicationBuilder.tray.getIconPath()).toBe(trayIconPaths.normal); + }); + it("user cannot install update", () => { - expect(applicationBuilder.tray.get("install-update")).toBeUndefined(); + expect(applicationBuilder.tray.get("install-update")).toBeNull(); }); it("user can check for updates again", () => { @@ -142,6 +157,10 @@ describe("installing update using tray", () => { expect(showApplicationWindowMock).toHaveBeenCalled(); }); + it("should use the update available icon", () => { + expect(applicationBuilder.tray.getIconPath()).toBe(trayIconPaths.updateAvailable); + }); + it("user cannot check for updates again yet", () => { expect( applicationBuilder.tray.get("check-for-updates")?.enabled.get(), @@ -167,7 +186,7 @@ describe("installing update using tray", () => { }); it("user still cannot install update", () => { - expect(applicationBuilder.tray.get("install-update")).toBeUndefined(); + expect(applicationBuilder.tray.get("install-update")).toBeNull(); }); it("renders", () => { @@ -182,7 +201,11 @@ describe("installing update using tray", () => { it("user cannot install update", () => { expect( applicationBuilder.tray.get("install-update"), - ).toBeUndefined(); + ).toBeNull(); + }); + + it("should revert to use the normal tray icon", () => { + expect(applicationBuilder.tray.getIconPath()).toBe(trayIconPaths.normal); }); it("user can check for updates again", () => { @@ -213,6 +236,10 @@ describe("installing update using tray", () => { ).toBe("Install update some-version"); }); + it("should use the update available icon", () => { + expect(applicationBuilder.tray.getIconPath()).toBe(trayIconPaths.updateAvailable); + }); + it("user can check for updates again", () => { expect( applicationBuilder.tray.get("check-for-updates")?.enabled.get(), diff --git a/src/common/application-update/update-channels.ts b/src/common/application-update/update-channels.ts index c5f7b4b8c1..dff1e5879e 100644 --- a/src/common/application-update/update-channels.ts +++ b/src/common/application-update/update-channels.ts @@ -3,6 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ + export type UpdateChannelId = "alpha" | "beta" | "latest"; const latestChannel: UpdateChannel = { diff --git a/src/common/utils/sync-box/sync-box-injection-token.ts b/src/common/utils/sync-box/sync-box-injection-token.ts index d35c7d5367..76ba0679f3 100644 --- a/src/common/utils/sync-box/sync-box-injection-token.ts +++ b/src/common/utils/sync-box/sync-box-injection-token.ts @@ -4,12 +4,21 @@ */ import { getInjectionToken } from "@ogre-tools/injectable"; import type { IComputedValue } from "mobx"; -import type { JsonValue } from "type-fest"; -export interface SyncBox { +type AsJson = T extends string | number | boolean | null + ? T + : T extends Function + ? never + : T extends Array + ? AsJson[] + : T extends object + ? { [K in keyof T]: AsJson } + : never; + +export interface SyncBox { id: string; - value: IComputedValue; - set: (value: TValue) => void; + value: IComputedValue>; + set: (value: AsJson) => void; } export const syncBoxInjectionToken = getInjectionToken>({ diff --git a/src/main/tray/electron-tray/electron-tray.injectable.ts b/src/main/tray/electron-tray/electron-tray.injectable.ts index 409e7abf3f..a96104f047 100644 --- a/src/main/tray/electron-tray/electron-tray.injectable.ts +++ b/src/main/tray/electron-tray/electron-tray.injectable.ts @@ -5,31 +5,37 @@ import { getInjectable } from "@ogre-tools/injectable"; import { Menu, Tray } from "electron"; import packageJsonInjectable from "../../../common/vars/package-json.injectable"; -import logger from "../../logger"; -import { TRAY_LOG_PREFIX } from "../tray"; import showApplicationWindowInjectable from "../../start-main-application/lens-window/show-application-window.injectable"; -import type { TrayMenuItem } from "../tray-menu-item/tray-menu-item-injection-token"; -import { pipeline } from "@ogre-tools/fp"; -import { isEmpty, map, filter } from "lodash/fp"; import isWindowsInjectable from "../../../common/vars/is-windows.injectable"; import loggerInjectable from "../../../common/logger.injectable"; -import trayIconPathInjectable from "../tray-icon-path.injectable"; +import trayIconPathsInjectable from "../tray-icon-path.injectable"; +import type { TrayMenuItem } from "../tray-menu-item/tray-menu-item-injection-token"; +import { convertToElectronMenuTemplate } from "../reactive-tray-menu-items/converters"; + +const TRAY_LOG_PREFIX = "[TRAY]"; + +export interface ElectronTray { + start(): void; + stop(): void; + setMenuItems(menuItems: TrayMenuItem[]): void; + setIconPath(iconPath: string): void; +} const electronTrayInjectable = getInjectable({ id: "electron-tray", - instantiate: (di) => { + instantiate: (di): ElectronTray => { const packageJson = di.inject(packageJsonInjectable); const showApplicationWindow = di.inject(showApplicationWindowInjectable); const isWindows = di.inject(isWindowsInjectable); const logger = di.inject(loggerInjectable); - const trayIconPath = di.inject(trayIconPathInjectable); + const trayIconPaths = di.inject(trayIconPathsInjectable); let tray: Tray; return { start: () => { - tray = new Tray(trayIconPath); + tray = new Tray(trayIconPaths.normal); tray.setToolTip(packageJson.description); tray.setIgnoreDoubleClickEvents(true); @@ -41,21 +47,17 @@ const electronTrayInjectable = getInjectable({ }); } }, - stop: () => { tray.destroy(); }, + setMenuItems: (menuItems) => { + const template = convertToElectronMenuTemplate(menuItems); + const menu = Menu.buildFromTemplate(template); - setMenuItems: (items: TrayMenuItem[]) => { - pipeline( - items, - convertToElectronMenuTemplate, - Menu.buildFromTemplate, - - (template) => { - tray.setContextMenu(template); - }, - ); + tray.setContextMenu(menu); + }, + setIconPath: (iconPath) => { + tray.setImage(iconPath); }, }; }, @@ -64,53 +66,3 @@ const electronTrayInjectable = getInjectable({ }); export default electronTrayInjectable; - -const convertToElectronMenuTemplate = (trayMenuItems: TrayMenuItem[]) => { - const _toTrayMenuOptions = (parentId: string | null) => - pipeline( - trayMenuItems, - - filter((item) => item.parentId === parentId), - - map( - (trayMenuItem: TrayMenuItem): Electron.MenuItemConstructorOptions => { - if (trayMenuItem.separator) { - return { id: trayMenuItem.id, type: "separator" }; - } - - const childItems = _toTrayMenuOptions(trayMenuItem.id); - - return { - id: trayMenuItem.id, - label: trayMenuItem.label?.get(), - 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); -}; diff --git a/src/main/tray/menu-icon/reactive.injectable.ts b/src/main/tray/menu-icon/reactive.injectable.ts new file mode 100644 index 0000000000..42622ff2a8 --- /dev/null +++ b/src/main/tray/menu-icon/reactive.injectable.ts @@ -0,0 +1,37 @@ +/** + * 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 { reaction } from "mobx"; +import discoveredUpdateVersionInjectable from "../../../common/application-update/discovered-update-version/discovered-update-version.injectable"; +import { getStartableStoppable } from "../../../common/utils/get-startable-stoppable"; +import electronTrayInjectable from "../electron-tray/electron-tray.injectable"; +import trayIconPathsInjectable from "../tray-icon-path.injectable"; + +const reactiveTrayMenuIconInjectable = getInjectable({ + id: "reactive-tray-menu-icon", + instantiate: (di) => { + const discoveredUpdateVersion = di.inject(discoveredUpdateVersionInjectable); + const electronTray = di.inject(electronTrayInjectable); + const trayIconPaths = di.inject(trayIconPathsInjectable); + + return getStartableStoppable("reactive-tray-menu-icon", () => ( + reaction( + () => discoveredUpdateVersion.value.get(), + updateVersion => { + if (updateVersion) { + electronTray.setIconPath(trayIconPaths.updateAvailable); + } else { + electronTray.setIconPath(trayIconPaths.normal); + } + }, + { + fireImmediately: true, + }, + ) + )); + }, +}); + +export default reactiveTrayMenuIconInjectable; diff --git a/src/main/tray/menu-icon/start-reactivity.injectable.ts b/src/main/tray/menu-icon/start-reactivity.injectable.ts new file mode 100644 index 0000000000..373c3cf8fb --- /dev/null +++ b/src/main/tray/menu-icon/start-reactivity.injectable.ts @@ -0,0 +1,28 @@ +/** + * 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 { onLoadOfApplicationInjectionToken } from "../../start-main-application/runnable-tokens/on-load-of-application-injection-token"; +import startTrayInjectable from "../electron-tray/start-tray.injectable"; +import reactiveTrayMenuIconInjectable from "./reactive.injectable"; + +const startReactiveTrayMenuIconInjectable = getInjectable({ + id: "start-reactive-tray-menu-icon", + + instantiate: (di) => { + const reactiveTrayMenuIcon = di.inject(reactiveTrayMenuIconInjectable); + + return { + run: async () => { + await reactiveTrayMenuIcon.start(); + }, + + runAfter: di.inject(startTrayInjectable), + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default startReactiveTrayMenuIconInjectable; diff --git a/src/main/tray/menu-icon/stop-reactivity.injectable.ts b/src/main/tray/menu-icon/stop-reactivity.injectable.ts new file mode 100644 index 0000000000..4b60aaaa54 --- /dev/null +++ b/src/main/tray/menu-icon/stop-reactivity.injectable.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 { getInjectable } from "@ogre-tools/injectable"; +import { beforeQuitOfBackEndInjectionToken } from "../../start-main-application/runnable-tokens/before-quit-of-back-end-injection-token"; +import reactiveTrayMenuIconInjectable from "./reactive.injectable"; + +const stopReactiveTrayMenuIconInjectable = getInjectable({ + id: "stop-reactive-tray-menu-icon", + + instantiate: (di) => { + const reactiveTrayMenuIcon = di.inject(reactiveTrayMenuIconInjectable); + + return { + run: async () => { + await reactiveTrayMenuIcon.stop(); + }, + }; + }, + + injectionToken: beforeQuitOfBackEndInjectionToken, +}); + +export default stopReactiveTrayMenuIconInjectable; diff --git a/src/main/tray/reactive-tray-menu-items/converters.ts b/src/main/tray/reactive-tray-menu-items/converters.ts new file mode 100644 index 0000000000..42add7481e --- /dev/null +++ b/src/main/tray/reactive-tray-menu-items/converters.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { TrayMenuItem } from "../tray-menu-item/tray-menu-item-injection-token"; + +export function convertToElectronMenuTemplate(trayMenuItems: TrayMenuItem[]): Electron.MenuItemConstructorOptions[] { + const toTrayMenuOptions = (parentId: string | null) => ( + trayMenuItems + .filter((item) => item.parentId === parentId) + .map((trayMenuItem: TrayMenuItem): Electron.MenuItemConstructorOptions => { + if (trayMenuItem.separator) { + return { id: trayMenuItem.id, type: "separator" }; + } + + const childItems = toTrayMenuOptions(trayMenuItem.id); + + return { + id: trayMenuItem.id, + label: trayMenuItem.label?.get(), + enabled: trayMenuItem.enabled.get(), + toolTip: trayMenuItem.tooltip, + + ...(childItems.length === 0 + ? { + type: "normal", + submenu: toTrayMenuOptions(trayMenuItem.id), + click: trayMenuItem.click, + } + : { + type: "submenu", + submenu: toTrayMenuOptions(trayMenuItem.id), + }), + + }; + }) + ); + + return toTrayMenuOptions(null); +} diff --git a/src/main/tray/reactive-tray-menu-items/reactive-tray-menu-items.injectable.ts b/src/main/tray/reactive-tray-menu-items/reactive-tray-menu-items.injectable.ts index b11654393a..22c3d29399 100644 --- a/src/main/tray/reactive-tray-menu-items/reactive-tray-menu-items.injectable.ts +++ b/src/main/tray/reactive-tray-menu-items/reactive-tray-menu-items.injectable.ts @@ -4,9 +4,9 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { getStartableStoppable } from "../../../common/utils/get-startable-stoppable"; -import { autorun } from "mobx"; -import trayMenuItemsInjectable from "../tray-menu-item/tray-menu-items.injectable"; +import { reaction } from "mobx"; import electronTrayInjectable from "../electron-tray/electron-tray.injectable"; +import trayMenuItemsInjectable from "../tray-menu-item/tray-menu-items.injectable"; const reactiveTrayMenuItemsInjectable = getInjectable({ id: "reactive-tray-menu-items", @@ -15,9 +15,15 @@ const reactiveTrayMenuItemsInjectable = getInjectable({ const electronTray = di.inject(electronTrayInjectable); const trayMenuItems = di.inject(trayMenuItemsInjectable); - return getStartableStoppable("reactive-tray-menu-items", () => autorun(() => { - electronTray.setMenuItems(trayMenuItems.get()); - })); + return getStartableStoppable("reactive-tray-menu-items", () => ( + reaction( + () => trayMenuItems.get(), + electronTray.setMenuItems, + { + fireImmediately: true, + }, + ) + )); }, }); diff --git a/src/main/tray/tray-icon-path.injectable.ts b/src/main/tray/tray-icon-path.injectable.ts index 1eb4d13118..df83a2e31c 100644 --- a/src/main/tray/tray-icon-path.injectable.ts +++ b/src/main/tray/tray-icon-path.injectable.ts @@ -6,21 +6,32 @@ import { getInjectable } from "@ogre-tools/injectable"; import getAbsolutePathInjectable from "../../common/path/get-absolute-path.injectable"; import staticFilesDirectoryInjectable from "../../common/vars/static-files-directory.injectable"; import isDevelopmentInjectable from "../../common/vars/is-development.injectable"; +import isMacInjectable from "../../common/vars/is-mac.injectable"; -const trayIconPathInjectable = getInjectable({ - id: "tray-icon-path", +export interface TrayIconPaths { + normal: string; + updateAvailable: string; +} - instantiate: (di) => { +const trayIconPathsInjectable = getInjectable({ + id: "tray-icon-paths", + + instantiate: (di): TrayIconPaths => { const getAbsolutePath = di.inject(getAbsolutePathInjectable); const staticFilesDirectory = di.inject(staticFilesDirectoryInjectable); const isDevelopment = di.inject(isDevelopmentInjectable); - - return getAbsolutePath( + const isMac = di.inject(isMacInjectable); + const baseIconDirectory = getAbsolutePath( staticFilesDirectory, isDevelopment ? "../build/tray" : "icons", // copied within electron-builder extras - "trayIconTemplate.png", ); + const fileSuffix = isMac ? "Template.png" : ".png"; + + return { + normal: getAbsolutePath(baseIconDirectory, `trayIcon${fileSuffix}`), + updateAvailable: getAbsolutePath(baseIconDirectory, `trayIconUpdateAvailable${fileSuffix}`), + }; }, }); -export default trayIconPathInjectable; +export default trayIconPathsInjectable; diff --git a/src/main/tray/tray-menu-items.injectable.ts b/src/main/tray/tray-menu-items.injectable.ts index 8ee9d25e5e..c008c123e0 100644 --- a/src/main/tray/tray-menu-items.injectable.ts +++ b/src/main/tray/tray-menu-items.injectable.ts @@ -12,8 +12,7 @@ const trayItemsInjectable = getInjectable({ 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.ts b/src/main/tray/tray.ts deleted file mode 100644 index 4d7e39c344..0000000000 --- a/src/main/tray/tray.ts +++ /dev/null @@ -1,99 +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 { Menu, Tray } from "electron"; -import type { IComputedValue } from "mobx"; -import { autorun } from "mobx"; -import logger from "../logger"; -import { isWindows } from "../../common/vars"; -import type { Disposer } from "../../common/utils"; -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"; - -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, - showApplicationWindow: () => Promise, - trayIconPath: string, -): Disposer { - tray = new Tray(trayIconPath); - tray.setToolTip(packageInfo.description); - tray.setIgnoreDoubleClickEvents(true); - - if (isWindows) { - tray.on("click", () => { - showApplicationWindow() - .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to open lens`, { error })); - }); - } - - return disposer( - autorun(() => { - try { - const options = toTrayMenuOptions(trayMenuItems.get()); - - const menu = Menu.buildFromTemplate(options); - - tray?.setContextMenu(menu); - } catch (error) { - logger.error(`${TRAY_LOG_PREFIX}: building failed`, { error }); - } - }), - () => { - tray?.destroy(); - tray = null; - }, - ); -} - -const toTrayMenuOptions = (trayMenuItems: TrayMenuItem[]) => { - const _toTrayMenuOptions = (parentId: string | null) => - pipeline( - trayMenuItems, - - filter((item) => item.parentId === parentId), - - map( - (trayMenuItem: TrayMenuItem): Electron.MenuItemConstructorOptions => { - if (trayMenuItem.separator) { - return { id: trayMenuItem.id, type: "separator" }; - } - - const childItems = _toTrayMenuOptions(trayMenuItem.id); - - return { - id: trayMenuItem.id, - label: trayMenuItem.label?.get(), - enabled: trayMenuItem.enabled.get(), - toolTip: trayMenuItem.tooltip, - - ...(isEmpty(childItems) - ? { - type: "normal", - submenu: _toTrayMenuOptions(trayMenuItem.id), - - click: () => { - trayMenuItem.click?.(); - }, - } - : { - type: "submenu", - submenu: _toTrayMenuOptions(trayMenuItem.id), - }), - }; - }, - ), - ); - - return _toTrayMenuOptions(null); -}; - diff --git a/src/renderer/components/icon/icon.tsx b/src/renderer/components/icon/icon.tsx index 084b75d804..7655194886 100644 --- a/src/renderer/components/icon/icon.tsx +++ b/src/renderer/components/icon/icon.tsx @@ -29,6 +29,7 @@ import Spinner from "./spinner.svg"; import Ssh from "./ssh.svg"; import Storage from "./storage.svg"; import Terminal from "./terminal.svg"; +import Notice from "./notice.svg"; import User from "./user.svg"; import Users from "./users.svg"; import Wheel from "./wheel.svg"; @@ -58,6 +59,7 @@ const localSvgIcons = new Map([ ["ssh", Ssh], ["storage", Storage], ["terminal", Terminal], + ["notice", Notice], ["user", User], ["users", Users], ["wheel", Wheel], diff --git a/src/renderer/components/icon/notice.svg b/src/renderer/components/icon/notice.svg new file mode 100644 index 0000000000..2774b185f6 --- /dev/null +++ b/src/renderer/components/icon/notice.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/src/renderer/components/test-utils/get-application-builder.tsx b/src/renderer/components/test-utils/get-application-builder.tsx index c1df2c5998..a817d21b71 100644 --- a/src/renderer/components/test-utils/get-application-builder.tsx +++ b/src/renderer/components/test-utils/get-application-builder.tsx @@ -24,12 +24,12 @@ import type { ClusterStore } from "../../../common/cluster-store/cluster-store"; import mainExtensionsInjectable from "../../../extensions/main-extensions.injectable"; import currentRouteComponentInjectable from "../../routes/current-route-component.injectable"; import { pipeline } from "@ogre-tools/fp"; -import { flatMap, compact, join, get, filter, find, map, matches } from "lodash/fp"; +import { flatMap, compact, join, get, filter, map, matches, find } from "lodash/fp"; import preferenceNavigationItemsInjectable from "../+preferences/preferences-navigation/preference-navigation-items.injectable"; import navigateToPreferencesInjectable from "../../../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable"; import type { MenuItemOpts } from "../../../main/menu/application-menu-items.injectable"; import applicationMenuItemsInjectable from "../../../main/menu/application-menu-items.injectable"; -import type { MenuItem, MenuItemConstructorOptions } from "electron"; +import type { MenuItemConstructorOptions, MenuItem } from "electron"; import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable"; import navigateToHelmChartsInjectable from "../../../common/front-end-routing/routes/cluster/helm/charts/navigate-to-helm-charts.injectable"; import hostedClusterInjectable from "../../../common/cluster-store/hosted-cluster.injectable"; @@ -43,7 +43,6 @@ import { flushPromises } from "../../../common/test-utils/flush-promises"; import type { NamespaceStore } from "../+namespaces/store"; import namespaceStoreInjectable from "../+namespaces/store.injectable"; import historyInjectable from "../../navigation/history.injectable"; -import type { TrayMenuItem } from "../../../main/tray/tray-menu-item/tray-menu-item-injection-token"; import electronTrayInjectable from "../../../main/tray/electron-tray/electron-tray.injectable"; import applicationWindowInjectable from "../../../main/start-main-application/lens-window/application-window/application-window.injectable"; import { Notifications } from "../notifications/notifications"; @@ -51,6 +50,8 @@ import broadcastThatRootFrameIsRenderedInjectable from "../../frames/root-frame/ import { getDiForUnitTesting as getRendererDi } from "../../getDiForUnitTesting"; import { getDiForUnitTesting as getMainDi } from "../../../main/getDiForUnitTesting"; import { overrideChannels } from "../../../test-utils/channel-fakes/override-channels"; +import type { TrayMenuItem } from "../../../main/tray/tray-menu-item/tray-menu-item-injection-token"; +import trayIconPathsInjectable from "../../../main/tray/tray-icon-path.injectable"; type Callback = (dis: DiContainers) => void | Promise; @@ -65,7 +66,8 @@ export interface ApplicationBuilder { tray: { click: (id: string) => Promise; - get: (id: string) => TrayMenuItem | undefined; + get: (id: string) => TrayMenuItem | null; + getIconPath: () => string; }; applicationMenu: { @@ -166,15 +168,22 @@ export const getApplicationBuilder = () => { computed(() => []), ); + const iconPaths = mainDi.inject(trayIconPathsInjectable); + let trayMenuItemsStateFake: TrayMenuItem[]; + let trayMenuIconPath: string; mainDi.override(electronTrayInjectable, () => ({ - start: () => {}, + start: () => { + trayMenuIconPath = iconPaths.normal; + }, stop: () => {}, - setMenuItems: (items) => { trayMenuItemsStateFake = items; }, + setIconPath: (path) => { + trayMenuIconPath = path; + }, })); let allowedResourcesState: IObservableArray; @@ -222,9 +231,9 @@ export const getApplicationBuilder = () => { tray: { get: (id: string) => { - return trayMenuItemsStateFake.find(matches({ id })); + return trayMenuItemsStateFake.find(matches({ id })) ?? null; }, - + getIconPath: () => trayMenuIconPath, click: async (id: string) => { const menuItem = pipeline( trayMenuItemsStateFake,