diff --git a/src/common/utils/hash-map.ts b/src/common/utils/hash-map.ts new file mode 100644 index 0000000000..00bda7fe88 --- /dev/null +++ b/src/common/utils/hash-map.ts @@ -0,0 +1,104 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +function makeIterableIterator(iterator: Iterator): IterableIterator { + (iterator as IterableIterator)[Symbol.iterator] = () => iterator as IterableIterator; + + return iterator as IterableIterator; +} + +export class HashMap implements Map { + #hashmap: Map; + + constructor(protected hasher: (key: K) => string, initialValues?: Iterable) { + this.#hashmap = new Map(); + + if (initialValues) { + for (const [key, value] of initialValues) { + this.#hashmap.set(this.hasher(key), { key, value }); + } + } + } + + clear(): void { + this.#hashmap.clear(); + } + + delete(key: K): boolean { + return this.#hashmap.delete(this.hasher(key)); + } + + forEach(callbackfn: (value: V, key: K, map: Map) => void, thisArg?: any): void { + this.#hashmap.forEach(entry => callbackfn(entry.value, entry.key, thisArg ?? this)); + } + + get(key: K): V | undefined { + return this.#hashmap.get(this.hasher(key))?.value; + } + + has(key: K): boolean { + return this.#hashmap.has(this.hasher(key)); + } + + set(key: K, value: V): this { + this.#hashmap.set(this.hasher(key), { key, value }); + + return this; + } + + get size(): number { + return this.#hashmap.size; + } + + entries(): IterableIterator<[K, V]> { + let nextIndex = 0; + const keys = Array.from(this.keys()); + const values = Array.from(this.values()); + + return makeIterableIterator<[K, V]>({ + next() { + const index = nextIndex++; + + return index < values.length + ? { value: [keys[index], values[index]], done: false } + : { done: true, value: undefined }; + }, + }); + } + + keys(): IterableIterator { + let nextIndex = 0; + const observableValues = Array.from(this.#hashmap.values()); + + return makeIterableIterator({ + next: () => { + return nextIndex < observableValues.length + ? { value: observableValues[nextIndex++].key, done: false } + : { done: true, value: undefined }; + }, + }); + } + + values(): IterableIterator { + let nextIndex = 0; + const observableValues = Array.from(this.#hashmap.values()); + + return makeIterableIterator({ + next: () => { + return nextIndex < observableValues.length + ? { value: observableValues[nextIndex++].value, done: false } + : { done: true, value: undefined }; + }, + }); + } + + [Symbol.iterator](): IterableIterator<[K, V]> { + return this.entries(); + } + + get [Symbol.toStringTag]() { + return "Map"; + } +} diff --git a/src/common/utils/hash-set.ts b/src/common/utils/hash-set.ts index 23ce13b1b2..8d87c992fd 100644 --- a/src/common/utils/hash-set.ts +++ b/src/common/utils/hash-set.ts @@ -6,7 +6,7 @@ import type { IInterceptable, IInterceptor, IListenable, ISetWillChange, ObservableMap } from "mobx"; import { action, observable, ObservableSet } from "mobx"; -export function makeIterableIterator(iterator: Iterator): IterableIterator { +function makeIterableIterator(iterator: Iterator): IterableIterator { (iterator as IterableIterator)[Symbol.iterator] = () => iterator as IterableIterator; return iterator as IterableIterator; diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index eab3239350..dcf6e6b1fd 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -26,6 +26,7 @@ export * from "./downloadFile"; export * from "./escapeRegExp"; export * from "./formatDuration"; export * from "./getRandId"; +export * from "./hash-map"; export * from "./hash-set"; export * from "./n-fircate"; export * from "./objects"; diff --git a/src/main/app-updater.ts b/src/main/app-updater.ts index 0b7a5609b8..25d8511dd1 100644 --- a/src/main/app-updater.ts +++ b/src/main/app-updater.ts @@ -14,6 +14,8 @@ import { once } from "lodash"; import { ipcMain } from "electron"; import { nextUpdateChannel } from "./utils/update-channel"; import { UserStore } from "../common/user-store"; +import { Environments, getEnvironmentSpecificLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import appUpdaterStateInjectable from "./app-updater/state.injectable"; let installVersion: null | string = null; @@ -52,6 +54,8 @@ export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24 } const userStore = UserStore.getInstance(); + const di = getEnvironmentSpecificLegacyGlobalDiForExtensionApi(Environments.main); + const state = di.inject(appUpdaterStateInjectable); autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = false; @@ -94,6 +98,9 @@ export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24 }); logger.info(`${AutoUpdateLogPrefix}: broadcasting update available`, { backchannel, version: info.version }); broadcastMessage(UpdateAvailableChannel, backchannel, info); + state.set({ + status: "update-install-ready", + }); } catch (error) { logger.error(`${AutoUpdateLogPrefix}: broadcasting failed`, { error }); installVersion = undefined; diff --git a/src/main/app-updater/state.injectable.ts b/src/main/app-updater/state.injectable.ts new file mode 100644 index 0000000000..3c60c97075 --- /dev/null +++ b/src/main/app-updater/state.injectable.ts @@ -0,0 +1,21 @@ +/** + * 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 { observable } from "mobx"; + +export type AppUpdaterState = { + status: "idle"; +} | { + status: "update-install-ready"; +}; + +const appUpdaterStateInjectable = getInjectable({ + id: "app-updater-state", + instantiate: () => observable.box({ + status: "idle", + }), +}); + +export default appUpdaterStateInjectable; diff --git a/src/main/app-updater/update-available.injectable.ts b/src/main/app-updater/update-available.injectable.ts new file mode 100644 index 0000000000..59dfd7809c --- /dev/null +++ b/src/main/app-updater/update-available.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import appUpdaterStateInjectable from "./state.injectable"; + +const updateAvailableInjectable = getInjectable({ + id: "update-available", + instantiate: (di) => { + const appUpdaterState = di.inject(appUpdaterStateInjectable); + + return computed(() => appUpdaterState.get().status === "update-install-ready"); + }, +}); + +export default updateAvailableInjectable; diff --git a/src/main/electron/use-dark-colors.injectable.ts b/src/main/electron/use-dark-colors.injectable.ts new file mode 100644 index 0000000000..30b2f44f37 --- /dev/null +++ b/src/main/electron/use-dark-colors.injectable.ts @@ -0,0 +1,21 @@ +/** + * 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 { observable } from "mobx"; +import nativeThemeInjectable from "./native-theme.injectable"; + +const useDarkColorsInjectable = getInjectable({ + id: "use-dark-colors", + instantiate: (di) => { + const nativeTheme = di.inject(nativeThemeInjectable); + const state = observable.box(nativeTheme.shouldUseDarkColors); + + nativeTheme.on("updated", () => state.set(nativeTheme.shouldUseDarkColors)); + + return state; + }, +}); + +export default useDarkColorsInjectable; diff --git a/src/main/tray/computed-tray-icon.injectable.ts b/src/main/tray/computed-tray-icon.injectable.ts new file mode 100644 index 0000000000..a00c6c28c9 --- /dev/null +++ b/src/main/tray/computed-tray-icon.injectable.ts @@ -0,0 +1,67 @@ +/** + * 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 AwaitLock from "await-lock"; +import type { NativeImage, Tray } from "electron"; +import { comparer, reaction } from "mobx"; +import loggerInjectable from "../../common/logger.injectable"; +import type { Disposer } from "../../common/utils"; +import { getOrInsertWithAsync, HashMap } from "../../common/utils"; +import updateAvailableInjectable from "../app-updater/update-available.injectable"; +import useDarkColorsInjectable from "../electron/use-dark-colors.injectable"; +import { createTrayIcon } from "./create-tray-icon"; + +export interface ComputedTrayIcon { + getCurrent(): Promise; + subscribe(tray: Tray): Disposer; +} + +interface NativeImageCacheKey { + updateAvailable: boolean; + useDarkColors: boolean; +} + +const computedTrayIconInjectable = getInjectable({ + id: "computed-tray-icon", + instantiate: (di): ComputedTrayIcon => { + const useDarkColors = di.inject(useDarkColorsInjectable); + const updateAvailable = di.inject(updateAvailableInjectable); + const logger = di.inject(loggerInjectable); + const lock = new AwaitLock(); + const cache = new HashMap( + (key) => `${key.updateAvailable ? "updateAvailable" : ""}:${key.useDarkColors ? "shouldUseDarkColors" : ""}`, + ); + + const computedCurrent = (key: NativeImageCacheKey) => getOrInsertWithAsync(cache, key, () => createTrayIcon({ + size: 16, + ...key, + })); + + return { + getCurrent: () => computedCurrent({ + updateAvailable: updateAvailable.get(), + useDarkColors: useDarkColors.get(), + }), + subscribe: (tray) => reaction( + () => ({ + updateAvailable: updateAvailable.get(), + useDarkColors: useDarkColors.get(), + }), + (key) => { + lock.acquireAsync() + .then(() => computedCurrent(key)) + .then(img => tray.setImage(img)) + .catch((error) => logger.warn("[TRAY]: failed to update image after changing state", { key, error })) + .finally(() => lock.release()); + }, + { + equals: (a, b) => comparer.structural(a, b), + }, + ), + }; + }, +}); + +export default computedTrayIconInjectable; diff --git a/src/main/tray/create-current-tray-icon.injectable.ts b/src/main/tray/create-current-tray-icon.injectable.ts deleted file mode 100644 index 104ce3b8c6..0000000000 --- a/src/main/tray/create-current-tray-icon.injectable.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * 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 nativeThemeInjectable from "../electron/native-theme.injectable"; -import { createTrayIcon } from "./create-tray-icon"; - -const createCurrentTrayIconInjectable = getInjectable({ - id: "create-current-tray-icon", - instantiate: (di) => { - const nativeTheme = di.inject(nativeThemeInjectable); - - return () => createTrayIcon({ - shouldUseDarkColors: nativeTheme.shouldUseDarkColors, - size: 16, - updateIsAvailable: false, - }); - }, -}); - -export default createCurrentTrayIconInjectable; diff --git a/src/main/tray/create-tray-icon.ts b/src/main/tray/create-tray-icon.ts index 2650c61c9a..e7ec099ba4 100644 --- a/src/main/tray/create-tray-icon.ts +++ b/src/main/tray/create-tray-icon.ts @@ -4,25 +4,22 @@ */ import { nativeImage } from "electron"; import type { NativeImage } from "electron"; -import { base64, getOrInsertWithAsync } from "../../common/utils"; +import { base64 } from "../../common/utils"; import sharp from "sharp"; import { JSDOM } from "jsdom"; import LogoLens from "../../renderer/components/icon/logo-lens.svg"; import Notice from "../../renderer/components/icon/notice.svg"; export interface CreateTrayIconArgs { - shouldUseDarkColors: boolean; + useDarkColors: boolean; size: number; - updateIsAvailable: boolean; + updateAvailable: boolean; } -const trayIcons = new Map(); - -export async function createTrayIcon({ shouldUseDarkColors, size, updateIsAvailable }: CreateTrayIconArgs): Promise { - return getOrInsertWithAsync(trayIcons, shouldUseDarkColors, async () => { - const trayIconColor = shouldUseDarkColors ? "white" : "black"; // Invert to show contrast - const trayBackgroundColor = shouldUseDarkColors ? "black" : "white"; - const styleTag = ` +export async function createTrayIcon({ useDarkColors, size, updateAvailable }: CreateTrayIconArgs): Promise { + const trayIconColor = useDarkColors ? "white" : "black"; // Invert to show contrast + const trayBackgroundColor = useDarkColors ? "black" : "white"; + const styleTag = ` `; - const overlayImages: sharp.OverlayOptions[] = []; - const parsedLogoSvg = base64.decode(LogoLens.split("base64,")[1]); - const logoSvgRoot = new JSDOM(parsedLogoSvg).window.document.getElementsByTagName("svg")[0]; + const overlayImages: sharp.OverlayOptions[] = []; + const parsedLogoSvg = base64.decode(LogoLens.split("base64,")[1]); + const logoSvgRoot = new JSDOM(parsedLogoSvg).window.document.getElementsByTagName("svg")[0]; - logoSvgRoot.innerHTML += styleTag; + logoSvgRoot.innerHTML += styleTag; - if (updateIsAvailable) { - // This adds some contrast between the notice icon and the logo - logoSvgRoot.innerHTML += ``; + if (updateAvailable) { + // This adds some contrast between the notice icon and the logo + logoSvgRoot.innerHTML += ``; - const parsedNoticeSvg = base64.decode(Notice.split("base64,")[1]); - const noticeSvgRoot = new JSDOM(parsedNoticeSvg).window.document.getElementsByTagName("svg")[0]; + const parsedNoticeSvg = base64.decode(Notice.split("base64,")[1]); + const noticeSvgRoot = new JSDOM(parsedNoticeSvg).window.document.getElementsByTagName("svg")[0]; - noticeSvgRoot.innerHTML += styleTag; + noticeSvgRoot.innerHTML += styleTag; - const noticeImage = await sharp(Buffer.from(noticeSvgRoot.outerHTML)) - .resize({ - width: Math.floor(size/1.5), - height: Math.floor(size/1.5), - }) - .toBuffer(); - - overlayImages.push({ - input: noticeImage, - top: Math.floor(size/2.5), - left: Math.floor(size/2.5), - }); - } - - const iconBuffer = await sharp(Buffer.from(logoSvgRoot.outerHTML)) - .composite(overlayImages) - .resize({ width: size, height: size }) - .png() + const noticeImage = await sharp(Buffer.from(noticeSvgRoot.outerHTML)) + .resize({ + width: Math.floor(size/1.5), + height: Math.floor(size/1.5), + }) .toBuffer(); - return nativeImage.createFromBuffer(iconBuffer); - }); + overlayImages.push({ + input: noticeImage, + top: Math.floor(size/2.5), + left: Math.floor(size/2.5), + }); + } + + const iconBuffer = await sharp(Buffer.from(logoSvgRoot.outerHTML)) + .composite(overlayImages) + .resize({ width: size, height: size }) + .png() + .toBuffer(); + + return nativeImage.createFromBuffer(iconBuffer); } diff --git a/src/main/tray/init-tray.injectable.ts b/src/main/tray/init-tray.injectable.ts index f70ddff171..6db86e7fa0 100644 --- a/src/main/tray/init-tray.injectable.ts +++ b/src/main/tray/init-tray.injectable.ts @@ -19,8 +19,7 @@ import { getInjectable } from "@ogre-tools/injectable"; import windowManagerInjectable from "../window-manager.injectable"; import navigateToPreferencesInjectable from "../../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable"; import trayMenuItemsInjectable from "./tray-menu-items.injectable"; -import createCurrentTrayIconInjectable from "./create-current-tray-icon.injectable"; -import trayIconUpdaterInjectable from "./tray-icon-updater.injectable"; +import computedTrayIconInjectable from "./computed-tray-icon.injectable"; const initTrayInjectable = getInjectable({ id: "init-tray", @@ -28,11 +27,10 @@ const initTrayInjectable = getInjectable({ const windowManager = di.inject(windowManagerInjectable); const trayMenuItems = di.inject(trayMenuItemsInjectable); const navigateToPreferences = di.inject(navigateToPreferencesInjectable); - const createCurrentTrayIcon = di.inject(createCurrentTrayIconInjectable); - const trayIconUpdater = di.inject(trayIconUpdaterInjectable); + const computedTrayIcon = di.inject(computedTrayIconInjectable); return async (): Promise => { - const tray = new Tray(await createCurrentTrayIcon()); + const tray = new Tray(await computedTrayIcon.getCurrent()); tray.setToolTip(packageInfo.description); tray.setIgnoreDoubleClickEvents(true); @@ -46,7 +44,7 @@ const initTrayInjectable = getInjectable({ } return disposer( - trayIconUpdater(tray), + computedTrayIcon.subscribe(tray), autorun(() => { try { const menu = createTrayMenu(windowManager, toJS(trayMenuItems.get()), navigateToPreferences); diff --git a/src/main/tray/tray-icon-updater.injectable.ts b/src/main/tray/tray-icon-updater.injectable.ts deleted file mode 100644 index da4728df76..0000000000 --- a/src/main/tray/tray-icon-updater.injectable.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * 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 type { Tray } from "electron"; -import type { Disposer } from "../../common/utils"; -import nativeThemeInjectable from "../electron/native-theme.injectable"; -import createCurrentTrayIconInjectable from "./create-current-tray-icon.injectable"; - -export type TrayIconUpdater = (tray: Tray) => Disposer; - -const trayIconUpdaterInjectable = getInjectable({ - id: "tray-icon-updater", - instantiate: (di): TrayIconUpdater => { - const nativeTheme = di.inject(nativeThemeInjectable); - const createCurrentTrayIcon = di.inject(createCurrentTrayIconInjectable); - - return (tray) => { - let prevShouldUseDarkColors = nativeTheme.shouldUseDarkColors; - const onUpdated = () => { - if (prevShouldUseDarkColors !== nativeTheme.shouldUseDarkColors) { - const localShouldUseDarkColors = prevShouldUseDarkColors = nativeTheme.shouldUseDarkColors; - - createCurrentTrayIcon() - .then(img => { - // This guards against rapid changes back and forth - if (localShouldUseDarkColors === prevShouldUseDarkColors) { - tray.setImage(img); - } - }); - } - }; - - nativeTheme.on("updated", onUpdated); - - return () => { - nativeTheme.off("updated", onUpdated); - }; - }; - }, -}); - -export default trayIconUpdaterInjectable;