Fully convert ExtensionLoader to be injectable

- To fix unit tests

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton committed 2023-03-01 13:34:46 -05:00
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);
}
}
@@ -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);
+36 -29
View File
@@ -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();