mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Fully convert ExtensionLoader to be injectable
- To fix unit tests Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
1 parent
90c37143bd
commit
7f86a89cc2
47 files changed
+867
-956
No files matched your search
@@ -1,172 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import type { ExtensionLoader } from "../extension-loader";
|
||||
import extensionLoaderInjectable from "../extension-loader/extension-loader.injectable";
|
||||
import { runInAction } from "mobx";
|
||||
import updateExtensionsStateInjectable from "../extension-loader/update-extensions-state/update-extensions-state.injectable";
|
||||
import { delay } from "../../renderer/utils";
|
||||
import { getDiForUnitTesting } from "../../renderer/getDiForUnitTesting";
|
||||
import ipcRendererInjectable from "../../renderer/utils/channel/ipc-renderer.injectable";
|
||||
import type { IpcRenderer } from "electron";
|
||||
import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
|
||||
import currentlyInClusterFrameInjectable from "../../renderer/routes/currently-in-cluster-frame.injectable";
|
||||
|
||||
const manifestPath = "manifest/path";
|
||||
const manifestPath2 = "manifest/path2";
|
||||
const manifestPath3 = "manifest/path3";
|
||||
|
||||
describe("ExtensionLoader", () => {
|
||||
let extensionLoader: ExtensionLoader;
|
||||
let updateExtensionStateMock: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
const di = getDiForUnitTesting({ doGeneralOverrides: true });
|
||||
|
||||
di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data");
|
||||
di.override(currentlyInClusterFrameInjectable, () => false);
|
||||
|
||||
di.override(ipcRendererInjectable, () => ({
|
||||
invoke: jest.fn(async (channel: string) => {
|
||||
if (channel === "extension-loader:main:state") {
|
||||
return [
|
||||
[
|
||||
manifestPath,
|
||||
{
|
||||
manifest: {
|
||||
name: "TestExtension",
|
||||
version: "1.0.0",
|
||||
},
|
||||
id: manifestPath,
|
||||
absolutePath: "/test/1",
|
||||
manifestPath,
|
||||
isBundled: false,
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
manifestPath2,
|
||||
{
|
||||
manifest: {
|
||||
name: "TestExtension2",
|
||||
version: "2.0.0",
|
||||
},
|
||||
id: manifestPath2,
|
||||
absolutePath: "/test/2",
|
||||
manifestPath: manifestPath2,
|
||||
isBundled: false,
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}),
|
||||
|
||||
on: (channel: string, listener: (event: any, ...args: any[]) => void) => {
|
||||
if (channel === "extension-loader:main:state") {
|
||||
// First initialize with extensions 1 and 2
|
||||
// and then broadcast event to remove extension 2 and add extension number 3
|
||||
setTimeout(() => {
|
||||
listener({}, [
|
||||
[
|
||||
manifestPath,
|
||||
{
|
||||
manifest: {
|
||||
name: "TestExtension",
|
||||
version: "1.0.0",
|
||||
},
|
||||
id: manifestPath,
|
||||
absolutePath: "/test/1",
|
||||
manifestPath,
|
||||
isBundled: false,
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
manifestPath3,
|
||||
{
|
||||
manifest: {
|
||||
name: "TestExtension3",
|
||||
version: "3.0.0",
|
||||
},
|
||||
id: manifestPath3,
|
||||
absolutePath: "/test/3",
|
||||
manifestPath: manifestPath3,
|
||||
isBundled: false,
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
]);
|
||||
}, 10);
|
||||
}
|
||||
},
|
||||
}) as unknown as IpcRenderer);
|
||||
|
||||
updateExtensionStateMock = jest.fn();
|
||||
|
||||
di.override(updateExtensionsStateInjectable, () => updateExtensionStateMock);
|
||||
|
||||
extensionLoader = di.inject(extensionLoaderInjectable);
|
||||
});
|
||||
|
||||
it("renderer updates extension after ipc broadcast", async () => {
|
||||
expect(extensionLoader.userExtensions.get().size).toBe(0);
|
||||
|
||||
await extensionLoader.init();
|
||||
await delay(10);
|
||||
|
||||
// Assert the extensions after the extension broadcast event
|
||||
expect(extensionLoader.userExtensions.get()).toEqual(new Map([
|
||||
["manifest/path", {
|
||||
absolutePath: "/test/1",
|
||||
id: "manifest/path",
|
||||
isBundled: false,
|
||||
isEnabled: true,
|
||||
manifest: {
|
||||
name: "TestExtension",
|
||||
version: "1.0.0",
|
||||
},
|
||||
manifestPath: "manifest/path",
|
||||
}],
|
||||
["manifest/path3", {
|
||||
absolutePath: "/test/3",
|
||||
id: "manifest/path3",
|
||||
isBundled: false,
|
||||
isEnabled: true,
|
||||
manifest: {
|
||||
name: "TestExtension3",
|
||||
version: "3.0.0",
|
||||
},
|
||||
manifestPath: "manifest/path3",
|
||||
}],
|
||||
]));
|
||||
});
|
||||
|
||||
it("updates ExtensionsStore after isEnabled is changed", async () => {
|
||||
await extensionLoader.init();
|
||||
|
||||
expect(updateExtensionStateMock).not.toHaveBeenCalled();
|
||||
|
||||
runInAction(() => {
|
||||
extensionLoader.setIsEnabled("manifest/path", false);
|
||||
});
|
||||
|
||||
expect(updateExtensionStateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
"manifest/path": {
|
||||
enabled: false,
|
||||
name: "TestExtension",
|
||||
},
|
||||
|
||||
"manifest/path2": {
|
||||
enabled: true,
|
||||
name: "TestExtension2",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import { ExtensionDiscovery } from "./extension-discovery";
|
||||
import extensionLoaderInjectable from "../extension-loader/extension-loader.injectable";
|
||||
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";
|
||||
@@ -28,12 +27,12 @@ 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({
|
||||
extensionLoader: di.inject(extensionLoaderInjectable),
|
||||
extensionsStore: di.inject(extensionsStoreInjectable),
|
||||
extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable),
|
||||
isCompatibleExtension: di.inject(isCompatibleExtensionInjectable),
|
||||
@@ -57,6 +56,7 @@ const extensionDiscoveryInjectable = getInjectable({
|
||||
getRelativePath: di.inject(getRelativePathInjectable),
|
||||
joinPaths: di.inject(joinPathsInjectable),
|
||||
homeDirectoryPath: di.inject(homeDirectoryPathInjectable),
|
||||
installedExtensions: di.inject(installedExtensionsInjectable),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
|
||||
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, toJS } from "../../common/utils";
|
||||
import { isErrnoException, iter, toJS } from "../../common/utils";
|
||||
import type { ExtensionsStore } from "../extensions-store/extensions-store";
|
||||
import type { ExtensionLoader } from "../extension-loader";
|
||||
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";
|
||||
@@ -32,7 +32,6 @@ import type { RemovePath } from "../../common/fs/remove.injectable";
|
||||
import type TypedEventEmitter from "typed-emitter";
|
||||
|
||||
interface Dependencies {
|
||||
readonly extensionLoader: ExtensionLoader;
|
||||
readonly extensionsStore: ExtensionsStore;
|
||||
readonly extensionInstallationStateStore: ExtensionInstallationStateStore;
|
||||
readonly extensionPackageRootDirectory: string;
|
||||
@@ -41,6 +40,7 @@ interface Dependencies {
|
||||
readonly isProduction: boolean;
|
||||
readonly fileSystemSeparator: string;
|
||||
readonly homeDirectoryPath: string;
|
||||
readonly installedExtensions: ObservableMap<LensExtensionId, InstalledExtension>;
|
||||
isCompatibleExtension: (manifest: LensExtensionManifest) => boolean;
|
||||
installExtension: (name: string) => Promise<void>;
|
||||
readJsonFile: ReadJson;
|
||||
@@ -60,11 +60,6 @@ interface Dependencies {
|
||||
|
||||
export interface BaseInstalledExtension {
|
||||
readonly id: LensExtensionId;
|
||||
// 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;
|
||||
}
|
||||
|
||||
export interface BundledInstalledExtension extends BaseInstalledExtension {
|
||||
@@ -78,6 +73,11 @@ 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;
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ const isDirectoryLike = (lstat: Stats) => lstat.isDirectory() || lstat.isSymboli
|
||||
|
||||
interface ExtensionDiscoveryEvents {
|
||||
add: (ext: InstalledExtension) => void;
|
||||
remove: (extId: LensExtensionId) => void;
|
||||
remove: (ext: InstalledExtension) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,7 +116,6 @@ export class ExtensionDiscovery {
|
||||
protected bundledFolderPath!: string;
|
||||
|
||||
private loadStarted = false;
|
||||
private extensions: Map<string, InstalledExtension> = new Map();
|
||||
|
||||
// True if extensions have been loaded from the disk after app startup
|
||||
@observable isLoaded = false;
|
||||
@@ -177,12 +176,9 @@ export class ExtensionDiscovery {
|
||||
* Watches for added/removed local extensions.
|
||||
* Dependencies are installed automatically after an extension folder is copied.
|
||||
*/
|
||||
async watchExtensions(): Promise<void> {
|
||||
watchExtensions() {
|
||||
this.dependencies.logger.info(`${logModule} watching extension add/remove in ${this.localFolderPath}`);
|
||||
|
||||
// Wait until .load() has been called and has been resolved
|
||||
await this.whenLoaded;
|
||||
|
||||
this.dependencies.watch(this.localFolderPath, {
|
||||
// For adding and removing symlinks to work, the depth has to be 1.
|
||||
depth: 1,
|
||||
@@ -224,7 +220,7 @@ export class ExtensionDiscovery {
|
||||
// Install dependencies for the new extension
|
||||
await this.dependencies.installExtension(extension.absolutePath);
|
||||
|
||||
this.extensions.set(extension.id, extension);
|
||||
this.dependencies.installedExtensions.set(extension.id, extension);
|
||||
this.dependencies.logger.info(`${logModule} Added extension ${extension.manifest.name}`);
|
||||
this.events.emit("add", extension);
|
||||
}
|
||||
@@ -251,28 +247,23 @@ export class ExtensionDiscovery {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const extension of this.extensions.values()) {
|
||||
if (extension.absolutePath !== filePath) {
|
||||
continue;
|
||||
}
|
||||
const extension = iter.find(
|
||||
this.dependencies.installedExtensions.values(),
|
||||
(ext) => !ext.isBundled && ext.absolutePath === filePath,
|
||||
);
|
||||
|
||||
const extensionName = extension.manifest.name;
|
||||
|
||||
// If the extension is deleted manually while the application is running, also remove the symlink
|
||||
await this.removeSymlinkByPackageName(extensionName);
|
||||
|
||||
// The path to the manifest file is the lens extension id
|
||||
// Note: that we need to use the symlinked path
|
||||
const lensExtensionId = extension.manifestPath;
|
||||
|
||||
this.extensions.delete(extension.id);
|
||||
this.dependencies.logger.info(`${logModule} removed extension ${extensionName}`);
|
||||
this.events.emit("remove", lensExtensionId);
|
||||
if (!extension) {
|
||||
this.dependencies.logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.dependencies.logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`);
|
||||
// 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);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -291,12 +282,16 @@ export class ExtensionDiscovery {
|
||||
* @param extensionId The ID of the extension to uninstall.
|
||||
*/
|
||||
async uninstallExtension(extensionId: LensExtensionId): Promise<void> {
|
||||
const extension = this.extensions.get(extensionId) ?? this.dependencies.extensionLoader.getExtensionById(extensionId);
|
||||
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}`);
|
||||
@@ -307,7 +302,7 @@ export class ExtensionDiscovery {
|
||||
await this.dependencies.removePath(absolutePath);
|
||||
}
|
||||
|
||||
async load(): Promise<Map<LensExtensionId, InstalledExtension>> {
|
||||
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");
|
||||
@@ -323,11 +318,10 @@ export class ExtensionDiscovery {
|
||||
await this.dependencies.ensureDirectory(this.nodeModulesPath);
|
||||
await this.dependencies.ensureDirectory(this.localFolderPath);
|
||||
|
||||
const extensions = await this.ensureExtensions();
|
||||
const userExtensions = await this.loadFromFolder(this.localFolderPath);
|
||||
|
||||
this.dependencies.installedExtensions.replace(userExtensions.map(ext => [ext.id, ext]));
|
||||
this.isLoaded = true;
|
||||
|
||||
return extensions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -385,12 +379,6 @@ export class ExtensionDiscovery {
|
||||
}
|
||||
}
|
||||
|
||||
async ensureExtensions(): Promise<Map<LensExtensionId, ExternalInstalledExtension>> {
|
||||
const userExtensions = await this.loadFromFolder(this.localFolderPath);
|
||||
|
||||
return this.extensions = new Map(userExtensions.map(extension => [extension.id, extension]));
|
||||
}
|
||||
|
||||
async loadFromFolder(folderPath: string): Promise<ExternalInstalledExtension[]> {
|
||||
const extensions: ExternalInstalledExtension[] = [];
|
||||
const paths = await this.dependencies.readDirectory(folderPath);
|
||||
|
||||
@@ -1,32 +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 { ExtensionLoader } from "./extension-loader";
|
||||
import updateExtensionsStateInjectable from "./update-extensions-state/update-extensions-state.injectable";
|
||||
import extensionInstancesInjectable from "./extension-instances.injectable";
|
||||
import type { LensExtension } from "../lens-extension";
|
||||
import extensionInjectable from "./extension/extension.injectable";
|
||||
import loggerInjectable from "../../common/logger.injectable";
|
||||
import joinPathsInjectable from "../../common/path/join-paths.injectable";
|
||||
import getDirnameOfPathInjectable from "../../common/path/get-dirname.injectable";
|
||||
import { bundledExtensionInjectionToken } from "../extension-discovery/bundled-extension-token";
|
||||
import { extensionEntryPointNameInjectionToken } from "./entry-point-name";
|
||||
|
||||
const extensionLoaderInjectable = getInjectable({
|
||||
id: "extension-loader",
|
||||
|
||||
instantiate: (di) => new ExtensionLoader({
|
||||
updateExtensionsState: di.inject(updateExtensionsStateInjectable),
|
||||
extensionInstances: di.inject(extensionInstancesInjectable),
|
||||
getExtension: (instance: LensExtension) => di.inject(extensionInjectable, instance),
|
||||
bundledExtensions: di.injectMany(bundledExtensionInjectionToken),
|
||||
extensionEntryPointName: di.inject(extensionEntryPointNameInjectionToken),
|
||||
logger: di.inject(loggerInjectable),
|
||||
joinPaths: di.inject(joinPathsInjectable),
|
||||
getDirnameOfPath: di.inject(getDirnameOfPathInjectable),
|
||||
}),
|
||||
});
|
||||
|
||||
export default extensionLoaderInjectable;
|
||||
@@ -1,395 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import { ipcMain, ipcRenderer } from "electron";
|
||||
import { isEqual } from "lodash";
|
||||
import type { ObservableMap } from "mobx";
|
||||
import { runInAction, action, computed, observable, reaction, when } from "mobx";
|
||||
import { broadcastMessage, ipcMainOn, ipcRendererOn, ipcMainHandle } from "../../common/ipc";
|
||||
import { isDefined, iter, toJS } from "../../common/utils";
|
||||
import type { BundledInstalledExtension, ExternalInstalledExtension, InstalledExtension } from "../extension-discovery/extension-discovery";
|
||||
import type { LensExtension, LensExtensionConstructor, LensExtensionId } from "../lens-extension";
|
||||
import type { LensExtensionState } from "../extensions-store/extensions-store";
|
||||
import { extensionLoaderFromMainChannel, extensionLoaderFromRendererChannel } from "../../common/ipc/extension-handling";
|
||||
import { requestExtensionLoaderInitialState } from "../../renderer/ipc";
|
||||
import assert from "assert";
|
||||
import { EventEmitter } from "../../common/event-emitter";
|
||||
import type { Extension } from "./extension/extension.injectable";
|
||||
import type { Logger } from "../../common/logger";
|
||||
import type { JoinPaths } from "../../common/path/join-paths.injectable";
|
||||
import type { GetDirnameOfPath } from "../../common/path/get-dirname.injectable";
|
||||
import type { BundledExtension } from "../extension-discovery/bundled-extension-token";
|
||||
|
||||
const logModule = "[EXTENSIONS-LOADER]";
|
||||
|
||||
interface Dependencies {
|
||||
readonly extensionInstances: ObservableMap<LensExtensionId, LensExtension>;
|
||||
readonly bundledExtensions: BundledExtension[];
|
||||
readonly logger: Logger;
|
||||
readonly extensionEntryPointName: "main" | "renderer";
|
||||
updateExtensionsState: (extensionsState: Record<LensExtensionId, LensExtensionState>) => void;
|
||||
getExtension: (instance: LensExtension) => Extension;
|
||||
joinPaths: JoinPaths;
|
||||
getDirnameOfPath: GetDirnameOfPath;
|
||||
}
|
||||
|
||||
interface ExtensionBeingActivated {
|
||||
instance: LensExtension;
|
||||
installedExtension: InstalledExtension;
|
||||
activated: Promise<void>;
|
||||
}
|
||||
|
||||
export interface ExtensionLoading {
|
||||
isBundled: boolean;
|
||||
loaded: Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads installed extensions to the Lens application
|
||||
*/
|
||||
export class ExtensionLoader {
|
||||
protected readonly extensions = observable.map<LensExtensionId, InstalledExtension>();
|
||||
|
||||
/**
|
||||
* This is the set of extensions that don't come with either
|
||||
* - Main.LensExtension when running in the main process
|
||||
* - Renderer.LensExtension when running in the renderer process
|
||||
*/
|
||||
protected readonly nonInstancesByName = observable.set<string>();
|
||||
|
||||
protected readonly instancesByName = computed(() => new Map((
|
||||
iter.chain(this.dependencies.extensionInstances.entries())
|
||||
.map(([, instance]) => [instance.name, instance])
|
||||
)));
|
||||
|
||||
private readonly onRemoveExtensionId = new EventEmitter<[string]>();
|
||||
|
||||
readonly isLoaded = observable.box(false);
|
||||
|
||||
constructor(protected readonly dependencies: Dependencies) {}
|
||||
|
||||
readonly userExtensions = computed(() => new Map((
|
||||
this.extensions.toJSON()
|
||||
.filter(([, extension]) => !extension.isBundled)
|
||||
)));
|
||||
|
||||
/**
|
||||
* Get the extension instance by its manifest name
|
||||
* @param name The name of the extension
|
||||
* @returns one of the following:
|
||||
* - the instance of `Main.LensExtension` on the main process if created
|
||||
* - the instance of `Renderer.LensExtension` on the renderer process if created
|
||||
* - `null` if no class definition is provided for the current process
|
||||
* - `undefined` if the name is not known about
|
||||
*/
|
||||
getInstanceByName(name: string): LensExtension | null | undefined {
|
||||
if (this.nonInstancesByName.has(name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.instancesByName.get().get(name);
|
||||
}
|
||||
|
||||
readonly storeState = computed(() => Object.fromEntries((
|
||||
iter.chain(this.userExtensions.get().entries())
|
||||
.map(([extId, extension]) => [
|
||||
extId,
|
||||
{
|
||||
enabled: extension.isEnabled,
|
||||
name: extension.manifest.name,
|
||||
},
|
||||
])
|
||||
)));
|
||||
|
||||
async init() {
|
||||
if (ipcMain) {
|
||||
await this.initMain();
|
||||
} else {
|
||||
await this.initRenderer();
|
||||
}
|
||||
|
||||
await when(() => this.isLoaded.get());
|
||||
|
||||
// broadcasting extensions between main/renderer processes
|
||||
reaction(() => this.toJSON(), () => this.broadcastExtensions(), {
|
||||
fireImmediately: true,
|
||||
});
|
||||
|
||||
reaction(
|
||||
() => this.storeState.get(),
|
||||
(state) => {
|
||||
this.dependencies.updateExtensionsState(state);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
initExtensions(extensions: Map<LensExtensionId, InstalledExtension>) {
|
||||
this.extensions.replace(extensions);
|
||||
}
|
||||
|
||||
addExtension(extension: InstalledExtension) {
|
||||
this.extensions.set(extension.id, extension);
|
||||
}
|
||||
|
||||
@action
|
||||
removeInstance(lensExtensionId: LensExtensionId) {
|
||||
this.dependencies.logger.info(`${logModule} deleting extension instance ${lensExtensionId}`);
|
||||
const instance = this.dependencies.extensionInstances.get(lensExtensionId);
|
||||
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
instance.disable();
|
||||
|
||||
const extension = this.dependencies.getExtension(instance);
|
||||
|
||||
extension.deregister();
|
||||
|
||||
this.onRemoveExtensionId.emit(instance.id);
|
||||
this.dependencies.extensionInstances.delete(lensExtensionId);
|
||||
this.nonInstancesByName.delete(instance.name);
|
||||
} catch (error) {
|
||||
this.dependencies.logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error });
|
||||
}
|
||||
}
|
||||
|
||||
removeExtension(lensExtensionId: LensExtensionId) {
|
||||
this.removeInstance(lensExtensionId);
|
||||
|
||||
if (!this.extensions.delete(lensExtensionId)) {
|
||||
throw new Error(`Can't remove extension ${lensExtensionId}, doesn't exist.`);
|
||||
}
|
||||
}
|
||||
|
||||
setIsEnabled(lensExtensionId: LensExtensionId, isEnabled: boolean) {
|
||||
const extension = this.extensions.get(lensExtensionId);
|
||||
|
||||
assert(extension, `Must register extension ${lensExtensionId} with before enabling it`);
|
||||
assert(!extension.isBundled, `Cannot change the enabled state of a bundled extension`);
|
||||
|
||||
extension.isEnabled = isEnabled;
|
||||
}
|
||||
|
||||
protected async initMain() {
|
||||
runInAction(() => {
|
||||
this.isLoaded.set(true);
|
||||
});
|
||||
|
||||
await this.autoInitExtensions();
|
||||
|
||||
ipcMainHandle(extensionLoaderFromMainChannel, () => [...this.toJSON()]);
|
||||
|
||||
ipcMainOn(extensionLoaderFromRendererChannel, (event, extensions: [LensExtensionId, InstalledExtension][]) => {
|
||||
this.syncExtensions(extensions);
|
||||
});
|
||||
}
|
||||
|
||||
protected async initRenderer() {
|
||||
const extensionListHandler = (extensions: [LensExtensionId, InstalledExtension][]) => {
|
||||
runInAction(() => {
|
||||
this.isLoaded.set(true);
|
||||
});
|
||||
this.syncExtensions(extensions);
|
||||
|
||||
const receivedExtensionIds = extensions.map(([lensExtensionId]) => lensExtensionId);
|
||||
|
||||
// Remove deleted extensions in renderer side only
|
||||
this.extensions.forEach((_, lensExtensionId) => {
|
||||
if (!receivedExtensionIds.includes(lensExtensionId)) {
|
||||
this.removeExtension(lensExtensionId);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
requestExtensionLoaderInitialState().then(extensionListHandler);
|
||||
ipcRendererOn(extensionLoaderFromMainChannel, (event, extensions: [LensExtensionId, InstalledExtension][]) => {
|
||||
extensionListHandler(extensions);
|
||||
});
|
||||
}
|
||||
|
||||
broadcastExtensions() {
|
||||
const channel = ipcRenderer
|
||||
? extensionLoaderFromRendererChannel
|
||||
: extensionLoaderFromMainChannel;
|
||||
|
||||
broadcastMessage(channel, Array.from(this.extensions));
|
||||
}
|
||||
|
||||
syncExtensions(extensions: [LensExtensionId, InstalledExtension][]) {
|
||||
extensions.forEach(([lensExtensionId, extension]) => {
|
||||
if (!isEqual(this.extensions.get(lensExtensionId), extension)) {
|
||||
this.extensions.set(lensExtensionId, extension);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected async loadBundledExtensions() {
|
||||
const bundledExtensions = await Promise.all((this.dependencies.bundledExtensions
|
||||
.map(async extension => {
|
||||
try {
|
||||
const LensExtensionClass = await extension[this.dependencies.extensionEntryPointName]();
|
||||
|
||||
if (!LensExtensionClass) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const installedExtension: BundledInstalledExtension = {
|
||||
absolutePath: "irrelevant",
|
||||
id: extension.manifest.name,
|
||||
isBundled: true,
|
||||
isCompatible: true,
|
||||
isEnabled: true,
|
||||
manifest: extension.manifest,
|
||||
manifestPath: "irrelevant",
|
||||
};
|
||||
const instance = new LensExtensionClass(installedExtension);
|
||||
|
||||
this.dependencies.extensionInstances.set(extension.manifest.name, instance);
|
||||
|
||||
return {
|
||||
instance,
|
||||
installedExtension,
|
||||
activated: instance.activate(),
|
||||
} as ExtensionBeingActivated;
|
||||
} catch (err) {
|
||||
this.dependencies.logger.error(`${logModule}: error loading extension`, { ext: extension, err });
|
||||
|
||||
return null;
|
||||
}
|
||||
})
|
||||
));
|
||||
|
||||
return bundledExtensions.filter(isDefined);
|
||||
}
|
||||
|
||||
protected async loadExtensions(extensions: ExtensionBeingActivated[]): Promise<ExtensionLoading[]> {
|
||||
// We first need to wait until each extension's `onActivate` is resolved or rejected,
|
||||
// as this might register new catalog categories. Afterwards we can safely .enable the extension.
|
||||
await Promise.all(
|
||||
extensions.map(extension =>
|
||||
// If extension activation fails, log error
|
||||
extension.activated.catch((error) => {
|
||||
this.dependencies.logger.error(`${logModule}: activation extension error`, { ext: extension.installedExtension, error });
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
extensions.forEach(({ instance }) => {
|
||||
const extension = this.dependencies.getExtension(instance);
|
||||
|
||||
extension.register();
|
||||
});
|
||||
|
||||
return extensions.map(extension => {
|
||||
const loaded = extension.instance.enable().catch((err) => {
|
||||
this.dependencies.logger.error(`${logModule}: failed to enable`, { ext: extension, err });
|
||||
});
|
||||
|
||||
return {
|
||||
isBundled: extension.installedExtension.isBundled,
|
||||
loaded,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
protected async loadUserExtensions(installedExtensions: Map<string, InstalledExtension>) {
|
||||
// Steps of the function:
|
||||
// 1. require and call .activate for each Extension
|
||||
// 2. Wait until every extension's onActivate has been resolved
|
||||
// 3. Call .enable for each extension
|
||||
// 4. Return ExtensionLoading[]
|
||||
|
||||
return [...installedExtensions.entries()]
|
||||
.filter((entry): entry is [string, ExternalInstalledExtension] => !entry[1].isBundled)
|
||||
.map(([extId, installedExtension]) => {
|
||||
const alreadyInit = this.dependencies.extensionInstances.has(extId) || this.nonInstancesByName.has(installedExtension.manifest.name);
|
||||
|
||||
if (installedExtension.isCompatible && installedExtension.isEnabled && !alreadyInit) {
|
||||
try {
|
||||
const LensExtensionClass = this.requireExtension(installedExtension);
|
||||
|
||||
if (!LensExtensionClass) {
|
||||
this.nonInstancesByName.add(installedExtension.manifest.name);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const instance = new LensExtensionClass(installedExtension);
|
||||
|
||||
this.dependencies.extensionInstances.set(extId, instance);
|
||||
|
||||
return {
|
||||
instance,
|
||||
installedExtension,
|
||||
activated: instance.activate(),
|
||||
} as ExtensionBeingActivated;
|
||||
} catch (err) {
|
||||
this.dependencies.logger.error(`${logModule}: error loading extension`, { ext: installedExtension, err });
|
||||
}
|
||||
} else if (!installedExtension.isEnabled && alreadyInit) {
|
||||
this.removeInstance(extId);
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter(isDefined);
|
||||
}
|
||||
|
||||
async autoInitExtensions() {
|
||||
this.dependencies.logger.info(`${logModule}: auto initializing extensions`);
|
||||
|
||||
const bundledExtensions = await this.loadBundledExtensions();
|
||||
const userExtensions = await this.loadUserExtensions(this.toJSON());
|
||||
const loadedExtensions = await this.loadExtensions([
|
||||
...bundledExtensions,
|
||||
...userExtensions,
|
||||
]);
|
||||
|
||||
// Setup reaction to load extensions on JSON changes
|
||||
reaction(() => this.toJSON(), installedExtensions => {
|
||||
void (async () => {
|
||||
const userExtensions = await this.loadUserExtensions(installedExtensions);
|
||||
|
||||
await this.loadExtensions(userExtensions);
|
||||
})();
|
||||
});
|
||||
|
||||
return loadedExtensions;
|
||||
}
|
||||
|
||||
protected requireExtension(extension: ExternalInstalledExtension): LensExtensionConstructor | null {
|
||||
const extRelativePath = extension.manifest[this.dependencies.extensionEntryPointName];
|
||||
|
||||
if (!extRelativePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const extAbsolutePath = this.dependencies.joinPaths(this.dependencies.getDirnameOfPath(extension.manifestPath), extRelativePath);
|
||||
|
||||
try {
|
||||
return require(/* webpackIgnore: true */ extAbsolutePath).default;
|
||||
} catch (error) {
|
||||
const message = (error instanceof Error ? error.stack : undefined) || error;
|
||||
|
||||
this.dependencies.logger.error(`${logModule}: can't load ${this.dependencies.extensionEntryPointName} for "${extension.manifest.name}": ${message}`, { extension });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getExtensionById(extId: LensExtensionId) {
|
||||
return this.extensions.get(extId);
|
||||
}
|
||||
|
||||
getInstanceById(extId: LensExtensionId) {
|
||||
return this.dependencies.extensionInstances.get(extId);
|
||||
}
|
||||
|
||||
toJSON(): Map<LensExtensionId, InstalledExtension> {
|
||||
return toJS(this.extensions);
|
||||
}
|
||||
}
|
||||
-13
@@ -1,13 +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 extensionsStoreInjectable from "../../extensions-store/extensions-store.injectable";
|
||||
|
||||
const updateExtensionsStateInjectable = getInjectable({
|
||||
id: "update-extensions-state",
|
||||
instantiate: (di) => di.inject(extensionsStoreInjectable).mergeState,
|
||||
});
|
||||
|
||||
export default updateExtensionsStateInjectable;
|
||||
@@ -34,7 +34,7 @@ export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> {
|
||||
.map(({ name }) => name);
|
||||
}
|
||||
|
||||
protected readonly state = observable.map<LensExtensionId, LensExtensionState>();
|
||||
readonly state = observable.map<LensExtensionId, LensExtensionState>();
|
||||
|
||||
isEnabled(extId: LensExtensionId): boolean {
|
||||
// By default false, so that copied extensions are disabled by default.
|
||||
@@ -42,10 +42,6 @@ export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> {
|
||||
return this.state.get(extId)?.enabled ?? false;
|
||||
}
|
||||
|
||||
mergeState = action((extensionsState: Record<LensExtensionId, LensExtensionState> | [LensExtensionId, LensExtensionState][]) => {
|
||||
this.state.merge(extensionsState);
|
||||
});
|
||||
|
||||
@action
|
||||
protected fromStore({ extensions }: LensExtensionsStoreModel) {
|
||||
this.state.merge(extensions);
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
*/
|
||||
|
||||
import type { BundledInstalledExtension, ExternalInstalledExtension, InstalledExtension } from "./extension-discovery/extension-discovery";
|
||||
import { action, computed, makeObservable, observable } from "mobx";
|
||||
import { observable } from "mobx";
|
||||
import { disposer } from "../common/utils";
|
||||
import type { ProtocolHandlerRegistration } from "../common/protocol-handler/registration";
|
||||
import type { PackageJson } from "type-fest";
|
||||
import type { FileSystemProvisionerStore } from "./extension-loader/file-system-provisioner-store/file-system-provisioner-store";
|
||||
import type { Logger } from "../common/logger";
|
||||
import assert from "assert";
|
||||
|
||||
export type LensExtensionId = string; // path to manifest (package.json)
|
||||
export type LensExtensionConstructor = new (ext: ExternalInstalledExtension) => LensExtension;
|
||||
@@ -19,6 +20,10 @@ export interface BundledLensExtensionManifest extends PackageJson {
|
||||
name: string;
|
||||
version: string;
|
||||
publishConfig?: Partial<Record<string, string>>;
|
||||
|
||||
// Specify extension name used for persisting data.
|
||||
// Useful if extension is renamed but the data should not be lost.
|
||||
storeName?: string;
|
||||
}
|
||||
|
||||
export interface LensExtensionDependencies {
|
||||
@@ -37,35 +42,39 @@ export interface LensExtensionManifest extends BundledLensExtensionManifest {
|
||||
lens: string; // "semver"-package format
|
||||
[x: string]: string | undefined;
|
||||
};
|
||||
|
||||
// Specify extension name used for persisting data.
|
||||
// Useful if extension is renamed but the data should not be lost.
|
||||
storeName?: string;
|
||||
}
|
||||
|
||||
export const Disposers = Symbol("disposers");
|
||||
|
||||
export class LensExtension {
|
||||
readonly id: LensExtensionId;
|
||||
readonly manifest: LensExtensionManifest;
|
||||
readonly manifestPath: string;
|
||||
readonly isBundled: boolean;
|
||||
get id() {
|
||||
return this.extension.id;
|
||||
}
|
||||
|
||||
get manifest() {
|
||||
return this.extension.manifest as LensExtensionManifest;
|
||||
}
|
||||
|
||||
get manifestPath() {
|
||||
assert(!this.extension.isBundled, "LensExtension.manifestPath doesn't exist for bundled extensions");
|
||||
|
||||
return this.extension.manifestPath;
|
||||
}
|
||||
|
||||
get isBundled() {
|
||||
return this.extension.isBundled;
|
||||
}
|
||||
|
||||
get sanitizedExtensionId() {
|
||||
return sanitizeExtensionName(this.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*/
|
||||
protected readonly dependencies: LensExtensionDependencies;
|
||||
|
||||
protocolHandlers: ProtocolHandlerRegistration[] = [];
|
||||
|
||||
@observable private _isEnabled = false;
|
||||
private readonly _isEnabled = observable.box(false);
|
||||
|
||||
@computed get isEnabled() {
|
||||
return this._isEnabled;
|
||||
get isEnabled() {
|
||||
return this._isEnabled.get();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,13 +82,13 @@ export class LensExtension {
|
||||
*/
|
||||
[Disposers] = disposer();
|
||||
|
||||
constructor(deps: LensExtensionDependencies, { id, manifest, manifestPath, isBundled }: InstalledExtension) {
|
||||
/**
|
||||
* @ignore
|
||||
*/
|
||||
declare protected readonly dependencies: LensExtensionDependencies;
|
||||
|
||||
constructor(deps: LensExtensionDependencies, private readonly extension: InstalledExtension) {
|
||||
this.dependencies = deps;
|
||||
this.id = id;
|
||||
this.manifest = manifest as LensExtensionManifest;
|
||||
this.manifestPath = manifestPath;
|
||||
this.isBundled = isBundled;
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
get name() {
|
||||
@@ -111,23 +120,21 @@ export class LensExtension {
|
||||
return this.dependencies.fileSystemProvisionerStore.requestDirectory(this.storeName);
|
||||
}
|
||||
|
||||
@action
|
||||
async enable() {
|
||||
if (this._isEnabled) {
|
||||
if (this._isEnabled.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._isEnabled = true;
|
||||
this._isEnabled.set(true);
|
||||
this.dependencies.logger.info(`[EXTENSION]: enabled ${this.name}@${this.version}`);
|
||||
}
|
||||
|
||||
@action
|
||||
async disable() {
|
||||
if (!this._isEnabled) {
|
||||
if (!this._isEnabled.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._isEnabled = false;
|
||||
this._isEnabled.set(false);
|
||||
|
||||
try {
|
||||
await this.onDeactivate();
|
||||
|
||||
Reference in new issue
Block a user