From 67c24cac9ac25020e58d527d458e74bd871dc72f Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Tue, 7 Dec 2021 19:29:38 +0200 Subject: [PATCH 01/13] Switch to getting menu items by observing change in extensions instead local state Signed-off-by: Janne Savolainen Co-authored-by: Mikko Aspiala --- src/extensions/extension-loader.ts | 23 +-- src/extensions/extensions.injectable.ts | 42 ++++++ src/extensions/lens-main-extension.ts | 2 +- src/main/getDi.ts | 34 +++++ src/main/getDiForUnitTesting.ts | 59 ++++++++ src/main/index.ts | 10 +- .../menu/electron-menu-items.injectable.ts | 41 ++++++ src/main/menu/electron-menu-items.test.ts | 132 ++++++++++++++++++ src/main/menu/menu-registration.d.ts | 25 ++++ src/main/{ => menu}/menu.ts | 44 +++--- src/main/tray.ts | 2 +- 11 files changed, 371 insertions(+), 43 deletions(-) create mode 100644 src/extensions/extensions.injectable.ts create mode 100644 src/main/getDi.ts create mode 100644 src/main/getDiForUnitTesting.ts create mode 100644 src/main/menu/electron-menu-items.injectable.ts create mode 100644 src/main/menu/electron-menu-items.test.ts create mode 100644 src/main/menu/menu-registration.d.ts rename src/main/{ => menu}/menu.ts (87%) diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index 8d249355c33a..6f1454df1510 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -32,7 +32,6 @@ import logger from "../main/logger"; import type { InstalledExtension } from "./extension-discovery"; import { ExtensionsStore } from "./extensions-store"; import type { LensExtension, LensExtensionConstructor, LensExtensionId } from "./lens-extension"; -import type { LensMainExtension } from "./lens-main-extension"; import type { LensRendererExtension } from "./lens-renderer-extension"; import * as registries from "./registries"; @@ -47,7 +46,7 @@ const logModule = "[EXTENSIONS-LOADER]"; */ export class ExtensionLoader extends Singleton { protected extensions = observable.map(); - protected instances = observable.map(); + instances = observable.map(); /** * This is the set of extensions that don't come with either @@ -248,25 +247,7 @@ export class ExtensionLoader extends Singleton { } loadOnMain() { - registries.MenuRegistry.createInstance(); - - logger.debug(`${logModule}: load on main`); - this.autoInitExtensions(async (extension: LensMainExtension) => { - // Each .add returns a function to remove the item - const removeItems = [ - registries.MenuRegistry.getInstance().add(extension.appMenus), - ]; - - this.events.on("remove", (removedExtension: LensRendererExtension) => { - if (removedExtension.id === extension.id) { - removeItems.forEach(remove => { - remove(); - }); - } - }); - - return removeItems; - }); + this.autoInitExtensions(() => Promise.resolve([])); } loadOnClusterManagerRenderer() { diff --git a/src/extensions/extensions.injectable.ts b/src/extensions/extensions.injectable.ts new file mode 100644 index 000000000000..9cc14bc86a73 --- /dev/null +++ b/src/extensions/extensions.injectable.ts @@ -0,0 +1,42 @@ +/** + * 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 { Injectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed, IComputedValue } from "mobx"; +import { ExtensionLoader } from "./extension-loader"; +import type { LensExtension } from "./lens-extension"; + +const extensionsInjectable: Injectable< + IComputedValue, + { extensionLoader: ExtensionLoader } +> = { + getDependencies: () => ({ + extensionLoader: ExtensionLoader.createInstance(), + }), + + lifecycle: lifecycleEnum.singleton, + + instantiate: ({ extensionLoader }) => + computed(() => + [...extensionLoader.instances.values()], + ), +}; + +export default extensionsInjectable; diff --git a/src/extensions/lens-main-extension.ts b/src/extensions/lens-main-extension.ts index cbdba9e07cb6..c0c0a5674adb 100644 --- a/src/extensions/lens-main-extension.ts +++ b/src/extensions/lens-main-extension.ts @@ -24,7 +24,7 @@ import { WindowManager } from "../main/window-manager"; import { catalogEntityRegistry } from "../main/catalog"; import type { CatalogEntity } from "../common/catalog"; import type { IObservableArray } from "mobx"; -import type { MenuRegistration } from "./registries"; +import type { MenuRegistration } from "../main/menu/menu-registration"; export class LensMainExtension extends LensExtension { appMenus: MenuRegistration[] = []; diff --git a/src/main/getDi.ts b/src/main/getDi.ts new file mode 100644 index 000000000000..2b59923a6cf1 --- /dev/null +++ b/src/main/getDi.ts @@ -0,0 +1,34 @@ +/** + * 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 { createContainer } from "@ogre-tools/injectable"; + +export const getDi = () => + createContainer( + getRequireContextForMainCode, + getRequireContextForCommonExtensionCode, + ); + +const getRequireContextForMainCode = () => + require.context("./", true, /\.injectable\.(ts|tsx)$/); + +const getRequireContextForCommonExtensionCode = () => + require.context("../extensions", true, /\.injectable\.(ts|tsx)$/); diff --git a/src/main/getDiForUnitTesting.ts b/src/main/getDiForUnitTesting.ts new file mode 100644 index 000000000000..09414d0dbc89 --- /dev/null +++ b/src/main/getDiForUnitTesting.ts @@ -0,0 +1,59 @@ +/** + * 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 glob from "glob"; +import { memoize } from "lodash/fp"; + +import { + createContainer, + ConfigurableDependencyInjectionContainer, +} from "@ogre-tools/injectable"; + +export const getDiForUnitTesting = () => { + const di: ConfigurableDependencyInjectionContainer = createContainer(); + + getInjectableFilePaths() + .map(key => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const injectable = require(key).default; + + if (!injectable) { + console.log(key); + } + + return { + id: key, + ...injectable, + aliases: [injectable, ...(injectable.aliases || [])], + }; + }) + + .forEach(injectable => di.register(injectable)); + + di.preventSideEffects(); + + return di; +}; + +const getInjectableFilePaths = memoize(() => [ + ...glob.sync("./**/*.injectable.{ts,tsx}", { cwd: __dirname }), + ...glob.sync("../extensions/**/*.injectable.{ts,tsx}", { cwd: __dirname }), +]); diff --git a/src/main/index.ts b/src/main/index.ts index 1c5b80042ccf..a697f2c63e0f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -61,11 +61,15 @@ import { FilesystemProvisionerStore } from "./extension-filesystem"; import { SentryInit } from "../common/sentry"; import { ensureDir } from "fs-extra"; import { Router } from "./router"; -import { initMenu } from "./menu"; +import { initMenu } from "./menu/menu"; import { initTray } from "./tray"; import { kubeApiRequest, shellApiRequest, ShellRequestAuthenticator } from "./proxy-functions"; import { AppPaths } from "../common/app-paths"; import { ShellSession } from "./shell-session/shell-session"; +import { getDi } from "./getDi"; +import electronMenuItemsInjectable from "./menu/electron-menu-items.injectable"; + +const di = getDi(); injectSystemCAs(); @@ -236,8 +240,10 @@ app.on("ready", async () => { logger.info("🖥️ Starting WindowManager"); const windowManager = WindowManager.createInstance(); + const menuItems = di.inject(electronMenuItemsInjectable); + onQuitCleanup.push( - initMenu(windowManager), + initMenu(windowManager, menuItems), initTray(windowManager), () => ShellSession.cleanup(), ); diff --git a/src/main/menu/electron-menu-items.injectable.ts b/src/main/menu/electron-menu-items.injectable.ts new file mode 100644 index 000000000000..4f9d43d1fe9a --- /dev/null +++ b/src/main/menu/electron-menu-items.injectable.ts @@ -0,0 +1,41 @@ +/** + * 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 { Injectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed, IComputedValue } from "mobx"; +import type { LensMainExtension } from "../../extensions/lens-main-extension"; +import extensionsInjectable from "../../extensions/extensions.injectable"; +import type { MenuRegistration } from "./menu-registration"; + +const electronMenuItemsInjectable: Injectable< + IComputedValue, + { extensions: IComputedValue } +> = { + lifecycle: lifecycleEnum.singleton, + + getDependencies: di => ({ + extensions: di.inject(extensionsInjectable), + }), + + instantiate: ({ extensions }) => + computed(() => extensions.get().flatMap(extension => extension.appMenus)), +}; + +export default electronMenuItemsInjectable; diff --git a/src/main/menu/electron-menu-items.test.ts b/src/main/menu/electron-menu-items.test.ts new file mode 100644 index 000000000000..e3062f2fb9b0 --- /dev/null +++ b/src/main/menu/electron-menu-items.test.ts @@ -0,0 +1,132 @@ +/** + * 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 electronMenuItemsInjectable from "./electron-menu-items.injectable"; +import type { IComputedValue } from "mobx"; +import { computed, ObservableMap, runInAction } from "mobx"; +import type { MenuRegistration } from "./menu-registration"; +import extensionsInjectable from "../../extensions/extensions.injectable"; +import { getDiForUnitTesting } from "../getDiForUnitTesting"; + +describe("electron-menu-items", () => { + let di: ConfigurableDependencyInjectionContainer; + let electronMenuItems: IComputedValue; + let extensionsStub: ObservableMap; + + beforeEach(() => { + di = getDiForUnitTesting(); + + extensionsStub = new ObservableMap(); + + di.override( + extensionsInjectable, + computed(() => [...extensionsStub.values()]), + ); + + electronMenuItems = di.inject(electronMenuItemsInjectable); + }); + + it("does not have any items yet", () => { + expect(electronMenuItems.get()).toHaveLength(0); + }); + + describe("when extension is enabled", () => { + beforeEach(() => { + const someExtension = new SomeTestExtension({ + id: "some-extension-id", + appMenus: [{ parentId: "some-parent-id-from-first-extension" }], + }); + + runInAction(() => { + extensionsStub.set("some-extension-id", someExtension); + }); + }); + + it("has menu items", () => { + expect(electronMenuItems.get()).toEqual([ + { + parentId: "some-parent-id-from-first-extension", + }, + ]); + }); + + it("when disabling extension, does not have menu items", () => { + extensionsStub.delete("some-extension-id"); + + expect(electronMenuItems.get()).toHaveLength(0); + }); + + describe("when other extension is enabled", () => { + beforeEach(() => { + const someOtherExtension = new SomeTestExtension({ + id: "some-extension-id", + appMenus: [{ parentId: "some-parent-id-from-second-extension" }], + }); + + extensionsStub.set("some-other-extension-id", someOtherExtension); + }); + + it("has menu items for both extensions", () => { + expect(electronMenuItems.get()).toEqual([ + { + parentId: "some-parent-id-from-first-extension", + }, + + { + parentId: "some-parent-id-from-second-extension", + }, + ]); + }); + + it("when extension is disabled, still returns menu items for extensions that are enabled", () => { + runInAction(() => { + extensionsStub.delete("some-other-extension-id"); + }); + + expect(electronMenuItems.get()).toEqual([ + { + parentId: "some-parent-id-from-first-extension", + }, + ]); + }); + }); + }); +}); + +class SomeTestExtension extends LensMainExtension { + constructor({ id, appMenus }: { + id: string; + appMenus: MenuRegistration[]; + }) { + super({ + id, + absolutePath: "irrelevant", + isBundled: false, + isCompatible: false, + isEnabled: false, + manifest: { name: id, version: "some-version" }, + manifestPath: "irrelevant", + }); + + this.appMenus = appMenus; + } +} diff --git a/src/main/menu/menu-registration.d.ts b/src/main/menu/menu-registration.d.ts new file mode 100644 index 000000000000..8d8b634d5ad8 --- /dev/null +++ b/src/main/menu/menu-registration.d.ts @@ -0,0 +1,25 @@ +/** + * 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 { MenuItemConstructorOptions } from "electron"; + +export interface MenuRegistration extends MenuItemConstructorOptions { + parentId: string; +} diff --git a/src/main/menu.ts b/src/main/menu/menu.ts similarity index 87% rename from src/main/menu.ts rename to src/main/menu/menu.ts index 08012cee2303..489718610157 100644 --- a/src/main/menu.ts +++ b/src/main/menu/menu.ts @@ -18,18 +18,17 @@ * 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 { app, BrowserWindow, dialog, Menu, MenuItem, MenuItemConstructorOptions, webContents, shell } from "electron"; -import { autorun } from "mobx"; -import type { WindowManager } from "./window-manager"; -import { appName, isMac, isWindows, docsUrl, supportUrl, productName } from "../common/vars"; -import { MenuRegistry } from "../extensions/registries/menu-registry"; -import logger from "./logger"; -import { exitApp } from "./exit-app"; -import { broadcastMessage } from "../common/ipc"; -import * as packageJson from "../../package.json"; -import { preferencesURL, extensionsURL, addClusterURL, catalogURL, welcomeURL } from "../common/routes"; -import { checkForUpdates, isAutoUpdateEnabled } from "./app-updater"; +import { autorun, IComputedValue } from "mobx"; +import type { WindowManager } from "../window-manager"; +import { appName, isMac, isWindows, docsUrl, supportUrl, productName } from "../../common/vars"; +import logger from "../logger"; +import { exitApp } from "../exit-app"; +import { broadcastMessage } from "../../common/ipc"; +import * as packageJson from "../../../package.json"; +import { preferencesURL, extensionsURL, addClusterURL, catalogURL, welcomeURL } from "../../common/routes"; +import { checkForUpdates, isAutoUpdateEnabled } from "../app-updater"; +import type { MenuRegistration } from "./menu-registration"; export type MenuTopId = "mac" | "file" | "edit" | "view" | "help"; @@ -37,8 +36,11 @@ interface MenuItemsOpts extends MenuItemConstructorOptions { submenu?: MenuItemConstructorOptions[]; } -export function initMenu(windowManager: WindowManager) { - return autorun(() => buildMenu(windowManager), { +export function initMenu( + windowManager: WindowManager, + electronMenuItems: IComputedValue, +) { + return autorun(() => buildMenu(windowManager, electronMenuItems.get()), { delay: 100, }); } @@ -61,7 +63,10 @@ export function showAbout(browserWindow: BrowserWindow) { }); } -export function buildMenu(windowManager: WindowManager) { +export function buildMenu( + windowManager: WindowManager, + electronMenuItems: MenuRegistration[], +) { function ignoreIf(check: boolean, menuItems: MenuItemConstructorOptions[]) { return check ? [] : menuItems; } @@ -302,14 +307,17 @@ export function buildMenu(windowManager: WindowManager) { ]); // Modify menu from extensions-api - for (const { parentId, ...menuItem } of MenuRegistry.getInstance().getItems()) { - if (!appMenu.has(parentId)) { - logger.error(`[MENU]: cannot register menu item for parentId=${parentId}, parent item doesn't exist`, { menuItem }); + for (const menuItem of electronMenuItems) { + if (!appMenu.has(menuItem.parentId)) { + logger.error( + `[MENU]: cannot register menu item for parentId=${menuItem.parentId}, parent item doesn't exist`, + { menuItem }, + ); continue; } - appMenu.get(parentId).submenu.push(menuItem); + appMenu.get(menuItem.parentId).submenu.push(menuItem); } if (!isMac) { diff --git a/src/main/tray.ts b/src/main/tray.ts index c1b4aa4f78c6..850d19fe215b 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -23,7 +23,7 @@ import path from "path"; import packageInfo from "../../package.json"; import { Menu, Tray } from "electron"; import { autorun } from "mobx"; -import { showAbout } from "./menu"; +import { showAbout } from "./menu/menu"; import { checkForUpdates, isAutoUpdateEnabled } from "./app-updater"; import type { WindowManager } from "./window-manager"; import logger from "./logger"; From 717b62d2e635eaad58b4168043cc4bcc3268369f Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Tue, 7 Dec 2021 19:41:03 +0200 Subject: [PATCH 02/13] Remove obsolete code Signed-off-by: Janne Savolainen --- src/extensions/registries/index.ts | 1 - src/extensions/registries/menu-registry.ts | 32 ---------------------- src/main/index.ts | 1 - src/main/initializers/index.ts | 2 -- src/main/initializers/registries.ts | 26 ------------------ 5 files changed, 62 deletions(-) delete mode 100644 src/extensions/registries/menu-registry.ts delete mode 100644 src/main/initializers/registries.ts diff --git a/src/extensions/registries/index.ts b/src/extensions/registries/index.ts index 99809265a255..4dd64a9c82a1 100644 --- a/src/extensions/registries/index.ts +++ b/src/extensions/registries/index.ts @@ -23,7 +23,6 @@ export * from "./page-registry"; export * from "./page-menu-registry"; -export * from "./menu-registry"; export * from "./app-preference-registry"; export * from "./status-bar-registry"; export * from "./kube-object-detail-registry"; diff --git a/src/extensions/registries/menu-registry.ts b/src/extensions/registries/menu-registry.ts deleted file mode 100644 index 99267adac56a..000000000000 --- a/src/extensions/registries/menu-registry.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * 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. - */ - -// Extensions API -> Global menu customizations - -import type { MenuItemConstructorOptions } from "electron"; -import { BaseRegistry } from "./base-registry"; - -export interface MenuRegistration extends MenuItemConstructorOptions { - parentId: string; -} - -export class MenuRegistry extends BaseRegistry { -} diff --git a/src/main/index.ts b/src/main/index.ts index a697f2c63e0f..d58d0ca931e0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -227,7 +227,6 @@ app.on("ready", async () => { return app.exit(); } - initializers.initRegistries(); const extensionDiscovery = ExtensionDiscovery.createInstance(); ExtensionLoader.createInstance().init(); diff --git a/src/main/initializers/index.ts b/src/main/initializers/index.ts index 34a5c32e34b4..44c994789a53 100644 --- a/src/main/initializers/index.ts +++ b/src/main/initializers/index.ts @@ -18,8 +18,6 @@ * 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 * from "./registries"; export * from "./metrics-providers"; export * from "./ipc"; export * from "./cluster-metadata-detectors"; diff --git a/src/main/initializers/registries.ts b/src/main/initializers/registries.ts deleted file mode 100644 index 28fcdff1a1ba..000000000000 --- a/src/main/initializers/registries.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * 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 * as registries from "../../extensions/registries"; - -export function initRegistries() { - registries.MenuRegistry.createInstance(); -} From 41837e86bb3631c05c81513e95601fa10495a0e8 Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Wed, 8 Dec 2021 14:17:15 +0200 Subject: [PATCH 03/13] Remove forgotten console.log used for debug Signed-off-by: Janne Savolainen --- src/main/getDiForUnitTesting.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/getDiForUnitTesting.ts b/src/main/getDiForUnitTesting.ts index 09414d0dbc89..06b0588ae20d 100644 --- a/src/main/getDiForUnitTesting.ts +++ b/src/main/getDiForUnitTesting.ts @@ -35,10 +35,6 @@ export const getDiForUnitTesting = () => { // eslint-disable-next-line @typescript-eslint/no-var-requires const injectable = require(key).default; - if (!injectable) { - console.log(key); - } - return { id: key, ...injectable, From f9cf08b4a8c010fd795613e8d6d5179839f02035 Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Thu, 9 Dec 2021 07:41:43 +0200 Subject: [PATCH 04/13] Make sure that extensions have been initialized before exposing them as enabled extensions Signed-off-by: Janne Savolainen --- src/extensions/extension-loader.ts | 6 +++++- src/extensions/extensions.injectable.ts | 6 ++---- src/extensions/lens-extension.ts | 17 +++++++++++------ 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index 6f1454df1510..f5b4806de190 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -46,7 +46,7 @@ const logModule = "[EXTENSIONS-LOADER]"; */ export class ExtensionLoader extends Singleton { protected extensions = observable.map(); - instances = observable.map(); + protected instances = observable.map(); /** * This is the set of extensions that don't come with either @@ -96,6 +96,10 @@ export class ExtensionLoader extends Singleton { }); } + @computed get enabledExtensionInstances() : LensExtension[] { + return [...this.instances.values()].filter(extension => extension.isEnabled); + } + @computed get userExtensions(): Map { const extensions = this.toJSON(); diff --git a/src/extensions/extensions.injectable.ts b/src/extensions/extensions.injectable.ts index 9cc14bc86a73..08a2a253d401 100644 --- a/src/extensions/extensions.injectable.ts +++ b/src/extensions/extensions.injectable.ts @@ -28,15 +28,13 @@ const extensionsInjectable: Injectable< { extensionLoader: ExtensionLoader } > = { getDependencies: () => ({ - extensionLoader: ExtensionLoader.createInstance(), + extensionLoader: ExtensionLoader.getInstance(), }), lifecycle: lifecycleEnum.singleton, instantiate: ({ extensionLoader }) => - computed(() => - [...extensionLoader.instances.values()], - ), + computed(() => extensionLoader.enabledExtensionInstances), }; export default extensionsInjectable; diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index 5d562953ca0b..7b6ca8c94f8f 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -20,7 +20,7 @@ */ import type { InstalledExtension } from "./extension-discovery"; -import { action, observable, makeObservable } from "mobx"; +import { action, observable, makeObservable, computed } from "mobx"; import { FilesystemProvisionerStore } from "../main/extension-filesystem"; import logger from "../main/logger"; import type { ProtocolHandlerRegistration } from "./registries"; @@ -47,7 +47,12 @@ export class LensExtension { protocolHandlers: ProtocolHandlerRegistration[] = []; - @observable private isEnabled = false; + @observable private _isEnabled = false; + + @computed get isEnabled() { + return this._isEnabled; + } + [Disposers] = disposer(); constructor({ id, manifest, manifestPath, isBundled }: InstalledExtension) { @@ -83,13 +88,13 @@ export class LensExtension { @action async enable(register: (ext: LensExtension) => Promise) { - if (this.isEnabled) { + if (this._isEnabled) { return; } try { await this.onActivate(); - this.isEnabled = true; + this._isEnabled = true; this[Disposers].push(...await register(this)); logger.info(`[EXTENSION]: enabled ${this.name}@${this.version}`); @@ -100,11 +105,11 @@ export class LensExtension { @action async disable() { - if (!this.isEnabled) { + if (!this._isEnabled) { return; } - this.isEnabled = false; + this._isEnabled = false; try { await this.onDeactivate(); From 34a47f5f44763fcde5e70cfe31eed43e112e267c Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Thu, 9 Dec 2021 08:09:49 +0200 Subject: [PATCH 05/13] Introduce a way to start replacing usages of Singleton base-class with no changes required on use places Signed-off-by: Janne Savolainen --- src/common/di-kludge/di-kludge.ts | 29 +++++++++ .../get-legacy-singleton.ts | 63 +++++++++++++++++++ src/main/getDi.ts | 10 ++- src/main/getDiForUnitTesting.ts | 3 + src/renderer/components/getDi.tsx | 3 + .../components/getDiForUnitTesting.tsx | 3 + 6 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 src/common/di-kludge/di-kludge.ts create mode 100644 src/common/di-kludge/get-legacy-singleton/get-legacy-singleton.ts diff --git a/src/common/di-kludge/di-kludge.ts b/src/common/di-kludge/di-kludge.ts new file mode 100644 index 000000000000..863eca938222 --- /dev/null +++ b/src/common/di-kludge/di-kludge.ts @@ -0,0 +1,29 @@ +/** + * 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 { DependencyInjectionContainer } from "@ogre-tools/injectable"; + +let kludgeDi: DependencyInjectionContainer; + +export const setDiKludge = (di: DependencyInjectionContainer) => { + kludgeDi = di; +}; + +export const getDiKludge = () => kludgeDi; diff --git a/src/common/di-kludge/get-legacy-singleton/get-legacy-singleton.ts b/src/common/di-kludge/get-legacy-singleton/get-legacy-singleton.ts new file mode 100644 index 000000000000..3ba3ac5d6426 --- /dev/null +++ b/src/common/di-kludge/get-legacy-singleton/get-legacy-singleton.ts @@ -0,0 +1,63 @@ +/** + * 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 { Injectable } from "@ogre-tools/injectable"; +import { getDiKludge } from "../di-kludge"; + +type Awaited = TMaybePromise extends PromiseLike + ? TValue + : TMaybePromise; + +export const getLegacySingleton = < + TInjectable extends Injectable< + TInstance, + TDependencies, + TInstantiationParameter + >, + TInstance, + TDependencies extends object, + TInstantiationParameter, + TMaybePromiseInstance = ReturnType, +>( + injectableKey: TInjectable, + ) => ({ + createInstance: (): TMaybePromiseInstance extends PromiseLike + ? Awaited + : TMaybePromiseInstance => { + const di = getDiKludge(); + + return di.inject(injectableKey); + }, + + getInstance: (): TMaybePromiseInstance extends PromiseLike + ? Awaited + : TMaybePromiseInstance => { + const di = getDiKludge(); + + return di.inject(injectableKey); + }, + + resetInstance: () => { + const di = getDiKludge(); + + // @ts-ignore + return di.purge(injectableKey); + }, + }); diff --git a/src/main/getDi.ts b/src/main/getDi.ts index 2b59923a6cf1..4a81bb908692 100644 --- a/src/main/getDi.ts +++ b/src/main/getDi.ts @@ -20,13 +20,19 @@ */ import { createContainer } from "@ogre-tools/injectable"; +import { setDiKludge } from "../common/di-kludge/di-kludge"; -export const getDi = () => - createContainer( +export const getDi = () => { + const di = createContainer( getRequireContextForMainCode, getRequireContextForCommonExtensionCode, ); + setDiKludge(di); + + return di; +}; + const getRequireContextForMainCode = () => require.context("./", true, /\.injectable\.(ts|tsx)$/); diff --git a/src/main/getDiForUnitTesting.ts b/src/main/getDiForUnitTesting.ts index 06b0588ae20d..a977e70a26d4 100644 --- a/src/main/getDiForUnitTesting.ts +++ b/src/main/getDiForUnitTesting.ts @@ -26,10 +26,13 @@ import { createContainer, ConfigurableDependencyInjectionContainer, } from "@ogre-tools/injectable"; +import { setDiKludge } from "../common/di-kludge/di-kludge"; export const getDiForUnitTesting = () => { const di: ConfigurableDependencyInjectionContainer = createContainer(); + setDiKludge(di); + getInjectableFilePaths() .map(key => { // eslint-disable-next-line @typescript-eslint/no-var-requires diff --git a/src/renderer/components/getDi.tsx b/src/renderer/components/getDi.tsx index 65367a8c7744..859563534e97 100644 --- a/src/renderer/components/getDi.tsx +++ b/src/renderer/components/getDi.tsx @@ -21,11 +21,14 @@ import { createContainer } from "@ogre-tools/injectable"; import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; +import { setDiKludge } from "../../common/di-kludge/di-kludge"; export const getDi = () => { const di: ConfigurableDependencyInjectionContainer = createContainer( () => require.context("./", true, /\.injectable\.(ts|tsx)$/), ); + setDiKludge(di); + return di; }; diff --git a/src/renderer/components/getDiForUnitTesting.tsx b/src/renderer/components/getDiForUnitTesting.tsx index 4e94d20dae25..f182d8131ef1 100644 --- a/src/renderer/components/getDiForUnitTesting.tsx +++ b/src/renderer/components/getDiForUnitTesting.tsx @@ -26,10 +26,13 @@ import { createContainer, ConfigurableDependencyInjectionContainer, } from "@ogre-tools/injectable"; +import { setDiKludge } from "../../common/di-kludge/di-kludge"; export const getDiForUnitTesting = () => { const di: ConfigurableDependencyInjectionContainer = createContainer(); + setDiKludge(di); + getInjectableFilePaths() .map(key => { const injectable = require(key).default; From b4a1c2af9f84d383af37d1694836890d6fc337f0 Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Thu, 9 Dec 2021 08:12:38 +0200 Subject: [PATCH 06/13] Make ExtensionLoader injectable to avoid confusion when instance should be created Signed-off-by: Janne Savolainen --- .../extension-loader.injectable.ts | 31 +++++++++++++++++++ .../extension-loader.ts | 23 +++++++------- src/extensions/extension-loader/index.ts | 29 +++++++++++++++++ src/extensions/extensions.injectable.ts | 5 +-- 4 files changed, 74 insertions(+), 14 deletions(-) create mode 100644 src/extensions/extension-loader/extension-loader.injectable.ts rename src/extensions/{ => extension-loader}/extension-loader.ts (95%) create mode 100644 src/extensions/extension-loader/index.ts diff --git a/src/extensions/extension-loader/extension-loader.injectable.ts b/src/extensions/extension-loader/extension-loader.injectable.ts new file mode 100644 index 000000000000..70922daf94fc --- /dev/null +++ b/src/extensions/extension-loader/extension-loader.injectable.ts @@ -0,0 +1,31 @@ +/** + * 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 { Injectable } from "@ogre-tools/injectable"; +import { lifecycleEnum } from "@ogre-tools/injectable"; +import { ExtensionLoader } from "./extension-loader"; + +const extensionLoaderInjectable: Injectable = { + getDependencies: () => ({}), + instantiate: () => new ExtensionLoader(), + lifecycle: lifecycleEnum.singleton, +}; + +export default extensionLoaderInjectable; diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader/extension-loader.ts similarity index 95% rename from src/extensions/extension-loader.ts rename to src/extensions/extension-loader/extension-loader.ts index f5b4806de190..43d3eece6360 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader/extension-loader.ts @@ -24,16 +24,16 @@ import { EventEmitter } from "events"; import { isEqual } from "lodash"; import { action, computed, makeObservable, observable, observe, reaction, when } from "mobx"; import path from "path"; -import { AppPaths } from "../common/app-paths"; -import { ClusterStore } from "../common/cluster-store"; -import { broadcastMessage, ipcMainOn, ipcRendererOn, requestMain, ipcMainHandle } from "../common/ipc"; -import { Disposer, getHostedClusterId, Singleton, toJS } from "../common/utils"; -import logger from "../main/logger"; -import type { InstalledExtension } from "./extension-discovery"; -import { ExtensionsStore } from "./extensions-store"; -import type { LensExtension, LensExtensionConstructor, LensExtensionId } from "./lens-extension"; -import type { LensRendererExtension } from "./lens-renderer-extension"; -import * as registries from "./registries"; +import { AppPaths } from "../../common/app-paths"; +import { ClusterStore } from "../../common/cluster-store"; +import { broadcastMessage, ipcMainOn, ipcRendererOn, requestMain, ipcMainHandle } from "../../common/ipc"; +import { Disposer, getHostedClusterId, toJS } from "../../common/utils"; +import logger from "../../main/logger"; +import type { InstalledExtension } from "../extension-discovery"; +import { ExtensionsStore } from "../extensions-store"; +import type { LensExtension, LensExtensionConstructor, LensExtensionId } from "../lens-extension"; +import type { LensRendererExtension } from "../lens-renderer-extension"; +import * as registries from "../registries"; export function extensionPackagesRoot() { return path.join(AppPaths.get("userData")); @@ -44,7 +44,7 @@ const logModule = "[EXTENSIONS-LOADER]"; /** * Loads installed extensions to the Lens application */ -export class ExtensionLoader extends Singleton { +export class ExtensionLoader { protected extensions = observable.map(); protected instances = observable.map(); @@ -76,7 +76,6 @@ export class ExtensionLoader extends Singleton { } constructor() { - super(); makeObservable(this); observe(this.instances, change => { switch (change.type) { diff --git a/src/extensions/extension-loader/index.ts b/src/extensions/extension-loader/index.ts new file mode 100644 index 000000000000..283fb3b5ee0f --- /dev/null +++ b/src/extensions/extension-loader/index.ts @@ -0,0 +1,29 @@ +/** + * 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 { getLegacySingleton } from "../../common/di-kludge/get-legacy-singleton/get-legacy-singleton"; +import extensionLoaderInjectable from "./extension-loader.injectable"; + +export * from "./extension-loader"; + +/** + * @deprecated Switch to using di.inject(extensionLoaderInjectable) + */ +export const ExtensionLoader = getLegacySingleton(extensionLoaderInjectable); diff --git a/src/extensions/extensions.injectable.ts b/src/extensions/extensions.injectable.ts index 08a2a253d401..9bd1bfae66e1 100644 --- a/src/extensions/extensions.injectable.ts +++ b/src/extensions/extensions.injectable.ts @@ -20,12 +20,13 @@ */ import { Injectable, lifecycleEnum } from "@ogre-tools/injectable"; import { computed, IComputedValue } from "mobx"; -import { ExtensionLoader } from "./extension-loader"; import type { LensExtension } from "./lens-extension"; +import { ExtensionLoader } from "./extension-loader"; +import type { ExtensionLoader as ExtensionLoaderType } from "./extension-loader/extension-loader"; const extensionsInjectable: Injectable< IComputedValue, - { extensionLoader: ExtensionLoader } + { extensionLoader: ExtensionLoaderType } > = { getDependencies: () => ({ extensionLoader: ExtensionLoader.getInstance(), From f3d45ea78bee0955f4b19b138d01cedad3fa350e Mon Sep 17 00:00:00 2001 From: Iku-turso Date: Thu, 9 Dec 2021 14:32:58 +0200 Subject: [PATCH 07/13] Fix hangup on application start by auto-registering more injectables Signed-off-by: Iku-turso Co-authored-by: Janne Savolainen --- src/renderer/components/getDi.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/getDi.tsx b/src/renderer/components/getDi.tsx index 859563534e97..d341d9c3ae2e 100644 --- a/src/renderer/components/getDi.tsx +++ b/src/renderer/components/getDi.tsx @@ -20,15 +20,21 @@ */ import { createContainer } from "@ogre-tools/injectable"; -import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; import { setDiKludge } from "../../common/di-kludge/di-kludge"; export const getDi = () => { - const di: ConfigurableDependencyInjectionContainer = createContainer( - () => require.context("./", true, /\.injectable\.(ts|tsx)$/), + const di = createContainer( + getRequireContextForRendererCode, + getRequireContextForCommonExtensionCode, ); setDiKludge(di); return di; }; + +const getRequireContextForRendererCode = () => + require.context("./", true, /\.injectable\.(ts|tsx)$/); + +const getRequireContextForCommonExtensionCode = () => + require.context("../../extensions", true, /\.injectable\.(ts|tsx)$/); From 0b172de7414975b7e777286bc0436411be6831f4 Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Fri, 10 Dec 2021 13:52:25 +0200 Subject: [PATCH 08/13] Adapt to change in DI to minimize boilerplate Signed-off-by: Janne Savolainen --- package.json | 4 +- .../components/kube-object-menu/index.ts | 2 +- .../kube-object-menu-container.tsx | 24 ------ .../kube-object-menu/kube-object-menu.tsx | 77 ++++++++++++------- yarn.lock | 30 ++++---- 5 files changed, 69 insertions(+), 68 deletions(-) delete mode 100644 src/renderer/components/kube-object-menu/kube-object-menu-container.tsx diff --git a/package.json b/package.json index a5db133e9640..9f708ba1e5ef 100644 --- a/package.json +++ b/package.json @@ -198,8 +198,8 @@ "@kubernetes/client-node": "^0.16.1", "@sentry/electron": "^2.5.4", "@sentry/integrations": "^6.15.0", - "@ogre-tools/injectable": "^1.3.0", - "@ogre-tools/injectable-react": "^1.3.1", + "@ogre-tools/injectable": "^1.4.1", + "@ogre-tools/injectable-react": "^1.4.1", "abort-controller": "^3.0.0", "auto-bind": "^4.0.0", "autobind-decorator": "^2.4.0", diff --git a/src/renderer/components/kube-object-menu/index.ts b/src/renderer/components/kube-object-menu/index.ts index 2a9f7fb5c65f..d043de2fa008 100644 --- a/src/renderer/components/kube-object-menu/index.ts +++ b/src/renderer/components/kube-object-menu/index.ts @@ -20,4 +20,4 @@ */ export type { KubeObjectMenuProps } from "./kube-object-menu"; -export { KubeObjectMenu } from "./kube-object-menu-container"; +export { KubeObjectMenu } from "./kube-object-menu"; diff --git a/src/renderer/components/kube-object-menu/kube-object-menu-container.tsx b/src/renderer/components/kube-object-menu/kube-object-menu-container.tsx deleted file mode 100644 index 9ac49a131ece..000000000000 --- a/src/renderer/components/kube-object-menu/kube-object-menu-container.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/** - * 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 { getInjectedComponent } from "@ogre-tools/injectable-react"; -import KubeObjectMenuInjectable from "./kube-object-menu.injectable"; - -export const KubeObjectMenu = getInjectedComponent(KubeObjectMenuInjectable); diff --git a/src/renderer/components/kube-object-menu/kube-object-menu.tsx b/src/renderer/components/kube-object-menu/kube-object-menu.tsx index ed382956c205..53ae44e44762 100644 --- a/src/renderer/components/kube-object-menu/kube-object-menu.tsx +++ b/src/renderer/components/kube-object-menu/kube-object-menu.tsx @@ -25,34 +25,45 @@ import type { KubeObject } from "../../../common/k8s-api/kube-object"; import { MenuActions, MenuActionsProps } from "../menu"; import identity from "lodash/identity"; import type { ApiManager } from "../../../common/k8s-api/api-manager"; - -export interface KubeObjectMenuDependencies { - apiManager: ApiManager; - kubeObjectMenuItems: React.ElementType[]; - clusterName: string; - hideDetails: () => void; - editResourceTab: (kubeObject: TKubeObject) => void; -} - +import { withInjectables } from "@ogre-tools/injectable-react"; +import clusterNameInjectable from "./dependencies/cluster-name.injectable"; +import editResourceTabInjectable from "./dependencies/edit-resource-tab.injectable"; +import hideDetailsInjectable from "./dependencies/hide-details.injectable"; +import kubeObjectMenuItemsInjectable from "./dependencies/kube-object-menu-items/kube-object-menu-items.injectable"; +import apiManagerInjectable from "./dependencies/api-manager.injectable"; + +// TODO: Replace with KubeObjectMenuProps2 export interface KubeObjectMenuProps extends MenuActionsProps { object: TKubeObject | null | undefined; editable?: boolean; removable?: boolean; } -export interface KubeObjectMenuPropsAndDependencies - extends KubeObjectMenuProps, - KubeObjectMenuDependencies {} +interface KubeObjectMenuProps2 extends MenuActionsProps { + object: KubeObject | null | undefined; + editable?: boolean; + removable?: boolean; -export class KubeObjectMenu< - TKubeObject extends KubeObject, -> extends React.Component> { + dependencies: { + apiManager: ApiManager; + kubeObjectMenuItems: React.ElementType[]; + clusterName: string; + hideDetails: () => void; + editResourceTab: (kubeObject: KubeObject) => void; + }; +} + +class NonInjectedKubeObjectMenu extends React.Component { + get dependencies() { + return this.props.dependencies; + } + get store() { const { object } = this.props; if (!object) return null; - return this.props.apiManager.getStore(object.selfLink); + return this.props.dependencies.apiManager.getStore(object.selfLink); } get isEditable() { @@ -65,13 +76,13 @@ export class KubeObjectMenu< @boundMethod async update() { - this.props.hideDetails(); - this.props.editResourceTab(this.props.object); + this.props.dependencies.hideDetails(); + this.props.dependencies.editResourceTab(this.props.object); } @boundMethod async remove() { - this.props.hideDetails(); + this.props.dependencies.hideDetails(); const { object, removeAction } = this.props; if (removeAction) await removeAction(); @@ -92,7 +103,8 @@ export class KubeObjectMenu< return (

- Remove {object.kind} {breadcrumb} from {this.props.clusterName}? + Remove {object.kind} {breadcrumb} from{" "} + {this.props.dependencies.clusterName}?

); } @@ -100,12 +112,8 @@ export class KubeObjectMenu< getMenuItems(): React.ReactChild[] { const { object, toolbar } = this.props; - return this.props.kubeObjectMenuItems.map((MenuItem, index) => ( - + return this.props.dependencies.kubeObjectMenuItems.map((MenuItem, index) => ( + )); } @@ -126,3 +134,20 @@ export class KubeObjectMenu< ); } } + +export const KubeObjectMenu = withInjectables(NonInjectedKubeObjectMenu, { + getProps: (di, props) => ({ + dependencies: { + clusterName: di.inject(clusterNameInjectable), + apiManager: di.inject(apiManagerInjectable), + editResourceTab: di.inject(editResourceTabInjectable), + hideDetails: di.inject(hideDetailsInjectable), + + kubeObjectMenuItems: di.inject(kubeObjectMenuItemsInjectable, { + kubeObject: props.object, + }), + }, + + ...props, + }), +}); diff --git a/yarn.lock b/yarn.lock index fe53a3016138..ed4bf8571a1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -972,28 +972,28 @@ "@nodelib/fs.scandir" "2.1.3" fastq "^1.6.0" -"@ogre-tools/fp@^1.0.2": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@ogre-tools/fp/-/fp-1.0.2.tgz#26c2c5cf60aa01cc94763cc68beba7052fdadfd9" - integrity sha512-ftvi/aoi5PaojWnuhHzp0YiecUd22HzW5gErsSiKyO2bps90WI4WjgY6d9hWdlzM9eukVmwM+dC6rGNlltNHNw== +"@ogre-tools/fp@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@ogre-tools/fp/-/fp-1.4.0.tgz#94c50378c5bc51ea1571f775e4428256f22c61b5" + integrity sha512-Eh/pK67CoYU/tJPWHeuNFEp+YdE8RPAAxZlSDAoXUDAd8sta3e+1vG7OEJlkYIJW4L8sCGKLWZu2DZ8uI6URhA== dependencies: lodash "^4.17.21" -"@ogre-tools/injectable-react@^1.3.1": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@ogre-tools/injectable-react/-/injectable-react-1.3.1.tgz#dec3829ac8cf295c32cfe636ca2cd39a495d56ce" - integrity sha512-5jHL9Zcb3QkrttdzqJpN6iCXaV2+fEuDNigwH6NJ3uyV1iQWuRIctnlXxfa9qtZESwaAz7o0hAwkyqEl7YSA4g== +"@ogre-tools/injectable-react@^1.4.1": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@ogre-tools/injectable-react/-/injectable-react-1.4.1.tgz#48d8633462189939292596a66631d6717e39e47f" + integrity sha512-SRk3QXvFCEQk4MeVG8TAomGcOt0Pf06hZ5kBh+iNIug3FLYeyWagH6OSVylZRu4u2Izd89J0taS1GmSfYDoHaA== dependencies: - "@ogre-tools/fp" "^1.0.2" - "@ogre-tools/injectable" "^1.3.0" + "@ogre-tools/fp" "^1.4.0" + "@ogre-tools/injectable" "^1.4.1" lodash "^4.17.21" -"@ogre-tools/injectable@^1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@ogre-tools/injectable/-/injectable-1.3.0.tgz#87d329a81575c9345b3af5c1afb0b45537f8f70e" - integrity sha512-rBy8HSExUy1r53ATvk823GXevwultKuSn3mmyRlIj7opJDVRp7Usx0bvOPs+X169jmAZNzsT6HBXbDLXt4Jl4A== +"@ogre-tools/injectable@^1.4.1": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@ogre-tools/injectable/-/injectable-1.4.1.tgz#45414c6e13c870d7d84f4fa8e0dd67b33f6cc23e" + integrity sha512-vX4QXS/2d3g7oUenOKcv3mZRnJ5XewUMPsSsELjCyhL2caJlD0eB9J7y3y0eeFu/I18L8GC3DRs9o3QNshwN5Q== dependencies: - "@ogre-tools/fp" "^1.0.2" + "@ogre-tools/fp" "^1.4.0" lodash "^4.17.21" "@panva/asn1.js@^1.0.0": From c92c8f8a11710395b82c271392b06250d62b19e4 Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Thu, 9 Dec 2021 14:44:12 +0200 Subject: [PATCH 09/13] Replace global state usages of ExtensionLoader with DI Signed-off-by: Janne Savolainen --- src/common/protocol-handler/router.ts | 26 +- .../__tests__/extension-discovery.test.ts | 17 +- .../__tests__/extension-loader.test.ts | 19 +- src/extensions/extension-discovery.ts | 6 +- src/extensions/extension-loader/index.ts | 7 - src/extensions/extensions.injectable.ts | 10 +- src/extensions/getDiForUnitTesting.ts | 57 ++ src/main/index.ts | 43 +- .../protocol-handler/__test__/router.test.ts | 43 +- src/main/protocol-handler/index.ts | 2 +- .../lens-protocol-router-main.injectable.ts | 40 ++ .../lens-protocol-router-main.ts} | 23 +- src/renderer/bootstrap.tsx | 31 +- src/renderer/cluster-frame.tsx | 7 +- .../+extensions/__tests__/extensions.test.tsx | 20 +- .../attempt-install-by-info.injectable.ts | 35 ++ .../attempt-install-by-info.tsx | 123 ++++ .../attempt-install.injectable.ts | 46 ++ .../attempt-install/attempt-install.tsx | 138 +++++ .../create-temp-files-and-validate.tsx | 101 ++++ .../get-extension-dest-folder.tsx | 28 + .../attempt-install/install-request.d.ts | 4 + .../unpack-extension.injectable.tsx | 43 ++ .../unpack-extension/unpack-extension.tsx | 112 ++++ .../validate-package/validate-package.tsx | 63 +++ .../attempt-installs.injectable.ts | 38 ++ .../attempt-installs/attempt-installs.ts | 44 ++ .../confirm-uninstall-extension.injectable.ts | 42 ++ .../confirm-uninstall-extension.tsx | 51 ++ .../disable-extension.injectable.ts | 40 ++ .../disable-extension/disable-extension.ts | 36 ++ .../enable-extension.injectable.ts | 40 ++ .../enable-extension/enable-extension.ts | 36 ++ .../components/+extensions/extensions.tsx | 533 +++--------------- .../get-message-from-error.ts | 41 ++ .../install-from-input.injectable.ts | 42 ++ .../install-from-input/install-from-input.tsx | 68 +++ ...tall-from-select-file-dialog.injectable.ts | 35 ++ .../install-from-select-file-dialog.ts | 45 ++ .../install-on-drop.injectable.ts | 38 ++ .../install-on-drop/install-on-drop.tsx | 32 ++ .../read-file-notify/read-file-notify.ts | 39 ++ .../supported-extension-formats.ts | 21 + .../uninstall-extension.injectable.ts | 40 ++ .../uninstall-extension.tsx | 76 +++ .../user-extensions.injectable.ts | 41 ++ src/renderer/components/getDi.tsx | 2 +- .../kube-object-menu.injectable.tsx | 60 -- src/renderer/initializers/ipc.ts | 6 +- .../protocol-handler/app-handlers.tsx | 116 ---- ...-protocol-add-route-handlers.injectable.ts | 41 ++ .../bind-protocol-add-route-handlers.tsx | 147 +++++ src/renderer/protocol-handler/index.ts | 4 +- ...ens-protocol-router-renderer.injectable.ts | 40 ++ .../lens-protocol-router-renderer.tsx} | 18 +- src/renderer/root-frame.tsx | 15 +- 56 files changed, 2110 insertions(+), 761 deletions(-) create mode 100644 src/extensions/getDiForUnitTesting.ts create mode 100644 src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable.ts rename src/main/protocol-handler/{router.ts => lens-protocol-router-main/lens-protocol-router-main.ts} (88%) create mode 100644 src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.injectable.ts create mode 100644 src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.tsx create mode 100644 src/renderer/components/+extensions/attempt-install/attempt-install.injectable.ts create mode 100644 src/renderer/components/+extensions/attempt-install/attempt-install.tsx create mode 100644 src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate/create-temp-files-and-validate.tsx create mode 100644 src/renderer/components/+extensions/attempt-install/get-extension-dest-folder/get-extension-dest-folder.tsx create mode 100644 src/renderer/components/+extensions/attempt-install/install-request.d.ts create mode 100644 src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.injectable.tsx create mode 100644 src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.tsx create mode 100644 src/renderer/components/+extensions/attempt-install/validate-package/validate-package.tsx create mode 100644 src/renderer/components/+extensions/attempt-installs/attempt-installs.injectable.ts create mode 100644 src/renderer/components/+extensions/attempt-installs/attempt-installs.ts create mode 100644 src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.injectable.ts create mode 100644 src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.tsx create mode 100644 src/renderer/components/+extensions/disable-extension/disable-extension.injectable.ts create mode 100644 src/renderer/components/+extensions/disable-extension/disable-extension.ts create mode 100644 src/renderer/components/+extensions/enable-extension/enable-extension.injectable.ts create mode 100644 src/renderer/components/+extensions/enable-extension/enable-extension.ts create mode 100644 src/renderer/components/+extensions/get-message-from-error/get-message-from-error.ts create mode 100644 src/renderer/components/+extensions/install-from-input/install-from-input.injectable.ts create mode 100644 src/renderer/components/+extensions/install-from-input/install-from-input.tsx create mode 100644 src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.injectable.ts create mode 100644 src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.ts create mode 100644 src/renderer/components/+extensions/install-on-drop/install-on-drop.injectable.ts create mode 100644 src/renderer/components/+extensions/install-on-drop/install-on-drop.tsx create mode 100644 src/renderer/components/+extensions/read-file-notify/read-file-notify.ts create mode 100644 src/renderer/components/+extensions/supported-extension-formats.ts create mode 100644 src/renderer/components/+extensions/uninstall-extension/uninstall-extension.injectable.ts create mode 100644 src/renderer/components/+extensions/uninstall-extension/uninstall-extension.tsx create mode 100644 src/renderer/components/+extensions/user-extensions/user-extensions.injectable.ts delete mode 100644 src/renderer/components/kube-object-menu/kube-object-menu.injectable.tsx delete mode 100644 src/renderer/protocol-handler/app-handlers.tsx create mode 100644 src/renderer/protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.injectable.ts create mode 100644 src/renderer/protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.tsx create mode 100644 src/renderer/protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.injectable.ts rename src/renderer/protocol-handler/{router.tsx => lens-protocol-router-renderer/lens-protocol-router-renderer.tsx} (89%) diff --git a/src/common/protocol-handler/router.ts b/src/common/protocol-handler/router.ts index 6aea2037d197..afb0f4c2afbb 100644 --- a/src/common/protocol-handler/router.ts +++ b/src/common/protocol-handler/router.ts @@ -21,13 +21,13 @@ import { match, matchPath } from "react-router"; import { countBy } from "lodash"; -import { iter, Singleton } from "../utils"; +import { iter } from "../utils"; import { pathToRegexp } from "path-to-regexp"; import logger from "../../main/logger"; import type Url from "url-parse"; import { RoutingError, RoutingErrorType } from "./error"; import { ExtensionsStore } from "../../extensions/extensions-store"; -import { ExtensionLoader } from "../../extensions/extension-loader"; +import type { ExtensionLoader as ExtensionLoaderType } from "../../extensions/extension-loader/extension-loader"; import type { LensExtension } from "../../extensions/lens-extension"; import type { RouteHandler, RouteParams } from "../../extensions/registries/protocol-handler"; import { when } from "mobx"; @@ -78,7 +78,11 @@ export function foldAttemptResults(mainAttempt: RouteAttempt, rendererAttempt: R } } -export abstract class LensProtocolRouter extends Singleton { +interface Dependencies { + extensionLoader: ExtensionLoaderType +} + +export abstract class LensProtocolRouter { // Map between path schemas and the handlers protected internalRoutes = new Map(); @@ -86,6 +90,8 @@ export abstract class LensProtocolRouter extends Singleton { static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`; + constructor(protected dependencies: Dependencies) {} + /** * Attempts to route the given URL to all internal routes that have been registered * @param url the parsed URL that initiated the `lens://` protocol @@ -180,15 +186,20 @@ export abstract class LensProtocolRouter extends Singleton { const { [EXTENSION_PUBLISHER_MATCH]: publisher, [EXTENSION_NAME_MATCH]: partialName } = match.params; const name = [publisher, partialName].filter(Boolean).join("/"); - const extensionLoader = ExtensionLoader.getInstance(); + + const extensionLoader = this.dependencies.extensionLoader; try { /** * Note, if `getInstanceByName` returns `null` that means we won't be getting an instance */ - await when(() => extensionLoader.getInstanceByName(name) !== (void 0), { timeout: 5_000 }); - } catch(error) { - logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not installed (${error})`); + await when(() => extensionLoader.getInstanceByName(name) !== void 0, { + timeout: 5_000, + }); + } catch (error) { + logger.info( + `${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not installed (${error})`, + ); return name; } @@ -233,7 +244,6 @@ export abstract class LensProtocolRouter extends Singleton { // remove the extension name from the path name so we don't need to match on it anymore url.set("pathname", url.pathname.slice(extension.name.length + 1)); - try { const handlers = iter.map(extension.protocolHandlers, ({ pathSchema, handler }) => [pathSchema, handler] as [string, RouteHandler]); diff --git a/src/extensions/__tests__/extension-discovery.test.ts b/src/extensions/__tests__/extension-discovery.test.ts index b7861cd5b0c7..e4ea1c44519f 100644 --- a/src/extensions/__tests__/extension-discovery.test.ts +++ b/src/extensions/__tests__/extension-discovery.test.ts @@ -27,6 +27,9 @@ import { ExtensionDiscovery } from "../extension-discovery"; import os from "os"; import { Console } from "console"; import { AppPaths } from "../../common/app-paths"; +import type { ExtensionLoader } from "../extension-loader"; +import extensionLoaderInjectable from "../extension-loader/extension-loader.injectable"; +import { getDiForUnitTesting } from "../getDiForUnitTesting"; jest.setTimeout(60_000); @@ -62,10 +65,16 @@ console = new Console(process.stdout, process.stderr); // fix mockFS const mockedWatch = watch as jest.MockedFunction; describe("ExtensionDiscovery", () => { + let extensionLoader: ExtensionLoader; + beforeEach(() => { ExtensionDiscovery.resetInstance(); ExtensionsStore.resetInstance(); ExtensionsStore.createInstance(); + + const di = getDiForUnitTesting(); + + extensionLoader = di.inject(extensionLoaderInjectable); }); describe("with mockFs", () => { @@ -98,7 +107,9 @@ describe("ExtensionDiscovery", () => { (mockWatchInstance) as any, ); - const extensionDiscovery = ExtensionDiscovery.createInstance(); + const extensionDiscovery = ExtensionDiscovery.createInstance( + extensionLoader, + ); // Need to force isLoaded to be true so that the file watching is started extensionDiscovery.isLoaded = true; @@ -140,7 +151,9 @@ describe("ExtensionDiscovery", () => { mockedWatch.mockImplementationOnce(() => (mockWatchInstance) as any, ); - const extensionDiscovery = ExtensionDiscovery.createInstance(); + const extensionDiscovery = ExtensionDiscovery.createInstance( + extensionLoader, + ); // Need to force isLoaded to be true so that the file watching is started extensionDiscovery.isLoaded = true; diff --git a/src/extensions/__tests__/extension-loader.test.ts b/src/extensions/__tests__/extension-loader.test.ts index 03f4257fdfbd..4a8786e34cef 100644 --- a/src/extensions/__tests__/extension-loader.test.ts +++ b/src/extensions/__tests__/extension-loader.test.ts @@ -19,11 +19,13 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { ExtensionLoader } from "../extension-loader"; +import type { ExtensionLoader } from "../extension-loader"; import { ipcRenderer } from "electron"; import { ExtensionsStore } from "../extensions-store"; import { Console } from "console"; import { stdout, stderr } from "process"; +import { getDiForUnitTesting } from "../getDiForUnitTesting"; +import extensionLoaderInjectable from "../extension-loader/extension-loader.injectable"; console = new Console(stdout, stderr); @@ -128,13 +130,15 @@ jest.mock( ); describe("ExtensionLoader", () => { + let extensionLoader: ExtensionLoader; + beforeEach(() => { - ExtensionLoader.resetInstance(); - }); + const di = getDiForUnitTesting(); - it.only("renderer updates extension after ipc broadcast", async (done) => { - const extensionLoader = ExtensionLoader.createInstance(); + extensionLoader = di.inject(extensionLoaderInjectable); + }); + it.only("renderer updates extension after ipc broadcast", async done => { expect(extensionLoader.userExtensions).toMatchInlineSnapshot(`Map {}`); await extensionLoader.init(); @@ -178,8 +182,6 @@ describe("ExtensionLoader", () => { // Disable sending events in this test (ipcRenderer.on as any).mockImplementation(); - const extensionLoader = ExtensionLoader.createInstance(); - await extensionLoader.init(); expect(ExtensionsStore.getInstance().mergeState).not.toHaveBeenCalled(); @@ -194,6 +196,7 @@ describe("ExtensionLoader", () => { "manifest/path2": { enabled: true, name: "TestExtension2", - }}); + }, + }); }); }); diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index dbaeb0142db7..d45c8bde8096 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -32,7 +32,7 @@ import logger from "../main/logger"; import { ExtensionInstallationStateStore } from "../renderer/components/+extensions/extension-install.store"; import { extensionInstaller } from "./extension-installer"; import { ExtensionsStore } from "./extensions-store"; -import { ExtensionLoader } from "./extension-loader"; +import type { ExtensionLoader } from "./extension-loader"; import type { LensExtensionId, LensExtensionManifest } from "./lens-extension"; import { isProduction } from "../common/vars"; import { isCompatibleBundledExtension, isCompatibleExtension } from "./extension-compatibility"; @@ -99,7 +99,7 @@ export class ExtensionDiscovery extends Singleton { public events = new EventEmitter(); - constructor() { + constructor(protected extensionLoader: ExtensionLoader) { super(); makeObservable(this); @@ -277,7 +277,7 @@ export class ExtensionDiscovery extends Singleton { * @param extensionId The ID of the extension to uninstall. */ async uninstallExtension(extensionId: LensExtensionId): Promise { - const { manifest, absolutePath } = this.extensions.get(extensionId) ?? ExtensionLoader.getInstance().getExtension(extensionId); + const { manifest, absolutePath } = this.extensions.get(extensionId) ?? this.extensionLoader.getExtension(extensionId); logger.info(`${logModule} Uninstalling ${manifest.name}`); diff --git a/src/extensions/extension-loader/index.ts b/src/extensions/extension-loader/index.ts index 283fb3b5ee0f..2c29f9dbf681 100644 --- a/src/extensions/extension-loader/index.ts +++ b/src/extensions/extension-loader/index.ts @@ -18,12 +18,5 @@ * 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 { getLegacySingleton } from "../../common/di-kludge/get-legacy-singleton/get-legacy-singleton"; -import extensionLoaderInjectable from "./extension-loader.injectable"; export * from "./extension-loader"; - -/** - * @deprecated Switch to using di.inject(extensionLoaderInjectable) - */ -export const ExtensionLoader = getLegacySingleton(extensionLoaderInjectable); diff --git a/src/extensions/extensions.injectable.ts b/src/extensions/extensions.injectable.ts index 9bd1bfae66e1..f05493bbbfd8 100644 --- a/src/extensions/extensions.injectable.ts +++ b/src/extensions/extensions.injectable.ts @@ -21,15 +21,15 @@ import { Injectable, lifecycleEnum } from "@ogre-tools/injectable"; import { computed, IComputedValue } from "mobx"; import type { LensExtension } from "./lens-extension"; -import { ExtensionLoader } from "./extension-loader"; -import type { ExtensionLoader as ExtensionLoaderType } from "./extension-loader/extension-loader"; +import type { ExtensionLoader } from "./extension-loader"; +import extensionLoaderInjectable from "./extension-loader/extension-loader.injectable"; const extensionsInjectable: Injectable< IComputedValue, - { extensionLoader: ExtensionLoaderType } + { extensionLoader: ExtensionLoader } > = { - getDependencies: () => ({ - extensionLoader: ExtensionLoader.getInstance(), + getDependencies: di => ({ + extensionLoader: di.inject(extensionLoaderInjectable), }), lifecycle: lifecycleEnum.singleton, diff --git a/src/extensions/getDiForUnitTesting.ts b/src/extensions/getDiForUnitTesting.ts new file mode 100644 index 000000000000..4dfd9912711d --- /dev/null +++ b/src/extensions/getDiForUnitTesting.ts @@ -0,0 +1,57 @@ +/** + * 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 glob from "glob"; +import { memoize } from "lodash/fp"; + +import { + createContainer, + ConfigurableDependencyInjectionContainer, +} from "@ogre-tools/injectable"; +import { setDiKludge } from "../common/di-kludge/di-kludge"; + +export const getDiForUnitTesting = () => { + const di: ConfigurableDependencyInjectionContainer = createContainer(); + + setDiKludge(di); + + getInjectableFilePaths() + .map(key => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const injectable = require(key).default; + + return { + id: key, + ...injectable, + aliases: [injectable, ...(injectable.aliases || [])], + }; + }) + + .forEach(injectable => di.register(injectable)); + + di.preventSideEffects(); + + return di; +}; + +const getInjectableFilePaths = memoize(() => [ + ...glob.sync("./**/*.injectable.{ts,tsx}", { cwd: __dirname }), +]); diff --git a/src/main/index.ts b/src/main/index.ts index d58d0ca931e0..2f66fb4fbd3a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -36,11 +36,9 @@ import { mangleProxyEnv } from "./proxy-env"; import { registerFileProtocol } from "../common/register-protocol"; import logger from "./logger"; import { appEventBus } from "../common/event-bus"; -import { ExtensionLoader } from "../extensions/extension-loader"; import { InstalledExtension, ExtensionDiscovery } from "../extensions/extension-discovery"; import type { LensExtensionId } from "../extensions/lens-extension"; import { installDeveloperTools } from "./developer-tools"; -import { LensProtocolRouterMain } from "./protocol-handler"; import { disposer, getAppVersion, getAppVersionFromProxyServer, storedKubeConfigFolder } from "../common/utils"; import { bindBroadcastHandlers, ipcMainOn } from "../common/ipc"; import { startUpdateChecking } from "./app-updater"; @@ -68,6 +66,8 @@ import { AppPaths } from "../common/app-paths"; import { ShellSession } from "./shell-session/shell-session"; import { getDi } from "./getDi"; import electronMenuItemsInjectable from "./menu/electron-menu-items.injectable"; +import extensionLoaderInjectable from "../extensions/extension-loader/extension-loader.injectable"; +import lensProtocolRouterMainInjectable from "./protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable"; const di = getDi(); @@ -79,7 +79,6 @@ const onQuitCleanup = disposer(); SentryInit(); app.setName(appName); - logger.info(`📟 Setting ${productName} as protocol client for lens://`); if (app.setAsDefaultProtocolClient("lens")) { @@ -111,14 +110,14 @@ if (app.commandLine.getSwitchValue("proxy-server") !== "") { logger.debug("[APP-MAIN] Lens protocol routing main"); +const lensProtocolRouterMain = di.inject(lensProtocolRouterMainInjectable); + if (!app.requestSingleInstanceLock()) { app.exit(); } else { - const lprm = LensProtocolRouterMain.createInstance(); - for (const arg of process.argv) { if (arg.toLowerCase().startsWith("lens://")) { - lprm.route(arg); + lensProtocolRouterMain.route(arg); } } } @@ -126,11 +125,9 @@ if (!app.requestSingleInstanceLock()) { app.on("second-instance", (event, argv) => { logger.debug("second-instance message"); - const lprm = LensProtocolRouterMain.createInstance(); - for (const arg of argv) { if (arg.toLowerCase().startsWith("lens://")) { - lprm.route(arg); + lensProtocolRouterMain.route(arg); } } @@ -227,9 +224,12 @@ app.on("ready", async () => { return app.exit(); } - const extensionDiscovery = ExtensionDiscovery.createInstance(); + const extensionLoader = di.inject(extensionLoaderInjectable); + + extensionLoader.init(); + + const extensionDiscovery = ExtensionDiscovery.createInstance(extensionLoader); - ExtensionLoader.createInstance().init(); extensionDiscovery.init(); // Start the app without showing the main window when auto starting on login @@ -258,7 +258,7 @@ app.on("ready", async () => { await ensureDir(storedKubeConfigFolder()); KubeconfigSyncManager.getInstance().startSync(); startUpdateChecking(); - LensProtocolRouterMain.getInstance().rendererLoaded = true; + lensProtocolRouterMain.rendererLoaded = true; }); logger.info("🧩 Initializing extensions"); @@ -273,13 +273,13 @@ app.on("ready", async () => { // Subscribe to extensions that are copied or deleted to/from the extensions folder extensionDiscovery.events .on("add", (extension: InstalledExtension) => { - ExtensionLoader.getInstance().addExtension(extension); + extensionLoader.addExtension(extension); }) .on("remove", (lensExtensionId: LensExtensionId) => { - ExtensionLoader.getInstance().removeExtension(lensExtensionId); + extensionLoader.removeExtension(lensExtensionId); }); - ExtensionLoader.getInstance().initExtensions(extensions); + extensionLoader.initExtensions(extensions); } catch (error) { dialog.showErrorBox("Lens Error", `Could not load extensions${error?.message ? `: ${error.message}` : ""}`); console.error(error); @@ -314,7 +314,6 @@ app.on("will-quit", (event) => { // This is called when the close button of the main window is clicked - const lprm = LensProtocolRouterMain.getInstance(false); logger.info("APP:QUIT"); appEventBus.emit({ name: "app", action: "close" }); @@ -322,11 +321,9 @@ app.on("will-quit", (event) => { KubeconfigSyncManager.getInstance(false)?.stopSync(); onCloseCleanup(); - if (lprm) { - // This is set to false here so that LPRM can wait to send future lens:// - // requests until after it loads again - lprm.rendererLoaded = false; - } + // This is set to false here so that LPRM can wait to send future lens:// + // requests until after it loads again + lensProtocolRouterMain.rendererLoaded = false; if (blockQuit) { // Quit app on Cmd+Q (MacOS) @@ -336,7 +333,7 @@ app.on("will-quit", (event) => { return; // skip exit to make tray work, to quit go to app's global menu or tray's menu } - lprm?.cleanup(); + lensProtocolRouterMain.cleanup(); onQuitCleanup(); }); @@ -345,7 +342,7 @@ app.on("open-url", (event, rawUrl) => { // lens:// protocol handler event.preventDefault(); - LensProtocolRouterMain.getInstance().route(rawUrl); + lensProtocolRouterMain.route(rawUrl); }); /** diff --git a/src/main/protocol-handler/__test__/router.test.ts b/src/main/protocol-handler/__test__/router.test.ts index 9e73eff5593b..1d17625b6d6e 100644 --- a/src/main/protocol-handler/__test__/router.test.ts +++ b/src/main/protocol-handler/__test__/router.test.ts @@ -25,11 +25,15 @@ import { broadcastMessage } from "../../../common/ipc"; import { ProtocolHandlerExtension, ProtocolHandlerInternal } from "../../../common/protocol-handler"; import { delay, noop } from "../../../common/utils"; import { LensExtension } from "../../../extensions/main-api"; -import { ExtensionLoader } from "../../../extensions/extension-loader"; import { ExtensionsStore } from "../../../extensions/extensions-store"; -import { LensProtocolRouterMain } from "../router"; +import type { LensProtocolRouterMain } from "../lens-protocol-router-main/lens-protocol-router-main"; import mockFs from "mock-fs"; import { AppPaths } from "../../../common/app-paths"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import extensionLoaderInjectable + from "../../../extensions/extension-loader/extension-loader.injectable"; +import lensProtocolRouterMainInjectable + from "../lens-protocol-router-main/lens-protocol-router-main.injectable"; jest.mock("../../../common/ipc"); @@ -58,14 +62,22 @@ function throwIfDefined(val: any): void { } describe("protocol router tests", () => { + // TODO: This test suite is using any to access protected property. + // Unit tests are allowed to only public interfaces. + let extensionLoader: any; + let lpr: LensProtocolRouterMain; + beforeEach(() => { + const di = getDiForUnitTesting(); + + extensionLoader = di.inject(extensionLoaderInjectable); + mockFs({ "tmp": {}, }); ExtensionsStore.createInstance(); - ExtensionLoader.createInstance(); - const lpr = LensProtocolRouterMain.createInstance(); + lpr = di.inject(lensProtocolRouterMainInjectable); lpr.rendererLoaded = true; }); @@ -74,15 +86,11 @@ describe("protocol router tests", () => { jest.clearAllMocks(); ExtensionsStore.resetInstance(); - ExtensionLoader.resetInstance(); - LensProtocolRouterMain.resetInstance(); mockFs.restore(); }); it("should throw on non-lens URLS", async () => { try { - const lpr = LensProtocolRouterMain.getInstance(); - expect(await lpr.route("https://google.ca")).toBeUndefined(); } catch (error) { expect(error).toBeInstanceOf(Error); @@ -91,8 +99,6 @@ describe("protocol router tests", () => { it("should throw when host not internal or extension", async () => { try { - const lpr = LensProtocolRouterMain.getInstance(); - expect(await lpr.route("lens://foobar")).toBeUndefined(); } catch (error) { expect(error).toBeInstanceOf(Error); @@ -113,14 +119,13 @@ describe("protocol router tests", () => { isCompatible: true, absolutePath: "/foo/bar", }); - const lpr = LensProtocolRouterMain.getInstance(); ext.protocolHandlers.push({ pathSchema: "/", handler: noop, }); - (ExtensionLoader.getInstance() as any).instances.set(extId, ext); + extensionLoader.instances.set(extId, ext); (ExtensionsStore.getInstance() as any).state.set(extId, { enabled: true, name: "@mirantis/minikube" }); lpr.addInternalHandler("/", noop); @@ -143,7 +148,6 @@ describe("protocol router tests", () => { }); it("should call handler if matches", async () => { - const lpr = LensProtocolRouterMain.getInstance(); let called = false; lpr.addInternalHandler("/page", () => { called = true; }); @@ -159,7 +163,6 @@ describe("protocol router tests", () => { }); it("should call most exact handler", async () => { - const lpr = LensProtocolRouterMain.getInstance(); let called: any = 0; lpr.addInternalHandler("/page", () => { called = 1; }); @@ -178,7 +181,6 @@ describe("protocol router tests", () => { it("should call most exact handler for an extension", async () => { let called: any = 0; - const lpr = LensProtocolRouterMain.getInstance(); const extId = uuid.v4(); const ext = new LensExtension({ id: extId, @@ -202,7 +204,7 @@ describe("protocol router tests", () => { handler: params => { called = params.pathname.id; }, }); - (ExtensionLoader.getInstance() as any).instances.set(extId, ext); + extensionLoader.instances.set(extId, ext); (ExtensionsStore.getInstance() as any).state.set(extId, { enabled: true, name: "@foobar/icecream" }); try { @@ -217,7 +219,6 @@ describe("protocol router tests", () => { }); it("should work with non-org extensions", async () => { - const lpr = LensProtocolRouterMain.getInstance(); let called: any = 0; { @@ -241,7 +242,7 @@ describe("protocol router tests", () => { handler: params => { called = params.pathname.id; }, }); - (ExtensionLoader.getInstance() as any).instances.set(extId, ext); + extensionLoader.instances.set(extId, ext); (ExtensionsStore.getInstance() as any).state.set(extId, { enabled: true, name: "@foobar/icecream" }); } @@ -266,7 +267,7 @@ describe("protocol router tests", () => { handler: () => { called = 1; }, }); - (ExtensionLoader.getInstance() as any).instances.set(extId, ext); + extensionLoader.instances.set(extId, ext); (ExtensionsStore.getInstance() as any).state.set(extId, { enabled: true, name: "icecream" }); } @@ -286,13 +287,10 @@ describe("protocol router tests", () => { }); it("should throw if urlSchema is invalid", () => { - const lpr = LensProtocolRouterMain.getInstance(); - expect(() => lpr.addInternalHandler("/:@", noop)).toThrowError(); }); it("should call most exact handler with 3 found handlers", async () => { - const lpr = LensProtocolRouterMain.getInstance(); let called: any = 0; lpr.addInternalHandler("/", () => { called = 2; }); @@ -311,7 +309,6 @@ describe("protocol router tests", () => { }); it("should call most exact handler with 2 found handlers", async () => { - const lpr = LensProtocolRouterMain.getInstance(); let called: any = 0; lpr.addInternalHandler("/", () => { called = 2; }); diff --git a/src/main/protocol-handler/index.ts b/src/main/protocol-handler/index.ts index dd14b3c335d5..448da6572f5c 100644 --- a/src/main/protocol-handler/index.ts +++ b/src/main/protocol-handler/index.ts @@ -19,4 +19,4 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -export * from "./router"; +export * from "./lens-protocol-router-main/lens-protocol-router-main"; diff --git a/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable.ts b/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable.ts new file mode 100644 index 000000000000..25b4ac37056f --- /dev/null +++ b/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable.ts @@ -0,0 +1,40 @@ +/** + * 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 { Injectable } from "@ogre-tools/injectable"; +import { lifecycleEnum } from "@ogre-tools/injectable"; +import extensionLoaderInjectable from "../../../extensions/extension-loader/extension-loader.injectable"; +import type { Dependencies } from "./lens-protocol-router-main"; +import { LensProtocolRouterMain } from "./lens-protocol-router-main"; + +const lensProtocolRouterMainInjectable: Injectable< + LensProtocolRouterMain, + Dependencies +> = { + getDependencies: di => ({ + extensionLoader: di.inject(extensionLoaderInjectable), + }), + + instantiate: dependencies => new LensProtocolRouterMain(dependencies), + + lifecycle: lifecycleEnum.singleton, +}; + +export default lensProtocolRouterMainInjectable; diff --git a/src/main/protocol-handler/router.ts b/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.ts similarity index 88% rename from src/main/protocol-handler/router.ts rename to src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.ts index ffcfd49d3ef8..03d3df9d89a3 100644 --- a/src/main/protocol-handler/router.ts +++ b/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.ts @@ -19,15 +19,16 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import logger from "../logger"; -import * as proto from "../../common/protocol-handler"; +import logger from "../../logger"; +import * as proto from "../../../common/protocol-handler"; import URLParse from "url-parse"; -import type { LensExtension } from "../../extensions/lens-extension"; -import { broadcastMessage } from "../../common/ipc"; +import type { LensExtension } from "../../../extensions/lens-extension"; +import { broadcastMessage } from "../../../common/ipc"; import { observable, when, makeObservable } from "mobx"; -import { ProtocolHandlerInvalid, RouteAttempt } from "../../common/protocol-handler"; -import { disposer, noop } from "../../common/utils"; -import { WindowManager } from "../window-manager"; +import { ProtocolHandlerInvalid, RouteAttempt } from "../../../common/protocol-handler"; +import { disposer, noop } from "../../../common/utils"; +import { WindowManager } from "../../window-manager"; +import type { ExtensionLoader } from "../../../extensions/extension-loader"; export interface FallbackHandler { (name: string): Promise; @@ -50,6 +51,10 @@ function checkHost(url: URLParse): boolean { } } +export interface Dependencies { + extensionLoader: ExtensionLoader +} + export class LensProtocolRouterMain extends proto.LensProtocolRouter { private missingExtensionHandlers: FallbackHandler[] = []; @@ -57,8 +62,8 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { protected disposers = disposer(); - constructor() { - super(); + constructor(protected dependencies: Dependencies) { + super(dependencies); makeObservable(this); } diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index f42a671de69e..5d1315b1fcce 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -34,7 +34,6 @@ import { isMac, isDevelopment } from "../common/vars"; import { ClusterStore } from "../common/cluster-store"; import { UserStore } from "../common/user-store"; import { ExtensionDiscovery } from "../extensions/extension-discovery"; -import { ExtensionLoader } from "../extensions/extension-loader"; import { HelmRepoManager } from "../main/helm/helm-repo-manager"; import { ExtensionInstallationStateStore } from "./components/+extensions/extension-install.store"; import { DefaultProps } from "./mui-base-theme"; @@ -53,6 +52,13 @@ import { registerCustomThemes } from "./components/monaco-editor"; import { getDi } from "./components/getDi"; import { DiContextProvider } from "@ogre-tools/injectable-react"; import type { DependencyInjectionContainer } from "@ogre-tools/injectable"; +import extensionLoaderInjectable from "../extensions/extension-loader/extension-loader.injectable"; +import type { ExtensionLoader } from "../extensions/extension-loader"; +import bindProtocolAddRouteHandlersInjectable + from "./protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.injectable"; +import type { LensProtocolRouterRenderer } from "./protocol-handler"; +import lensProtocolRouterRendererInjectable + from "./protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.injectable"; if (process.isMainFrame) { SentryInit(); @@ -73,7 +79,14 @@ async function attachChromeDebugger() { } type AppComponent = React.ComponentType & { - init(rootElem: HTMLElement): Promise; + + // TODO: This static method is criminal as it has no direct relation with component + init( + rootElem: HTMLElement, + extensionLoader: ExtensionLoader, + bindProtocolAddRouteHandlers?: () => void, + lensProtocolRouterRendererInjectable?: LensProtocolRouterRenderer + ): Promise; }; export async function bootstrap(comp: () => Promise, di: DependencyInjectionContainer) { @@ -116,14 +129,17 @@ export async function bootstrap(comp: () => Promise, di: Dependenc logger.info(`${logPrefix} initializing Catalog`); initializers.initCatalog(); + const extensionLoader = di.inject(extensionLoaderInjectable); + logger.info(`${logPrefix} initializing IpcRendererListeners`); - initializers.initIpcRendererListeners(); + initializers.initIpcRendererListeners(extensionLoader); logger.info(`${logPrefix} initializing StatusBarRegistry`); initializers.initStatusBarRegistry(); - ExtensionLoader.createInstance().init(); - ExtensionDiscovery.createInstance().init(); + extensionLoader.init(); + + ExtensionDiscovery.createInstance(extensionLoader).init(); // ClusterStore depends on: UserStore const clusterStore = ClusterStore.createInstance(); @@ -151,7 +167,10 @@ export async function bootstrap(comp: () => Promise, di: Dependenc // init app's dependencies if any const App = await comp(); - await App.init(rootElem); + const bindProtocolAddRouteHandlers = di.inject(bindProtocolAddRouteHandlersInjectable); + const lensProtocolRouterRenderer = di.inject(lensProtocolRouterRendererInjectable); + + await App.init(rootElem, extensionLoader, bindProtocolAddRouteHandlers, lensProtocolRouterRenderer); render( diff --git a/src/renderer/cluster-frame.tsx b/src/renderer/cluster-frame.tsx index b5ea54dad062..fe867da04a15 100755 --- a/src/renderer/cluster-frame.tsx +++ b/src/renderer/cluster-frame.tsx @@ -35,7 +35,7 @@ import { isAllowedResource } from "../common/utils/allowed-resource"; import logger from "../main/logger"; import { webFrame } from "electron"; import { ClusterPageRegistry, getExtensionPageUrl } from "../extensions/registries/page-registry"; -import { ExtensionLoader } from "../extensions/extension-loader"; +import type { ExtensionLoader } from "../extensions/extension-loader"; import { appEventBus } from "../common/event-bus"; import { requestMain } from "../common/ipc"; import { clusterSetFrameIdHandler } from "../common/cluster-ipc"; @@ -86,7 +86,7 @@ export class ClusterFrame extends React.Component { makeObservable(this); } - static async init(rootElem: HTMLElement) { + static async init(rootElem: HTMLElement, extensionLoader: ExtensionLoader) { catalogEntityRegistry.init(); const frameId = webFrame.routingId; @@ -101,7 +101,8 @@ export class ClusterFrame extends React.Component { catalogEntityRegistry.activeEntity = ClusterFrame.clusterId; - ExtensionLoader.getInstance().loadOnClusterRenderer(); + extensionLoader.loadOnClusterRenderer(); + setTimeout(() => { appEventBus.emit({ name: "cluster", diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx index d2ec4906ab1e..284a05fee504 100644 --- a/src/renderer/components/+extensions/__tests__/extensions.test.tsx +++ b/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -25,13 +25,16 @@ import fse from "fs-extra"; import React from "react"; import { UserStore } from "../../../../common/user-store"; import { ExtensionDiscovery } from "../../../../extensions/extension-discovery"; -import { ExtensionLoader } from "../../../../extensions/extension-loader"; +import type { ExtensionLoader } from "../../../../extensions/extension-loader"; import { ConfirmDialog } from "../../confirm-dialog"; import { ExtensionInstallationStateStore } from "../extension-install.store"; import { Extensions } from "../extensions"; import mockFs from "mock-fs"; import { mockWindow } from "../../../../../__mocks__/windowMock"; import { AppPaths } from "../../../../common/app-paths"; +import extensionLoaderInjectable + from "../../../../extensions/extension-loader/extension-loader.injectable"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; mockWindow(); @@ -73,14 +76,20 @@ jest.mock("electron", () => ({ AppPaths.init(); describe("Extensions", () => { + let extensionLoader: ExtensionLoader; + beforeEach(async () => { + const di = getDiForUnitTesting(); + + extensionLoader = di.inject(extensionLoaderInjectable); + mockFs({ "tmp": {}, }); ExtensionInstallationStateStore.reset(); - ExtensionLoader.createInstance().addExtension({ + extensionLoader.addExtension({ id: "extensionId", manifest: { name: "test", @@ -92,7 +101,11 @@ describe("Extensions", () => { isEnabled: true, isCompatible: true, }); - ExtensionDiscovery.createInstance().uninstallExtension = jest.fn(() => Promise.resolve()); + + const extensionDiscovery = ExtensionDiscovery.createInstance(extensionLoader); + + extensionDiscovery.uninstallExtension = jest.fn(() => Promise.resolve()); + UserStore.createInstance(); }); @@ -100,7 +113,6 @@ describe("Extensions", () => { mockFs.restore(); UserStore.resetInstance(); ExtensionDiscovery.resetInstance(); - ExtensionLoader.resetInstance(); }); it("disables uninstall and disable buttons while uninstalling", async () => { diff --git a/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.injectable.ts b/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.injectable.ts new file mode 100644 index 000000000000..0db5c1677c5f --- /dev/null +++ b/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.injectable.ts @@ -0,0 +1,35 @@ +/** + * 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 { Injectable } from "@ogre-tools/injectable"; +import { lifecycleEnum } from "@ogre-tools/injectable"; +import { attemptInstallByInfo, ExtensionInfo } from "./attempt-install-by-info"; +import attemptInstallInjectable from "../attempt-install/attempt-install.injectable"; + +const attemptInstallByInfoInjectable: Injectable<(extensionInfo: ExtensionInfo) => Promise, {}> = { + getDependencies: di => ({ + attemptInstall: di.inject(attemptInstallInjectable), + }), + + instantiate: attemptInstallByInfo, + lifecycle: lifecycleEnum.singleton, +}; + +export default attemptInstallByInfoInjectable; diff --git a/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.tsx b/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.tsx new file mode 100644 index 000000000000..bc5f54c14737 --- /dev/null +++ b/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.tsx @@ -0,0 +1,123 @@ +/** + * 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 { ExtensionInstallationStateStore } from "../extension-install.store"; +import { downloadFile, downloadJson, ExtendableDisposer } from "../../../../common/utils"; +import { Notifications } from "../../notifications"; +import { ConfirmDialog } from "../../confirm-dialog"; +import React from "react"; +import path from "path"; +import { SemVer } from "semver"; +import URLParse from "url-parse"; +import type { InstallRequest } from "../attempt-install/install-request"; +import lodash from "lodash"; + +export interface ExtensionInfo { + name: string; + version?: string; + requireConfirmation?: boolean; +} + +export interface Dependencies { + attemptInstall: (request: InstallRequest, d: ExtendableDisposer) => Promise +} + +export const attemptInstallByInfo = ({ attemptInstall }: Dependencies) => async ({ + name, + version, + requireConfirmation = false, +}: ExtensionInfo) => { + const disposer = ExtensionInstallationStateStore.startPreInstall(); + const registryUrl = new URLParse("https://registry.npmjs.com") + .set("pathname", name) + .toString(); + const { promise } = downloadJson({ url: registryUrl }); + const json = await promise.catch(console.error); + + if ( + !json || + json.error || + typeof json.versions !== "object" || + !json.versions + ) { + const message = json?.error ? `: ${json.error}` : ""; + + Notifications.error( + `Failed to get registry information for that extension${message}`, + ); + + return disposer(); + } + + if (version) { + if (!json.versions[version]) { + if (json["dist-tags"][version]) { + version = json["dist-tags"][version]; + } else { + Notifications.error( +

