Restructure to monorepo (#6907)

* wip: restructure to monorepo

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* refactor create-release-pr to a package

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* build fixes

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* github workflow fixes

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix typo

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* add webpack-env types to core

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix github workflows

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* refactor/fix integration tests

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* lint fix

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* yarn run dev

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* eslint settings for vscode

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* move templates to right package

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* open-lens build fixes

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* integration test fix

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix nx task dependencies

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* use bash shell for unit tests in test workflow

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix test:unit for windows

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix win-ca webpack error in open-lens

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix win-ca webpack error in open-lens

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix build:app on windows

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* remove ELECTRON_BUILDER_EXTRA_ARGS

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* sync src/ from master

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* remove Makefile from core

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen authored and GitHub committed 2023-01-24 10:46:26 -08:00
1 parent 0066ca4ea2
commit 2657df2293
3321 files changed
+28699 -11447

No files matched your search

@@ -0,0 +1,174 @@
/**
* 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).toMatchInlineSnapshot(`Map {}`);
await extensionLoader.init();
await delay(10);
// Assert the extensions after the extension broadcast event
expect(extensionLoader.userExtensions).toMatchInlineSnapshot(`
Map {
"manifest/path" => Object {
"absolutePath": "/test/1",
"id": "manifest/path",
"isBundled": false,
"isEnabled": true,
"manifest": Object {
"name": "TestExtension",
"version": "1.0.0",
},
"manifestPath": "manifest/path",
},
"manifest/path3" => Object {
"absolutePath": "/test/3",
"id": "manifest/path3",
"isBundled": false,
"isEnabled": true,
"manifest": Object {
"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",
},
}),
);
});
});
@@ -0,0 +1,63 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { isCompatibleExtension } from "../extension-discovery/is-compatible-extension/is-compatible-extension";
import type { LensExtensionManifest } from "../lens-extension";
describe("Extension/App versions compatibility checks", () => {
it("is compatible with exact version matching", () => {
expect(isCompatible({ extLensEngineVersion: "5.5.0", extensionApiVersion: "5.5.0" })).toBeTruthy();
});
it("is compatible with upper %PATCH versions of base app", () => {
expect(isCompatible({ extLensEngineVersion: "5.5.0", extensionApiVersion: "5.5.5" })).toBeTruthy();
});
it("is compatible with higher %MINOR version of base app", () => {
expect(isCompatible({ extLensEngineVersion: "5.5.0", extensionApiVersion: "5.6.0" })).toBeTruthy();
});
it("is not compatible with higher %MAJOR version of base app", () => {
expect(isCompatible({ extLensEngineVersion: "5.6.0", extensionApiVersion: "6.0.0" })).toBeFalsy(); // extension for lens@5 not compatible with lens@6
expect(isCompatible({ extLensEngineVersion: "6.0.0", extensionApiVersion: "5.6.0" })).toBeFalsy();
});
it("supports short version format for manifest.engines.lens", () => {
expect(isCompatible({ extLensEngineVersion: "5.5", extensionApiVersion: "5.5.1" })).toBeTruthy();
});
it("throws for incorrect or not supported version format", () => {
expect(() => isCompatible({
extLensEngineVersion: ">=2.0",
extensionApiVersion: "2.0",
})).toThrow(/Invalid format/i);
expect(() => isCompatible({
extLensEngineVersion: "~2.0",
extensionApiVersion: "2.0",
})).toThrow(/Invalid format/i);
expect(() => isCompatible({
extLensEngineVersion: "*",
extensionApiVersion: "1.0",
})).toThrow(/Invalid format/i);
});
});
function isCompatible({ extLensEngineVersion = "^1.0", extensionApiVersion = "1.0" } = {}): boolean {
const extensionManifestMock = getExtensionManifestMock(extLensEngineVersion);
return isCompatibleExtension({ extensionApiVersion })(extensionManifestMock);
}
function getExtensionManifestMock(lensEngine = "1.0"): LensExtensionManifest {
return {
name: "some-extension",
version: "1.0",
engines: {
lens: lensEngine,
},
};
}
@@ -0,0 +1,36 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { LensExtension } from "../lens-extension";
import { Console } from "console";
import { stdout, stderr } from "process";
console = new Console(stdout, stderr);
let ext: LensExtension;
describe("lens extension", () => {
beforeEach(async () => {
ext = new LensExtension({
manifest: {
name: "foo-bar",
version: "0.1.1",
engines: { lens: "^5.5.0" },
},
id: "/this/is/fake/package.json",
absolutePath: "/absolute/fake/",
manifestPath: "/this/is/fake/package.json",
isBundled: false,
isEnabled: true,
isCompatible: true,
});
});
describe("name", () => {
it("returns name", () => {
expect(ext.name).toBe("foo-bar");
});
});
});
@@ -0,0 +1,20 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getLegacyGlobalDiForExtensionApi } from "./legacy-global-di-for-extension-api";
import type { Inject } from "@ogre-tools/injectable";
export const asLegacyGlobalFunctionForExtensionApi = ((
injectableKey,
instantiationParameter,
) =>
(...args: any[]) => {
const injected = getLegacyGlobalDiForExtensionApi().inject(
injectableKey,
instantiationParameter,
) as unknown as (...args: any[]) => any;
return injected(...args);
}) as Inject;
@@ -0,0 +1,81 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type {
DiContainer,
Injectable } from "@ogre-tools/injectable";
import {
createContainer,
getInjectable,
} from "@ogre-tools/injectable";
import { Environments, setLegacyGlobalDiForExtensionApi } from "./legacy-global-di-for-extension-api";
import { asLegacyGlobalObjectForExtensionApiWithModifications } from "./as-legacy-global-object-for-extension-api-with-modifications";
describe("asLegacyGlobalObjectForExtensionApiWithModifications", () => {
describe("given legacy global object", () => {
let di: DiContainer;
let someInjectable: Injectable<{ someProperty: string }, unknown, void>;
let actual: { someProperty: string } & {
someModificationProperty: string;
};
beforeEach(() => {
di = createContainer("irrelevant");
jest.spyOn(di, "inject");
setLegacyGlobalDiForExtensionApi(di, Environments.renderer);
someInjectable = getInjectable({
id: "some-injectable",
instantiate: () => ({
someProperty: "some-property-value",
}),
});
di.register(someInjectable);
actual = asLegacyGlobalObjectForExtensionApiWithModifications(
someInjectable,
{ someModificationProperty: "some-modification-value" },
);
});
it("when not accessed, does not inject yet", () => {
expect(di.inject).not.toHaveBeenCalled();
});
describe("when a property of global is accessed, ", () => {
let actualPropertyValue: string;
beforeEach(() => {
actualPropertyValue = actual.someProperty;
});
it("injects the injectable for global", () => {
expect(di.inject).toHaveBeenCalledWith(someInjectable, undefined);
});
it("global has property of injectable", () => {
expect(actualPropertyValue).toBe("some-property-value");
});
});
describe("when a property of modification is accessed, ", () => {
let actualModificationValue: string;
beforeEach(() => {
actualModificationValue = actual.someModificationProperty;
});
it("injects the injectable for global", () => {
expect(di.inject).toHaveBeenCalledWith(someInjectable, undefined);
});
it("global has property of modification", () => {
expect(actualModificationValue).toBe("some-modification-value");
});
});
});
});
@@ -0,0 +1,22 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { asLegacyGlobalForExtensionApi } from "./as-legacy-global-object-for-extension-api";
import type { Injectable } from "@ogre-tools/injectable";
/**
* @deprecated use asLegacyGlobalForExtensionApi instead, and use proper implementations instead of "modifications".
*/
export const asLegacyGlobalObjectForExtensionApiWithModifications = <
InjectableInstance extends InjectionTokenInstance & object,
InjectionTokenInstance,
ModificationObject extends object,
>(
injectable: Injectable<InjectableInstance, InjectionTokenInstance, void>,
modificationObject: ModificationObject,
) =>
Object.assign(
asLegacyGlobalForExtensionApi(injectable),
modificationObject,
);
@@ -0,0 +1,46 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { Inject } from "@ogre-tools/injectable";
import { getLegacyGlobalDiForExtensionApi } from "./legacy-global-di-for-extension-api";
export const asLegacyGlobalForExtensionApi = ((
injectable,
instantiationParameter,
) =>
new Proxy(
{},
{
apply(target: any, thisArg, argArray) {
const fn = getLegacyGlobalDiForExtensionApi().inject(
injectable,
instantiationParameter,
) as unknown as (...args: any[]) => any;
return fn(...argArray);
},
get(target, propertyName) {
if (propertyName === "$$typeof") {
return undefined;
}
const instance: any = getLegacyGlobalDiForExtensionApi().inject(
injectable,
instantiationParameter,
);
const propertyValue = instance[propertyName] ?? target[propertyName];
if (typeof propertyValue === "function") {
return function (...args: any[]) {
return propertyValue.apply(instance, args);
};
}
return propertyValue;
},
},
)) as Inject;
@@ -0,0 +1,42 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { Injectable } from "@ogre-tools/injectable";
import { asLegacyGlobalForExtensionApi } from "./as-legacy-global-object-for-extension-api";
import { getLegacyGlobalDiForExtensionApi } from "./legacy-global-di-for-extension-api";
import loggerInjectable from "../../common/logger.injectable";
export interface LegacySingleton<T> {
createInstance(): T;
getInstance(): T;
resetInstance(): void;
}
export function asLegacyGlobalSingletonForExtensionApi<Instance>(injectable: Injectable<Instance, unknown, void>): LegacySingleton<Instance>;
export function asLegacyGlobalSingletonForExtensionApi<Instance, InstantiationParameter>(injectable: Injectable<Instance, unknown, InstantiationParameter>, param: InstantiationParameter): LegacySingleton<Instance>;
export function asLegacyGlobalSingletonForExtensionApi<Instance, InstantiationParameter>(
injectable: Injectable<Instance, unknown, InstantiationParameter>,
instantiationParameter?: InstantiationParameter,
): LegacySingleton<Instance> {
const instance = asLegacyGlobalForExtensionApi(
injectable as never,
instantiationParameter,
) as Instance;
return {
createInstance: () => instance,
getInstance: () => instance,
resetInstance: () => {
const di = getLegacyGlobalDiForExtensionApi();
const logger = di.inject(loggerInjectable);
logger.warn(
`resetInstance() for a legacy global singleton of "${injectable.id}" does nothing.`,
);
},
};
}
@@ -0,0 +1,43 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { DiContainer } from "@ogre-tools/injectable";
const legacyGlobalDis = new Map<Environments, DiContainer>();
export enum Environments {
renderer,
main,
}
export const setLegacyGlobalDiForExtensionApi = (
di: DiContainer,
environment: Environments,
) => {
legacyGlobalDis.set(environment, di);
};
export const getLegacyGlobalDiForExtensionApi = () => {
if (legacyGlobalDis.size > 1) {
throw new Error("Tried to get DI container using legacy globals where there is multiple containers available.");
}
const [di] = [...legacyGlobalDis.values()];
if (!di) {
throw new Error("Tried to get DI container using legacy globals where there is no containers available.");
}
return di;
};
export function getEnvironmentSpecificLegacyGlobalDiForExtensionApi(environment: Environments) {
const di = legacyGlobalDis.get(environment);
if (!di) {
throw new Error("Tried to get DI container using legacy globals in environment which doesn't exist");
}
return di;
}
@@ -0,0 +1,67 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import appNameInjectable from "../../common/vars/app-name.injectable";
import isLinuxInjectable from "../../common/vars/is-linux.injectable";
import isMacInjectable from "../../common/vars/is-mac.injectable";
import isSnapPackageInjectable from "../../common/vars/is-snap-package.injectable";
import isWindowsInjectable from "../../common/vars/is-windows.injectable";
import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api";
import { getLegacyGlobalDiForExtensionApi } from "../as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api";
import getEnabledExtensionsInjectable from "./get-enabled-extensions/get-enabled-extensions.injectable";
import type { UserPreferenceExtensionItems } from "./user-preferences";
import { Preferences } from "./user-preferences";
import { slackUrl, issuesTrackerUrl } from "../../common/vars";
import { buildVersionInjectionToken } from "../../common/vars/build-semantic-version.injectable";
export interface AppExtensionItems {
readonly Preferences: UserPreferenceExtensionItems;
readonly version: string;
readonly appName: string;
readonly slackUrl: string;
readonly issuesTrackerUrl: string;
readonly isSnap: boolean;
readonly isWindows: boolean;
readonly isMac: boolean;
readonly isLinux: boolean;
getEnabledExtensions: () => string[];
}
export const App: AppExtensionItems = {
Preferences,
getEnabledExtensions: asLegacyGlobalFunctionForExtensionApi(getEnabledExtensionsInjectable),
get version() {
const di = getLegacyGlobalDiForExtensionApi();
return di.inject(buildVersionInjectionToken).get();
},
get appName() {
const di = getLegacyGlobalDiForExtensionApi();
return di.inject(appNameInjectable);
},
get isSnap() {
const di = getLegacyGlobalDiForExtensionApi();
return di.inject(isSnapPackageInjectable);
},
get isWindows() {
const di = getLegacyGlobalDiForExtensionApi();
return di.inject(isWindowsInjectable);
},
get isMac() {
const di = getLegacyGlobalDiForExtensionApi();
return di.inject(isMacInjectable);
},
get isLinux() {
const di = getLegacyGlobalDiForExtensionApi();
return di.inject(isLinuxInjectable);
},
slackUrl,
issuesTrackerUrl,
};
@@ -0,0 +1,28 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import kubernetesClusterCategoryInjectable from "../../common/catalog/categories/kubernetes-cluster.injectable";
import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api";
export {
KubernetesCluster,
GeneralEntity,
WebLink,
} from "../../common/catalog-entities";
export const kubernetesClusterCategory = asLegacyGlobalForExtensionApi(kubernetesClusterCategoryInjectable);
export type {
KubernetesClusterPrometheusMetrics,
KubernetesClusterSpec,
KubernetesClusterMetadata,
WebLinkSpec,
WebLinkStatus,
WebLinkStatusPhase,
KubernetesClusterStatusPhase,
KubernetesClusterStatus,
} from "../../common/catalog-entities";
export * from "../../common/catalog/catalog-entity";
@@ -0,0 +1,11 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import appEventBusInjectable from "../../common/app-event-bus/app-event-bus.injectable";
import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api";
export type { AppEvent } from "../../common/app-event-bus/event-bus";
export const appEventBus = asLegacyGlobalForExtensionApi(appEventBusInjectable);
@@ -0,0 +1,15 @@
/**
* 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 getEnabledExtensionsInjectable = getInjectable({
id: "get-enabled-extensions",
instantiate: (di) => () =>
di.inject(extensionsStoreInjectable).enabledExtensions,
});
export default getEnabledExtensionsInjectable;
@@ -0,0 +1,28 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
// APIs
import { App } from "./app";
import * as EventBus from "./event-bus";
import * as Store from "./stores";
import { Util } from "./utils";
import * as Catalog from "./catalog";
import * as Types from "./types";
import * as Proxy from "./proxy";
import loggerInjectable from "../../common/logger.injectable";
import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api";
const logger = asLegacyGlobalForExtensionApi(loggerInjectable);
export {
App,
EventBus,
Catalog,
Store,
Types,
Util,
logger,
Proxy,
};
@@ -0,0 +1,279 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
// NOTE: this file is not currently exported as part of `Common`, but should be.
// It is here to consolidate the common parts which are exported to `Main`
// and to `Renderer`
import apiManagerInjectable from "../../common/k8s-api/api-manager/manager.injectable";
import createKubeApiForClusterInjectable from "../../common/k8s-api/create-kube-api-for-cluster.injectable";
import createKubeApiForRemoteClusterInjectable from "../../common/k8s-api/create-kube-api-for-remote-cluster.injectable";
import createResourceStackInjectable from "../../common/k8s/create-resource-stack.injectable";
import type { ResourceApplyingStack } from "../../common/k8s/resource-stack";
import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api";
import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api";
import type { KubernetesCluster } from "./catalog";
import type { KubeApiDataFrom, KubeObjectStoreOptions } from "../../common/k8s-api/kube-object.store";
import { KubeObjectStore as InternalKubeObjectStore } from "../../common/k8s-api/kube-object.store";
import type { KubeJsonApiDataFor, KubeObject } from "../../common/k8s-api/kube-object";
import type { DerivedKubeApiOptions, KubeApiDependencies, KubeApiOptions } from "../../common/k8s-api/kube-api";
import { KubeApi as InternalKubeApi } from "../../common/k8s-api/kube-api";
import clusterFrameContextForNamespacedResourcesInjectable from "../../renderer/cluster-frame-context/for-namespaced-resources.injectable";
import type { ClusterContext } from "../../renderer/cluster-frame-context/cluster-frame-context";
import loggerInjectable from "../../common/logger.injectable";
import { getLegacyGlobalDiForExtensionApi } from "../as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api";
import maybeKubeApiInjectable from "../../common/k8s-api/maybe-kube-api.injectable";
import { DeploymentApi as InternalDeploymentApi, IngressApi as InternalIngressApi, NodeApi, PersistentVolumeClaimApi, PodApi } from "../../common/k8s-api/endpoints";
import { storesAndApisCanBeCreatedInjectionToken } from "../../common/k8s-api/stores-apis-can-be-created.token";
import type { JsonApiConfig } from "../../common/k8s-api/json-api";
import type { KubeJsonApi as InternalKubeJsonApi } from "../../common/k8s-api/kube-json-api";
import createKubeJsonApiInjectable from "../../common/k8s-api/create-kube-json-api.injectable";
import type { RequestInit } from "node-fetch";
import createKubeJsonApiForClusterInjectable from "../../common/k8s-api/create-kube-json-api-for-cluster.injectable";
export const apiManager = asLegacyGlobalForExtensionApi(apiManagerInjectable);
export const forCluster = asLegacyGlobalFunctionForExtensionApi(createKubeApiForClusterInjectable);
export const forRemoteCluster = asLegacyGlobalFunctionForExtensionApi(createKubeApiForRemoteClusterInjectable);
export const createResourceStack = asLegacyGlobalFunctionForExtensionApi(createResourceStackInjectable);
const getKubeApiDeps = (): KubeApiDependencies => {
const di = getLegacyGlobalDiForExtensionApi();
return {
logger: di.inject(loggerInjectable),
maybeKubeApi: di.inject(maybeKubeApiInjectable),
};
};
// NOTE: this is done to preserve `instanceOf` behaviour
function KubeApiCstr<
Object extends KubeObject = KubeObject,
Data extends KubeJsonApiDataFor<Object> = KubeJsonApiDataFor<Object>,
>(opts: KubeApiOptions<Object, Data>) {
const api = new InternalKubeApi(getKubeApiDeps(), opts);
const di = getLegacyGlobalDiForExtensionApi();
const storesAndApisCanBeCreated = di.inject(storesAndApisCanBeCreatedInjectionToken);
if (storesAndApisCanBeCreated) {
apiManager.registerApi(api);
}
return api;
}
export type KubeApi<
Object extends KubeObject = KubeObject,
Data extends KubeJsonApiDataFor<Object> = KubeJsonApiDataFor<Object>,
> = InternalKubeApi<Object, Data>;
export const KubeApi = KubeApiCstr as unknown as new<
Object extends KubeObject = KubeObject,
Data extends KubeJsonApiDataFor<Object> = KubeJsonApiDataFor<Object>,
>(opts: KubeApiOptions<Object, Data>) => InternalKubeApi<Object, Data>;
/**
* @deprecated Switch to using `Common.createResourceStack` instead
*/
export class ResourceStack implements ResourceApplyingStack {
private readonly impl: ResourceApplyingStack;
constructor(cluster: KubernetesCluster, name: string) {
this.impl = createResourceStack(cluster, name);
}
kubectlApplyFolder(folderPath: string, templateContext?: any, extraArgs?: string[] | undefined): Promise<string> {
return this.impl.kubectlApplyFolder(folderPath, templateContext, extraArgs);
}
kubectlDeleteFolder(folderPath: string, templateContext?: any, extraArgs?: string[] | undefined): Promise<string> {
return this.impl.kubectlDeleteFolder(folderPath, templateContext, extraArgs);
}
}
/**
* @deprecated This type is unused
*/
export interface IKubeApiCluster {
metadata: {
uid: string;
};
}
export type { CreateKubeApiForRemoteClusterConfig as IRemoteKubeApiConfig } from "../../common/k8s-api/create-kube-api-for-remote-cluster.injectable";
export type { CreateKubeApiForLocalClusterConfig as ILocalKubeApiConfig } from "../../common/k8s-api/create-kube-api-for-cluster.injectable";
export {
KubeObject,
KubeStatus,
type OwnerReference,
type KubeObjectMetadata,
type NamespaceScopedMetadata,
type ClusterScopedMetadata,
type BaseKubeJsonApiObjectMetadata,
type KubeJsonApiObjectMetadata,
type KubeStatusData,
} from "../../common/k8s-api/kube-object";
export type {
KubeJsonApiData,
} from "../../common/k8s-api/kube-json-api";
function KubeJsonApiCstr(config: JsonApiConfig, reqInit?: RequestInit) {
const di = getLegacyGlobalDiForExtensionApi();
const createKubeJsonApi = di.inject(createKubeJsonApiInjectable);
return createKubeJsonApi(config, reqInit);
}
export type KubeJsonApi = InternalKubeJsonApi;
export const KubeJsonApi = Object.assign(
KubeJsonApiCstr as unknown as new (config: JsonApiConfig, reqInit?: RequestInit) => InternalKubeJsonApi,
{
forCluster: asLegacyGlobalForExtensionApi(createKubeJsonApiForClusterInjectable),
},
);
export abstract class KubeObjectStore<
K extends KubeObject = KubeObject,
A extends InternalKubeApi<K, D> = InternalKubeApi<K, KubeJsonApiDataFor<K>>,
D extends KubeJsonApiDataFor<K> = KubeApiDataFrom<K, A>,
> extends InternalKubeObjectStore<K, A, D> {
/**
* @deprecated This is no longer used and shouldn't have been every really used
*/
static readonly context = {
set: (ctx: ClusterContext) => {
console.warn("Setting KubeObjectStore.context is no longer supported");
void ctx;
},
get: () => asLegacyGlobalForExtensionApi(clusterFrameContextForNamespacedResourcesInjectable),
};
get context() {
return this.dependencies.context;
}
constructor(api: A, opts?: KubeObjectStoreOptions);
/**
* @deprecated Supply API instance through constructor
*/
constructor();
constructor(api?: A, opts?: KubeObjectStoreOptions) {
const di = getLegacyGlobalDiForExtensionApi();
super(
{
context: di.inject(clusterFrameContextForNamespacedResourcesInjectable),
logger: di.inject(loggerInjectable),
},
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api!,
opts,
);
}
}
export {
type JsonPatch,
type KubeObjectStoreLoadAllParams,
type KubeObjectStoreLoadingParams,
type KubeObjectStoreSubscribeParams,
} from "../../common/k8s-api/kube-object.store";
/**
* @deprecated This type is only present for backwards compatable typescript support
*/
export interface IgnoredKubeApiOptions {
/**
* @deprecated this option is overridden and should not be used
*/
objectConstructor?: any;
/**
* @deprecated this option is overridden and should not be used
*/
kind?: any;
/**
* @deprecated this option is overridden and should not be used
*/
isNamespaces?: any;
/**
* @deprecated this option is overridden and should not be used
*/
apiBase?: any;
}
// NOTE: these *Constructor functions MUST be `function` to work with `new X()`
function PodsApiConstructor(opts?: DerivedKubeApiOptions & IgnoredKubeApiOptions) {
return new PodApi(getKubeApiDeps(), opts);
}
export type PodsApi = PodApi;
export const PodsApi = PodsApiConstructor as unknown as new (opts?: DerivedKubeApiOptions & IgnoredKubeApiOptions) => PodApi;
function NodesApiConstructor(opts?: DerivedKubeApiOptions & IgnoredKubeApiOptions) {
return new NodeApi(getKubeApiDeps(), opts);
}
export type NodesApi = NodeApi;
export const NodesApi = NodesApiConstructor as unknown as new (opts?: DerivedKubeApiOptions & IgnoredKubeApiOptions) => NodeApi;
function DeploymentApiConstructor(opts?: DerivedKubeApiOptions) {
return new InternalDeploymentApi(getKubeApiDeps(), opts);
}
export type DeploymentApi = InternalDeploymentApi;
export const DeploymentApi = DeploymentApiConstructor as unknown as new (opts?: DerivedKubeApiOptions) => InternalDeploymentApi;
function IngressApiConstructor(opts?: DerivedKubeApiOptions & IgnoredKubeApiOptions) {
return new InternalIngressApi(getKubeApiDeps(), opts);
}
export type IngressApi = InternalIngressApi;
export const IngressApi = IngressApiConstructor as unknown as new (opts?: DerivedKubeApiOptions & IgnoredKubeApiOptions) => InternalIngressApi;
function PersistentVolumeClaimsApiConstructor(opts?: DerivedKubeApiOptions & IgnoredKubeApiOptions) {
return new PersistentVolumeClaimApi(getKubeApiDeps(), opts);
}
export type PersistentVolumeClaimsApi = PersistentVolumeClaimApi;
export const PersistentVolumeClaimsApi = PersistentVolumeClaimsApiConstructor as unknown as new (opts?: DerivedKubeApiOptions & IgnoredKubeApiOptions) => PersistentVolumeClaimApi;
export {
type Container as IPodContainer,
type PodContainerStatus as IPodContainerStatus,
Pod,
Node,
Deployment,
DaemonSet,
StatefulSet,
Job,
CronJob,
ConfigMap,
type SecretReference as ISecretRef,
Secret,
ReplicaSet,
ResourceQuota,
LimitRange,
HorizontalPodAutoscaler,
PodDisruptionBudget,
PriorityClass,
Service,
Endpoints as Endpoint,
Ingress,
NetworkPolicy,
PersistentVolume,
PersistentVolumeClaim,
StorageClass,
Namespace,
KubeEvent,
ServiceAccount,
Role,
RoleBinding,
ClusterRole,
ClusterRoleBinding,
CustomResourceDefinition,
} from "../../common/k8s-api/endpoints";
@@ -0,0 +1,14 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api";
import { resolveSystemProxyInjectionToken } from "../../common/utils/resolve-system-proxy/resolve-system-proxy-injection-token";
/**
* Resolves URL-specific proxy information from system. See more here: https://www.electronjs.org/docs/latest/api/session#sesresolveproxyurl
* @param url - The URL for proxy information
* @returns Promise for proxy information as string
*/
export const resolveSystemProxy = asLegacyGlobalFunctionForExtensionApi(resolveSystemProxyInjectionToken);
@@ -0,0 +1,17 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export type { StatusBarRegistration } from "../../renderer/components/status-bar/status-bar-registration";
export type { KubeObjectMenuRegistration, KubeObjectMenuComponents } from "../../renderer/components/kube-object-menu/kube-object-menu-registration";
export type { AppPreferenceRegistration, AppPreferenceComponents } from "../../features/preferences/renderer/compliance-for-legacy-extension-api/app-preference-registration";
export type { KubeObjectDetailRegistration, KubeObjectDetailComponents } from "../../renderer/components/kube-object-details/kube-object-detail-registration";
export type { KubeObjectStatusRegistration } from "../../renderer/components/kube-object-status-icon/kube-object-status-registration";
export type { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } from "../../renderer/routes/page-registration";
export type { ClusterPageMenuRegistration, ClusterPageMenuComponents } from "../../renderer/components/layout/cluster-page-menu";
export type { ProtocolHandlerRegistration, RouteParams as ProtocolRouteParams, RouteHandler as ProtocolRouteHandler } from "../../common/protocol-handler/registration";
export type { CustomCategoryViewProps, CustomCategoryViewComponents, CustomCategoryViewRegistration } from "../../renderer/components/+catalog/custom-views";
export type { ShellEnvModifier, ShellEnvContext } from "../../main/shell-session/shell-env-modifier/shell-env-modifier-registration";
export type { KubeObjectContextMenuItem, KubeObjectOnContextMenuOpenContext, KubeObjectOnContextMenuOpen, KubeObjectHandlers, KubeObjectHandlerRegistration } from "../../renderer/kube-object/handler";
export type { TrayMenuRegistration } from "../../main/tray/tray-menu-registration";
export type { MenuRegistration } from "../../features/application-menu/main/menu-registration";
@@ -0,0 +1,6 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export { ExtensionStore } from "../extension-store";
@@ -0,0 +1,10 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export type IpcMainInvokeEvent = Electron.IpcMainInvokeEvent;
export type IpcRendererEvent = Electron.IpcRendererEvent;
export type IpcMainEvent = Electron.IpcMainEvent;
export * from "./registrations";
@@ -0,0 +1,19 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import userStoreInjectable from "../../common/user-store/user-store.injectable";
import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api";
export interface UserPreferenceExtensionItems {
/**
* Get the configured kubectl binaries path.
*/
getKubectlPath: () => string | undefined;
}
const userStore = asLegacyGlobalForExtensionApi(userStoreInjectable);
export const Preferences: UserPreferenceExtensionItems = {
getKubectlPath: () => userStore.kubectlBinariesPath,
};
@@ -0,0 +1,37 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import openLinkInBrowserInjectable from "../../common/utils/open-link-in-browser.injectable";
import buildVersionInjectable from "../../main/vars/build-version/build-version.injectable";
import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api";
import { getLegacyGlobalDiForExtensionApi } from "../as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api";
import { Singleton } from "../../common/utils";
import { prevDefault, stopPropagation } from "../../renderer/utils/prevDefault";
import type { IClassName } from "../../renderer/utils/cssNames";
import { cssNames } from "../../renderer/utils/cssNames";
export interface UtilsExtensionItems {
Singleton: typeof Singleton;
prevDefault: <E extends React.SyntheticEvent | Event, R>(callback: (evt: E) => R) => (evt: E) => R;
stopPropagation: (evt: Event | React.SyntheticEvent) => void;
cssNames: (...classNames: IClassName[]) => string;
openExternal: (url: string) => Promise<void>;
openBrowser: (url: string) => Promise<void>;
getAppVersion: () => string;
}
export const Util: UtilsExtensionItems = {
Singleton,
prevDefault,
stopPropagation,
cssNames,
openExternal: asLegacyGlobalFunctionForExtensionApi(openLinkInBrowserInjectable),
openBrowser: asLegacyGlobalFunctionForExtensionApi(openLinkInBrowserInjectable),
getAppVersion: () => {
const di = getLegacyGlobalDiForExtensionApi();
return di.inject(buildVersionInjectable).get();
},
};
@@ -0,0 +1,17 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
// Extensions-api types bundle (main + renderer)
// Available for lens-extensions via NPM-package "@k8slens/extensions"
import * as Common from "./common-api";
import * as Renderer from "./renderer-api";
import * as Main from "./main-api";
export {
Common,
Renderer,
Main,
};
@@ -0,0 +1,17 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectionToken } from "@ogre-tools/injectable";
import type { LensExtensionConstructor, LensExtensionManifest } from "../lens-extension";
export interface BundledExtension {
readonly manifest: LensExtensionManifest;
main: () => LensExtensionConstructor | null;
renderer: () => LensExtensionConstructor | null;
}
export const bundledExtensionInjectionToken = getInjectionToken<BundledExtension>({
id: "bundled-extension-path",
});
@@ -0,0 +1,63 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { ExtensionDiscovery } from "./extension-discovery";
import 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";
import installExtensionInjectable from "../extension-installer/install-extension/install-extension.injectable";
import extensionPackageRootDirectoryInjectable from "../extension-installer/extension-package-root-directory/extension-package-root-directory.injectable";
import readJsonFileInjectable from "../../common/fs/read-json-file.injectable";
import loggerInjectable from "../../common/logger.injectable";
import pathExistsInjectable from "../../common/fs/path-exists.injectable";
import watchInjectable from "../../common/fs/watch/watch.injectable";
import accessPathInjectable from "../../common/fs/access-path.injectable";
import copyInjectable from "../../common/fs/copy.injectable";
import ensureDirInjectable from "../../common/fs/ensure-dir.injectable";
import isProductionInjectable from "../../common/vars/is-production.injectable";
import lstatInjectable from "../../common/fs/lstat.injectable";
import readDirectoryInjectable from "../../common/fs/read-directory.injectable";
import fileSystemSeparatorInjectable from "../../common/path/separator.injectable";
import getBasenameOfPathInjectable from "../../common/path/get-basename.injectable";
import getDirnameOfPathInjectable from "../../common/path/get-dirname.injectable";
import getRelativePathInjectable from "../../common/path/get-relative-path.injectable";
import joinPathsInjectable from "../../common/path/join-paths.injectable";
import removePathInjectable from "../../common/fs/remove.injectable";
import homeDirectoryPathInjectable from "../../common/os/home-directory-path.injectable";
import lensResourcesDirInjectable from "../../common/vars/lens-resources-dir.injectable";
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),
installExtension: di.inject(installExtensionInjectable),
extensionPackageRootDirectory: di.inject(extensionPackageRootDirectoryInjectable),
resourcesDirectory: di.inject(lensResourcesDirInjectable),
readJsonFile: di.inject(readJsonFileInjectable),
pathExists: di.inject(pathExistsInjectable),
watch: di.inject(watchInjectable),
logger: di.inject(loggerInjectable),
accessPath: di.inject(accessPathInjectable),
copy: di.inject(copyInjectable),
removePath: di.inject(removePathInjectable),
ensureDirectory: di.inject(ensureDirInjectable),
isProduction: di.inject(isProductionInjectable),
lstat: di.inject(lstatInjectable),
readDirectory: di.inject(readDirectoryInjectable),
fileSystemSeparator: di.inject(fileSystemSeparatorInjectable),
getBasenameOfPath: di.inject(getBasenameOfPathInjectable),
getDirnameOfPath: di.inject(getDirnameOfPathInjectable),
getRelativePath: di.inject(getRelativePathInjectable),
joinPaths: di.inject(joinPathsInjectable),
homeDirectoryPath: di.inject(homeDirectoryPathInjectable),
}),
});
export default extensionDiscoveryInjectable;
@@ -0,0 +1,147 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { FSWatcher } from "chokidar";
import { getDiForUnitTesting } from "../../main/getDiForUnitTesting";
import extensionDiscoveryInjectable from "../extension-discovery/extension-discovery.injectable";
import type { ExtensionDiscovery } from "../extension-discovery/extension-discovery";
import installExtensionInjectable from "../extension-installer/install-extension/install-extension.injectable";
import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import { delay } from "../../renderer/utils";
import { observable, runInAction, when } from "mobx";
import readJsonFileInjectable from "../../common/fs/read-json-file.injectable";
import pathExistsInjectable from "../../common/fs/path-exists.injectable";
import watchInjectable from "../../common/fs/watch/watch.injectable";
import extensionApiVersionInjectable from "../../common/vars/extension-api-version.injectable";
import removePathInjectable from "../../common/fs/remove.injectable";
import type { JoinPaths } from "../../common/path/join-paths.injectable";
import joinPathsInjectable from "../../common/path/join-paths.injectable";
import homeDirectoryPathInjectable from "../../common/os/home-directory-path.injectable";
import pathExistsSyncInjectable from "../../common/fs/path-exists-sync.injectable";
import readJsonSyncInjectable from "../../common/fs/read-json-sync.injectable";
import writeJsonSyncInjectable from "../../common/fs/write-json-sync.injectable";
describe("ExtensionDiscovery", () => {
let extensionDiscovery: ExtensionDiscovery;
let readJsonFileMock: jest.Mock;
let pathExistsMock: jest.Mock;
let watchMock: jest.Mock;
let joinPaths: JoinPaths;
let homeDirectoryPath: string;
beforeEach(() => {
const di = getDiForUnitTesting({ doGeneralOverrides: true });
di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data");
di.override(installExtensionInjectable, () => () => Promise.resolve());
di.override(extensionApiVersionInjectable, () => "5.0.0");
di.override(pathExistsSyncInjectable, () => () => { throw new Error("tried call pathExistsSync without override"); });
di.override(readJsonSyncInjectable, () => () => { throw new Error("tried call readJsonSync without override"); });
di.override(writeJsonSyncInjectable, () => () => { throw new Error("tried call writeJsonSync without override"); });
joinPaths = di.inject(joinPathsInjectable);
homeDirectoryPath = di.inject(homeDirectoryPathInjectable);
readJsonFileMock = jest.fn();
di.override(readJsonFileInjectable, () => readJsonFileMock);
pathExistsMock = jest.fn(() => Promise.resolve(true));
di.override(pathExistsInjectable, () => pathExistsMock);
watchMock = jest.fn();
di.override(watchInjectable, () => watchMock);
di.override(removePathInjectable, () => async () => {}); // allow deleting files for now
extensionDiscovery = di.inject(extensionDiscoveryInjectable);
});
it("emits add for added extension", async () => {
const letTestFinish = observable.box(false);
let addHandler!: (filePath: string) => void;
readJsonFileMock.mockImplementation((p) => {
expect(p).toBe(joinPaths(homeDirectoryPath, ".k8slens/extensions/my-extension/package.json"));
return {
name: "my-extension",
version: "1.0.0",
engines: {
lens: "5.0.0",
},
};
});
const mockWatchInstance = {
on: jest.fn((event: string, handler: typeof addHandler) => {
if (event === "add") {
addHandler = handler;
}
return mockWatchInstance;
}),
} as unknown as FSWatcher;
watchMock.mockImplementationOnce(() => mockWatchInstance);
// Need to force isLoaded to be true so that the file watching is started
extensionDiscovery.isLoaded = true;
await extensionDiscovery.watchExtensions();
extensionDiscovery.events.on("add", extension => {
expect(extension).toEqual({
absolutePath: expect.any(String),
id: "/some-directory-for-user-data/node_modules/my-extension/package.json",
isBundled: false,
isEnabled: false,
isCompatible: true,
manifest: {
name: "my-extension",
version: "1.0.0",
engines: {
lens: "5.0.0",
},
},
manifestPath: "/some-directory-for-user-data/node_modules/my-extension/package.json",
});
runInAction(() => letTestFinish.set(true));
});
addHandler(joinPaths(extensionDiscovery.localFolderPath, "/my-extension/package.json"));
await when(() => letTestFinish.get());
});
it("doesn't emit add for added file under extension", async () => {
let addHandler!: (filePath: string) => void;
const mockWatchInstance = {
on: jest.fn((event: string, handler: typeof addHandler) => {
if (event === "add") {
addHandler = handler;
}
return mockWatchInstance;
}),
} as unknown as FSWatcher;
watchMock.mockImplementationOnce(() => mockWatchInstance);
// Need to force isLoaded to be true so that the file watching is started
extensionDiscovery.isLoaded = true;
await extensionDiscovery.watchExtensions();
const onAdd = jest.fn();
extensionDiscovery.events.on("add", onAdd);
addHandler(joinPaths(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json"));
await delay(10);
expect(onAdd).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,443 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { ipcRenderer } from "electron";
import { EventEmitter } from "events";
import { makeObservable, observable, reaction, when } from "mobx";
import { broadcastMessage, ipcMainHandle, ipcRendererOn } from "../../common/ipc";
import { isErrnoException, toJS } from "../../common/utils";
import type { ExtensionsStore } from "../extensions-store/extensions-store";
import type { ExtensionLoader } from "../extension-loader";
import type { LensExtensionId, LensExtensionManifest } from "../lens-extension";
import type { ExtensionInstallationStateStore } from "../extension-installation-state-store/extension-installation-state-store";
import { extensionDiscoveryStateChannel } from "../../common/ipc/extension-handling";
import { requestInitialExtensionDiscovery } from "../../renderer/ipc";
import type { ReadJson } from "../../common/fs/read-json-file.injectable";
import type { Logger } from "../../common/logger";
import type { PathExists } from "../../common/fs/path-exists.injectable";
import type { Watch } from "../../common/fs/watch/watch.injectable";
import type { Stats } from "fs";
import type { LStat } from "../../common/fs/lstat.injectable";
import type { ReadDirectory } from "../../common/fs/read-directory.injectable";
import type { EnsureDirectory } from "../../common/fs/ensure-dir.injectable";
import type { AccessPath } from "../../common/fs/access-path.injectable";
import type { Copy } from "../../common/fs/copy.injectable";
import type { JoinPaths } from "../../common/path/join-paths.injectable";
import type { GetBasenameOfPath } from "../../common/path/get-basename.injectable";
import type { GetDirnameOfPath } from "../../common/path/get-dirname.injectable";
import type { GetRelativePath } from "../../common/path/get-relative-path.injectable";
import type { RemovePath } from "../../common/fs/remove.injectable";
import type TypedEventEmitter from "typed-emitter";
interface Dependencies {
readonly extensionLoader: ExtensionLoader;
readonly extensionsStore: ExtensionsStore;
readonly extensionInstallationStateStore: ExtensionInstallationStateStore;
readonly extensionPackageRootDirectory: string;
readonly resourcesDirectory: string;
readonly logger: Logger;
readonly isProduction: boolean;
readonly fileSystemSeparator: string;
readonly homeDirectoryPath: string;
isCompatibleExtension: (manifest: LensExtensionManifest) => boolean;
installExtension: (name: string) => Promise<void>;
readJsonFile: ReadJson;
pathExists: PathExists;
removePath: RemovePath;
lstat: LStat;
watch: Watch;
readDirectory: ReadDirectory;
ensureDirectory: EnsureDirectory;
accessPath: AccessPath;
copy: Copy;
joinPaths: JoinPaths;
getBasenameOfPath: GetBasenameOfPath;
getDirnameOfPath: GetDirnameOfPath;
getRelativePath: GetRelativePath;
}
export interface InstalledExtension {
id: LensExtensionId;
readonly manifest: LensExtensionManifest;
// Absolute path to the non-symlinked source folder,
// e.g. "/Users/user/.k8slens/extensions/helloworld"
readonly absolutePath: string;
// Absolute to the symlinked package.json file
readonly manifestPath: string;
readonly isBundled: boolean; // defined in project root's package.json
readonly isCompatible: boolean;
isEnabled: boolean;
}
const logModule = "[EXTENSION-DISCOVERY]";
export const manifestFilename = "package.json";
interface ExtensionDiscoveryChannelMessage {
isLoaded: boolean;
}
/**
* Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link)
* @param lstat the stats to compare
*/
const isDirectoryLike = (lstat: Stats) => lstat.isDirectory() || lstat.isSymbolicLink();
interface LoadFromFolderOptions {
isBundled?: boolean;
}
interface ExtensionDiscoveryEvents {
add: (ext: InstalledExtension) => void;
remove: (extId: LensExtensionId) => void;
}
/**
* Discovers installed bundled and local extensions from the filesystem.
* Also watches for added and removed local extensions by watching the directory.
* Uses ExtensionInstaller to install dependencies for all of the extensions.
* This is also done when a new extension is copied to the local extensions directory.
* .init() must be called to start the directory watching.
* The class emits events for added and removed extensions:
* - "add": When extension is added. The event is of type InstalledExtension
* - "remove": When extension is removed. The event is of type LensExtensionId
*/
export class ExtensionDiscovery {
protected bundledFolderPath!: string;
private loadStarted = false;
private extensions: Map<string, InstalledExtension> = new Map();
// True if extensions have been loaded from the disk after app startup
@observable isLoaded = false;
get whenLoaded() {
return when(() => this.isLoaded);
}
public readonly events: TypedEventEmitter<ExtensionDiscoveryEvents> = new EventEmitter();
constructor(protected readonly dependencies: Dependencies) {
makeObservable(this);
}
get localFolderPath(): string {
return this.dependencies.joinPaths(this.dependencies.homeDirectoryPath, ".k8slens", "extensions");
}
get packageJsonPath(): string {
return this.dependencies.joinPaths(this.dependencies.extensionPackageRootDirectory, manifestFilename);
}
get nodeModulesPath(): string {
return this.dependencies.joinPaths(this.dependencies.extensionPackageRootDirectory, "node_modules");
}
/**
* Initializes the class and setups the file watcher for added/removed local extensions.
*/
async init(): Promise<void> {
if (ipcRenderer) {
await this.initRenderer();
} else {
await this.initMain();
}
}
async initRenderer(): Promise<void> {
const onMessage = ({ isLoaded }: ExtensionDiscoveryChannelMessage) => {
this.isLoaded = isLoaded;
};
requestInitialExtensionDiscovery().then(onMessage);
ipcRendererOn(extensionDiscoveryStateChannel, (_event, message: ExtensionDiscoveryChannelMessage) => {
onMessage(message);
});
}
async initMain(): Promise<void> {
ipcMainHandle(extensionDiscoveryStateChannel, () => this.toJSON());
reaction(() => this.toJSON(), () => {
this.broadcast();
});
}
/**
* Watches for added/removed local extensions.
* Dependencies are installed automatically after an extension folder is copied.
*/
async watchExtensions(): Promise<void> {
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,
ignoreInitial: true,
// Try to wait until the file has been completely copied.
// The OS might emit an event for added file even it's not completely written to the file-system.
awaitWriteFinish: {
// Wait 300ms until the file size doesn't change to consider the file written.
// For a small file like package.json this should be plenty of time.
stabilityThreshold: 300,
},
})
// Extension add is detected by watching "<extensionDir>/package.json" add
.on("add", this.handleWatchFileAdd)
// Extension remove is detected by watching "<extensionDir>" unlink
.on("unlinkDir", this.handleWatchUnlinkEvent)
// Extension remove is detected by watching "<extensionSymLink>" unlink
.on("unlink", this.handleWatchUnlinkEvent);
}
handleWatchFileAdd = async (manifestPath: string): Promise<void> => {
// e.g. "foo/package.json"
const relativePath = this.dependencies.getRelativePath(this.localFolderPath, manifestPath);
// Converts "foo/package.json" to ["foo", "package.json"], where length of 2 implies
// that the added file is in a folder under local folder path.
// This safeguards against a file watch being triggered under a sub-directory which is not an extension.
const isUnderLocalFolderPath = relativePath.split(this.dependencies.fileSystemSeparator).length === 2;
if (this.dependencies.getBasenameOfPath(manifestPath) === manifestFilename && isUnderLocalFolderPath) {
try {
this.dependencies.extensionInstallationStateStore.setInstallingFromMain(manifestPath);
const absPath = this.dependencies.getDirnameOfPath(manifestPath);
// this.loadExtensionFromPath updates this.packagesJson
const extension = await this.loadExtensionFromFolder(absPath);
if (extension) {
// Remove a broken symlink left by a previous installation if it exists.
await this.dependencies.removePath(extension.manifestPath);
// Install dependencies for the new extension
await this.dependencies.installExtension(extension.absolutePath);
this.extensions.set(extension.id, extension);
this.dependencies.logger.info(`${logModule} Added extension ${extension.manifest.name}`);
this.events.emit("add", extension);
}
} catch (error) {
this.dependencies.logger.error(`${logModule}: failed to add extension: ${error}`, { error });
} finally {
this.dependencies.extensionInstallationStateStore.clearInstallingFromMain(manifestPath);
}
}
};
/**
* Handle any unlink event, filtering out non-package.json links so the delete code
* only happens once per extension.
* @param filePath The absolute path to either a folder or file in the extensions folder
*/
handleWatchUnlinkEvent = async (filePath: string): Promise<void> => {
// Check that the removed path is directly under this.localFolderPath
// Note that the watcher can create unlink events for subdirectories of the extension
const extensionFolderName = this.dependencies.getBasenameOfPath(filePath);
const expectedPath = this.dependencies.getRelativePath(this.localFolderPath, filePath);
if (expectedPath !== extensionFolderName) {
return;
}
for (const extension of this.extensions.values()) {
if (extension.absolutePath !== filePath) {
continue;
}
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);
return;
}
this.dependencies.logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`);
};
/**
* Remove the symlink under node_modules if exists.
* If we don't remove the symlink, the uninstall would leave a non-working symlink,
* which wouldn't be fixed if the extension was reinstalled, causing the extension not to work.
* @param name e.g. "@mirantis/lens-extension-cc"
*/
removeSymlinkByPackageName(name: string): Promise<void> {
return this.dependencies.removePath(this.getInstalledPath(name));
}
/**
* Uninstalls extension.
* The application will detect the folder unlink and remove the extension from the UI automatically.
* @param extensionId The ID of the extension to uninstall.
*/
async uninstallExtension(extensionId: LensExtensionId): Promise<void> {
const extension = this.extensions.get(extensionId) ?? this.dependencies.extensionLoader.getExtension(extensionId);
if (!extension) {
return void this.dependencies.logger.warn(`${logModule} could not uninstall extension, not found`, { id: extensionId });
}
const { manifest, absolutePath } = extension;
this.dependencies.logger.info(`${logModule} Uninstalling ${manifest.name}`);
await this.removeSymlinkByPackageName(manifest.name);
// fs.remove does nothing if the path doesn't exist anymore
await this.dependencies.removePath(absolutePath);
}
async load(): Promise<Map<LensExtensionId, InstalledExtension>> {
if (this.loadStarted) {
// The class is simplified by only supporting .load() to be called once
throw new Error("ExtensionDiscovery.load() can be only be called once");
}
this.loadStarted = true;
this.dependencies.logger.info(
`${logModule} loading extensions from ${this.dependencies.extensionPackageRootDirectory}`,
);
await this.dependencies.removePath(this.dependencies.joinPaths(this.dependencies.extensionPackageRootDirectory, "package-lock.json"));
await this.dependencies.ensureDirectory(this.nodeModulesPath);
await this.dependencies.ensureDirectory(this.localFolderPath);
const extensions = await this.ensureExtensions();
this.isLoaded = true;
return extensions;
}
/**
* Returns the symlinked path to the extension folder,
* e.g. "/Users/<username>/Library/Application Support/Lens/node_modules/@publisher/extension"
*/
protected getInstalledPath(name: string): string {
return this.dependencies.joinPaths(this.nodeModulesPath, name);
}
/**
* Returns the symlinked path to the package.json,
* e.g. "/Users/<username>/Library/Application Support/Lens/node_modules/@publisher/extension/package.json"
*/
protected getInstalledManifestPath(name: string): string {
return this.dependencies.joinPaths(this.getInstalledPath(name), manifestFilename);
}
/**
* Returns InstalledExtension from path to package.json file.
* Also updates this.packagesJson.
*/
protected async getByManifest(manifestPath: string, { isBundled = false } = {}): Promise<InstalledExtension | null> {
try {
const manifest = await this.dependencies.readJsonFile(manifestPath) as unknown as LensExtensionManifest;
const id = isBundled ? manifestPath : this.getInstalledManifestPath(manifest.name);
const isEnabled = this.dependencies.extensionsStore.isEnabled({ id, isBundled });
const extensionDir = this.dependencies.getDirnameOfPath(manifestPath);
const npmPackage = this.dependencies.joinPaths(extensionDir, `${manifest.name}-${manifest.version}.tgz`);
const absolutePath = this.dependencies.isProduction && await this.dependencies.pathExists(npmPackage)
? npmPackage
: extensionDir;
const isCompatible = isBundled || this.dependencies.isCompatibleExtension(manifest);
return {
id,
absolutePath,
manifestPath: id,
manifest,
isBundled,
isEnabled,
isCompatible,
};
} catch (error) {
if (isErrnoException(error) && error.code === "ENOTDIR") {
// ignore this error, probably from .DS_Store file
this.dependencies.logger.debug(`${logModule}: failed to load extension manifest through a not-dir-like at ${manifestPath}`);
} else {
this.dependencies.logger.error(`${logModule}: can't load extension manifest at ${manifestPath}: ${error}`);
}
return null;
}
}
async ensureExtensions(): Promise<Map<LensExtensionId, InstalledExtension>> {
const userExtensions = await this.loadFromFolder(this.localFolderPath);
return this.extensions = new Map(userExtensions.map(extension => [extension.id, extension]));
}
async loadFromFolder(folderPath: string): Promise<InstalledExtension[]> {
const extensions: InstalledExtension[] = [];
const paths = await this.dependencies.readDirectory(folderPath);
for (const fileName of paths) {
const absPath = this.dependencies.joinPaths(folderPath, fileName);
try {
const lstat = await this.dependencies.lstat(absPath);
// skip non-directories
if (!isDirectoryLike(lstat)) {
continue;
}
} catch (error) {
if (isErrnoException(error) && error.code === "ENOENT") {
continue;
}
throw error;
}
const extension = await this.loadExtensionFromFolder(absPath);
if (extension) {
extensions.push(extension);
}
}
this.dependencies.logger.debug(`${logModule}: ${extensions.length} extensions loaded`, { folderPath, extensions });
return extensions;
}
/**
* 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 {
return toJS({
isLoaded: this.isLoaded,
});
}
broadcast(): void {
broadcastMessage(extensionDiscoveryStateChannel, this.toJSON());
}
}
@@ -0,0 +1,16 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import extensionApiVersionInjectable from "../../../common/vars/extension-api-version.injectable";
import { isCompatibleExtension } from "./is-compatible-extension";
const isCompatibleExtensionInjectable = getInjectable({
id: "is-compatible-extension",
instantiate: (di) => isCompatibleExtension({
extensionApiVersion: di.inject(extensionApiVersionInjectable),
}),
});
export default isCompatibleExtensionInjectable;
@@ -0,0 +1,37 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import semver from "semver";
import type { LensExtensionManifest } from "../../lens-extension";
interface Dependencies {
extensionApiVersion: string;
}
export const isCompatibleExtension = ({ extensionApiVersion }: Dependencies): ((manifest: LensExtensionManifest) => boolean) => {
return (manifest: LensExtensionManifest): boolean => {
const manifestLensEngine = manifest.engines.lens;
const validVersion = manifestLensEngine.match(/^[\^0-9]\d*\.\d+\b/); // must start from ^ or number
if (!validVersion) {
const errorInfo = [
`Invalid format for "manifest.engines.lens"="${manifestLensEngine}"`,
`Range versions can only be specified starting with '^'.`,
`Otherwise it's recommended to use plain %MAJOR.%MINOR to match with supported Lens version.`,
].join("\n");
throw new Error(errorInfo);
}
const { major: extMajor, minor: extMinor } = semver.coerce(manifestLensEngine, {
loose: true,
}) as semver.SemVer;
const supportedVersionsByExtension = semver.validRange(`^${extMajor}.${extMinor}`) as string;
return semver.satisfies(extensionApiVersion, supportedVersionsByExtension, {
loose: true,
includePrerelease: false,
});
};
};
@@ -0,0 +1,16 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import loggerInjectable from "../../common/logger.injectable";
import { ExtensionInstallationStateStore } from "./extension-installation-state-store";
const extensionInstallationStateStoreInjectable = getInjectable({
id: "extension-installation-state-store",
instantiate: (di) => new ExtensionInstallationStateStore({
logger: di.inject(loggerInjectable),
}),
});
export default extensionInstallationStateStoreInjectable;
@@ -0,0 +1,247 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { action, computed, observable } from "mobx";
import { disposer } from "../../renderer/utils";
import type { ExtendableDisposer } from "../../renderer/utils";
import * as uuid from "uuid";
import { broadcastMessage } from "../../common/ipc";
import { ipcRenderer } from "electron";
import type { Logger } from "../../common/logger";
export enum ExtensionInstallationState {
INSTALLING = "installing",
UNINSTALLING = "uninstalling",
IDLE = "idle",
}
interface Dependencies {
readonly logger: Logger;
}
const Prefix = "[ExtensionInstallationStore]";
const installingFromMainChannel = "extension-installation-state-store:install";
const clearInstallingFromMainChannel = "extension-installation-state-store:clear-install";
export class ExtensionInstallationStateStore {
private readonly preInstallIds = observable.set<string>();
private readonly uninstallingExtensions = observable.set<string>();
private readonly installingExtensions = observable.set<string>();
constructor(private readonly dependencies: Dependencies) {}
bindIpcListeners = () => {
ipcRenderer
.on(installingFromMainChannel, (event, extId) => {
this.setInstalling(extId);
})
.on(clearInstallingFromMainChannel, (event, extId) => {
this.clearInstalling(extId);
});
};
/**
* Strictly transitions an extension from not installing to installing
* @param extId the ID of the extension
* @throws if state is not IDLE
*/
@action setInstalling = (extId: string): void => {
this.dependencies.logger.debug(`${Prefix}: trying to set ${extId} as installing`);
const curState = this.getInstallationState(extId);
if (curState !== ExtensionInstallationState.IDLE) {
throw new Error(
`${Prefix}: cannot set ${extId} as installing. Is currently ${curState}.`,
);
}
this.installingExtensions.add(extId);
};
/**
* Broadcasts that an extension is being installed by the main process
* @param extId the ID of the extension
*/
setInstallingFromMain = (extId: string): void => {
broadcastMessage(installingFromMainChannel, extId);
};
/**
* Broadcasts that an extension is no longer being installed by the main process
* @param extId the ID of the extension
*/
clearInstallingFromMain = (extId: string): void => {
broadcastMessage(clearInstallingFromMainChannel, extId);
};
/**
* Marks the start of a pre-install phase of an extension installation. The
* part of the installation before the tarball has been unpacked and the ID
* determined.
* @returns a disposer which should be called to mark the end of the install phase
*/
@action startPreInstall = (): ExtendableDisposer => {
const preInstallStepId = uuid.v4();
this.dependencies.logger.debug(
`${Prefix}: starting a new preinstall phase: ${preInstallStepId}`,
);
this.preInstallIds.add(preInstallStepId);
return disposer(() => {
this.preInstallIds.delete(preInstallStepId);
this.dependencies.logger.debug(`${Prefix}: ending a preinstall phase: ${preInstallStepId}`);
});
};
/**
* Strictly transitions an extension from not uninstalling to uninstalling
* @param extId the ID of the extension
* @throws if state is not IDLE
*/
@action setUninstalling = (extId: string): void => {
this.dependencies.logger.debug(`${Prefix}: trying to set ${extId} as uninstalling`);
const curState = this.getInstallationState(extId);
if (curState !== ExtensionInstallationState.IDLE) {
throw new Error(
`${Prefix}: cannot set ${extId} as uninstalling. Is currently ${curState}.`,
);
}
this.uninstallingExtensions.add(extId);
};
/**
* Strictly clears the INSTALLING state of an extension
* @param extId The ID of the extension
* @throws if state is not INSTALLING
*/
@action clearInstalling = (extId: string): void => {
this.dependencies.logger.debug(`${Prefix}: trying to clear ${extId} as installing`);
const curState = this.getInstallationState(extId);
switch (curState) {
case ExtensionInstallationState.INSTALLING:
return void this.installingExtensions.delete(extId);
default:
throw new Error(
`${Prefix}: cannot clear INSTALLING state for ${extId}, it is currently ${curState}`,
);
}
};
/**
* Strictly clears the UNINSTALLING state of an extension
* @param extId The ID of the extension
* @throws if state is not UNINSTALLING
*/
@action clearUninstalling = (extId: string): void => {
this.dependencies.logger.debug(`${Prefix}: trying to clear ${extId} as uninstalling`);
const curState = this.getInstallationState(extId);
switch (curState) {
case ExtensionInstallationState.UNINSTALLING:
return void this.uninstallingExtensions.delete(extId);
default:
throw new Error(
`${Prefix}: cannot clear UNINSTALLING state for ${extId}, it is currently ${curState}`,
);
}
};
/**
* Returns the current state of the extension. IDLE is default value.
* @param extId The ID of the extension
*/
getInstallationState = (extId: string): ExtensionInstallationState => {
if (this.installingExtensions.has(extId)) {
return ExtensionInstallationState.INSTALLING;
}
if (this.uninstallingExtensions.has(extId)) {
return ExtensionInstallationState.UNINSTALLING;
}
return ExtensionInstallationState.IDLE;
};
/**
* Returns true if the extension is currently INSTALLING
* @param extId The ID of the extension
*/
isExtensionInstalling = (extId: string): boolean =>
this.getInstallationState(extId) === ExtensionInstallationState.INSTALLING;
/**
* Returns true if the extension is currently UNINSTALLING
* @param extId The ID of the extension
*/
isExtensionUninstalling = (extId: string): boolean =>
this.getInstallationState(extId) ===
ExtensionInstallationState.UNINSTALLING;
/**
* Returns true if the extension is currently IDLE
* @param extId The ID of the extension
*/
isExtensionIdle = (extId: string): boolean =>
this.getInstallationState(extId) === ExtensionInstallationState.IDLE;
/**
* The current number of extensions installing
*/
@computed get installing(): number {
return this.installingExtensions.size;
}
/**
* The current number of extensions uninstalling
*/
get uninstalling(): number {
return this.uninstallingExtensions.size;
}
/**
* If there is at least one extension currently installing
*/
get anyInstalling(): boolean {
return this.installing > 0;
}
/**
* If there is at least one extension currently uninstalling
*/
get anyUninstalling(): boolean {
return this.uninstalling > 0;
}
/**
* The current number of extensions preinstalling
*/
get preinstalling(): number {
return this.preInstallIds.size;
}
/**
* If there is at least one extension currently downloading
*/
get anyPreinstalling(): boolean {
return this.preinstalling > 0;
}
/**
* If there is at least one installing or preinstalling step taking place
*/
get anyPreInstallingOrInstalling(): boolean {
return this.anyInstalling || this.anyPreinstalling;
}
}
@@ -0,0 +1,21 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import pathToNpmCliInjectable from "../../common/app-paths/path-to-npm-cli.injectable";
import loggerInjectable from "../../common/logger.injectable";
import { ExtensionInstaller } from "./extension-installer";
import extensionPackageRootDirectoryInjectable from "./extension-package-root-directory/extension-package-root-directory.injectable";
const extensionInstallerInjectable = getInjectable({
id: "extension-installer",
instantiate: (di) => new ExtensionInstaller({
extensionPackageRootDirectory: di.inject(extensionPackageRootDirectoryInjectable),
logger: di.inject(loggerInjectable),
pathToNpmCli: di.inject(pathToNpmCliInjectable),
}),
});
export default extensionInstallerInjectable;
@@ -0,0 +1,78 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import AwaitLock from "await-lock";
import child_process from "child_process";
import type { Logger } from "../../common/logger";
const logModule = "[EXTENSION-INSTALLER]";
interface Dependencies {
readonly extensionPackageRootDirectory: string;
readonly logger: Logger;
readonly pathToNpmCli: string;
}
const baseNpmInstallArgs = [
"install",
"--audit=false",
"--fund=false",
// NOTE: we do not omit the `optional` dependencies because that is how we specify the non-bundled extensions
"--omit=dev",
"--omit=peer",
"--prefer-offline",
];
/**
* Installs dependencies for extensions
*/
export class ExtensionInstaller {
private readonly installLock = new AwaitLock();
constructor(private readonly dependencies: Dependencies) {}
/**
* Install single package using npm
*/
installPackage = async (name: string): Promise<void> => {
// Mutual exclusion to install packages in sequence
await this.installLock.acquireAsync();
try {
this.dependencies.logger.info(`${logModule} installing package from ${name} to ${this.dependencies.extensionPackageRootDirectory}`);
await this.npm(...baseNpmInstallArgs, name);
this.dependencies.logger.info(`${logModule} package ${name} installed to ${this.dependencies.extensionPackageRootDirectory}`);
} finally {
this.installLock.release();
}
};
private npm(...args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = child_process.fork(this.dependencies.pathToNpmCli, args, {
cwd: this.dependencies.extensionPackageRootDirectory,
silent: true,
env: {},
});
let stderr = "";
child.stderr?.on("data", data => {
stderr += String(data);
});
child.on("close", (code) => {
if (code !== 0) {
reject(new Error(stderr));
} else {
resolve();
}
});
child.on("error", error => {
reject(error);
});
});
}
}
@@ -0,0 +1,14 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
const extensionPackageRootDirectoryInjectable = getInjectable({
id: "extension-package-root-directory",
instantiate: (di) => di.inject(directoryForUserDataInjectable),
});
export default extensionPackageRootDirectoryInjectable;
@@ -0,0 +1,13 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import extensionInstallerInjectable from "../extension-installer.injectable";
const installExtensionInjectable = getInjectable({
id: "install-extension",
instantiate: (di) => di.inject(extensionInstallerInjectable).installPackage,
});
export default installExtensionInjectable;
@@ -0,0 +1,14 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectionToken } from "@ogre-tools/injectable";
import type { InstalledExtension } from "../extension-discovery/extension-discovery";
import type { LensExtension, LensExtensionConstructor } from "../lens-extension";
export type CreateExtensionInstance = (ExtensionClass: LensExtensionConstructor, extension: InstalledExtension) => LensExtension;
export const createExtensionInstanceInjectionToken = getInjectionToken<CreateExtensionInstance>({
id: "create-extension-instance-token",
});
@@ -0,0 +1,10 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectionToken } from "@ogre-tools/injectable";
export const extensionEntryPointNameInjectionToken = getInjectionToken<"main" | "renderer">({
id: "extension-entry-point-name-token",
});
@@ -0,0 +1,14 @@
/**
* 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 { observable } from "mobx";
import type { LensExtensionId, LensExtension } from "../lens-extension";
const extensionInstancesInjectable = getInjectable({
id: "extension-instances",
instantiate: () => observable.map<LensExtensionId, LensExtension>(),
});
export default extensionInstancesInjectable;
@@ -0,0 +1,30 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import type { KubernetesCluster } from "../../common/catalog-entities";
import type { LensRendererExtension } from "../lens-renderer-extension";
interface ExtensionIsEnabledForCluster {
extension: LensRendererExtension;
cluster: KubernetesCluster;
}
const extensionIsEnabledForClusterInjectable = getInjectable({
id: "extension-is-enabled-for-cluster",
instantiate: async (
di,
{ extension, cluster }: ExtensionIsEnabledForCluster,
) => (await extension.isEnabledForCluster(cluster)) as boolean,
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (
di,
{ extension, cluster }: ExtensionIsEnabledForCluster,
) => `${extension.sanitizedExtensionId}-${cluster.getId()}`,
}),
});
export default extensionIsEnabledForClusterInjectable;
@@ -0,0 +1,34 @@
/**
* 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 { createExtensionInstanceInjectionToken } from "./create-extension-instance.token";
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),
createExtensionInstance: di.inject(createExtensionInstanceInjectionToken),
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;
@@ -0,0 +1,428 @@
/**
* 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 { action, computed, makeObservable, observable, observe, reaction, when } from "mobx";
import { broadcastMessage, ipcMainOn, ipcRendererOn, ipcMainHandle } from "../../common/ipc";
import { isDefined, toJS } from "../../common/utils";
import type { 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 { CreateExtensionInstance } from "./create-extension-instance.token";
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;
createExtensionInstance: CreateExtensionInstance;
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>();
/**
* This is updated by the `observe` in the constructor. DO NOT write directly to it
*/
protected readonly instancesByName = observable.map<string, LensExtension>();
private readonly onRemoveExtensionId = new EventEmitter<[string]>();
@observable isLoaded = false;
get whenLoaded() {
return when(() => this.isLoaded);
}
constructor(protected readonly dependencies: Dependencies) {
makeObservable(this);
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
* @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(name);
}
// Transform userExtensions to a state object for storing into ExtensionsStore
@computed get storeState() {
return Object.fromEntries(
Array.from(this.userExtensions)
.map(([extId, extension]) => [extId, {
enabled: extension.isEnabled,
name: extension.manifest.name,
}]),
);
}
@action
async init() {
if (ipcMain) {
await this.initMain();
} else {
await this.initRenderer();
}
await Promise.all([this.whenLoaded]);
// broadcasting extensions between main/renderer processes
reaction(() => this.toJSON(), () => this.broadcastExtensions(), {
fireImmediately: true,
});
reaction(
() => this.storeState,
(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`);
extension.isEnabled = isEnabled;
}
protected async initMain() {
this.isLoaded = true;
await this.autoInitExtensions();
ipcMainHandle(extensionLoaderFromMainChannel, () => {
return Array.from(this.toJSON());
});
ipcMainOn(extensionLoaderFromRendererChannel, (event, extensions: [LensExtensionId, InstalledExtension][]) => {
this.syncExtensions(extensions);
});
}
protected async initRenderer() {
const extensionListHandler = (extensions: [LensExtensionId, InstalledExtension][]) => {
this.isLoaded = 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() {
return this.dependencies.bundledExtensions
.map(extension => {
try {
const LensExtensionClass = extension[this.dependencies.extensionEntryPointName]();
if (!LensExtensionClass) {
return null;
}
const installedExtension: InstalledExtension = {
absolutePath: "irrelevant",
id: extension.manifest.name,
isBundled: true,
isCompatible: true,
isEnabled: true,
manifest: extension.manifest,
manifestPath: "irrelevant",
};
const instance = this.dependencies.createExtensionInstance(
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;
}
})
.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()]
.map(([extId, extension]) => {
const alreadyInit = this.dependencies.extensionInstances.has(extId) || this.nonInstancesByName.has(extension.manifest.name);
if (extension.isCompatible && extension.isEnabled && !alreadyInit) {
try {
const LensExtensionClass = this.requireExtension(extension);
if (!LensExtensionClass) {
this.nonInstancesByName.add(extension.manifest.name);
return null;
}
const instance = this.dependencies.createExtensionInstance(
LensExtensionClass,
extension,
);
this.dependencies.extensionInstances.set(extId, instance);
return {
instance,
installedExtension: extension,
activated: instance.activate(),
} as ExtensionBeingActivated;
} catch (err) {
this.dependencies.logger.error(`${logModule}: error loading extension`, { ext: extension, err });
}
} else if (!extension.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: InstalledExtension): 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;
}
getExtension(extId: LensExtensionId) {
return this.extensions.get(extId);
}
getInstanceById(extId: LensExtensionId) {
return this.dependencies.extensionInstances.get(extId);
}
toJSON(): Map<LensExtensionId, InstalledExtension> {
return toJS(this.extensions);
}
}
@@ -0,0 +1,15 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { Injectable } from "@ogre-tools/injectable";
import { getInjectionToken } from "@ogre-tools/injectable";
import type { IComputedValue } from "mobx";
import type { LensExtension } from "../lens-extension";
export type ExtensionRegistrator = (extension: LensExtension) =>
Injectable<any, any, any>[] | IComputedValue<Injectable<any, any, any>[]>;
export const extensionRegistratorInjectionToken = getInjectionToken<ExtensionRegistrator>({
id: "extension-registrator-token",
});
@@ -0,0 +1,93 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { Injectable } from "@ogre-tools/injectable";
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { difference, find, map } from "lodash";
import { reaction, runInAction } from "mobx";
import { disposer } from "../../../common/utils/disposer";
import type { LensExtension } from "../../lens-extension";
import { extensionRegistratorInjectionToken } from "../extension-registrator-injection-token";
export interface Extension {
register: () => void;
deregister: () => void;
}
const idsToInjectables = (ids: string[], injectables: Injectable<any, any, any>[]) => ids.map(id => {
const injectable = find(injectables, { id });
if (!injectable) {
throw new Error(`Injectable ${id} not found`);
}
return injectable;
});
const extensionInjectable = getInjectable({
id: "extension",
instantiate: (parentDi, instance: LensExtension): Extension => {
const extensionInjectable = getInjectable({
id: `extension-${instance.sanitizedExtensionId}`,
instantiate: (childDi) => {
const extensionRegistrators = childDi.injectMany(extensionRegistratorInjectionToken);
const reactionDisposer = disposer();
return {
register: () => {
extensionRegistrators.forEach((getInjectablesOfExtension) => {
const injectables = getInjectablesOfExtension(instance);
reactionDisposer.push(
// injectables is either an array or a computed array, in which case
// we need to update the registered injectables with a reaction every time they change
reaction(
() => Array.isArray(injectables) ? injectables : injectables.get(),
(currentInjectables, previousInjectables = []) => {
// Register new injectables and deregister removed injectables by id
const currentIds = map(currentInjectables, "id");
const previousIds = map(previousInjectables, "id");
const idsToAdd = difference(currentIds, previousIds);
const idsToRemove = previousIds.filter(previousId => !currentIds.includes(previousId));
if (idsToRemove.length > 0) {
childDi.deregister(...idsToInjectables(idsToRemove, previousInjectables));
}
if (idsToAdd.length > 0) {
childDi.register(...idsToInjectables(idsToAdd, currentInjectables));
}
}, {
fireImmediately: true,
},
));
});
},
deregister: () => {
reactionDisposer();
runInAction(() => {
parentDi.deregister(extensionInjectable);
});
},
};
},
});
runInAction(() => {
parentDi.register(extensionInjectable);
});
return parentDi.inject(extensionInjectable);
},
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, instance: LensExtension) => instance,
}),
});
export default extensionInjectable;
@@ -0,0 +1,20 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import joinPathsInjectable from "../../../common/path/join-paths.injectable";
const directoryForExtensionDataInjectable = getInjectable({
id: "directory-for-extension-data",
instantiate: (di) => {
const joinPaths = di.inject(joinPathsInjectable);
const directoryForUserData = di.inject(directoryForUserDataInjectable);
return joinPaths(directoryForUserData, "extension_data");
},
});
export default directoryForExtensionDataInjectable;
@@ -0,0 +1,42 @@
/**
* 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 { FileSystemProvisionerStore } from "./file-system-provisioner-store";
import directoryForExtensionDataInjectable from "./directory-for-extension-data.injectable";
import ensureDirectoryInjectable from "../../../common/fs/ensure-dir.injectable";
import joinPathsInjectable from "../../../common/path/join-paths.injectable";
import randomBytesInjectable from "../../../common/utils/random-bytes.injectable";
import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import getConfigurationFileModelInjectable from "../../../common/get-configuration-file-model/get-configuration-file-model.injectable";
import loggerInjectable from "../../../common/logger.injectable";
import storeMigrationVersionInjectable from "../../../common/vars/store-migration-version.injectable";
import { baseStoreIpcChannelPrefixesInjectionToken } from "../../../common/base-store/channel-prefix";
import { shouldBaseStoreDisableSyncInIpcListenerInjectionToken } from "../../../common/base-store/disable-sync";
import { persistStateToConfigInjectionToken } from "../../../common/base-store/save-to-file";
import getBasenameOfPathInjectable from "../../../common/path/get-basename.injectable";
import { enlistMessageChannelListenerInjectionToken } from "../../../common/utils/channel/enlist-message-channel-listener-injection-token";
const fileSystemProvisionerStoreInjectable = getInjectable({
id: "file-system-provisioner-store",
instantiate: (di) => new FileSystemProvisionerStore({
directoryForExtensionData: di.inject(directoryForExtensionDataInjectable),
ensureDirectory: di.inject(ensureDirectoryInjectable),
joinPaths: di.inject(joinPathsInjectable),
randomBytes: di.inject(randomBytesInjectable),
directoryForUserData: di.inject(directoryForUserDataInjectable),
getConfigurationFileModel: di.inject(getConfigurationFileModelInjectable),
logger: di.inject(loggerInjectable),
storeMigrationVersion: di.inject(storeMigrationVersionInjectable),
migrations: {},
getBasenameOfPath: di.inject(getBasenameOfPathInjectable),
ipcChannelPrefixes: di.inject(baseStoreIpcChannelPrefixesInjectionToken),
persistStateToConfig: di.inject(persistStateToConfigInjectionToken),
enlistMessageChannelListener: di.inject(enlistMessageChannelListenerInjectionToken),
shouldDisableSyncInListener: di.inject(shouldBaseStoreDisableSyncInIpcListenerInjectionToken),
}),
});
export default fileSystemProvisionerStoreInjectable;
@@ -0,0 +1,68 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { SHA256 } from "crypto-js";
import { action, makeObservable, observable } from "mobx";
import type { BaseStoreDependencies } from "../../../common/base-store/base-store";
import { BaseStore } from "../../../common/base-store/base-store";
import type { LensExtensionId } from "../../lens-extension";
import { getOrInsertWithAsync, toJS } from "../../../common/utils";
import type { EnsureDirectory } from "../../../common/fs/ensure-dir.injectable";
import type { JoinPaths } from "../../../common/path/join-paths.injectable";
import type { RandomBytes } from "../../../common/utils/random-bytes.injectable";
interface FSProvisionModel {
extensions: Record<string, string>; // extension names to paths
}
interface Dependencies extends BaseStoreDependencies {
readonly directoryForExtensionData: string;
ensureDirectory: EnsureDirectory;
joinPaths: JoinPaths;
randomBytes: RandomBytes;
}
export class FileSystemProvisionerStore extends BaseStore<FSProvisionModel> {
readonly registeredExtensions = observable.map<LensExtensionId, string>();
constructor(protected readonly dependencies: Dependencies) {
super(dependencies, {
configName: "lens-filesystem-provisioner-store",
accessPropertiesByDotNotation: false, // To make dots safe in cluster context names
});
makeObservable(this);
}
/**
* This function retrieves the saved path to the folder which the extension
* can saves files to. If the folder is not present then it is created.
* @param extensionName the name of the extension requesting the path
* @returns path to the folder that the extension can safely write files to.
*/
async requestDirectory(extensionName: string): Promise<string> {
const dirPath = await getOrInsertWithAsync(this.registeredExtensions, extensionName, async () => {
const salt = (await this.dependencies.randomBytes(32)).toString("hex");
const hashedName = SHA256(`${extensionName}/${salt}`).toString();
return this.dependencies.joinPaths(this.dependencies.directoryForExtensionData, hashedName);
});
await this.dependencies.ensureDirectory(dirPath);
return dirPath;
}
@action
protected fromStore({ extensions }: FSProvisionModel = { extensions: {}}): void {
this.registeredExtensions.merge(extensions);
}
toJSON(): FSProvisionModel {
return toJS({
extensions: Object.fromEntries(this.registeredExtensions),
});
}
}
@@ -0,0 +1,6 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export * from "./extension-loader";
@@ -0,0 +1,13 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import extensionsStoreInjectable from "../../extensions-store/extensions-store.injectable";
const updateExtensionsStateInjectable = getInjectable({
id: "update-extensions-state",
instantiate: (di) => di.inject(extensionsStoreInjectable).mergeState,
});
export default updateExtensionsStateInjectable;
@@ -0,0 +1,14 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import directoryForUserDataInjectable
from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
const extensionPackagesRootInjectable = getInjectable({
id: "extension-packages-root",
instantiate: (di) => di.inject(directoryForUserDataInjectable),
});
export default extensionPackagesRootInjectable;
@@ -0,0 +1,97 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { BaseStoreParams } from "../common/base-store/base-store";
import { BaseStore } from "../common/base-store/base-store";
import * as path from "path";
import type { LensExtension } from "./lens-extension";
import assert from "assert";
import type { StaticThis } from "../common/utils";
import { getOrInsertWith } from "../common/utils";
import { getLegacyGlobalDiForExtensionApi } from "./as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api";
import directoryForUserDataInjectable from "../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import getConfigurationFileModelInjectable from "../common/get-configuration-file-model/get-configuration-file-model.injectable";
import loggerInjectable from "../common/logger.injectable";
import storeMigrationVersionInjectable from "../common/vars/store-migration-version.injectable";
import type { Migrations } from "conf/dist/source/types";
import { baseStoreIpcChannelPrefixesInjectionToken } from "../common/base-store/channel-prefix";
import { shouldBaseStoreDisableSyncInIpcListenerInjectionToken } from "../common/base-store/disable-sync";
import { persistStateToConfigInjectionToken } from "../common/base-store/save-to-file";
import getBasenameOfPathInjectable from "../common/path/get-basename.injectable";
import { enlistMessageChannelListenerInjectionToken } from "../common/utils/channel/enlist-message-channel-listener-injection-token";
export interface ExtensionStoreParams<T extends object> extends BaseStoreParams<T> {
migrations?: Migrations<T>;
}
export abstract class ExtensionStore<T extends object> extends BaseStore<T> {
private static readonly instances = new WeakMap<object, any>();
/**
* @deprecated This is a form of global shared state. Just call `new Store(...)`
*/
static createInstance<T, R extends any[]>(this: StaticThis<T, R>, ...args: R): T {
return getOrInsertWith(ExtensionStore.instances, this, () => new this(...args)) as T;
}
/**
* @deprecated This is a form of global shared state. Just call `new Store(...)`
*/
static getInstance<T, R extends any[]>(this: StaticThis<T, R>, strict?: true): T;
static getInstance<T, R extends any[]>(this: StaticThis<T, R>, strict: false): T | undefined;
static getInstance<T, R extends any[]>(this: StaticThis<T, R>, strict = true): T | undefined {
if (!ExtensionStore.instances.has(this) && strict) {
throw new TypeError(`instance of ${this.name} is not created`);
}
return ExtensionStore.instances.get(this) as (T | undefined);
}
constructor({ migrations, ...params }: ExtensionStoreParams<T>) {
const di = getLegacyGlobalDiForExtensionApi();
super({
directoryForUserData: di.inject(directoryForUserDataInjectable),
getConfigurationFileModel: di.inject(getConfigurationFileModelInjectable),
logger: di.inject(loggerInjectable),
storeMigrationVersion: di.inject(storeMigrationVersionInjectable),
migrations: migrations as Migrations<Record<string, unknown>>,
getBasenameOfPath: di.inject(getBasenameOfPathInjectable),
ipcChannelPrefixes: di.inject(baseStoreIpcChannelPrefixesInjectionToken),
persistStateToConfig: di.inject(persistStateToConfigInjectionToken),
enlistMessageChannelListener: di.inject(enlistMessageChannelListenerInjectionToken),
shouldDisableSyncInListener: di.inject(shouldBaseStoreDisableSyncInIpcListenerInjectionToken),
}, params);
}
/**
* @deprecated This is a form of global shared state. Just call `new Store(...)`
*/
static resetInstance() {
ExtensionStore.instances.delete(this);
}
protected extension?: LensExtension;
loadExtension(extension: LensExtension) {
this.extension = extension;
this.params.projectVersion ??= this.extension.version;
return super.load();
}
load() {
if (!this.extension) { return; }
return super.load();
}
protected cwd() {
assert(this.extension, "must call this.load() first");
return path.join(super.cwd(), "extension-store", this.extension.name);
}
}
@@ -0,0 +1,33 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import { baseStoreIpcChannelPrefixesInjectionToken } from "../../common/base-store/channel-prefix";
import { shouldBaseStoreDisableSyncInIpcListenerInjectionToken } from "../../common/base-store/disable-sync";
import { persistStateToConfigInjectionToken } from "../../common/base-store/save-to-file";
import getConfigurationFileModelInjectable from "../../common/get-configuration-file-model/get-configuration-file-model.injectable";
import loggerInjectable from "../../common/logger.injectable";
import getBasenameOfPathInjectable from "../../common/path/get-basename.injectable";
import { enlistMessageChannelListenerInjectionToken } from "../../common/utils/channel/enlist-message-channel-listener-injection-token";
import storeMigrationVersionInjectable from "../../common/vars/store-migration-version.injectable";
import { ExtensionsStore } from "./extensions-store";
const extensionsStoreInjectable = getInjectable({
id: "extensions-store",
instantiate: (di) => new ExtensionsStore({
directoryForUserData: di.inject(directoryForUserDataInjectable),
getConfigurationFileModel: di.inject(getConfigurationFileModelInjectable),
logger: di.inject(loggerInjectable),
storeMigrationVersion: di.inject(storeMigrationVersionInjectable),
migrations: {},
getBasenameOfPath: di.inject(getBasenameOfPathInjectable),
ipcChannelPrefixes: di.inject(baseStoreIpcChannelPrefixesInjectionToken),
persistStateToConfig: di.inject(persistStateToConfigInjectionToken),
enlistMessageChannelListener: di.inject(enlistMessageChannelListenerInjectionToken),
shouldDisableSyncInListener: di.inject(shouldBaseStoreDisableSyncInIpcListenerInjectionToken),
}),
});
export default extensionsStoreInjectable;
@@ -0,0 +1,64 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { LensExtensionId } from "../lens-extension";
import { action, computed, makeObservable, observable } from "mobx";
import { toJS } from "../../common/utils";
import type { BaseStoreDependencies } from "../../common/base-store/base-store";
import { BaseStore } from "../../common/base-store/base-store";
export interface LensExtensionsStoreModel {
extensions: Record<LensExtensionId, LensExtensionState>;
}
export interface LensExtensionState {
enabled?: boolean;
name: string;
}
export interface IsEnabledExtensionDescriptor {
id: string;
isBundled: boolean;
}
export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> {
constructor(deps: BaseStoreDependencies) {
super(deps, {
configName: "lens-extensions",
});
makeObservable(this);
this.load();
}
@computed
get enabledExtensions() {
return Array.from(this.state.values())
.filter(({ enabled }) => enabled)
.map(({ name }) => name);
}
protected state = observable.map<LensExtensionId, LensExtensionState>();
isEnabled({ id, isBundled }: IsEnabledExtensionDescriptor): boolean {
// By default false, so that copied extensions are disabled by default.
// If user installs the extension from the UI, the Extensions component will specifically enable it.
return isBundled || Boolean(this.state.get(id)?.enabled);
}
mergeState = action((extensionsState: Record<LensExtensionId, LensExtensionState> | [LensExtensionId, LensExtensionState][]) => {
this.state.merge(extensionsState);
});
@action
protected fromStore({ extensions }: LensExtensionsStoreModel) {
this.state.merge(extensions);
}
toJSON(): LensExtensionsStoreModel {
return toJS({
extensions: Object.fromEntries(this.state),
});
}
}
@@ -0,0 +1,18 @@
/**
* 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 { computed } from "mobx";
import extensionInstancesInjectable from "./extension-loader/extension-instances.injectable";
const extensionsInjectable = getInjectable({
id: "extensions",
instantiate: (di) => {
const extensionInstances = di.inject(extensionInstancesInjectable);
return computed(() => [...extensionInstances.values()].filter(extension => extension.isEnabled));
},
});
export default extensionsInjectable;
@@ -0,0 +1,58 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { ipcMain } from "electron";
import { IpcPrefix, IpcRegistrar } from "./ipc-registrar";
import { Disposers, lensExtensionDependencies } from "../lens-extension";
import type { LensMainExtension } from "../lens-main-extension";
import type { Disposer } from "../../common/utils";
import { once } from "lodash";
import { ipcMainHandle } from "../../common/ipc";
export abstract class IpcMain extends IpcRegistrar {
constructor(extension: LensMainExtension) {
super(extension);
// Call the static method on the bottom child class.
extension[Disposers].push(() => (this.constructor as typeof IpcMain).resetInstance());
}
/**
* Listen for broadcasts within your extension
* @param channel The channel to listen for broadcasts on
* @param listener The function that will be called with the arguments of the broadcast
* @returns An optional disposer, Lens will cleanup when the extension is disabled or uninstalled even if this is not called
*/
listen(channel: string, listener: (event: Electron.IpcRendererEvent, ...args: any[]) => any): Disposer {
const prefixedChannel = `extensions@${this[IpcPrefix]}:${channel}`;
const cleanup = once(() => {
this.extension[lensExtensionDependencies].logger.info(`[IPC-RENDERER]: removing extension listener`, { channel, extension: { name: this.extension.name, version: this.extension.version }});
return ipcMain.removeListener(prefixedChannel, listener);
});
this.extension[lensExtensionDependencies].logger.info(`[IPC-RENDERER]: adding extension listener`, { channel, extension: { name: this.extension.name, version: this.extension.version }});
ipcMain.addListener(prefixedChannel, listener);
this.extension[Disposers].push(cleanup);
return cleanup;
}
/**
* Declare a RPC over `channel`. Lens will cleanup when the extension is disabled or uninstalled
* @param channel The name of the RPC
* @param handler The remote procedure that is called
*/
handle(channel: string, handler: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any): void {
const prefixedChannel = `extensions@${this[IpcPrefix]}:${channel}`;
this.extension[lensExtensionDependencies].logger.info(`[IPC-RENDERER]: adding extension handler`, { channel, extension: { name: this.extension.name, version: this.extension.version }});
ipcMainHandle(prefixedChannel, handler);
this.extension[Disposers].push(() => {
this.extension[lensExtensionDependencies].logger.info(`[IPC-RENDERER]: removing extension handler`, { channel, extension: { name: this.extension.name, version: this.extension.version }});
return ipcMain.removeHandler(prefixedChannel);
});
}
}
@@ -0,0 +1,28 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { Singleton } from "../../common/utils";
import type { LensExtension } from "../lens-extension";
import { createHash } from "crypto";
import { broadcastMessage } from "../../common/ipc";
export const IpcPrefix = Symbol();
export abstract class IpcRegistrar extends Singleton {
readonly [IpcPrefix]: string;
constructor(protected readonly extension: LensExtension) {
super();
this[IpcPrefix] = createHash("sha256").update(extension.id).digest("hex");
}
/**
*
* @param channel The channel to broadcast to your whole extension, both `main` and `renderer`
* @param args The arguments passed to all listeners
*/
broadcast(channel: string, ...args: any[]): void {
broadcastMessage(`extensions@${this[IpcPrefix]}:${channel}`, ...args);
}
}
@@ -0,0 +1,56 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { ipcRenderer } from "electron";
import { IpcPrefix, IpcRegistrar } from "./ipc-registrar";
import { Disposers } from "../lens-extension";
import type { LensRendererExtension } from "../lens-renderer-extension";
import type { Disposer } from "../../common/utils";
import { once } from "lodash";
export abstract class IpcRenderer extends IpcRegistrar {
constructor(extension: LensRendererExtension) {
super(extension);
// Call the static method on the bottom child class.
extension[Disposers].push(() => (this.constructor as typeof IpcRenderer).resetInstance());
}
/**
* Listen for broadcasts within your extension.
* If the lifetime of the listener should be tied to the mounted lifetime of
* a component then putting the returned value in a `disposeOnUnmount` call will suffice.
* @param channel The channel to listen for broadcasts on
* @param listener The function that will be called with the arguments of the broadcast
* @returns An optional disposer, Lens will cleanup even if this is not called
*/
listen(channel: string, listener: (event: Electron.IpcRendererEvent, ...args: any[]) => any): Disposer {
const prefixedChannel = `extensions@${this[IpcPrefix]}:${channel}`;
const cleanup = once(() => {
console.info(`[IPC-RENDERER]: removing extension listener`, { channel, extension: { name: this.extension.name, version: this.extension.version }});
return ipcRenderer.removeListener(prefixedChannel, listener);
});
console.info(`[IPC-RENDERER]: adding extension listener`, { channel, extension: { name: this.extension.name, version: this.extension.version }});
ipcRenderer.addListener(prefixedChannel, listener);
this.extension[Disposers].push(cleanup);
return cleanup;
}
/**
* Request main to execute its function over the `channel` channel.
* This function only interacts with functions registered via `Ipc.IpcMain.handleRpc`
* An error will be thrown if no function has been registered on `main` with this channel ID.
* @param channel The channel to invoke a RPC on
* @param args The arguments to pass to the RPC
* @returns A promise of the resulting value
*/
invoke(channel: string, ...args: any[]): Promise<any> {
const prefixedChannel = `extensions@${this[IpcPrefix]}:${channel}`;
return ipcRenderer.invoke(prefixedChannel, ...args);
}
}
@@ -0,0 +1,33 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { IComputedValue } from "mobx";
import type { CatalogCategoryRegistry } from "../common/catalog";
import type { NavigateToRoute } from "../common/front-end-routing/navigate-to-route-injection-token";
import type { Route } from "../common/front-end-routing/front-end-route-injection-token";
import type { CatalogEntityRegistry as MainCatalogEntityRegistry } from "../main/catalog";
import type { CatalogEntityRegistry as RendererCatalogEntityRegistry } from "../renderer/api/catalog/entity/registry";
import type { GetExtensionPageParameters } from "../renderer/routes/get-extension-page-parameters.injectable";
import type { FileSystemProvisionerStore } from "./extension-loader/file-system-provisioner-store/file-system-provisioner-store";
import type { NavigateForExtension } from "../main/start-main-application/lens-window/navigate-for-extension.injectable";
import type { Logger } from "../common/logger";
export interface LensExtensionDependencies {
readonly fileSystemProvisionerStore: FileSystemProvisionerStore;
readonly logger: Logger;
}
export interface LensMainExtensionDependencies extends LensExtensionDependencies {
readonly entityRegistry: MainCatalogEntityRegistry;
readonly navigate: NavigateForExtension;
}
export interface LensRendererExtensionDependencies extends LensExtensionDependencies {
navigateToRoute: NavigateToRoute;
getExtensionPageParameters: GetExtensionPageParameters;
readonly routes: IComputedValue<Route<unknown>[]>;
readonly entityRegistry: RendererCatalogEntityRegistry;
readonly categoryRegistry: CatalogCategoryRegistry;
}
@@ -0,0 +1,142 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { InstalledExtension } from "./extension-discovery/extension-discovery";
import { action, computed, makeObservable, observable } from "mobx";
import type { PackageJson } from "type-fest";
import { disposer } from "../common/utils";
import type { LensExtensionDependencies } from "./lens-extension-set-dependencies";
import type { ProtocolHandlerRegistration } from "./common-api/registrations";
export type LensExtensionId = string; // path to manifest (package.json)
export type LensExtensionConstructor = new (...args: ConstructorParameters<typeof LensExtension>) => LensExtension;
export interface LensExtensionManifest extends PackageJson {
name: string;
version: string;
main?: string; // path to %ext/dist/main.js
renderer?: string; // path to %ext/dist/renderer.js
/**
* Supported Lens version engine by extension could be defined in `manifest.engines.lens`
* Only MAJOR.MINOR version is taken in consideration.
*/
engines: {
lens: string; // "semver"-package format
npm?: string;
node?: string;
};
}
export const lensExtensionDependencies = Symbol("lens-extension-dependencies");
export const Disposers = Symbol("disposers");
export class LensExtension<Dependencies extends LensExtensionDependencies = LensExtensionDependencies> {
readonly id: LensExtensionId;
readonly manifest: LensExtensionManifest;
readonly manifestPath: string;
readonly isBundled: boolean;
get sanitizedExtensionId() {
return sanitizeExtensionName(this.name);
}
protocolHandlers: ProtocolHandlerRegistration[] = [];
@observable private _isEnabled = false;
@computed get isEnabled() {
return this._isEnabled;
}
[Disposers] = disposer();
constructor({ id, manifest, manifestPath, isBundled }: InstalledExtension) {
makeObservable(this);
this.id = id;
this.manifest = manifest;
this.manifestPath = manifestPath;
this.isBundled = !!isBundled;
}
get name() {
return this.manifest.name;
}
get version() {
return this.manifest.version;
}
get description() {
return this.manifest.description;
}
readonly [lensExtensionDependencies]!: Dependencies;
/**
* getExtensionFileFolder returns the path to an already created folder. This
* folder is for the sole use of this extension.
*
* Note: there is no security done on this folder, only obfuscation of the
* folder name.
*/
async getExtensionFileFolder(): Promise<string> {
return this[lensExtensionDependencies].fileSystemProvisionerStore.requestDirectory(this.id);
}
@action
async enable() {
if (this._isEnabled) {
return;
}
this._isEnabled = true;
this[lensExtensionDependencies].logger.info(`[EXTENSION]: enabled ${this.name}@${this.version}`);
}
@action
async disable() {
if (!this._isEnabled) {
return;
}
this._isEnabled = false;
try {
await this.onDeactivate();
this[Disposers]();
this[lensExtensionDependencies].logger.info(`[EXTENSION]: disabled ${this.name}@${this.version}`);
} catch (error) {
this[lensExtensionDependencies].logger.error(`[EXTENSION]: disabling ${this.name}@${this.version} threw an error: ${error}`);
}
}
async activate(): Promise<void> {
return this.onActivate();
}
protected onActivate(): Promise<void> | void {
return;
}
protected onDeactivate(): Promise<void> | void {
return;
}
}
export function sanitizeExtensionName(name: string) {
return name.replace("@", "").replace("/", "--");
}
export function getSanitizedPath(...parts: string[]) {
return parts
.filter(Boolean)
.join("/")
.replace(/\/+/g, "/")
.replace(/\/$/, "");
} // normalize multi-slashes (e.g. coming from page.id)
export function extensionDisplayName(name: string, version: string) {
return `${name}@${version}`;
}
@@ -0,0 +1,44 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { LensExtension, lensExtensionDependencies } from "./lens-extension";
import type { CatalogEntity } from "../common/catalog";
import type { IComputedValue, IObservableArray } from "mobx";
import type { MenuRegistration } from "../features/application-menu/main/menu-registration";
import type { TrayMenuRegistration } from "../main/tray/tray-menu-registration";
import type { ShellEnvModifier } from "../main/shell-session/shell-env-modifier/shell-env-modifier-registration";
import type { LensMainExtensionDependencies } from "./lens-extension-set-dependencies";
export class LensMainExtension extends LensExtension<LensMainExtensionDependencies> {
appMenus: MenuRegistration[] | IComputedValue<MenuRegistration[]> = [];
trayMenus: TrayMenuRegistration[] | IComputedValue<TrayMenuRegistration[]> = [];
/**
* implement this to modify the shell environment that Lens terminals are opened with. The ShellEnvModifier type has the signature
*
* (ctx: ShellEnvContext, env: Record<string, string | undefined>) => Record<string, string | undefined>
*
* @param ctx the shell environment context, specifically the relevant catalog entity for the terminal. This can be used, for example, to get
* cluster-specific information that can be made available in the shell environment by the implementation of terminalShellEnvModifier
*
* @param env the current shell environment that the terminal will be opened with. The implementation should modify this as desired.
*
* @returns the modified shell environment that the terminal will be opened with. The implementation must return env as passed in, if it
* does not modify the shell environment
*/
terminalShellEnvModifier?: ShellEnvModifier;
async navigate(pageId?: string, params?: Record<string, any>, frameId?: number) {
await this[lensExtensionDependencies].navigate(this.id, pageId, params, frameId);
}
addCatalogSource(id: string, source: IObservableArray<CatalogEntity>) {
this[lensExtensionDependencies].entityRegistry.addObservableSource(`${this.name}:${id}`, source);
}
removeCatalogSource(id: string) {
this[lensExtensionDependencies].entityRegistry.removeSource(`${this.name}:${id}`);
}
}
@@ -0,0 +1,128 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { Disposers, LensExtension, lensExtensionDependencies } from "./lens-extension";
import type { CatalogEntity, CategoryFilter } from "../common/catalog";
import type { Disposer } from "../common/utils";
import type { EntityFilter } from "../renderer/api/catalog/entity/registry";
import type { TopBarRegistration } from "../renderer/components/layout/top-bar/top-bar-registration";
import type { KubernetesCluster } from "../common/catalog-entities";
import type { WelcomeMenuRegistration } from "../renderer/components/+welcome/welcome-menu-items/welcome-menu-registration";
import type { WelcomeBannerRegistration } from "../renderer/components/+welcome/welcome-banner-items/welcome-banner-registration";
import type { CommandRegistration } from "../renderer/components/command-palette/registered-commands/commands";
import type { AppPreferenceRegistration } from "../features/preferences/renderer/compliance-for-legacy-extension-api/app-preference-registration";
import type { AdditionalCategoryColumnRegistration } from "../renderer/components/+catalog/custom-category-columns";
import type { CustomCategoryViewRegistration } from "../renderer/components/+catalog/custom-views";
import type { StatusBarRegistration } from "../renderer/components/status-bar/status-bar-registration";
import type { KubeObjectMenuRegistration } from "../renderer/components/kube-object-menu/kube-object-menu-registration";
import type { WorkloadsOverviewDetailRegistration } from "../renderer/components/+workloads-overview/workloads-overview-detail-registration";
import type { KubeObjectStatusRegistration } from "../renderer/components/kube-object-status-icon/kube-object-status-registration";
import { fromPairs, map, matches, toPairs } from "lodash/fp";
import { pipeline } from "@ogre-tools/fp";
import { getExtensionRoutePath } from "../renderer/routes/for-extension";
import type { LensRendererExtensionDependencies } from "./lens-extension-set-dependencies";
import type { KubeObjectHandlerRegistration } from "../renderer/kube-object/handler";
import type { AppPreferenceTabRegistration } from "../features/preferences/renderer/compliance-for-legacy-extension-api/app-preference-tab-registration";
import type { KubeObjectDetailRegistration } from "../renderer/components/kube-object-details/kube-object-detail-registration";
import type { ClusterFrameChildComponent } from "../renderer/frames/cluster-frame/cluster-frame-child-component-injection-token";
import type { EntitySettingRegistration } from "../renderer/components/+entity-settings/extension-registrator.injectable";
import type { CatalogEntityDetailRegistration } from "../renderer/components/+catalog/entity-details/token";
import type { ClusterPageMenuRegistration, PageRegistration } from "./common-api/registrations";
export class LensRendererExtension extends LensExtension<LensRendererExtensionDependencies> {
globalPages: PageRegistration[] = [];
clusterPages: PageRegistration[] = [];
clusterPageMenus: ClusterPageMenuRegistration[] = [];
clusterFrameComponents: ClusterFrameChildComponent[] = [];
kubeObjectStatusTexts: KubeObjectStatusRegistration[] = [];
appPreferences: AppPreferenceRegistration[] = [];
appPreferenceTabs: AppPreferenceTabRegistration[] = [];
entitySettings: EntitySettingRegistration[] = [];
statusBarItems: StatusBarRegistration[] = [];
kubeObjectDetailItems: KubeObjectDetailRegistration[] = [];
kubeObjectMenuItems: KubeObjectMenuRegistration[] = [];
kubeWorkloadsOverviewItems: WorkloadsOverviewDetailRegistration[] = [];
commands: CommandRegistration[] = [];
welcomeMenus: WelcomeMenuRegistration[] = [];
welcomeBanners: WelcomeBannerRegistration[] = [];
catalogEntityDetailItems: CatalogEntityDetailRegistration<CatalogEntity>[] = [];
topBarItems: TopBarRegistration[] = [];
additionalCategoryColumns: AdditionalCategoryColumnRegistration[] = [];
customCategoryViews: CustomCategoryViewRegistration[] = [];
kubeObjectHandlers: KubeObjectHandlerRegistration[] = [];
async navigate(pageId?: string, params: object = {}) {
const routes = this[lensExtensionDependencies].routes.get();
const targetRegistration = [...this.globalPages, ...this.clusterPages]
.find(registration => registration.id === (pageId || undefined));
if (!targetRegistration) {
return;
}
const targetRoutePath = getExtensionRoutePath(this, targetRegistration.id);
const targetRoute = routes.find(matches({ path: targetRoutePath }));
if (!targetRoute) {
return;
}
const normalizedParams = this[lensExtensionDependencies].getExtensionPageParameters({
extension: this,
registration: targetRegistration,
});
const query = pipeline(
params,
toPairs,
map(([key, value]) => [
key,
normalizedParams[key].stringify(value),
]),
fromPairs,
);
this[lensExtensionDependencies].navigateToRoute(targetRoute, {
query,
});
}
/**
* Defines if extension is enabled for a given cluster. This method is only
* called when the extension is created within a cluster frame.
*
* The default implementation is to return `true`
*
* @deprecated Switch to using "enabled" or "visible" properties in each registration together with `activeCluster`
*/
async isEnabledForCluster(cluster: KubernetesCluster): Promise<Boolean> {
return (void cluster) || true;
}
/**
* Add a filtering function for the catalog entities. This will be removed if the extension is disabled.
* @param fn The function which should return a truthy value for those entities which should be kept.
* @returns A function to clean up the filter
*/
addCatalogFilter(fn: EntityFilter): Disposer {
const dispose = this[lensExtensionDependencies].entityRegistry.addCatalogFilter(fn);
this[Disposers].push(dispose);
return dispose;
}
/**
* Add a filtering function for the catalog categories. This will be removed if the extension is disabled.
* @param fn The function which should return a truthy value for those categories which should be kept.
* @returns A function to clean up the filter
*/
addCatalogCategoryFilter(fn: CategoryFilter): Disposer {
const dispose = this[lensExtensionDependencies].categoryRegistry.addCatalogCategoryFilter(fn);
this[Disposers].push(dispose);
return dispose;
}
}
@@ -0,0 +1,26 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { CatalogEntity } from "../../common/catalog";
import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api";
import catalogCategoryRegistryInjectable from "../../common/catalog/category-registry.injectable";
import catalogEntityRegistryInjectable from "../../main/catalog/entity-registry.injectable";
export const catalogCategories = asLegacyGlobalForExtensionApi(catalogCategoryRegistryInjectable);
const catalogEntityRegistry = asLegacyGlobalForExtensionApi(catalogEntityRegistryInjectable);
export interface CatalogEntityRegistry {
getItemsForApiKind(apiVersion: string, kind: string): CatalogEntity[];
/**
* @deprecated use a cast instead of a unbounded type parameter
*/
getItemsForApiKind<T extends CatalogEntity>(apiVersion: string, kind: string): T[];
}
export const catalogEntities: CatalogEntityRegistry = {
getItemsForApiKind(apiVersion: string, kind: string) {
return catalogEntityRegistry.filterItemsForApiKind(apiVersion, kind);
},
};
@@ -0,0 +1,20 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import * as Catalog from "./catalog";
import * as Navigation from "./navigation";
import * as K8sApi from "./k8s-api";
import * as Power from "./power";
import { IpcMain as Ipc } from "../ipc/ipc-main";
import { LensMainExtension as LensExtension } from "../lens-main-extension";
export {
Catalog,
Navigation,
K8sApi,
Ipc,
LensExtension,
Power,
};
@@ -0,0 +1,14 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
/**
* @deprecated This function never works
* @returns false
*/
export function isAllowedResource(...args: any[]) {
return Boolean(void args);
}
export * from "../common-api/k8s-api";
@@ -0,0 +1,19 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import {
Environments,
getEnvironmentSpecificLegacyGlobalDiForExtensionApi,
} from "../as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api";
import navigateInjectable from "../../main/start-main-application/lens-window/navigate.injectable";
export function navigate(url: string) {
const di = getEnvironmentSpecificLegacyGlobalDiForExtensionApi(Environments.main);
const navigate = di.inject(navigateInjectable);
return navigate(url);
}
@@ -0,0 +1,51 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { powerMonitor } from "electron";
import type { Disposer } from "../../common/utils/disposer";
/**
* Event listener for system power events
*/
export type PowerEventListener = () => void;
/**
* Adds event listener to system suspend events
* @param listener function which will be called on system suspend
* @returns function to remove event listener
*/
export const onSuspend = (listener: PowerEventListener): Disposer => {
powerMonitor.on("suspend", listener);
return () => {
powerMonitor.off("suspend", listener);
};
};
/**
* Adds event listener to system resume event
* @param listener function which will be called on system resume
* @returns function to remove event listener
*/
export const onResume = (listener: PowerEventListener): Disposer => {
powerMonitor.on("resume", listener);
return () => {
powerMonitor.off("resume", listener);
};
};
/**
* Adds event listener to the event which is emitted when
* the system is about to reboot or shut down
* @param listener function which will be called on system shutdown
* @returns function to remove event listener
*/
export const onShutdown = (listener: PowerEventListener): Disposer => {
powerMonitor.on("shutdown", listener);
return () => {
powerMonitor.off("shutdown", listener);
};
};
@@ -0,0 +1,17 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { IComputedValue } from "mobx";
import extensionsInjectable from "./extensions.injectable";
import type { LensMainExtension } from "./lens-main-extension";
const mainExtensionsInjectable = getInjectable({
id: "main-extensions",
instantiate: (di) =>
di.inject(extensionsInjectable) as IComputedValue<LensMainExtension[]>,
});
export default mainExtensionsInjectable;
@@ -0,0 +1,60 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { CatalogCategory, CatalogEntity } from "../../common/catalog";
import type { CatalogEntityOnBeforeRun } from "../../renderer/api/catalog/entity/registry";
import type { Disposer } from "../../common/utils";
import catalogCategoryRegistryInjectable from "../../common/catalog/category-registry.injectable";
import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api";
import catalogEntityRegistryInjectable from "../../renderer/api/catalog/entity/registry.injectable";
import activeKubernetesClusterInjectable from "../../renderer/cluster-frame-context/active-kubernetes-cluster.injectable";
export const catalogCategories = asLegacyGlobalForExtensionApi(catalogCategoryRegistryInjectable);
const internalEntityRegistry = asLegacyGlobalForExtensionApi(catalogEntityRegistryInjectable);
export class CatalogEntityRegistry {
/**
* Currently active/visible entity
*/
get activeEntity() {
return internalEntityRegistry.activeEntity;
}
get entities(): Map<string, CatalogEntity> {
return internalEntityRegistry.entities;
}
getById(id: string) {
return this.entities.get(id);
}
getItemsForApiKind<T extends CatalogEntity>(apiVersion: string, kind: string): T[] {
return internalEntityRegistry.getItemsForApiKind<T>(apiVersion, kind);
}
getItemsForCategory<T extends CatalogEntity>(category: CatalogCategory): T[] {
return internalEntityRegistry.getItemsForCategory(category);
}
/**
* Add a onBeforeRun hook to a catalog entities. If `onBeforeRun` was previously
* added then it will not be added again.
* @param onBeforeRun The function to be called with a `CatalogRunEvent`
* event target will be the catalog entity. onBeforeRun hook can call event.preventDefault()
* to stop run sequence
* @returns A function to remove that hook
*/
addOnBeforeRun(onBeforeRun: CatalogEntityOnBeforeRun): Disposer {
return internalEntityRegistry.addOnBeforeRun(onBeforeRun);
}
}
export const catalogEntities = new CatalogEntityRegistry();
export const activeCluster = asLegacyGlobalForExtensionApi(
activeKubernetesClusterInjectable,
);
@@ -0,0 +1,173 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api";
import createTerminalTabInjectable from "../../renderer/components/dock/terminal/create-terminal-tab.injectable";
import terminalStoreInjectable from "../../renderer/components/dock/terminal/store.injectable";
import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api";
import logTabStoreInjectable from "../../renderer/components/dock/logs/tab-store.injectable";
import commandOverlayInjectable from "../../renderer/components/command-palette/command-overlay.injectable";
import createPodLogsTabInjectable from "../../renderer/components/dock/logs/create-pod-logs-tab.injectable";
import createWorkloadLogsTabInjectable from "../../renderer/components/dock/logs/create-workload-logs-tab.injectable";
import sendCommandInjectable from "../../renderer/components/dock/terminal/send-command.injectable";
import renameTabInjectable from "../../renderer/components/dock/dock/rename-tab.injectable";
import { asLegacyGlobalObjectForExtensionApiWithModifications } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api-with-modifications";
import { ConfirmDialog as _ConfirmDialog } from "../../renderer/components/confirm-dialog";
import type { ConfirmDialogBooleanParams, ConfirmDialogParams, ConfirmDialogProps } from "../../renderer/components/confirm-dialog";
import openConfirmDialogInjectable from "../../renderer/components/confirm-dialog/open.injectable";
import confirmInjectable from "../../renderer/components/confirm-dialog/confirm.injectable";
import notificationsStoreInjectable from "../../renderer/components/notifications/notifications-store.injectable";
import podStoreInjectable from "../../renderer/components/+workloads-pods/store.injectable";
import getDetailsUrlInjectable from "../../renderer/components/kube-detail-params/get-details-url.injectable";
import showDetailsInjectable from "../../renderer/components/kube-detail-params/show-details.injectable";
import showCheckedErrorNotificationInjectable from "../../renderer/components/notifications/show-checked-error.injectable";
import showErrorNotificationInjectable from "../../renderer/components/notifications/show-error-notification.injectable";
import showInfoNotificationInjectable from "../../renderer/components/notifications/show-info-notification.injectable";
import showShortInfoNotificationInjectable from "../../renderer/components/notifications/show-short-info.injectable";
import showSuccessNotificationInjectable from "../../renderer/components/notifications/show-success-notification.injectable";
// layouts
export * from "../../renderer/components/layout/main-layout";
export * from "../../renderer/components/layout/setting-layout";
export * from "../../renderer/components/layout/page-layout";
export * from "../../renderer/components/layout/wizard-layout";
export * from "../../renderer/components/layout/tab-layout";
// form-controls
export * from "../../renderer/components/button";
export * from "../../renderer/components/checkbox";
export * from "../../renderer/components/radio";
export * from "../../renderer/components/select";
export * from "../../renderer/components/slider";
export * from "../../renderer/components/switch";
export * from "../../renderer/components/input/input";
// command-overlay
export const CommandOverlay = asLegacyGlobalForExtensionApi(commandOverlayInjectable);
export type {
CategoryColumnRegistration,
AdditionalCategoryColumnRegistration,
} from "../../renderer/components/+catalog/custom-category-columns";
// other components
export type {
ConfirmDialogBooleanParams,
ConfirmDialogParams,
ConfirmDialogProps,
};
export const ConfirmDialog = Object.assign(_ConfirmDialog, {
open: asLegacyGlobalFunctionForExtensionApi(openConfirmDialogInjectable),
confirm: asLegacyGlobalFunctionForExtensionApi(confirmInjectable),
});
export * from "../../renderer/components/icon";
export * from "../../renderer/components/tooltip";
export * from "../../renderer/components/tabs";
export * from "../../renderer/components/table";
export * from "../../renderer/components/badge";
export * from "../../renderer/components/drawer";
export * from "../../renderer/components/dialog";
export * from "../../renderer/components/line-progress";
export * from "../../renderer/components/menu";
export {
NotificationStatus,
type CreateNotificationOptions,
type Notification,
type NotificationId,
type NotificationMessage,
type ShowNotification,
type NotificationsStore,
} from "../../renderer/components/notifications";
export const Notifications = {
ok: asLegacyGlobalFunctionForExtensionApi(showSuccessNotificationInjectable),
error: asLegacyGlobalFunctionForExtensionApi(showErrorNotificationInjectable),
checkedError: asLegacyGlobalFunctionForExtensionApi(showCheckedErrorNotificationInjectable),
info: asLegacyGlobalFunctionForExtensionApi(showInfoNotificationInjectable),
shortInfo: asLegacyGlobalFunctionForExtensionApi(showShortInfoNotificationInjectable),
};
export * from "../../renderer/components/spinner";
export * from "../../renderer/components/stepper";
export * from "../../renderer/components/wizard";
export * from "../../renderer/components/+workloads-pods/pod-details-list";
export * from "../../renderer/components/+namespaces/namespace-select";
export * from "../../renderer/components/+namespaces/namespace-select-filter";
export * from "../../renderer/components/layout/sub-title";
export * from "../../renderer/components/input/search-input";
export * from "../../renderer/components/chart/bar-chart";
export * from "../../renderer/components/chart/pie-chart";
export {
MonacoEditor,
type MonacoEditorProps, type MonacoEditorId,
type MonacoTheme, type MonacoCustomTheme,
} from "../../renderer/components/monaco-editor";
/**
* @deprecated Use `Renderer.Navigation.getDetailsUrl`
*/
export const getDetailsUrl = asLegacyGlobalFunctionForExtensionApi(getDetailsUrlInjectable);
/**
* @deprecated Use `Renderer.Navigation.showDetails`
*/
export const showDetails = asLegacyGlobalFunctionForExtensionApi(showDetailsInjectable);
// kube helpers
export * from "../../renderer/components/kube-object-details";
export * from "../../renderer/components/kube-object-list-layout";
export * from "../../renderer/components/kube-object-menu";
export * from "../../renderer/components/kube-object-meta";
export * from "../../renderer/components/+events/kube-event-details";
// specific exports
export * from "../../renderer/components/status-brick";
export const createTerminalTab = asLegacyGlobalFunctionForExtensionApi(createTerminalTabInjectable);
export const terminalStore = asLegacyGlobalObjectForExtensionApiWithModifications(
terminalStoreInjectable,
{
sendCommand: asLegacyGlobalFunctionForExtensionApi(sendCommandInjectable),
},
);
const renameTab = asLegacyGlobalFunctionForExtensionApi(renameTabInjectable);
const podStore = asLegacyGlobalForExtensionApi(podStoreInjectable);
export const logTabStore = asLegacyGlobalObjectForExtensionApiWithModifications(
logTabStoreInjectable,
{
createPodTab: asLegacyGlobalFunctionForExtensionApi(createPodLogsTabInjectable),
createWorkloadTab: asLegacyGlobalFunctionForExtensionApi(createWorkloadLogsTabInjectable),
renameTab: (tabId: string): void => {
const { selectedPodId } = logTabStore.getData(tabId) ?? {};
const pod = selectedPodId && podStore.getById(selectedPodId);
if (pod) {
renameTab(tabId, `Pod ${pod.getName()}`);
}
},
tabs: undefined,
},
);
export class TerminalStore {
static getInstance() {
return terminalStore;
}
static createInstance() {
return terminalStore;
}
static resetInstance() {
console.warn("TerminalStore.resetInstance() does nothing");
}
}
export const notificationsStore = asLegacyGlobalForExtensionApi(notificationsStoreInjectable);
@@ -0,0 +1,25 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
// Lens-extensions apis, required in renderer process runtime
// APIs
import * as Catalog from "./catalog";
import * as Component from "./components";
import * as K8sApi from "./k8s-api";
import * as Navigation from "./navigation";
import * as Theme from "./theming";
import { IpcRenderer as Ipc } from "../ipc/ipc-renderer";
import { LensRendererExtension as LensExtension } from "../lens-renderer-extension";
export {
Catalog,
Component,
K8sApi,
Navigation,
Theme,
Ipc,
LensExtension,
};
@@ -0,0 +1,129 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { KubeResource } from "../../common/rbac";
import { apiResourceRecord } from "../../common/rbac";
import { getLegacyGlobalDiForExtensionApi } from "../as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api";
import clusterRoleBindingApiInjectable from "../../common/k8s-api/endpoints/cluster-role-binding.api.injectable";
import clusterRoleApiInjectable from "../../common/k8s-api/endpoints/cluster-role.api.injectable";
import serviceAccountApiInjectable from "../../common/k8s-api/endpoints/service-account.api.injectable";
import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api";
import roleApiInjectable from "../../common/k8s-api/endpoints/role.api.injectable";
import podApiInjectable from "../../common/k8s-api/endpoints/pod.api.injectable";
import daemonSetApiInjectable from "../../common/k8s-api/endpoints/daemon-set.api.injectable";
import replicaSetApiInjectable from "../../common/k8s-api/endpoints/replica-set.api.injectable";
import statefulSetApiInjectable from "../../common/k8s-api/endpoints/stateful-set.api.injectable";
import deploymentApiInjectable from "../../common/k8s-api/endpoints/deployment.api.injectable";
import jobApiInjectable from "../../common/k8s-api/endpoints/job.api.injectable";
import cronJobApiInjectable from "../../common/k8s-api/endpoints/cron-job.api.injectable";
import nodeApiInjectable from "../../common/k8s-api/endpoints/node.api.injectable";
import configMapApiInjectable from "../../common/k8s-api/endpoints/config-map.api.injectable";
import secretApiInjectable from "../../common/k8s-api/endpoints/secret.api.injectable";
import resourceQuotaApiInjectable from "../../common/k8s-api/endpoints/resource-quota.api.injectable";
import limitRangeApiInjectable from "../../common/k8s-api/endpoints/limit-range.api.injectable";
import horizontalPodAutoscalerApiInjectable from "../../common/k8s-api/endpoints/horizontal-pod-autoscaler.api.injectable";
import podDisruptionBudgetApiInjectable from "../../common/k8s-api/endpoints/pod-disruption-budget.api.injectable";
import priorityClassStoreApiInjectable from "../../common/k8s-api/endpoints/priority-class.api.injectable";
import serviceApiInjectable from "../../common/k8s-api/endpoints/service.api.injectable";
import endpointsApiInjectable from "../../common/k8s-api/endpoints/endpoint.api.injectable";
import ingressApiInjectable from "../../common/k8s-api/endpoints/ingress.api.injectable";
import networkPolicyApiInjectable from "../../common/k8s-api/endpoints/network-policy.api.injectable";
import persistentVolumeApiInjectable from "../../common/k8s-api/endpoints/persistent-volume.api.injectable";
import persistentVolumeClaimApiInjectable from "../../common/k8s-api/endpoints/persistent-volume-claim.api.injectable";
import storageClassApiInjectable from "../../common/k8s-api/endpoints/storage-class.api.injectable";
import namespaceApiInjectable from "../../common/k8s-api/endpoints/namespace.api.injectable";
import kubeEventApiInjectable from "../../common/k8s-api/endpoints/events.api.injectable";
import roleBindingApiInjectable from "../../common/k8s-api/endpoints/role-binding.api.injectable";
import customResourceDefinitionApiInjectable from "../../common/k8s-api/endpoints/custom-resource-definition.api.injectable";
import { shouldShowResourceInjectionToken } from "../../common/cluster-store/allowed-resources-injection-token";
export function isAllowedResource(resources: KubeResource | KubeResource[]) {
const di = getLegacyGlobalDiForExtensionApi();
return [resources].flat().every((resourceName) => {
const resource = apiResourceRecord[resourceName];
if (!resource) {
return true;
}
const _isAllowedResource = di.inject(shouldShowResourceInjectionToken, {
apiName: resourceName,
group: resource.group,
});
// Note: Legacy isAllowedResource does not advertise reactivity
return _isAllowedResource.get();
});
}
export const serviceAccountsApi = asLegacyGlobalForExtensionApi(serviceAccountApiInjectable);
export const clusterRoleApi = asLegacyGlobalForExtensionApi(clusterRoleApiInjectable);
export const clusterRoleBindingApi = asLegacyGlobalForExtensionApi(clusterRoleBindingApiInjectable);
export const roleApi = asLegacyGlobalForExtensionApi(roleApiInjectable);
export const podsApi = asLegacyGlobalForExtensionApi(podApiInjectable);
export const daemonSetApi = asLegacyGlobalForExtensionApi(daemonSetApiInjectable);
export const replicaSetApi = asLegacyGlobalForExtensionApi(replicaSetApiInjectable);
export const statefulSetApi = asLegacyGlobalForExtensionApi(statefulSetApiInjectable);
export const deploymentApi = asLegacyGlobalForExtensionApi(deploymentApiInjectable);
export const jobApi = asLegacyGlobalForExtensionApi(jobApiInjectable);
export const cronJobApi = asLegacyGlobalForExtensionApi(cronJobApiInjectable);
export const nodesApi = asLegacyGlobalForExtensionApi(nodeApiInjectable);
export const secretsApi = asLegacyGlobalForExtensionApi(secretApiInjectable);
export const configMapApi = asLegacyGlobalForExtensionApi(configMapApiInjectable);
export const resourceQuotaApi = asLegacyGlobalForExtensionApi(resourceQuotaApiInjectable);
export const limitRangeApi = asLegacyGlobalForExtensionApi(limitRangeApiInjectable);
export const serviceApi = asLegacyGlobalForExtensionApi(serviceApiInjectable);
export const hpaApi = asLegacyGlobalForExtensionApi(horizontalPodAutoscalerApiInjectable);
export const pdbApi = asLegacyGlobalForExtensionApi(podDisruptionBudgetApiInjectable);
export const pcApi = asLegacyGlobalForExtensionApi(priorityClassStoreApiInjectable);
export const endpointApi = asLegacyGlobalForExtensionApi(endpointsApiInjectable);
export const ingressApi = asLegacyGlobalForExtensionApi(ingressApiInjectable);
export const networkPolicyApi = asLegacyGlobalForExtensionApi(networkPolicyApiInjectable);
export const persistentVolumeApi = asLegacyGlobalForExtensionApi(persistentVolumeApiInjectable);
export const pvcApi = asLegacyGlobalForExtensionApi(persistentVolumeClaimApiInjectable);
export const storageClassApi = asLegacyGlobalForExtensionApi(storageClassApiInjectable);
export const namespacesApi = asLegacyGlobalForExtensionApi(namespaceApiInjectable);
export const eventApi = asLegacyGlobalForExtensionApi(kubeEventApiInjectable);
export const roleBindingApi = asLegacyGlobalForExtensionApi(roleBindingApiInjectable);
export const crdApi = asLegacyGlobalForExtensionApi(customResourceDefinitionApiInjectable);
export * from "../common-api/k8s-api";
export {
KubeObjectStatusLevel,
type KubeObjectStatus,
} from "../../common/k8s-api/kube-object-status";
// stores
export type { EventStore } from "../../renderer/components/+events/store";
export type { PodStore as PodsStore } from "../../renderer/components/+workloads-pods/store";
export type { NodeStore as NodesStore } from "../../renderer/components/+nodes/store";
export type { DeploymentStore } from "../../renderer/components/+workloads-deployments/store";
export type { DaemonSetStore } from "../../renderer/components/+workloads-daemonsets/store";
export type { StatefulSetStore } from "../../renderer/components/+workloads-statefulsets/store";
export type { JobStore } from "../../renderer/components/+workloads-jobs/store";
export type { CronJobStore } from "../../renderer/components/+workloads-cronjobs/store";
export type { ConfigMapStore as ConfigMapsStore } from "../../renderer/components/+config-maps/store";
export type { SecretStore as SecretsStore } from "../../renderer/components/+config-secrets/store";
export type { ReplicaSetStore } from "../../renderer/components/+workloads-replicasets/store";
export type { ResourceQuotaStore as ResourceQuotasStore } from "../../renderer/components/+config-resource-quotas/store";
export type { LimitRangeStore as LimitRangesStore } from "../../renderer/components/+config-limit-ranges/store";
export type { HorizontalPodAutoscalerStore as HPAStore } from "../../renderer/components/+config-autoscalers/store";
export type { PodDisruptionBudgetStore as PodDisruptionBudgetsStore } from "../../renderer/components/+config-pod-disruption-budgets/store";
export type { PriorityClassStore as PriorityClassStoreStore } from "../../renderer/components/+config-priority-classes/store";
export type { ServiceStore } from "../../renderer/components/+network-services/store";
export type { EndpointsStore as EndpointStore } from "../../renderer/components/+network-endpoints/store";
export type { IngressStore } from "../../renderer/components/+network-ingresses/ingress-store";
export type { IngressClassStore } from "../../renderer/components/+network-ingresses/ingress-class-store";
export type { NetworkPolicyStore } from "../../renderer/components/+network-policies/store";
export type { PersistentVolumeStore as PersistentVolumesStore } from "../../renderer/components/+storage-volumes/store";
export type { PersistentVolumeClaimStore as VolumeClaimStore } from "../../renderer/components/+storage-volume-claims/store";
export type { StorageClassStore } from "../../renderer/components/+storage-classes/store";
export type { NamespaceStore } from "../../renderer/components/+namespaces/store";
export type { ServiceAccountStore as ServiceAccountsStore } from "../../renderer/components/+user-management/+service-accounts/store";
export type { RoleStore as RolesStore } from "../../renderer/components/+user-management/+roles/store";
export type { RoleBindingStore as RoleBindingsStore } from "../../renderer/components/+user-management/+role-bindings/store";
export type { CustomResourceDefinitionStore as CRDStore } from "../../renderer/components/+custom-resources/definition.store";
export type { CustomResourceStore as CRDResourceStore } from "../../common/k8s-api/api-manager/resource.store";
@@ -0,0 +1,22 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import getDetailsUrlInjectable from "../../renderer/components/kube-detail-params/get-details-url.injectable";
import hideDetailsInjectable from "../../renderer/components/kube-detail-params/hide-details.injectable";
import showDetailsInjectable from "../../renderer/components/kube-detail-params/show-details.injectable";
import createPageParamInjectable from "../../renderer/navigation/create-page-param.injectable";
import isActiveRouteInjectable from "../../renderer/navigation/is-route-active.injectable";
import navigateInjectable from "../../renderer/navigation/navigate.injectable";
import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api";
export type { PageParamInit, PageParam } from "../../renderer/navigation/page-param";
export type { URLParams } from "../../common/utils/buildUrl";
export const getDetailsUrl = asLegacyGlobalFunctionForExtensionApi(getDetailsUrlInjectable);
export const showDetails = asLegacyGlobalFunctionForExtensionApi(showDetailsInjectable);
export const hideDetails = asLegacyGlobalFunctionForExtensionApi(hideDetailsInjectable);
export const createPageParam = asLegacyGlobalFunctionForExtensionApi(createPageParamInjectable);
export const isActiveRoute = asLegacyGlobalFunctionForExtensionApi(isActiveRouteInjectable);
export const navigate = asLegacyGlobalFunctionForExtensionApi(navigateInjectable);
@@ -0,0 +1,19 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import activeThemeInjectable from "../../renderer/themes/active.injectable";
import type { LensTheme } from "../../renderer/themes/lens-theme";
import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api";
export const activeTheme = asLegacyGlobalForExtensionApi(activeThemeInjectable);
/**
* @deprecated This hides the reactivity of active theme, use {@link activeTheme} instead
*/
export function getActiveTheme() {
return activeTheme.get();
}
export type { LensTheme };
@@ -0,0 +1,15 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { IComputedValue } from "mobx";
import extensionsInjectable from "./extensions.injectable";
import type { LensRendererExtension } from "./lens-renderer-extension";
const rendererExtensionsInjectable = getInjectable({
id: "renderer-extensions",
instantiate: (di) => di.inject(extensionsInjectable) as IComputedValue<LensRendererExtension[]>,
});
export default rendererExtensionsInjectable;