diff --git a/packages/core/src/common/fs/copy.global-override-for-injectable.ts b/packages/core/src/common/fs/copy.global-override-for-injectable.ts deleted file mode 100644 index b6d899d2c4..0000000000 --- a/packages/core/src/common/fs/copy.global-override-for-injectable.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { getGlobalOverride } from "../test-utils/get-global-override"; -import copyInjectable from "./copy.injectable"; - -export default getGlobalOverride(copyInjectable, () => async () => { - throw new Error("tried to copy filepaths without override"); -}); diff --git a/packages/core/src/common/fs/ensure-dir.injectable.ts b/packages/core/src/common/fs/ensure-directory.injectable.ts similarity index 66% rename from packages/core/src/common/fs/ensure-dir.injectable.ts rename to packages/core/src/common/fs/ensure-directory.injectable.ts index 78ec4d91dc..2fbdd8ca36 100644 --- a/packages/core/src/common/fs/ensure-dir.injectable.ts +++ b/packages/core/src/common/fs/ensure-directory.injectable.ts @@ -7,12 +7,9 @@ import fsInjectable from "./fs.injectable"; export type EnsureDirectory = (dirPath: string) => Promise; -const ensureDirInjectable = getInjectable({ +const ensureDirectoryInjectable = getInjectable({ id: "ensure-dir", - - // TODO: Remove usages of ensureDir from business logic. - // TODO: Read, Write, Watch etc. operations should do this internally. instantiate: (di): EnsureDirectory => di.inject(fsInjectable).ensureDir, }); -export default ensureDirInjectable; +export default ensureDirectoryInjectable; diff --git a/packages/core/src/common/fs/lstat.global-override-for-injectable.ts b/packages/core/src/common/fs/lstat.global-override-for-injectable.ts deleted file mode 100644 index 9c9f3d4933..0000000000 --- a/packages/core/src/common/fs/lstat.global-override-for-injectable.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { getGlobalOverride } from "../test-utils/get-global-override"; -import lstatInjectable from "./lstat.injectable"; - -export default getGlobalOverride(lstatInjectable, () => async () => { - throw new Error("tried to lstat a filepath without override"); -}); diff --git a/packages/core/src/common/fs/read-directory.global-override-for-injectable.ts b/packages/core/src/common/fs/read-directory.global-override-for-injectable.ts deleted file mode 100644 index 57c83ceffb..0000000000 --- a/packages/core/src/common/fs/read-directory.global-override-for-injectable.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { getGlobalOverride } from "../test-utils/get-global-override"; -import readDirectoryInjectable from "./read-directory.injectable"; - -export default getGlobalOverride(readDirectoryInjectable, () => async () => { - throw new Error("tried to read a directory's content without override"); -}); diff --git a/packages/core/src/common/fs/remove.global-override-for-injectable.ts b/packages/core/src/common/fs/remove.global-override-for-injectable.ts deleted file mode 100644 index 4b92353344..0000000000 --- a/packages/core/src/common/fs/remove.global-override-for-injectable.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { getGlobalOverride } from "../test-utils/get-global-override"; -import removePathInjectable from "./remove.injectable"; - -export default getGlobalOverride(removePathInjectable, () => async () => { - throw new Error("tried to remove path without override"); -}); diff --git a/packages/core/src/common/fs/write-file.global-override-for-injectable.ts b/packages/core/src/common/fs/write-file.global-override-for-injectable.ts deleted file mode 100644 index c8b7ef8e45..0000000000 --- a/packages/core/src/common/fs/write-file.global-override-for-injectable.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { getGlobalOverride } from "../test-utils/get-global-override"; -import writeFileInjectable from "./write-file.injectable"; - -export default getGlobalOverride(writeFileInjectable, () => async () => { - throw new Error("tried to write file without override"); -}); diff --git a/packages/core/src/common/ipc/extension-handling.ts b/packages/core/src/common/ipc/extension-handling.ts deleted file mode 100644 index 04d52c0a9f..0000000000 --- a/packages/core/src/common/ipc/extension-handling.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -export const extensionDiscoveryStateChannel = "extension-discovery:state"; diff --git a/packages/core/src/common/library.ts b/packages/core/src/common/library.ts index bc625eb3dd..95f8a8a1d3 100644 --- a/packages/core/src/common/library.ts +++ b/packages/core/src/common/library.ts @@ -4,4 +4,4 @@ */ // @experimental -export { bundledExtensionInjectionToken } from "../extensions/extension-discovery/bundled-extension-token"; +export { bundledExtensionInjectionToken } from "../features/extensions/common/bundled-extension-token"; diff --git a/packages/core/src/common/utils/sync-box/sync-box-injection-token.ts b/packages/core/src/common/utils/sync-box/sync-box-injection-token.ts index 8db80243d3..f8be98fc6a 100644 --- a/packages/core/src/common/utils/sync-box/sync-box-injection-token.ts +++ b/packages/core/src/common/utils/sync-box/sync-box-injection-token.ts @@ -5,8 +5,8 @@ import { getInjectionToken } from "@ogre-tools/injectable"; import type { IComputedValue } from "mobx"; export interface SyncBox { - id: string; - value: IComputedValue; + readonly id: string; + readonly value: IComputedValue; set: (value: Value) => void; } diff --git a/packages/core/src/common/vars.ts b/packages/core/src/common/vars.ts index f4c19c16e6..2a4287cfc0 100644 --- a/packages/core/src/common/vars.ts +++ b/packages/core/src/common/vars.ts @@ -12,6 +12,8 @@ export const defaultFontSize = 12; export const defaultTerminalFontFamily = "RobotoMono"; export const defaultEditorFontFamily = "RobotoMono"; +export const manifestFilename = "package.json"; + // Apis export const apiPrefix = "/api"; // local router apis export const apiKubePrefix = "/api-kube"; // k8s cluster apis diff --git a/packages/core/src/extensions/extension-discovery/extension-discovery.injectable.ts b/packages/core/src/extensions/extension-discovery/extension-discovery.injectable.ts deleted file mode 100644 index 5794279128..0000000000 --- a/packages/core/src/extensions/extension-discovery/extension-discovery.injectable.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import { ExtensionDiscovery } from "./extension-discovery"; -import isCompatibleExtensionInjectable from "./is-compatible-extension/is-compatible-extension.injectable"; -import extensionsStoreInjectable from "../extensions-store/extensions-store.injectable"; -import extensionInstallationStateStoreInjectable from "../extension-installation-state-store/extension-installation-state-store.injectable"; -import installExtensionInjectable from "../install-extension/install-extension.injectable"; -import extensionPackageRootDirectoryInjectable from "../install-extension/extension-package-root-directory.injectable"; -import readJsonFileInjectable from "../../common/fs/read-json-file.injectable"; -import loggerInjectable from "../../common/logger.injectable"; -import pathExistsInjectable from "../../common/fs/path-exists.injectable"; -import watchInjectable from "../../common/fs/watch/watch.injectable"; -import accessPathInjectable from "../../common/fs/access-path.injectable"; -import copyInjectable from "../../common/fs/copy.injectable"; -import ensureDirInjectable from "../../common/fs/ensure-dir.injectable"; -import isProductionInjectable from "../../common/vars/is-production.injectable"; -import lstatInjectable from "../../common/fs/lstat.injectable"; -import readDirectoryInjectable from "../../common/fs/read-directory.injectable"; -import fileSystemSeparatorInjectable from "../../common/path/separator.injectable"; -import getBasenameOfPathInjectable from "../../common/path/get-basename.injectable"; -import getDirnameOfPathInjectable from "../../common/path/get-dirname.injectable"; -import getRelativePathInjectable from "../../common/path/get-relative-path.injectable"; -import joinPathsInjectable from "../../common/path/join-paths.injectable"; -import removePathInjectable from "../../common/fs/remove.injectable"; -import homeDirectoryPathInjectable from "../../common/os/home-directory-path.injectable"; -import lensResourcesDirInjectable from "../../common/vars/lens-resources-dir.injectable"; -import installedExtensionsInjectable from "../../features/extensions/common/installed-extensions.injectable"; - -const extensionDiscoveryInjectable = getInjectable({ - id: "extension-discovery", - - instantiate: (di) => new ExtensionDiscovery({ - extensionsStore: di.inject(extensionsStoreInjectable), - extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable), - isCompatibleExtension: di.inject(isCompatibleExtensionInjectable), - installExtension: di.inject(installExtensionInjectable), - extensionPackageRootDirectory: di.inject(extensionPackageRootDirectoryInjectable), - resourcesDirectory: di.inject(lensResourcesDirInjectable), - readJsonFile: di.inject(readJsonFileInjectable), - pathExists: di.inject(pathExistsInjectable), - watch: di.inject(watchInjectable), - logger: di.inject(loggerInjectable), - accessPath: di.inject(accessPathInjectable), - copy: di.inject(copyInjectable), - removePath: di.inject(removePathInjectable), - ensureDirectory: di.inject(ensureDirInjectable), - isProduction: di.inject(isProductionInjectable), - lstat: di.inject(lstatInjectable), - readDirectory: di.inject(readDirectoryInjectable), - fileSystemSeparator: di.inject(fileSystemSeparatorInjectable), - getBasenameOfPath: di.inject(getBasenameOfPathInjectable), - getDirnameOfPath: di.inject(getDirnameOfPathInjectable), - getRelativePath: di.inject(getRelativePathInjectable), - joinPaths: di.inject(joinPathsInjectable), - homeDirectoryPath: di.inject(homeDirectoryPathInjectable), - installedExtensions: di.inject(installedExtensionsInjectable), - }), -}); - -export default extensionDiscoveryInjectable; diff --git a/packages/core/src/extensions/extension-discovery/extension-discovery.test.ts b/packages/core/src/extensions/extension-discovery/extension-discovery.test.ts deleted file mode 100644 index 77e9cd0623..0000000000 --- a/packages/core/src/extensions/extension-discovery/extension-discovery.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { FSWatcher } from "chokidar"; -import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; -import extensionDiscoveryInjectable from "../extension-discovery/extension-discovery.injectable"; -import type { ExtensionDiscovery } from "../extension-discovery/extension-discovery"; -import installExtensionInjectable from "../install-extension/install-extension.injectable"; -import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import { delay } from "../../renderer/utils"; -import { observable, runInAction, when } from "mobx"; -import readJsonFileInjectable from "../../common/fs/read-json-file.injectable"; -import pathExistsInjectable from "../../common/fs/path-exists.injectable"; -import watchInjectable from "../../common/fs/watch/watch.injectable"; -import extensionApiVersionInjectable from "../../common/vars/extension-api-version.injectable"; -import removePathInjectable from "../../common/fs/remove.injectable"; -import type { JoinPaths } from "../../common/path/join-paths.injectable"; -import joinPathsInjectable from "../../common/path/join-paths.injectable"; -import homeDirectoryPathInjectable from "../../common/os/home-directory-path.injectable"; -import pathExistsSyncInjectable from "../../common/fs/path-exists-sync.injectable"; -import readJsonSyncInjectable from "../../common/fs/read-json-sync.injectable"; -import writeJsonSyncInjectable from "../../common/fs/write-json-sync.injectable"; - -describe("ExtensionDiscovery", () => { - let extensionDiscovery: ExtensionDiscovery; - let readJsonFileMock: jest.Mock; - let pathExistsMock: jest.Mock; - let watchMock: jest.Mock; - let joinPaths: JoinPaths; - let homeDirectoryPath: string; - - beforeEach(() => { - const di = getDiForUnitTesting({ doGeneralOverrides: true }); - - di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data"); - di.override(installExtensionInjectable, () => () => Promise.resolve()); - di.override(extensionApiVersionInjectable, () => "5.0.0"); - di.override(pathExistsSyncInjectable, () => () => { throw new Error("tried call pathExistsSync without override"); }); - di.override(readJsonSyncInjectable, () => () => { throw new Error("tried call readJsonSync without override"); }); - di.override(writeJsonSyncInjectable, () => () => { throw new Error("tried call writeJsonSync without override"); }); - - joinPaths = di.inject(joinPathsInjectable); - homeDirectoryPath = di.inject(homeDirectoryPathInjectable); - - readJsonFileMock = jest.fn(); - di.override(readJsonFileInjectable, () => readJsonFileMock); - - pathExistsMock = jest.fn(() => Promise.resolve(true)); - di.override(pathExistsInjectable, () => pathExistsMock); - - watchMock = jest.fn(); - di.override(watchInjectable, () => watchMock); - - di.override(removePathInjectable, () => async () => {}); // allow deleting files for now - - extensionDiscovery = di.inject(extensionDiscoveryInjectable); - }); - - it("emits add for added extension", async () => { - const letTestFinish = observable.box(false); - let addHandler!: (filePath: string) => void; - - readJsonFileMock.mockImplementation((p) => { - expect(p).toBe(joinPaths(homeDirectoryPath, ".k8slens/extensions/my-extension/package.json")); - - return { - name: "my-extension", - version: "1.0.0", - engines: { - lens: "5.0.0", - }, - }; - }); - - const mockWatchInstance = { - on: jest.fn((event: string, handler: typeof addHandler) => { - if (event === "add") { - addHandler = handler; - } - - return mockWatchInstance; - }), - } as unknown as FSWatcher; - - watchMock.mockImplementationOnce(() => mockWatchInstance); - - // Need to force isLoaded to be true so that the file watching is started - extensionDiscovery.isLoaded = true; - - await extensionDiscovery.watchExtensions(); - - extensionDiscovery.events.on("add", extension => { - expect(extension).toEqual({ - absolutePath: expect.any(String), - id: "/some-directory-for-user-data/node_modules/my-extension/package.json", - isBundled: false, - isEnabled: false, - isCompatible: true, - manifest: { - name: "my-extension", - version: "1.0.0", - engines: { - lens: "5.0.0", - }, - }, - manifestPath: "/some-directory-for-user-data/node_modules/my-extension/package.json", - }); - runInAction(() => letTestFinish.set(true)); - }); - - addHandler(joinPaths(extensionDiscovery.localFolderPath, "/my-extension/package.json")); - await when(() => letTestFinish.get()); - }); - - it("doesn't emit add for added file under extension", async () => { - let addHandler!: (filePath: string) => void; - - const mockWatchInstance = { - on: jest.fn((event: string, handler: typeof addHandler) => { - if (event === "add") { - addHandler = handler; - } - - return mockWatchInstance; - }), - } as unknown as FSWatcher; - - watchMock.mockImplementationOnce(() => mockWatchInstance); - - // Need to force isLoaded to be true so that the file watching is started - extensionDiscovery.isLoaded = true; - - await extensionDiscovery.watchExtensions(); - - const onAdd = jest.fn(); - - extensionDiscovery.events.on("add", onAdd); - - addHandler(joinPaths(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json")); - - await delay(10); - - expect(onAdd).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/core/src/extensions/extension-discovery/extension-discovery.ts b/packages/core/src/extensions/extension-discovery/extension-discovery.ts deleted file mode 100644 index ab4aa11cd7..0000000000 --- a/packages/core/src/extensions/extension-discovery/extension-discovery.ts +++ /dev/null @@ -1,425 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { ipcRenderer } from "electron"; -import { EventEmitter } from "events"; -import type { ObservableMap } from "mobx"; -import { makeObservable, observable, reaction, when } from "mobx"; -import { broadcastMessage, ipcMainHandle, ipcRendererOn } from "../../common/ipc"; -import { isErrnoException, iter, toJS } from "../../common/utils"; -import type { ExtensionsStore } from "../extensions-store/extensions-store"; -import type { BundledLensExtensionManifest, LensExtensionId, LensExtensionManifest } from "../lens-extension"; -import type { ExtensionInstallationStateStore } from "../extension-installation-state-store/extension-installation-state-store"; -import { extensionDiscoveryStateChannel } from "../../common/ipc/extension-handling"; -import { requestInitialExtensionDiscovery } from "../../renderer/ipc"; -import type { ReadJson } from "../../common/fs/read-json-file.injectable"; -import type { Logger } from "../../common/logger"; -import type { PathExists } from "../../common/fs/path-exists.injectable"; -import type { Watch } from "../../common/fs/watch/watch.injectable"; -import type { Stats } from "fs"; -import type { LStat } from "../../common/fs/lstat.injectable"; -import type { ReadDirectory } from "../../common/fs/read-directory.injectable"; -import type { EnsureDirectory } from "../../common/fs/ensure-dir.injectable"; -import type { AccessPath } from "../../common/fs/access-path.injectable"; -import type { Copy } from "../../common/fs/copy.injectable"; -import type { JoinPaths } from "../../common/path/join-paths.injectable"; -import type { GetBasenameOfPath } from "../../common/path/get-basename.injectable"; -import type { GetDirnameOfPath } from "../../common/path/get-dirname.injectable"; -import type { GetRelativePath } from "../../common/path/get-relative-path.injectable"; -import type { RemovePath } from "../../common/fs/remove.injectable"; -import type TypedEventEmitter from "typed-emitter"; - -interface Dependencies { - readonly extensionsStore: ExtensionsStore; - readonly extensionInstallationStateStore: ExtensionInstallationStateStore; - readonly extensionPackageRootDirectory: string; - readonly resourcesDirectory: string; - readonly logger: Logger; - readonly isProduction: boolean; - readonly fileSystemSeparator: string; - readonly homeDirectoryPath: string; - readonly installedExtensions: ObservableMap; - isCompatibleExtension: (manifest: LensExtensionManifest) => boolean; - installExtension: (name: string) => Promise; - readJsonFile: ReadJson; - pathExists: PathExists; - removePath: RemovePath; - lstat: LStat; - watch: Watch; - readDirectory: ReadDirectory; - ensureDirectory: EnsureDirectory; - accessPath: AccessPath; - copy: Copy; - joinPaths: JoinPaths; - getBasenameOfPath: GetBasenameOfPath; - getDirnameOfPath: GetDirnameOfPath; - getRelativePath: GetRelativePath; -} - -export interface BaseInstalledExtension { - readonly id: LensExtensionId; -} - -export interface BundledInstalledExtension extends BaseInstalledExtension { - readonly manifest: BundledLensExtensionManifest; - readonly isBundled: true; - readonly isCompatible: true; - readonly isEnabled: true; -} - -export interface ExternalInstalledExtension extends BaseInstalledExtension { - readonly manifest: LensExtensionManifest; - readonly isBundled: false; - readonly isCompatible: boolean; - // Absolute path to the non-symlinked source folder, - // e.g. "/Users/user/.k8slens/extensions/helloworld" - readonly absolutePath: string; - // Absolute to the symlinked package.json file - readonly manifestPath: string; - isEnabled: boolean; -} - -export type InstalledExtension = BundledInstalledExtension | ExternalInstalledExtension; - -const logModule = "[EXTENSION-DISCOVERY]"; - -export const manifestFilename = "package.json"; - -interface ExtensionDiscoveryChannelMessage { - isLoaded: boolean; -} - -/** - * Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link) - * @param lstat the stats to compare - */ -const isDirectoryLike = (lstat: Stats) => lstat.isDirectory() || lstat.isSymbolicLink(); - -interface ExtensionDiscoveryEvents { - add: (ext: InstalledExtension) => void; - remove: (ext: InstalledExtension) => void; -} - -/** - * Discovers installed bundled and local extensions from the filesystem. - * Also watches for added and removed local extensions by watching the directory. - * Uses ExtensionInstaller to install dependencies for all of the extensions. - * This is also done when a new extension is copied to the local extensions directory. - * .init() must be called to start the directory watching. - * The class emits events for added and removed extensions: - * - "add": When extension is added. The event is of type InstalledExtension - * - "remove": When extension is removed. The event is of type LensExtensionId - */ -export class ExtensionDiscovery { - protected bundledFolderPath!: string; - - private loadStarted = false; - - // True if extensions have been loaded from the disk after app startup - @observable isLoaded = false; - - get whenLoaded() { - return when(() => this.isLoaded); - } - - public readonly events: TypedEventEmitter = new EventEmitter(); - - constructor(protected readonly dependencies: Dependencies) { - makeObservable(this); - } - - get localFolderPath(): string { - return this.dependencies.joinPaths(this.dependencies.homeDirectoryPath, ".k8slens", "extensions"); - } - - get packageJsonPath(): string { - return this.dependencies.joinPaths(this.dependencies.extensionPackageRootDirectory, manifestFilename); - } - - get nodeModulesPath(): string { - return this.dependencies.joinPaths(this.dependencies.extensionPackageRootDirectory, "node_modules"); - } - - /** - * Initializes the class and setups the file watcher for added/removed local extensions. - */ - async init(): Promise { - if (ipcRenderer) { - await this.initRenderer(); - } else { - await this.initMain(); - } - } - - async initRenderer(): Promise { - const onMessage = ({ isLoaded }: ExtensionDiscoveryChannelMessage) => { - this.isLoaded = isLoaded; - }; - - requestInitialExtensionDiscovery().then(onMessage); - ipcRendererOn(extensionDiscoveryStateChannel, (_event, message: ExtensionDiscoveryChannelMessage) => { - onMessage(message); - }); - } - - async initMain(): Promise { - ipcMainHandle(extensionDiscoveryStateChannel, () => this.toJSON()); - - reaction(() => this.toJSON(), () => { - this.broadcast(); - }); - } - - /** - * Watches for added/removed local extensions. - * Dependencies are installed automatically after an extension folder is copied. - */ - watchExtensions() { - this.dependencies.logger.info(`${logModule} watching extension add/remove in ${this.localFolderPath}`); - - this.dependencies.watch(this.localFolderPath, { - // For adding and removing symlinks to work, the depth has to be 1. - depth: 1, - ignoreInitial: true, - // Try to wait until the file has been completely copied. - // The OS might emit an event for added file even it's not completely written to the file-system. - awaitWriteFinish: { - // Wait 300ms until the file size doesn't change to consider the file written. - // For a small file like package.json this should be plenty of time. - stabilityThreshold: 300, - }, - }) - // Extension add is detected by watching "/package.json" add - .on("add", this.handleWatchFileAdd) - // Extension remove is detected by watching "" unlink - .on("unlinkDir", this.handleWatchUnlinkEvent) - // Extension remove is detected by watching "" unlink - .on("unlink", this.handleWatchUnlinkEvent); - } - - handleWatchFileAdd = async (manifestPath: string): Promise => { - // e.g. "foo/package.json" - const relativePath = this.dependencies.getRelativePath(this.localFolderPath, manifestPath); - - // Converts "foo/package.json" to ["foo", "package.json"], where length of 2 implies - // that the added file is in a folder under local folder path. - // This safeguards against a file watch being triggered under a sub-directory which is not an extension. - const isUnderLocalFolderPath = relativePath.split(this.dependencies.fileSystemSeparator).length === 2; - - if (this.dependencies.getBasenameOfPath(manifestPath) === manifestFilename && isUnderLocalFolderPath) { - try { - this.dependencies.extensionInstallationStateStore.setInstallingFromMain(manifestPath); - const absPath = this.dependencies.getDirnameOfPath(manifestPath); - - // this.loadExtensionFromPath updates this.packagesJson - const extension = await this.loadExtensionFromFolder(absPath); - - if (extension) { - // Install dependencies for the new extension - await this.dependencies.installExtension(extension.absolutePath); - - this.dependencies.installedExtensions.set(extension.id, extension); - this.dependencies.logger.info(`${logModule} Added extension ${extension.manifest.name}`); - this.events.emit("add", extension); - } - } catch (error) { - this.dependencies.logger.error(`${logModule}: failed to add extension: ${error}`, { error }); - } finally { - this.dependencies.extensionInstallationStateStore.clearInstallingFromMain(manifestPath); - } - } - }; - - /** - * Handle any unlink event, filtering out non-package.json links so the delete code - * only happens once per extension. - * @param filePath The absolute path to either a folder or file in the extensions folder - */ - handleWatchUnlinkEvent = async (filePath: string): Promise => { - // Check that the removed path is directly under this.localFolderPath - // Note that the watcher can create unlink events for subdirectories of the extension - const extensionFolderName = this.dependencies.getBasenameOfPath(filePath); - const expectedPath = this.dependencies.getRelativePath(this.localFolderPath, filePath); - - if (expectedPath !== extensionFolderName) { - return; - } - - const extension = iter.find( - this.dependencies.installedExtensions.values(), - (ext) => !ext.isBundled && ext.absolutePath === filePath, - ); - - if (!extension) { - this.dependencies.logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`); - - return; - } - - // If the extension is deleted manually while the application is running, also remove the symlink - await this.removeSymlinkByPackageName(extension.manifest.name); - - this.dependencies.installedExtensions.delete(extension.id); - this.dependencies.logger.info(`${logModule} removed extension ${extension.manifest.name}`); - this.events.emit("remove", extension); - }; - - /** - * Remove the symlink under node_modules if exists. - * If we don't remove the symlink, the uninstall would leave a non-working symlink, - * which wouldn't be fixed if the extension was reinstalled, causing the extension not to work. - * @param name e.g. "@mirantis/lens-extension-cc" - */ - removeSymlinkByPackageName(name: string): Promise { - return this.dependencies.removePath(this.getInstalledPath(name)); - } - - /** - * Uninstalls extension. - * The application will detect the folder unlink and remove the extension from the UI automatically. - * @param extensionId The ID of the extension to uninstall. - */ - async uninstallExtension(extensionId: LensExtensionId): Promise { - const extension = this.dependencies.installedExtensions.get(extensionId); - - if (!extension) { - return void this.dependencies.logger.warn(`${logModule} could not uninstall extension, not found`, { id: extensionId }); - } - - if (extension.isBundled) { - return void this.dependencies.logger.warn(`${logModule} could not uninstall extension, is bundled`, { id: extensionId }); - } - - const { manifest, absolutePath } = extension; - - this.dependencies.logger.info(`${logModule} Uninstalling ${manifest.name}`); - - await this.removeSymlinkByPackageName(manifest.name); - - // fs.remove does nothing if the path doesn't exist anymore - await this.dependencies.removePath(absolutePath); - } - - async load() { - if (this.loadStarted) { - // The class is simplified by only supporting .load() to be called once - throw new Error("ExtensionDiscovery.load() can be only be called once"); - } - - this.loadStarted = true; - - this.dependencies.logger.info( - `${logModule} loading extensions from ${this.dependencies.extensionPackageRootDirectory}`, - ); - - await this.dependencies.removePath(this.dependencies.joinPaths(this.dependencies.extensionPackageRootDirectory, "package-lock.json")); - await this.dependencies.ensureDirectory(this.nodeModulesPath); - await this.dependencies.ensureDirectory(this.localFolderPath); - - const userExtensions = await this.loadFromFolder(this.localFolderPath); - - this.dependencies.installedExtensions.replace(userExtensions.map(ext => [ext.id, ext])); - this.isLoaded = true; - } - - /** - * Returns the symlinked path to the extension folder, - * e.g. "/Users//Library/Application Support/Lens/node_modules/@publisher/extension" - */ - protected getInstalledPath(name: string): string { - return this.dependencies.joinPaths(this.nodeModulesPath, name); - } - - /** - * Returns the symlinked path to the package.json, - * e.g. "/Users//Library/Application Support/Lens/node_modules/@publisher/extension/package.json" - */ - protected getInstalledManifestPath(name: string): string { - return this.dependencies.joinPaths(this.getInstalledPath(name), manifestFilename); - } - - /** - * Returns InstalledExtension from path to package.json file. - * Also updates this.packagesJson. - */ - protected async loadExtensionFromFolder(folderPath: string): Promise { - const manifestPath = this.dependencies.joinPaths(folderPath, manifestFilename); - - try { - const manifest = await this.dependencies.readJsonFile(manifestPath) as unknown as LensExtensionManifest; - const id = this.getInstalledManifestPath(manifest.name); - const isEnabled = this.dependencies.extensionsStore.isEnabled(id); - const extensionDir = this.dependencies.getDirnameOfPath(manifestPath); - const npmPackage = this.dependencies.joinPaths(extensionDir, `${manifest.name}-${manifest.version}.tgz`); - const absolutePath = this.dependencies.isProduction && await this.dependencies.pathExists(npmPackage) - ? npmPackage - : extensionDir; - const isCompatible = this.dependencies.isCompatibleExtension(manifest); - - return { - id, - absolutePath, - manifestPath: id, - manifest, - isBundled: false, - isEnabled, - isCompatible, - }; - } catch (error) { - if (isErrnoException(error) && error.code === "ENOTDIR") { - // ignore this error, probably from .DS_Store file - this.dependencies.logger.debug(`${logModule}: failed to load extension manifest through a not-dir-like at ${manifestPath}`); - } else { - this.dependencies.logger.error(`${logModule}: can't load extension manifest at ${manifestPath}: ${error}`); - } - - return null; - } - } - - async loadFromFolder(folderPath: string): Promise { - const extensions: ExternalInstalledExtension[] = []; - const paths = await this.dependencies.readDirectory(folderPath); - - for (const fileName of paths) { - const absPath = this.dependencies.joinPaths(folderPath, fileName); - - try { - const lstat = await this.dependencies.lstat(absPath); - - // skip non-directories - if (!isDirectoryLike(lstat)) { - continue; - } - } catch (error) { - if (isErrnoException(error) && error.code === "ENOENT") { - continue; - } - - throw error; - } - - const extension = await this.loadExtensionFromFolder(absPath); - - if (extension) { - extensions.push(extension); - } - } - - this.dependencies.logger.debug(`${logModule}: ${extensions.length} extensions loaded`, { folderPath, extensions }); - - return extensions; - } - - toJSON(): ExtensionDiscoveryChannelMessage { - return toJS({ - isLoaded: this.isLoaded, - }); - } - - broadcast(): void { - broadcastMessage(extensionDiscoveryStateChannel, this.toJSON()); - } -} diff --git a/packages/core/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.injectable.ts b/packages/core/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.injectable.ts deleted file mode 100644 index de2fd4390f..0000000000 --- a/packages/core/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.injectable.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import extensionApiVersionInjectable from "../../../common/vars/extension-api-version.injectable"; -import { isCompatibleExtension } from "./is-compatible-extension"; - -const isCompatibleExtensionInjectable = getInjectable({ - id: "is-compatible-extension", - instantiate: (di) => isCompatibleExtension({ - extensionApiVersion: di.inject(extensionApiVersionInjectable), - }), -}); - -export default isCompatibleExtensionInjectable; diff --git a/packages/core/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.ts b/packages/core/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.ts deleted file mode 100644 index 74cbb4fd0c..0000000000 --- a/packages/core/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import semver from "semver"; -import type { LensExtensionManifest } from "../../lens-extension"; - -interface Dependencies { - extensionApiVersion: string; -} - -export const isCompatibleExtension = ({ extensionApiVersion }: Dependencies): ((manifest: LensExtensionManifest) => boolean) => { - return (manifest: LensExtensionManifest): boolean => { - const manifestLensEngine = manifest.engines.lens; - const validVersion = manifestLensEngine.match(/^[\^0-9]\d*\.\d+\b/); // must start from ^ or number - - if (!validVersion) { - const errorInfo = [ - `Invalid format for "manifest.engines.lens"="${manifestLensEngine}"`, - `Range versions can only be specified starting with '^'.`, - `Otherwise it's recommended to use plain %MAJOR.%MINOR to match with supported Lens version.`, - ].join("\n"); - - throw new Error(errorInfo); - } - - const { major: extMajor, minor: extMinor } = semver.coerce(manifestLensEngine, { - loose: true, - }) as semver.SemVer; - const supportedVersionsByExtension = semver.validRange(`^${extMajor}.${extMinor}`) as string; - - return semver.satisfies(extensionApiVersion, supportedVersionsByExtension, { - loose: true, - includePrerelease: false, - }); - }; -}; diff --git a/packages/core/src/extensions/extension-loader/file-system-provisioner-store/ensure-hashed-directory-for-extension.injectable.ts b/packages/core/src/extensions/extension-loader/file-system-provisioner-store/ensure-hashed-directory-for-extension.injectable.ts index 50051bf094..34811cb058 100644 --- a/packages/core/src/extensions/extension-loader/file-system-provisioner-store/ensure-hashed-directory-for-extension.injectable.ts +++ b/packages/core/src/extensions/extension-loader/file-system-provisioner-store/ensure-hashed-directory-for-extension.injectable.ts @@ -9,7 +9,7 @@ import { getOrInsert } from "../../../common/utils"; import randomBytesInjectable from "../../../common/utils/random-bytes.injectable"; import joinPathsInjectable from "../../../common/path/join-paths.injectable"; import directoryForExtensionDataInjectable from "./directory-for-extension-data.injectable"; -import ensureDirInjectable from "../../../common/fs/ensure-dir.injectable"; +import ensureDirectoryInjectable from "../../../common/fs/ensure-directory.injectable"; import getHashInjectable from "./get-hash.injectable"; import getPathToLegacyPackageJsonInjectable from "./get-path-to-legacy-package-json.injectable"; import { registeredExtensionsInjectable } from "./registered-extensions.injectable"; @@ -23,7 +23,7 @@ const ensureHashedDirectoryForExtensionInjectable = getInjectable({ const randomBytes = di.inject(randomBytesInjectable); const joinPaths = di.inject(joinPathsInjectable); const directoryForExtensionData = di.inject(directoryForExtensionDataInjectable); - const ensureDirectory = di.inject(ensureDirInjectable); + const ensureDirectory = di.inject(ensureDirectoryInjectable); const getHash = di.inject(getHashInjectable); const getPathToLegacyPackageJson = di.inject(getPathToLegacyPackageJsonInjectable); const registeredExtensions = di.inject(registeredExtensionsInjectable); diff --git a/packages/core/src/extensions/extensions-store/extensions-store.ts b/packages/core/src/extensions/extensions-store/extensions-store.ts index 443cf1b9b7..bdfa982d8c 100644 --- a/packages/core/src/extensions/extensions-store/extensions-store.ts +++ b/packages/core/src/extensions/extensions-store/extensions-store.ts @@ -36,10 +36,8 @@ export class ExtensionsStore extends BaseStore { readonly state = observable.map(); - isEnabled(extId: LensExtensionId): boolean { - // By default false, so that copied extensions are disabled by default. - // If user installs the extension from the UI, the Extensions component will specifically enable it. - return this.state.get(extId)?.enabled ?? false; + isEnabled(id: LensExtensionId): boolean { + return this.state.get(id)?.enabled ?? false; } @action diff --git a/packages/core/src/extensions/install-extension/extension-package-root-directory.injectable.ts b/packages/core/src/extensions/install-extension/extension-package-root-directory.injectable.ts deleted file mode 100644 index ffa0a7666d..0000000000 --- a/packages/core/src/extensions/install-extension/extension-package-root-directory.injectable.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; - -const extensionPackageRootDirectoryInjectable = getInjectable({ - id: "extension-package-root-directory", - - instantiate: (di) => di.inject(directoryForUserDataInjectable), -}); - -export default extensionPackageRootDirectoryInjectable; diff --git a/packages/core/src/extensions/install-extension/install-extension.injectable.ts b/packages/core/src/extensions/install-extension/install-extension.injectable.ts deleted file mode 100644 index ca46772eb3..0000000000 --- a/packages/core/src/extensions/install-extension/install-extension.injectable.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import { fork } from "child_process"; -import AwaitLock from "await-lock"; -import pathToNpmCliInjectable from "../../common/app-paths/path-to-npm-cli.injectable"; -import extensionPackageRootDirectoryInjectable from "./extension-package-root-directory.injectable"; -import prefixedLoggerInjectable from "../../common/logger/prefixed-logger.injectable"; -import readJsonFileInjectable from "../../common/fs/read-json-file.injectable"; -import joinPathsInjectable from "../../common/path/join-paths.injectable"; -import type { PackageJson } from "../common-api"; -import writeJsonFileInjectable from "../../common/fs/write-json-file.injectable"; -import { once } from "lodash"; -import { isErrnoException } from "../../common/utils"; - -const baseNpmInstallArgs = [ - "install", - "--save-optional", - "--audit=false", - "--fund=false", - // NOTE: we do not omit the `optional` dependencies because that is how we specify the non-bundled extensions - "--omit=dev", - "--omit=peer", - "--prefer-offline", -]; - -export type InstallExtension = (name: string) => Promise; - -const installExtensionInjectable = getInjectable({ - id: "install-extension", - instantiate: (di): InstallExtension => { - const pathToNpmCli = di.inject(pathToNpmCliInjectable); - const extensionPackageRootDirectory = di.inject(extensionPackageRootDirectoryInjectable); - const readJsonFile = di.inject(readJsonFileInjectable); - const writeJsonFile = di.inject(writeJsonFileInjectable); - const joinPaths = di.inject(joinPathsInjectable); - const logger = di.inject(prefixedLoggerInjectable, "EXTENSION-INSTALLER"); - - const forkNpm = (...args: string[]) => new Promise((resolve, reject) => { - const child = fork(pathToNpmCli, args, { - cwd: extensionPackageRootDirectory, - silent: true, - env: {}, - }); - let stderr = ""; - - child.stderr?.on("data", data => { - stderr += String(data); - }); - - child.on("close", (code) => { - if (code !== 0) { - reject(new Error(stderr)); - } else { - resolve(); - } - }); - - child.on("error", error => { - reject(error); - }); - }); - - const packageJsonPath = joinPaths(extensionPackageRootDirectory, "package.json"); - - /** - * NOTES: - * - We have to keep the `package.json` because `npm install` removes files from `node_modules` - * if they are no longer in the `package.json` - * - In v6.2.X we saved bundled extensions as `"dependencies"` and external extensions as - * `"optionalDependencies"` at startup. This was done because `"optionalDependencies"` can - * fail to install and that is OK. - * - We continue to maintain this behavior here by only installing new dependencies as - * `"optionalDependencies"` - */ - const fixupPackageJson = once(async () => { - try { - const packageJson = await readJsonFile(packageJsonPath) as PackageJson; - - delete packageJson.dependencies; - - await writeJsonFile(packageJsonPath, packageJson); - } catch (error) { - if (isErrnoException(error) && error.code === "ENOENT") { - return; - } - - throw error; - } - }); - - const installLock = new AwaitLock(); - - return async (name) => { - await installLock.acquireAsync(); - await fixupPackageJson(); - - try { - logger.info(`installing package for extension "${name}"`); - await forkNpm(...baseNpmInstallArgs, name); - logger.info(`installed package for extension "${name}"`); - } finally { - installLock.release(); - } - }; - }, -}); - -export default installExtensionInjectable; diff --git a/packages/core/src/extensions/extension-discovery/bundled-extension-token.ts b/packages/core/src/features/extensions/common/bundled-extension-token.ts similarity index 91% rename from packages/core/src/extensions/extension-discovery/bundled-extension-token.ts rename to packages/core/src/features/extensions/common/bundled-extension-token.ts index c4415ddc25..f4116cf906 100644 --- a/packages/core/src/extensions/extension-discovery/bundled-extension-token.ts +++ b/packages/core/src/features/extensions/common/bundled-extension-token.ts @@ -4,7 +4,7 @@ */ import { getInjectionToken } from "@ogre-tools/injectable"; -import type { BundledLensExtensionManifest, BundledLensExtensionContructor } from "../lens-extension"; +import type { BundledLensExtensionManifest, BundledLensExtensionContructor } from "../../../extensions/lens-extension"; export interface BundledExtension { readonly manifest: BundledLensExtensionManifest; diff --git a/packages/core/src/features/extensions/common/installed-extension.ts b/packages/core/src/features/extensions/common/installed-extension.ts new file mode 100644 index 0000000000..20a29ee719 --- /dev/null +++ b/packages/core/src/features/extensions/common/installed-extension.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { LensExtensionId, LensExtensionManifest } from "../../../extensions/lens-extension"; + +export interface InstalledExtension { + readonly id: LensExtensionId; + + readonly manifest: LensExtensionManifest; + + // Absolute path to the non-symlinked source folder, + // e.g. "/Users/user/.k8slens/extensions/helloworld" + readonly absolutePath: string; + + // Absolute to the symlinked package.json file + readonly manifestPath: string; + readonly isBundled: boolean; // defined in project root's package.json + readonly isCompatible: boolean; + isEnabled: boolean; +} diff --git a/packages/core/src/features/extensions/discovery/common/exec-npm.global-override-for-injectable.ts b/packages/core/src/features/extensions/discovery/common/exec-npm.global-override-for-injectable.ts new file mode 100644 index 0000000000..41530db407 --- /dev/null +++ b/packages/core/src/features/extensions/discovery/common/exec-npm.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverrideForFunction } from "../../../../common/test-utils/get-global-override-for-function"; +import execNpmInjectable from "./exec-npm.injectable"; + +export default getGlobalOverrideForFunction(execNpmInjectable); diff --git a/packages/core/src/features/extensions/discovery/common/exec-npm.injectable.ts b/packages/core/src/features/extensions/discovery/common/exec-npm.injectable.ts new file mode 100644 index 0000000000..f35dcc0480 --- /dev/null +++ b/packages/core/src/features/extensions/discovery/common/exec-npm.injectable.ts @@ -0,0 +1,55 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { fork } from "child_process"; +import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import pathToNpmCliInjectable from "../../../../common/app-paths/path-to-npm-cli.injectable"; +import type { AsyncResult } from "../../../../common/utils/async-result"; + +export type ExecNpm = (...args: string[]) => Promise>; + +const execNpmInjectable = getInjectable({ + id: "exec-npm", + instantiate: (di): ExecNpm => { + const pathToNpmCli = di.inject(pathToNpmCliInjectable); + const directoryForUserData = di.inject(directoryForUserDataInjectable); + + return (...args) => new Promise((resolve) => { + const child = fork(pathToNpmCli, args, { + cwd: directoryForUserData, + silent: true, + env: {}, + }); + let stderr = ""; + + child.stderr?.on("data", data => { + stderr += String(data); + }); + + child.on("close", (code) => { + if (code !== 0) { + resolve({ + callWasSuccessful: false, + error: new Error(stderr), + }); + } else { + resolve({ + callWasSuccessful: true, + }); + } + }); + + child.on("error", error => { + resolve({ + callWasSuccessful: false, + error, + }); + }); + }); + }, + causesSideEffects: true, +}); + +export default execNpmInjectable; diff --git a/packages/core/src/features/extensions/discovery/common/extension-node-modules-directory-path.injectable.ts b/packages/core/src/features/extensions/discovery/common/extension-node-modules-directory-path.injectable.ts new file mode 100644 index 0000000000..5f668f1d51 --- /dev/null +++ b/packages/core/src/features/extensions/discovery/common/extension-node-modules-directory-path.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import joinPathsInjectable from "../../../../common/path/join-paths.injectable"; + +const extensionsNodeModulesDirectoryPathInjectable = getInjectable({ + id: "extensions-node-modules-directory-path", + instantiate: (di) => { + const directoryForUserData = di.inject(directoryForUserDataInjectable); + const joinPaths = di.inject(joinPathsInjectable); + + return joinPaths(directoryForUserData, "node_modules"); + }, +}); + +export default extensionsNodeModulesDirectoryPathInjectable; diff --git a/packages/core/src/features/extensions/discovery/common/initial-load-completed.injectable.ts b/packages/core/src/features/extensions/discovery/common/initial-load-completed.injectable.ts new file mode 100644 index 0000000000..7234322801 --- /dev/null +++ b/packages/core/src/features/extensions/discovery/common/initial-load-completed.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import createSyncBoxInjectable from "../../../../common/utils/sync-box/create-sync-box.injectable"; +import { syncBoxInjectionToken } from "../../../../common/utils/sync-box/sync-box-injection-token"; + +const initialDiscoveryLoadCompletedInjectable = getInjectable({ + id: "initial-discovery-load-completed", + instantiate: (di) => { + const createSyncBox = di.inject(createSyncBoxInjectable); + + return createSyncBox("initial-extension-discovery-load-complete", false); + }, + injectionToken: syncBoxInjectionToken, +}); + +export default initialDiscoveryLoadCompletedInjectable; diff --git a/packages/core/src/features/extensions/discovery/common/install-package.injectable.ts b/packages/core/src/features/extensions/discovery/common/install-package.injectable.ts new file mode 100644 index 0000000000..3b23f6c953 --- /dev/null +++ b/packages/core/src/features/extensions/discovery/common/install-package.injectable.ts @@ -0,0 +1,87 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import AwaitLock from "await-lock"; +import { once } from "lodash"; +import type { PackageJson } from "type-fest"; +import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import readJsonFileInjectable from "../../../../common/fs/read-json-file.injectable"; +import writeJsonFileInjectable from "../../../../common/fs/write-json-file.injectable"; +import prefixedLoggerInjectable from "../../../../common/logger/prefixed-logger.injectable"; +import joinPathsInjectable from "../../../../common/path/join-paths.injectable"; +import { isErrnoException } from "../../../../common/utils"; +import execNpmInjectable from "./exec-npm.injectable"; + +const baseNpmInstallArgs = [ + "install", + "--save-optional", + "--audit=false", + "--fund=false", + // NOTE: we do not omit the `optional` dependencies because that is how we specify the non-bundled extensions + "--omit=dev", + "--omit=peer", + "--prefer-offline", +]; + +export type InstallExtensionPackage = (name: string) => Promise; + +const installExtensionPackageInjectable = getInjectable({ + id: "install-extension-package", + instantiate: (di): InstallExtensionPackage => { + const logger = di.inject(prefixedLoggerInjectable, "EXTENSION-INSTALLER"); + const execNpm = di.inject(execNpmInjectable); + const directoryForUserData = di.inject(directoryForUserDataInjectable); + const joinPaths = di.inject(joinPathsInjectable); + const readJsonFile = di.inject(readJsonFileInjectable); + const writeJsonFile = di.inject(writeJsonFileInjectable); + + const installLock = new AwaitLock(); + const packageJsonPath = joinPaths(directoryForUserData, "package.json"); + + /** + * NOTES: + * - We have to keep the `package.json` because `npm install` removes files from `node_modules` + * if they are no longer in the `package.json` + * - In v6.2.X we saved bundled extensions as `"dependencies"` and external extensions as + * `"optionalDependencies"` at startup. This was done because `"optionalDependencies"` can + * fail to install and that is OK. + * - We continue to maintain this behavior here by only installing new dependencies as + * `"optionalDependencies"` + */ + const fixupPackageJson = once(async () => { + try { + const packageJson = await readJsonFile(packageJsonPath) as PackageJson; + + delete packageJson.dependencies; + + await writeJsonFile(packageJsonPath, packageJson); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") { + return; + } + + throw error; + } + }); + + return async (name) => { + await installLock.acquireAsync(); + + logger.info(`Installing "${name}"`); + + await fixupPackageJson(); + + const result = await execNpm(...baseNpmInstallArgs, name); + + if (!result.callWasSuccessful) { + logger.warn(`Failed to install "${name}"`, result.error); + } + + installLock.release(); + }; + }, +}); + +export default installExtensionPackageInjectable; diff --git a/packages/core/src/features/extensions/discovery/common/local-extensions-directory-path.injectable.ts b/packages/core/src/features/extensions/discovery/common/local-extensions-directory-path.injectable.ts new file mode 100644 index 0000000000..90ba6e6d14 --- /dev/null +++ b/packages/core/src/features/extensions/discovery/common/local-extensions-directory-path.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import homeDirectoryPathInjectable from "../../../../common/os/home-directory-path.injectable"; +import joinPathsInjectable from "../../../../common/path/join-paths.injectable"; + +const localExtensionsDirectoryPathInjectable = getInjectable({ + id: "local-extensions-directory-path", + instantiate: (di) => { + const joinPaths = di.inject(joinPathsInjectable); + const homeDirectoryPath = di.inject(homeDirectoryPathInjectable); + + return joinPaths(homeDirectoryPath, ".k8slens", "extensions"); + }, +}); + +export default localExtensionsDirectoryPathInjectable; diff --git a/packages/core/src/features/extensions/discovery/common/logger.injectable.ts b/packages/core/src/features/extensions/discovery/common/logger.injectable.ts new file mode 100644 index 0000000000..17ff93e2f6 --- /dev/null +++ b/packages/core/src/features/extensions/discovery/common/logger.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import prefixedLoggerInjectable from "../../../../common/logger/prefixed-logger.injectable"; + +const extensionDiscoveryLoggerInjectable = getInjectable({ + id: "extension-discovery-logger", + instantiate: (di) => di.inject(prefixedLoggerInjectable, "EXTENSION-DISCOVERY"), +}); + +export default extensionDiscoveryLoggerInjectable; diff --git a/packages/core/src/features/extensions/discovery/common/remove-extension-symlink-by-name.injectable.ts b/packages/core/src/features/extensions/discovery/common/remove-extension-symlink-by-name.injectable.ts new file mode 100644 index 0000000000..a22b01ec83 --- /dev/null +++ b/packages/core/src/features/extensions/discovery/common/remove-extension-symlink-by-name.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import removePathInjectable from "../../../../common/fs/remove.injectable"; +import getExtensionInstallPathInjectable from "../main/get-extension-install-path.injectable"; + +/** + * Remove the symlink under node_modules if exists. + * If we don't remove the symlink, the uninstall would leave a non-working symlink, + * which wouldn't be fixed if the extension was reinstalled, causing the extension not to work. + * @param name e.g. "@mirantis/lens-extension-cc" + */ +export type RemoveExtensionSymlinkByName = (name: string) => Promise; + +const removeExtensionSymlinkByNameInjectable = getInjectable({ + id: "remove-extension-symlink-by-name", + instantiate: (di): RemoveExtensionSymlinkByName => { + const removePath = di.inject(removePathInjectable); + const getExtensionInstallPath = di.inject(getExtensionInstallPathInjectable); + + return (name) => removePath(getExtensionInstallPath(name)); + }, +}); + +export default removeExtensionSymlinkByNameInjectable; diff --git a/packages/core/src/features/extensions/discovery/common/uninstall-extension.injectable.ts b/packages/core/src/features/extensions/discovery/common/uninstall-extension.injectable.ts new file mode 100644 index 0000000000..41e300e8dc --- /dev/null +++ b/packages/core/src/features/extensions/discovery/common/uninstall-extension.injectable.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import removePathInjectable from "../../../../common/fs/remove.injectable"; +import type { LensExtensionId } from "../../../../extensions/lens-extension"; +import installedExtensionsInjectable from "../../common/installed-extensions.injectable"; +import extensionDiscoveryLoggerInjectable from "./logger.injectable"; +import removeExtensionSymlinkByNameInjectable from "./remove-extension-symlink-by-name.injectable"; + +/** + * The application will detect the folder unlink and remove the extension from the UI automatically. + * @param id The ID of the extension to uninstall. + */ +export type RemoveExtensionFiles = (id: LensExtensionId) => Promise; + +const removeExtensionFilesInjectable = getInjectable({ + id: "remove-extension-files", + instantiate: (di): RemoveExtensionFiles => { + const installedExtensions = di.inject(installedExtensionsInjectable); + const removePath = di.inject(removePathInjectable); + const logger = di.inject(extensionDiscoveryLoggerInjectable); + const removeExtensionSymlinkByName = di.inject(removeExtensionSymlinkByNameInjectable); + + return async (id): Promise => { + const extension = installedExtensions.get(id); + + if (!extension) { + return logger.warn(`could not uninstall extension, not found`, { id }); + } + + if (extension.isBundled) { + return logger.warn(`could not uninstall extension, is bundled`, { id }); + } + + const { manifest, absolutePath } = extension; + + logger.info(`Uninstalling ${manifest.name}`); + + await removeExtensionSymlinkByName(manifest.name); + + // fs.remove does nothing if the path doesn't exist anymore + await removePath(absolutePath); + }; + }, +}); + +export default removeExtensionFilesInjectable; diff --git a/packages/core/src/features/extensions/discovery/main/extension-file-add.injectable.ts b/packages/core/src/features/extensions/discovery/main/extension-file-add.injectable.ts new file mode 100644 index 0000000000..a3d86ea879 --- /dev/null +++ b/packages/core/src/features/extensions/discovery/main/extension-file-add.injectable.ts @@ -0,0 +1,66 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import getBasenameOfPathInjectable from "../../../../common/path/get-basename.injectable"; +import getDirnameOfPathInjectable from "../../../../common/path/get-dirname.injectable"; +import getRelativePathInjectable from "../../../../common/path/get-relative-path.injectable"; +import fileSystemSeparatorInjectable from "../../../../common/path/separator.injectable"; +import { manifestFilename } from "../../../../common/vars"; +import extensionInstallationStateStoreInjectable from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; +import installedExtensionsInjectable from "../../common/installed-extensions.injectable"; +import installExtensionPackageInjectable from "../common/install-package.injectable"; +import localExtensionsDirectoryPathInjectable from "../common/local-extensions-directory-path.injectable"; +import extensionDiscoveryLoggerInjectable from "../common/logger.injectable"; +import loadUserExtensionFromFolderInjectable from "./load-user-extension-from-folder.injectable"; + +const extensionFileAddedInjectable = getInjectable({ + id: "extension-file-added", + instantiate: (di) => { + const getRelativePath = di.inject(getRelativePathInjectable); + const localExtensionsDirectoryPath = di.inject(localExtensionsDirectoryPathInjectable); + const fileSystemSeparator = di.inject(fileSystemSeparatorInjectable); + const getBasenameOfPath = di.inject(getBasenameOfPathInjectable); + const extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable); + const getDirnameOfPath = di.inject(getDirnameOfPathInjectable); + const loadUserExtensionFromFolder = di.inject(loadUserExtensionFromFolderInjectable); + const installExtensionPackage = di.inject(installExtensionPackageInjectable); + const installedExtensions = di.inject(installedExtensionsInjectable); + const logger = di.inject(extensionDiscoveryLoggerInjectable); + + return async (manifestPath: string): Promise => { + // e.g. "foo/package.json" + const relativePath = getRelativePath(localExtensionsDirectoryPath, manifestPath); + + // Converts "foo/package.json" to ["foo", "package.json"], where length of 2 implies + // that the added file is in a folder under local folder path. + // This safeguards against a file watch being triggered under a sub-directory which is not an extension. + const isUnderLocalFolderPath = relativePath.split(fileSystemSeparator).length === 2; + + if (getBasenameOfPath(manifestPath) === manifestFilename && isUnderLocalFolderPath) { + try { + extensionInstallationStateStore.setInstallingFromMain(manifestPath); + const absPath = getDirnameOfPath(manifestPath); + + // this.loadExtensionFromPath updates this.packagesJson + const extension = await loadUserExtensionFromFolder(absPath); + + if (extension) { + // Install dependencies for the new extension + await installExtensionPackage(extension.absolutePath); + + installedExtensions.set(extension.id, extension); + logger.info(`Added extension ${extension.manifest.name}`); + } + } catch (error) { + logger.error(`failed to add extension: ${error}`, { error }); + } finally { + extensionInstallationStateStore.clearInstallingFromMain(manifestPath); + } + } + }; + }, +}); + +export default extensionFileAddedInjectable; diff --git a/packages/core/src/features/extensions/discovery/main/extension-file-removed.injectable.ts b/packages/core/src/features/extensions/discovery/main/extension-file-removed.injectable.ts new file mode 100644 index 0000000000..652ac1070d --- /dev/null +++ b/packages/core/src/features/extensions/discovery/main/extension-file-removed.injectable.ts @@ -0,0 +1,57 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import getBasenameOfPathInjectable from "../../../../common/path/get-basename.injectable"; +import getRelativePathInjectable from "../../../../common/path/get-relative-path.injectable"; +import { iter } from "../../../../common/utils"; +import installedExtensionsInjectable from "../../common/installed-extensions.injectable"; +import removeExtensionInstanceInjectable from "../../loader/common/remove-instance.injectable"; +import localExtensionsDirectoryPathInjectable from "../common/local-extensions-directory-path.injectable"; +import extensionDiscoveryLoggerInjectable from "../common/logger.injectable"; +import removeExtensionSymlinkByNameInjectable from "../common/remove-extension-symlink-by-name.injectable"; + +const extensionFileRemovedInjectable = getInjectable({ + id: "extension-file-removed", + instantiate: (di) => { + const getBasenameOfPath = di.inject(getBasenameOfPathInjectable); + const getRelativePath = di.inject(getRelativePathInjectable); + const removeExtensionSymlinkByName = di.inject(removeExtensionSymlinkByNameInjectable); + const removeExtensionInstance = di.inject(removeExtensionInstanceInjectable); + const localExtensionsDirectoryPath = di.inject(localExtensionsDirectoryPathInjectable); + const installedExtensions = di.inject(installedExtensionsInjectable); + const logger = di.inject(extensionDiscoveryLoggerInjectable); + + return async (filePath: string): Promise => { + // Check that the removed path is directly under this.dependencies.localExtensionsDirectoryPath + // Note that the watcher can create unlink events for subdirectories of the extension + const extensionFolderName = getBasenameOfPath(filePath); + const expectedPath = getRelativePath(localExtensionsDirectoryPath, filePath); + + if (expectedPath !== extensionFolderName) { + return; + } + + const extension = iter.find( + installedExtensions.values(), + (ext) => !ext.isBundled && ext.absolutePath === filePath, + ); + + if (!extension) { + logger.warn(`extension ${extensionFolderName} not found, can't remove`); + + return; + } + + // If the extension is deleted manually while the application is running, also remove the symlink + await removeExtensionSymlinkByName(extension.manifest.name); + + installedExtensions.delete(extension.id); + logger.info(`removed extension ${extension.manifest.name}`); + removeExtensionInstance(extension.id); + }; + }, +}); + +export default extensionFileRemovedInjectable; diff --git a/packages/core/src/features/extensions/discovery/main/get-extension-install-path.injectable.ts b/packages/core/src/features/extensions/discovery/main/get-extension-install-path.injectable.ts new file mode 100644 index 0000000000..6df00f1399 --- /dev/null +++ b/packages/core/src/features/extensions/discovery/main/get-extension-install-path.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import joinPathsInjectable from "../../../../common/path/join-paths.injectable"; +import extensionsNodeModulesDirectoryPathInjectable from "../common/extension-node-modules-directory-path.injectable"; + +export type GetExtensionInstallPath = (name: string) => string; + +const getExtensionInstallPathInjectable = getInjectable({ + id: "get-extension-install-path", + instantiate: (di): GetExtensionInstallPath => { + const joinPaths = di.inject(joinPathsInjectable); + const extensionsNodeModulesDirectoryPath = di.inject(extensionsNodeModulesDirectoryPathInjectable); + + return (name) => joinPaths(extensionsNodeModulesDirectoryPath, name); + }, +}); + +export default getExtensionInstallPathInjectable; diff --git a/packages/core/src/features/extensions/discovery/main/is-compatible-extension.injectable.ts b/packages/core/src/features/extensions/discovery/main/is-compatible-extension.injectable.ts new file mode 100644 index 0000000000..74aaaa9f0a --- /dev/null +++ b/packages/core/src/features/extensions/discovery/main/is-compatible-extension.injectable.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import semver from "semver"; +import extensionApiVersionInjectable from "../../../../common/vars/extension-api-version.injectable"; +import type { LensExtensionManifest } from "../../../../extensions/lens-extension"; + +export type IsCompatibleExtension = (manifest: LensExtensionManifest) => boolean; + +const isCompatibleExtensionInjectable = getInjectable({ + id: "is-compatible-extension", + instantiate: (di): IsCompatibleExtension => { + const extensionApiVersion = di.inject(extensionApiVersionInjectable); + + return (manifest: LensExtensionManifest): boolean => { + const manifestLensEngine = manifest.engines.lens; + const validVersion = manifestLensEngine.match(/^[\^0-9]\d*\.\d+\b/); // must start from ^ or number + + if (!validVersion) { + const errorInfo = [ + `Invalid format for "manifest.engines.lens"="${manifestLensEngine}"`, + `Range versions can only be specified starting with '^'.`, + `Otherwise it's recommended to use plain %MAJOR.%MINOR to match with supported Lens version.`, + ].join("\n"); + + throw new Error(errorInfo); + } + + const { major: extMajor, minor: extMinor } = semver.coerce(manifestLensEngine, { + loose: true, + }) as semver.SemVer; + const supportedVersionsByExtension = semver.validRange(`^${extMajor}.${extMinor}`) as string; + + return semver.satisfies(extensionApiVersion, supportedVersionsByExtension, { + loose: true, + includePrerelease: false, + }); + }; + }, +}); + +export default isCompatibleExtensionInjectable; diff --git a/packages/core/src/features/extensions/discovery/main/load-initial-extensions.injectable.ts b/packages/core/src/features/extensions/discovery/main/load-initial-extensions.injectable.ts new file mode 100644 index 0000000000..725606822d --- /dev/null +++ b/packages/core/src/features/extensions/discovery/main/load-initial-extensions.injectable.ts @@ -0,0 +1,89 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { Stats } from "fs-extra"; +import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import ensureDirectoryInjectable from "../../../../common/fs/ensure-directory.injectable"; +import lstatInjectable from "../../../../common/fs/lstat.injectable"; +import readDirectoryInjectable from "../../../../common/fs/read-directory.injectable"; +import removePathInjectable from "../../../../common/fs/remove.injectable"; +import joinPathsInjectable from "../../../../common/path/join-paths.injectable"; +import { isErrnoException } from "../../../../common/utils"; +import type { InstalledExtension } from "../../../../extensions/extension-discovery/extension-discovery"; +import installedExtensionsInjectable from "../../common/installed-extensions.injectable"; +import extensionsNodeModulesDirectoryPathInjectable from "../common/extension-node-modules-directory-path.injectable"; +import initialDiscoveryLoadCompletedInjectable from "../common/initial-load-completed.injectable"; +import localExtensionsDirectoryPathInjectable from "../common/local-extensions-directory-path.injectable"; +import extensionDiscoveryLoggerInjectable from "../common/logger.injectable"; +import loadUserExtensionFromFolderInjectable from "./load-user-extension-from-folder.injectable"; + +/** + * Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link) + * @param lstat the stats to compare + */ +const isDirectoryLike = (lstat: Stats) => lstat.isDirectory() || lstat.isSymbolicLink(); + +export type LoadInitialExtensions = () => Promise; + +const loadInitialExtensionsInjectable = getInjectable({ + id: "load-initial-extensions", + instantiate: (di): LoadInitialExtensions => { + const directoryForUserData = di.inject(directoryForUserDataInjectable); + const logger = di.inject(extensionDiscoveryLoggerInjectable); + const removePath = di.inject(removePathInjectable); + const joinPaths = di.inject(joinPathsInjectable); + const ensureDirectory = di.inject(ensureDirectoryInjectable); + const readDirectory = di.inject(readDirectoryInjectable); + const lstat = di.inject(lstatInjectable); + const installedExtensions = di.inject(installedExtensionsInjectable); + const initialDiscoveryLoadCompleted = di.inject(initialDiscoveryLoadCompletedInjectable); + const extensionsNodeModulesDirectoryPath = di.inject(extensionsNodeModulesDirectoryPathInjectable); + const localExtensionsDirectoryPath = di.inject(localExtensionsDirectoryPathInjectable); + const loadUserExtensionFromFolder = di.inject(loadUserExtensionFromFolderInjectable); + + return async () => { + logger.info(`loading extensions from ${directoryForUserData}`); + + await removePath(joinPaths(directoryForUserData, "package-lock.json")); + await ensureDirectory(extensionsNodeModulesDirectoryPath); + await ensureDirectory(localExtensionsDirectoryPath); + + const userExtensions: InstalledExtension[] = []; + const paths = await readDirectory(localExtensionsDirectoryPath); + + for (const fileName of paths) { + const absPath = joinPaths(localExtensionsDirectoryPath, fileName); + + try { + const stats = await lstat(absPath); + + // skip non-directories + if (!isDirectoryLike(stats)) { + continue; + } + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") { + continue; + } + + throw error; + } + + const extension = await loadUserExtensionFromFolder(absPath); + + if (extension) { + userExtensions.push(extension); + } + } + + logger.debug(`${userExtensions.length} extensions loaded from "${localExtensionsDirectoryPath}"`, userExtensions.map(ext => `${ext.manifest.name}@${ext.manifest.version}`)); + + installedExtensions.replace(userExtensions.map(ext => [ext.id, ext])); + initialDiscoveryLoadCompleted.set(true); + }; + }, +}); + +export default loadInitialExtensionsInjectable; diff --git a/packages/core/src/features/extensions/discovery/main/load-user-extension-from-folder.injectable.ts b/packages/core/src/features/extensions/discovery/main/load-user-extension-from-folder.injectable.ts new file mode 100644 index 0000000000..1fac4e7e04 --- /dev/null +++ b/packages/core/src/features/extensions/discovery/main/load-user-extension-from-folder.injectable.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import pathExistsInjectable from "../../../../common/fs/path-exists.injectable"; +import readJsonFileInjectable from "../../../../common/fs/read-json-file.injectable"; +import getDirnameOfPathInjectable from "../../../../common/path/get-dirname.injectable"; +import joinPathsInjectable from "../../../../common/path/join-paths.injectable"; +import { isErrnoException } from "../../../../common/utils"; +import { manifestFilename } from "../../../../common/vars"; +import isProductionInjectable from "../../../../common/vars/is-production.injectable"; +import isCompatibleExtensionInjectable from "./is-compatible-extension.injectable"; +import extensionsStoreInjectable from "../../../../extensions/extensions-store/extensions-store.injectable"; +import type { LensExtensionManifest } from "../../../../extensions/lens-extension"; +import extensionDiscoveryLoggerInjectable from "../common/logger.injectable"; +import getExtensionInstallPathInjectable from "./get-extension-install-path.injectable"; +import type { InstalledExtension } from "../../common/installed-extension"; + +export type LoadUserExtensionFromFolder = (folderPath: string) => Promise; + +const loadUserExtensionFromFolderInjectable = getInjectable({ + id: "load-user-extension-from-folder", + instantiate: (di): LoadUserExtensionFromFolder => { + const joinPaths = di.inject(joinPathsInjectable); + const readJsonFile = di.inject(readJsonFileInjectable); + const extensionsStore = di.inject(extensionsStoreInjectable); + const getDirnameOfPath = di.inject(getDirnameOfPathInjectable); + const isProduction = di.inject(isProductionInjectable); + const pathExists = di.inject(pathExistsInjectable); + const isCompatibleExtension = di.inject(isCompatibleExtensionInjectable); + const logger = di.inject(extensionDiscoveryLoggerInjectable); + const getExtensionInstallPath = di.inject(getExtensionInstallPathInjectable); + + return async (folderPath) => { + const manifestPath = joinPaths(folderPath, manifestFilename); + + try { + const manifest = await readJsonFile(manifestPath) as unknown as LensExtensionManifest; + const id = joinPaths(getExtensionInstallPath(manifest.name), manifestFilename); + const extensionDir = getDirnameOfPath(manifestPath); + const npmPackage = joinPaths(extensionDir, `${manifest.name}-${manifest.version}.tgz`); + const absolutePath = isProduction && await pathExists(npmPackage) + ? npmPackage + : extensionDir; + + return { + id, + absolutePath, + manifestPath: id, + manifest, + isBundled: false, + isEnabled: extensionsStore.isEnabled(id), + isCompatible: isCompatibleExtension(manifest), + }; + } catch (error) { + if (isErrnoException(error) && error.code === "ENOTDIR") { + // ignore this error, probably from .DS_Store file + logger.debug(`failed to load extension manifest through a not-dir-like at ${manifestPath}`); + } else { + logger.error(`can't load extension manifest at ${manifestPath}: ${error}`); + } + + return null; + } + }; + }, +}); + +export default loadUserExtensionFromFolderInjectable; diff --git a/packages/core/src/features/extensions/discovery/main/run-watch-extensions.injectable.ts b/packages/core/src/features/extensions/discovery/main/run-watch-extensions.injectable.ts new file mode 100644 index 0000000000..051a8e5501 --- /dev/null +++ b/packages/core/src/features/extensions/discovery/main/run-watch-extensions.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { onLoadOfApplicationInjectionToken } from "../../../../main/start-main-application/runnable-tokens/on-load-of-application-injection-token"; +import runLoadInitialExtensionsInjectable from "../../loader/main/run-load-initial-extensions.injectable"; +import watchForExtensionsInjectable from "./watch-extensions.injectable"; + +const runWatchForExtensionsInjectable = getInjectable({ + id: "run-watch-for-extensions", + instantiate: (di) => ({ + id: "run-watch-for-extensions", + run: async () => { + const watchForExtensions = di.inject(watchForExtensionsInjectable); + + watchForExtensions(); + }, + runAfter: di.inject(runLoadInitialExtensionsInjectable), + }), + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default runWatchForExtensionsInjectable; diff --git a/packages/core/src/features/extensions/discovery/main/watch-extensions.global-override-for-injectable.ts b/packages/core/src/features/extensions/discovery/main/watch-extensions.global-override-for-injectable.ts new file mode 100644 index 0000000000..70dc1cf1e1 --- /dev/null +++ b/packages/core/src/features/extensions/discovery/main/watch-extensions.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../../../../common/test-utils/get-global-override"; +import watchExtensionsInjectable from "./watch-extensions.injectable"; + +export default getGlobalOverride(watchExtensionsInjectable, () => () => {}); diff --git a/packages/core/src/features/extensions/discovery/main/watch-extensions.injectable.ts b/packages/core/src/features/extensions/discovery/main/watch-extensions.injectable.ts new file mode 100644 index 0000000000..01adeab30f --- /dev/null +++ b/packages/core/src/features/extensions/discovery/main/watch-extensions.injectable.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import watchInjectable from "../../../../common/fs/watch/watch.injectable"; +import localExtensionsDirectoryPathInjectable from "../common/local-extensions-directory-path.injectable"; +import extensionDiscoveryLoggerInjectable from "../common/logger.injectable"; +import extensionFileAddedInjectable from "./extension-file-add.injectable"; +import extensionFileRemovedInjectable from "./extension-file-removed.injectable"; + +export type WatchForExtensions = () => void; + +const watchForExtensionsInjectable = getInjectable({ + id: "watch-for-extensions", + instantiate: (di): WatchForExtensions => { + const extensionFileAdded = di.inject(extensionFileAddedInjectable); + const extensionFileRemoved = di.inject(extensionFileRemovedInjectable); + const logger = di.inject(extensionDiscoveryLoggerInjectable); + const watch = di.inject(watchInjectable); + const localExtensionsDirectoryPath = di.inject(localExtensionsDirectoryPathInjectable); + + return () => { + logger.info(`watching extension add/remove in ${localExtensionsDirectoryPath}`); + + watch(localExtensionsDirectoryPath, { + // For adding and removing symlinks to work, the depth has to be 1. + depth: 1, + ignoreInitial: true, + // Try to wait until the file has been completely copied. + // The OS might emit an event for added file even it's not completely written to the file-system. + awaitWriteFinish: { + // Wait 300ms until the file size doesn't change to consider the file written. + // For a small file like package.json this should be plenty of time. + stabilityThreshold: 300, + }, + }) + // Extension add is detected by watching "/package.json" add + .on("add", extensionFileAdded) + // Extension remove is detected by watching "" unlink + .on("unlinkDir", extensionFileRemoved) + // Extension remove is detected by watching "" unlink + .on("unlink", extensionFileRemoved); + }; + }, + causesSideEffects: true, +}); + +export default watchForExtensionsInjectable; diff --git a/packages/core/src/features/extensions/loader/common/auto-init-extensions.injectable.ts b/packages/core/src/features/extensions/loader/common/auto-init-extensions.injectable.ts index f7838f0e54..1f70557cdf 100644 --- a/packages/core/src/features/extensions/loader/common/auto-init-extensions.injectable.ts +++ b/packages/core/src/features/extensions/loader/common/auto-init-extensions.injectable.ts @@ -23,13 +23,11 @@ const autoInitExtensionsInjectable = getInjectable({ const finalizeExtensionLoading = di.inject(finalizeExtensionLoadingInjectable); return async () => { - logger.info("auto initializing extensions"); + logger.info("🧩 Initializing..."); - const bundledExtensions = await loadBundledExtensions(); - const userExtensions = await loadUserExtensions(installedExtensions.toJSON()); const loadedExtensions = await finalizeExtensionLoading([ - ...bundledExtensions, - ...userExtensions, + ...await loadBundledExtensions(), + ...await loadUserExtensions(installedExtensions.toJSON()), ]); // Setup reaction to load extensions on JSON changes diff --git a/packages/core/src/features/extensions/loader/common/import-installed-extension.global-override-for-injectable.ts b/packages/core/src/features/extensions/loader/common/import-installed-extension.global-override-for-injectable.ts new file mode 100644 index 0000000000..8dc1a39c6b --- /dev/null +++ b/packages/core/src/features/extensions/loader/common/import-installed-extension.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverrideForFunction } from "../../../../common/test-utils/get-global-override-for-function"; +import importInstalledExtensionInjectable from "./import-installed-extension.injectable"; + +export default getGlobalOverrideForFunction(importInstalledExtensionInjectable); diff --git a/packages/core/src/features/extensions/loader/common/import-installed-extension.injectable.ts b/packages/core/src/features/extensions/loader/common/import-installed-extension.injectable.ts index cec1483cbd..ec17224b58 100644 --- a/packages/core/src/features/extensions/loader/common/import-installed-extension.injectable.ts +++ b/packages/core/src/features/extensions/loader/common/import-installed-extension.injectable.ts @@ -46,6 +46,7 @@ const importInstalledExtensionInjectable = getInjectable({ return null; }; }, + causesSideEffects: true, }); export default importInstalledExtensionInjectable; diff --git a/packages/core/src/features/extensions/loader/main/run-auto-init-extensions.injectable.ts b/packages/core/src/features/extensions/loader/main/run-auto-init-extensions.injectable.ts new file mode 100644 index 0000000000..4879611571 --- /dev/null +++ b/packages/core/src/features/extensions/loader/main/run-auto-init-extensions.injectable.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { onLoadOfApplicationInjectionToken } from "../../../../main/library"; +import autoInitExtensionsInjectable from "../common/auto-init-extensions.injectable"; + +const runAutoInitExtensionsInjectable = getInjectable({ + id: "run-auto-init-extensions", + instantiate: (di) => ({ + id: "run-auto-init-extensions", + run: async () => { + const autoInitExtensions = di.inject(autoInitExtensionsInjectable); + + await autoInitExtensions(); + }, + }), + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default runAutoInitExtensionsInjectable; diff --git a/packages/core/src/features/extensions/loader/main/run-load-initial-extensions.injectable.ts b/packages/core/src/features/extensions/loader/main/run-load-initial-extensions.injectable.ts new file mode 100644 index 0000000000..55f30de4c4 --- /dev/null +++ b/packages/core/src/features/extensions/loader/main/run-load-initial-extensions.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { onLoadOfApplicationInjectionToken } from "../../../../main/library"; +import loadInitialExtensionsInjectable from "../../discovery/main/load-initial-extensions.injectable"; +import runAutoInitExtensionsInjectable from "./run-auto-init-extensions.injectable"; + +const runLoadInitialExtensionsInjectable = getInjectable({ + id: "run-load-initial-extensions", + instantiate: (di) => ({ + id: "run-load-initial-extensions", + run: async () => { + const loadInitialExtensions = di.inject(loadInitialExtensionsInjectable); + + await loadInitialExtensions(); + }, + runAfter: di.inject(runAutoInitExtensionsInjectable), + }), + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default runLoadInitialExtensionsInjectable; diff --git a/packages/core/src/main/__test__/kube-auth-proxy.test.ts b/packages/core/src/main/__test__/kube-auth-proxy.test.ts index 4f8bf9ba29..e6f7aa5881 100644 --- a/packages/core/src/main/__test__/kube-auth-proxy.test.ts +++ b/packages/core/src/main/__test__/kube-auth-proxy.test.ts @@ -24,7 +24,7 @@ import kubectlBinaryNameInjectable from "../kubectl/binary-name.injectable"; import kubectlDownloadingNormalizedArchInjectable from "../kubectl/normalized-arch.injectable"; import broadcastMessageInjectable from "../../common/ipc/broadcast-message.injectable"; import writeJsonSyncInjectable from "../../common/fs/write-json-sync.injectable"; -import ensureDirInjectable from "../../common/fs/ensure-dir.injectable"; +import ensureDirectoryInjectable from "../../common/fs/ensure-directory.injectable"; import type { GetBasenameOfPath } from "../../common/path/get-basename.injectable"; import getBasenameOfPathInjectable from "../../common/path/get-basename.injectable"; @@ -45,7 +45,7 @@ describe("kube auth proxy tests", () => { di.override(directoryForTempInjectable, () => "/some-directory-for-temp"); const writeJsonSync = di.inject(writeJsonSyncInjectable); - const ensureDir = di.inject(ensureDirInjectable); + const ensureDir = di.inject(ensureDirectoryInjectable); getBasenameOfPath = di.inject(getBasenameOfPathInjectable); diff --git a/packages/core/src/main/getDiForUnitTesting.ts b/packages/core/src/main/getDiForUnitTesting.ts index 1d06e42d61..f542574832 100644 --- a/packages/core/src/main/getDiForUnitTesting.ts +++ b/packages/core/src/main/getDiForUnitTesting.ts @@ -7,7 +7,6 @@ import { chunk } from "lodash/fp"; import type { DiContainer } from "@ogre-tools/injectable"; import { isInjectable } from "@ogre-tools/injectable"; import spawnInjectable from "./child-process/spawn.injectable"; -import initializeExtensionsInjectable from "./start-main-application/runnables/initialize-extensions.injectable"; import setupIpcMainHandlersInjectable from "./electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.injectable"; import setupLensProxyInjectable from "./start-main-application/runnables/setup-lens-proxy.injectable"; import setupSyncingOfWeblinksInjectable from "./start-main-application/runnables/setup-syncing-of-weblinks.injectable"; @@ -82,7 +81,6 @@ export function getDiForUnitTesting(opts: { doGeneralOverrides?: boolean } = {}) // TODO: Reorganize code in Runnables to get rid of requirement for override const overrideRunnablesHavingSideEffects = (di: DiContainer) => { [ - initializeExtensionsInjectable, initializeClusterManagerInjectable, setupIpcMainHandlersInjectable, setupLensProxyInjectable, diff --git a/packages/core/src/main/start-main-application/runnables/initialize-extensions.injectable.ts b/packages/core/src/main/start-main-application/runnables/initialize-extensions.injectable.ts deleted file mode 100644 index cced65b0cb..0000000000 --- a/packages/core/src/main/start-main-application/runnables/initialize-extensions.injectable.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import loggerInjectable from "../../../common/logger.injectable"; -import extensionDiscoveryInjectable from "../../../extensions/extension-discovery/extension-discovery.injectable"; -import autoInitExtensionsInjectable from "../../../features/extensions/loader/common/auto-init-extensions.injectable"; -import removeExtensionInstanceInjectable from "../../../features/extensions/loader/common/remove-instance.injectable"; -import showErrorPopupInjectable from "../../electron-app/features/show-error-popup.injectable"; -import { onLoadOfApplicationInjectionToken } from "../runnable-tokens/on-load-of-application-injection-token"; - -const initializeExtensionsInjectable = getInjectable({ - id: "initialize-extensions", - - instantiate: (di) => { - const logger = di.inject(loggerInjectable); - const extensionDiscovery = di.inject(extensionDiscoveryInjectable); - const showErrorPopup = di.inject(showErrorPopupInjectable); - const removeExtensionInstance = di.inject(removeExtensionInstanceInjectable); - const autoInitExtensions = di.inject(autoInitExtensionsInjectable); - - return { - id: "initialize-extensions", - run: async () => { - logger.info("🧩 Initializing extensions"); - - await extensionDiscovery.init(); - - await autoInitExtensions(); - - try { - await extensionDiscovery.load(); - extensionDiscovery.events.on("remove", (ext) => removeExtensionInstance(ext.id)); - - // Start watching after bundled extensions are loaded - extensionDiscovery.watchExtensions(); - } catch (error: any) { - showErrorPopup( - "Lens Error", - `Could not load extensions${error?.message ? `: ${error.message}` : ""}`, - ); - - console.error(error); - console.trace(); - } - }, - }; - }, - - causesSideEffects: true, - - injectionToken: onLoadOfApplicationInjectionToken, -}); - -export default initializeExtensionsInjectable; diff --git a/packages/core/src/main/start-main-application/runnables/kube-config-sync/start-kube-config-sync.injectable.ts b/packages/core/src/main/start-main-application/runnables/kube-config-sync/start-kube-config-sync.injectable.ts index 1dbadc4246..da517b0a21 100644 --- a/packages/core/src/main/start-main-application/runnables/kube-config-sync/start-kube-config-sync.injectable.ts +++ b/packages/core/src/main/start-main-application/runnables/kube-config-sync/start-kube-config-sync.injectable.ts @@ -5,7 +5,7 @@ import { getInjectable } from "@ogre-tools/injectable"; import { afterApplicationIsLoadedInjectionToken } from "../../runnable-tokens/after-application-is-loaded-injection-token"; import directoryForKubeConfigsInjectable from "../../../../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; -import ensureDirInjectable from "../../../../common/fs/ensure-dir.injectable"; +import ensureDirectoryInjectable from "../../../../common/fs/ensure-directory.injectable"; import kubeconfigSyncManagerInjectable from "../../../catalog-sources/kubeconfig-sync/manager.injectable"; import addKubeconfigSyncAsEntitySourceInjectable from "./add-source.injectable"; @@ -15,7 +15,7 @@ const startKubeConfigSyncInjectable = getInjectable({ instantiate: (di) => { const directoryForKubeConfigs = di.inject(directoryForKubeConfigsInjectable); const kubeConfigSyncManager = di.inject(kubeconfigSyncManagerInjectable); - const ensureDir = di.inject(ensureDirInjectable); + const ensureDir = di.inject(ensureDirectoryInjectable); return { id: "start-kubeconfig-sync", diff --git a/packages/core/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/packages/core/src/renderer/components/+extensions/__tests__/extensions.test.tsx index c185c5d3fd..227a0ee763 100644 --- a/packages/core/src/renderer/components/+extensions/__tests__/extensions.test.tsx +++ b/packages/core/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -6,13 +6,12 @@ import "@testing-library/jest-dom/extend-expect"; import { fireEvent, screen, waitFor } from "@testing-library/react"; import React from "react"; -import type { ExtensionDiscovery, InstalledExtension } from "../../../../extensions/extension-discovery/extension-discovery"; +import type { InstalledExtension } from "../../../../extensions/extension-discovery/extension-discovery"; import { ConfirmDialog } from "../../confirm-dialog"; import { Extensions } from "../extensions"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import type { DiRender } from "../../test-utils/renderFor"; import { renderFor } from "../../test-utils/renderFor"; -import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable"; import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; import directoryForDownloadsInjectable from "../../../../common/app-paths/directory-for-downloads/directory-for-downloads.injectable"; import assert from "assert"; @@ -20,8 +19,8 @@ import type { InstallExtensionFromInput } from "../install-extension-from-input. import installExtensionFromInputInjectable from "../install-extension-from-input.injectable"; import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store"; import extensionInstallationStateStoreInjectable from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; -import type { ObservableMap } from "mobx"; -import { observable, when } from "mobx"; +import type { IObservableValue, ObservableMap } from "mobx"; +import { computed, observable, when } from "mobx"; import type { RemovePath } from "../../../../common/fs/remove.injectable"; import removePathInjectable from "../../../../common/fs/remove.injectable"; import type { DownloadBinary } from "../../../../common/fetch/download-binary.injectable"; @@ -29,15 +28,19 @@ import downloadBinaryInjectable from "../../../../common/fetch/download-binary.i import currentlyInClusterFrameInjectable from "../../../routes/currently-in-cluster-frame.injectable"; import type { LensExtensionId } from "../../../../extensions/lens-extension"; import installedExtensionsInjectable from "../../../../features/extensions/common/installed-extensions.injectable"; +import initialDiscoveryLoadCompletedInjectable from "../../../../features/extensions/discovery/common/initial-load-completed.injectable"; +import type { RemoveExtensionFiles } from "../../../../features/extensions/discovery/common/uninstall-extension.injectable"; +import removeExtensionFilesInjectable from "../../../../features/extensions/discovery/common/uninstall-extension.injectable"; describe("Extensions", () => { let installedExtensions: ObservableMap; - let extensionDiscovery: ExtensionDiscovery; let installExtensionFromInput: jest.MockedFunction; let extensionInstallationStateStore: ExtensionInstallationStateStore; let render: DiRender; let deleteFileMock: jest.MockedFunction; let downloadBinary: jest.MockedFunction; + let isLoaded: IObservableValue; + let removeExtensionFilesMock: jest.MockedFunction; beforeEach(() => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); @@ -48,6 +51,14 @@ describe("Extensions", () => { render = renderFor(di); + isLoaded = observable.box(false); + + di.override(initialDiscoveryLoadCompletedInjectable, () => ({ + id: "some", + set: value => isLoaded.set(value), + value: computed(() => isLoaded.get()), + })); + installExtensionFromInput = jest.fn(); di.override(installExtensionFromInputInjectable, () => installExtensionFromInput); @@ -58,7 +69,6 @@ describe("Extensions", () => { di.override(downloadBinaryInjectable, () => downloadBinary); installedExtensions = di.inject(installedExtensionsInjectable); - extensionDiscovery = di.inject(extensionDiscoveryInjectable); extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable); installedExtensions.set("extensionId", { @@ -75,11 +85,12 @@ describe("Extensions", () => { isCompatible: true, }); - extensionDiscovery.uninstallExtension = jest.fn(() => Promise.resolve()); + removeExtensionFilesMock = jest.fn(); + di.override(removeExtensionFilesInjectable, () => removeExtensionFilesMock); }); it("disables uninstall and disable buttons while uninstalling", async () => { - extensionDiscovery.isLoaded = true; + isLoaded.set(true); render(( <> @@ -103,7 +114,7 @@ describe("Extensions", () => { fireEvent.click(await screen.findByText("Yes")); await waitFor(async () => { - expect(extensionDiscovery.uninstallExtension).toHaveBeenCalled(); + expect(removeExtensionFilesMock).toHaveBeenCalled(); fireEvent.click(menuTrigger); expect(screen.getByText("Disable")).toHaveAttribute("aria-disabled", "true"); expect(screen.getByText("Uninstall")).toHaveAttribute("aria-disabled", "true"); @@ -155,14 +166,14 @@ describe("Extensions", () => { }); it("displays spinner while extensions are loading", () => { - extensionDiscovery.isLoaded = false; + isLoaded.set(false); const { container } = render(); expect(container.querySelector(".Spinner")).toBeInTheDocument(); }); it("does not display the spinner while extensions are not loading", async () => { - extensionDiscovery.isLoaded = true; + isLoaded.set(true); const { container } = render(); expect(container.querySelector(".Spinner")).not.toBeInTheDocument(); diff --git a/packages/core/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate.injectable.tsx b/packages/core/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate.injectable.tsx index 5c1674fc94..eb68fdec54 100644 --- a/packages/core/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate.injectable.tsx +++ b/packages/core/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate.injectable.tsx @@ -3,7 +3,6 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable"; import { validatePackage } from "./validate-package"; import { getMessageFromError } from "../get-message-from-error/get-message-from-error"; import React from "react"; @@ -14,6 +13,7 @@ import writeFileInjectable from "../../../../common/fs/write-file.injectable"; import joinPathsInjectable from "../../../../common/path/join-paths.injectable"; import tempDirectoryPathInjectable from "../../../../common/os/temp-directory-path.injectable"; import showErrorNotificationInjectable from "../../notifications/show-error-notification.injectable"; +import extensionsNodeModulesDirectoryPathInjectable from "../../../../features/extensions/discovery/common/extension-node-modules-directory-path.injectable"; export interface InstallRequestValidated { fileName: string; @@ -28,7 +28,7 @@ export type CreateTempFilesAndValidate = (request: InstallRequest) => Promise { - const extensionDiscovery = di.inject(extensionDiscoveryInjectable); + const extensionsNodeModulesDirectoryPath = di.inject(extensionsNodeModulesDirectoryPathInjectable); const logger = di.inject(loggerInjectable); const writeFile = di.inject(writeFileInjectable); const joinPaths = di.inject(joinPathsInjectable); @@ -49,7 +49,7 @@ const createTempFilesAndValidateInjectable = getInjectable({ await writeFile(tempFile, data); const manifest = await validatePackage(tempFile); const id = joinPaths( - extensionDiscovery.nodeModulesPath, + extensionsNodeModulesDirectoryPath, manifest.name, "package.json", ); diff --git a/packages/core/src/renderer/components/+extensions/attempt-install/get-extension-dest-folder.injectable.ts b/packages/core/src/renderer/components/+extensions/attempt-install/get-extension-dest-folder.injectable.ts index 6d6dbfbef3..3816ef0f2d 100644 --- a/packages/core/src/renderer/components/+extensions/attempt-install/get-extension-dest-folder.injectable.ts +++ b/packages/core/src/renderer/components/+extensions/attempt-install/get-extension-dest-folder.injectable.ts @@ -3,9 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable"; import { sanitizeExtensionName } from "../../../../extensions/lens-extension"; import path from "path"; +import localExtensionsDirectoryPathInjectable from "../../../../features/extensions/discovery/common/local-extensions-directory-path.injectable"; export type GetExtensionDestFolder = (name: string) => string; @@ -13,9 +13,9 @@ const getExtensionDestFolderInjectable = getInjectable({ id: "get-extension-dest-folder", instantiate: (di): GetExtensionDestFolder => { - const extensionDiscovery = di.inject(extensionDiscoveryInjectable); + const localExtensionsDirectoryPath = di.inject(localExtensionsDirectoryPathInjectable); - return (name) => path.join(extensionDiscovery.localFolderPath, sanitizeExtensionName(name)); + return (name) => path.join(localExtensionsDirectoryPath, sanitizeExtensionName(name)); }, }); diff --git a/packages/core/src/renderer/components/+extensions/attempt-install/validate-package.tsx b/packages/core/src/renderer/components/+extensions/attempt-install/validate-package.tsx index e9597b2d10..08f01c91cb 100644 --- a/packages/core/src/renderer/components/+extensions/attempt-install/validate-package.tsx +++ b/packages/core/src/renderer/components/+extensions/attempt-install/validate-package.tsx @@ -4,8 +4,8 @@ */ import type { LensExtensionManifest } from "../../../../extensions/lens-extension"; import { hasTypedProperty, isObject, isString, listTarEntries, readFileFromTar } from "../../../../common/utils"; -import { manifestFilename } from "../../../../extensions/extension-discovery/extension-discovery"; import path from "path"; +import { manifestFilename } from "../../../../common/vars"; export async function validatePackage(filePath: string): Promise { const tarFiles = await listTarEntries(filePath); diff --git a/packages/core/src/renderer/components/+extensions/installed-extensions.tsx b/packages/core/src/renderer/components/+extensions/installed-extensions.tsx index 8da6e14185..4ddad164df 100644 --- a/packages/core/src/renderer/components/+extensions/installed-extensions.tsx +++ b/packages/core/src/renderer/components/+extensions/installed-extensions.tsx @@ -6,7 +6,6 @@ import styles from "./installed-extensions.module.scss"; import React, { useMemo } from "react"; import type { - ExtensionDiscovery, InstalledExtension, } from "../../../extensions/extension-discovery/extension-discovery"; import { Icon } from "../icon"; @@ -17,13 +16,14 @@ import { cssNames, toJS } from "../../utils"; import { observer } from "mobx-react"; import type { Row } from "react-table"; import type { LensExtensionId } from "../../../extensions/lens-extension"; -import extensionDiscoveryInjectable - from "../../../extensions/extension-discovery/extension-discovery.injectable"; + import { withInjectables } from "@ogre-tools/injectable-react"; import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store"; +import type { IComputedValue } from "mobx"; +import initialDiscoveryLoadCompletedInjectable from "../../../features/extensions/discovery/common/initial-load-completed.injectable"; export interface InstalledExtensionsProps { extensions: InstalledExtension[]; @@ -33,7 +33,7 @@ export interface InstalledExtensionsProps { } interface Dependencies { - extensionDiscovery: ExtensionDiscovery; + initialDiscoveryLoadCompleted: IComputedValue; extensionInstallationStateStore: ExtensionInstallationStateStore; } @@ -46,7 +46,7 @@ function getStatus(extension: InstalledExtension) { } const NonInjectedInstalledExtensions = observer(({ - extensionDiscovery, + initialDiscoveryLoadCompleted, extensionInstallationStateStore, extensions, uninstall, @@ -148,7 +148,7 @@ const NonInjectedInstalledExtensions = observer(({ }), [toJS(extensions), extensionInstallationStateStore.anyUninstalling], ); - if (!extensionDiscovery.isLoaded) { + if (!initialDiscoveryLoadCompleted.get()) { return
; } @@ -183,8 +183,8 @@ const NonInjectedInstalledExtensions = observer(({ export const InstalledExtensions = withInjectables(NonInjectedInstalledExtensions, { getProps: (di, props) => ({ - extensionDiscovery: di.inject(extensionDiscoveryInjectable), - extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable), ...props, + extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable), + initialDiscoveryLoadCompleted: di.inject(initialDiscoveryLoadCompletedInjectable).value, }), }); diff --git a/packages/core/src/renderer/components/+extensions/uninstall-extension.injectable.tsx b/packages/core/src/renderer/components/+extensions/uninstall-extension.injectable.tsx index 6342a23168..dae0781af1 100644 --- a/packages/core/src/renderer/components/+extensions/uninstall-extension.injectable.tsx +++ b/packages/core/src/renderer/components/+extensions/uninstall-extension.injectable.tsx @@ -4,7 +4,6 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; -import extensionDiscoveryInjectable from "../../../extensions/extension-discovery/extension-discovery.injectable"; import loggerInjectable from "../../../common/logger.injectable"; import type { LensExtensionId } from "../../../extensions/lens-extension"; import { extensionDisplayName } from "../../../extensions/lens-extension"; @@ -15,18 +14,19 @@ import showSuccessNotificationInjectable from "../notifications/show-success-not import showErrorNotificationInjectable from "../notifications/show-error-notification.injectable"; import installedUserExtensionsInjectable from "../../../features/extensions/common/user-extensions.injectable"; import getInstalledExtensionInjectable from "../../../features/extensions/common/get-installed-extension.injectable"; +import removeExtensionFilesInjectable from "../../../features/extensions/discovery/common/uninstall-extension.injectable"; const uninstallExtensionInjectable = getInjectable({ id: "uninstall-extension", instantiate: (di) => { - const extensionDiscovery = di.inject(extensionDiscoveryInjectable); const extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable); const logger = di.inject(loggerInjectable); const showSuccessNotification = di.inject(showSuccessNotificationInjectable); const showErrorNotification = di.inject(showErrorNotificationInjectable); const installedUserExtensions = di.inject(installedUserExtensionsInjectable); const getInstalledExtension = di.inject(getInstalledExtensionInjectable); + const removeExtensionFiles = di.inject(removeExtensionFilesInjectable); return async (extensionId: LensExtensionId): Promise => { const ext = getInstalledExtension(extensionId); @@ -44,7 +44,7 @@ const uninstallExtensionInjectable = getInjectable({ logger.debug(`[EXTENSIONS]: trying to uninstall ${extensionId}`); extensionInstallationStateStore.setUninstalling(extensionId); - await extensionDiscovery.uninstallExtension(extensionId); + await removeExtensionFiles(extensionId); // wait for the ExtensionLoader to actually uninstall the extension await when(() => !installedUserExtensions.get().has(extensionId)); diff --git a/packages/core/src/renderer/components/test-utils/get-application-builder.tsx b/packages/core/src/renderer/components/test-utils/get-application-builder.tsx index 41f26803d4..c7d6ad0903 100644 --- a/packages/core/src/renderer/components/test-utils/get-application-builder.tsx +++ b/packages/core/src/renderer/components/test-utils/get-application-builder.tsx @@ -249,27 +249,36 @@ export const getApplicationBuilder = () => { close: () => {}, loadFile: async () => {}, loadUrl: async () => { + console.log("+beforeWindowStarts"); + for (const callback of beforeWindowStartCallbacks) { await callback(windowDi); } + console.log("-beforeWindowStarts"); + const startFrame = windowDi.inject(startFrameInjectable); + console.log("start frame"); await startFrame(); + console.log("+afterWindowStarts"); for (const callback of afterWindowStartCallbacks) { await callback(windowDi); } - + console.log("-afterWindowStarts"); const history = windowDi.inject(historyInjectable); const render = renderFor(windowDi); + console.log("renderFor"); + rendered = render( , ); + console.log("finished application-builder loadUrl"); }, send: (arg) => { @@ -294,13 +303,18 @@ export const getApplicationBuilder = () => { const startApplication = async ({ shouldStartHidden }: { shouldStartHidden: boolean }) => { mainDi.inject(lensProxyPortInjectable).set(42); + console.log("beforeApplicationStartCallbacks"); + for (const callback of beforeApplicationStartCallbacks) { await callback(mainDi); } mainDi.override(shouldStartHiddenInjectable, () => shouldStartHidden); + console.log("startMainApplication"); await startMainApplication(); + console.log("afterApplicationStartCallbacks"); + for (const callback of afterApplicationStartCallbacks) { await callback(mainDi); } diff --git a/packages/core/src/renderer/extension-discovery/init.injectable.ts b/packages/core/src/renderer/extension-discovery/init.injectable.ts deleted file mode 100644 index e903926bf0..0000000000 --- a/packages/core/src/renderer/extension-discovery/init.injectable.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import extensionDiscoveryInjectable from "../../extensions/extension-discovery/extension-discovery.injectable"; -import { beforeFrameStartsSecondInjectionToken } from "../before-frame-starts/tokens"; - -const initializeExtensionDiscoveryInjectable = getInjectable({ - id: "initialize-extension-discovery", - instantiate: (di) => ({ - id: "initialize-extension-discovery", - run: async () => { - const extensionDiscovery = di.inject(extensionDiscoveryInjectable); - - await extensionDiscovery.init(); - }, - }), - injectionToken: beforeFrameStartsSecondInjectionToken, -}); - -export default initializeExtensionDiscoveryInjectable; diff --git a/packages/core/src/renderer/ipc/index.ts b/packages/core/src/renderer/ipc/index.ts index 08b3f1e92a..5cc4c245c2 100644 --- a/packages/core/src/renderer/ipc/index.ts +++ b/packages/core/src/renderer/ipc/index.ts @@ -6,7 +6,6 @@ import { clusterActivateHandler, clusterDisconnectHandler, clusterSetFrameIdHandler, clusterStates } from "../../common/ipc/cluster"; import type { ClusterId, ClusterState } from "../../common/cluster-types"; import { windowActionHandleChannel, windowLocationChangedChannel, windowOpenAppMenuAsContextMenuChannel, type WindowAction } from "../../common/ipc/window"; -import { extensionDiscoveryStateChannel } from "../../common/ipc/extension-handling"; import { toJS } from "../utils"; import type { Location } from "history"; import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; @@ -55,7 +54,3 @@ export function requestClusterDisconnection(clusterId: ClusterId, force?: boolea export function requestInitialClusterStates(): Promise<{ id: string; state: ClusterState }[]> { return requestMain(clusterStates); } - -export function requestInitialExtensionDiscovery(): Promise<{ isLoaded: boolean }> { - return requestMain(extensionDiscoveryStateChannel); -}