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

Rework the image updating injectables to use the updater state

- Move cache up a level and add a new option to createTryIcon

- Make useDarkColors and updateAvailable fully observable, computed, and
  injectable

- Add locking to remove race conditions within async code

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2022-05-03 14:29:35 -04:00
parent 1fc2008b2f
commit c5edceca67
12 changed files with 280 additions and 113 deletions

View File

@ -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<T>(iterator: Iterator<T>): IterableIterator<T> {
(iterator as IterableIterator<T>)[Symbol.iterator] = () => iterator as IterableIterator<T>;
return iterator as IterableIterator<T>;
}
export class HashMap<K, V> implements Map<K, V> {
#hashmap: Map<string, { key: K; value: V }>;
constructor(protected hasher: (key: K) => string, initialValues?: Iterable<readonly [K, V]>) {
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<K, V>) => 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<K> {
let nextIndex = 0;
const observableValues = Array.from(this.#hashmap.values());
return makeIterableIterator<K>({
next: () => {
return nextIndex < observableValues.length
? { value: observableValues[nextIndex++].key, done: false }
: { done: true, value: undefined };
},
});
}
values(): IterableIterator<V> {
let nextIndex = 0;
const observableValues = Array.from(this.#hashmap.values());
return makeIterableIterator<V>({
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";
}
}

View File

@ -6,7 +6,7 @@
import type { IInterceptable, IInterceptor, IListenable, ISetWillChange, ObservableMap } from "mobx"; import type { IInterceptable, IInterceptor, IListenable, ISetWillChange, ObservableMap } from "mobx";
import { action, observable, ObservableSet } from "mobx"; import { action, observable, ObservableSet } from "mobx";
export function makeIterableIterator<T>(iterator: Iterator<T>): IterableIterator<T> { function makeIterableIterator<T>(iterator: Iterator<T>): IterableIterator<T> {
(iterator as IterableIterator<T>)[Symbol.iterator] = () => iterator as IterableIterator<T>; (iterator as IterableIterator<T>)[Symbol.iterator] = () => iterator as IterableIterator<T>;
return iterator as IterableIterator<T>; return iterator as IterableIterator<T>;

View File

@ -26,6 +26,7 @@ export * from "./downloadFile";
export * from "./escapeRegExp"; export * from "./escapeRegExp";
export * from "./formatDuration"; export * from "./formatDuration";
export * from "./getRandId"; export * from "./getRandId";
export * from "./hash-map";
export * from "./hash-set"; export * from "./hash-set";
export * from "./n-fircate"; export * from "./n-fircate";
export * from "./objects"; export * from "./objects";

View File

@ -14,6 +14,8 @@ import { once } from "lodash";
import { ipcMain } from "electron"; import { ipcMain } from "electron";
import { nextUpdateChannel } from "./utils/update-channel"; import { nextUpdateChannel } from "./utils/update-channel";
import { UserStore } from "../common/user-store"; 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; let installVersion: null | string = null;
@ -52,6 +54,8 @@ export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24
} }
const userStore = UserStore.getInstance(); const userStore = UserStore.getInstance();
const di = getEnvironmentSpecificLegacyGlobalDiForExtensionApi(Environments.main);
const state = di.inject(appUpdaterStateInjectable);
autoUpdater.autoDownload = false; autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = 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 }); logger.info(`${AutoUpdateLogPrefix}: broadcasting update available`, { backchannel, version: info.version });
broadcastMessage(UpdateAvailableChannel, backchannel, info); broadcastMessage(UpdateAvailableChannel, backchannel, info);
state.set({
status: "update-install-ready",
});
} catch (error) { } catch (error) {
logger.error(`${AutoUpdateLogPrefix}: broadcasting failed`, { error }); logger.error(`${AutoUpdateLogPrefix}: broadcasting failed`, { error });
installVersion = undefined; installVersion = undefined;

View File

@ -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<AppUpdaterState>({
status: "idle",
}),
});
export default appUpdaterStateInjectable;

View File

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

View File

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

View File

