mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Refactor the Extensions settings page (#2221)
This commit is contained in:
parent
96258c369f
commit
86205b365c
@ -23,8 +23,8 @@ export const ProtocolHandlerExtension= `${ProtocolHandlerIpcPrefix}:extension`;
|
||||
* Though under the current (2021/01/18) implementation, these are never matched
|
||||
* against in the final matching so their names are less of a concern.
|
||||
*/
|
||||
const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH";
|
||||
const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH";
|
||||
export const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH";
|
||||
export const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH";
|
||||
|
||||
export abstract class LensProtocolRouter extends Singleton {
|
||||
// Map between path schemas and the handlers
|
||||
@ -32,7 +32,7 @@ export abstract class LensProtocolRouter extends Singleton {
|
||||
|
||||
public static readonly LoggingPrefix = "[PROTOCOL ROUTER]";
|
||||
|
||||
protected static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`;
|
||||
static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`;
|
||||
|
||||
/**
|
||||
*
|
||||
|
||||
20
src/common/utils/disposer.ts
Normal file
20
src/common/utils/disposer.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export type Disposer = () => void;
|
||||
|
||||
interface Extendable<T> {
|
||||
push(...vals: T[]): void;
|
||||
}
|
||||
|
||||
export type ExtendableDisposer = Disposer & Extendable<Disposer>;
|
||||
|
||||
export function disposer(...args: Disposer[]): ExtendableDisposer {
|
||||
const res = () => {
|
||||
args.forEach(dispose => dispose?.());
|
||||
args.length = 0;
|
||||
};
|
||||
|
||||
res.push = (...vals: Disposer[]) => {
|
||||
args.push(...vals);
|
||||
};
|
||||
|
||||
return res;
|
||||
}
|
||||
@ -6,13 +6,13 @@ export interface DownloadFileOptions {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface DownloadFileTicket {
|
||||
export interface DownloadFileTicket<T> {
|
||||
url: string;
|
||||
promise: Promise<Buffer>;
|
||||
promise: Promise<T>;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket {
|
||||
export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket<Buffer> {
|
||||
const fileChunks: Buffer[] = [];
|
||||
const req = request(url, { gzip, timeout });
|
||||
const promise: Promise<Buffer> = new Promise((resolve, reject) => {
|
||||
@ -35,3 +35,12 @@ export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function downloadJson(args: DownloadFileOptions): DownloadFileTicket<any> {
|
||||
const { promise, ...rest } = downloadFile(args);
|
||||
|
||||
return {
|
||||
promise: promise.then(res => JSON.parse(res.toString())),
|
||||
...rest
|
||||
};
|
||||
}
|
||||
|
||||
@ -19,3 +19,4 @@ export * from "./downloadFile";
|
||||
export * from "./escapeRegExp";
|
||||
export * from "./tar";
|
||||
export * from "./type-narrowing";
|
||||
export * from "./disposer";
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import mockFs from "mock-fs";
|
||||
import { watch } from "chokidar";
|
||||
import { join, normalize } from "path";
|
||||
import { ExtensionDiscovery, InstalledExtension } from "../extension-discovery";
|
||||
import path from "path";
|
||||
import { ExtensionDiscovery } from "../extension-discovery";
|
||||
import os from "os";
|
||||
import { Console } from "console";
|
||||
|
||||
jest.mock("../../common/ipc");
|
||||
jest.mock("fs-extra");
|
||||
jest.mock("chokidar", () => ({
|
||||
watch: jest.fn()
|
||||
}));
|
||||
@ -14,13 +16,24 @@ jest.mock("../extension-installer", () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
console = new Console(process.stdout, process.stderr); // fix mockFS
|
||||
const mockedWatch = watch as jest.MockedFunction<typeof watch>;
|
||||
|
||||
describe("ExtensionDiscovery", () => {
|
||||
it("emits add for added extension", async done => {
|
||||
globalThis.__non_webpack_require__.mockImplementation(() => ({
|
||||
describe("with mockFs", () => {
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
[`${os.homedir()}/.k8slens/extensions/my-extension/package.json`]: JSON.stringify({
|
||||
name: "my-extension"
|
||||
}));
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
it("emits add for added extension", async (done) => {
|
||||
let addHandler: (filePath: string) => void;
|
||||
|
||||
const mockWatchInstance: any = {
|
||||
@ -43,21 +56,22 @@ describe("ExtensionDiscovery", () => {
|
||||
|
||||
await extensionDiscovery.watchExtensions();
|
||||
|
||||
extensionDiscovery.events.on("add", (extension: InstalledExtension) => {
|
||||
extensionDiscovery.events.on("add", extension => {
|
||||
expect(extension).toEqual({
|
||||
absolutePath: expect.any(String),
|
||||
id: normalize("node_modules/my-extension/package.json"),
|
||||
id: path.normalize("node_modules/my-extension/package.json"),
|
||||
isBundled: false,
|
||||
isEnabled: false,
|
||||
manifest: {
|
||||
name: "my-extension",
|
||||
},
|
||||
manifestPath: normalize("node_modules/my-extension/package.json"),
|
||||
manifestPath: path.normalize("node_modules/my-extension/package.json"),
|
||||
});
|
||||
done();
|
||||
});
|
||||
|
||||
addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/package.json"));
|
||||
addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/package.json"));
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't emit add for added file under extension", async done => {
|
||||
@ -87,7 +101,7 @@ describe("ExtensionDiscovery", () => {
|
||||
|
||||
extensionDiscovery.events.on("add", onAdd);
|
||||
|
||||
addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json"));
|
||||
addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json"));
|
||||
|
||||
setTimeout(() => {
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
import { watch } from "chokidar";
|
||||
import { ipcRenderer } from "electron";
|
||||
import { EventEmitter } from "events";
|
||||
import fs from "fs-extra";
|
||||
import fse from "fs-extra";
|
||||
import { observable, reaction, toJS, when } from "mobx";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc";
|
||||
import { getBundledExtensions } from "../common/utils/app-version";
|
||||
import logger from "../main/logger";
|
||||
import { ExtensionInstallationStateStore } from "../renderer/components/+extensions/extension-install.store";
|
||||
import { extensionInstaller, PackageJson } from "./extension-installer";
|
||||
import { extensionLoader } from "./extension-loader";
|
||||
import { extensionsStore } from "./extensions-store";
|
||||
import type { LensExtensionId, LensExtensionManifest } from "./lens-extension";
|
||||
|
||||
@ -25,7 +27,7 @@ export interface InstalledExtension {
|
||||
readonly manifestPath: string;
|
||||
readonly isBundled: boolean; // defined in project root's package.json
|
||||
isEnabled: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const logModule = "[EXTENSION-DISCOVERY]";
|
||||
|
||||
@ -39,7 +41,7 @@ interface ExtensionDiscoveryChannelMessage {
|
||||
* 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: fs.Stats) => lstat.isDirectory() || lstat.isSymbolicLink();
|
||||
const isDirectoryLike = (lstat: fse.Stats) => lstat.isDirectory() || lstat.isSymbolicLink();
|
||||
|
||||
/**
|
||||
* Discovers installed bundled and local extensions from the filesystem.
|
||||
@ -64,11 +66,7 @@ export class ExtensionDiscovery {
|
||||
// IPC channel to broadcast changes to extension-discovery from main
|
||||
protected static readonly extensionDiscoveryChannel = "extension-discovery:main";
|
||||
|
||||
public events: EventEmitter;
|
||||
|
||||
constructor() {
|
||||
this.events = new EventEmitter();
|
||||
}
|
||||
public events = new EventEmitter();
|
||||
|
||||
get localFolderPath(): string {
|
||||
return path.join(os.homedir(), ".k8slens", "extensions");
|
||||
@ -136,7 +134,7 @@ export class ExtensionDiscovery {
|
||||
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 filesysten.
|
||||
// The OS might emit an event for added file even it's not completely written to the filesystem.
|
||||
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.
|
||||
@ -145,8 +143,10 @@ export class ExtensionDiscovery {
|
||||
})
|
||||
// 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.handleWatchUnlinkDir);
|
||||
// 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) => {
|
||||
@ -160,6 +160,7 @@ export class ExtensionDiscovery {
|
||||
|
||||
if (path.basename(manifestPath) === manifestFilename && isUnderLocalFolderPath) {
|
||||
try {
|
||||
ExtensionInstallationStateStore.setInstallingFromMain(manifestPath);
|
||||
const absPath = path.dirname(manifestPath);
|
||||
|
||||
// this.loadExtensionFromPath updates this.packagesJson
|
||||
@ -167,7 +168,7 @@ export class ExtensionDiscovery {
|
||||
|
||||
if (extension) {
|
||||
// Remove a broken symlink left by a previous installation if it exists.
|
||||
await this.removeSymlinkByManifestPath(manifestPath);
|
||||
await fse.remove(extension.manifestPath);
|
||||
|
||||
// Install dependencies for the new extension
|
||||
await this.installPackage(extension.absolutePath);
|
||||
@ -177,40 +178,46 @@ export class ExtensionDiscovery {
|
||||
this.events.emit("add", extension);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error(`${logModule}: failed to add extension: ${error}`, { error });
|
||||
} finally {
|
||||
ExtensionInstallationStateStore.clearInstallingFromMain(manifestPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleWatchUnlinkDir = async (filePath: string) => {
|
||||
// filePath is the non-symlinked path to the extension folder
|
||||
// this.packagesJson.dependencies value is the non-symlinked path to the extension folder
|
||||
// LensExtensionId in extension-loader is the symlinked path to the extension folder manifest file
|
||||
|
||||
/**
|
||||
* 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 = path.basename(filePath);
|
||||
const expectedPath = path.relative(this.localFolderPath, filePath);
|
||||
|
||||
if (expectedPath !== extensionFolderName) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.relative(this.localFolderPath, filePath) === extensionFolderName) {
|
||||
const extension = Array.from(this.extensions.values()).find((extension) => extension.absolutePath === filePath);
|
||||
|
||||
if (extension) {
|
||||
if (!extension) {
|
||||
return void logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`);
|
||||
}
|
||||
|
||||
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
|
||||
// Note: that we need to use the symlinked path
|
||||
const lensExtensionId = extension.manifestPath;
|
||||
|
||||
this.extensions.delete(extension.id);
|
||||
logger.info(`${logModule} removed extension ${extensionName}`);
|
||||
this.events.emit("remove", lensExtensionId as LensExtensionId);
|
||||
} else {
|
||||
logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`);
|
||||
}
|
||||
}
|
||||
this.events.emit("remove", lensExtensionId);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -220,31 +227,23 @@ export class ExtensionDiscovery {
|
||||
* @param name e.g. "@mirantis/lens-extension-cc"
|
||||
*/
|
||||
removeSymlinkByPackageName(name: string) {
|
||||
return fs.remove(this.getInstalledPath(name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the symlink under node_modules if it exists.
|
||||
* @param manifestPath Path to package.json
|
||||
*/
|
||||
removeSymlinkByManifestPath(manifestPath: string) {
|
||||
const manifestJson = __non_webpack_require__(manifestPath);
|
||||
|
||||
return this.removeSymlinkByPackageName(manifestJson.name);
|
||||
return fse.remove(this.getInstalledPath(name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstalls extension.
|
||||
* The application will detect the folder unlink and remove the extension from the UI automatically.
|
||||
* @param extension Extension to unistall.
|
||||
* @param extensionId The ID of the extension to uninstall.
|
||||
*/
|
||||
async uninstallExtension({ absolutePath, manifest }: InstalledExtension) {
|
||||
async uninstallExtension(extensionId: LensExtensionId) {
|
||||
const { manifest, absolutePath } = this.extensions.get(extensionId) ?? extensionLoader.getExtension(extensionId);
|
||||
|
||||
logger.info(`${logModule} Uninstalling ${manifest.name}`);
|
||||
|
||||
await this.removeSymlinkByPackageName(manifest.name);
|
||||
|
||||
// fs.remove does nothing if the path doesn't exist anymore
|
||||
await fs.remove(absolutePath);
|
||||
await fse.remove(absolutePath);
|
||||
}
|
||||
|
||||
async load(): Promise<Map<LensExtensionId, InstalledExtension>> {
|
||||
@ -258,12 +257,11 @@ export class ExtensionDiscovery {
|
||||
logger.info(`${logModule} loading extensions from ${extensionInstaller.extensionPackagesRoot}`);
|
||||
|
||||
// fs.remove won't throw if path is missing
|
||||
await fs.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json"));
|
||||
|
||||
await fse.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json"));
|
||||
|
||||
try {
|
||||
// Verify write access to static/extensions, which is needed for symlinking
|
||||
await fs.access(this.inTreeFolderPath, fs.constants.W_OK);
|
||||
await fse.access(this.inTreeFolderPath, fse.constants.W_OK);
|
||||
|
||||
// Set bundled folder path to static/extensions
|
||||
this.bundledFolderPath = this.inTreeFolderPath;
|
||||
@ -272,20 +270,20 @@ export class ExtensionDiscovery {
|
||||
// The error can happen if there is read-only rights to static/extensions, which would fail symlinking.
|
||||
|
||||
// Remove e.g. /Users/<username>/Library/Application Support/LensDev/extensions
|
||||
await fs.remove(this.inTreeTargetPath);
|
||||
await fse.remove(this.inTreeTargetPath);
|
||||
|
||||
// Create folder e.g. /Users/<username>/Library/Application Support/LensDev/extensions
|
||||
await fs.ensureDir(this.inTreeTargetPath);
|
||||
await fse.ensureDir(this.inTreeTargetPath);
|
||||
|
||||
// Copy static/extensions to e.g. /Users/<username>/Library/Application Support/LensDev/extensions
|
||||
await fs.copy(this.inTreeFolderPath, this.inTreeTargetPath);
|
||||
await fse.copy(this.inTreeFolderPath, this.inTreeTargetPath);
|
||||
|
||||
// Set bundled folder path to e.g. /Users/<username>/Library/Application Support/LensDev/extensions
|
||||
this.bundledFolderPath = this.inTreeTargetPath;
|
||||
}
|
||||
|
||||
await fs.ensureDir(this.nodeModulesPath);
|
||||
await fs.ensureDir(this.localFolderPath);
|
||||
await fse.ensureDir(this.nodeModulesPath);
|
||||
await fse.ensureDir(this.localFolderPath);
|
||||
|
||||
const extensions = await this.ensureExtensions();
|
||||
|
||||
@ -314,30 +312,22 @@ export class ExtensionDiscovery {
|
||||
* Returns InstalledExtension from path to package.json file.
|
||||
* Also updates this.packagesJson.
|
||||
*/
|
||||
protected async getByManifest(manifestPath: string, { isBundled = false }: {
|
||||
isBundled?: boolean;
|
||||
} = {}): Promise<InstalledExtension | null> {
|
||||
let manifestJson: LensExtensionManifest;
|
||||
|
||||
protected async getByManifest(manifestPath: string, { isBundled = false } = {}): Promise<InstalledExtension | null> {
|
||||
try {
|
||||
// check manifest file for existence
|
||||
fs.accessSync(manifestPath, fs.constants.F_OK);
|
||||
|
||||
manifestJson = __non_webpack_require__(manifestPath);
|
||||
const installedManifestPath = this.getInstalledManifestPath(manifestJson.name);
|
||||
|
||||
const manifest = await fse.readJson(manifestPath);
|
||||
const installedManifestPath = this.getInstalledManifestPath(manifest.name);
|
||||
const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath);
|
||||
|
||||
return {
|
||||
id: installedManifestPath,
|
||||
absolutePath: path.dirname(manifestPath),
|
||||
manifestPath: installedManifestPath,
|
||||
manifest: manifestJson,
|
||||
manifest,
|
||||
isBundled,
|
||||
isEnabled
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logModule}: can't load extension manifest at ${manifestPath}: ${error}`, { manifestJson });
|
||||
logger.error(`${logModule}: can't load extension manifest at ${manifestPath}: ${error}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -351,7 +341,7 @@ export class ExtensionDiscovery {
|
||||
const userExtensions = await this.loadFromFolder(this.localFolderPath);
|
||||
|
||||
for (const extension of userExtensions) {
|
||||
if (await fs.pathExists(extension.manifestPath) === false) {
|
||||
if (await fse.pathExists(extension.manifestPath) === false) {
|
||||
await this.installPackage(extension.absolutePath);
|
||||
}
|
||||
}
|
||||
@ -383,7 +373,7 @@ export class ExtensionDiscovery {
|
||||
const extensions: InstalledExtension[] = [];
|
||||
const folderPath = this.bundledFolderPath;
|
||||
const bundledExtensions = getBundledExtensions();
|
||||
const paths = await fs.readdir(folderPath);
|
||||
const paths = await fse.readdir(folderPath);
|
||||
|
||||
for (const fileName of paths) {
|
||||
if (!bundledExtensions.includes(fileName)) {
|
||||
@ -405,7 +395,7 @@ export class ExtensionDiscovery {
|
||||
async loadFromFolder(folderPath: string): Promise<InstalledExtension[]> {
|
||||
const bundledExtensions = getBundledExtensions();
|
||||
const extensions: InstalledExtension[] = [];
|
||||
const paths = await fs.readdir(folderPath);
|
||||
const paths = await fse.readdir(folderPath);
|
||||
|
||||
for (const fileName of paths) {
|
||||
// do not allow to override bundled extensions
|
||||
@ -415,11 +405,11 @@ export class ExtensionDiscovery {
|
||||
|
||||
const absPath = path.resolve(folderPath, fileName);
|
||||
|
||||
if (!fs.existsSync(absPath)) {
|
||||
if (!fse.existsSync(absPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lstat = await fs.lstat(absPath);
|
||||
const lstat = await fse.lstat(absPath);
|
||||
|
||||
// skip non-directories
|
||||
if (!isDirectoryLike(lstat)) {
|
||||
|
||||
@ -12,8 +12,6 @@ import type { LensExtension, LensExtensionConstructor, LensExtensionId } from ".
|
||||
import type { LensMainExtension } from "./lens-main-extension";
|
||||
import type { LensRendererExtension } from "./lens-renderer-extension";
|
||||
import * as registries from "./registries";
|
||||
import fs from "fs";
|
||||
|
||||
|
||||
export function extensionPackagesRoot() {
|
||||
return path.join((app || remote.app).getPath("userData"));
|
||||
@ -290,28 +288,20 @@ export class ExtensionLoader {
|
||||
});
|
||||
}
|
||||
|
||||
protected requireExtension(extension: InstalledExtension): LensExtensionConstructor {
|
||||
let extEntrypoint = "";
|
||||
protected requireExtension(extension: InstalledExtension): LensExtensionConstructor | null {
|
||||
const entryPointName = ipcRenderer ? "renderer" : "main";
|
||||
const extRelativePath = extension.manifest[entryPointName];
|
||||
|
||||
if (!extRelativePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const extAbsolutePath = path.resolve(path.join(path.dirname(extension.manifestPath), extRelativePath));
|
||||
|
||||
try {
|
||||
if (ipcRenderer && extension.manifest.renderer) {
|
||||
extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.renderer));
|
||||
} else if (!ipcRenderer && extension.manifest.main) {
|
||||
extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.main));
|
||||
}
|
||||
|
||||
if (extEntrypoint !== "") {
|
||||
if (!fs.existsSync(extEntrypoint)) {
|
||||
console.log(`${logModule}: entrypoint ${extEntrypoint} not found, skipping ...`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return __non_webpack_require__(extEntrypoint).default;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`${logModule}: can't load extension main at ${extEntrypoint}: ${err}`, { extension });
|
||||
console.trace(err);
|
||||
return __non_webpack_require__(extAbsolutePath).default;
|
||||
} catch (error) {
|
||||
logger.error(`${logModule}: can't load extension main at ${extAbsolutePath}: ${error}`, { extension, error });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ import { filesystemProvisionerStore } from "../main/extension-filesystem";
|
||||
import { App } from "./components/app";
|
||||
import { LensApp } from "./lens-app";
|
||||
import { themeStore } from "./theme.store";
|
||||
import { ExtensionInstallationStateStore } from "./components/+extensions/extension-install.store";
|
||||
|
||||
/**
|
||||
* If this is a development buid, wait a second to attach
|
||||
@ -50,6 +51,7 @@ export async function bootstrap(App: AppComponent) {
|
||||
await attachChromeDebugger();
|
||||
rootElem.classList.toggle("is-mac", isMac);
|
||||
|
||||
ExtensionInstallationStateStore.bindIpcListeners();
|
||||
extensionLoader.init();
|
||||
extensionDiscovery.init();
|
||||
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
import "@testing-library/jest-dom/extend-expect";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import fse from "fs-extra";
|
||||
import React from "react";
|
||||
import { extensionDiscovery } from "../../../../extensions/extension-discovery";
|
||||
import { ConfirmDialog } from "../../confirm-dialog";
|
||||
import { Notifications } from "../../notifications";
|
||||
import { ExtensionStateStore } from "../extension-install.store";
|
||||
import { ExtensionInstallationStateStore } from "../extension-install.store";
|
||||
import { Extensions } from "../extensions";
|
||||
|
||||
jest.setTimeout(30000);
|
||||
jest.mock("fs-extra");
|
||||
jest.mock("../../notifications");
|
||||
|
||||
jest.mock("../../../../common/utils", () => ({
|
||||
...jest.requireActual("../../../../common/utils"),
|
||||
@ -42,62 +43,42 @@ jest.mock("../../../../extensions/extension-loader", () => ({
|
||||
isBundled: false,
|
||||
isEnabled: true
|
||||
}]
|
||||
])
|
||||
]),
|
||||
getExtension: jest.fn(() => ({ manifest: {} })),
|
||||
}
|
||||
}));
|
||||
|
||||
jest.mock("../../notifications", () => ({
|
||||
ok: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn()
|
||||
}));
|
||||
|
||||
describe("Extensions", () => {
|
||||
beforeEach(() => {
|
||||
ExtensionStateStore.resetInstance();
|
||||
ExtensionInstallationStateStore.reset();
|
||||
});
|
||||
|
||||
it("disables uninstall and disable buttons while uninstalling", async () => {
|
||||
render(<><Extensions /><ConfirmDialog/></>);
|
||||
const res = render(<><Extensions /><ConfirmDialog /></>);
|
||||
|
||||
expect(screen.getByText("Disable").closest("button")).not.toBeDisabled();
|
||||
expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled();
|
||||
expect(res.getByText("Disable").closest("button")).not.toBeDisabled();
|
||||
expect(res.getByText("Uninstall").closest("button")).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByText("Uninstall"));
|
||||
fireEvent.click(res.getByText("Uninstall"));
|
||||
|
||||
// Approve confirm dialog
|
||||
fireEvent.click(screen.getByText("Yes"));
|
||||
fireEvent.click(res.getByText("Yes"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(extensionDiscovery.uninstallExtension).toHaveBeenCalled();
|
||||
expect(screen.getByText("Disable").closest("button")).toBeDisabled();
|
||||
expect(screen.getByText("Uninstall").closest("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("displays error notification on uninstall error", () => {
|
||||
(extensionDiscovery.uninstallExtension as any).mockImplementationOnce(() =>
|
||||
Promise.reject()
|
||||
);
|
||||
render(<><Extensions /><ConfirmDialog/></>);
|
||||
|
||||
expect(screen.getByText("Disable").closest("button")).not.toBeDisabled();
|
||||
expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByText("Uninstall"));
|
||||
|
||||
// Approve confirm dialog
|
||||
fireEvent.click(screen.getByText("Yes"));
|
||||
|
||||
waitFor(() => {
|
||||
expect(screen.getByText("Disable").closest("button")).not.toBeDisabled();
|
||||
expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled();
|
||||
expect(Notifications.error).toHaveBeenCalledTimes(1);
|
||||
expect(res.getByText("Disable").closest("button")).toBeDisabled();
|
||||
expect(res.getByText("Uninstall").closest("button")).toBeDisabled();
|
||||
}, {
|
||||
timeout: 30000,
|
||||
});
|
||||
});
|
||||
|
||||
it("disables install button while installing", () => {
|
||||
render(<Extensions />);
|
||||
it("disables install button while installing", async () => {
|
||||
const res = render(<Extensions />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Path or URL to an extension package", {
|
||||
(fse.unlink as jest.MockedFunction<typeof fse.unlink>).mockReturnValue(Promise.resolve() as any);
|
||||
|
||||
fireEvent.change(res.getByPlaceholderText("Path or URL to an extension package", {
|
||||
exact: false
|
||||
}), {
|
||||
target: {
|
||||
@ -105,25 +86,21 @@ describe("Extensions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Install"));
|
||||
|
||||
waitFor(() => {
|
||||
expect(screen.getByText("Install").closest("button")).toBeDisabled();
|
||||
expect(fse.move).toHaveBeenCalledWith("");
|
||||
expect(Notifications.error).not.toHaveBeenCalled();
|
||||
});
|
||||
fireEvent.click(res.getByText("Install"));
|
||||
expect(res.getByText("Install").closest("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("displays spinner while extensions are loading", () => {
|
||||
it("displays spinner while extensions are loading", async () => {
|
||||
extensionDiscovery.isLoaded = false;
|
||||
const { container } = render(<Extensions />);
|
||||
const res = render(<Extensions />);
|
||||
|
||||
expect(container.querySelector(".Spinner")).toBeInTheDocument();
|
||||
expect(res.container.querySelector(".Spinner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not display the spinner while extensions are not loading", async () => {
|
||||
extensionDiscovery.isLoaded = true;
|
||||
const res = render(<Extensions />);
|
||||
|
||||
waitFor(() =>
|
||||
expect(container.querySelector(".Spinner")).not.toBeInTheDocument()
|
||||
);
|
||||
expect(res.container.querySelector(".Spinner")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,13 +1,218 @@
|
||||
import { observable } from "mobx";
|
||||
import { autobind, Singleton } from "../../utils";
|
||||
import { action, computed, observable } from "mobx";
|
||||
import logger from "../../../main/logger";
|
||||
import { disposer, ExtendableDisposer } from "../../utils";
|
||||
import * as uuid from "uuid";
|
||||
import { broadcastMessage } from "../../../common/ipc";
|
||||
import { ipcRenderer } from "electron";
|
||||
|
||||
interface ExtensionState {
|
||||
displayName: string;
|
||||
// Possible states the extension can be
|
||||
state: "installing" | "uninstalling";
|
||||
export enum ExtensionInstallationState {
|
||||
INSTALLING = "installing",
|
||||
UNINSTALLING = "uninstalling",
|
||||
IDLE = "idle",
|
||||
}
|
||||
|
||||
@autobind()
|
||||
export class ExtensionStateStore extends Singleton {
|
||||
extensionState = observable.map<string, ExtensionState>();
|
||||
const Prefix = "[ExtensionInstallationStore]";
|
||||
|
||||
export class ExtensionInstallationStateStore {
|
||||
private static InstallingFromMainChannel = "extension-installation-state-store:install";
|
||||
private static ClearInstallingFromMainChannel = "extension-installation-state-store:clear-install";
|
||||
private static PreInstallIds = observable.set<string>();
|
||||
private static UninstallingExtensions = observable.set<string>();
|
||||
private static InstallingExtensions = observable.set<string>();
|
||||
|
||||
static bindIpcListeners() {
|
||||
ipcRenderer
|
||||
.on(ExtensionInstallationStateStore.InstallingFromMainChannel, (event, extId) => {
|
||||
ExtensionInstallationStateStore.setInstalling(extId);
|
||||
})
|
||||
.on(ExtensionInstallationStateStore.ClearInstallingFromMainChannel, (event, extId) => {
|
||||
ExtensionInstallationStateStore.clearInstalling(extId);
|
||||
});
|
||||
}
|
||||
|
||||
@action static reset() {
|
||||
logger.warn(`${Prefix}: resetting, may throw errors`);
|
||||
ExtensionInstallationStateStore.InstallingExtensions.clear();
|
||||
ExtensionInstallationStateStore.UninstallingExtensions.clear();
|
||||
ExtensionInstallationStateStore.PreInstallIds.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Strictly transitions an extension from not installing to installing
|
||||
* @param extId the ID of the extension
|
||||
* @throws if state is not IDLE
|
||||
*/
|
||||
@action static setInstalling(extId: string): void {
|
||||
logger.debug(`${Prefix}: trying to set ${extId} as installing`);
|
||||
|
||||
const curState = ExtensionInstallationStateStore.getInstallationState(extId);
|
||||
|
||||
if (curState !== ExtensionInstallationState.IDLE) {
|
||||
throw new Error(`${Prefix}: cannot set ${extId} as installing. Is currently ${curState}.`);
|
||||
}
|
||||
|
||||
ExtensionInstallationStateStore.InstallingExtensions.add(extId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts that an extension is being installed by the main process
|
||||
* @param extId the ID of the extension
|
||||
*/
|
||||
static setInstallingFromMain(extId: string): void {
|
||||
broadcastMessage(ExtensionInstallationStateStore.InstallingFromMainChannel, extId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts that an extension is no longer being installed by the main process
|
||||
* @param extId the ID of the extension
|
||||
*/
|
||||
static clearInstallingFromMain(extId: string): void {
|
||||
broadcastMessage(ExtensionInstallationStateStore.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 static startPreInstall(): ExtendableDisposer {
|
||||
const preInstallStepId = uuid.v4();
|
||||
|
||||
logger.debug(`${Prefix}: starting a new preinstall phase: ${preInstallStepId}`);
|
||||
ExtensionInstallationStateStore.PreInstallIds.add(preInstallStepId);
|
||||
|
||||
return disposer(() => {
|
||||
ExtensionInstallationStateStore.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 static setUninstalling(extId: string): void {
|
||||
logger.debug(`${Prefix}: trying to set ${extId} as uninstalling`);
|
||||
|
||||
const curState = ExtensionInstallationStateStore.getInstallationState(extId);
|
||||
|
||||
if (curState !== ExtensionInstallationState.IDLE) {
|
||||
throw new Error(`${Prefix}: cannot set ${extId} as uninstalling. Is currently ${curState}.`);
|
||||
}
|
||||
|
||||
ExtensionInstallationStateStore.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 static clearInstalling(extId: string): void {
|
||||
logger.debug(`${Prefix}: trying to clear ${extId} as installing`);
|
||||
|
||||
const curState = ExtensionInstallationStateStore.getInstallationState(extId);
|
||||
|
||||
switch (curState) {
|
||||
case ExtensionInstallationState.INSTALLING:
|
||||
return void ExtensionInstallationStateStore.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 static clearUninstalling(extId: string): void {
|
||||
logger.debug(`${Prefix}: trying to clear ${extId} as uninstalling`);
|
||||
|
||||
const curState = ExtensionInstallationStateStore.getInstallationState(extId);
|
||||
|
||||
switch (curState) {
|
||||
case ExtensionInstallationState.UNINSTALLING:
|
||||
return void ExtensionInstallationStateStore.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
|
||||
*/
|
||||
static getInstallationState(extId: string): ExtensionInstallationState {
|
||||
if (ExtensionInstallationStateStore.InstallingExtensions.has(extId)) {
|
||||
return ExtensionInstallationState.INSTALLING;
|
||||
}
|
||||
|
||||
if (ExtensionInstallationStateStore.UninstallingExtensions.has(extId)) {
|
||||
return ExtensionInstallationState.UNINSTALLING;
|
||||
}
|
||||
|
||||
return ExtensionInstallationState.IDLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the extension is currently INSTALLING
|
||||
* @param extId The ID of the extension
|
||||
*/
|
||||
static isExtensionInstalling(extId: string): boolean {
|
||||
return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.INSTALLING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the extension is currently UNINSTALLING
|
||||
* @param extId The ID of the extension
|
||||
*/
|
||||
static isExtensionUninstalling(extId: string): boolean {
|
||||
return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.UNINSTALLING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the extension is currently IDLE
|
||||
* @param extId The ID of the extension
|
||||
*/
|
||||
static isExtensionIdle(extId: string): boolean {
|
||||
return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.IDLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* The current number of extensions installing
|
||||
*/
|
||||
@computed static get installing(): number {
|
||||
return ExtensionInstallationStateStore.InstallingExtensions.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* If there is at least one extension currently installing
|
||||
*/
|
||||
@computed static get anyInstalling(): boolean {
|
||||
return ExtensionInstallationStateStore.installing > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The current number of extensions preinstalling
|
||||
*/
|
||||
@computed static get preinstalling(): number {
|
||||
return ExtensionInstallationStateStore.PreInstallIds.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* If there is at least one extension currently downloading
|
||||
*/
|
||||
@computed static get anyPreinstalling(): boolean {
|
||||
return ExtensionInstallationStateStore.preinstalling > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* If there is at least one installing or preinstalling step taking place
|
||||
*/
|
||||
@computed static get anyPreInstallingOrInstalling(): boolean {
|
||||
return ExtensionInstallationStateStore.anyInstalling || ExtensionInstallationStateStore.anyPreinstalling;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
import "./extensions.scss";
|
||||
import { remote, shell } from "electron";
|
||||
import fse from "fs-extra";
|
||||
import { computed, observable, reaction } from "mobx";
|
||||
import { computed, observable, reaction, when } from "mobx";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import React from "react";
|
||||
import { downloadFile, extractTar, listTarEntries, readFileFromTar } from "../../../common/utils";
|
||||
import { autobind, disposer, Disposer, downloadFile, downloadJson, ExtendableDisposer, extractTar, listTarEntries, noop, readFileFromTar } from "../../../common/utils";
|
||||
import { docsUrl } from "../../../common/vars";
|
||||
import { extensionDiscovery, InstalledExtension, manifestFilename } from "../../../extensions/extension-discovery";
|
||||
import { extensionLoader } from "../../../extensions/extension-loader";
|
||||
import { extensionDisplayName, LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension";
|
||||
import { extensionDisplayName, LensExtensionId, LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension";
|
||||
import logger from "../../../main/logger";
|
||||
import { prevDefault } from "../../utils";
|
||||
import { Button } from "../button";
|
||||
@ -21,215 +22,126 @@ import { SubTitle } from "../layout/sub-title";
|
||||
import { Notifications } from "../notifications";
|
||||
import { Spinner } from "../spinner/spinner";
|
||||
import { TooltipPosition } from "../tooltip";
|
||||
import { ExtensionStateStore } from "./extension-install.store";
|
||||
import "./extensions.scss";
|
||||
import { ExtensionInstallationState, ExtensionInstallationStateStore } from "./extension-install.store";
|
||||
import URLParse from "url-parse";
|
||||
import { SemVer } from "semver";
|
||||
import _ from "lodash";
|
||||
|
||||
function getMessageFromError(error: any): string {
|
||||
if (!error || typeof error !== "object") {
|
||||
return "an error has occured";
|
||||
}
|
||||
|
||||
if (error.message) {
|
||||
return String(error.message);
|
||||
}
|
||||
|
||||
if (error.err) {
|
||||
return String(error.err);
|
||||
}
|
||||
|
||||
const rawMessage = String(error);
|
||||
|
||||
if (rawMessage === String({})) {
|
||||
return "an error has occured";
|
||||
}
|
||||
|
||||
return rawMessage;
|
||||
}
|
||||
|
||||
interface ExtensionInfo {
|
||||
name: string;
|
||||
version?: string;
|
||||
requireConfirmation?: boolean;
|
||||
}
|
||||
|
||||
interface InstallRequest {
|
||||
fileName: string;
|
||||
filePath?: string;
|
||||
data?: Buffer;
|
||||
dataP: Promise<Buffer | null>;
|
||||
}
|
||||
|
||||
interface InstallRequestPreloaded extends InstallRequest {
|
||||
interface InstallRequestValidated {
|
||||
fileName: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
interface InstallRequestValidated extends InstallRequestPreloaded {
|
||||
id: LensExtensionId;
|
||||
manifest: LensExtensionManifest;
|
||||
tempFile: string; // temp system path to packed extension for unpacking
|
||||
}
|
||||
|
||||
@observer
|
||||
export class Extensions extends React.Component {
|
||||
private static supportedFormats = ["tar", "tgz"];
|
||||
async function uninstallExtension(extensionId: LensExtensionId): Promise<boolean> {
|
||||
const { manifest } = extensionLoader.getExtension(extensionId);
|
||||
const displayName = extensionDisplayName(manifest.name, manifest.version);
|
||||
|
||||
private static installPathValidator: InputValidator = {
|
||||
message: "Invalid URL or absolute path",
|
||||
validate(value: string) {
|
||||
return InputValidators.isUrl.validate(value) || InputValidators.isPath.validate(value);
|
||||
}
|
||||
};
|
||||
try {
|
||||
logger.debug(`[EXTENSIONS]: trying to uninstall ${extensionId}`);
|
||||
ExtensionInstallationStateStore.setUninstalling(extensionId);
|
||||
|
||||
get extensionStateStore() {
|
||||
return ExtensionStateStore.getInstance<ExtensionStateStore>();
|
||||
}
|
||||
await extensionDiscovery.uninstallExtension(extensionId);
|
||||
|
||||
@observable search = "";
|
||||
@observable installPath = "";
|
||||
// wait for the extensionLoader to actually uninstall the extension
|
||||
await when(() => !extensionLoader.userExtensions.has(extensionId));
|
||||
|
||||
// True if the preliminary install steps have started, but unpackExtension has not started yet
|
||||
@observable startingInstall = false;
|
||||
|
||||
/**
|
||||
* Extensions that were removed from extensions but are still in "uninstalling" state
|
||||
*/
|
||||
@computed get removedUninstalling() {
|
||||
return Array.from(this.extensionStateStore.extensionState.entries())
|
||||
.filter(([id, extension]) =>
|
||||
extension.state === "uninstalling"
|
||||
&& !this.extensions.find(extension => extension.id === id)
|
||||
)
|
||||
.map(([id, extension]) => ({ ...extension, id }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extensions that were added to extensions but are still in "installing" state
|
||||
*/
|
||||
@computed get addedInstalling() {
|
||||
return Array.from(this.extensionStateStore.extensionState.entries())
|
||||
.filter(([id, extension]) =>
|
||||
extension.state === "installing"
|
||||
&& this.extensions.find(extension => extension.id === id)
|
||||
)
|
||||
.map(([id, extension]) => ({ ...extension, id }));
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
disposeOnUnmount(this,
|
||||
reaction(() => this.extensions, () => {
|
||||
this.removedUninstalling.forEach(({ id, displayName }) => {
|
||||
Notifications.ok(
|
||||
<p>Extension <b>{displayName}</b> successfully uninstalled!</p>
|
||||
);
|
||||
this.extensionStateStore.extensionState.delete(id);
|
||||
});
|
||||
|
||||
this.addedInstalling.forEach(({ id, displayName }) => {
|
||||
const extension = this.extensions.find(extension => extension.id === id);
|
||||
|
||||
if (!extension) {
|
||||
throw new Error("Extension not found");
|
||||
}
|
||||
|
||||
Notifications.ok(
|
||||
<p>Extension <b>{displayName}</b> successfully installed!</p>
|
||||
);
|
||||
this.extensionStateStore.extensionState.delete(id);
|
||||
this.installPath = "";
|
||||
|
||||
// Enable installed extensions by default.
|
||||
extension.isEnabled = true;
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@computed get extensions() {
|
||||
const searchText = this.search.toLowerCase();
|
||||
|
||||
return Array.from(extensionLoader.userExtensions.values())
|
||||
.filter(({ manifest: { name, description }}) => (
|
||||
name.toLowerCase().includes(searchText)
|
||||
|| description?.toLowerCase().includes(searchText)
|
||||
));
|
||||
}
|
||||
|
||||
get extensionsPath() {
|
||||
return extensionDiscovery.localFolderPath;
|
||||
}
|
||||
|
||||
getExtensionPackageTemp(fileName = "") {
|
||||
return path.join(os.tmpdir(), "lens-extensions", fileName);
|
||||
}
|
||||
|
||||
getExtensionDestFolder(name: string) {
|
||||
return path.join(this.extensionsPath, sanitizeExtensionName(name));
|
||||
}
|
||||
|
||||
installFromSelectFileDialog = async () => {
|
||||
const { dialog, BrowserWindow, app } = remote;
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), {
|
||||
defaultPath: app.getPath("downloads"),
|
||||
properties: ["openFile", "multiSelections"],
|
||||
message: `Select extensions to install (formats: ${Extensions.supportedFormats.join(", ")}), `,
|
||||
buttonLabel: `Use configuration`,
|
||||
filters: [
|
||||
{ name: "tarball", extensions: Extensions.supportedFormats }
|
||||
]
|
||||
});
|
||||
|
||||
if (!canceled && filePaths.length) {
|
||||
this.requestInstall(
|
||||
filePaths.map(filePath => ({
|
||||
fileName: path.basename(filePath),
|
||||
filePath,
|
||||
}))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
installFromUrlOrPath = async () => {
|
||||
const { installPath } = this;
|
||||
|
||||
if (!installPath) return;
|
||||
|
||||
this.startingInstall = true;
|
||||
const fileName = path.basename(installPath);
|
||||
|
||||
try {
|
||||
// install via url
|
||||
// fixme: improve error messages for non-tar-file URLs
|
||||
if (InputValidators.isUrl.validate(installPath)) {
|
||||
const { promise: filePromise } = downloadFile({ url: installPath, timeout: 60000 /*1m*/ });
|
||||
const data = await filePromise;
|
||||
|
||||
await this.requestInstall({ fileName, data });
|
||||
}
|
||||
// otherwise installing from system path
|
||||
else if (InputValidators.isPath.validate(installPath)) {
|
||||
await this.requestInstall({ fileName, filePath: installPath });
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.startingInstall = false;
|
||||
Notifications.error(
|
||||
<p>Installation has failed: <b>{String(error)}</b></p>
|
||||
);
|
||||
const message = getMessageFromError(error);
|
||||
|
||||
logger.info(`[EXTENSION-UNINSTALL]: uninstalling ${displayName} has failed: ${error}`, { error });
|
||||
Notifications.error(<p>Uninstalling extension <b>{displayName}</b> has failed: <em>{message}</em></p>);
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
// Remove uninstall state on uninstall failure
|
||||
ExtensionInstallationStateStore.clearUninstalling(extensionId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
installOnDrop = (files: File[]) => {
|
||||
logger.info("Install from D&D");
|
||||
async function confirmUninstallExtension(extension: InstalledExtension): Promise<void> {
|
||||
const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version);
|
||||
const confirmed = await ConfirmDialog.confirm({
|
||||
message: <p>Are you sure you want to uninstall extension <b>{displayName}</b>?</p>,
|
||||
labelOk: "Yes",
|
||||
labelCancel: "No",
|
||||
});
|
||||
|
||||
return this.requestInstall(
|
||||
files.map(file => ({
|
||||
fileName: path.basename(file.path),
|
||||
filePath: file.path,
|
||||
}))
|
||||
);
|
||||
};
|
||||
if (confirmed) {
|
||||
await uninstallExtension(extension.id);
|
||||
}
|
||||
}
|
||||
|
||||
async preloadExtensions(requests: InstallRequest[], { showError = true } = {}) {
|
||||
const preloadedRequests = requests.filter(request => request.data);
|
||||
function getExtensionDestFolder(name: string) {
|
||||
return path.join(extensionDiscovery.localFolderPath, sanitizeExtensionName(name));
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
requests
|
||||
.filter(request => !request.data && request.filePath)
|
||||
.map(async request => {
|
||||
function getExtensionPackageTemp(fileName = "") {
|
||||
return path.join(os.tmpdir(), "lens-extensions", fileName);
|
||||
}
|
||||
|
||||
async function readFileNotify(filePath: string, showError = true): Promise<Buffer | null> {
|
||||
try {
|
||||
const data = await fse.readFile(request.filePath);
|
||||
|
||||
request.data = data;
|
||||
preloadedRequests.push(request);
|
||||
|
||||
return request;
|
||||
} catch(error) {
|
||||
return await fse.readFile(filePath);
|
||||
} catch (error) {
|
||||
if (showError) {
|
||||
Notifications.error(`Error while reading "${request.filePath}": ${String(error)}`);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
const message = getMessageFromError(error);
|
||||
|
||||
return preloadedRequests as InstallRequestPreloaded[];
|
||||
logger.info(`[EXTENSION-INSTALL]: preloading ${filePath} has failed: ${message}`, { error });
|
||||
Notifications.error(`Error while reading "${filePath}": ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async validatePackage(filePath: string): Promise<LensExtensionManifest> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function validatePackage(filePath: string): Promise<LensExtensionManifest> {
|
||||
const tarFiles = await listTarEntries(filePath);
|
||||
|
||||
// tarball from npm contains single root folder "package/*"
|
||||
const firstFile = tarFiles[0];
|
||||
|
||||
if (!firstFile) {
|
||||
if(!firstFile) {
|
||||
throw new Error(`invalid extension bundle, ${manifestFilename} not found`);
|
||||
}
|
||||
|
||||
@ -237,7 +149,7 @@ export class Extensions extends React.Component {
|
||||
const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder));
|
||||
const manifestLocation = packedInRootFolder ? path.join(rootFolder, manifestFilename) : manifestFilename;
|
||||
|
||||
if (!tarFiles.includes(manifestLocation)) {
|
||||
if(!tarFiles.includes(manifestLocation)) {
|
||||
throw new Error(`invalid extension bundle, ${manifestFilename} not found`);
|
||||
}
|
||||
|
||||
@ -247,119 +159,70 @@ export class Extensions extends React.Component {
|
||||
parseJson: true,
|
||||
});
|
||||
|
||||
if (!manifest.lens && !manifest.renderer) {
|
||||
if (!manifest.main && !manifest.renderer) {
|
||||
throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async createTempFilesAndValidate(requests: InstallRequestPreloaded[], { showErrors = true } = {}) {
|
||||
const validatedRequests: InstallRequestValidated[] = [];
|
||||
}
|
||||
|
||||
async function createTempFilesAndValidate({ fileName, dataP }: InstallRequest, disposer: ExtendableDisposer): Promise<InstallRequestValidated | null> {
|
||||
// copy files to temp
|
||||
await fse.ensureDir(this.getExtensionPackageTemp());
|
||||
|
||||
for (const request of requests) {
|
||||
const tempFile = this.getExtensionPackageTemp(request.fileName);
|
||||
|
||||
await fse.writeFile(tempFile, request.data);
|
||||
}
|
||||
await fse.ensureDir(getExtensionPackageTemp());
|
||||
|
||||
// validate packages
|
||||
await Promise.all(
|
||||
requests.map(async req => {
|
||||
const tempFile = this.getExtensionPackageTemp(req.fileName);
|
||||
const tempFile = getExtensionPackageTemp(fileName);
|
||||
|
||||
disposer.push(() => fse.unlink(tempFile));
|
||||
|
||||
try {
|
||||
const manifest = await this.validatePackage(tempFile);
|
||||
const data = await dataP;
|
||||
|
||||
validatedRequests.push({
|
||||
...req,
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fse.writeFile(tempFile, data);
|
||||
const manifest = await validatePackage(tempFile);
|
||||
const id = path.join(extensionDiscovery.nodeModulesPath, manifest.name, "package.json");
|
||||
|
||||
return {
|
||||
fileName,
|
||||
data,
|
||||
manifest,
|
||||
tempFile,
|
||||
});
|
||||
id,
|
||||
};
|
||||
} catch (error) {
|
||||
fse.unlink(tempFile).catch(() => null); // remove invalid temp package
|
||||
const message = getMessageFromError(error);
|
||||
|
||||
if (showErrors) {
|
||||
logger.info(`[EXTENSION-INSTALLATION]: installing ${fileName} has failed: ${message}`, { error });
|
||||
Notifications.error(
|
||||
<div className="flex column gaps">
|
||||
<p>Installing <em>{req.fileName}</em> has failed, skipping.</p>
|
||||
<p>Reason: <em>{String(error)}</em></p>
|
||||
<p>Installing <em>{fileName}</em> has failed, skipping.</p>
|
||||
<p>Reason: <em>{message}</em></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return validatedRequests;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async requestInstall(init: InstallRequest | InstallRequest[]) {
|
||||
const requests = Array.isArray(init) ? init : [init];
|
||||
const preloadedRequests = await this.preloadExtensions(requests);
|
||||
const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests);
|
||||
async function unpackExtension(request: InstallRequestValidated, disposeDownloading?: Disposer) {
|
||||
const { id, fileName, tempFile, manifest: { name, version } } = request;
|
||||
|
||||
// If there are no requests for installing, reset startingInstall state
|
||||
if (validatedRequests.length === 0) {
|
||||
this.startingInstall = false;
|
||||
}
|
||||
ExtensionInstallationStateStore.setInstalling(id);
|
||||
disposeDownloading?.();
|
||||
|
||||
for (const install of validatedRequests) {
|
||||
const { name, version, description } = install.manifest;
|
||||
const extensionFolder = this.getExtensionDestFolder(name);
|
||||
const folderExists = await fse.pathExists(extensionFolder);
|
||||
|
||||
if (!folderExists) {
|
||||
// auto-install extension if not yet exists
|
||||
this.unpackExtension(install);
|
||||
} else {
|
||||
// If we show the confirmation dialog, we stop the install spinner until user clicks ok
|
||||
// and the install continues
|
||||
this.startingInstall = false;
|
||||
|
||||
// otherwise confirmation required (re-install / update)
|
||||
const removeNotification = Notifications.info(
|
||||
<div className="InstallingExtensionNotification flex gaps align-center">
|
||||
<div className="flex column gaps">
|
||||
<p>Install extension <b>{name}@{version}</b>?</p>
|
||||
<p>Description: <em>{description}</em></p>
|
||||
<div className="remove-folder-warning" onClick={() => shell.openPath(extensionFolder)}>
|
||||
<b>Warning:</b> <code>{extensionFolder}</code> will be removed before installation.
|
||||
</div>
|
||||
</div>
|
||||
<Button autoFocus label="Install" onClick={() => {
|
||||
removeNotification();
|
||||
this.unpackExtension(install);
|
||||
}}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) {
|
||||
const displayName = extensionDisplayName(name, version);
|
||||
const extensionId = path.join(extensionDiscovery.nodeModulesPath, name, "package.json");
|
||||
|
||||
logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile });
|
||||
|
||||
this.extensionStateStore.extensionState.set(extensionId, {
|
||||
state: "installing",
|
||||
displayName
|
||||
});
|
||||
this.startingInstall = false;
|
||||
|
||||
const extensionFolder = this.getExtensionDestFolder(name);
|
||||
const extensionFolder = getExtensionDestFolder(name);
|
||||
const unpackingTempFolder = path.join(path.dirname(tempFile), `${path.basename(tempFile)}-unpacked`);
|
||||
|
||||
logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile });
|
||||
|
||||
try {
|
||||
// extract to temp folder first
|
||||
await fse.remove(unpackingTempFolder).catch(Function);
|
||||
await fse.remove(unpackingTempFolder).catch(noop);
|
||||
await fse.ensureDir(unpackingTempFolder);
|
||||
await extractTar(tempFile, { cwd: unpackingTempFolder });
|
||||
|
||||
@ -375,77 +238,286 @@ export class Extensions extends React.Component {
|
||||
|
||||
await fse.ensureDir(extensionFolder);
|
||||
await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true });
|
||||
} catch (error) {
|
||||
Notifications.error(
|
||||
<p>Installing extension <b>{displayName}</b> has failed: <em>{error}</em></p>
|
||||
|
||||
// wait for the loader has actually install it
|
||||
await when(() => extensionLoader.userExtensions.has(id));
|
||||
|
||||
// Enable installed extensions by default.
|
||||
extensionLoader.userExtensions.get(id).isEnabled = true;
|
||||
|
||||
Notifications.ok(
|
||||
<p>Extension <b>{displayName}</b> successfully installed!</p>
|
||||
);
|
||||
} catch (error) {
|
||||
const message = getMessageFromError(error);
|
||||
|
||||
// Remove install state on install failure
|
||||
if (this.extensionStateStore.extensionState.get(extensionId)?.state === "installing") {
|
||||
this.extensionStateStore.extensionState.delete(extensionId);
|
||||
}
|
||||
logger.info(`[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`, { error });
|
||||
Notifications.error(<p>Installing extension <b>{displayName}</b> has failed: <em>{message}</em></p>);
|
||||
} finally {
|
||||
// Remove install state once finished
|
||||
ExtensionInstallationStateStore.clearInstalling(id);
|
||||
|
||||
// clean up
|
||||
fse.remove(unpackingTempFolder).catch(Function);
|
||||
fse.unlink(tempFile).catch(Function);
|
||||
fse.remove(unpackingTempFolder).catch(noop);
|
||||
fse.unlink(tempFile).catch(noop);
|
||||
}
|
||||
}
|
||||
|
||||
export async function attemptInstallByInfo({ name, version, requireConfirmation = false }: ExtensionInfo) {
|
||||
const disposer = ExtensionInstallationStateStore.startPreInstall();
|
||||
const registryUrl = new URLParse("https://registry.npmjs.com").set("pathname", name).toString();
|
||||
const { promise } = downloadJson({ url: registryUrl });
|
||||
const json = await promise.catch(console.error);
|
||||
|
||||
if (!json || json.error || typeof json.versions !== "object" || !json.versions) {
|
||||
const message = json?.error ? `: ${json.error}` : "";
|
||||
|
||||
Notifications.error(`Failed to get registry information for that extension${message}`);
|
||||
|
||||
return disposer();
|
||||
}
|
||||
|
||||
confirmUninstallExtension = (extension: InstalledExtension) => {
|
||||
const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version);
|
||||
if (version) {
|
||||
if (!json.versions[version]) {
|
||||
Notifications.error(<p>The <em>{name}</em> extension does not have a v{version}.</p>);
|
||||
|
||||
ConfirmDialog.open({
|
||||
message: <p>Are you sure you want to uninstall extension <b>{displayName}</b>?</p>,
|
||||
labelOk: "Yes",
|
||||
labelCancel: "No",
|
||||
ok: () => this.uninstallExtension(extension)
|
||||
return disposer();
|
||||
}
|
||||
} else {
|
||||
const versions = Object.keys(json.versions)
|
||||
.map(version => new SemVer(version, { loose: true, includePrerelease: true }))
|
||||
// ignore pre-releases for auto picking the version
|
||||
.filter(version => version.prerelease.length === 0);
|
||||
|
||||
version = _.reduce(versions, (prev, curr) => (
|
||||
prev.compareMain(curr) === -1
|
||||
? curr
|
||||
: prev
|
||||
)).format();
|
||||
}
|
||||
|
||||
if (requireConfirmation) {
|
||||
const proceed = await ConfirmDialog.confirm({
|
||||
message: <p>Are you sure you want to install <b>{name}@{version}</b>?</p>,
|
||||
labelCancel: "Cancel",
|
||||
labelOk: "Install",
|
||||
});
|
||||
};
|
||||
|
||||
async uninstallExtension(extension: InstalledExtension) {
|
||||
const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version);
|
||||
if (!proceed) {
|
||||
return disposer();
|
||||
}
|
||||
}
|
||||
|
||||
const url = json.versions[version].dist.tarball;
|
||||
const fileName = path.basename(url);
|
||||
const { promise: dataP } = downloadFile({ url, timeout: 10 * 60 * 1000 });
|
||||
|
||||
return attemptInstall({ fileName, dataP }, disposer);
|
||||
}
|
||||
|
||||
async function attemptInstall(request: InstallRequest, d?: ExtendableDisposer): Promise<void> {
|
||||
const dispose = disposer(ExtensionInstallationStateStore.startPreInstall(), d);
|
||||
const validatedRequest = await createTempFilesAndValidate(request, dispose);
|
||||
|
||||
if (!validatedRequest) {
|
||||
return dispose();
|
||||
}
|
||||
|
||||
const { name, version, description } = validatedRequest.manifest;
|
||||
const curState = ExtensionInstallationStateStore.getInstallationState(validatedRequest.id);
|
||||
|
||||
if (curState !== ExtensionInstallationState.IDLE) {
|
||||
dispose();
|
||||
|
||||
return Notifications.error(
|
||||
<div className="flex column gaps">
|
||||
<b>Extension Install Collision:</b>
|
||||
<p>The <em>{name}</em> extension is currently {curState.toLowerCase()}.</p>
|
||||
<p>Will not proceed with this current install request.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const extensionFolder = getExtensionDestFolder(name);
|
||||
const folderExists = await fse.pathExists(extensionFolder);
|
||||
|
||||
if (!folderExists) {
|
||||
// install extension if not yet exists
|
||||
await unpackExtension(validatedRequest, dispose);
|
||||
} else {
|
||||
const { manifest: { version: oldVersion } } = extensionLoader.getExtension(validatedRequest.id);
|
||||
|
||||
// otherwise confirmation required (re-install / update)
|
||||
const removeNotification = Notifications.info(
|
||||
<div className="InstallingExtensionNotification flex gaps align-center">
|
||||
<div className="flex column gaps">
|
||||
<p>Install extension <b>{name}@{version}</b>?</p>
|
||||
<p>Description: <em>{description}</em></p>
|
||||
<div className="remove-folder-warning" onClick={() => shell.openPath(extensionFolder)}>
|
||||
<b>Warning:</b> {name}@{oldVersion} will be removed before installation.
|
||||
</div>
|
||||
</div>
|
||||
<Button autoFocus label="Install" onClick={async () => {
|
||||
removeNotification();
|
||||
|
||||
if (await uninstallExtension(validatedRequest.id)) {
|
||||
await unpackExtension(validatedRequest, dispose);
|
||||
} else {
|
||||
dispose();
|
||||
}
|
||||
}} />
|
||||
</div>,
|
||||
{
|
||||
onClose: dispose,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function attemptInstalls(filePaths: string[]): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
promises.push(attemptInstall({
|
||||
fileName: path.basename(filePath),
|
||||
dataP: readFileNotify(filePath),
|
||||
}));
|
||||
}
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
async function installOnDrop(files: File[]) {
|
||||
logger.info("Install from D&D");
|
||||
await attemptInstalls(files.map(({ path }) => path));
|
||||
}
|
||||
|
||||
async function installFromInput(input: string) {
|
||||
let disposer: ExtendableDisposer | undefined = undefined;
|
||||
|
||||
try {
|
||||
this.extensionStateStore.extensionState.set(extension.id, {
|
||||
state: "uninstalling",
|
||||
displayName
|
||||
// fixme: improve error messages for non-tar-file URLs
|
||||
if (InputValidators.isUrl.validate(input)) {
|
||||
// install via url
|
||||
disposer = ExtensionInstallationStateStore.startPreInstall();
|
||||
const { promise } = downloadFile({ url: input, timeout: 10 * 60 * 1000 });
|
||||
const fileName = path.basename(input);
|
||||
|
||||
await attemptInstall({ fileName, dataP: promise }, disposer);
|
||||
} else if (InputValidators.isPath.validate(input)) {
|
||||
// install from system path
|
||||
const fileName = path.basename(input);
|
||||
|
||||
await attemptInstall({ fileName, dataP: readFileNotify(input) });
|
||||
} else if (InputValidators.isExtensionNameInstall.validate(input)) {
|
||||
const [{ groups: { name, version }}] = [...input.matchAll(InputValidators.isExtensionNameInstallRegex)];
|
||||
|
||||
await attemptInstallByInfo({ name, version });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = getMessageFromError(error);
|
||||
|
||||
logger.info(`[EXTENSION-INSTALL]: installation has failed: ${message}`, { error, installPath: input });
|
||||
Notifications.error(<p>Installation has failed: <b>{message}</b></p>);
|
||||
} finally {
|
||||
disposer?.();
|
||||
}
|
||||
}
|
||||
|
||||
const supportedFormats = ["tar", "tgz"];
|
||||
|
||||
async function installFromSelectFileDialog() {
|
||||
const { dialog, BrowserWindow, app } = remote;
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), {
|
||||
defaultPath: app.getPath("downloads"),
|
||||
properties: ["openFile", "multiSelections"],
|
||||
message: `Select extensions to install (formats: ${supportedFormats.join(", ")}), `,
|
||||
buttonLabel: "Use configuration",
|
||||
filters: [
|
||||
{ name: "tarball", extensions: supportedFormats }
|
||||
]
|
||||
});
|
||||
|
||||
await extensionDiscovery.uninstallExtension(extension);
|
||||
} catch (error) {
|
||||
Notifications.error(
|
||||
<p>Uninstalling extension <b>{displayName}</b> has failed: <em>{error?.message ?? ""}</em></p>
|
||||
if (!canceled) {
|
||||
await attemptInstalls(filePaths);
|
||||
}
|
||||
}
|
||||
|
||||
@observer
|
||||
export class Extensions extends React.Component {
|
||||
private static installInputValidators = [
|
||||
InputValidators.isUrl,
|
||||
InputValidators.isPath,
|
||||
InputValidators.isExtensionNameInstall,
|
||||
];
|
||||
|
||||
private static installInputValidator: InputValidator = {
|
||||
message: "Invalid URL, absolute path, or extension name",
|
||||
validate: (value: string) => (
|
||||
Extensions.installInputValidators.some(({ validate }) => validate(value))
|
||||
),
|
||||
};
|
||||
|
||||
@observable search = "";
|
||||
@observable installPath = "";
|
||||
|
||||
@computed get searchedForExtensions() {
|
||||
const searchText = this.search.toLowerCase();
|
||||
|
||||
return Array.from(extensionLoader.userExtensions.values())
|
||||
.filter(({ manifest: { name, description }}) => (
|
||||
name.toLowerCase().includes(searchText)
|
||||
|| description?.toLowerCase().includes(searchText)
|
||||
));
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// TODO: change this after upgrading to mobx6 as that versions' reactions have this functionality
|
||||
let prevSize = extensionLoader.userExtensions.size;
|
||||
|
||||
disposeOnUnmount(this, [
|
||||
reaction(() => extensionLoader.userExtensions.size, curSize => {
|
||||
try {
|
||||
if (curSize > prevSize) {
|
||||
when(() => !ExtensionInstallationStateStore.anyInstalling)
|
||||
.then(() => this.installPath = "");
|
||||
}
|
||||
} finally {
|
||||
prevSize = curSize;
|
||||
}
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
renderNoExtensionsHelpText() {
|
||||
if (this.search) {
|
||||
return <p>No search results found</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<p>
|
||||
There are no installed extensions.
|
||||
See list of <a href="https://github.com/lensapp/lens-extensions/blob/main/README.md" target="_blank" rel="noreferrer">available extensions</a>.
|
||||
</p>
|
||||
);
|
||||
|
||||
// Remove uninstall state on uninstall failure
|
||||
if (this.extensionStateStore.extensionState.get(extension.id)?.state === "uninstalling") {
|
||||
this.extensionStateStore.extensionState.delete(extension.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderExtensions() {
|
||||
const { extensions, search } = this;
|
||||
|
||||
if (!extensions.length) {
|
||||
renderNoExtensions() {
|
||||
return (
|
||||
<div className="no-extensions flex box gaps justify-center">
|
||||
<Icon material="info"/>
|
||||
<Icon material="info" />
|
||||
<div>
|
||||
{
|
||||
search
|
||||
? <p>No search results found</p>
|
||||
: <p>There are no installed extensions. See list of <a href="https://github.com/lensapp/lens-extensions/blob/main/README.md" target="_blank" rel="noreferrer">available extensions</a>.</p>
|
||||
}
|
||||
{this.renderNoExtensionsHelpText()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return extensions.map(extension => {
|
||||
@autobind()
|
||||
renderExtension(extension: InstalledExtension) {
|
||||
const { id, isEnabled, manifest } = extension;
|
||||
const { name, description, version } = manifest;
|
||||
const isUninstalling = this.extensionStateStore.extensionState.get(id)?.state === "uninstalling";
|
||||
const isUninstalling = ExtensionInstallationStateStore.isExtensionUninstalling(id);
|
||||
|
||||
return (
|
||||
<div key={id} className="extension flex gaps align-center">
|
||||
@ -455,30 +527,41 @@ export class Extensions extends React.Component {
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
{!isEnabled && (
|
||||
<Button plain active disabled={isUninstalling} onClick={() => {
|
||||
extension.isEnabled = true;
|
||||
}}>Enable</Button>
|
||||
)}
|
||||
{isEnabled && (
|
||||
<Button accent disabled={isUninstalling} onClick={() => {
|
||||
extension.isEnabled = false;
|
||||
}}>Disable</Button>
|
||||
)}
|
||||
<Button plain active disabled={isUninstalling} waiting={isUninstalling} onClick={() => {
|
||||
this.confirmUninstallExtension(extension);
|
||||
}}>Uninstall</Button>
|
||||
{
|
||||
isEnabled
|
||||
? <Button accent disabled={isUninstalling} onClick={() => extension.isEnabled = false}>Disable</Button>
|
||||
: <Button plain active disabled={isUninstalling} onClick={() => extension.isEnabled = true}>Enable</Button>
|
||||
}
|
||||
<Button
|
||||
plain
|
||||
active
|
||||
disabled={isUninstalling}
|
||||
waiting={isUninstalling}
|
||||
onClick={() => confirmUninstallExtension(extension)}
|
||||
>
|
||||
Uninstall
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* True if at least one extension is in installing state
|
||||
*/
|
||||
@computed get isInstalling() {
|
||||
return [...this.extensionStateStore.extensionState.values()].some(extension => extension.state === "installing");
|
||||
renderExtensions() {
|
||||
if (!extensionDiscovery.isLoaded) {
|
||||
return <div className="spinner-wrapper"><Spinner /></div>;
|
||||
}
|
||||
|
||||
const { searchedForExtensions } = this;
|
||||
|
||||
if (!searchedForExtensions.length) {
|
||||
return this.renderNoExtensions();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{...searchedForExtensions.map(this.renderExtension)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
@ -486,7 +569,7 @@ export class Extensions extends React.Component {
|
||||
const { installPath } = this;
|
||||
|
||||
return (
|
||||
<DropFileInput onDropFiles={this.installOnDrop}>
|
||||
<DropFileInput onDropFiles={installOnDrop}>
|
||||
<PageLayout showOnTop className="Extensions" header={topHeader} contentGaps={false}>
|
||||
<h2>Lens Extensions</h2>
|
||||
<div>
|
||||
@ -500,19 +583,19 @@ export class Extensions extends React.Component {
|
||||
<Input
|
||||
className="box grow"
|
||||
theme="round-black"
|
||||
disabled={this.isInstalling}
|
||||
placeholder={`Path or URL to an extension package (${Extensions.supportedFormats.join(", ")})`}
|
||||
disabled={ExtensionInstallationStateStore.anyPreInstallingOrInstalling}
|
||||
placeholder={`Name or file path or URL to an extension package (${supportedFormats.join(", ")})`}
|
||||
showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }}
|
||||
validators={installPath ? Extensions.installPathValidator : undefined}
|
||||
validators={installPath ? Extensions.installInputValidator : undefined}
|
||||
value={installPath}
|
||||
onChange={value => this.installPath = value}
|
||||
onSubmit={this.installFromUrlOrPath}
|
||||
onSubmit={() => installFromInput(this.installPath)}
|
||||
iconLeft="link"
|
||||
iconRight={
|
||||
<Icon
|
||||
interactive
|
||||
material="folder"
|
||||
onClick={prevDefault(this.installFromSelectFileDialog)}
|
||||
onClick={prevDefault(installFromSelectFileDialog)}
|
||||
tooltip="Browse"
|
||||
/>
|
||||
}
|
||||
@ -521,9 +604,9 @@ export class Extensions extends React.Component {
|
||||
<Button
|
||||
primary
|
||||
label="Install"
|
||||
disabled={this.isInstalling || !Extensions.installPathValidator.validate(installPath)}
|
||||
waiting={this.isInstalling}
|
||||
onClick={this.installFromUrlOrPath}
|
||||
disabled={ExtensionInstallationStateStore.anyPreInstallingOrInstalling || !Extensions.installInputValidator.validate(installPath)}
|
||||
waiting={ExtensionInstallationStateStore.anyPreInstallingOrInstalling}
|
||||
onClick={() => installFromInput(this.installPath)}
|
||||
/>
|
||||
<small className="hint">
|
||||
<b>Pro-Tip</b>: you can also drag-n-drop tarball-file to this area
|
||||
@ -537,7 +620,7 @@ export class Extensions extends React.Component {
|
||||
value={this.search}
|
||||
onChange={(value) => this.search = value}
|
||||
/>
|
||||
{extensionDiscovery.isLoaded ? this.renderExtensions() : <div className="spinner-wrapper"><Spinner/></div>}
|
||||
{this.renderExtensions()}
|
||||
</div>
|
||||
</PageLayout>
|
||||
</DropFileInput>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import "./button.scss";
|
||||
import React, { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import React, { ButtonHTMLAttributes } from "react";
|
||||
import { cssNames } from "../../utils";
|
||||
import { TooltipDecoratorProps, withTooltip } from "../tooltip";
|
||||
|
||||
@ -26,29 +26,22 @@ export class Button extends React.PureComponent<ButtonProps, {}> {
|
||||
|
||||
render() {
|
||||
const {
|
||||
className, waiting, label, primary, accent, plain, hidden, active, big,
|
||||
round, outlined, tooltip, light, children, ...props
|
||||
waiting, label, primary, accent, plain, hidden, active, big,
|
||||
round, outlined, tooltip, light, children, ...btnProps
|
||||
} = this.props;
|
||||
const btnProps: Partial<ButtonProps> = props;
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
btnProps.className = cssNames("Button", className, {
|
||||
btnProps.className = cssNames("Button", btnProps.className, {
|
||||
waiting, primary, accent, plain, active, big, round, outlined, light,
|
||||
});
|
||||
|
||||
const btnContent: ReactNode = (
|
||||
<>
|
||||
{label}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
// render as link
|
||||
if (this.props.href) {
|
||||
return (
|
||||
<a {...btnProps} ref={e => this.link = e}>
|
||||
{btnContent}
|
||||
{label}
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@ -56,7 +49,8 @@ export class Button extends React.PureComponent<ButtonProps, {}> {
|
||||
// render as button
|
||||
return (
|
||||
<button type="button" {...btnProps} ref={e => this.button = e}>
|
||||
{btnContent}
|
||||
{label}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@ -11,14 +11,18 @@ import { Icon } from "../icon";
|
||||
export interface ConfirmDialogProps extends Partial<DialogProps> {
|
||||
}
|
||||
|
||||
export interface ConfirmDialogParams {
|
||||
ok?: () => void;
|
||||
export interface ConfirmDialogParams extends ConfirmDialogBooleanParams {
|
||||
ok?: () => any | Promise<any>;
|
||||
cancel?: () => any | Promise<any>;
|
||||
}
|
||||
|
||||
export interface ConfirmDialogBooleanParams {
|
||||
labelOk?: ReactNode;
|
||||
labelCancel?: ReactNode;
|
||||
message?: ReactNode;
|
||||
message: ReactNode;
|
||||
icon?: ReactNode;
|
||||
okButtonProps?: Partial<ButtonProps>
|
||||
cancelButtonProps?: Partial<ButtonProps>
|
||||
okButtonProps?: Partial<ButtonProps>;
|
||||
cancelButtonProps?: Partial<ButtonProps>;
|
||||
}
|
||||
|
||||
@observer
|
||||
@ -33,19 +37,26 @@ export class ConfirmDialog extends React.Component<ConfirmDialogProps> {
|
||||
ConfirmDialog.params = params;
|
||||
}
|
||||
|
||||
static close() {
|
||||
ConfirmDialog.isOpen = false;
|
||||
static confirm(params: ConfirmDialogBooleanParams): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
ConfirmDialog.open({
|
||||
ok: () => resolve(true),
|
||||
cancel: () => resolve(false),
|
||||
...params,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public defaultParams: ConfirmDialogParams = {
|
||||
static defaultParams: Partial<ConfirmDialogParams> = {
|
||||
ok: noop,
|
||||
cancel: noop,
|
||||
labelOk: "Ok",
|
||||
labelCancel: "Cancel",
|
||||
icon: <Icon big material="warning"/>,
|
||||
};
|
||||
|
||||
get params(): ConfirmDialogParams {
|
||||
return Object.assign({}, this.defaultParams, ConfirmDialog.params);
|
||||
return Object.assign({}, ConfirmDialog.defaultParams, ConfirmDialog.params);
|
||||
}
|
||||
|
||||
ok = async () => {
|
||||
@ -54,16 +65,21 @@ export class ConfirmDialog extends React.Component<ConfirmDialogProps> {
|
||||
await Promise.resolve(this.params.ok()).catch(noop);
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
ConfirmDialog.isOpen = false;
|
||||
}
|
||||
this.close();
|
||||
};
|
||||
|
||||
onClose = () => {
|
||||
this.isSaving = false;
|
||||
};
|
||||
|
||||
close = () => {
|
||||
ConfirmDialog.close();
|
||||
close = async () => {
|
||||
try {
|
||||
await Promise.resolve(this.params.cancel()).catch(noop);
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
ConfirmDialog.isOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
@ -315,6 +315,7 @@ export class Input extends React.Component<InputProps, State> {
|
||||
rows: multiLine ? (rows || 1) : null,
|
||||
ref: this.bindRef,
|
||||
spellCheck: "false",
|
||||
disabled,
|
||||
});
|
||||
const showErrors = errors.length > 0 && !valid && dirty;
|
||||
const errorsInfo = (
|
||||
|
||||
@ -47,6 +47,14 @@ export const isUrl: InputValidator = {
|
||||
},
|
||||
};
|
||||
|
||||
export const isExtensionNameInstallRegex = /^(?<name>(@[-\w]+\/)?[-\w]+)(@(?<version>\d\.\d\.\d(-\w+)?))?$/gi;
|
||||
|
||||
export const isExtensionNameInstall: InputValidator = {
|
||||
condition: ({ type }) => type === "text",
|
||||
message: () => "Not an extension name with optional version",
|
||||
validate: value => value.match(isExtensionNameInstallRegex) !== null,
|
||||
};
|
||||
|
||||
export const isPath: InputValidator = {
|
||||
condition: ({ type }) => type === "text",
|
||||
message: () => `This field must be a valid path`,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { addClusterURL } from "../components/+add-cluster";
|
||||
import { clusterSettingsURL } from "../components/+cluster-settings";
|
||||
import { extensionsURL } from "../components/+extensions";
|
||||
import { attemptInstallByInfo, extensionsURL } from "../components/+extensions";
|
||||
import { landingURL } from "../components/+landing-page";
|
||||
import { preferencesURL } from "../components/+preferences";
|
||||
import { clusterViewURL } from "../components/cluster-manager/cluster-view.route";
|
||||
@ -8,6 +8,7 @@ import { LensProtocolRouterRenderer } from "./router";
|
||||
import { navigate } from "../navigation/helpers";
|
||||
import { clusterStore } from "../../common/cluster-store";
|
||||
import { workspaceStore } from "../../common/workspace-store";
|
||||
import { EXTENSION_NAME_MATCH, EXTENSION_PUBLISHER_MATCH, LensProtocolRouter } from "../../common/protocol-handler";
|
||||
|
||||
export function bindProtocolAddRouteHandlers() {
|
||||
LensProtocolRouterRenderer
|
||||
@ -54,5 +55,15 @@ export function bindProtocolAddRouteHandlers() {
|
||||
})
|
||||
.addInternalHandler("/extensions", () => {
|
||||
navigate(extensionsURL());
|
||||
})
|
||||
.addInternalHandler(`/extensions/install${LensProtocolRouter.ExtensionUrlSchema}`, ({ pathname, search: { version } }) => {
|
||||
const name = [
|
||||
pathname[EXTENSION_PUBLISHER_MATCH],
|
||||
pathname[EXTENSION_NAME_MATCH],
|
||||
].filter(Boolean)
|
||||
.join("/");
|
||||
|
||||
navigate(extensionsURL());
|
||||
attemptInstallByInfo({ name, version, requireConfirmation: true });
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user