Extension tray menu items (#4619)

* Add extension ability to add tray menu items.

Signed-off-by: Juho Heikka <juho.heikka@gmail.com>

* Add tray menu extension documentation

Signed-off-by: Juho Heikka <juho.heikka@gmail.com>

* Add tests to tray menu items. Fix autorun infinite loop.

Signed-off-by: Juho Heikka <juho.heikka@gmail.com>

* Fix documentation

Signed-off-by: Juho Heikka <juho.heikka@gmail.com>

* Remove unnecessary slice()

Signed-off-by: Juho Heikka <juho.heikka@gmail.com>

* Define a type for tray menu registration

Signed-off-by: Juho Heikka <juho.heikka@gmail.com>

* Change TrayMenuRegistration not to leak or depend on Electron Menu API

Signed-off-by: Juho Heikka <juho.heikka@gmail.com>

* Update trayMenus Extension API documentation

Signed-off-by: Juho Heikka <juho.heikka@gmail.com>

* Refactor all tests to use runInAction

Signed-off-by: Juho Heikka <juho.heikka@gmail.com>
This commit is contained in:
Juho Heikka authored and GitHub committed 2021-12-31 15:50:01 +02:00
1 parent 58ea0dc822
commit 1db805b451
9 files changed
+298 -23

No files matched your search

@@ -0,0 +1,36 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { computed } from "mobx";
import mainExtensionsInjectable from "../../extensions/main-extensions.injectable";
const trayItemsInjectable = getInjectable({
lifecycle: lifecycleEnum.singleton,
instantiate: (di) => {
const extensions = di.inject(mainExtensionsInjectable);
return computed(() =>
extensions.get().flatMap(extension => extension.trayMenus));
},
});
export default trayItemsInjectable;
+136
View File
@@ -0,0 +1,136 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable";
import { LensMainExtension } from "../../extensions/lens-main-extension";
import trayItemsInjectable from "./tray-menu-items.injectable";
import type { IComputedValue } from "mobx";
import { computed, ObservableMap, runInAction } from "mobx";
import { getDiForUnitTesting } from "../getDiForUnitTesting";
import mainExtensionsInjectable from "../../extensions/main-extensions.injectable";
import type { TrayMenuRegistration } from "./tray-menu-registration";
describe("tray-menu-items", () => {
let di: ConfigurableDependencyInjectionContainer;
let trayMenuItems: IComputedValue<TrayMenuRegistration[]>;
let extensionsStub: ObservableMap<string, LensMainExtension>;
beforeEach(() => {
di = getDiForUnitTesting();
extensionsStub = new ObservableMap();
di.override(
mainExtensionsInjectable,
() => computed(() => [...extensionsStub.values()]),
);
trayMenuItems = di.inject(trayItemsInjectable);
});
it("does not have any items yet", () => {
expect(trayMenuItems.get()).toHaveLength(0);
});
describe("when extension is enabled", () => {
beforeEach(() => {
const someExtension = new SomeTestExtension({
id: "some-extension-id",
trayMenus: [{ label: "tray-menu-from-some-extension" }],
});
runInAction(() => {
extensionsStub.set("some-extension-id", someExtension);
});
});
it("has tray menu items", () => {
expect(trayMenuItems.get()).toEqual([
{
label: "tray-menu-from-some-extension",
},
]);
});
it("when disabling extension, does not have tray menu items", () => {
runInAction(() => {
extensionsStub.delete("some-extension-id");
});
expect(trayMenuItems.get()).toHaveLength(0);
});
describe("when other extension is enabled", () => {
beforeEach(() => {
const someOtherExtension = new SomeTestExtension({
id: "some-extension-id",
trayMenus: [{ label: "some-label-from-second-extension" }],
});
runInAction(() => {
extensionsStub.set("some-other-extension-id", someOtherExtension);
});
});
it("has tray menu items for both extensions", () => {
expect(trayMenuItems.get()).toEqual([
{
label: "tray-menu-from-some-extension",
},
{
label: "some-label-from-second-extension",
},
]);
});
it("when extension is disabled, still returns tray menu items for extensions that are enabled", () => {
runInAction(() => {
extensionsStub.delete("some-other-extension-id");
});
expect(trayMenuItems.get()).toEqual([
{
label: "tray-menu-from-some-extension",
},
]);
});
});
});
});
class SomeTestExtension extends LensMainExtension {
constructor({ id, trayMenus }: {
id: string;
trayMenus: TrayMenuRegistration[];
}) {
super({
id,
absolutePath: "irrelevant",
isBundled: false,
isCompatible: false,
isEnabled: false,
manifest: { name: id, version: "some-version" },
manifestPath: "irrelevant",
});
this.trayMenus = trayMenus;
}
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
export interface TrayMenuRegistration {
label?: string;
click?: (menuItem: TrayMenuRegistration) => void;
id?: string;
type?: "normal" | "separator" | "submenu"
toolTip?: string;
enabled?: boolean;
submenu?: TrayMenuRegistration[]
}
+148
View File
@@ -0,0 +1,148 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import path from "path";
import packageInfo from "../../../package.json";
import { Menu, Tray } from "electron";
import { autorun, IComputedValue } from "mobx";
import { showAbout } from "../menu/menu";
import { checkForUpdates, isAutoUpdateEnabled } from "../app-updater";
import type { WindowManager } from "../window-manager";
import logger from "../logger";
import { isDevelopment, isWindows, productName } from "../../common/vars";
import { exitApp } from "../exit-app";
import { preferencesURL } from "../../common/routes";
import { toJS } from "../../common/utils";
import type { TrayMenuRegistration } from "./tray-menu-registration";
const TRAY_LOG_PREFIX = "[TRAY]";
// note: instance of Tray should be saved somewhere, otherwise it disappears
export let tray: Tray;
export function getTrayIcon(): string {
return path.resolve(
__static,
isDevelopment ? "../build/tray" : "icons", // copied within electron-builder extras
"trayIconTemplate.png",
);
}
export function initTray(
windowManager: WindowManager,
trayMenuItems: IComputedValue<TrayMenuRegistration[]>,
) {
const icon = getTrayIcon();
tray = new Tray(icon);
tray.setToolTip(packageInfo.description);
tray.setIgnoreDoubleClickEvents(true);
if (isWindows) {
tray.on("click", () => {
windowManager
.ensureMainWindow()
.catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to open lens`, { error }));
});
}
const disposers = [
autorun(() => {
try {
const menu = createTrayMenu(windowManager, toJS(trayMenuItems.get()));
tray.setContextMenu(menu);
} catch (error) {
logger.error(`${TRAY_LOG_PREFIX}: building failed`, { error });
}
}),
];
return () => {
disposers.forEach(disposer => disposer());
tray?.destroy();
tray = null;
};
}
function getMenuItemConstructorOptions(trayItem: TrayMenuRegistration): Electron.MenuItemConstructorOptions {
return {
...trayItem,
submenu: trayItem.submenu ? trayItem.submenu.map(getMenuItemConstructorOptions) : undefined,
click: trayItem.click ? () => {
trayItem.click(trayItem);
} : undefined,
};
}
function createTrayMenu(
windowManager: WindowManager,
extensionTrayItems: TrayMenuRegistration[],
): Menu {
let template: Electron.MenuItemConstructorOptions[] = [
{
label: `Open ${productName}`,
click() {
windowManager
.ensureMainWindow()
.catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to open lens`, { error }));
},
},
{
label: "Preferences",
click() {
windowManager
.navigate(preferencesURL())
.catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to navigate to Preferences`, { error }));
},
},
];
if (isAutoUpdateEnabled()) {
template.push({
label: "Check for updates",
click() {
checkForUpdates()
.then(() => windowManager.ensureMainWindow());
},
});
}
template = template.concat(extensionTrayItems.map(getMenuItemConstructorOptions));
return Menu.buildFromTemplate(template.concat([
{
label: `About ${productName}`,
click() {
windowManager.ensureMainWindow()
.then(showAbout)
.catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to show Lens About view`, { error }));
},
},
{ type: "separator" },
{
label: "Quit App",
click() {
exitApp();
},
},
]));
}