1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Add reactive tray icon for when update is available (#5567)

This commit is contained in:
Sebastian Malton 2022-06-08 08:37:19 -04:00 committed by GitHub
parent db65a89ee0
commit 17c7b6a1bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 399 additions and 241 deletions

View File

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

View File

@ -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(`<body>${svgData}</body>`);
const svgRoot = svgDom.window.document.body.getElementsByTagName("svg")[0];
async function ensureOutputFoler() {
await ensureDir(outputFolder);
}
svgRoot.innerHTML += `<style>* {fill: white !important;}</style>`;
const lightTemplate = svgRoot.outerHTML;
function getSvgStyling(colouring: "dark" | "light"): string {
return `
<style>
ellipse {
stroke: ${colouring === "dark" ? "white" : "black"} !important;
}
path, rect {
fill: ${colouring === "dark" ? "white" : "black"} !important;
}
</style>
`;
}
svgRoot.innerHTML += `<style>* {fill: black !important;}</style>`;
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(`<body>${svgData}</body>`);
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")),
.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(`
<svg viewBox="0 0 64 64">
<circle cx="32" cy="32" r="32" fill="black" />
</svg>
`))
.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,
},
])
.then((resolutions) => console.log(`Generated ${resolutions.length} images`))
.catch(console.error);
.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();

View File

Before

Width:  |  Height:  |  Size: 392 B

After

Width:  |  Height:  |  Size: 392 B

View File

Before

Width:  |  Height:  |  Size: 724 B

After

Width:  |  Height:  |  Size: 724 B

BIN
build/tray/trayIcon@3x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
build/tray/trayIcon@4x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -44,7 +44,7 @@ exports[`installing update using tray when started when user checks for updates
>
<i
class="Icon close material interactive focusable"
data-testid="close-notification-for-notification_13"
data-testid="close-notification-for-notification_16"
tabindex="0"
>
<span
@ -95,7 +95,7 @@ exports[`installing update using tray when started when user checks for updates
>
<i
class="Icon close material interactive focusable"
data-testid="close-notification-for-notification_96"
data-testid="close-notification-for-notification_115"
tabindex="0"
>
<span
@ -135,7 +135,7 @@ exports[`installing update using tray when started when user checks for updates
>
<i
class="Icon close material interactive focusable"
data-testid="close-notification-for-notification_99"
data-testid="close-notification-for-notification_118"
tabindex="0"
>
<span
@ -186,7 +186,7 @@ exports[`installing update using tray when started when user checks for updates
>
<i
class="Icon close material interactive focusable"
data-testid="close-notification-for-notification_149"
data-testid="close-notification-for-notification_183"
tabindex="0"
>
<span
@ -226,7 +226,7 @@ exports[`installing update using tray when started when user checks for updates
>
<i
class="Icon close material interactive focusable"
data-testid="close-notification-for-notification_152"
data-testid="close-notification-for-notification_186"
tabindex="0"
>
<span
@ -266,7 +266,7 @@ exports[`installing update using tray when started when user checks for updates
>
<i
class="Icon close material interactive focusable"
data-testid="close-notification-for-notification_157"
data-testid="close-notification-for-notification_191"
tabindex="0"
>
<span
@ -317,7 +317,7 @@ exports[`installing update using tray when started when user checks for updates
>
<i
class="Icon close material interactive focusable"
data-testid="close-notification-for-notification_215"
data-testid="close-notification-for-notification_266"
tabindex="0"
>
<span
@ -357,7 +357,7 @@ exports[`installing update using tray when started when user checks for updates
>
<i
class="Icon close material interactive focusable"
data-testid="close-notification-for-notification_218"
data-testid="close-notification-for-notification_269"
tabindex="0"
>
<span
@ -478,7 +478,7 @@ exports[`installing update using tray when started when user checks for updates
>
<i
class="Icon close material interactive focusable"
data-testid="close-notification-for-notification_48"
data-testid="close-notification-for-notification_59"
tabindex="0"
>
<span
@ -518,7 +518,7 @@ exports[`installing update using tray when started when user checks for updates
>
<i
class="Icon close material interactive focusable"
data-testid="close-notification-for-notification_51"
data-testid="close-notification-for-notification_62"
tabindex="0"
>
<span

View File

@ -15,12 +15,15 @@ import type { DownloadPlatformUpdate } from "../../main/application-update/downl
import downloadPlatformUpdateInjectable from "../../main/application-update/download-platform-update/download-platform-update.injectable";
import showApplicationWindowInjectable from "../../main/start-main-application/lens-window/show-application-window.injectable";
import progressOfUpdateDownloadInjectable from "../../common/application-update/progress-of-update-download/progress-of-update-download.injectable";
import type { TrayIconPaths } from "../../main/tray/tray-icon-path.injectable";
import trayIconPathsInjectable from "../../main/tray/tray-icon-path.injectable";
describe("installing update using tray", () => {
let applicationBuilder: ApplicationBuilder;
let checkForPlatformUpdatesMock: AsyncFnMock<CheckForPlatformUpdates>;
let downloadPlatformUpdateMock: AsyncFnMock<DownloadPlatformUpdate>;
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<void>;
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(),

View File

@ -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 = {

View File

@ -4,12 +4,21 @@
*/
import { getInjectionToken } from "@ogre-tools/injectable";
import type { IComputedValue } from "mobx";
import type { JsonValue } from "type-fest";
export interface SyncBox<TValue extends JsonValue> {
type AsJson<T> = T extends string | number | boolean | null
? T
: T extends Function
? never
: T extends Array<infer V>
? AsJson<V>[]
: T extends object
? { [K in keyof T]: AsJson<T[K]> }
: never;
export interface SyncBox<TValue> {
id: string;
value: IComputedValue<TValue>;
set: (value: TValue) => void;
value: IComputedValue<AsJson<TValue>>;
set: (value: AsJson<TValue>) => void;
}
export const syncBoxInjectionToken = getInjectionToken<SyncBox<any>>({

View File

@ -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);
};

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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);
}

View File

@ -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,
},
)
));
},
});

