diff --git a/src/common/protocol-handler/router.ts b/src/common/protocol-handler/router.ts index 7b7659992f..a044fdd964 100644 --- a/src/common/protocol-handler/router.ts +++ b/src/common/protocol-handler/router.ts @@ -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}`; /** * diff --git a/src/common/utils/disposer.ts b/src/common/utils/disposer.ts new file mode 100644 index 0000000000..5e26bcd0e1 --- /dev/null +++ b/src/common/utils/disposer.ts @@ -0,0 +1,20 @@ +export type Disposer = () => void; + +interface Extendable { + push(...vals: T[]): void; +} + +export type ExtendableDisposer = Disposer & Extendable; + +export function disposer(...args: Disposer[]): ExtendableDisposer { + const res = () => { + args.forEach(dispose => dispose?.()); + args.length = 0; + }; + + res.push = (...vals: Disposer[]) => { + args.push(...vals); + }; + + return res; +} diff --git a/src/common/utils/downloadFile.ts b/src/common/utils/downloadFile.ts index dfa549da07..cd01db29ac 100644 --- a/src/common/utils/downloadFile.ts +++ b/src/common/utils/downloadFile.ts @@ -6,13 +6,13 @@ export interface DownloadFileOptions { timeout?: number; } -export interface DownloadFileTicket { +export interface DownloadFileTicket { url: string; - promise: Promise; + promise: Promise; cancel(): void; } -export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket { +export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket { const fileChunks: Buffer[] = []; const req = request(url, { gzip, timeout }); const promise: Promise = new Promise((resolve, reject) => { @@ -35,3 +35,12 @@ export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions) } }; } + +export function downloadJson(args: DownloadFileOptions): DownloadFileTicket { + const { promise, ...rest } = downloadFile(args); + + return { + promise: promise.then(res => JSON.parse(res.toString())), + ...rest + }; +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 6f26bab2da..74794d721b 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -19,3 +19,4 @@ export * from "./downloadFile"; export * from "./escapeRegExp"; export * from "./tar"; export * from "./type-narrowing"; +export * from "./disposer"; diff --git a/src/extensions/__tests__/extension-discovery.test.ts b/src/extensions/__tests__/extension-discovery.test.ts index d0066c3a7e..9bfd5a000a 100644 --- a/src/extensions/__tests__/extension-discovery.test.ts +++ b/src/extensions/__tests__/extension-discovery.test.ts @@ -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,50 +16,62 @@ jest.mock("../extension-installer", () => ({ } })); +console = new Console(process.stdout, process.stderr); // fix mockFS const mockedWatch = watch as jest.MockedFunction; describe("ExtensionDiscovery", () => { - it("emits add for added extension", async done => { - globalThis.__non_webpack_require__.mockImplementation(() => ({ - name: "my-extension" - })); - let addHandler: (filePath: string) => void; - - const mockWatchInstance: any = { - on: jest.fn((event: string, handler: typeof addHandler) => { - if (event === "add") { - addHandler = handler; - } - - return mockWatchInstance; - }) - }; - - mockedWatch.mockImplementationOnce(() => - (mockWatchInstance) as any - ); - const extensionDiscovery = new ExtensionDiscovery(); - - // Need to force isLoaded to be true so that the file watching is started - extensionDiscovery.isLoaded = true; - - await extensionDiscovery.watchExtensions(); - - extensionDiscovery.events.on("add", (extension: InstalledExtension) => { - expect(extension).toEqual({ - absolutePath: expect.any(String), - id: normalize("node_modules/my-extension/package.json"), - isBundled: false, - isEnabled: false, - manifest: { - name: "my-extension", - }, - manifestPath: normalize("node_modules/my-extension/package.json"), + describe("with mockFs", () => { + beforeEach(() => { + mockFs({ + [`${os.homedir()}/.k8slens/extensions/my-extension/package.json`]: JSON.stringify({ + name: "my-extension" + }), }); - done(); }); - addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/package.json")); + afterEach(() => { + mockFs.restore(); + }); + + it("emits add for added extension", async (done) => { + let addHandler: (filePath: string) => void; + + const mockWatchInstance: any = { + on: jest.fn((event: string, handler: typeof addHandler) => { + if (event === "add") { + addHandler = handler; + } + + return mockWatchInstance; + }) + }; + + mockedWatch.mockImplementationOnce(() => + (mockWatchInstance) as any + ); + const extensionDiscovery = new ExtensionDiscovery(); + + // Need to force isLoaded to be true so that the file watching is started + extensionDiscovery.isLoaded = true; + + await extensionDiscovery.watchExtensions(); + + extensionDiscovery.events.on("add", extension => { + expect(extension).toEqual({ + absolutePath: expect.any(String), + id: path.normalize("node_modules/my-extension/package.json"), + isBundled: false, + isEnabled: false, + manifest: { + name: "my-extension", + }, + manifestPath: path.normalize("node_modules/my-extension/package.json"), + }); + done(); + }); + + 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(); diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index 0994f89995..29cb593996 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -1,31 +1,33 @@ 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"; export interface InstalledExtension { - id: LensExtensionId; + id: LensExtensionId; - readonly manifest: LensExtensionManifest; + readonly manifest: LensExtensionManifest; - // Absolute path to the non-symlinked source folder, - // e.g. "/Users/user/.k8slens/extensions/helloworld" - readonly absolutePath: string; + // Absolute path to the non-symlinked source folder, + // e.g. "/Users/user/.k8slens/extensions/helloworld" + readonly absolutePath: string; - // Absolute to the symlinked package.json file - readonly manifestPath: string; - readonly isBundled: boolean; // defined in project root's package.json - isEnabled: boolean; - } + // Absolute to the symlinked package.json file + 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 "/package.json" add .on("add", this.handleWatchFileAdd) - // Extension remove is detected by watching " unlink - .on("unlinkDir", this.handleWatchUnlinkDir); + // Extension remove is detected by watching "" unlink + .on("unlinkDir", this.handleWatchUnlinkEvent) + // Extension remove is detected by watching "" 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 => { // 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 (path.relative(this.localFolderPath, filePath) === extensionFolderName) { - const extension = Array.from(this.extensions.values()).find((extension) => extension.absolutePath === filePath); - - if (extension) { - const extensionName = extension.manifest.name; - - // If the extension is deleted manually while the application is running, also remove the symlink - await this.removeSymlinkByPackageName(extensionName); - - // The path to the manifest file is the lens extension id - // Note that we need to use the symlinked path - const lensExtensionId = extension.manifestPath; - - this.extensions.delete(extension.id); - logger.info(`${logModule} removed extension ${extensionName}`); - this.events.emit("remove", lensExtensionId as LensExtensionId); - } else { - logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`); - } + if (expectedPath !== extensionFolderName) { + return; } + + const extension = Array.from(this.extensions.values()).find((extension) => extension.absolutePath === filePath); + + 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 + const lensExtensionId = extension.manifestPath; + + this.extensions.delete(extension.id); + logger.info(`${logModule} removed extension ${extensionName}`); + 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> { @@ -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//Library/Application Support/LensDev/extensions - await fs.remove(this.inTreeTargetPath); + await fse.remove(this.inTreeTargetPath); // Create folder e.g. /Users//Library/Application Support/LensDev/extensions - await fs.ensureDir(this.inTreeTargetPath); + await fse.ensureDir(this.inTreeTargetPath); // Copy static/extensions to e.g. /Users//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//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 { - let manifestJson: LensExtensionManifest; - + protected async getByManifest(manifestPath: string, { isBundled = false } = {}): Promise { 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 { 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)) { diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index 3af9ad874e..0924318028 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -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 }); } } diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index ef2fc87b44..518c2aa9c6 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -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(); diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx index 8899d9d74c..7a477cbc01 100644 --- a/src/renderer/components/+extensions/__tests__/extensions.test.tsx +++ b/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -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(<>); + const res = render(<>); - 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")); - 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(<>); - - 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); + await waitFor(() => { + expect(extensionDiscovery.uninstallExtension).toHaveBeenCalled(); + expect(res.getByText("Disable").closest("button")).toBeDisabled(); + expect(res.getByText("Uninstall").closest("button")).toBeDisabled(); + }, { + timeout: 30000, }); }); - it("disables install button while installing", () => { - render(); + it("disables install button while installing", async () => { + const res = render(); - fireEvent.change(screen.getByPlaceholderText("Path or URL to an extension package", { + (fse.unlink as jest.MockedFunction).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(); + const res = render(); - 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(); - waitFor(() => - expect(container.querySelector(".Spinner")).not.toBeInTheDocument() - ); + expect(res.container.querySelector(".Spinner")).not.toBeInTheDocument(); }); }); diff --git a/src/renderer/components/+extensions/extension-install.store.ts b/src/renderer/components/+extensions/extension-install.store.ts index c4a8ed6690..787dc2b364 100644 --- a/src/renderer/components/+extensions/extension-install.store.ts +++ b/src/renderer/components/+extensions/extension-install.store.ts @@ -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(); +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(); + private static UninstallingExtensions = observable.set(); + private static InstallingExtensions = observable.set(); + + 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; + } } diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index c14a6988b6..5142c92eef 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -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,100 +22,446 @@ 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; } -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 } +async function uninstallExtension(extensionId: LensExtensionId): Promise { + const { manifest } = extensionLoader.getExtension(extensionId); + const displayName = extensionDisplayName(manifest.name, manifest.version); + + try { + logger.debug(`[EXTENSIONS]: trying to uninstall ${extensionId}`); + ExtensionInstallationStateStore.setUninstalling(extensionId); + + await extensionDiscovery.uninstallExtension(extensionId); + + // wait for the extensionLoader to actually uninstall the extension + await when(() => !extensionLoader.userExtensions.has(extensionId)); + + Notifications.ok( +

Extension {displayName} successfully uninstalled!

+ ); + + return true; + } catch (error) { + const message = getMessageFromError(error); + + logger.info(`[EXTENSION-UNINSTALL]: uninstalling ${displayName} has failed: ${error}`, { error }); + Notifications.error(

Uninstalling extension {displayName} has failed: {message}

); + + return false; + } finally { + // Remove uninstall state on uninstall failure + ExtensionInstallationStateStore.clearUninstalling(extensionId); + } +} + +async function confirmUninstallExtension(extension: InstalledExtension): Promise { + const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); + const confirmed = await ConfirmDialog.confirm({ + message:

Are you sure you want to uninstall extension {displayName}?

, + labelOk: "Yes", + labelCancel: "No", + }); + + if (confirmed) { + await uninstallExtension(extension.id); + } +} + +function getExtensionDestFolder(name: string) { + return path.join(extensionDiscovery.localFolderPath, sanitizeExtensionName(name)); +} + +function getExtensionPackageTemp(fileName = "") { + return path.join(os.tmpdir(), "lens-extensions", fileName); +} + +async function readFileNotify(filePath: string, showError = true): Promise { + try { + return await fse.readFile(filePath); + } catch (error) { + if (showError) { + const message = getMessageFromError(error); + + logger.info(`[EXTENSION-INSTALL]: preloading ${filePath} has failed: ${message}`, { error }); + Notifications.error(`Error while reading "${filePath}": ${message}`); + } + } + + return null; +} + +async function validatePackage(filePath: string): Promise { + const tarFiles = await listTarEntries(filePath); + + // tarball from npm contains single root folder "package/*" + const firstFile = tarFiles[0]; + + if(!firstFile) { + throw new Error(`invalid extension bundle, ${manifestFilename} not found`); + } + + const rootFolder = path.normalize(firstFile).split(path.sep)[0]; + const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder)); + const manifestLocation = packedInRootFolder ? path.join(rootFolder, manifestFilename) : manifestFilename; + + if(!tarFiles.includes(manifestLocation)) { + throw new Error(`invalid extension bundle, ${manifestFilename} not found`); + } + + const manifest = await readFileFromTar({ + tarPath: filePath, + filePath: manifestLocation, + parseJson: true, + }); + + if (!manifest.main && !manifest.renderer) { + throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`); + } + + return manifest; +} + +async function createTempFilesAndValidate({ fileName, dataP }: InstallRequest, disposer: ExtendableDisposer): Promise { + // copy files to temp + await fse.ensureDir(getExtensionPackageTemp()); + + // validate packages + const tempFile = getExtensionPackageTemp(fileName); + + disposer.push(() => fse.unlink(tempFile)); + + try { + const data = await dataP; + + 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) { + const message = getMessageFromError(error); + + logger.info(`[EXTENSION-INSTALLATION]: installing ${fileName} has failed: ${message}`, { error }); + Notifications.error( +
+

Installing {fileName} has failed, skipping.

+

Reason: {message}

+
+ ); + } + + return null; +} + +async function unpackExtension(request: InstallRequestValidated, disposeDownloading?: Disposer) { + const { id, fileName, tempFile, manifest: { name, version } } = request; + + ExtensionInstallationStateStore.setInstalling(id); + disposeDownloading?.(); + + const displayName = extensionDisplayName(name, version); + 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(noop); + await fse.ensureDir(unpackingTempFolder); + await extractTar(tempFile, { cwd: unpackingTempFolder }); + + // move contents to extensions folder + const unpackedFiles = await fse.readdir(unpackingTempFolder); + let unpackedRootFolder = unpackingTempFolder; + + if (unpackedFiles.length === 1) { + // check if %extension.tgz was packed with single top folder, + // e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball + unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]); + } + + await fse.ensureDir(extensionFolder); + await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true }); + + // 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( +

Extension {displayName} successfully installed!

+ ); + } catch (error) { + const message = getMessageFromError(error); + + logger.info(`[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`, { error }); + Notifications.error(

Installing extension {displayName} has failed: {message}

); + } finally { + // Remove install state once finished + ExtensionInstallationStateStore.clearInstalling(id); + + // clean up + 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(); + } + + if (version) { + if (!json.versions[version]) { + Notifications.error(

The {name} extension does not have a v{version}.

); + + 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:

Are you sure you want to install {name}@{version}?

, + labelCancel: "Cancel", + labelOk: "Install", + }); + + 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 { + 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( +
+ Extension Install Collision: +

The {name} extension is currently {curState.toLowerCase()}.

+

Will not proceed with this current install request.

+
+ ); + } + + 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( +
+
+

Install extension {name}@{version}?

+

Description: {description}

+
shell.openPath(extensionFolder)}> + Warning: {name}@{oldVersion} will be removed before installation. +
+
+
, + { + onClose: dispose, + } + ); + } +} + +async function attemptInstalls(filePaths: string[]): Promise { + const promises: Promise[] = []; + + 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 { + // 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(

Installation has failed: {message}

); + } 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 } + ] + }); + + if (!canceled) { + await attemptInstalls(filePaths); + } +} + @observer export class Extensions extends React.Component { - private static supportedFormats = ["tar", "tgz"]; + private static installInputValidators = [ + InputValidators.isUrl, + InputValidators.isPath, + InputValidators.isExtensionNameInstall, + ]; - private static installPathValidator: InputValidator = { - message: "Invalid URL or absolute path", - validate(value: string) { - return InputValidators.isUrl.validate(value) || InputValidators.isPath.validate(value); - } + private static installInputValidator: InputValidator = { + message: "Invalid URL, absolute path, or extension name", + validate: (value: string) => ( + Extensions.installInputValidators.some(({ validate }) => validate(value)) + ), }; - get extensionStateStore() { - return ExtensionStateStore.getInstance(); - } - @observable search = ""; @observable installPath = ""; - // 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( -

Extension {displayName} successfully uninstalled!

- ); - 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( -

Extension {displayName} successfully installed!

- ); - this.extensionStateStore.extensionState.delete(id); - this.installPath = ""; - - // Enable installed extensions by default. - extension.isEnabled = true; - }); - }) - ); - } - - @computed get extensions() { + @computed get searchedForExtensions() { const searchText = this.search.toLowerCase(); return Array.from(extensionLoader.userExtensions.values()) @@ -124,361 +471,97 @@ export class Extensions extends React.Component { )); } - 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 }); - } - } catch (error) { - this.startingInstall = false; - Notifications.error( -

Installation has failed: {String(error)}

- ); - } - }; - - 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) { - if (showError) { - Notifications.error(`Error while reading "${request.filePath}": ${String(error)}`); - } - } - }) - ); - - return preloadedRequests as InstallRequestPreloaded[]; - } - - async validatePackage(filePath: string): Promise { - const tarFiles = await listTarEntries(filePath); - - // tarball from npm contains single root folder "package/*" - const firstFile = tarFiles[0]; - - if (!firstFile) { - throw new Error(`invalid extension bundle, ${manifestFilename} not found`); - } - - const rootFolder = path.normalize(firstFile).split(path.sep)[0]; - const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder)); - const manifestLocation = packedInRootFolder ? path.join(rootFolder, manifestFilename) : manifestFilename; - - if (!tarFiles.includes(manifestLocation)) { - throw new Error(`invalid extension bundle, ${manifestFilename} not found`); - } - - const manifest = await readFileFromTar({ - tarPath: filePath, - filePath: manifestLocation, - parseJson: true, - }); - - if (!manifest.lens && !manifest.renderer) { - throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`); - } - - return manifest; - } - - async createTempFilesAndValidate(requests: InstallRequestPreloaded[], { showErrors = true } = {}) { - const validatedRequests: InstallRequestValidated[] = []; - - // 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); - } - - // validate packages - await Promise.all( - requests.map(async req => { - const tempFile = this.getExtensionPackageTemp(req.fileName); + 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 { - const manifest = await this.validatePackage(tempFile); - - validatedRequests.push({ - ...req, - manifest, - tempFile, - }); - } catch (error) { - fse.unlink(tempFile).catch(() => null); // remove invalid temp package - - if (showErrors) { - Notifications.error( -
-

Installing {req.fileName} has failed, skipping.

-

Reason: {String(error)}

-
- ); + if (curSize > prevSize) { + when(() => !ExtensionInstallationStateStore.anyInstalling) + .then(() => this.installPath = ""); } + } finally { + prevSize = curSize; } }) + ]); + } + + renderNoExtensionsHelpText() { + if (this.search) { + return

No search results found

; + } + + return ( +

+ There are no installed extensions. + See list of available extensions. +

); - - return validatedRequests; } - async requestInstall(init: InstallRequest | InstallRequest[]) { - const requests = Array.isArray(init) ? init : [init]; - const preloadedRequests = await this.preloadExtensions(requests); - const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests); - - // If there are no requests for installing, reset startingInstall state - if (validatedRequests.length === 0) { - 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( -
-
-