@ -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<NativeImage>;
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<NativeImageCacheKey, NativeImage>(
(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;

View File

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

View File

@ -4,24 +4,21 @@
*/ */
import { nativeImage } from "electron"; import { nativeImage } from "electron";
import type { NativeImage } from "electron"; import type { NativeImage } from "electron";
import { base64, getOrInsertWithAsync } from "../../common/utils"; import { base64 } from "../../common/utils";
import sharp from "sharp"; import sharp from "sharp";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import LogoLens from "../../renderer/components/icon/logo-lens.svg"; import LogoLens from "../../renderer/components/icon/logo-lens.svg";
import Notice from "../../renderer/components/icon/notice.svg"; import Notice from "../../renderer/components/icon/notice.svg";
export interface CreateTrayIconArgs { export interface CreateTrayIconArgs {
shouldUseDarkColors: boolean; useDarkColors: boolean;
size: number; size: number;
updateIsAvailable: boolean; updateAvailable: boolean;
} }
const trayIcons = new Map<boolean, NativeImage>(); export async function createTrayIcon({ useDarkColors, size, updateAvailable }: CreateTrayIconArgs): Promise<NativeImage> {
const trayIconColor = useDarkColors ? "white" : "black"; // Invert to show contrast
export async function createTrayIcon({ shouldUseDarkColors, size, updateIsAvailable }: CreateTrayIconArgs): Promise<NativeImage> { const trayBackgroundColor = useDarkColors ? "black" : "white";
return getOrInsertWithAsync(trayIcons, shouldUseDarkColors, async () => {
const trayIconColor = shouldUseDarkColors ? "white" : "black"; // Invert to show contrast
const trayBackgroundColor = shouldUseDarkColors ? "black" : "white";
const styleTag = ` const styleTag = `
<style> <style>
ellipse { ellipse {
@ -41,7 +38,7 @@ export async function createTrayIcon({ shouldUseDarkColors, size, updateIsAvaila
logoSvgRoot.innerHTML += styleTag; logoSvgRoot.innerHTML += styleTag;
if (updateIsAvailable) { if (updateAvailable) {
// This adds some contrast between the notice icon and the logo // This adds some contrast between the notice icon and the logo
logoSvgRoot.innerHTML += `<ellipse ry="192" rx="192" cy="352" cx="352" />`; logoSvgRoot.innerHTML += `<ellipse ry="192" rx="192" cy="352" cx="352" />`;
@ -71,5 +68,4 @@ export async function createTrayIcon({ shouldUseDarkColors, size, updateIsAvaila
.toBuffer(); .toBuffer();
return nativeImage.createFromBuffer(iconBuffer); return nativeImage.createFromBuffer(iconBuffer);
});
} }

View File

@ -19,8 +19,7 @@ import { getInjectable } from "@ogre-tools/injectable";
import windowManagerInjectable from "../window-manager.injectable"; import windowManagerInjectable from "../window-manager.injectable";
import navigateToPreferencesInjectable from "../../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable"; import navigateToPreferencesInjectable from "../../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable";
import trayMenuItemsInjectable from "./tray-menu-items.injectable"; import trayMenuItemsInjectable from "./tray-menu-items.injectable";
import createCurrentTrayIconInjectable from "./create-current-tray-icon.injectable"; import computedTrayIconInjectable from "./computed-tray-icon.injectable";
import trayIconUpdaterInjectable from "./tray-icon-updater.injectable";
const initTrayInjectable = getInjectable({ const initTrayInjectable = getInjectable({
id: "init-tray", id: "init-tray",
@ -28,11 +27,10 @@ const initTrayInjectable = getInjectable({
const windowManager = di.inject(windowManagerInjectable); const windowManager = di.inject(windowManagerInjectable);
const trayMenuItems = di.inject(trayMenuItemsInjectable); const trayMenuItems = di.inject(trayMenuItemsInjectable);
const navigateToPreferences = di.inject(navigateToPreferencesInjectable); const navigateToPreferences = di.inject(navigateToPreferencesInjectable);
const createCurrentTrayIcon = di.inject(createCurrentTrayIconInjectable); const computedTrayIcon = di.inject(computedTrayIconInjectable);
const trayIconUpdater = di.inject(trayIconUpdaterInjectable);
return async (): Promise<Disposer> => { return async (): Promise<Disposer> => {
const tray = new Tray(await createCurrentTrayIcon()); const tray = new Tray(await computedTrayIcon.getCurrent());
tray.setToolTip(packageInfo.description); tray.setToolTip(packageInfo.description);
tray.setIgnoreDoubleClickEvents(true); tray.setIgnoreDoubleClickEvents(true);
@ -46,7 +44,7 @@ const initTrayInjectable = getInjectable({
} }
return disposer( return disposer(
trayIconUpdater(tray), computedTrayIcon.subscribe(tray),
autorun(() => { autorun(() => {
try { try {
const menu = createTrayMenu(windowManager, toJS(trayMenuItems.get()), navigateToPreferences); const menu = createTrayMenu(windowManager, toJS(trayMenuItems.get()), navigateToPreferences);

View File

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