View File

@ -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;

View File

@ -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));
},
});

View File

@ -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<TrayMenuItem[]>,
showApplicationWindow: () => Promise<void>,
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);
};

View File

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

View File

@ -0,0 +1,30 @@
<svg xmlns:osb="http://www.openswatchbook.org/uri/2009/osb"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" sodipodi:docname="notice.svg" inkscape:version="1.0 (4035a4f, 2020-05-01)" id="notice" version="1.1" viewBox="0 0 135.46666 135.46666" height="512" width="512">
<defs id="defs2">
<rect id="rect2785" height="134.1693" width="135.23837" y="0.26726951" x="-0.53453903" />
<linearGradient osb:paint="solid" id="linearGradient837">
<stop id="stop835" offset="0" style="stop-color:#000000;stop-opacity:1;" />
</linearGradient>
</defs>
<sodipodi:namedview inkscape:window-maximized="0" inkscape:window-y="0" inkscape:window-x="0" inkscape:window-height="1080" inkscape:window-width="1920" inkscape:pagecheckerboard="true" units="px" showgrid="false" inkscape:document-rotation="0" inkscape:current-layer="layer1" inkscape:document-units="px" inkscape:cy="263.00246" inkscape:cx="240.74335" inkscape:zoom="2.0359339" inkscape:pageshadow="2" inkscape:pageopacity="0.0" borderopacity="1.0" bordercolor="#666666" pagecolor="#ffffff" id="base" />
<metadata id="metadata5">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="layer1" inkscape:groupmode="layer" inkscape:label="Layer 1">
<ellipse ry="59.764721" rx="59.764713" cy="67.73333" cx="67.733345" id="path10" style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:15.9372;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path id="path2807" d="M 58.707956,29.522524 H 76.674473 L 76.739452,43.330427 73.23062,71.076192 62.054343,71.043701 58.740444,43.427894 Z" style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<rect y="83.204803" x="59.292759" height="14.815064" width="16.861885" id="rect2809" style="fill:#000000;fill-opacity:1;stroke:none;" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -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<void>;
@ -65,7 +66,8 @@ export interface ApplicationBuilder {
tray: {
click: (id: string) => Promise<void>;
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<KubeResource>;
@ -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,