Install extension {name}@{version}?

-

Description: {description}

-
shell.openPath(extensionFolder)}> - Warning: {extensionFolder} will be removed before installation. -
-
-
- ); - } - } + renderNoExtensions() { + return ( +
+ +
+ {this.renderNoExtensionsHelpText()} +
+
+ ); } - async unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) { - const displayName = extensionDisplayName(name, version); - const extensionId = path.join(extensionDiscovery.nodeModulesPath, name, "package.json"); + @autobind() + renderExtension(extension: InstalledExtension) { + const { id, isEnabled, manifest } = extension; + const { name, description, version } = manifest; + const isUninstalling = ExtensionInstallationStateStore.isExtensionUninstalling(id); - 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`); - - logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile }); - - try { - // extract to temp folder first - await fse.remove(unpackingTempFolder).catch(Function); - await fse.ensureDir(unpackingTempFolder); - await extractTar(tempFile, { cwd: unpackingTempFolder }); - - // move contents to extensions folder - const unpackedFiles = await fse.readdir(unpackingTempFolder); - let unpackedRootFolder = unpackingTempFolder; - - if (unpackedFiles.length === 1) { - // check if %extension.tgz was packed with single top folder, - // e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball - unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]); - } - - await fse.ensureDir(extensionFolder); - await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true }); - } catch (error) { - Notifications.error( -

Installing extension {displayName} has failed: {error}

- ); - - // Remove install state on install failure - if (this.extensionStateStore.extensionState.get(extensionId)?.state === "installing") { - this.extensionStateStore.extensionState.delete(extensionId); - } - } finally { - // clean up - fse.remove(unpackingTempFolder).catch(Function); - fse.unlink(tempFile).catch(Function); - } - } - - confirmUninstallExtension = (extension: InstalledExtension) => { - const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); - - ConfirmDialog.open({ - message:

Are you sure you want to uninstall extension {displayName}?

, - labelOk: "Yes", - labelCancel: "No", - ok: () => this.uninstallExtension(extension) - }); - }; - - async uninstallExtension(extension: InstalledExtension) { - const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); - - try { - this.extensionStateStore.extensionState.set(extension.id, { - state: "uninstalling", - displayName - }); - - await extensionDiscovery.uninstallExtension(extension); - } catch (error) { - Notifications.error( -

Uninstalling extension {displayName} has failed: {error?.message ?? ""}

- ); - - // Remove uninstall state on uninstall failure - if (this.extensionStateStore.extensionState.get(extension.id)?.state === "uninstalling") { - this.extensionStateStore.extensionState.delete(extension.id); - } - } + return ( +
+
+
{name}
+
{version}
+

{description}

+
+
+ { + isEnabled + ? + : + } + +
+
+ ); } renderExtensions() { - const { extensions, search } = this; - - if (!extensions.length) { - return ( -
- -
- { - search - ?

No search results found

- :

There are no installed extensions. See list of available extensions.

- } -
-
- ); + if (!extensionDiscovery.isLoaded) { + return
; } - return extensions.map(extension => { - const { id, isEnabled, manifest } = extension; - const { name, description, version } = manifest; - const isUninstalling = this.extensionStateStore.extensionState.get(id)?.state === "uninstalling"; + const { searchedForExtensions } = this; - return ( -
-
-
{name}
-
{version}
-

{description}

-
-
- {!isEnabled && ( - - )} - {isEnabled && ( - - )} - -
-
- ); - }); - } + if (!searchedForExtensions.length) { + return this.renderNoExtensions(); + } - /** - * True if at least one extension is in installing state - */ - @computed get isInstalling() { - return [...this.extensionStateStore.extensionState.values()].some(extension => extension.state === "installing"); + return ( + <> + {...searchedForExtensions.map(this.renderExtension)} + + ); } render() { @@ -486,7 +569,7 @@ export class Extensions extends React.Component { const { installPath } = this; return ( - +

Lens Extensions

@@ -500,19 +583,19 @@ export class Extensions extends React.Component { this.installPath = value} - onSubmit={this.installFromUrlOrPath} + onSubmit={() => installFromInput(this.installPath)} iconLeft="link" iconRight={ } @@ -521,9 +604,9 @@ export class Extensions extends React.Component {
diff --git a/src/renderer/components/button/button.tsx b/src/renderer/components/button/button.tsx index 8bcb37bad4..da1d88d13f 100644 --- a/src/renderer/components/button/button.tsx +++ b/src/renderer/components/button/button.tsx @@ -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 { 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 = 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 ( this.link = e}> - {btnContent} + {label} + {children} ); } @@ -56,7 +49,8 @@ export class Button extends React.PureComponent { // render as button return ( ); } diff --git a/src/renderer/components/confirm-dialog/confirm-dialog.tsx b/src/renderer/components/confirm-dialog/confirm-dialog.tsx index 721ee36e45..0c29b40c02 100644 --- a/src/renderer/components/confirm-dialog/confirm-dialog.tsx +++ b/src/renderer/components/confirm-dialog/confirm-dialog.tsx @@ -11,14 +11,18 @@ import { Icon } from "../icon"; export interface ConfirmDialogProps extends Partial { } -export interface ConfirmDialogParams { - ok?: () => void; +export interface ConfirmDialogParams extends ConfirmDialogBooleanParams { + ok?: () => any | Promise; + cancel?: () => any | Promise; +} + +export interface ConfirmDialogBooleanParams { labelOk?: ReactNode; labelCancel?: ReactNode; - message?: ReactNode; + message: ReactNode; icon?: ReactNode; - okButtonProps?: Partial - cancelButtonProps?: Partial + okButtonProps?: Partial; + cancelButtonProps?: Partial; } @observer @@ -33,19 +37,26 @@ export class ConfirmDialog extends React.Component { ConfirmDialog.params = params; } - static close() { - ConfirmDialog.isOpen = false; + static confirm(params: ConfirmDialogBooleanParams): Promise { + return new Promise(resolve => { + ConfirmDialog.open({ + ok: () => resolve(true), + cancel: () => resolve(false), + ...params, + }); + }); } - public defaultParams: ConfirmDialogParams = { + static defaultParams: Partial = { ok: noop, + cancel: noop, labelOk: "Ok", labelCancel: "Cancel", icon: , }; 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 { 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() { diff --git a/src/renderer/components/input/input.tsx b/src/renderer/components/input/input.tsx index ce0594c0e6..ad3b77c8e8 100644 --- a/src/renderer/components/input/input.tsx +++ b/src/renderer/components/input/input.tsx @@ -315,6 +315,7 @@ export class Input extends React.Component { rows: multiLine ? (rows || 1) : null, ref: this.bindRef, spellCheck: "false", + disabled, }); const showErrors = errors.length > 0 && !valid && dirty; const errorsInfo = ( diff --git a/src/renderer/components/input/input_validators.ts b/src/renderer/components/input/input_validators.ts index ae5fd6d1e1..c96d63a4c5 100644 --- a/src/renderer/components/input/input_validators.ts +++ b/src/renderer/components/input/input_validators.ts @@ -47,6 +47,14 @@ export const isUrl: InputValidator = { }, }; +export const isExtensionNameInstallRegex = /^(?(@[-\w]+\/)?[-\w]+)(@(?\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`, diff --git a/src/renderer/protocol-handler/app-handlers.ts b/src/renderer/protocol-handler/app-handlers.ts index 256b8b396a..b3ace68a45 100644 --- a/src/renderer/protocol-handler/app-handlers.ts +++ b/src/renderer/protocol-handler/app-handlers.ts @@ -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 }); }); }