Stop using global shared state for ExtensionDiscovery and it's relatives

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>
Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
This commit is contained in:
Janne SavolainenandMikko Aspiala committed 2021-12-28 11:51:34 +02:00
1 parent 41aacb3db2
commit a9189f82e6
55 files changed
+1251 -587

No files matched your search

@@ -21,11 +21,15 @@
import type { ExtensionLoader } from "../extension-loader";
import { ipcRenderer } from "electron";
import { ExtensionsStore } from "../extensions-store";
import type {
ExtensionsStore,
} from "../extensions-store/extensions-store";
import { Console } from "console";
import { stdout, stderr } from "process";
import { getDiForUnitTesting } from "../getDiForUnitTesting";
import extensionLoaderInjectable from "../extension-loader/extension-loader.injectable";
import extensionsStoreInjectable from "../extensions-store/extensions-store.injectable";
import { AppPaths } from "../../common/app-paths";
console = new Console(stdout, stderr);
@@ -33,15 +37,6 @@ const manifestPath = "manifest/path";
const manifestPath2 = "manifest/path2";
const manifestPath3 = "manifest/path3";
jest.mock("../extensions-store", () => ({
ExtensionsStore: {
getInstance: () => ({
whenLoaded: Promise.resolve(true),
mergeState: jest.fn(),
}),
},
}));
jest.mock(
"electron",
() => ({
@@ -129,13 +124,25 @@ jest.mock(
},
);
AppPaths.init();
describe("ExtensionLoader", () => {
let extensionLoader: ExtensionLoader;
let extensionsStoreStub: ExtensionsStore;
beforeEach(() => {
const di = getDiForUnitTesting();
extensionLoader = di.inject(extensionLoaderInjectable);
// TODO: Find out how to either easily create mocks of interfaces with a lot of members or
// introduce design for more minimal interfaces
// @ts-ignore
extensionsStoreStub = {
mergeState: jest.fn(),
};
di.override(extensionsStoreInjectable, () => extensionsStoreStub);
});
it.only("renderer updates extension after ipc broadcast", async done => {
@@ -177,18 +184,18 @@ describe("ExtensionLoader", () => {
});
it("updates ExtensionsStore after isEnabled is changed", async () => {
(ExtensionsStore.getInstance().mergeState as any).mockClear();
(extensionsStoreStub.mergeState as any).mockClear();
// Disable sending events in this test
(ipcRenderer.on as any).mockImplementation();
await extensionLoader.init();
expect(ExtensionsStore.getInstance().mergeState).not.toHaveBeenCalled();
expect(extensionsStoreStub.mergeState).not.toHaveBeenCalled();
Array.from(extensionLoader.userExtensions.values())[0].isEnabled = false;
expect(ExtensionsStore.getInstance().mergeState).toHaveBeenCalledWith({
expect(extensionsStoreStub.mergeState).toHaveBeenCalledWith({
"manifest/path": {
enabled: false,
name: "TestExtension",
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import type { Injectable } from "@ogre-tools/injectable";
import { getLegacyGlobalDiForExtensionApi } from "./legacy-global-di-for-extension-api";
type TentativeTuple<T> = T extends object ? [T] : [undefined?];
type FactoryType = <
TInjectable extends Injectable<unknown, TInstance, TInstantiationParameter>,
TInstantiationParameter,
TInstance extends (...args: unknown[]) => any,
TFunction extends (...args: unknown[]) => any = Awaited<
ReturnType<TInjectable["instantiate"]>
>,
>(
injectableKey: TInjectable,
...instantiationParameter: TentativeTuple<TInstantiationParameter>
) => (...args: Parameters<TFunction>) => ReturnType<TFunction>;
export const asLegacyGlobalFunctionForExtensionApi: FactoryType =
(injectableKey, ...instantiationParameter) =>
(...args) => {
const injected = getLegacyGlobalDiForExtensionApi().inject(
injectableKey,
...instantiationParameter,
);
return injected(...args);
};
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import type { DependencyInjectionContainer } from "@ogre-tools/injectable";
let legacyGlobalDi: DependencyInjectionContainer;
export const setLegacyGlobalDiForExtensionApi = (di: DependencyInjectionContainer) => {
legacyGlobalDi = di;
};
export const getLegacyGlobalDiForExtensionApi = () => legacyGlobalDi;
+3 -4
View File
@@ -20,14 +20,13 @@
*/
import { getAppVersion } from "../../common/utils";
import { ExtensionsStore } from "../extensions-store";
import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-global-function-for-extension-api/as-legacy-global-function-for-extension-api";
import getEnabledExtensionsInjectable from "./get-enabled-extensions/get-enabled-extensions.injectable";
import * as Preferences from "./user-preferences";
export const version = getAppVersion();
export { isSnap, isWindows, isMac, isLinux, appName, slackUrl, issuesTrackerUrl } from "../../common/vars";
export function getEnabledExtensions(): string[] {
return ExtensionsStore.getInstance().enabledExtensions;
}
export const getEnabledExtensions = asLegacyGlobalFunctionForExtensionApi(getEnabledExtensionsInjectable);
export { Preferences };
@@ -0,0 +1,31 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import extensionsStoreInjectable from "../../extensions-store/extensions-store.injectable";
const getEnabledExtensionsInjectable = getInjectable({
instantiate: (di) => () =>
di.inject(extensionsStoreInjectable).enabledExtensions,
lifecycle: lifecycleEnum.singleton,
});
export default getEnabledExtensionsInjectable;
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { lifecycleEnum } from "@ogre-tools/injectable";
import { ExtensionDiscovery } from "./extension-discovery";
import extensionLoaderInjectable from "../extension-loader/extension-loader.injectable";
import extensionInstallerInjectable from "../extension-installer/extension-installer.injectable";
import isCompatibleExtensionInjectable from "./is-compatible-extension/is-compatible-extension.injectable";
import isCompatibleBundledExtensionInjectable from "./is-compatible-bundled-extension/is-compatible-bundled-extension.injectable";
import extensionsStoreInjectable from "../extensions-store/extensions-store.injectable";
import extensionInstallationStateStoreInjectable from "../extension-installation-state-store/extension-installation-state-store.injectable";
const extensionDiscoveryInjectable = getInjectable({
instantiate: (di) =>
new ExtensionDiscovery({
extensionLoader: di.inject(extensionLoaderInjectable),
extensionInstaller: di.inject(extensionInstallerInjectable),
extensionsStore: di.inject(extensionsStoreInjectable),
extensionInstallationStateStore: di.inject(
extensionInstallationStateStoreInjectable,
),
isCompatibleBundledExtension: di.inject(
isCompatibleBundledExtensionInjectable,
),
isCompatibleExtension: di.inject(isCompatibleExtensionInjectable),
}),
lifecycle: lifecycleEnum.singleton,
});
export default extensionDiscoveryInjectable;
@@ -21,15 +21,14 @@
import mockFs from "mock-fs";
import { watch } from "chokidar";
import { ExtensionsStore } from "../extensions-store";
import path from "path";
import { ExtensionDiscovery } from "../extension-discovery";
import type { ExtensionDiscovery } from "./extension-discovery";
import os from "os";
import { Console } from "console";
import { AppPaths } from "../../common/app-paths";
import type { ExtensionLoader } from "../extension-loader";
import extensionLoaderInjectable from "../extension-loader/extension-loader.injectable";
import { getDiForUnitTesting } from "../getDiForUnitTesting";
import extensionDiscoveryInjectable from "./extension-discovery.injectable";
import extensionInstallerInjectable from "../extension-installer/extension-installer.injectable";
jest.setTimeout(60_000);
@@ -37,12 +36,6 @@ jest.mock("../../common/ipc");
jest.mock("chokidar", () => ({
watch: jest.fn(),
}));
jest.mock("../extension-installer", () => ({
extensionInstaller: {
extensionPackagesRoot: "",
installPackage: jest.fn(),
},
}));
jest.mock("electron", () => ({
app: {
getVersion: () => "99.99.99",
@@ -65,16 +58,23 @@ console = new Console(process.stdout, process.stderr); // fix mockFS
const mockedWatch = watch as jest.MockedFunction<typeof watch>;
describe("ExtensionDiscovery", () => {
let extensionLoader: ExtensionLoader;
let extensionDiscovery: ExtensionDiscovery;
beforeEach(() => {
ExtensionDiscovery.resetInstance();
ExtensionsStore.resetInstance();
ExtensionsStore.createInstance();
const di = getDiForUnitTesting();
extensionLoader = di.inject(extensionLoaderInjectable);
const extensionInstallerStub = {
installPackages: () => Promise.resolve(),
npm: () => Promise.resolve(),
extensionPackagesRoot: "some-extension-packages-root",
npmPath: "some-npm-path",
installPackage: jest.fn(),
};
// @ts-ignore
di.override(extensionInstallerInjectable, () => extensionInstallerStub);
extensionDiscovery = di.inject(extensionDiscoveryInjectable);
});
describe("with mockFs", () => {
@@ -103,13 +103,7 @@ describe("ExtensionDiscovery", () => {
}),
};
mockedWatch.mockImplementationOnce(() =>
(mockWatchInstance) as any,
);
const extensionDiscovery = ExtensionDiscovery.createInstance(
extensionLoader,
);
mockedWatch.mockImplementationOnce(() => mockWatchInstance as any);
// Need to force isLoaded to be true so that the file watching is started
extensionDiscovery.isLoaded = true;
@@ -119,15 +113,18 @@ describe("ExtensionDiscovery", () => {
extensionDiscovery.events.on("add", extension => {
expect(extension).toEqual({
absolutePath: expect.any(String),
id: path.normalize("node_modules/my-extension/package.json"),
id: path.normalize("some-extension-packages-root/node_modules/my-extension/package.json"),
isBundled: false,
isEnabled: false,
isCompatible: false,
manifest: {
manifest: {
name: "my-extension",
},
manifestPath: path.normalize("node_modules/my-extension/package.json"),
manifestPath: path.normalize(
"some-extension-packages-root/node_modules/my-extension/package.json",
),
});
done();
});
@@ -148,12 +145,7 @@ describe("ExtensionDiscovery", () => {
}),
};
mockedWatch.mockImplementationOnce(() =>
(mockWatchInstance) as any,
);
const extensionDiscovery = ExtensionDiscovery.createInstance(
extensionLoader,
);
mockedWatch.mockImplementationOnce(() => mockWatchInstance as any);
// Need to force isLoaded to be true so that the file watching is started
extensionDiscovery.isLoaded = true;
@@ -23,19 +23,35 @@ import { watch } from "chokidar";
import { ipcRenderer } from "electron";
import { EventEmitter } from "events";
import fse from "fs-extra";
import { observable, reaction, when, makeObservable } from "mobx";
import { makeObservable, observable, reaction, when } from "mobx";
import os from "os";
import path from "path";
import { broadcastMessage, ipcMainHandle, ipcRendererOn, requestMain } from "../common/ipc";
import { Singleton, toJS } from "../common/utils";
import logger from "../main/logger";
import { ExtensionInstallationStateStore } from "../renderer/components/+extensions/extension-install.store";
import { extensionInstaller } from "./extension-installer";
import { ExtensionsStore } from "./extensions-store";
import type { ExtensionLoader } from "./extension-loader";
import type { LensExtensionId, LensExtensionManifest } from "./lens-extension";
import { isProduction } from "../common/vars";
import { isCompatibleBundledExtension, isCompatibleExtension } from "./extension-compatibility";
import {
broadcastMessage,
ipcMainHandle,
ipcRendererOn,
requestMain,
} from "../../common/ipc";
import { toJS } from "../../common/utils";
import logger from "../../main/logger";
import type { ExtensionInstaller } from "../extension-installer/extension-installer";
import type { ExtensionsStore } from "../extensions-store/extensions-store";
import type { ExtensionLoader } from "../extension-loader";
import type { LensExtensionId, LensExtensionManifest } from "../lens-extension";
import { isProduction } from "../../common/vars";
import type { ExtensionInstallationStateStore } from "../extension-installation-state-store/extension-installation-state-store";
interface Dependencies {
extensionLoader: ExtensionLoader;
extensionInstaller: ExtensionInstaller;
extensionsStore: ExtensionsStore;
extensionInstallationStateStore: ExtensionInstallationStateStore;
isCompatibleBundledExtension: (manifest: LensExtensionManifest) => boolean;
isCompatibleExtension: (manifest: LensExtensionManifest) => boolean;
}
export interface InstalledExtension {
id: LensExtensionId;
@@ -81,7 +97,7 @@ interface LoadFromFolderOptions {
* - "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 extends Singleton {
export class ExtensionDiscovery {
protected bundledFolderPath: string;
private loadStarted = false;
@@ -99,9 +115,7 @@ export class ExtensionDiscovery extends Singleton {
public events = new EventEmitter();
constructor(protected extensionLoader: ExtensionLoader) {
super();
constructor(protected dependencies : Dependencies) {
makeObservable(this);
}
@@ -110,11 +124,11 @@ export class ExtensionDiscovery extends Singleton {
}
get packageJsonPath(): string {
return path.join(extensionInstaller.extensionPackagesRoot, manifestFilename);
return path.join(this.dependencies.extensionInstaller.extensionPackagesRoot, manifestFilename);
}
get inTreeTargetPath(): string {
return path.join(extensionInstaller.extensionPackagesRoot, "extensions");
return path.join(this.dependencies.extensionInstaller.extensionPackagesRoot, "extensions");
}
get inTreeFolderPath(): string {
@@ -122,7 +136,7 @@ export class ExtensionDiscovery extends Singleton {
}
get nodeModulesPath(): string {
return path.join(extensionInstaller.extensionPackagesRoot, "node_modules");
return path.join(this.dependencies.extensionInstaller.extensionPackagesRoot, "node_modules");
}
/**
@@ -197,7 +211,7 @@ export class ExtensionDiscovery extends Singleton {
if (path.basename(manifestPath) === manifestFilename && isUnderLocalFolderPath) {
try {
ExtensionInstallationStateStore.setInstallingFromMain(manifestPath);
this.dependencies.extensionInstallationStateStore.setInstallingFromMain(manifestPath);
const absPath = path.dirname(manifestPath);
// this.loadExtensionFromPath updates this.packagesJson
@@ -217,7 +231,7 @@ export class ExtensionDiscovery extends Singleton {
} catch (error) {
logger.error(`${logModule}: failed to add extension: ${error}`, { error });
} finally {
ExtensionInstallationStateStore.clearInstallingFromMain(manifestPath);
this.dependencies.extensionInstallationStateStore.clearInstallingFromMain(manifestPath);
}
}
};
@@ -277,7 +291,7 @@ export class ExtensionDiscovery extends Singleton {
* @param extensionId The ID of the extension to uninstall.
*/
async uninstallExtension(extensionId: LensExtensionId): Promise<void> {
const { manifest, absolutePath } = this.extensions.get(extensionId) ?? this.extensionLoader.getExtension(extensionId);
const { manifest, absolutePath } = this.extensions.get(extensionId) ?? this.dependencies.extensionLoader.getExtension(extensionId);
logger.info(`${logModule} Uninstalling ${manifest.name}`);
@@ -295,10 +309,15 @@ export class ExtensionDiscovery extends Singleton {
this.loadStarted = true;
logger.info(`${logModule} loading extensions from ${extensionInstaller.extensionPackagesRoot}`);
const extensionPackagesRoot =
this.dependencies.extensionInstaller.extensionPackagesRoot;
logger.info(
`${logModule} loading extensions from ${extensionPackagesRoot}`,
);
// fs.remove won't throw if path is missing
await fse.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json"));
await fse.remove(path.join(extensionPackagesRoot, "package-lock.json"));
try {
// Verify write access to static/extensions, which is needed for symlinking
@@ -357,11 +376,11 @@ export class ExtensionDiscovery extends Singleton {
try {
const manifest = await fse.readJson(manifestPath) as LensExtensionManifest;
const id = this.getInstalledManifestPath(manifest.name);
const isEnabled = ExtensionsStore.getInstance().isEnabled({ id, isBundled });
const isEnabled = this.dependencies.extensionsStore.isEnabled({ id, isBundled });
const extensionDir = path.dirname(manifestPath);
const npmPackage = path.join(extensionDir, `${manifest.name}-${manifest.version}.tgz`);
const absolutePath = (isProduction && await fse.pathExists(npmPackage)) ? npmPackage : extensionDir;
const isCompatible = (isBundled && isCompatibleBundledExtension(manifest)) || isCompatibleExtension(manifest);
const isCompatible = (isBundled && this.dependencies.isCompatibleBundledExtension(manifest)) || this.dependencies.isCompatibleExtension(manifest);
return {
id,
@@ -417,11 +436,11 @@ export class ExtensionDiscovery extends Singleton {
extensions.map(extension => [extension.manifest.name, extension.absolutePath]),
);
return extensionInstaller.installPackages(packageJsonPath, { dependencies });
return this.dependencies.extensionInstaller.installPackages(packageJsonPath, { dependencies });
}
async installPackage(name: string): Promise<void> {
return extensionInstaller.installPackage(name);
return this.dependencies.extensionInstaller.installPackage(name);
}
async loadBundledExtensions(): Promise<InstalledExtension[]> {
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { appSemVer } from "../../../common/vars";
import { isCompatibleBundledExtension } from "./is-compatible-bundled-extension";
const isCompatibleBundledExtensionInjectable = getInjectable({
instantiate: () => isCompatibleBundledExtension({ appSemVer }),
lifecycle: lifecycleEnum.singleton,
});
export default isCompatibleBundledExtensionInjectable;
@@ -0,0 +1,32 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import type { LensExtensionManifest } from "../../lens-extension";
import { isProduction } from "../../../common/vars";
import type { SemVer } from "semver";
interface Dependencies {
appSemVer: SemVer;
}
export const isCompatibleBundledExtension =
({ appSemVer }: Dependencies) =>
(manifest: LensExtensionManifest): boolean =>
!isProduction || manifest.version === appSemVer.raw;
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { appSemVer } from "../../../common/vars";
import { isCompatibleExtension } from "./is-compatible-extension";
const isCompatibleExtensionInjectable = getInjectable({
instantiate: () => isCompatibleExtension({ appSemVer }),
lifecycle: lifecycleEnum.singleton,
});
export default isCompatibleExtensionInjectable;
@@ -0,0 +1,68 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import semver, { SemVer } from "semver";
import type { LensExtensionManifest } from "../../lens-extension";
interface Dependencies {
appSemVer: SemVer;
}
export const isCompatibleExtension = ({
appSemVer,
}: Dependencies): ((manifest: LensExtensionManifest) => boolean) => {
const { major, minor, patch, prerelease: oldPrelease } = appSemVer;
let prerelease = "";
if (oldPrelease.length > 0) {
const [first] = oldPrelease;
if (first === "alpha" || first === "beta" || first === "rc") {
/**
* Strip the build IDs and "latest" prerelease tag as that is not really
* a part of API version
*/
prerelease = `-${oldPrelease.slice(0, 2).join(".")}`;
}
}
/**
* We unfortunately have to format as string because the constructor only
* takes an instance or a string.
*/
const strippedVersion = new SemVer(
`${major}.${minor}.${patch}${prerelease}`,
{ includePrerelease: true },
);
return (manifest: LensExtensionManifest): boolean => {
if (manifest.engines?.lens) {
/**
* include Lens's prerelease tag in the matching so the extension's
* compatibility is not limited by it
*/
return semver.satisfies(strippedVersion, manifest.engines.lens, {
includePrerelease: true,
});
}
return false;
};
};
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { ExtensionInstallationStateStore } from "./extension-installation-state-store";
const extensionInstallationStateStoreInjectable = getInjectable({
instantiate: () => new ExtensionInstallationStateStore(),
lifecycle: lifecycleEnum.singleton,
});
export default extensionInstallationStateStoreInjectable;
@@ -0,0 +1,260 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { action, computed, observable } from "mobx";
import logger from "../../main/logger";
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";
export enum ExtensionInstallationState {
INSTALLING = "installing",
UNINSTALLING = "uninstalling",
IDLE = "idle",
}
const Prefix = "[ExtensionInstallationStore]";
export class ExtensionInstallationStateStore {
private InstallingFromMainChannel =
"extension-installation-state-store:install";
private ClearInstallingFromMainChannel =
"extension-installation-state-store:clear-install";
private PreInstallIds = observable.set<string>();
private UninstallingExtensions = observable.set<string>();
private InstallingExtensions = observable.set<string>();
bindIpcListeners = () => {
ipcRenderer
.on(this.InstallingFromMainChannel, (event, extId) => {
this.setInstalling(extId);
})
.on(this.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 => {
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(this.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(this.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();
logger.debug(
`${Prefix}: starting a new preinstall phase: ${preInstallStepId}`,
);
this.PreInstallIds.add(preInstallStepId);
return disposer(() => {
this.PreInstallIds.delete(preInstallStepId);
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 => {
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 => {
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 => {
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,29 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { ExtensionInstaller } from "./extension-installer";
const extensionInstallerInjectable = getInjectable({
instantiate: () => new ExtensionInstaller(),
lifecycle: lifecycleEnum.singleton,
});
export default extensionInstallerInjectable;
@@ -23,13 +23,12 @@ import AwaitLock from "await-lock";
import child_process from "child_process";
import fs from "fs-extra";
import path from "path";
import logger from "../main/logger";
import { extensionPackagesRoot } from "./extension-loader";
import logger from "../../main/logger";
import { extensionPackagesRoot } from "../extension-loader";
import type { PackageJson } from "type-fest";
const logModule = "[EXTENSION-INSTALLER]";
/**
* Installs dependencies for extensions
*/
@@ -108,5 +107,3 @@ export class ExtensionInstaller {
});
}
}
export const extensionInstaller = new ExtensionInstaller();
@@ -21,9 +21,13 @@
import { getInjectable } from "@ogre-tools/injectable";
import { lifecycleEnum } from "@ogre-tools/injectable";
import { ExtensionLoader } from "./extension-loader";
import extensionsStoreInjectable from "../extensions-store/extensions-store.injectable";
const extensionLoaderInjectable = getInjectable({
instantiate: () => new ExtensionLoader(),
instantiate: (di) => new ExtensionLoader({
extensionsStore: di.inject(extensionsStoreInjectable),
}),
lifecycle: lifecycleEnum.singleton,
});
@@ -29,8 +29,8 @@ import { broadcastMessage, ipcMainOn, ipcRendererOn, requestMain, ipcMainHandle
import { Disposer, toJS } from "../../common/utils";
import logger from "../../main/logger";
import type { KubernetesCluster } from "../common-api/catalog";
import type { InstalledExtension } from "../extension-discovery";
import { ExtensionsStore } from "../extensions-store";
import type { InstalledExtension } from "../extension-discovery/extension-discovery";
import type { ExtensionsStore } from "../extensions-store/extensions-store";
import type { LensExtension, LensExtensionConstructor, LensExtensionId } from "../lens-extension";
import type { LensRendererExtension } from "../lens-renderer-extension";
import * as registries from "../registries";
@@ -41,6 +41,10 @@ export function extensionPackagesRoot() {
const logModule = "[EXTENSIONS-LOADER]";
interface Dependencies {
extensionsStore: ExtensionsStore
}
/**
* Loads installed extensions to the Lens application
*/
@@ -75,7 +79,7 @@ export class ExtensionLoader {
return when(() => this.isLoaded);
}
constructor() {
constructor(protected dependencies : Dependencies) {
makeObservable(this);
observe(this.instances, change => {
switch (change.type) {
@@ -156,7 +160,7 @@ export class ExtensionLoader {
// save state on change `extension.isEnabled`
reaction(() => this.storeState, extensionsState => {
ExtensionsStore.getInstance().mergeState(extensionsState);
this.dependencies.extensionsStore.mergeState(extensionsState);
});
}
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { ExtensionsStore } from "./extensions-store";
const extensionsStoreInjectable = getInjectable({
instantiate: () => ExtensionsStore.createInstance(),
lifecycle: lifecycleEnum.singleton,
});
export default extensionsStoreInjectable;
@@ -19,10 +19,10 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import type { LensExtensionId } from "./lens-extension";
import { BaseStore } from "../common/base-store";
import { action, computed, observable, makeObservable } from "mobx";
import { toJS } from "../common/utils";
import type { LensExtensionId } from "../lens-extension";
import { action, computed, makeObservable, observable } from "mobx";
import { toJS } from "../../common/utils";
import { BaseStore } from "../../common/base-store";
export interface LensExtensionsStoreModel {
extensions: Record<LensExtensionId, LensExtensionState>;
+3
View File
@@ -26,10 +26,13 @@ import {
createContainer,
ConfigurableDependencyInjectionContainer,
} from "@ogre-tools/injectable";
import { setLegacyGlobalDiForExtensionApi } from "./as-legacy-global-function-for-extension-api/legacy-global-di-for-extension-api";
export const getDiForUnitTesting = () => {
const di: ConfigurableDependencyInjectionContainer = createContainer();
setLegacyGlobalDiForExtensionApi(di);
getInjectableFilePaths()
.map(key => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
+1 -1
View File
@@ -19,7 +19,7 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import type { InstalledExtension } from "./extension-discovery";
import type { InstalledExtension } from "./extension-discovery/extension-discovery";
import { action, observable, makeObservable, computed } from "mobx";
import { FilesystemProvisionerStore } from "../main/extension-filesystem";
import logger from "../main/logger";