1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Introduce clearer boundry between extensions

- Bundled extensions are always enabled, and are always compatible
- Have bundled extensions be loaded asyncronously to support
  typescript dynamic import (which is typed) as opposed to require

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2023-02-14 15:06:06 -05:00
parent 6df01ba468
commit e3c0bc34fd
26 changed files with 231 additions and 278 deletions

View File

@ -209,7 +209,7 @@ export abstract class LensProtocolRouter {
return name; return name;
} }
if (!this.dependencies.extensionsStore.isEnabled(extension)) { if (!extension.isBundled && !this.dependencies.extensionsStore.isEnabled(extension.id)) {
this.dependencies.logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not enabled`); this.dependencies.logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not enabled`);
return name; return name;

View File

@ -114,38 +114,36 @@ describe("ExtensionLoader", () => {
}); });
it("renderer updates extension after ipc broadcast", async () => { it("renderer updates extension after ipc broadcast", async () => {
expect(extensionLoader.userExtensions).toMatchInlineSnapshot(`Map {}`); expect(extensionLoader.userExtensions.get().size).toBe(0);
await extensionLoader.init(); await extensionLoader.init();
await delay(10); await delay(10);
// Assert the extensions after the extension broadcast event // Assert the extensions after the extension broadcast event
expect(extensionLoader.userExtensions).toMatchInlineSnapshot(` expect(extensionLoader.userExtensions.get()).toEqual(new Map([
Map { ["manifest/path", {
"manifest/path" => Object { absolutePath: "/test/1",
"absolutePath": "/test/1", id: "manifest/path",
"id": "manifest/path", isBundled: false,
"isBundled": false, isEnabled: true,
"isEnabled": true, manifest: {
"manifest": Object { name: "TestExtension",
"name": "TestExtension", version: "1.0.0",
"version": "1.0.0",
},
"manifestPath": "manifest/path",
}, },
"manifest/path3" => Object { manifestPath: "manifest/path",
"absolutePath": "/test/3", }],
"id": "manifest/path3", ["manifest/path3", {
"isBundled": false, absolutePath: "/test/3",
"isEnabled": true, id: "manifest/path3",
"manifest": Object { isBundled: false,
"name": "TestExtension3", isEnabled: true,
"version": "3.0.0", manifest: {
}, name: "TestExtension3",
"manifestPath": "manifest/path3", version: "3.0.0",
}, },
} manifestPath: "manifest/path3",
`); }],
]));
}); });
it("updates ExtensionsStore after isEnabled is changed", async () => { it("updates ExtensionsStore after isEnabled is changed", async () => {

View File

@ -4,12 +4,12 @@
*/ */
import { getInjectionToken } from "@ogre-tools/injectable"; import { getInjectionToken } from "@ogre-tools/injectable";
import type { LensExtensionConstructor, LensExtensionManifest } from "../lens-extension"; import type { BundledLensExtensionManifest, BundledLensExtensionContructor } from "../lens-extension";
export interface BundledExtension { export interface BundledExtension {
readonly manifest: LensExtensionManifest; readonly manifest: BundledLensExtensionManifest;
main: () => LensExtensionConstructor | null; main: () => Promise<BundledLensExtensionContructor | null>;
renderer: () => LensExtensionConstructor | null; renderer: () => Promise<BundledLensExtensionContructor | null>;
} }
export const bundledExtensionInjectionToken = getInjectionToken<BundledExtension>({ export const bundledExtensionInjectionToken = getInjectionToken<BundledExtension>({

View File

@ -10,7 +10,7 @@ import { broadcastMessage, ipcMainHandle, ipcRendererOn } from "../../common/ipc
import { isErrnoException, toJS } from "../../common/utils"; import { isErrnoException, toJS } from "../../common/utils";
import type { ExtensionsStore } from "../extensions-store/extensions-store"; import type { ExtensionsStore } from "../extensions-store/extensions-store";
import type { ExtensionLoader } from "../extension-loader"; import type { ExtensionLoader } from "../extension-loader";
import type { LensExtensionId, LensExtensionManifest } from "../lens-extension"; import type { BundledLensExtensionManifest, LensExtensionId, LensExtensionManifest } from "../lens-extension";
import type { ExtensionInstallationStateStore } from "../extension-installation-state-store/extension-installation-state-store"; import type { ExtensionInstallationStateStore } from "../extension-installation-state-store/extension-installation-state-store";
import { extensionDiscoveryStateChannel } from "../../common/ipc/extension-handling"; import { extensionDiscoveryStateChannel } from "../../common/ipc/extension-handling";
import { requestInitialExtensionDiscovery } from "../../renderer/ipc"; import { requestInitialExtensionDiscovery } from "../../renderer/ipc";
@ -58,22 +58,31 @@ interface Dependencies {
getRelativePath: GetRelativePath; getRelativePath: GetRelativePath;
} }
export interface InstalledExtension { export interface BaseInstalledExtension {
id: LensExtensionId; readonly id: LensExtensionId;
readonly manifest: LensExtensionManifest;
// Absolute path to the non-symlinked source folder, // Absolute path to the non-symlinked source folder,
// e.g. "/Users/user/.k8slens/extensions/helloworld" // e.g. "/Users/user/.k8slens/extensions/helloworld"
readonly absolutePath: string; readonly absolutePath: string;
// Absolute to the symlinked package.json file // Absolute to the symlinked package.json file
readonly manifestPath: string; readonly manifestPath: string;
readonly isBundled: boolean; // defined in project root's package.json }
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; readonly isCompatible: boolean;
isEnabled: boolean; isEnabled: boolean;
} }
export type InstalledExtension = BundledInstalledExtension | ExternalInstalledExtension;
const logModule = "[EXTENSION-DISCOVERY]"; const logModule = "[EXTENSION-DISCOVERY]";
export const manifestFilename = "package.json"; export const manifestFilename = "package.json";
@ -88,10 +97,6 @@ interface ExtensionDiscoveryChannelMessage {
*/ */
const isDirectoryLike = (lstat: Stats) => lstat.isDirectory() || lstat.isSymbolicLink(); const isDirectoryLike = (lstat: Stats) => lstat.isDirectory() || lstat.isSymbolicLink();
interface LoadFromFolderOptions {
isBundled?: boolean;
}
interface ExtensionDiscoveryEvents { interface ExtensionDiscoveryEvents {
add: (ext: InstalledExtension) => void; add: (ext: InstalledExtension) => void;
remove: (extId: LensExtensionId) => void; remove: (extId: LensExtensionId) => void;
@ -286,7 +291,7 @@ export class ExtensionDiscovery {
* @param extensionId The ID of the extension to uninstall. * @param extensionId The ID of the extension to uninstall.
*/ */
async uninstallExtension(extensionId: LensExtensionId): Promise<void> { async uninstallExtension(extensionId: LensExtensionId): Promise<void> {
const extension = this.extensions.get(extensionId) ?? this.dependencies.extensionLoader.getExtension(extensionId); const extension = this.extensions.get(extensionId) ?? this.dependencies.extensionLoader.getExtensionById(extensionId);
if (!extension) { if (!extension) {
return void this.dependencies.logger.warn(`${logModule} could not uninstall extension, not found`, { id: extensionId }); return void this.dependencies.logger.warn(`${logModule} could not uninstall extension, not found`, { id: extensionId });
@ -345,24 +350,26 @@ export class ExtensionDiscovery {
* Returns InstalledExtension from path to package.json file. * Returns InstalledExtension from path to package.json file.
* Also updates this.packagesJson. * Also updates this.packagesJson.
*/ */
protected async getByManifest(manifestPath: string, { isBundled = false } = {}): Promise<InstalledExtension | null> { protected async loadExtensionFromFolder(folderPath: string): Promise<ExternalInstalledExtension | null> {
const manifestPath = this.dependencies.joinPaths(folderPath, manifestFilename);
try { try {
const manifest = await this.dependencies.readJsonFile(manifestPath) as unknown as LensExtensionManifest; const manifest = await this.dependencies.readJsonFile(manifestPath) as unknown as LensExtensionManifest;
const id = isBundled ? manifestPath : this.getInstalledManifestPath(manifest.name); const id = this.getInstalledManifestPath(manifest.name);
const isEnabled = this.dependencies.extensionsStore.isEnabled({ id, isBundled }); const isEnabled = this.dependencies.extensionsStore.isEnabled(id);
const extensionDir = this.dependencies.getDirnameOfPath(manifestPath); const extensionDir = this.dependencies.getDirnameOfPath(manifestPath);
const npmPackage = this.dependencies.joinPaths(extensionDir, `${manifest.name}-${manifest.version}.tgz`); const npmPackage = this.dependencies.joinPaths(extensionDir, `${manifest.name}-${manifest.version}.tgz`);
const absolutePath = this.dependencies.isProduction && await this.dependencies.pathExists(npmPackage) const absolutePath = this.dependencies.isProduction && await this.dependencies.pathExists(npmPackage)
? npmPackage ? npmPackage
: extensionDir; : extensionDir;
const isCompatible = isBundled || this.dependencies.isCompatibleExtension(manifest); const isCompatible = this.dependencies.isCompatibleExtension(manifest);
return { return {
id, id,
absolutePath, absolutePath,
manifestPath: id, manifestPath: id,
manifest, manifest,
isBundled, isBundled: false,
isEnabled, isEnabled,
isCompatible, isCompatible,
}; };
@ -378,14 +385,14 @@ export class ExtensionDiscovery {
} }
} }
async ensureExtensions(): Promise<Map<LensExtensionId, InstalledExtension>> { async ensureExtensions(): Promise<Map<LensExtensionId, ExternalInstalledExtension>> {
const userExtensions = await this.loadFromFolder(this.localFolderPath); const userExtensions = await this.loadFromFolder(this.localFolderPath);
return this.extensions = new Map(userExtensions.map(extension => [extension.id, extension])); return this.extensions = new Map(userExtensions.map(extension => [extension.id, extension]));
} }
async loadFromFolder(folderPath: string): Promise<InstalledExtension[]> { async loadFromFolder(folderPath: string): Promise<ExternalInstalledExtension[]> {
const extensions: InstalledExtension[] = []; const extensions: ExternalInstalledExtension[] = [];
const paths = await this.dependencies.readDirectory(folderPath); const paths = await this.dependencies.readDirectory(folderPath);
for (const fileName of paths) { for (const fileName of paths) {
@ -418,16 +425,6 @@ export class ExtensionDiscovery {
return extensions; return extensions;
} }
/**
* Loads extension from absolute path, updates this.packagesJson to include it and returns the extension.
* @param folderPath Folder path to extension
*/
async loadExtensionFromFolder(folderPath: string, { isBundled = false }: LoadFromFolderOptions = {}): Promise<InstalledExtension | null> {
const manifestPath = this.dependencies.joinPaths(folderPath, manifestFilename);
return this.getByManifest(manifestPath, { isBundled });
}
toJSON(): ExtensionDiscoveryChannelMessage { toJSON(): ExtensionDiscoveryChannelMessage {
return toJS({ return toJS({
isLoaded: this.isLoaded, isLoaded: this.isLoaded,

View File

@ -4,10 +4,13 @@
*/ */
import { getInjectionToken } from "@ogre-tools/injectable"; import { getInjectionToken } from "@ogre-tools/injectable";
import type { InstalledExtension } from "../extension-discovery/extension-discovery"; import type { BundledInstalledExtension, ExternalInstalledExtension } from "../extension-discovery/extension-discovery";
import type { LensExtension, LensExtensionConstructor } from "../lens-extension"; import type { BundledLensExtensionContructor, LensExtension, LensExtensionConstructor } from "../lens-extension";
export type CreateExtensionInstance = (ExtensionClass: LensExtensionConstructor, extension: InstalledExtension) => LensExtension; export interface CreateExtensionInstance {
(ExtensionClass: LensExtensionConstructor, extension: ExternalInstalledExtension): LensExtension;
(ExtensionClass: BundledLensExtensionContructor, extension: BundledInstalledExtension): LensExtension;
}
export const createExtensionInstanceInjectionToken = getInjectionToken<CreateExtensionInstance>({ export const createExtensionInstanceInjectionToken = getInjectionToken<CreateExtensionInstance>({
id: "create-extension-instance-token", id: "create-extension-instance-token",

View File

@ -6,10 +6,10 @@
import { ipcMain, ipcRenderer } from "electron"; import { ipcMain, ipcRenderer } from "electron";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import type { ObservableMap } from "mobx"; import type { ObservableMap } from "mobx";
import { action, computed, makeObservable, observable, observe, reaction, when } from "mobx"; import { runInAction, action, computed, observable, reaction, when } from "mobx";
import { broadcastMessage, ipcMainOn, ipcRendererOn, ipcMainHandle } from "../../common/ipc"; import { broadcastMessage, ipcMainOn, ipcRendererOn, ipcMainHandle } from "../../common/ipc";
import { isDefined, toJS } from "../../common/utils"; import { isDefined, iter, toJS } from "../../common/utils";
import type { InstalledExtension } from "../extension-discovery/extension-discovery"; import type { ExternalInstalledExtension, InstalledExtension } from "../extension-discovery/extension-discovery";
import type { LensExtension, LensExtensionConstructor, LensExtensionId } from "../lens-extension"; import type { LensExtension, LensExtensionConstructor, LensExtensionId } from "../lens-extension";
import type { LensExtensionState } from "../extensions-store/extensions-store"; import type { LensExtensionState } from "../extensions-store/extensions-store";
import { extensionLoaderFromMainChannel, extensionLoaderFromRendererChannel } from "../../common/ipc/extension-handling"; import { extensionLoaderFromMainChannel, extensionLoaderFromRendererChannel } from "../../common/ipc/extension-handling";
@ -61,51 +61,21 @@ export class ExtensionLoader {
*/ */
protected readonly nonInstancesByName = observable.set<string>(); protected readonly nonInstancesByName = observable.set<string>();
/** protected readonly instancesByName = computed(() => new Map((
* This is updated by the `observe` in the constructor. DO NOT write directly to it iter.chain(this.dependencies.extensionInstances.entries())
*/ .map(([, instance]) => [instance.name, instance])
protected readonly instancesByName = observable.map<string, LensExtension>(); )));
private readonly onRemoveExtensionId = new EventEmitter<[string]>(); private readonly onRemoveExtensionId = new EventEmitter<[string]>();
@observable isLoaded = false; readonly isLoaded = observable.box(false);
get whenLoaded() { constructor(protected readonly dependencies: Dependencies) {}
return when(() => this.isLoaded);
}
constructor(protected readonly dependencies: Dependencies) { readonly userExtensions = computed(() => new Map((
makeObservable(this); this.extensions.toJSON()
.filter(([, extension]) => !extension.isBundled)
observe(this.dependencies.extensionInstances, change => { )));
switch (change.type) {
case "add":
if (this.instancesByName.has(change.newValue.name)) {
throw new TypeError("Extension names must be unique");
}
this.instancesByName.set(change.newValue.name, change.newValue);
break;
case "delete":
this.instancesByName.delete(change.oldValue.name);
break;
case "update":
throw new Error("Extension instances shouldn't be updated");
}
});
}
@computed get userExtensions(): Map<LensExtensionId, InstalledExtension> {
const extensions = this.toJSON();
extensions.forEach((ext, extId) => {
if (ext.isBundled) {
extensions.delete(extId);
}
});
return extensions;
}
/** /**
* Get the extension instance by its manifest name * Get the extension instance by its manifest name
@ -121,21 +91,20 @@ export class ExtensionLoader {
return null; return null;
} }
return this.instancesByName.get(name); return this.instancesByName.get().get(name);
} }
// Transform userExtensions to a state object for storing into ExtensionsStore readonly storeState = computed(() => Object.fromEntries((
@computed get storeState() { iter.chain(this.userExtensions.get().entries())
return Object.fromEntries( .map(([extId, extension]) => [
Array.from(this.userExtensions) extId,
.map(([extId, extension]) => [extId, { {
enabled: extension.isEnabled, enabled: extension.isEnabled,
name: extension.manifest.name, name: extension.manifest.name,
}]), },
); ])
} )));
@action
async init() { async init() {
if (ipcMain) { if (ipcMain) {
await this.initMain(); await this.initMain();
@ -143,7 +112,7 @@ export class ExtensionLoader {
await this.initRenderer(); await this.initRenderer();
} }
await Promise.all([this.whenLoaded]); await when(() => this.isLoaded.get());
// broadcasting extensions between main/renderer processes // broadcasting extensions between main/renderer processes
reaction(() => this.toJSON(), () => this.broadcastExtensions(), { reaction(() => this.toJSON(), () => this.broadcastExtensions(), {
@ -151,8 +120,7 @@ export class ExtensionLoader {
}); });
reaction( reaction(
() => this.storeState, () => this.storeState.get(),
(state) => { (state) => {
this.dependencies.updateExtensionsState(state); this.dependencies.updateExtensionsState(state);
}, },
@ -203,17 +171,19 @@ export class ExtensionLoader {
const extension = this.extensions.get(lensExtensionId); const extension = this.extensions.get(lensExtensionId);
assert(extension, `Must register extension ${lensExtensionId} with before enabling it`); 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; extension.isEnabled = isEnabled;
} }
protected async initMain() { protected async initMain() {
this.isLoaded = true; runInAction(() => {
this.isLoaded.set(true);
});
await this.autoInitExtensions(); await this.autoInitExtensions();
ipcMainHandle(extensionLoaderFromMainChannel, () => { ipcMainHandle(extensionLoaderFromMainChannel, () => [...this.toJSON()]);
return Array.from(this.toJSON());
});
ipcMainOn(extensionLoaderFromRendererChannel, (event, extensions: [LensExtensionId, InstalledExtension][]) => { ipcMainOn(extensionLoaderFromRendererChannel, (event, extensions: [LensExtensionId, InstalledExtension][]) => {
this.syncExtensions(extensions); this.syncExtensions(extensions);
@ -222,7 +192,9 @@ export class ExtensionLoader {
protected async initRenderer() { protected async initRenderer() {
const extensionListHandler = (extensions: [LensExtensionId, InstalledExtension][]) => { const extensionListHandler = (extensions: [LensExtensionId, InstalledExtension][]) => {
this.isLoaded = true; runInAction(() => {
this.isLoaded.set(true);
});
this.syncExtensions(extensions); this.syncExtensions(extensions);
const receivedExtensionIds = extensions.map(([lensExtensionId]) => lensExtensionId); const receivedExtensionIds = extensions.map(([lensExtensionId]) => lensExtensionId);
@ -258,10 +230,10 @@ export class ExtensionLoader {
} }
protected async loadBundledExtensions() { protected async loadBundledExtensions() {
return this.dependencies.bundledExtensions const bundledExtensions = await Promise.all((this.dependencies.bundledExtensions
.map(extension => { .map(async extension => {
try { try {
const LensExtensionClass = extension[this.dependencies.extensionEntryPointName](); const LensExtensionClass = await extension[this.dependencies.extensionEntryPointName]();
if (!LensExtensionClass) { if (!LensExtensionClass) {
return null; return null;
@ -294,7 +266,9 @@ export class ExtensionLoader {
return null; return null;
} }
}) })
.filter(isDefined); ));
return bundledExtensions.filter(isDefined);
} }
protected async loadExtensions(extensions: ExtensionBeingActivated[]): Promise<ExtensionLoading[]> { protected async loadExtensions(extensions: ExtensionBeingActivated[]): Promise<ExtensionLoading[]> {
@ -335,6 +309,7 @@ export class ExtensionLoader {
// 4. Return ExtensionLoading[] // 4. Return ExtensionLoading[]
return [...installedExtensions.entries()] return [...installedExtensions.entries()]
.filter((entry): entry is [string, ExternalInstalledExtension] => !entry[1].isBundled)
.map(([extId, extension]) => { .map(([extId, extension]) => {
const alreadyInit = this.dependencies.extensionInstances.has(extId) || this.nonInstancesByName.has(extension.manifest.name); const alreadyInit = this.dependencies.extensionInstances.has(extId) || this.nonInstancesByName.has(extension.manifest.name);
@ -394,7 +369,7 @@ export class ExtensionLoader {
return loadedExtensions; return loadedExtensions;
} }
protected requireExtension(extension: InstalledExtension): LensExtensionConstructor | null { protected requireExtension(extension: ExternalInstalledExtension): LensExtensionConstructor | null {
const extRelativePath = extension.manifest[this.dependencies.extensionEntryPointName]; const extRelativePath = extension.manifest[this.dependencies.extensionEntryPointName];
if (!extRelativePath) { if (!extRelativePath) {
@ -414,7 +389,7 @@ export class ExtensionLoader {
return null; return null;
} }
getExtension(extId: LensExtensionId) { getExtensionById(extId: LensExtensionId) {
return this.extensions.get(extId); return this.extensions.get(extId);
} }

View File

@ -18,11 +18,6 @@ export interface LensExtensionState {
name: string; name: string;
} }
export interface IsEnabledExtensionDescriptor {
id: string;
isBundled: boolean;
}
export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> { export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> {
constructor(deps: BaseStoreDependencies) { constructor(deps: BaseStoreDependencies) {
super(deps, { super(deps, {
@ -39,12 +34,12 @@ export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> {
.map(({ name }) => name); .map(({ name }) => name);
} }
protected state = observable.map<LensExtensionId, LensExtensionState>(); protected readonly state = observable.map<LensExtensionId, LensExtensionState>();
isEnabled({ id, isBundled }: IsEnabledExtensionDescriptor): boolean { isEnabled(extId: LensExtensionId): boolean {
// By default false, so that copied extensions are disabled by default. // 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. // If user installs the extension from the UI, the Extensions component will specifically enable it.
return isBundled || Boolean(this.state.get(id)?.enabled); return this.state.get(extId)?.enabled ?? false;
} }
mergeState = action((extensionsState: Record<LensExtensionId, LensExtensionState> | [LensExtensionId, LensExtensionState][]) => { mergeState = action((extensionsState: Record<LensExtensionId, LensExtensionState> | [LensExtensionId, LensExtensionState][]) => {

View File

@ -3,19 +3,24 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import type { InstalledExtension } from "./extension-discovery/extension-discovery"; import type { BundledInstalledExtension, ExternalInstalledExtension, InstalledExtension } from "./extension-discovery/extension-discovery";
import { action, computed, makeObservable, observable } from "mobx"; import { action, computed, makeObservable, observable } from "mobx";
import type { PackageJson } from "type-fest";
import { disposer } from "../common/utils"; import { disposer } from "../common/utils";
import type { LensExtensionDependencies } from "./lens-extension-set-dependencies"; import type { LensExtensionDependencies } from "./lens-extension-set-dependencies";
import type { ProtocolHandlerRegistration } from "../common/protocol-handler/registration"; import type { ProtocolHandlerRegistration } from "../common/protocol-handler/registration";
import type { PackageJson } from "type-fest";
export type LensExtensionId = string; // path to manifest (package.json) export type LensExtensionId = string; // path to manifest (package.json)
export type LensExtensionConstructor = new (...args: ConstructorParameters<typeof LensExtension>) => LensExtension; export type LensExtensionConstructor = new (ext: ExternalInstalledExtension) => LensExtension;
export type BundledLensExtensionContructor = new (ext: BundledInstalledExtension) => LensExtension;
export interface LensExtensionManifest extends PackageJson { export interface BundledLensExtensionManifest extends PackageJson {
name: string; name: string;
version: string; version: string;
publishConfig?: Partial<Record<string, string>>;
}
export interface LensExtensionManifest extends BundledLensExtensionManifest {
main?: string; // path to %ext/dist/main.js main?: string; // path to %ext/dist/main.js
renderer?: string; // path to %ext/dist/renderer.js renderer?: string; // path to %ext/dist/renderer.js
/** /**
@ -24,8 +29,7 @@ export interface LensExtensionManifest extends PackageJson {
*/ */
engines: { engines: {
lens: string; // "semver"-package format lens: string; // "semver"-package format
npm?: string; [x: string]: string | undefined;
node?: string;
}; };
// Specify extension name used for persisting data. // Specify extension name used for persisting data.
@ -65,14 +69,12 @@ export class LensExtension<
[Disposers] = disposer(); [Disposers] = disposer();
constructor({ id, manifest, manifestPath, isBundled }: InstalledExtension) { constructor({ id, manifest, manifestPath, isBundled }: InstalledExtension) {
makeObservable(this);
// id is the name of the manifest // id is the name of the manifest
this.id = id; this.id = id;
this.manifest = manifest as LensExtensionManifest;
this.manifest = manifest;
this.manifestPath = manifestPath; this.manifestPath = manifestPath;
this.isBundled = !!isBundled; this.isBundled = !!isBundled;
makeObservable(this);
} }
get name() { get name() {

View File

@ -24,7 +24,7 @@ const createExtensionInstanceInjectable = getInjectable({
}; };
return (ExtensionClass, extension) => { return (ExtensionClass, extension) => {
const instance = new ExtensionClass(extension) as LensMainExtension; const instance = new ExtensionClass(extension as any) as LensMainExtension;
(instance as Writable<LensMainExtension>)[lensExtensionDependencies] = deps; (instance as Writable<LensMainExtension>)[lensExtensionDependencies] = deps;

View File

@ -6,23 +6,18 @@
import * as uuid from "uuid"; import * as uuid from "uuid";
import { ProtocolHandlerExtension, ProtocolHandlerInternal, ProtocolHandlerInvalid } from "../../../common/protocol-handler"; import { ProtocolHandlerExtension, ProtocolHandlerInternal, ProtocolHandlerInvalid } from "../../../common/protocol-handler";
import { delay, noop } from "../../../common/utils"; import { noop } from "../../../common/utils";
import type { ExtensionsStore, IsEnabledExtensionDescriptor } from "../../../extensions/extensions-store/extensions-store";
import type { LensProtocolRouterMain } from "../lens-protocol-router-main/lens-protocol-router-main"; import type { LensProtocolRouterMain } from "../lens-protocol-router-main/lens-protocol-router-main";
import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import lensProtocolRouterMainInjectable from "../lens-protocol-router-main/lens-protocol-router-main.injectable"; import lensProtocolRouterMainInjectable from "../lens-protocol-router-main/lens-protocol-router-main.injectable";
import extensionsStoreInjectable from "../../../extensions/extensions-store/extensions-store.injectable"; import extensionsStoreInjectable from "../../../extensions/extensions-store/extensions-store.injectable";
import getConfigurationFileModelInjectable from "../../../common/get-configuration-file-model/get-configuration-file-model.injectable";
import { LensExtension } from "../../../extensions/lens-extension"; import { LensExtension } from "../../../extensions/lens-extension";
import type { LensExtensionId } from "../../../extensions/lens-extension"; import type { LensExtensionId } from "../../../extensions/lens-extension";
import type { ObservableMap } from "mobx"; import type { ObservableMap } from "mobx";
import { runInAction } from "mobx";
import extensionInstancesInjectable from "../../../extensions/extension-loader/extension-instances.injectable"; import extensionInstancesInjectable from "../../../extensions/extension-loader/extension-instances.injectable";
import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import broadcastMessageInjectable from "../../../common/ipc/broadcast-message.injectable"; import broadcastMessageInjectable from "../../../common/ipc/broadcast-message.injectable";
import pathExistsSyncInjectable from "../../../common/fs/path-exists-sync.injectable";
import pathExistsInjectable from "../../../common/fs/path-exists.injectable";
import readJsonSyncInjectable from "../../../common/fs/read-json-sync.injectable";
import writeJsonSyncInjectable from "../../../common/fs/write-json-sync.injectable";
function throwIfDefined(val: any): void { function throwIfDefined(val: any): void {
if (val != null) { if (val != null) {
@ -39,20 +34,13 @@ describe("protocol router tests", () => {
beforeEach(async () => { beforeEach(async () => {
const di = getDiForUnitTesting({ doGeneralOverrides: true }); const di = getDiForUnitTesting({ doGeneralOverrides: true });
di.override(pathExistsInjectable, () => () => { throw new Error("tried call pathExists without override"); });
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"); });
enabledExtensions = new Set(); enabledExtensions = new Set();
di.override(extensionsStoreInjectable, () => ({ di.override(extensionsStoreInjectable, () => ({
isEnabled: ({ id, isBundled }: IsEnabledExtensionDescriptor) => isBundled || enabledExtensions.has(id), isEnabled: (id) => enabledExtensions.has(id),
} as unknown as ExtensionsStore)); }));
di.permitSideEffects(getConfigurationFileModelInjectable); di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data");
di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data");
broadcastMessageMock = jest.fn(); broadcastMessageMock = jest.fn();
di.override(broadcastMessageInjectable, () => broadcastMessageMock); di.override(broadcastMessageInjectable, () => broadcastMessageMock);
@ -60,7 +48,9 @@ describe("protocol router tests", () => {
extensionInstances = di.inject(extensionInstancesInjectable); extensionInstances = di.inject(extensionInstancesInjectable);
lpr = di.inject(lensProtocolRouterMainInjectable); lpr = di.inject(lensProtocolRouterMainInjectable);
lpr.rendererLoaded = true; runInAction(() => {
lpr.rendererLoaded.set(true);
});
}); });
it("should broadcast invalid protocol on non-lens URLs", async () => { it("should broadcast invalid protocol on non-lens URLs", async () => {
@ -73,7 +63,19 @@ describe("protocol router tests", () => {
expect(broadcastMessageMock).toBeCalledWith(ProtocolHandlerInvalid, "invalid host", "lens://foobar"); expect(broadcastMessageMock).toBeCalledWith(ProtocolHandlerInvalid, "invalid host", "lens://foobar");
}); });
it("should not throw when has valid host", async () => { it("should broadcast internal route when called with valid host", async () => {
lpr.addInternalHandler("/", noop);
try {
expect(await lpr.route("lens://app")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(broadcastMessageMock).toHaveBeenCalledWith(ProtocolHandlerInternal, "lens://app", "matched");
});
it("should broadcast external route when called with valid host", async () => {
const extId = uuid.v4(); const extId = uuid.v4();
const ext = new LensExtension({ const ext = new LensExtension({
id: extId, id: extId,
@ -97,22 +99,12 @@ describe("protocol router tests", () => {
extensionInstances.set(extId, ext); extensionInstances.set(extId, ext);
enabledExtensions.add(extId); enabledExtensions.add(extId);
lpr.addInternalHandler("/", noop);
try {
expect(await lpr.route("lens://app")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
try { try {
expect(await lpr.route("lens://extension/@mirantis/minikube")).toBeUndefined(); expect(await lpr.route("lens://extension/@mirantis/minikube")).toBeUndefined();
} catch (error) { } catch (error) {
expect(throwIfDefined(error)).not.toThrow(); expect(throwIfDefined(error)).not.toThrow();
} }
await delay(50);
expect(broadcastMessageMock).toHaveBeenCalledWith(ProtocolHandlerInternal, "lens://app", "matched");
expect(broadcastMessageMock).toHaveBeenCalledWith(ProtocolHandlerExtension, "lens://extension/@mirantis/minikube", "matched"); expect(broadcastMessageMock).toHaveBeenCalledWith(ProtocolHandlerExtension, "lens://extension/@mirantis/minikube", "matched");
}); });
@ -183,7 +175,6 @@ describe("protocol router tests", () => {
expect(throwIfDefined(error)).not.toThrow(); expect(throwIfDefined(error)).not.toThrow();
} }
await delay(50);
expect(called).toBe("foob"); expect(called).toBe("foob");
expect(broadcastMessageMock).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/@foobar/icecream/page/foob", "matched"); expect(broadcastMessageMock).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/@foobar/icecream/page/foob", "matched");
}); });
@ -252,7 +243,6 @@ describe("protocol router tests", () => {
expect(throwIfDefined(error)).not.toThrow(); expect(throwIfDefined(error)).not.toThrow();
} }
await delay(50);
expect(called).toBe(1); expect(called).toBe(1);
expect(broadcastMessageMock).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/icecream/page", "matched"); expect(broadcastMessageMock).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/icecream/page", "matched");

View File

@ -6,7 +6,7 @@
import * as proto from "../../../common/protocol-handler"; import * as proto from "../../../common/protocol-handler";
import URLParse from "url-parse"; import URLParse from "url-parse";
import type { LensExtension } from "../../../extensions/lens-extension"; import type { LensExtension } from "../../../extensions/lens-extension";
import { observable, when, makeObservable } from "mobx"; import { observable, when } from "mobx";
import type { LensProtocolRouterDependencies, RouteAttempt } from "../../../common/protocol-handler"; import type { LensProtocolRouterDependencies, RouteAttempt } from "../../../common/protocol-handler";
import { ProtocolHandlerInvalid } from "../../../common/protocol-handler"; import { ProtocolHandlerInvalid } from "../../../common/protocol-handler";
import { disposer, noop } from "../../../common/utils"; import { disposer, noop } from "../../../common/utils";
@ -39,17 +39,15 @@ export interface LensProtocolRouterMainDependencies extends LensProtocolRouterDe
} }
export class LensProtocolRouterMain extends proto.LensProtocolRouter { export class LensProtocolRouterMain extends proto.LensProtocolRouter {
private missingExtensionHandlers: FallbackHandler[] = []; private readonly missingExtensionHandlers: FallbackHandler[] = [];
// TODO: This is used to solve out-of-place temporal dependency. Remove, and solve otherwise. // TODO: This is used to solve out-of-place temporal dependency. Remove, and solve otherwise.
@observable rendererLoaded = false; readonly rendererLoaded = observable.box(false);
protected disposers = disposer(); protected readonly disposers = disposer();
constructor(protected readonly dependencies: LensProtocolRouterMainDependencies) { constructor(protected readonly dependencies: LensProtocolRouterMainDependencies) {
super(dependencies); super(dependencies);
makeObservable(this);
} }
public cleanup() { public cleanup() {
@ -118,8 +116,13 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter {
protected _routeToInternal(url: URLParse<Record<string, string | undefined>>): RouteAttempt { protected _routeToInternal(url: URLParse<Record<string, string | undefined>>): RouteAttempt {
const rawUrl = url.toString(); // for sending to renderer const rawUrl = url.toString(); // for sending to renderer
const attempt = super._routeToInternal(url); const attempt = super._routeToInternal(url);
const broadcastToRenderer = () => this.dependencies.broadcastMessage(proto.ProtocolHandlerInternal, rawUrl, attempt);
this.disposers.push(when(() => this.rendererLoaded, () => this.dependencies.broadcastMessage(proto.ProtocolHandlerInternal, rawUrl, attempt))); if (this.rendererLoaded.get()) {
broadcastToRenderer();
} else {
this.disposers.push(when(() => this.rendererLoaded.get(), broadcastToRenderer));
}
return attempt; return attempt;
} }
@ -135,8 +138,13 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter {
* argument. * argument.
*/ */
const attempt = await super._routeToExtension(new URLParse(url.toString(), true)); const attempt = await super._routeToExtension(new URLParse(url.toString(), true));
const broadcastToRenderer = () => this.dependencies.broadcastMessage(proto.ProtocolHandlerExtension, rawUrl, attempt);
this.disposers.push(when(() => this.rendererLoaded, () => this.dependencies.broadcastMessage(proto.ProtocolHandlerExtension, rawUrl, attempt))); if (this.rendererLoaded.get()) {
broadcastToRenderer();
} else {
this.disposers.push(when(() => this.rendererLoaded.get(), broadcastToRenderer));
}
return attempt; return attempt;
} }

View File

@ -18,7 +18,7 @@ const flagRendererAsLoadedInjectable = getInjectable({
run: () => { run: () => {
runInAction(() => { runInAction(() => {
// Todo: remove this kludge which enables out-of-place temporal dependency. // Todo: remove this kludge which enables out-of-place temporal dependency.
lensProtocolRouterMain.rendererLoaded = true; lensProtocolRouterMain.rendererLoaded.set(true);
}); });
}, },
}; };

View File

@ -18,7 +18,7 @@ const flagRendererAsNotLoadedInjectable = getInjectable({
run: () => { run: () => {
runInAction(() => { runInAction(() => {
// Todo: remove this kludge which enables out-of-place temporal dependency. // Todo: remove this kludge which enables out-of-place temporal dependency.
lensProtocolRouterMain.rendererLoaded = false; lensProtocolRouterMain.rendererLoaded.set(false);
}); });
return undefined; return undefined;

View File

@ -86,7 +86,7 @@ const attemptInstall = ({
} }
const extensionFolder = getExtensionDestFolder(name); const extensionFolder = getExtensionDestFolder(name);
const installedExtension = extensionLoader.getExtension(validatedRequest.id); const installedExtension = extensionLoader.getExtensionById(validatedRequest.id);
if (installedExtension) { if (installedExtension) {
const { version: oldVersion } = installedExtension.manifest; const { version: oldVersion } = installedExtension.manifest;

View File

@ -73,7 +73,7 @@ const unpackExtensionInjectable = getInjectable({
await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true }); await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true });
// wait for the loader has actually install it // wait for the loader has actually install it
await when(() => extensionLoader.userExtensions.has(id)); await when(() => extensionLoader.userExtensions.get().has(id));
// Enable installed extensions by default. // Enable installed extensions by default.
extensionLoader.setIsEnabled(id, true); extensionLoader.setIsEnabled(id, true);

View File

@ -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 extensionLoaderInjectable from "../../../extensions/extension-loader/extension-loader.injectable";
import type { LensExtensionId } from "../../../extensions/lens-extension";
export type DisableExtension = (extId: LensExtensionId) => void;
const disableExtensionInjectable = getInjectable({
id: "disable-extension",
instantiate: (di): DisableExtension => {
const extensionLoader = di.inject(extensionLoaderInjectable);
return (extId) => {
const ext = extensionLoader.getExtensionById(extId);
if (ext && !ext.isBundled) {
ext.isEnabled = false;
}
};
},
});
export default disableExtensionInjectable;

View File

@ -1,18 +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 extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable";
import { disableExtension } from "./disable-extension";
const disableExtensionInjectable = getInjectable({
id: "disable-extension",
instantiate: (di) =>
disableExtension({
extensionLoader: di.inject(extensionLoaderInjectable),
}),
});
export default disableExtensionInjectable;

View File

@ -1,20 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { LensExtensionId } from "../../../../extensions/lens-extension";
import type { ExtensionLoader } from "../../../../extensions/extension-loader";
interface Dependencies {
extensionLoader: ExtensionLoader;
}
export const disableExtension =
({ extensionLoader }: Dependencies) =>
(id: LensExtensionId) => {
const extension = extensionLoader.getExtension(id);
if (extension) {
extension.isEnabled = false;
}
};

View File

@ -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 extensionLoaderInjectable from "../../../extensions/extension-loader/extension-loader.injectable";
import type { LensExtensionId } from "../../../extensions/lens-extension";
export type EnableExtension = (extId: LensExtensionId) => void;
const enableExtensionInjectable = getInjectable({
id: "enable-extension",
instantiate: (di): EnableExtension => {
const extensionLoader = di.inject(extensionLoaderInjectable);
return (extId) => {
const ext = extensionLoader.getExtensionById(extId);
if (ext && !ext.isBundled) {
ext.isEnabled = true;
}
};
},
});
export default enableExtensionInjectable;

View File

@ -1,18 +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 extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable";
import { enableExtension } from "./enable-extension";
const enableExtensionInjectable = getInjectable({
id: "enable-extension",
instantiate: (di) =>
enableExtension({
extensionLoader: di.inject(extensionLoaderInjectable),
}),
});
export default enableExtensionInjectable;

View File

@ -1,20 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { LensExtensionId } from "../../../../extensions/lens-extension";
import type { ExtensionLoader } from "../../../../extensions/extension-loader";
interface Dependencies {
extensionLoader: ExtensionLoader;
}
export const enableExtension =
({ extensionLoader }: Dependencies) =>
(id: LensExtensionId) => {
const extension = extensionLoader.getExtension(id);
if (extension) {
extension.isEnabled = true;
}
};

View File

@ -24,8 +24,8 @@ import { docsUrl } from "../../../common/vars";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import userExtensionsInjectable from "./user-extensions/user-extensions.injectable"; import userExtensionsInjectable from "./user-extensions/user-extensions.injectable";
import enableExtensionInjectable from "./enable-extension/enable-extension.injectable"; import enableExtensionInjectable from "./enable-extension.injectable";
import disableExtensionInjectable from "./disable-extension/disable-extension.injectable"; import disableExtensionInjectable from "./disable-extension.injectable";
import type { ConfirmUninstallExtension } from "./confirm-uninstall-extension.injectable"; import type { ConfirmUninstallExtension } from "./confirm-uninstall-extension.injectable";
import confirmUninstallExtensionInjectable from "./confirm-uninstall-extension.injectable"; import confirmUninstallExtensionInjectable from "./confirm-uninstall-extension.injectable";
import type { InstallExtensionFromInput } from "./install-extension-from-input.injectable"; import type { InstallExtensionFromInput } from "./install-extension-from-input.injectable";

View File

@ -13,7 +13,7 @@ import { Icon } from "../icon";
import { List } from "../list/list"; import { List } from "../list/list";
import { MenuActions, MenuItem } from "../menu"; import { MenuActions, MenuItem } from "../menu";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import { cssNames } from "../../utils"; import { cssNames, toJS } from "../../utils";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import type { Row } from "react-table"; import type { Row } from "react-table";
import type { LensExtensionId } from "../../../extensions/lens-extension"; import type { LensExtensionId } from "../../../extensions/lens-extension";
@ -45,7 +45,14 @@ function getStatus(extension: InstalledExtension) {
return extension.isEnabled ? "Enabled" : "Disabled"; return extension.isEnabled ? "Enabled" : "Disabled";
} }
const NonInjectedInstalledExtensions = observer(({ extensionDiscovery, extensionInstallationStateStore, extensions, uninstall, enable, disable }: Dependencies & InstalledExtensionsProps) => { const NonInjectedInstalledExtensions = observer(({
extensionDiscovery,
extensionInstallationStateStore,
extensions,
uninstall,
enable,
disable,
}: Dependencies & InstalledExtensionsProps) => {
const columns = useMemo( const columns = useMemo(
() => [ () => [
{ {
@ -138,7 +145,7 @@ const NonInjectedInstalledExtensions = observer(({ extensionDiscovery, extension
</MenuActions> </MenuActions>
), ),
}; };
}), [extensions, extensionInstallationStateStore.anyUninstalling], }), [toJS(extensions), extensionInstallationStateStore.anyUninstalling],
); );
if (!extensionDiscovery.isLoaded) { if (!extensionDiscovery.isLoaded) {

View File

@ -27,7 +27,7 @@ const uninstallExtensionInjectable = getInjectable({
const showErrorNotification = di.inject(showErrorNotificationInjectable); const showErrorNotification = di.inject(showErrorNotificationInjectable);
return async (extensionId: LensExtensionId): Promise<boolean> => { return async (extensionId: LensExtensionId): Promise<boolean> => {
const ext = extensionLoader.getExtension(extensionId); const ext = extensionLoader.getExtensionById(extensionId);
if (!ext) { if (!ext) {
logger.debug(`[EXTENSIONS]: cannot uninstall ${extensionId}, was not installed`); logger.debug(`[EXTENSIONS]: cannot uninstall ${extensionId}, was not installed`);
@ -45,7 +45,7 @@ const uninstallExtensionInjectable = getInjectable({
await extensionDiscovery.uninstallExtension(extensionId); await extensionDiscovery.uninstallExtension(extensionId);
// wait for the ExtensionLoader to actually uninstall the extension // wait for the ExtensionLoader to actually uninstall the extension
await when(() => !extensionLoader.userExtensions.has(extensionId)); await when(() => !extensionLoader.userExtensions.get().has(extensionId));
showSuccessNotification( showSuccessNotification(
<p> <p>

View File

@ -12,7 +12,7 @@ const userExtensionsInjectable = getInjectable({
instantiate: (di) => { instantiate: (di) => {
const extensionLoader = di.inject(extensionLoaderInjectable); const extensionLoader = di.inject(extensionLoaderInjectable);
return computed(() => [...extensionLoader.userExtensions.values()]); return computed(() => [...extensionLoader.userExtensions.get().values()]);
}, },
}); });

View File

@ -30,7 +30,7 @@ const createExtensionInstanceInjectable = getInjectable({
}; };
return (ExtensionClass, extension) => { return (ExtensionClass, extension) => {
const instance = new ExtensionClass(extension) as LensRendererExtension; const instance = new ExtensionClass(extension as any) as LensRendererExtension;
(instance as Writable<LensRendererExtension>)[lensExtensionDependencies] = deps; (instance as Writable<LensRendererExtension>)[lensExtensionDependencies] = deps;