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 @@
+
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,