+ The {name} extension does not have a version or tag{" "} + {version}. +

, + ); + + return disposer(); + } + } + } else { + const versions = Object.keys(json.versions) + .map( + version => + new SemVer(version, { loose: true, includePrerelease: true }), + ) + // ignore pre-releases for auto picking the version + .filter(version => version.prerelease.length === 0); + + version = lodash.reduce(versions, (prev, curr) => + prev.compareMain(curr) === -1 ? curr : prev, + ).format(); + } + + if (requireConfirmation) { + const proceed = await ConfirmDialog.confirm({ + message: ( +

+ Are you sure you want to install{" "} + + {name}@{version} + + ? +

+ ), + labelCancel: "Cancel", + labelOk: "Install", + }); + + if (!proceed) { + return disposer(); + } + } + + const url = json.versions[version].dist.tarball; + const fileName = path.basename(url); + const { promise: dataP } = downloadFile({ url, timeout: 10 * 60 * 1000 }); + + return attemptInstall({ fileName, dataP }, disposer); +}; diff --git a/src/renderer/components/+extensions/attempt-install/attempt-install.injectable.ts b/src/renderer/components/+extensions/attempt-install/attempt-install.injectable.ts new file mode 100644 index 000000000000..bb989df45445 --- /dev/null +++ b/src/renderer/components/+extensions/attempt-install/attempt-install.injectable.ts @@ -0,0 +1,46 @@ +/** + * 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 { Injectable } from "@ogre-tools/injectable"; +import { lifecycleEnum } from "@ogre-tools/injectable"; + +import type { ExtendableDisposer } from "../../../../common/utils"; +import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable"; +import uninstallExtensionInjectable from "../uninstall-extension/uninstall-extension.injectable"; +import type { Dependencies } from "./attempt-install"; +import { attemptInstall } from "./attempt-install"; +import type { InstallRequest } from "./install-request"; +import unpackExtensionInjectable from "./unpack-extension/unpack-extension.injectable"; + +const attemptInstallInjectable: Injectable< + (request: InstallRequest, d?: ExtendableDisposer) => Promise, + Dependencies +> = { + getDependencies: di => ({ + extensionLoader: di.inject(extensionLoaderInjectable), + uninstallExtension: di.inject(uninstallExtensionInjectable), + unpackExtension: di.inject(unpackExtensionInjectable), + }), + + instantiate: attemptInstall, + lifecycle: lifecycleEnum.singleton, +}; + +export default attemptInstallInjectable; diff --git a/src/renderer/components/+extensions/attempt-install/attempt-install.tsx b/src/renderer/components/+extensions/attempt-install/attempt-install.tsx new file mode 100644 index 000000000000..a5526ef22049 --- /dev/null +++ b/src/renderer/components/+extensions/attempt-install/attempt-install.tsx @@ -0,0 +1,138 @@ +/** + * 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 { + Disposer, + disposer, + ExtendableDisposer, +} from "../../../../common/utils"; +import { + ExtensionInstallationState, + ExtensionInstallationStateStore, +} from "../extension-install.store"; +import { Notifications } from "../../notifications"; +import { Button } from "../../button"; +import type { ExtensionLoader } from "../../../../extensions/extension-loader"; +import type { LensExtensionId } from "../../../../extensions/lens-extension"; +import React from "react"; +import fse from "fs-extra"; +import { shell } from "electron"; +import { + createTempFilesAndValidate, + InstallRequestValidated, +} from "./create-temp-files-and-validate/create-temp-files-and-validate"; +import { getExtensionDestFolder } from "./get-extension-dest-folder/get-extension-dest-folder"; +import type { InstallRequest } from "./install-request"; + +export interface Dependencies { + extensionLoader: ExtensionLoader; + uninstallExtension: (id: LensExtensionId) => Promise; + unpackExtension: ( + request: InstallRequestValidated, + disposeDownloading: Disposer, + ) => Promise; +} + +export const attemptInstall = + ({ extensionLoader, uninstallExtension, unpackExtension }: Dependencies) => + async (request: InstallRequest, d?: ExtendableDisposer): Promise => { + const dispose = disposer( + ExtensionInstallationStateStore.startPreInstall(), + d, + ); + + const validatedRequest = await createTempFilesAndValidate(request); + + if (!validatedRequest) { + return dispose(); + } + + const { name, version, description } = validatedRequest.manifest; + const curState = ExtensionInstallationStateStore.getInstallationState( + validatedRequest.id, + ); + + if (curState !== ExtensionInstallationState.IDLE) { + dispose(); + + return void Notifications.error( +
+ Extension Install Collision: +

+ The {name} extension is currently {curState.toLowerCase()}. +

+

Will not proceed with this current install request.

+
, + ); + } + + const extensionFolder = getExtensionDestFolder(name); + const folderExists = await fse.pathExists(extensionFolder); + + if (!folderExists) { + // install extension if not yet exists + await unpackExtension(validatedRequest, dispose); + } else { + const { + manifest: { version: oldVersion }, + } = extensionLoader.getExtension(validatedRequest.id); + + // otherwise confirmation required (re-install / update) + const removeNotification = Notifications.info( +
+
+

+ Install extension{" "} + + {name}@{version} + + ? +

+

+ Description: {description} +

+
shell.openPath(extensionFolder)} + > + Warning: {name}@{oldVersion} will be removed before + installation. +
+
+
, + { + onClose: dispose, + }, + ); + } + }; diff --git a/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate/create-temp-files-and-validate.tsx b/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate/create-temp-files-and-validate.tsx new file mode 100644 index 000000000000..e692ace784e5 --- /dev/null +++ b/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate/create-temp-files-and-validate.tsx @@ -0,0 +1,101 @@ +/** + * 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 { validatePackage } from "../validate-package/validate-package"; +import { ExtensionDiscovery } from "../../../../../extensions/extension-discovery"; +import { getMessageFromError } from "../../get-message-from-error/get-message-from-error"; +import logger from "../../../../../main/logger"; +import { Notifications } from "../../../notifications"; +import path from "path"; +import fse from "fs-extra"; +import React from "react"; +import os from "os"; +import type { + LensExtensionId, + LensExtensionManifest, +} from "../../../../../extensions/lens-extension"; +import type { InstallRequest } from "../install-request"; + +export interface InstallRequestValidated { + fileName: string; + data: Buffer; + id: LensExtensionId; + manifest: LensExtensionManifest; + tempFile: string; // temp system path to packed extension for unpacking +} + +export async function createTempFilesAndValidate({ + fileName, + dataP, +}: InstallRequest): Promise { + // copy files to temp + await fse.ensureDir(getExtensionPackageTemp()); + + // validate packages + const tempFile = getExtensionPackageTemp(fileName); + + try { + const data = await dataP; + + if (!data) { + return null; + } + + await fse.writeFile(tempFile, data); + const manifest = await validatePackage(tempFile); + const id = path.join( + ExtensionDiscovery.getInstance().nodeModulesPath, + manifest.name, + "package.json", + ); + + return { + fileName, + data, + manifest, + tempFile, + id, + }; + } catch (error) { + const message = getMessageFromError(error); + + logger.info( + `[EXTENSION-INSTALLATION]: installing ${fileName} has failed: ${message}`, + { error }, + ); + Notifications.error( +
+

+ Installing {fileName} has failed, skipping. +

+

+ Reason: {message} +

+
, + ); + } + + return null; +} + + +function getExtensionPackageTemp(fileName = "") { + return path.join(os.tmpdir(), "lens-extensions", fileName); +} diff --git a/src/renderer/components/+extensions/attempt-install/get-extension-dest-folder/get-extension-dest-folder.tsx b/src/renderer/components/+extensions/attempt-install/get-extension-dest-folder/get-extension-dest-folder.tsx new file mode 100644 index 000000000000..141b6d54ecfa --- /dev/null +++ b/src/renderer/components/+extensions/attempt-install/get-extension-dest-folder/get-extension-dest-folder.tsx @@ -0,0 +1,28 @@ +/** + * 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 { ExtensionDiscovery } from "../../../../../extensions/extension-discovery"; +import { sanitizeExtensionName } from "../../../../../extensions/lens-extension"; +import path from "path"; + +export const getExtensionDestFolder = (name: string) => path.join( + ExtensionDiscovery.getInstance().localFolderPath, + sanitizeExtensionName(name), +); diff --git a/src/renderer/components/+extensions/attempt-install/install-request.d.ts b/src/renderer/components/+extensions/attempt-install/install-request.d.ts new file mode 100644 index 000000000000..3c510d327802 --- /dev/null +++ b/src/renderer/components/+extensions/attempt-install/install-request.d.ts @@ -0,0 +1,4 @@ +export interface InstallRequest { + fileName: string; + dataP: Promise; +} diff --git a/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.injectable.tsx b/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.injectable.tsx new file mode 100644 index 000000000000..7bdb2bc64a92 --- /dev/null +++ b/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.injectable.tsx @@ -0,0 +1,43 @@ +/** + * 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 { Injectable } from "@ogre-tools/injectable"; +import { lifecycleEnum } from "@ogre-tools/injectable"; +import { Dependencies, unpackExtension } from "./unpack-extension"; +import type { InstallRequestValidated } from "../create-temp-files-and-validate/create-temp-files-and-validate"; +import type { Disposer } from "../../../../../common/utils"; +import extensionLoaderInjectable from "../../../../../extensions/extension-loader/extension-loader.injectable"; + +const unpackExtensionInjectable: Injectable< + ( + request: InstallRequestValidated, + disposeDownloading?: Disposer, + ) => Promise, + Dependencies +> = { + getDependencies: di => ({ + extensionLoader: di.inject(extensionLoaderInjectable), + }), + + instantiate: unpackExtension, + lifecycle: lifecycleEnum.singleton, +}; + +export default unpackExtensionInjectable; diff --git a/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.tsx b/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.tsx new file mode 100644 index 000000000000..54bc672356e7 --- /dev/null +++ b/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.tsx @@ -0,0 +1,112 @@ +/** + * 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 { InstallRequestValidated } from "../create-temp-files-and-validate/create-temp-files-and-validate"; +import { Disposer, extractTar, noop } from "../../../../../common/utils"; +import { ExtensionInstallationStateStore } from "../../extension-install.store"; +import { extensionDisplayName } from "../../../../../extensions/lens-extension"; +import logger from "../../../../../main/logger"; +import type { ExtensionLoader } from "../../../../../extensions/extension-loader"; +import { Notifications } from "../../../notifications"; +import { getMessageFromError } from "../../get-message-from-error/get-message-from-error"; +import { getExtensionDestFolder } from "../get-extension-dest-folder/get-extension-dest-folder"; +import path from "path"; +import fse from "fs-extra"; +import { when } from "mobx"; +import React from "react"; + +export interface Dependencies { + extensionLoader: ExtensionLoader +} + +export const unpackExtension = ({ extensionLoader }: Dependencies) => async ( + request: InstallRequestValidated, + disposeDownloading?: Disposer, +) => { + const { + id, + fileName, + tempFile, + manifest: { name, version }, + } = request; + + ExtensionInstallationStateStore.setInstalling(id); + disposeDownloading?.(); + + const displayName = extensionDisplayName(name, version); + const extensionFolder = getExtensionDestFolder(name); + const unpackingTempFolder = path.join( + path.dirname(tempFile), + `${path.basename(tempFile)}-unpacked`, + ); + + logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile }); + + try { + // extract to temp folder first + await fse.remove(unpackingTempFolder).catch(noop); + await fse.ensureDir(unpackingTempFolder); + await extractTar(tempFile, { cwd: unpackingTempFolder }); + + // move contents to extensions folder + const unpackedFiles = await fse.readdir(unpackingTempFolder); + let unpackedRootFolder = unpackingTempFolder; + + if (unpackedFiles.length === 1) { + // check if %extension.tgz was packed with single top folder, + // e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball + unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]); + } + + await fse.ensureDir(extensionFolder); + await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true }); + + // wait for the loader has actually install it + await when(() => extensionLoader.userExtensions.has(id)); + + // Enable installed extensions by default. + extensionLoader.setIsEnabled(id, true); + + Notifications.ok( +

+ Extension {displayName} successfully installed! +

, + ); + } catch (error) { + const message = getMessageFromError(error); + + logger.info( + `[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`, + { error }, + ); + Notifications.error( +

+ Installing extension {displayName} has failed: {message} +

, + ); + } finally { + // Remove install state once finished + ExtensionInstallationStateStore.clearInstalling(id); + + // clean up + fse.remove(unpackingTempFolder).catch(noop); + fse.unlink(tempFile).catch(noop); + } +}; diff --git a/src/renderer/components/+extensions/attempt-install/validate-package/validate-package.tsx b/src/renderer/components/+extensions/attempt-install/validate-package/validate-package.tsx new file mode 100644 index 000000000000..d50bd7fb9a6a --- /dev/null +++ b/src/renderer/components/+extensions/attempt-install/validate-package/validate-package.tsx @@ -0,0 +1,63 @@ +/** + * 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 { LensExtensionManifest } from "../../../../../extensions/lens-extension"; +import { listTarEntries, readFileFromTar } from "../../../../../common/utils"; +import { manifestFilename } from "../../../../../extensions/extension-discovery"; +import path from "path"; + +export const validatePackage = async ( + filePath: string, +): Promise => { + const tarFiles = await listTarEntries(filePath); + + // tarball from npm contains single root folder "package/*" + const firstFile = tarFiles[0]; + + if (!firstFile) { + throw new Error(`invalid extension bundle, ${manifestFilename} not found`); + } + + const rootFolder = path.normalize(firstFile).split(path.sep)[0]; + const packedInRootFolder = tarFiles.every(entry => + entry.startsWith(rootFolder), + ); + const manifestLocation = packedInRootFolder + ? path.join(rootFolder, manifestFilename) + : manifestFilename; + + if (!tarFiles.includes(manifestLocation)) { + throw new Error(`invalid extension bundle, ${manifestFilename} not found`); + } + + const manifest = await readFileFromTar({ + tarPath: filePath, + filePath: manifestLocation, + parseJson: true, + }); + + if (!manifest.main && !manifest.renderer) { + throw new Error( + `${manifestFilename} must specify "main" and/or "renderer" fields`, + ); + } + + return manifest; +}; diff --git a/src/renderer/components/+extensions/attempt-installs/attempt-installs.injectable.ts b/src/renderer/components/+extensions/attempt-installs/attempt-installs.injectable.ts new file mode 100644 index 000000000000..10e3adbd2750 --- /dev/null +++ b/src/renderer/components/+extensions/attempt-installs/attempt-installs.injectable.ts @@ -0,0 +1,38 @@ +/** + * 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 { Injectable } from "@ogre-tools/injectable"; +import { lifecycleEnum } from "@ogre-tools/injectable"; +import { attemptInstalls, Dependencies } from "./attempt-installs"; +import attemptInstallInjectable from "../attempt-install/attempt-install.injectable"; + +const attemptInstallsInjectable: Injectable< + (filePaths: string[]) => Promise, + Dependencies +> = { + getDependencies: di => ({ + attemptInstall: di.inject(attemptInstallInjectable), + }), + + instantiate: attemptInstalls, + lifecycle: lifecycleEnum.singleton, +}; + +export default attemptInstallsInjectable; diff --git a/src/renderer/components/+extensions/attempt-installs/attempt-installs.ts b/src/renderer/components/+extensions/attempt-installs/attempt-installs.ts new file mode 100644 index 000000000000..4c74ee10ff29 --- /dev/null +++ b/src/renderer/components/+extensions/attempt-installs/attempt-installs.ts @@ -0,0 +1,44 @@ +/** + * 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 { readFileNotify } from "../read-file-notify/read-file-notify"; +import path from "path"; +import type { InstallRequest } from "../attempt-install/install-request"; + +export interface Dependencies { + attemptInstall: (request: InstallRequest) => Promise; +} + +export const attemptInstalls = + ({ attemptInstall }: Dependencies) => + async (filePaths: string[]): Promise => { + const promises: Promise[] = []; + + for (const filePath of filePaths) { + promises.push( + attemptInstall({ + fileName: path.basename(filePath), + dataP: readFileNotify(filePath), + }), + ); + } + + await Promise.allSettled(promises); + }; diff --git a/src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.injectable.ts b/src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.injectable.ts new file mode 100644 index 000000000000..fa82c3090514 --- /dev/null +++ b/src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.injectable.ts @@ -0,0 +1,42 @@ +/** + * 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 { Injectable } from "@ogre-tools/injectable"; +import { lifecycleEnum } from "@ogre-tools/injectable"; +import { + confirmUninstallExtension, + Dependencies, +} from "./confirm-uninstall-extension"; +import type { InstalledExtension } from "../../../../extensions/extension-discovery"; +import uninstallExtensionInjectable from "../uninstall-extension/uninstall-extension.injectable"; + +const confirmUninstallExtensionInjectable: Injectable< + (extension: InstalledExtension) => Promise, + Dependencies +> = { + getDependencies: di => ({ + uninstallExtension: di.inject(uninstallExtensionInjectable), + }), + + instantiate: confirmUninstallExtension, + lifecycle: lifecycleEnum.singleton, +}; + +export default confirmUninstallExtensionInjectable; diff --git a/src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.tsx b/src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.tsx new file mode 100644 index 000000000000..3df8c9a7494d --- /dev/null +++ b/src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.tsx @@ -0,0 +1,51 @@ +/** + * 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 React from "react"; +import type { InstalledExtension } from "../../../../extensions/extension-discovery"; +import type { LensExtensionId } from "../../../../extensions/lens-extension"; +import { extensionDisplayName } from "../../../../extensions/lens-extension"; +import { ConfirmDialog } from "../../confirm-dialog"; + +export interface Dependencies { + uninstallExtension: (id: LensExtensionId) => Promise; +} + +export const confirmUninstallExtension = + ({ uninstallExtension }: Dependencies) => + async (extension: InstalledExtension): Promise => { + const displayName = extensionDisplayName( + extension.manifest.name, + extension.manifest.version, + ); + const confirmed = await ConfirmDialog.confirm({ + message: ( +

+ Are you sure you want to uninstall extension {displayName}? +

+ ), + labelOk: "Yes", + labelCancel: "No", + }); + + if (confirmed) { + await uninstallExtension(extension.id); + } + }; diff --git a/src/renderer/components/+extensions/disable-extension/disable-extension.injectable.ts b/src/renderer/components/+extensions/disable-extension/disable-extension.injectable.ts new file mode 100644 index 000000000000..eefc67980d54 --- /dev/null +++ b/src/renderer/components/+extensions/disable-extension/disable-extension.injectable.ts @@ -0,0 +1,40 @@ +/** + * 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 { Injectable } from "@ogre-tools/injectable"; +import { lifecycleEnum } from "@ogre-tools/injectable"; +import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable"; +import type { LensExtensionId } from "../../../../extensions/lens-extension"; +import { Dependencies, disableExtension } from "./disable-extension"; + +const disableExtensionInjectable: Injectable< + (id: LensExtensionId) => void, + Dependencies +> = { + getDependencies: di => ({ + extensionLoader: di.inject(extensionLoaderInjectable), + }), + + instantiate: disableExtension, + + lifecycle: lifecycleEnum.singleton, +}; + +export default disableExtensionInjectable; diff --git a/src/renderer/components/+extensions/disable-extension/disable-extension.ts b/src/renderer/components/+extensions/disable-extension/disable-extension.ts new file mode 100644 index 000000000000..d01e85ecbcb5 --- /dev/null +++ b/src/renderer/components/+extensions/disable-extension/disable-extension.ts @@ -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 type { LensExtensionId } from "../../../../extensions/lens-extension"; +import type { ExtensionLoader } from "../../../../extensions/extension-loader"; + +export interface Dependencies { + extensionLoader: ExtensionLoader; +} + +export const disableExtension = + ({ extensionLoader }: Dependencies) => + (id: LensExtensionId) => { + const extension = extensionLoader.getExtension(id); + + if (extension) { + extension.isEnabled = false; + } + }; diff --git a/src/renderer/components/+extensions/enable-extension/enable-extension.injectable.ts b/src/renderer/components/+extensions/enable-extension/enable-extension.injectable.ts new file mode 100644 index 000000000000..e3c7833ecb61 --- /dev/null +++ b/src/renderer/components/+extensions/enable-extension/enable-extension.injectable.ts @@ -0,0 +1,40 @@ +/** + * 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 { Injectable } from "@ogre-tools/injectable"; +import { lifecycleEnum } from "@ogre-tools/injectable"; +import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable"; +import type { LensExtensionId } from "../../../../extensions/lens-extension"; +import { Dependencies, enableExtension } from "./enable-extension"; + +const enableExtensionInjectable: Injectable< + (id: LensExtensionId) => void, + Dependencies +> = { + getDependencies: di => ({ + extensionLoader: di.inject(extensionLoaderInjectable), + }), + + instantiate: enableExtension, + + lifecycle: lifecycleEnum.singleton, +}; + +export default enableExtensionInjectable; diff --git a/src/renderer/components/+extensions/enable-extension/enable-extension.ts b/src/renderer/components/+extensions/enable-extension/enable-extension.ts new file mode 100644 index 000000000000..0323dec1940e --- /dev/null +++ b/src/renderer/components/+extensions/enable-extension/enable-extension.ts @@ -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 type { LensExtensionId } from "../../../../extensions/lens-extension"; +import type { ExtensionLoader } from "../../../../extensions/extension-loader"; + +export interface Dependencies { + extensionLoader: ExtensionLoader; +} + +export const enableExtension = + ({ extensionLoader }: Dependencies) => + (id: LensExtensionId) => { + const extension = extensionLoader.getExtension(id); + + if (extension) { + extension.isEnabled = true; + } + }; diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index 5df3573181fd..10438e4aca82 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -20,484 +20,63 @@ */ import "./extensions.scss"; - -import { shell } from "electron"; -import fse from "fs-extra"; -import _ from "lodash"; -import { makeObservable, observable, reaction, when } from "mobx"; +import { + IComputedValue, + makeObservable, + observable, + reaction, + when, +} from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; -import os from "os"; -import path from "path"; import React from "react"; -import { SemVer } from "semver"; -import URLParse from "url-parse"; -import { Disposer, disposer, downloadFile, downloadJson, ExtendableDisposer, extractTar, listTarEntries, noop, readFileFromTar } from "../../../common/utils"; -import { ExtensionDiscovery, InstalledExtension, manifestFilename } from "../../../extensions/extension-discovery"; -import { ExtensionLoader } from "../../../extensions/extension-loader"; -import { extensionDisplayName, LensExtensionId, LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension"; -import logger from "../../../main/logger"; -import { Button } from "../button"; -import { ConfirmDialog } from "../confirm-dialog"; -import { DropFileInput, InputValidators } from "../input"; -import { Notifications } from "../notifications"; -import { ExtensionInstallationState, ExtensionInstallationStateStore } from "./extension-install.store"; +import type { InstalledExtension } from "../../../extensions/extension-discovery"; +import { DropFileInput } from "../input"; +import { ExtensionInstallationStateStore } from "./extension-install.store"; import { Install } from "./install"; import { InstalledExtensions } from "./installed-extensions"; import { Notice } from "./notice"; import { SettingLayout } from "../layout/setting-layout"; import { docsUrl } from "../../../common/vars"; -import { dialog } from "../../remote-helpers"; -import { AppPaths } from "../../../common/app-paths"; - -function getMessageFromError(error: any): string { - if (!error || typeof error !== "object") { - return "an error has occurred"; - } - - if (error.message) { - return String(error.message); - } - - if (error.err) { - return String(error.err); - } - - const rawMessage = String(error); - - if (rawMessage === String({})) { - return "an error has occurred"; - } - - return rawMessage; -} - -interface ExtensionInfo { - name: string; - version?: string; - requireConfirmation?: boolean; -} - -interface InstallRequest { - fileName: string; - dataP: Promise; -} - -interface InstallRequestValidated { - fileName: string; - data: Buffer; - id: LensExtensionId; - manifest: LensExtensionManifest; - tempFile: string; // temp system path to packed extension for unpacking -} - -function setExtensionEnabled(id: LensExtensionId, isEnabled: boolean): void { - const extension = ExtensionLoader.getInstance().getExtension(id); - - if (extension) { - extension.isEnabled = isEnabled; - } -} - -function enableExtension(id: LensExtensionId) { - setExtensionEnabled(id, true); -} - -function disableExtension(id: LensExtensionId) { - setExtensionEnabled(id, false); -} - -async function uninstallExtension(extensionId: LensExtensionId): Promise { - const loader = ExtensionLoader.getInstance(); - const { manifest } = loader.getExtension(extensionId); - const displayName = extensionDisplayName(manifest.name, manifest.version); - - try { - logger.debug(`[EXTENSIONS]: trying to uninstall ${extensionId}`); - ExtensionInstallationStateStore.setUninstalling(extensionId); - - await ExtensionDiscovery.getInstance().uninstallExtension(extensionId); - - // wait for the ExtensionLoader to actually uninstall the extension - await when(() => !loader.userExtensions.has(extensionId)); - - Notifications.ok( -

Extension {displayName} successfully uninstalled!

, - ); - - return true; - } catch (error) { - const message = getMessageFromError(error); - - logger.info(`[EXTENSION-UNINSTALL]: uninstalling ${displayName} has failed: ${error}`, { error }); - Notifications.error(

Uninstalling extension {displayName} has failed: {message}

); - - return false; - } finally { - // Remove uninstall state on uninstall failure - ExtensionInstallationStateStore.clearUninstalling(extensionId); - } -} - -async function confirmUninstallExtension(extension: InstalledExtension): Promise { - const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); - const confirmed = await ConfirmDialog.confirm({ - message:

Are you sure you want to uninstall extension {displayName}?

, - labelOk: "Yes", - labelCancel: "No", - }); - - if (confirmed) { - await uninstallExtension(extension.id); - } -} - -function getExtensionDestFolder(name: string) { - return path.join(ExtensionDiscovery.getInstance().localFolderPath, sanitizeExtensionName(name)); -} - -function getExtensionPackageTemp(fileName = "") { - return path.join(os.tmpdir(), "lens-extensions", fileName); -} - -async function readFileNotify(filePath: string, showError = true): Promise { - try { - return await fse.readFile(filePath); - } catch (error) { - if (showError) { - const message = getMessageFromError(error); - - logger.info(`[EXTENSION-INSTALL]: preloading ${filePath} has failed: ${message}`, { error }); - Notifications.error(`Error while reading "${filePath}": ${message}`); - } - } - - return null; -} - -async function validatePackage(filePath: string): Promise { - const tarFiles = await listTarEntries(filePath); - - // tarball from npm contains single root folder "package/*" - const firstFile = tarFiles[0]; - - if (!firstFile) { - throw new Error(`invalid extension bundle, ${manifestFilename} not found`); - } - - const rootFolder = path.normalize(firstFile).split(path.sep)[0]; - const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder)); - const manifestLocation = packedInRootFolder ? path.join(rootFolder, manifestFilename) : manifestFilename; - - if (!tarFiles.includes(manifestLocation)) { - throw new Error(`invalid extension bundle, ${manifestFilename} not found`); - } - - const manifest = await readFileFromTar({ - tarPath: filePath, - filePath: manifestLocation, - parseJson: true, - }); - - if (!manifest.main && !manifest.renderer) { - throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`); - } - - return manifest; -} - -async function createTempFilesAndValidate({ fileName, dataP }: InstallRequest): Promise { - // copy files to temp - await fse.ensureDir(getExtensionPackageTemp()); - - // validate packages - const tempFile = getExtensionPackageTemp(fileName); - - try { - const data = await dataP; - - if (!data) { - return null; - } - - await fse.writeFile(tempFile, data); - const manifest = await validatePackage(tempFile); - const id = path.join(ExtensionDiscovery.getInstance().nodeModulesPath, manifest.name, "package.json"); - - return { - fileName, - data, - manifest, - tempFile, - id, - }; - } catch (error) { - const message = getMessageFromError(error); - - logger.info(`[EXTENSION-INSTALLATION]: installing ${fileName} has failed: ${message}`, { error }); - Notifications.error( -
-

Installing {fileName} has failed, skipping.

-

Reason: {message}

-
, - ); - } - - return null; -} - -async function unpackExtension(request: InstallRequestValidated, disposeDownloading?: Disposer) { - const { id, fileName, tempFile, manifest: { name, version }} = request; - - ExtensionInstallationStateStore.setInstalling(id); - disposeDownloading?.(); - - const displayName = extensionDisplayName(name, version); - const extensionFolder = getExtensionDestFolder(name); - const unpackingTempFolder = path.join(path.dirname(tempFile), `${path.basename(tempFile)}-unpacked`); - - logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile }); - - try { - // extract to temp folder first - await fse.remove(unpackingTempFolder).catch(noop); - await fse.ensureDir(unpackingTempFolder); - await extractTar(tempFile, { cwd: unpackingTempFolder }); - - // move contents to extensions folder - const unpackedFiles = await fse.readdir(unpackingTempFolder); - let unpackedRootFolder = unpackingTempFolder; - - if (unpackedFiles.length === 1) { - // check if %extension.tgz was packed with single top folder, - // e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball - unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]); - } - - await fse.ensureDir(extensionFolder); - await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true }); - - // wait for the loader has actually install it - await when(() => ExtensionLoader.getInstance().userExtensions.has(id)); - - // Enable installed extensions by default. - ExtensionLoader.getInstance().setIsEnabled(id, true); - - Notifications.ok( -

Extension {displayName} successfully installed!

, - ); - } catch (error) { - const message = getMessageFromError(error); - - logger.info(`[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`, { error }); - Notifications.error(

Installing extension {displayName} has failed: {message}

); - } finally { - // Remove install state once finished - ExtensionInstallationStateStore.clearInstalling(id); - - // clean up - fse.remove(unpackingTempFolder).catch(noop); - fse.unlink(tempFile).catch(noop); - } -} - -export async function attemptInstallByInfo({ name, version, requireConfirmation = false }: ExtensionInfo) { - const disposer = ExtensionInstallationStateStore.startPreInstall(); - const registryUrl = new URLParse("https://registry.npmjs.com").set("pathname", name).toString(); - const { promise } = downloadJson({ url: registryUrl }); - const json = await promise.catch(console.error); - - if (!json || json.error || typeof json.versions !== "object" || !json.versions) { - const message = json?.error ? `: ${json.error}` : ""; - - Notifications.error(`Failed to get registry information for that extension${message}`); - - return disposer(); - } - - if (version) { - if (!json.versions[version]) { - if (json["dist-tags"][version]) { - version = json["dist-tags"][version]; - } else { - Notifications.error(

The {name} extension does not have a version or tag {version}.

); - - return disposer(); - } - } - } else { - const versions = Object.keys(json.versions) - .map(version => new SemVer(version, { loose: true, includePrerelease: true })) - // ignore pre-releases for auto picking the version - .filter(version => version.prerelease.length === 0); - - version = _.reduce(versions, (prev, curr) => ( - prev.compareMain(curr) === -1 - ? curr - : prev - )).format(); - } - - if (requireConfirmation) { - const proceed = await ConfirmDialog.confirm({ - message:

Are you sure you want to install {name}@{version}?

, - labelCancel: "Cancel", - labelOk: "Install", - }); - - if (!proceed) { - return disposer(); - } - } - - const url = json.versions[version].dist.tarball; - const fileName = path.basename(url); - const { promise: dataP } = downloadFile({ url, timeout: 10 * 60 * 1000 }); - - return attemptInstall({ fileName, dataP }, disposer); -} - -async function attemptInstall(request: InstallRequest, d?: ExtendableDisposer): Promise { - const dispose = disposer(ExtensionInstallationStateStore.startPreInstall(), d); - const validatedRequest = await createTempFilesAndValidate(request); - - if (!validatedRequest) { - return dispose(); - } - - const { name, version, description } = validatedRequest.manifest; - const curState = ExtensionInstallationStateStore.getInstallationState(validatedRequest.id); - - if (curState !== ExtensionInstallationState.IDLE) { - dispose(); - - return void Notifications.error( -
- Extension Install Collision: -

The {name} extension is currently {curState.toLowerCase()}.

-

Will not proceed with this current install request.

-
, - ); - } - - const extensionFolder = getExtensionDestFolder(name); - const folderExists = await fse.pathExists(extensionFolder); - - if (!folderExists) { - // install extension if not yet exists - await unpackExtension(validatedRequest, dispose); - } else { - const { manifest: { version: oldVersion }} = ExtensionLoader.getInstance().getExtension(validatedRequest.id); - - // otherwise confirmation required (re-install / update) - const removeNotification = Notifications.info( -
-
-

Install extension {name}@{version}?

-

Description: {description}

-
shell.openPath(extensionFolder)}> - Warning: {name}@{oldVersion} will be removed before installation. -
-
-
, - { - onClose: dispose, - }, - ); - } -} - -async function attemptInstalls(filePaths: string[]): Promise { - const promises: Promise[] = []; - - for (const filePath of filePaths) { - promises.push(attemptInstall({ - fileName: path.basename(filePath), - dataP: readFileNotify(filePath), - })); - } - - await Promise.allSettled(promises); -} - -async function installOnDrop(files: File[]) { - logger.info("Install from D&D"); - await attemptInstalls(files.map(({ path }) => path)); -} - -async function installFromInput(input: string) { - let disposer: ExtendableDisposer | undefined = undefined; - - try { - // fixme: improve error messages for non-tar-file URLs - if (InputValidators.isUrl.validate(input)) { - // install via url - disposer = ExtensionInstallationStateStore.startPreInstall(); - const { promise } = downloadFile({ url: input, timeout: 10 * 60 * 1000 }); - const fileName = path.basename(input); - - await attemptInstall({ fileName, dataP: promise }, disposer); - } else if (InputValidators.isPath.validate(input)) { - // install from system path - const fileName = path.basename(input); - - await attemptInstall({ fileName, dataP: readFileNotify(input) }); - } else if (InputValidators.isExtensionNameInstall.validate(input)) { - const [{ groups: { name, version }}] = [...input.matchAll(InputValidators.isExtensionNameInstallRegex)]; - - await attemptInstallByInfo({ name, version }); - } - } catch (error) { - const message = getMessageFromError(error); - - logger.info(`[EXTENSION-INSTALL]: installation has failed: ${message}`, { error, installPath: input }); - Notifications.error(

Installation has failed: {message}

); - } finally { - disposer?.(); - } -} - -const supportedFormats = ["tar", "tgz"]; - -async function installFromSelectFileDialog() { - const { canceled, filePaths } = await dialog.showOpenDialog({ - defaultPath: AppPaths.get("downloads"), - properties: ["openFile", "multiSelections"], - message: `Select extensions to install (formats: ${supportedFormats.join(", ")}), `, - buttonLabel: "Use configuration", - filters: [ - { name: "tarball", extensions: supportedFormats }, - ], - }); - - if (!canceled) { - await attemptInstalls(filePaths); - } -} +import { withInjectables } from "@ogre-tools/injectable-react"; + +import userExtensionsInjectable from "./user-extensions/user-extensions.injectable"; +import enableExtensionInjectable from "./enable-extension/enable-extension.injectable"; +import disableExtensionInjectable from "./disable-extension/disable-extension.injectable"; +import confirmUninstallExtensionInjectable from "./confirm-uninstall-extension/confirm-uninstall-extension.injectable"; +import installFromInputInjectable from "./install-from-input/install-from-input.injectable"; +import installFromSelectFileDialogInjectable from "./install-from-select-file-dialog/install-from-select-file-dialog.injectable"; +import type { LensExtensionId } from "../../../extensions/lens-extension"; +import installOnDropInjectable from "./install-on-drop/install-on-drop.injectable"; +import { supportedExtensionFormats } from "./supported-extension-formats"; interface Props { + dependencies: { + userExtensions: IComputedValue; + enableExtension: (id: LensExtensionId) => void; + disableExtension: (id: LensExtensionId) => void; + confirmUninstallExtension: (extension: InstalledExtension) => Promise; + installFromInput: (input: string) => Promise; + installFromSelectFileDialog: () => Promise; + installOnDrop: (files: File[]) => Promise; + }; } @observer -export class Extensions extends React.Component { +class NonInjectedExtensions extends React.Component { @observable installPath = ""; constructor(props: Props) { super(props); makeObservable(this); } + + get dependencies() { + return this.props.dependencies; + } componentDidMount() { disposeOnUnmount(this, [ - reaction(() => ExtensionLoader.getInstance().userExtensions.size, (curSize, prevSize) => { + reaction(() => this.dependencies.userExtensions.get().length, (curSize, prevSize) => { if (curSize > prevSize) { disposeOnUnmount(this, [ when(() => !ExtensionInstallationStateStore.anyInstalling, () => this.installPath = ""), @@ -508,10 +87,10 @@ export class Extensions extends React.Component { } render() { - const extensions = Array.from(ExtensionLoader.getInstance().userExtensions.values()); + const userExtensions = this.dependencies.userExtensions.get(); return ( - +

Extensions

@@ -525,20 +104,20 @@ export class Extensions extends React.Component { this.installPath = value} - installFromInput={() => installFromInput(this.installPath)} - installFromSelectFileDialog={installFromSelectFileDialog} + supportedFormats={supportedExtensionFormats} + onChange={value => (this.installPath = value)} + installFromInput={() => this.dependencies.installFromInput(this.installPath)} + installFromSelectFileDialog={this.dependencies.installFromSelectFileDialog} installPath={this.installPath} /> - {extensions.length > 0 &&
} + {userExtensions.length > 0 &&
}
@@ -546,3 +125,21 @@ export class Extensions extends React.Component { ); } } + + +export const Extensions = withInjectables(NonInjectedExtensions, { + getProps: di => ({ + dependencies: { + userExtensions: di.inject(userExtensionsInjectable), + enableExtension: di.inject(enableExtensionInjectable), + disableExtension: di.inject(disableExtensionInjectable), + confirmUninstallExtension: di.inject(confirmUninstallExtensionInjectable), + installFromInput: di.inject(installFromInputInjectable), + installOnDrop: di.inject(installOnDropInjectable), + + installFromSelectFileDialog: di.inject( + installFromSelectFileDialogInjectable, + ), + }, + }), +}); diff --git a/src/renderer/components/+extensions/get-message-from-error/get-message-from-error.ts b/src/renderer/components/+extensions/get-message-from-error/get-message-from-error.ts new file mode 100644 index 000000000000..b6130d39d958 --- /dev/null +++ b/src/renderer/components/+extensions/get-message-from-error/get-message-from-error.ts @@ -0,0 +1,41 @@ +/** + * 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 function getMessageFromError(error: any): string { + if (!error || typeof error !== "object") { + return "an error has occurred"; + } + + if (error.message) { + return String(error.message); + } + + if (error.err) { + return String(error.err); + } + + const rawMessage = String(error); + + if (rawMessage === String({})) { + return "an error has occurred"; + } + + return rawMessage; +} diff --git a/src/renderer/components/+extensions/install-from-input/install-from-input.injectable.ts b/src/renderer/components/+extensions/install-from-input/install-from-input.injectable.ts new file mode 100644 index 000000000000..9056f8be5d37 --- /dev/null +++ b/src/renderer/components/+extensions/install-from-input/install-from-input.injectable.ts @@ -0,0 +1,42 @@ +/** + * 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 { Injectable } from "@ogre-tools/injectable"; +import { lifecycleEnum } from "@ogre-tools/injectable"; +import attemptInstallInjectable from "../attempt-install/attempt-install.injectable"; +import type { Dependencies } from "./install-from-input"; +import { installFromInput } from "./install-from-input"; +import attemptInstallByInfoInjectable + from "../attempt-install-by-info/attempt-install-by-info.injectable"; + +const installFromInputInjectable: Injectable< + (input: string) => Promise, + Dependencies +> = { + getDependencies: di => ({ + attemptInstall: di.inject(attemptInstallInjectable), + attemptInstallByInfo: di.inject(attemptInstallByInfoInjectable), + }), + + instantiate: installFromInput, + lifecycle: lifecycleEnum.singleton, +}; + +export default installFromInputInjectable; diff --git a/src/renderer/components/+extensions/install-from-input/install-from-input.tsx b/src/renderer/components/+extensions/install-from-input/install-from-input.tsx new file mode 100644 index 000000000000..59e9cc267ad1 --- /dev/null +++ b/src/renderer/components/+extensions/install-from-input/install-from-input.tsx @@ -0,0 +1,68 @@ +/** + * 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 { downloadFile, ExtendableDisposer } from "../../../../common/utils"; +import { InputValidators } from "../../input"; +import { ExtensionInstallationStateStore } from "../extension-install.store"; +import { getMessageFromError } from "../get-message-from-error/get-message-from-error"; +import logger from "../../../../main/logger"; +import { Notifications } from "../../notifications"; +import path from "path"; +import React from "react"; +import { readFileNotify } from "../read-file-notify/read-file-notify"; +import type { InstallRequest } from "../attempt-install/install-request"; +import type { ExtensionInfo } from "../attempt-install-by-info/attempt-install-by-info"; + +export interface Dependencies { + attemptInstall: (request: InstallRequest, disposer?: ExtendableDisposer) => Promise, + attemptInstallByInfo: (extensionInfo: ExtensionInfo) => Promise +} + +export const installFromInput = ({ attemptInstall, attemptInstallByInfo }: Dependencies) => async (input: string) => { + let disposer: ExtendableDisposer | undefined = undefined; + + try { + // fixme: improve error messages for non-tar-file URLs + if (InputValidators.isUrl.validate(input)) { + // install via url + disposer = ExtensionInstallationStateStore.startPreInstall(); + const { promise } = downloadFile({ url: input, timeout: 10 * 60 * 1000 }); + const fileName = path.basename(input); + + await attemptInstall({ fileName, dataP: promise }, disposer); + } else if (InputValidators.isPath.validate(input)) { + // install from system path + const fileName = path.basename(input); + + await attemptInstall({ fileName, dataP: readFileNotify(input) }); + } else if (InputValidators.isExtensionNameInstall.validate(input)) { + const [{ groups: { name, version }}] = [...input.matchAll(InputValidators.isExtensionNameInstallRegex)]; + + await attemptInstallByInfo({ name, version }); + } + } catch (error) { + const message = getMessageFromError(error); + + logger.info(`[EXTENSION-INSTALL]: installation has failed: ${message}`, { error, installPath: input }); + Notifications.error(

Installation has failed: {message}

); + } finally { + disposer?.(); + } +}; diff --git a/src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.injectable.ts b/src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.injectable.ts new file mode 100644 index 000000000000..43c4dc65653d --- /dev/null +++ b/src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.injectable.ts @@ -0,0 +1,35 @@ +/** + * 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 { Injectable } from "@ogre-tools/injectable"; +import { lifecycleEnum } from "@ogre-tools/injectable"; +import { Dependencies, installFromSelectFileDialog } from "./install-from-select-file-dialog"; +import attemptInstallsInjectable from "../attempt-installs/attempt-installs.injectable"; + +const installFromSelectFileDialogInjectable: Injectable<() => Promise, Dependencies> = { + getDependencies: di => ({ + attemptInstalls: di.inject(attemptInstallsInjectable), + }), + + instantiate: installFromSelectFileDialog, + lifecycle: lifecycleEnum.singleton, +}; + +export default installFromSelectFileDialogInjectable; diff --git a/src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.ts b/src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.ts new file mode 100644 index 000000000000..26c509f9f625 --- /dev/null +++ b/src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.ts @@ -0,0 +1,45 @@ +/** + * 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 { dialog } from "../../../remote-helpers"; +import { AppPaths } from "../../../../common/app-paths"; +import { supportedExtensionFormats } from "../supported-extension-formats"; + +export interface Dependencies { + attemptInstalls: (filePaths: string[]) => Promise +} + +export const installFromSelectFileDialog = + ({ attemptInstalls }: Dependencies) => + async () => { + const { canceled, filePaths } = await dialog.showOpenDialog({ + defaultPath: AppPaths.get("downloads"), + properties: ["openFile", "multiSelections"], + message: `Select extensions to install (formats: ${supportedExtensionFormats.join( + ", ", + )}), `, + buttonLabel: "Use configuration", + filters: [{ name: "tarball", extensions: supportedExtensionFormats }], + }); + + if (!canceled) { + await attemptInstalls(filePaths); + } + }; diff --git a/src/renderer/components/+extensions/install-on-drop/install-on-drop.injectable.ts b/src/renderer/components/+extensions/install-on-drop/install-on-drop.injectable.ts new file mode 100644 index 000000000000..6fcdc90893ff --- /dev/null +++ b/src/renderer/components/+extensions/install-on-drop/install-on-drop.injectable.ts @@ -0,0 +1,38 @@ +/** + * 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 { Injectable } from "@ogre-tools/injectable"; +import { lifecycleEnum } from "@ogre-tools/injectable"; +import { Dependencies, installOnDrop } from "./install-on-drop"; +import attemptInstallsInjectable from "../attempt-installs/attempt-installs.injectable"; + +const installOnDropInjectable: Injectable< + (files: File[]) => Promise, + Dependencies +> = { + getDependencies: di => ({ + attemptInstalls: di.inject(attemptInstallsInjectable), + }), + + instantiate: installOnDrop, + lifecycle: lifecycleEnum.singleton, +}; + +export default installOnDropInjectable; diff --git a/src/renderer/components/+extensions/install-on-drop/install-on-drop.tsx b/src/renderer/components/+extensions/install-on-drop/install-on-drop.tsx new file mode 100644 index 000000000000..fe96f5ab7ef1 --- /dev/null +++ b/src/renderer/components/+extensions/install-on-drop/install-on-drop.tsx @@ -0,0 +1,32 @@ +/** + * 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 logger from "../../../../main/logger"; + +export interface Dependencies { + attemptInstalls: (filePaths: string[]) => Promise; +} + +export const installOnDrop = + ({ attemptInstalls }: Dependencies) => + async (files: File[]) => { + logger.info("Install from D&D"); + await attemptInstalls(files.map(({ path }) => path)); + }; diff --git a/src/renderer/components/+extensions/read-file-notify/read-file-notify.ts b/src/renderer/components/+extensions/read-file-notify/read-file-notify.ts new file mode 100644 index 000000000000..9580ae7b9ec1 --- /dev/null +++ b/src/renderer/components/+extensions/read-file-notify/read-file-notify.ts @@ -0,0 +1,39 @@ +/** + * 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 fse from "fs-extra"; +import { getMessageFromError } from "../get-message-from-error/get-message-from-error"; +import logger from "../../../../main/logger"; +import { Notifications } from "../../notifications"; + +export const readFileNotify = async (filePath: string, showError = true): Promise => { + try { + return await fse.readFile(filePath); + } catch (error) { + if (showError) { + const message = getMessageFromError(error); + + logger.info(`[EXTENSION-INSTALL]: preloading ${filePath} has failed: ${message}`, { error }); + Notifications.error(`Error while reading "${filePath}": ${message}`); + } + } + + return null; +}; diff --git a/src/renderer/components/+extensions/supported-extension-formats.ts b/src/renderer/components/+extensions/supported-extension-formats.ts new file mode 100644 index 000000000000..df24aab1a5eb --- /dev/null +++ b/src/renderer/components/+extensions/supported-extension-formats.ts @@ -0,0 +1,21 @@ +/** + * 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 const supportedExtensionFormats = ["tar", "tgz"]; diff --git a/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.injectable.ts b/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.injectable.ts new file mode 100644 index 000000000000..5ffe8784c6f6 --- /dev/null +++ b/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.injectable.ts @@ -0,0 +1,40 @@ +/** + * 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 { Injectable } from "@ogre-tools/injectable"; +import { lifecycleEnum } from "@ogre-tools/injectable"; +import type { LensExtensionId } from "../../../../extensions/lens-extension"; +import extensionLoaderInjectable + from "../../../../extensions/extension-loader/extension-loader.injectable"; +import { Dependencies, uninstallExtension } from "./uninstall-extension"; + +const uninstallExtensionInjectable: Injectable< + (extensionId: LensExtensionId) => Promise, + Dependencies +> = { + getDependencies: di => ({ + extensionLoader: di.inject(extensionLoaderInjectable), + }), + + instantiate: uninstallExtension, + lifecycle: lifecycleEnum.singleton, +}; + +export default uninstallExtensionInjectable; diff --git a/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.tsx b/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.tsx new file mode 100644 index 000000000000..cc941adf1a28 --- /dev/null +++ b/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.tsx @@ -0,0 +1,76 @@ +/** + * 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 { ExtensionLoader } from "../../../../extensions/extension-loader"; +import { extensionDisplayName, LensExtensionId } from "../../../../extensions/lens-extension"; +import logger from "../../../../main/logger"; +import { ExtensionInstallationStateStore } from "../extension-install.store"; +import { ExtensionDiscovery } from "../../../../extensions/extension-discovery"; +import { Notifications } from "../../notifications"; +import React from "react"; +import { when } from "mobx"; +import { getMessageFromError } from "../get-message-from-error/get-message-from-error"; + +export interface Dependencies { + extensionLoader: ExtensionLoader +} + +export const uninstallExtension = + ({ extensionLoader }: Dependencies) => + async (extensionId: LensExtensionId): Promise => { + const { manifest } = extensionLoader.getExtension(extensionId); + const displayName = extensionDisplayName(manifest.name, manifest.version); + + try { + logger.debug(`[EXTENSIONS]: trying to uninstall ${extensionId}`); + ExtensionInstallationStateStore.setUninstalling(extensionId); + + await ExtensionDiscovery.getInstance().uninstallExtension(extensionId); + + // wait for the ExtensionLoader to actually uninstall the extension + await when(() => !extensionLoader.userExtensions.has(extensionId)); + + Notifications.ok( +

+ Extension {displayName} successfully uninstalled! +

, + ); + + return true; + } catch (error) { + const message = getMessageFromError(error); + + logger.info( + `[EXTENSION-UNINSTALL]: uninstalling ${displayName} has failed: ${error}`, + { error }, + ); + Notifications.error( +

+ Uninstalling extension {displayName} has failed:{" "} + {message} +

, + ); + + return false; + } finally { + // Remove uninstall state on uninstall failure + ExtensionInstallationStateStore.clearUninstalling(extensionId); + } + }; diff --git a/src/renderer/components/+extensions/user-extensions/user-extensions.injectable.ts b/src/renderer/components/+extensions/user-extensions/user-extensions.injectable.ts new file mode 100644 index 000000000000..ecaa50e454cd --- /dev/null +++ b/src/renderer/components/+extensions/user-extensions/user-extensions.injectable.ts @@ -0,0 +1,41 @@ +/** + * 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 { Injectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed, IComputedValue } from "mobx"; +import type { InstalledExtension } from "../../../../extensions/extension-discovery"; +import type { ExtensionLoader } from "../../../../extensions/extension-loader"; +import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable"; + +const userExtensionsInjectable: Injectable< + IComputedValue, + { extensionLoader: ExtensionLoader } +> = { + getDependencies: di => ({ + extensionLoader: di.inject(extensionLoaderInjectable), + }), + + lifecycle: lifecycleEnum.singleton, + + instantiate: ({ extensionLoader }) => + computed(() => [...extensionLoader.userExtensions.values()]), +}; + +export default userExtensionsInjectable; diff --git a/src/renderer/components/getDi.tsx b/src/renderer/components/getDi.tsx index d341d9c3ae2e..57b82c1ac592 100644 --- a/src/renderer/components/getDi.tsx +++ b/src/renderer/components/getDi.tsx @@ -34,7 +34,7 @@ export const getDi = () => { }; const getRequireContextForRendererCode = () => - require.context("./", true, /\.injectable\.(ts|tsx)$/); + require.context("../", true, /\.injectable\.(ts|tsx)$/); const getRequireContextForCommonExtensionCode = () => require.context("../../extensions", true, /\.injectable\.(ts|tsx)$/); diff --git a/src/renderer/components/kube-object-menu/kube-object-menu.injectable.tsx b/src/renderer/components/kube-object-menu/kube-object-menu.injectable.tsx deleted file mode 100644 index fdc78518540f..000000000000 --- a/src/renderer/components/kube-object-menu/kube-object-menu.injectable.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/** - * 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 React from "react"; - -import { - KubeObjectMenu, - KubeObjectMenuDependencies, - KubeObjectMenuProps, -} from "./kube-object-menu"; - -import type { KubeObject } from "../../../common/k8s-api/kube-object"; -import { lifecycleEnum, Injectable } from "@ogre-tools/injectable"; -import apiManagerInjectable from "./dependencies/api-manager.injectable"; -import clusterNameInjectable from "./dependencies/cluster-name.injectable"; -import editResourceTabInjectable from "./dependencies/edit-resource-tab.injectable"; -import hideDetailsInjectable from "./dependencies/hide-details.injectable"; -import kubeObjectMenuItemsInjectable from "./dependencies/kube-object-menu-items/kube-object-menu-items.injectable"; - -const KubeObjectMenuInjectable: Injectable< - JSX.Element, - KubeObjectMenuDependencies, - KubeObjectMenuProps -> = { - getDependencies: (di, props) => ({ - clusterName: di.inject(clusterNameInjectable), - apiManager: di.inject(apiManagerInjectable), - editResourceTab: di.inject(editResourceTabInjectable), - hideDetails: di.inject(hideDetailsInjectable), - - kubeObjectMenuItems: di.inject(kubeObjectMenuItemsInjectable, { - kubeObject: props.object, - }), - }), - - instantiate: (dependencies, props) => ( - - ), - - lifecycle: lifecycleEnum.transient, -}; - -export default KubeObjectMenuInjectable; diff --git a/src/renderer/initializers/ipc.ts b/src/renderer/initializers/ipc.ts index fedb007d9b26..09a710202e68 100644 --- a/src/renderer/initializers/ipc.ts +++ b/src/renderer/initializers/ipc.ts @@ -20,11 +20,11 @@ */ import { ipcRendererOn } from "../../common/ipc"; -import { ExtensionLoader } from "../../extensions/extension-loader"; +import type { ExtensionLoader } from "../../extensions/extension-loader"; import type { LensRendererExtension } from "../../extensions/lens-renderer-extension"; -export function initIpcRendererListeners() { +export function initIpcRendererListeners(extensionLoader: ExtensionLoader) { ipcRendererOn("extension:navigate", (event, extId: string, pageId ?: string, params?: Record) => { - ExtensionLoader.getInstance().getInstanceById(extId).navigate(pageId, params); + extensionLoader.getInstanceById(extId).navigate(pageId, params); }); } diff --git a/src/renderer/protocol-handler/app-handlers.tsx b/src/renderer/protocol-handler/app-handlers.tsx deleted file mode 100644 index f08da747ef7a..000000000000 --- a/src/renderer/protocol-handler/app-handlers.tsx +++ /dev/null @@ -1,116 +0,0 @@ -/** - * 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 React from "react"; -import { attemptInstallByInfo } from "../components/+extensions"; -import { LensProtocolRouterRenderer } from "./router"; -import { navigate } from "../navigation/helpers"; -import { catalogEntityRegistry } from "../api/catalog-entity-registry"; -import { ClusterStore } from "../../common/cluster-store"; -import { EXTENSION_NAME_MATCH, EXTENSION_PUBLISHER_MATCH, LensProtocolRouter } from "../../common/protocol-handler"; -import { Notifications } from "../components/notifications"; -import * as routes from "../../common/routes"; - -export function bindProtocolAddRouteHandlers() { - LensProtocolRouterRenderer - .getInstance() - .addInternalHandler("/preferences", ({ search: { highlight }}) => { - navigate(routes.preferencesURL({ fragment: highlight })); - }) - .addInternalHandler("/", ({ tail }) => { - if (tail) { - Notifications.shortInfo( -

- Unknown Action for lens://app/{tail}.{" "} - Are you on the latest version? -

, - ); - } - - navigate(routes.catalogURL()); - }) - .addInternalHandler("/landing", () => { - navigate(routes.catalogURL()); - }) - .addInternalHandler("/landing/view/:group/:kind", ({ pathname: { group, kind }}) => { - navigate(routes.catalogURL({ - params: { - group, kind, - }, - })); - }) - .addInternalHandler("/cluster", () => { - navigate(routes.addClusterURL()); - }) - .addInternalHandler("/entity/:entityId/settings", ({ pathname: { entityId }}) => { - const entity = catalogEntityRegistry.getById(entityId); - - if (entity) { - navigate(routes.entitySettingsURL({ params: { entityId }})); - } else { - Notifications.shortInfo( -

- Unknown catalog entity {entityId}. -

, - ); - } - }) - // Handlers below are deprecated and only kept for backward compact purposes - .addInternalHandler("/cluster/:clusterId", ({ pathname: { clusterId }}) => { - const cluster = ClusterStore.getInstance().getById(clusterId); - - if (cluster) { - navigate(routes.clusterViewURL({ params: { clusterId }})); - } else { - Notifications.shortInfo( -

- Unknown catalog entity {clusterId}. -

, - ); - } - }) - .addInternalHandler("/cluster/:clusterId/settings", ({ pathname: { clusterId }}) => { - const cluster = ClusterStore.getInstance().getById(clusterId); - - if (cluster) { - navigate(routes.entitySettingsURL({ params: { entityId: clusterId }})); - } else { - Notifications.shortInfo( -

- Unknown catalog entity {clusterId}. -

, - ); - } - }) - .addInternalHandler("/extensions", () => { - navigate(routes.extensionsURL()); - }) - .addInternalHandler(`/extensions/install${LensProtocolRouter.ExtensionUrlSchema}`, ({ pathname, search: { version }}) => { - const name = [ - pathname[EXTENSION_PUBLISHER_MATCH], - pathname[EXTENSION_NAME_MATCH], - ].filter(Boolean) - .join("/"); - - navigate(routes.extensionsURL()); - attemptInstallByInfo({ name, version, requireConfirmation: true }); - }); -} diff --git a/src/renderer/protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.injectable.ts b/src/renderer/protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.injectable.ts new file mode 100644 index 000000000000..c4eb3d9a585b --- /dev/null +++ b/src/renderer/protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.injectable.ts @@ -0,0 +1,41 @@ +/** + * 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 { Injectable } from "@ogre-tools/injectable"; +import { lifecycleEnum } from "@ogre-tools/injectable"; +import attemptInstallByInfoInjectable from "../../components/+extensions/attempt-install-by-info/attempt-install-by-info.injectable"; +import { + bindProtocolAddRouteHandlers, + Dependencies, +} from "./bind-protocol-add-route-handlers"; +import lensProtocolRouterRendererInjectable + from "../lens-protocol-router-renderer/lens-protocol-router-renderer.injectable"; + +const bindProtocolAddRouteHandlersInjectable: Injectable<() => void, Dependencies> = { + getDependencies: di => ({ + attemptInstallByInfo: di.inject(attemptInstallByInfoInjectable), + lensProtocolRouterRenderer: di.inject(lensProtocolRouterRendererInjectable), + }), + + instantiate: bindProtocolAddRouteHandlers, + lifecycle: lifecycleEnum.singleton, +}; + +export default bindProtocolAddRouteHandlersInjectable; diff --git a/src/renderer/protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.tsx b/src/renderer/protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.tsx new file mode 100644 index 000000000000..6ff367f6549d --- /dev/null +++ b/src/renderer/protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.tsx @@ -0,0 +1,147 @@ +/** + * 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 React from "react"; +import type { LensProtocolRouterRenderer } from "../lens-protocol-router-renderer/lens-protocol-router-renderer"; +import { navigate } from "../../navigation/helpers"; +import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; +import { ClusterStore } from "../../../common/cluster-store"; +import { + EXTENSION_NAME_MATCH, + EXTENSION_PUBLISHER_MATCH, + LensProtocolRouter, +} from "../../../common/protocol-handler"; +import { Notifications } from "../../components/notifications"; +import * as routes from "../../../common/routes"; +import type { ExtensionInfo } from "../../components/+extensions/attempt-install-by-info/attempt-install-by-info"; + +export interface Dependencies { + attemptInstallByInfo: (extensionInfo: ExtensionInfo) => Promise; + lensProtocolRouterRenderer: LensProtocolRouterRenderer; +} + +export const bindProtocolAddRouteHandlers = + ({ attemptInstallByInfo, lensProtocolRouterRenderer }: Dependencies) => + () => { + lensProtocolRouterRenderer + .addInternalHandler("/preferences", ({ search: { highlight }}) => { + navigate(routes.preferencesURL({ fragment: highlight })); + }) + .addInternalHandler("/", ({ tail }) => { + if (tail) { + Notifications.shortInfo( +

+ Unknown Action for lens://app/{tail}. Are you on the + latest version? +

, + ); + } + + navigate(routes.catalogURL()); + }) + .addInternalHandler("/landing", () => { + navigate(routes.catalogURL()); + }) + .addInternalHandler( + "/landing/view/:group/:kind", + ({ pathname: { group, kind }}) => { + navigate( + routes.catalogURL({ + params: { + group, + kind, + }, + }), + ); + }, + ) + .addInternalHandler("/cluster", () => { + navigate(routes.addClusterURL()); + }) + .addInternalHandler( + "/entity/:entityId/settings", + ({ pathname: { entityId }}) => { + const entity = catalogEntityRegistry.getById(entityId); + + if (entity) { + navigate(routes.entitySettingsURL({ params: { entityId }})); + } else { + Notifications.shortInfo( +

+ Unknown catalog entity {entityId}. +

, + ); + } + }, + ) + // Handlers below are deprecated and only kept for backward compact purposes + .addInternalHandler( + "/cluster/:clusterId", + ({ pathname: { clusterId }}) => { + const cluster = ClusterStore.getInstance().getById(clusterId); + + if (cluster) { + navigate(routes.clusterViewURL({ params: { clusterId }})); + } else { + Notifications.shortInfo( +

+ Unknown catalog entity {clusterId}. +

, + ); + } + }, + ) + .addInternalHandler( + "/cluster/:clusterId/settings", + ({ pathname: { clusterId }}) => { + const cluster = ClusterStore.getInstance().getById(clusterId); + + if (cluster) { + navigate( + routes.entitySettingsURL({ params: { entityId: clusterId }}), + ); + } else { + Notifications.shortInfo( +

+ Unknown catalog entity {clusterId}. +

, + ); + } + }, + ) + .addInternalHandler("/extensions", () => { + navigate(routes.extensionsURL()); + }) + .addInternalHandler( + `/extensions/install${LensProtocolRouter.ExtensionUrlSchema}`, + ({ pathname, search: { version }}) => { + const name = [ + pathname[EXTENSION_PUBLISHER_MATCH], + pathname[EXTENSION_NAME_MATCH], + ] + .filter(Boolean) + .join("/"); + + navigate(routes.extensionsURL()); + attemptInstallByInfo({ name, version, requireConfirmation: true }); + }, + ); + }; diff --git a/src/renderer/protocol-handler/index.ts b/src/renderer/protocol-handler/index.ts index ad2999f5e95a..8a69c3596bee 100644 --- a/src/renderer/protocol-handler/index.ts +++ b/src/renderer/protocol-handler/index.ts @@ -19,5 +19,5 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -export * from "./router"; -export * from "./app-handlers"; +export { LensProtocolRouterRenderer } from "./lens-protocol-router-renderer/lens-protocol-router-renderer"; +export { bindProtocolAddRouteHandlers } from "./bind-protocol-add-route-handlers/bind-protocol-add-route-handlers"; diff --git a/src/renderer/protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.injectable.ts b/src/renderer/protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.injectable.ts new file mode 100644 index 000000000000..c46c129350bf --- /dev/null +++ b/src/renderer/protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.injectable.ts @@ -0,0 +1,40 @@ +/** + * 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 { Injectable } from "@ogre-tools/injectable"; +import { lifecycleEnum } from "@ogre-tools/injectable"; +import extensionLoaderInjectable from "../../../extensions/extension-loader/extension-loader.injectable"; +import type { Dependencies } from "./lens-protocol-router-renderer"; +import { LensProtocolRouterRenderer } from "./lens-protocol-router-renderer"; + +const lensProtocolRouterRendererInjectable: Injectable< + LensProtocolRouterRenderer, + Dependencies +> = { + getDependencies: di => ({ + extensionLoader: di.inject(extensionLoaderInjectable), + }), + + instantiate: dependencies => new LensProtocolRouterRenderer(dependencies), + + lifecycle: lifecycleEnum.singleton, +}; + +export default lensProtocolRouterRendererInjectable; diff --git a/src/renderer/protocol-handler/router.tsx b/src/renderer/protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.tsx similarity index 89% rename from src/renderer/protocol-handler/router.tsx rename to src/renderer/protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.tsx index ffc28130f9cb..0caf1f125810 100644 --- a/src/renderer/protocol-handler/router.tsx +++ b/src/renderer/protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.tsx @@ -21,11 +21,12 @@ import React from "react"; import { ipcRenderer } from "electron"; -import * as proto from "../../common/protocol-handler"; +import * as proto from "../../../common/protocol-handler"; import Url from "url-parse"; -import { onCorrect } from "../../common/ipc"; -import { foldAttemptResults, ProtocolHandlerInvalid, RouteAttempt } from "../../common/protocol-handler"; -import { Notifications } from "../components/notifications"; +import { onCorrect } from "../../../common/ipc"; +import { foldAttemptResults, ProtocolHandlerInvalid, RouteAttempt } from "../../../common/protocol-handler"; +import { Notifications } from "../../components/notifications"; +import type { ExtensionLoader } from "../../../extensions/extension-loader"; function verifyIpcArgs(args: unknown[]): args is [string, RouteAttempt] { if (args.length !== 2) { @@ -46,7 +47,16 @@ function verifyIpcArgs(args: unknown[]): args is [string, RouteAttempt] { } } +export interface Dependencies { + extensionLoader: ExtensionLoader +} + + export class LensProtocolRouterRenderer extends proto.LensProtocolRouter { + constructor(protected dependencies: Dependencies) { + super(dependencies); + } + /** * This function is needed to be called early on in the renderers lifetime. */ diff --git a/src/renderer/root-frame.tsx b/src/renderer/root-frame.tsx index 5fd3051b647c..c403af4bed6a 100644 --- a/src/renderer/root-frame.tsx +++ b/src/renderer/root-frame.tsx @@ -28,10 +28,9 @@ import { ClusterManager } from "./components/cluster-manager"; import { ErrorBoundary } from "./components/error-boundary"; import { Notifications } from "./components/notifications"; import { ConfirmDialog } from "./components/confirm-dialog"; -import { ExtensionLoader } from "../extensions/extension-loader"; +import type { ExtensionLoader } from "../extensions/extension-loader"; import { broadcastMessage } from "../common/ipc"; import { CommandContainer } from "./components/command-palette/command-container"; -import { bindProtocolAddRouteHandlers, LensProtocolRouterRenderer } from "./protocol-handler"; import { registerIpcListeners } from "./ipc"; import { ipcRenderer } from "electron"; import { IpcRendererNavigationEvents } from "./navigation/events"; @@ -39,6 +38,7 @@ import { catalogEntityRegistry } from "./api/catalog-entity-registry"; import logger from "../common/logger"; import { unmountComponentAtNode } from "react-dom"; import { ClusterFrameHandler } from "./components/cluster-manager/lens-views"; +import type { LensProtocolRouterRenderer } from "./protocol-handler"; injectSystemCAs(); @@ -47,10 +47,15 @@ export class RootFrame extends React.Component { static readonly logPrefix = "[ROOT-FRAME]:"; static displayName = "RootFrame"; - static async init(rootElem: HTMLElement) { + static async init( + rootElem: HTMLElement, + extensionLoader: ExtensionLoader, + bindProtocolAddRouteHandlers: () => void, + lensProtocolRouterRendererInjectable: LensProtocolRouterRenderer, + ) { catalogEntityRegistry.init(); - ExtensionLoader.getInstance().loadOnClusterManagerRenderer(); - LensProtocolRouterRenderer.createInstance().init(); + extensionLoader.loadOnClusterManagerRenderer(); + lensProtocolRouterRendererInjectable.init(); bindProtocolAddRouteHandlers(); window.addEventListener("offline", () => broadcastMessage("network:offline")); From 9f7515db0ec0d4d54f1eea2136fe9df06ceb6443 Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Sun, 12 Dec 2021 12:35:59 +0200 Subject: [PATCH 10/13] Revert "Introduce a way to start replacing usages of Singleton base-class with no changes required on use places" This reverts commit 34a47f5f Signed-off-by: Janne Savolainen --- src/common/di-kludge/di-kludge.ts | 29 --------- .../get-legacy-singleton.ts | 63 ------------------- src/extensions/getDiForUnitTesting.ts | 3 - src/main/getDi.ts | 10 +-- src/main/getDiForUnitTesting.ts | 3 - src/renderer/components/getDi.tsx | 10 +-- .../components/getDiForUnitTesting.tsx | 3 - 7 files changed, 4 insertions(+), 117 deletions(-) delete mode 100644 src/common/di-kludge/di-kludge.ts delete mode 100644 src/common/di-kludge/get-legacy-singleton/get-legacy-singleton.ts diff --git a/src/common/di-kludge/di-kludge.ts b/src/common/di-kludge/di-kludge.ts deleted file mode 100644 index 863eca938222..000000000000 --- a/src/common/di-kludge/di-kludge.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * 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 { DependencyInjectionContainer } from "@ogre-tools/injectable"; - -let kludgeDi: DependencyInjectionContainer; - -export const setDiKludge = (di: DependencyInjectionContainer) => { - kludgeDi = di; -}; - -export const getDiKludge = () => kludgeDi; diff --git a/src/common/di-kludge/get-legacy-singleton/get-legacy-singleton.ts b/src/common/di-kludge/get-legacy-singleton/get-legacy-singleton.ts deleted file mode 100644 index 3ba3ac5d6426..000000000000 --- a/src/common/di-kludge/get-legacy-singleton/get-legacy-singleton.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * 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 { Injectable } from "@ogre-tools/injectable"; -import { getDiKludge } from "../di-kludge"; - -type Awaited = TMaybePromise extends PromiseLike - ? TValue - : TMaybePromise; - -export const getLegacySingleton = < - TInjectable extends Injectable< - TInstance, - TDependencies, - TInstantiationParameter - >, - TInstance, - TDependencies extends object, - TInstantiationParameter, - TMaybePromiseInstance = ReturnType, ->( - injectableKey: TInjectable, - ) => ({ - createInstance: (): TMaybePromiseInstance extends PromiseLike - ? Awaited - : TMaybePromiseInstance => { - const di = getDiKludge(); - - return di.inject(injectableKey); - }, - - getInstance: (): TMaybePromiseInstance extends PromiseLike - ? Awaited - : TMaybePromiseInstance => { - const di = getDiKludge(); - - return di.inject(injectableKey); - }, - - resetInstance: () => { - const di = getDiKludge(); - - // @ts-ignore - return di.purge(injectableKey); - }, - }); diff --git a/src/extensions/getDiForUnitTesting.ts b/src/extensions/getDiForUnitTesting.ts index 4dfd9912711d..5460d8887b8b 100644 --- a/src/extensions/getDiForUnitTesting.ts +++ b/src/extensions/getDiForUnitTesting.ts @@ -26,13 +26,10 @@ import { createContainer, ConfigurableDependencyInjectionContainer, } from "@ogre-tools/injectable"; -import { setDiKludge } from "../common/di-kludge/di-kludge"; export const getDiForUnitTesting = () => { const di: ConfigurableDependencyInjectionContainer = createContainer(); - setDiKludge(di); - getInjectableFilePaths() .map(key => { // eslint-disable-next-line @typescript-eslint/no-var-requires diff --git a/src/main/getDi.ts b/src/main/getDi.ts index 4a81bb908692..2b59923a6cf1 100644 --- a/src/main/getDi.ts +++ b/src/main/getDi.ts @@ -20,19 +20,13 @@ */ import { createContainer } from "@ogre-tools/injectable"; -import { setDiKludge } from "../common/di-kludge/di-kludge"; -export const getDi = () => { - const di = createContainer( +export const getDi = () => + createContainer( getRequireContextForMainCode, getRequireContextForCommonExtensionCode, ); - setDiKludge(di); - - return di; -}; - const getRequireContextForMainCode = () => require.context("./", true, /\.injectable\.(ts|tsx)$/); diff --git a/src/main/getDiForUnitTesting.ts b/src/main/getDiForUnitTesting.ts index a977e70a26d4..06b0588ae20d 100644 --- a/src/main/getDiForUnitTesting.ts +++ b/src/main/getDiForUnitTesting.ts @@ -26,13 +26,10 @@ import { createContainer, ConfigurableDependencyInjectionContainer, } from "@ogre-tools/injectable"; -import { setDiKludge } from "../common/di-kludge/di-kludge"; export const getDiForUnitTesting = () => { const di: ConfigurableDependencyInjectionContainer = createContainer(); - setDiKludge(di); - getInjectableFilePaths() .map(key => { // eslint-disable-next-line @typescript-eslint/no-var-requires diff --git a/src/renderer/components/getDi.tsx b/src/renderer/components/getDi.tsx index 57b82c1ac592..a0e4615a7c31 100644 --- a/src/renderer/components/getDi.tsx +++ b/src/renderer/components/getDi.tsx @@ -20,19 +20,13 @@ */ import { createContainer } from "@ogre-tools/injectable"; -import { setDiKludge } from "../../common/di-kludge/di-kludge"; -export const getDi = () => { - const di = createContainer( +export const getDi = () => + createContainer( getRequireContextForRendererCode, getRequireContextForCommonExtensionCode, ); - setDiKludge(di); - - return di; -}; - const getRequireContextForRendererCode = () => require.context("../", true, /\.injectable\.(ts|tsx)$/); diff --git a/src/renderer/components/getDiForUnitTesting.tsx b/src/renderer/components/getDiForUnitTesting.tsx index f182d8131ef1..4e94d20dae25 100644 --- a/src/renderer/components/getDiForUnitTesting.tsx +++ b/src/renderer/components/getDiForUnitTesting.tsx @@ -26,13 +26,10 @@ import { createContainer, ConfigurableDependencyInjectionContainer, } from "@ogre-tools/injectable"; -import { setDiKludge } from "../../common/di-kludge/di-kludge"; export const getDiForUnitTesting = () => { const di: ConfigurableDependencyInjectionContainer = createContainer(); - setDiKludge(di); - getInjectableFilePaths() .map(key => { const injectable = require(key).default; From a2486da0ebf18e80ee8324876b15be7c4a3dff83 Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Thu, 16 Dec 2021 07:00:20 +0200 Subject: [PATCH 11/13] Fix linting Signed-off-by: Janne Savolainen --- .../attempt-install/install-request.d.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/renderer/components/+extensions/attempt-install/install-request.d.ts b/src/renderer/components/+extensions/attempt-install/install-request.d.ts index 3c510d327802..cee3a3b86955 100644 --- a/src/renderer/components/+extensions/attempt-install/install-request.d.ts +++ b/src/renderer/components/+extensions/attempt-install/install-request.d.ts @@ -1,3 +1,23 @@ +/** + * 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 InstallRequest { fileName: string; dataP: Promise; From 250b301655e143462bb219b8e717232e1071c6ad Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Thu, 16 Dec 2021 07:00:37 +0200 Subject: [PATCH 12/13] Revert code style change Signed-off-by: Janne Savolainen --- src/renderer/components/kube-object-menu/kube-object-menu.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/renderer/components/kube-object-menu/kube-object-menu.tsx b/src/renderer/components/kube-object-menu/kube-object-menu.tsx index 53ae44e44762..989094023f7f 100644 --- a/src/renderer/components/kube-object-menu/kube-object-menu.tsx +++ b/src/renderer/components/kube-object-menu/kube-object-menu.tsx @@ -103,8 +103,7 @@ class NonInjectedKubeObjectMenu extends React.Component { return (

- Remove {object.kind} {breadcrumb} from{" "} - {this.props.dependencies.clusterName}? + Remove {object.kind} {breadcrumb} from {this.dependencies.clusterName}?

); } From 8337dfd21b50f107359019e29864f74a729ed6e7 Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Thu, 16 Dec 2021 07:05:57 +0200 Subject: [PATCH 13/13] Fix test that was accidentally broken Signed-off-by: Janne Savolainen --- .../components/+extensions/__tests__/extensions.test.tsx | 6 +++++- src/renderer/components/getDiForUnitTesting.tsx | 7 ++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx index 284a05fee504..4ebdb6156fed 100644 --- a/src/renderer/components/+extensions/__tests__/extensions.test.tsx +++ b/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -20,7 +20,7 @@ */ import "@testing-library/jest-dom/extend-expect"; -import { fireEvent, render, waitFor } from "@testing-library/react"; +import { fireEvent, waitFor } from "@testing-library/react"; import fse from "fs-extra"; import React from "react"; import { UserStore } from "../../../../common/user-store"; @@ -35,6 +35,7 @@ import { AppPaths } from "../../../../common/app-paths"; import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable"; import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import { DiRender, renderFor } from "../../test-utils/renderFor"; mockWindow(); @@ -77,10 +78,13 @@ AppPaths.init(); describe("Extensions", () => { let extensionLoader: ExtensionLoader; + let render: DiRender; beforeEach(async () => { const di = getDiForUnitTesting(); + render = renderFor(di); + extensionLoader = di.inject(extensionLoaderInjectable); mockFs({ diff --git a/src/renderer/components/getDiForUnitTesting.tsx b/src/renderer/components/getDiForUnitTesting.tsx index 4e94d20dae25..fefd120fb4b4 100644 --- a/src/renderer/components/getDiForUnitTesting.tsx +++ b/src/renderer/components/getDiForUnitTesting.tsx @@ -48,6 +48,7 @@ export const getDiForUnitTesting = () => { return di; }; -const getInjectableFilePaths = memoize(() => - glob.sync("./**/*.injectable.{ts,tsx}", { cwd: __dirname }), -); +const getInjectableFilePaths = memoize(() => [ + ...glob.sync("./**/*.injectable.{ts,tsx}", { cwd: __dirname }), + ...glob.sync("../../extensions/**/*.injectable.{ts,tsx}", { cwd: __dirname }), +]);