1
0
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:
Sebastian Malton 2021-04-19 13:50:51 -04:00 committed by GitHub
parent 96258c369f
commit 86205b365c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 994 additions and 673 deletions

View File

@ -23,8 +23,8 @@ export const ProtocolHandlerExtension= `${ProtocolHandlerIpcPrefix}:extension`;
* Though under the current (2021/01/18) implementation, these are never matched * 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. * against in the final matching so their names are less of a concern.
*/ */
const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH"; export const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH";
const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH"; export const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH";
export abstract class LensProtocolRouter extends Singleton { export abstract class LensProtocolRouter extends Singleton {
// Map between path schemas and the handlers // Map between path schemas and the handlers
@ -32,7 +32,7 @@ export abstract class LensProtocolRouter extends Singleton {
public static readonly LoggingPrefix = "[PROTOCOL ROUTER]"; 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}`;
/** /**
* *

View 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;
}

View File

@ -6,13 +6,13 @@ export interface DownloadFileOptions {
timeout?: number; timeout?: number;
} }
export interface DownloadFileTicket { export interface DownloadFileTicket<T> {
url: string; url: string;
promise: Promise<Buffer>; promise: Promise<T>;
cancel(): void; 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 fileChunks: Buffer[] = [];
const req = request(url, { gzip, timeout }); const req = request(url, { gzip, timeout });
const promise: Promise<Buffer> = new Promise((resolve, reject) => { 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
};
}

View File

@ -19,3 +19,4 @@ export * from "./downloadFile";
export * from "./escapeRegExp"; export * from "./escapeRegExp";
export * from "./tar"; export * from "./tar";
export * from "./type-narrowing"; export * from "./type-narrowing";
export * from "./disposer";

View File

@ -1,9 +1,11 @@
import mockFs from "mock-fs";
import { watch } from "chokidar"; import { watch } from "chokidar";
import { join, normalize } from "path"; import path from "path";
import { ExtensionDiscovery, InstalledExtension } from "../extension-discovery"; import { ExtensionDiscovery } from "../extension-discovery";
import os from "os";
import { Console } from "console";
jest.mock("../../common/ipc"); jest.mock("../../common/ipc");
jest.mock("fs-extra");
jest.mock("chokidar", () => ({ jest.mock("chokidar", () => ({
watch: jest.fn() 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>; const mockedWatch = watch as jest.MockedFunction<typeof watch>;
describe("ExtensionDiscovery", () => { describe("ExtensionDiscovery", () => {
it("emits add for added extension", async done => { describe("with mockFs", () => {
globalThis.__non_webpack_require__.mockImplementation(() => ({ beforeEach(() => {
mockFs({
[`${os.homedir()}/.k8slens/extensions/my-extension/package.json`]: JSON.stringify({
name: "my-extension" name: "my-extension"
})); }),
});
});
afterEach(() => {
mockFs.restore();
});
it("emits add for added extension", async (done) => {
let addHandler: (filePath: string) => void; let addHandler: (filePath: string) => void;
const mockWatchInstance: any = { const mockWatchInstance: any = {
@ -43,21 +56,22 @@ describe("ExtensionDiscovery", () => {
await extensionDiscovery.watchExtensions(); await extensionDiscovery.watchExtensions();
extensionDiscovery.events.on("add", (extension: InstalledExtension) => { extensionDiscovery.events.on("add", extension => {
expect(extension).toEqual({ expect(extension).toEqual({
absolutePath: expect.any(String), absolutePath: expect.any(String),
id: normalize("node_modules/my-extension/package.json"), id: path.normalize("node_modules/my-extension/package.json"),
isBundled: false, isBundled: false,
isEnabled: false, isEnabled: false,
manifest: { manifest: {
name: "my-extension", name: "my-extension",
}, },
manifestPath: normalize("node_modules/my-extension/package.json"), manifestPath: path.normalize("node_modules/my-extension/package.json"),
}); });
done(); 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 => { it("doesn't emit add for added file under extension", async done => {
@ -87,7 +101,7 @@ describe("ExtensionDiscovery", () => {
extensionDiscovery.events.on("add", onAdd); 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(() => { setTimeout(() => {
expect(onAdd).not.toHaveBeenCalled(); expect(onAdd).not.toHaveBeenCalled();

View File

@ -1,14 +1,16 @@
import { watch } from "chokidar"; import { watch } from "chokidar";
import { ipcRenderer } from "electron"; import { ipcRenderer } from "electron";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import fs from "fs-extra"; import fse from "fs-extra";
import { observable, reaction, toJS, when } from "mobx"; import { observable, reaction, toJS, when } from "mobx";
import os from "os"; import os from "os";
import path from "path"; import path from "path";
import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc"; import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc";
import { getBundledExtensions } from "../common/utils/app-version"; import { getBundledExtensions } from "../common/utils/app-version";
import logger from "../main/logger"; import logger from "../main/logger";
import { ExtensionInstallationStateStore } from "../renderer/components/+extensions/extension-install.store";
import { extensionInstaller, PackageJson } from "./extension-installer"; import { extensionInstaller, PackageJson } from "./extension-installer";
import { extensionLoader } from "./extension-loader";
import { extensionsStore } from "./extensions-store"; import { extensionsStore } from "./extensions-store";
import type { LensExtensionId, LensExtensionManifest } from "./lens-extension"; import type { LensExtensionId, LensExtensionManifest } from "./lens-extension";
@ -39,7 +41,7 @@ interface ExtensionDiscoveryChannelMessage {
* Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link) * Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link)
* @param lstat the stats to compare * @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. * 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 // IPC channel to broadcast changes to extension-discovery from main
protected static readonly extensionDiscoveryChannel = "extension-discovery:main"; protected static readonly extensionDiscoveryChannel = "extension-discovery:main";
public events: EventEmitter; public events = new EventEmitter();
constructor() {
this.events = new EventEmitter();
}
get localFolderPath(): string { get localFolderPath(): string {
return path.join(os.homedir(), ".k8slens", "extensions"); return path.join(os.homedir(), ".k8slens", "extensions");
@ -136,7 +134,7 @@ export class ExtensionDiscovery {
depth: 1, depth: 1,
ignoreInitial: true, ignoreInitial: true,
// Try to wait until the file has been completely copied. // 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: { awaitWriteFinish: {
// Wait 300ms until the file size doesn't change to consider the file written. // 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. // 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 // Extension add is detected by watching "<extensionDir>/package.json" add
.on("add", this.handleWatchFileAdd) .on("add", this.handleWatchFileAdd)
// Extension remove is detected by watching <extensionDir>" unlink // Extension remove is detected by watching "<extensionDir>" unlink
.on("unlinkDir", this.handleWatchUnlinkDir); .on("unlinkDir", this.handleWatchUnlinkEvent)
// Extension remove is detected by watching "<extensionSymLink>" unlink
.on("unlink", this.handleWatchUnlinkEvent);
} }
handleWatchFileAdd = async (manifestPath: string) => { handleWatchFileAdd = async (manifestPath: string) => {
@ -160,6 +160,7 @@ export class ExtensionDiscovery {
if (path.basename(manifestPath) === manifestFilename && isUnderLocalFolderPath) { if (path.basename(manifestPath) === manifestFilename && isUnderLocalFolderPath) {
try { try {
ExtensionInstallationStateStore.setInstallingFromMain(manifestPath);
const absPath = path.dirname(manifestPath); const absPath = path.dirname(manifestPath);
// this.loadExtensionFromPath updates this.packagesJson // this.loadExtensionFromPath updates this.packagesJson
@ -167,7 +168,7 @@ export class ExtensionDiscovery {
if (extension) { if (extension) {
// Remove a broken symlink left by a previous installation if it exists. // 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 // Install dependencies for the new extension
await this.installPackage(extension.absolutePath); await this.installPackage(extension.absolutePath);
@ -177,40 +178,46 @@ export class ExtensionDiscovery {
this.events.emit("add", extension); this.events.emit("add", extension);
} }
} catch (error) { } 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 * Handle any unlink event, filtering out non-package.json links so the delete code
// this.packagesJson.dependencies value is the non-symlinked path to the extension folder * only happens once per extension.
// LensExtensionId in extension-loader is the symlinked path to the extension folder manifest file * @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 // Check that the removed path is directly under this.localFolderPath
// Note that the watcher can create unlink events for subdirectories of the extension // Note that the watcher can create unlink events for subdirectories of the extension
const extensionFolderName = path.basename(filePath); 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); 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; const extensionName = extension.manifest.name;
// If the extension is deleted manually while the application is running, also remove the symlink // If the extension is deleted manually while the application is running, also remove the symlink
await this.removeSymlinkByPackageName(extensionName); await this.removeSymlinkByPackageName(extensionName);
// The path to the manifest file is the lens extension id // 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; const lensExtensionId = extension.manifestPath;
this.extensions.delete(extension.id); this.extensions.delete(extension.id);
logger.info(`${logModule} removed extension ${extensionName}`); logger.info(`${logModule} removed extension ${extensionName}`);
this.events.emit("remove", lensExtensionId as LensExtensionId); this.events.emit("remove", lensExtensionId);
} else {
logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`);
}
}
}; };
/** /**
@ -220,31 +227,23 @@ export class ExtensionDiscovery {
* @param name e.g. "@mirantis/lens-extension-cc" * @param name e.g. "@mirantis/lens-extension-cc"
*/ */
removeSymlinkByPackageName(name: string) { removeSymlinkByPackageName(name: string) {
return fs.remove(this.getInstalledPath(name)); return fse.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);
} }
/** /**
* Uninstalls extension. * Uninstalls extension.
* The application will detect the folder unlink and remove the extension from the UI automatically. * 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}`); logger.info(`${logModule} Uninstalling ${manifest.name}`);
await this.removeSymlinkByPackageName(manifest.name); await this.removeSymlinkByPackageName(manifest.name);
// fs.remove does nothing if the path doesn't exist anymore // 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>> { async load(): Promise<Map<LensExtensionId, InstalledExtension>> {
@ -258,12 +257,11 @@ export class ExtensionDiscovery {
logger.info(`${logModule} loading extensions from ${extensionInstaller.extensionPackagesRoot}`); logger.info(`${logModule} loading extensions from ${extensionInstaller.extensionPackagesRoot}`);
// fs.remove won't throw if path is missing // 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 { try {
// Verify write access to static/extensions, which is needed for symlinking // 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 // Set bundled folder path to static/extensions
this.bundledFolderPath = this.inTreeFolderPath; 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. // 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 // 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 // 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 // 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 // Set bundled folder path to e.g. /Users/<username>/Library/Application Support/LensDev/extensions
this.bundledFolderPath = this.inTreeTargetPath; this.bundledFolderPath = this.inTreeTargetPath;
} }
await fs.ensureDir(this.nodeModulesPath); await fse.ensureDir(this.nodeModulesPath);
await fs.ensureDir(this.localFolderPath); await fse.ensureDir(this.localFolderPath);
const extensions = await this.ensureExtensions(); const extensions = await this.ensureExtensions();
@ -314,30 +312,22 @@ export class ExtensionDiscovery {
* Returns InstalledExtension from path to package.json file. * Returns InstalledExtension from path to package.json file.
* Also updates this.packagesJson. * Also updates this.packagesJson.
*/ */
protected async getByManifest(manifestPath: string, { isBundled = false }: { protected async getByManifest(manifestPath: string, { isBundled = false } = {}): Promise<InstalledExtension | null> {
isBundled?: boolean;
} = {}): Promise<InstalledExtension | null> {
let manifestJson: LensExtensionManifest;
try { try {
// check manifest file for existence const manifest = await fse.readJson(manifestPath);
fs.accessSync(manifestPath, fs.constants.F_OK); const installedManifestPath = this.getInstalledManifestPath(manifest.name);
manifestJson = __non_webpack_require__(manifestPath);
const installedManifestPath = this.getInstalledManifestPath(manifestJson.name);
const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath); const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath);
return { return {
id: installedManifestPath, id: installedManifestPath,
absolutePath: path.dirname(manifestPath), absolutePath: path.dirname(manifestPath),
manifestPath: installedManifestPath, manifestPath: installedManifestPath,
manifest: manifestJson, manifest,
isBundled, isBundled,
isEnabled isEnabled
}; };
} catch (error) { } 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; return null;
} }
@ -351,7 +341,7 @@ export class ExtensionDiscovery {
const userExtensions = await this.loadFromFolder(this.localFolderPath); const userExtensions = await this.loadFromFolder(this.localFolderPath);
for (const extension of userExtensions) { for (const extension of userExtensions) {
if (await fs.pathExists(extension.manifestPath) === false) { if (await fse.pathExists(extension.manifestPath) === false) {
await this.installPackage(extension.absolutePath); await this.installPackage(extension.absolutePath);
} }
} }
@ -383,7 +373,7 @@ export class ExtensionDiscovery {
const extensions: InstalledExtension[] = []; const extensions: InstalledExtension[] = [];
const folderPath = this.bundledFolderPath; const folderPath = this.bundledFolderPath;
const bundledExtensions = getBundledExtensions(); const bundledExtensions = getBundledExtensions();
const paths = await fs.readdir(folderPath); const paths = await fse.readdir(folderPath);
for (const fileName of paths) { for (const fileName of paths) {
if (!bundledExtensions.includes(fileName)) { if (!bundledExtensions.includes(fileName)) {
@ -405,7 +395,7 @@ export class ExtensionDiscovery {
async loadFromFolder(folderPath: string): Promise<InstalledExtension[]> { async loadFromFolder(folderPath: string): Promise<InstalledExtension[]> {
const bundledExtensions = getBundledExtensions(); const bundledExtensions = getBundledExtensions();
const extensions: InstalledExtension[] = []; const extensions: InstalledExtension[] = [];
const paths = await fs.readdir(folderPath); const paths = await fse.readdir(folderPath);
for (const fileName of paths) { for (const fileName of paths) {
// do not allow to override bundled extensions // do not allow to override bundled extensions
@ -415,11 +405,11 @@ export class ExtensionDiscovery {
const absPath = path.resolve(folderPath, fileName); const absPath = path.resolve(folderPath, fileName);
if (!fs.existsSync(absPath)) { if (!fse.existsSync(absPath)) {
continue; continue;
} }
const lstat = await fs.lstat(absPath); const lstat = await fse.lstat(absPath);
// skip non-directories // skip non-directories
if (!isDirectoryLike(lstat)) { if (!isDirectoryLike(lstat)) {

View File

@ -12,8 +12,6 @@ import type { LensExtension, LensExtensionConstructor, LensExtensionId } from ".
import type { LensMainExtension } from "./lens-main-extension"; import type { LensMainExtension } from "./lens-main-extension";
import type { LensRendererExtension } from "./lens-renderer-extension"; import type { LensRendererExtension } from "./lens-renderer-extension";
import * as registries from "./registries"; import * as registries from "./registries";
import fs from "fs";
export function extensionPackagesRoot() { export function extensionPackagesRoot() {
return path.join((app || remote.app).getPath("userData")); return path.join((app || remote.app).getPath("userData"));
@ -290,28 +288,20 @@ export class ExtensionLoader {
}); });
} }
protected requireExtension(extension: InstalledExtension): LensExtensionConstructor { protected requireExtension(extension: InstalledExtension): LensExtensionConstructor | null {
let extEntrypoint = ""; 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 { try {
if (ipcRenderer && extension.manifest.renderer) { return __non_webpack_require__(extAbsolutePath).default;
extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.renderer)); } catch (error) {
} else if (!ipcRenderer && extension.manifest.main) { logger.error(`${logModule}: can't load extension main at ${extAbsolutePath}: ${error}`, { extension, error });
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);
} }
} }

View File

@ -19,6 +19,7 @@ import { filesystemProvisionerStore } from "../main/extension-filesystem";
import { App } from "./components/app"; import { App } from "./components/app";
import { LensApp } from "./lens-app"; import { LensApp } from "./lens-app";
import { themeStore } from "./theme.store"; import { themeStore } from "./theme.store";
import { ExtensionInstallationStateStore } from "./components/+extensions/extension-install.store";
/** /**
* If this is a development buid, wait a second to attach * If this is a development buid, wait a second to attach
@ -50,6 +51,7 @@ export async function bootstrap(App: AppComponent) {
await attachChromeDebugger(); await attachChromeDebugger();
rootElem.classList.toggle("is-mac", isMac); rootElem.classList.toggle("is-mac", isMac);
ExtensionInstallationStateStore.bindIpcListeners();
extensionLoader.init(); extensionLoader.init();
extensionDiscovery.init(); extensionDiscovery.init();

View File

@ -1,14 +1,15 @@
import "@testing-library/jest-dom/extend-expect"; 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 fse from "fs-extra";
import React from "react"; import React from "react";
import { extensionDiscovery } from "../../../../extensions/extension-discovery"; import { extensionDiscovery } from "../../../../extensions/extension-discovery";
import { ConfirmDialog } from "../../confirm-dialog"; import { ConfirmDialog } from "../../confirm-dialog";
import { Notifications } from "../../notifications"; import { ExtensionInstallationStateStore } from "../extension-install.store";
import { ExtensionStateStore } from "../extension-install.store";
import { Extensions } from "../extensions"; import { Extensions } from "../extensions";
jest.setTimeout(30000);
jest.mock("fs-extra"); jest.mock("fs-extra");
jest.mock("../../notifications");
jest.mock("../../../../common/utils", () => ({ jest.mock("../../../../common/utils", () => ({
...jest.requireActual("../../../../common/utils"), ...jest.requireActual("../../../../common/utils"),
@ -42,62 +43,42 @@ jest.mock("../../../../extensions/extension-loader", () => ({
isBundled: false, isBundled: false,
isEnabled: true isEnabled: true
}] }]
]) ]),
getExtension: jest.fn(() => ({ manifest: {} })),
} }
})); }));
jest.mock("../../notifications", () => ({
ok: jest.fn(),
error: jest.fn(),
info: jest.fn()
}));
describe("Extensions", () => { describe("Extensions", () => {
beforeEach(() => { beforeEach(() => {
ExtensionStateStore.resetInstance(); ExtensionInstallationStateStore.reset();
}); });
it("disables uninstall and disable buttons while uninstalling", async () => { 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(res.getByText("Disable").closest("button")).not.toBeDisabled();
expect(screen.getByText("Uninstall").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 // Approve confirm dialog
fireEvent.click(screen.getByText("Yes")); fireEvent.click(res.getByText("Yes"));
await waitFor(() => {
expect(extensionDiscovery.uninstallExtension).toHaveBeenCalled(); expect(extensionDiscovery.uninstallExtension).toHaveBeenCalled();
expect(screen.getByText("Disable").closest("button")).toBeDisabled(); expect(res.getByText("Disable").closest("button")).toBeDisabled();
expect(screen.getByText("Uninstall").closest("button")).toBeDisabled(); expect(res.getByText("Uninstall").closest("button")).toBeDisabled();
}); }, {
timeout: 30000,
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);
}); });
}); });
it("disables install button while installing", () => { it("disables install button while installing", async () => {
render(<Extensions />); 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 exact: false
}), { }), {
target: { target: {
@ -105,25 +86,21 @@ describe("Extensions", () => {
} }
}); });
fireEvent.click(screen.getByText("Install")); fireEvent.click(res.getByText("Install"));
expect(res.getByText("Install").closest("button")).toBeDisabled();
waitFor(() => {
expect(screen.getByText("Install").closest("button")).toBeDisabled();
expect(fse.move).toHaveBeenCalledWith("");
expect(Notifications.error).not.toHaveBeenCalled();
});
}); });
it("displays spinner while extensions are loading", () => { it("displays spinner while extensions are loading", async () => {
extensionDiscovery.isLoaded = false; 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; extensionDiscovery.isLoaded = true;
const res = render(<Extensions />);
waitFor(() => expect(res.container.querySelector(".Spinner")).not.toBeInTheDocument();
expect(container.querySelector(".Spinner")).not.toBeInTheDocument()
);
}); });
}); });

View File

@ -1,13 +1,218 @@
import { observable } from "mobx"; import { action, computed, observable } from "mobx";
import { autobind, Singleton } from "../../utils"; 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 { export enum ExtensionInstallationState {
displayName: string; INSTALLING = "installing",
// Possible states the extension can be UNINSTALLING = "uninstalling",
state: "installing" | "uninstalling"; IDLE = "idle",
} }
@autobind() const Prefix = "[ExtensionInstallationStore]";
export class ExtensionStateStore extends Singleton {
extensionState = observable.map<string, ExtensionState>(); 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;
}
} }

View File

@ -1,15 +1,16 @@
import "./extensions.scss";
import { remote, shell } from "electron"; import { remote, shell } from "electron";
import fse from "fs-extra"; 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 { disposeOnUnmount, observer } from "mobx-react";
import os from "os"; import os from "os";
import path from "path"; import path from "path";
import React from "react"; 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 { docsUrl } from "../../../common/vars";
import { extensionDiscovery, InstalledExtension, manifestFilename } from "../../../extensions/extension-discovery"; import { extensionDiscovery, InstalledExtension, manifestFilename } from "../../../extensions/extension-discovery";
import { extensionLoader } from "../../../extensions/extension-loader"; 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 logger from "../../../main/logger";
import { prevDefault } from "../../utils"; import { prevDefault } from "../../utils";
import { Button } from "../button"; import { Button } from "../button";
@ -21,209 +22,120 @@ import { SubTitle } from "../layout/sub-title";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import { Spinner } from "../spinner/spinner"; import { Spinner } from "../spinner/spinner";
import { TooltipPosition } from "../tooltip"; import { TooltipPosition } from "../tooltip";
import { ExtensionStateStore } from "./extension-install.store"; import { ExtensionInstallationState, ExtensionInstallationStateStore } from "./extension-install.store";
import "./extensions.scss"; 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 { interface InstallRequest {
fileName: string; fileName: string;
filePath?: string; dataP: Promise<Buffer | null>;
data?: Buffer;
} }
interface InstallRequestPreloaded extends InstallRequest { interface InstallRequestValidated {
fileName: string;
data: Buffer; data: Buffer;
} id: LensExtensionId;
interface InstallRequestValidated extends InstallRequestPreloaded {
manifest: LensExtensionManifest; manifest: LensExtensionManifest;
tempFile: string; // temp system path to packed extension for unpacking tempFile: string; // temp system path to packed extension for unpacking
} }
@observer async function uninstallExtension(extensionId: LensExtensionId): Promise<boolean> {
export class Extensions extends React.Component { const { manifest } = extensionLoader.getExtension(extensionId);
private static supportedFormats = ["tar", "tgz"]; const displayName = extensionDisplayName(manifest.name, manifest.version);
private static installPathValidator: InputValidator = { try {
message: "Invalid URL or absolute path", logger.debug(`[EXTENSIONS]: trying to uninstall ${extensionId}`);
validate(value: string) { ExtensionInstallationStateStore.setUninstalling(extensionId);
return InputValidators.isUrl.validate(value) || InputValidators.isPath.validate(value);
}
};
get extensionStateStore() { await extensionDiscovery.uninstallExtension(extensionId);
return ExtensionStateStore.getInstance<ExtensionStateStore>();
}
@observable search = ""; // wait for the extensionLoader to actually uninstall the extension
@observable installPath = ""; 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( Notifications.ok(
<p>Extension <b>{displayName}</b> successfully uninstalled!</p> <p>Extension <b>{displayName}</b> successfully uninstalled!</p>
); );
this.extensionStateStore.extensionState.delete(id);
return true;
} catch (error) {
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);
}
}
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",
}); });
this.addedInstalling.forEach(({ id, displayName }) => { if (confirmed) {
const extension = this.extensions.find(extension => extension.id === id); await uninstallExtension(extension.id);
}
if (!extension) {
throw new Error("Extension not found");
} }
Notifications.ok( function getExtensionDestFolder(name: string) {
<p>Extension <b>{displayName}</b> successfully installed!</p> return path.join(extensionDiscovery.localFolderPath, sanitizeExtensionName(name));
);
this.extensionStateStore.extensionState.delete(id);
this.installPath = "";
// Enable installed extensions by default.
extension.isEnabled = true;
});
})
);
} }
@computed get extensions() { function getExtensionPackageTemp(fileName = "") {
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); return path.join(os.tmpdir(), "lens-extensions", fileName);
} }
getExtensionDestFolder(name: string) { async function readFileNotify(filePath: string, showError = true): Promise<Buffer | null> {
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 { try {
// install via url return await fse.readFile(filePath);
// 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 });
}
} catch (error) {
this.startingInstall = false;
Notifications.error(
<p>Installation has failed: <b>{String(error)}</b></p>
);
}
};
installOnDrop = (files: File[]) => {
logger.info("Install from D&D");
return this.requestInstall(
files.map(file => ({
fileName: path.basename(file.path),
filePath: file.path,
}))
);
};
async preloadExtensions(requests: InstallRequest[], { showError = true } = {}) {
const preloadedRequests = requests.filter(request => request.data);
await Promise.all(
requests
.filter(request => !request.data && request.filePath)
.map(async request => {
try {
const data = await fse.readFile(request.filePath);
request.data = data;
preloadedRequests.push(request);
return request;
} catch (error) { } catch (error) {
if (showError) { 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); const tarFiles = await listTarEntries(filePath);
// tarball from npm contains single root folder "package/*" // tarball from npm contains single root folder "package/*"
@ -247,119 +159,70 @@ export class Extensions extends React.Component {
parseJson: true, parseJson: true,
}); });
if (!manifest.lens && !manifest.renderer) { if (!manifest.main && !manifest.renderer) {
throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`); throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`);
} }
return manifest; return manifest;
} }
async createTempFilesAndValidate(requests: InstallRequestPreloaded[], { showErrors = true } = {}) { async function createTempFilesAndValidate({ fileName, dataP }: InstallRequest, disposer: ExtendableDisposer): Promise<InstallRequestValidated | null> {
const validatedRequests: InstallRequestValidated[] = [];
// copy files to temp // copy files to temp
await fse.ensureDir(this.getExtensionPackageTemp()); await fse.ensureDir(getExtensionPackageTemp());
for (const request of requests) {
const tempFile = this.getExtensionPackageTemp(request.fileName);
await fse.writeFile(tempFile, request.data);
}
// validate packages // validate packages
await Promise.all( const tempFile = getExtensionPackageTemp(fileName);
requests.map(async req => {
const tempFile = this.getExtensionPackageTemp(req.fileName); disposer.push(() => fse.unlink(tempFile));
try { try {
const manifest = await this.validatePackage(tempFile); const data = await dataP;
validatedRequests.push({ if (!data) {
...req, 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, manifest,
tempFile, tempFile,
}); id,
};
} catch (error) { } 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( Notifications.error(
<div className="flex column gaps"> <div className="flex column gaps">
<p>Installing <em>{req.fileName}</em> has failed, skipping.</p> <p>Installing <em>{fileName}</em> has failed, skipping.</p>
<p>Reason: <em>{String(error)}</em></p> <p>Reason: <em>{message}</em></p>
</div> </div>
); );
} }
}
})
);
return validatedRequests; return null;
} }
async requestInstall(init: InstallRequest | InstallRequest[]) { async function unpackExtension(request: InstallRequestValidated, disposeDownloading?: Disposer) {
const requests = Array.isArray(init) ? init : [init]; const { id, fileName, tempFile, manifest: { name, version } } = request;
const preloadedRequests = await this.preloadExtensions(requests);
const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests);
// If there are no requests for installing, reset startingInstall state ExtensionInstallationStateStore.setInstalling(id);
if (validatedRequests.length === 0) { disposeDownloading?.();
this.startingInstall = false;
}
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 displayName = extensionDisplayName(name, version);
const extensionId = path.join(extensionDiscovery.nodeModulesPath, name, "package.json"); const extensionFolder = getExtensionDestFolder(name);
logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile });
this.extensionStateStore.extensionState.set(extensionId, {
state: "installing",
displayName
});
this.startingInstall = false;
const extensionFolder = this.getExtensionDestFolder(name);
const unpackingTempFolder = path.join(path.dirname(tempFile), `${path.basename(tempFile)}-unpacked`); const unpackingTempFolder = path.join(path.dirname(tempFile), `${path.basename(tempFile)}-unpacked`);
logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile }); logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile });
try { try {
// extract to temp folder first // extract to temp folder first
await fse.remove(unpackingTempFolder).catch(Function); await fse.remove(unpackingTempFolder).catch(noop);
await fse.ensureDir(unpackingTempFolder); await fse.ensureDir(unpackingTempFolder);
await extractTar(tempFile, { cwd: unpackingTempFolder }); await extractTar(tempFile, { cwd: unpackingTempFolder });
@ -375,77 +238,286 @@ export class Extensions extends React.Component {
await fse.ensureDir(extensionFolder); await fse.ensureDir(extensionFolder);
await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true }); await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true });
} catch (error) {
Notifications.error( // wait for the loader has actually install it
<p>Installing extension <b>{displayName}</b> has failed: <em>{error}</em></p> 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 logger.info(`[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`, { error });
if (this.extensionStateStore.extensionState.get(extensionId)?.state === "installing") { Notifications.error(<p>Installing extension <b>{displayName}</b> has failed: <em>{message}</em></p>);
this.extensionStateStore.extensionState.delete(extensionId);
}
} finally { } finally {
// Remove install state once finished
ExtensionInstallationStateStore.clearInstalling(id);
// clean up // clean up
fse.remove(unpackingTempFolder).catch(Function); fse.remove(unpackingTempFolder).catch(noop);
fse.unlink(tempFile).catch(Function); fse.unlink(tempFile).catch(noop);
} }
} }
confirmUninstallExtension = (extension: InstalledExtension) => { export async function attemptInstallByInfo({ name, version, requireConfirmation = false }: ExtensionInfo) {
const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); 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);
ConfirmDialog.open({ if (!json || json.error || typeof json.versions !== "object" || !json.versions) {
message: <p>Are you sure you want to uninstall extension <b>{displayName}</b>?</p>, const message = json?.error ? `: ${json.error}` : "";
labelOk: "Yes",
labelCancel: "No", Notifications.error(`Failed to get registry information for that extension${message}`);
ok: () => this.uninstallExtension(extension)
return disposer();
}
if (version) {
if (!json.versions[version]) {
Notifications.error(<p>The <em>{name}</em> extension does not have a v{version}.</p>);
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) { if (!proceed) {
const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); 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 { try {
this.extensionStateStore.extensionState.set(extension.id, { // fixme: improve error messages for non-tar-file URLs
state: "uninstalling", if (InputValidators.isUrl.validate(input)) {
displayName // 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); if (!canceled) {
} catch (error) { await attemptInstalls(filePaths);
Notifications.error( }
<p>Uninstalling extension <b>{displayName}</b> has failed: <em>{error?.message ?? ""}</em></p> }
@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() { renderNoExtensions() {
const { extensions, search } = this;
if (!extensions.length) {
return ( return (
<div className="no-extensions flex box gaps justify-center"> <div className="no-extensions flex box gaps justify-center">
<Icon material="info" /> <Icon material="info" />
<div> <div>
{ {this.renderNoExtensionsHelpText()}
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>
}
</div> </div>
</div> </div>
); );
} }
return extensions.map(extension => { @autobind()
renderExtension(extension: InstalledExtension) {
const { id, isEnabled, manifest } = extension; const { id, isEnabled, manifest } = extension;
const { name, description, version } = manifest; const { name, description, version } = manifest;
const isUninstalling = this.extensionStateStore.extensionState.get(id)?.state === "uninstalling"; const isUninstalling = ExtensionInstallationStateStore.isExtensionUninstalling(id);
return ( return (
<div key={id} className="extension flex gaps align-center"> <div key={id} className="extension flex gaps align-center">
@ -455,30 +527,41 @@ export class Extensions extends React.Component {
<p>{description}</p> <p>{description}</p>
</div> </div>
<div className="actions"> <div className="actions">
{!isEnabled && ( {
<Button plain active disabled={isUninstalling} onClick={() => { isEnabled
extension.isEnabled = true; ? <Button accent disabled={isUninstalling} onClick={() => extension.isEnabled = false}>Disable</Button>
}}>Enable</Button> : <Button plain active disabled={isUninstalling} onClick={() => extension.isEnabled = true}>Enable</Button>
)} }
{isEnabled && ( <Button
<Button accent disabled={isUninstalling} onClick={() => { plain
extension.isEnabled = false; active
}}>Disable</Button> disabled={isUninstalling}
)} waiting={isUninstalling}
<Button plain active disabled={isUninstalling} waiting={isUninstalling} onClick={() => { onClick={() => confirmUninstallExtension(extension)}
this.confirmUninstallExtension(extension); >
}}>Uninstall</Button> Uninstall
</Button>
</div> </div>
</div> </div>
); );
});
} }
/** renderExtensions() {
* True if at least one extension is in installing state if (!extensionDiscovery.isLoaded) {
*/ return <div className="spinner-wrapper"><Spinner /></div>;
@computed get isInstalling() { }
return [...this.extensionStateStore.extensionState.values()].some(extension => extension.state === "installing");
const { searchedForExtensions } = this;
if (!searchedForExtensions.length) {
return this.renderNoExtensions();
}
return (
<>
{...searchedForExtensions.map(this.renderExtension)}
</>
);
} }
render() { render() {
@ -486,7 +569,7 @@ export class Extensions extends React.Component {
const { installPath } = this; const { installPath } = this;
return ( return (
<DropFileInput onDropFiles={this.installOnDrop}> <DropFileInput onDropFiles={installOnDrop}>
<PageLayout showOnTop className="Extensions" header={topHeader} contentGaps={false}> <PageLayout showOnTop className="Extensions" header={topHeader} contentGaps={false}>
<h2>Lens Extensions</h2> <h2>Lens Extensions</h2>
<div> <div>
@ -500,19 +583,19 @@ export class Extensions extends React.Component {
<Input <Input
className="box grow" className="box grow"
theme="round-black" theme="round-black"
disabled={this.isInstalling} disabled={ExtensionInstallationStateStore.anyPreInstallingOrInstalling}
placeholder={`Path or URL to an extension package (${Extensions.supportedFormats.join(", ")})`} placeholder={`Name or file path or URL to an extension package (${supportedFormats.join(", ")})`}
showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }} showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }}
validators={installPath ? Extensions.installPathValidator : undefined} validators={installPath ? Extensions.installInputValidator : undefined}
value={installPath} value={installPath}
onChange={value => this.installPath = value} onChange={value => this.installPath = value}
onSubmit={this.installFromUrlOrPath} onSubmit={() => installFromInput(this.installPath)}
iconLeft="link" iconLeft="link"
iconRight={ iconRight={
<Icon <Icon
interactive interactive
material="folder" material="folder"
onClick={prevDefault(this.installFromSelectFileDialog)} onClick={prevDefault(installFromSelectFileDialog)}
tooltip="Browse" tooltip="Browse"
/> />
} }
@ -521,9 +604,9 @@ export class Extensions extends React.Component {
<Button <Button
primary primary
label="Install" label="Install"
disabled={this.isInstalling || !Extensions.installPathValidator.validate(installPath)} disabled={ExtensionInstallationStateStore.anyPreInstallingOrInstalling || !Extensions.installInputValidator.validate(installPath)}
waiting={this.isInstalling} waiting={ExtensionInstallationStateStore.anyPreInstallingOrInstalling}
onClick={this.installFromUrlOrPath} onClick={() => installFromInput(this.installPath)}
/> />
<small className="hint"> <small className="hint">
<b>Pro-Tip</b>: you can also drag-n-drop tarball-file to this area <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} value={this.search}
onChange={(value) => this.search = value} onChange={(value) => this.search = value}
/> />
{extensionDiscovery.isLoaded ? this.renderExtensions() : <div className="spinner-wrapper"><Spinner/></div>} {this.renderExtensions()}
</div> </div>
</PageLayout> </PageLayout>
</DropFileInput> </DropFileInput>

View File

@ -1,5 +1,5 @@
import "./button.scss"; import "./button.scss";
import React, { ButtonHTMLAttributes, ReactNode } from "react"; import React, { ButtonHTMLAttributes } from "react";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { TooltipDecoratorProps, withTooltip } from "../tooltip"; import { TooltipDecoratorProps, withTooltip } from "../tooltip";
@ -26,29 +26,22 @@ export class Button extends React.PureComponent<ButtonProps, {}> {
render() { render() {
const { const {
className, waiting, label, primary, accent, plain, hidden, active, big, waiting, label, primary, accent, plain, hidden, active, big,
round, outlined, tooltip, light, children, ...props round, outlined, tooltip, light, children, ...btnProps
} = this.props; } = this.props;
const btnProps: Partial<ButtonProps> = props;
if (hidden) return null; if (hidden) return null;
btnProps.className = cssNames("Button", className, { btnProps.className = cssNames("Button", btnProps.className, {
waiting, primary, accent, plain, active, big, round, outlined, light, waiting, primary, accent, plain, active, big, round, outlined, light,
}); });
const btnContent: ReactNode = (
<>
{label}
{children}
</>
);
// render as link // render as link
if (this.props.href) { if (this.props.href) {
return ( return (
<a {...btnProps} ref={e => this.link = e}> <a {...btnProps} ref={e => this.link = e}>
{btnContent} {label}
{children}
</a> </a>
); );
} }
@ -56,7 +49,8 @@ export class Button extends React.PureComponent<ButtonProps, {}> {
// render as button // render as button
return ( return (
<button type="button" {...btnProps} ref={e => this.button = e}> <button type="button" {...btnProps} ref={e => this.button = e}>
{btnContent} {label}
{children}
</button> </button>
); );
} }

View File

@ -11,14 +11,18 @@ import { Icon } from "../icon";
export interface ConfirmDialogProps extends Partial<DialogProps> { export interface ConfirmDialogProps extends Partial<DialogProps> {
} }
export interface ConfirmDialogParams { export interface ConfirmDialogParams extends ConfirmDialogBooleanParams {
ok?: () => void; ok?: () => any | Promise<any>;
cancel?: () => any | Promise<any>;
}
export interface ConfirmDialogBooleanParams {
labelOk?: ReactNode; labelOk?: ReactNode;
labelCancel?: ReactNode; labelCancel?: ReactNode;
message?: ReactNode; message: ReactNode;
icon?: ReactNode; icon?: ReactNode;
okButtonProps?: Partial<ButtonProps> okButtonProps?: Partial<ButtonProps>;
cancelButtonProps?: Partial<ButtonProps> cancelButtonProps?: Partial<ButtonProps>;
} }
@observer @observer
@ -33,19 +37,26 @@ export class ConfirmDialog extends React.Component<ConfirmDialogProps> {
ConfirmDialog.params = params; ConfirmDialog.params = params;
} }
static close() { static confirm(params: ConfirmDialogBooleanParams): Promise<boolean> {
ConfirmDialog.isOpen = false; return new Promise(resolve => {
ConfirmDialog.open({
ok: () => resolve(true),
cancel: () => resolve(false),
...params,
});
});
} }
public defaultParams: ConfirmDialogParams = { static defaultParams: Partial<ConfirmDialogParams> = {
ok: noop, ok: noop,
cancel: noop,
labelOk: "Ok", labelOk: "Ok",
labelCancel: "Cancel", labelCancel: "Cancel",
icon: <Icon big material="warning"/>, icon: <Icon big material="warning"/>,
}; };
get params(): ConfirmDialogParams { get params(): ConfirmDialogParams {
return Object.assign({}, this.defaultParams, ConfirmDialog.params); return Object.assign({}, ConfirmDialog.defaultParams, ConfirmDialog.params);
} }
ok = async () => { ok = async () => {
@ -54,16 +65,21 @@ export class ConfirmDialog extends React.Component<ConfirmDialogProps> {
await Promise.resolve(this.params.ok()).catch(noop); await Promise.resolve(this.params.ok()).catch(noop);
} finally { } finally {
this.isSaving = false; this.isSaving = false;
ConfirmDialog.isOpen = false;
} }
this.close();
}; };
onClose = () => { onClose = () => {
this.isSaving = false; this.isSaving = false;
}; };
close = () => { close = async () => {
ConfirmDialog.close(); try {
await Promise.resolve(this.params.cancel()).catch(noop);
} finally {
this.isSaving = false;
ConfirmDialog.isOpen = false;
}
}; };
render() { render() {

View File

@ -315,6 +315,7 @@ export class Input extends React.Component<InputProps, State> {
rows: multiLine ? (rows || 1) : null, rows: multiLine ? (rows || 1) : null,
ref: this.bindRef, ref: this.bindRef,
spellCheck: "false", spellCheck: "false",
disabled,
}); });
const showErrors = errors.length > 0 && !valid && dirty; const showErrors = errors.length > 0 && !valid && dirty;
const errorsInfo = ( const errorsInfo = (

View File

@ -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 = { export const isPath: InputValidator = {
condition: ({ type }) => type === "text", condition: ({ type }) => type === "text",
message: () => `This field must be a valid path`, message: () => `This field must be a valid path`,

View File

@ -1,6 +1,6 @@
import { addClusterURL } from "../components/+add-cluster"; import { addClusterURL } from "../components/+add-cluster";
import { clusterSettingsURL } from "../components/+cluster-settings"; import { clusterSettingsURL } from "../components/+cluster-settings";
import { extensionsURL } from "../components/+extensions"; import { attemptInstallByInfo, extensionsURL } from "../components/+extensions";
import { landingURL } from "../components/+landing-page"; import { landingURL } from "../components/+landing-page";
import { preferencesURL } from "../components/+preferences"; import { preferencesURL } from "../components/+preferences";
import { clusterViewURL } from "../components/cluster-manager/cluster-view.route"; import { clusterViewURL } from "../components/cluster-manager/cluster-view.route";
@ -8,6 +8,7 @@ import { LensProtocolRouterRenderer } from "./router";
import { navigate } from "../navigation/helpers"; import { navigate } from "../navigation/helpers";
import { clusterStore } from "../../common/cluster-store"; import { clusterStore } from "../../common/cluster-store";
import { workspaceStore } from "../../common/workspace-store"; import { workspaceStore } from "../../common/workspace-store";
import { EXTENSION_NAME_MATCH, EXTENSION_PUBLISHER_MATCH, LensProtocolRouter } from "../../common/protocol-handler";
export function bindProtocolAddRouteHandlers() { export function bindProtocolAddRouteHandlers() {
LensProtocolRouterRenderer LensProtocolRouterRenderer
@ -54,5 +55,15 @@ export function bindProtocolAddRouteHandlers() {
}) })
.addInternalHandler("/extensions", () => { .addInternalHandler("/extensions", () => {
navigate(extensionsURL()); 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 });
}); });
} }