From 79aec6bdb7539b8b3b7e13c6b44e82fbcf7a7d56 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 24 Feb 2021 16:30:50 -0500 Subject: [PATCH] Refactor the Extensions settings page - Simplify the install logic by refactoring out the multi-install logic - Revamp the ExtensionInstallStateStore to more strictly track the lifetime of an install or uninstall request - Fix the install of an already installed extension just hanging visually - Display a spinner more often for more visual feedback Signed-off-by: Sebastian Malton --- src/common/utils/disposer.ts | 18 + src/common/utils/index.ts | 1 + src/extensions/extension-discovery.ts | 27 +- .../+extensions/__tests__/extensions.test.tsx | 6 +- .../+extensions/extension-install.store.ts | 192 ++++- .../components/+extensions/extensions.tsx | 812 +++++++++--------- 6 files changed, 617 insertions(+), 439 deletions(-) create mode 100644 src/common/utils/disposer.ts diff --git a/src/common/utils/disposer.ts b/src/common/utils/disposer.ts new file mode 100644 index 0000000000..93c404015b --- /dev/null +++ b/src/common/utils/disposer.ts @@ -0,0 +1,18 @@ +export type Disposer = () => void; + +interface Extendable { + push(...vals: T[]): void; +} + +export function disposer(...args: Disposer[]): Disposer & Extendable { + const res = () => { + args.forEach(dispose => dispose?.()); + args.length = 0; + }; + + res.push = (...vals: Disposer[]) => { + args.push(...vals); + }; + + return res; +} 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/extension-discovery.ts b/src/extensions/extension-discovery.ts index 0994f89995..227a9aa59b 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -9,23 +9,24 @@ import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } fr import { getBundledExtensions } from "../common/utils/app-version"; import logger from "../main/logger"; 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]"; @@ -236,9 +237,11 @@ export class ExtensionDiscovery { /** * 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); diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx index 8899d9d74c..88a1d99805 100644 --- a/src/renderer/components/+extensions/__tests__/extensions.test.tsx +++ b/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -5,7 +5,7 @@ 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.mock("fs-extra"); @@ -54,7 +54,7 @@ jest.mock("../../notifications", () => ({ describe("Extensions", () => { beforeEach(() => { - ExtensionStateStore.resetInstance(); + ExtensionInstallationStateStore.reset(); }); it("disables uninstall and disable buttons while uninstalling", async () => { @@ -122,7 +122,7 @@ describe("Extensions", () => { extensionDiscovery.isLoaded = true; - waitFor(() => + waitFor(() => expect(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..943aa424b5 100644 --- a/src/renderer/components/+extensions/extension-install.store.ts +++ b/src/renderer/components/+extensions/extension-install.store.ts @@ -1,13 +1,187 @@ -import { observable } from "mobx"; -import { autobind, Singleton } from "../../utils"; +import { action, computed, observable } from "mobx"; +import logger from "../../../main/logger"; +import { disposer, Disposer } from "../../utils"; +import * as uuid from "uuid"; -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]"; +const installingExtensions = observable.set(); +const uninstallingExtensions = observable.set(); +const preInstallIds = observable.set(); + +export class ExtensionInstallationStateStore { + @action static reset() { + logger.warn(`${Prefix}: resetting, may throw errors`); + installingExtensions.clear(); + uninstallingExtensions.clear(); + 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}.`); + } + + installingExtensions.add(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(): Disposer { + const preInstallStepId = uuid.v4(); + + logger.debug(`${Prefix}: starting a new preinstall phase: ${preInstallStepId}`); + preInstallIds.add(preInstallStepId); + + return disposer(() => { + 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}.`); + } + + 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 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 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 (installingExtensions.has(extId)) { + return ExtensionInstallationState.INSTALLING; + } + + if (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 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 preinstallig + */ + @computed static get preinstalling(): number { + return 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..63114c328e 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, 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,8 +22,7 @@ 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"; interface InstallRequest { fileName: string; @@ -35,14 +35,324 @@ interface InstallRequestPreloaded extends InstallRequest { } interface InstallRequestValidated extends InstallRequestPreloaded { + id: LensExtensionId; manifest: LensExtensionManifest; tempFile: string; // temp system path to packed extension for unpacking } +async function uninstallExtension(extensionId: LensExtensionId, manifest: LensExtensionManifest): Promise { + 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) { + Notifications.error( +

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

+ ); + + return false; + } finally { + // Remove uninstall state on uninstall failure + ExtensionInstallationStateStore.clearUninstalling(extensionId); + } +} + +function confirmUninstallExtension(extension: InstalledExtension): void { + 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: () => { + // Don't want the confirm dialog to stay up longer than the click + uninstallExtension(extension.id, extension.manifest); + } + }); +} + +function getExtensionDestFolder(name: string) { + return path.join(extensionDiscovery.localFolderPath, sanitizeExtensionName(name)); +} + +function getExtensionPackageTemp(fileName = "") { + return path.join(os.tmpdir(), "lens-extensions", fileName); +} + +async function preloadExtension({ fileName, data, filePath }: InstallRequest, { showError = true } = {}): Promise { + if(data) { + return { filePath, data, fileName }; + } + + try { + const data = await fse.readFile(filePath); + + return { filePath, data, fileName }; + } catch(error) { + if (showError) { + Notifications.error(`Error while reading "${filePath}": ${String(error)}`); + } + } + + 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.lens && !manifest.renderer) { + throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`); + } + + return manifest; +} + +async function createTempFilesAndValidate(request: InstallRequestPreloaded, { showErrors = true } = {}): Promise { + // copy files to temp + await fse.ensureDir(getExtensionPackageTemp()); + + // validate packages + const tempFile = getExtensionPackageTemp(request.fileName); + + try { + await fse.writeFile(tempFile, request.data); + const manifest = await validatePackage(tempFile); + const id = path.join(extensionDiscovery.nodeModulesPath, manifest.name, "package.json"); + + return { + ...request, + manifest, + tempFile, + id, + }; + } catch (error) { + fse.unlink(tempFile).catch(noop); // remove invalid temp package + + if (showErrors) { + Notifications.error( +
+

Installing {request.fileName} has failed, skipping.

+

Reason: {String(error)}

+
+ ); + } + } + + 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) { + Notifications.error( +

Installing extension {displayName} has failed: {error}

+ ); + } finally { + // Remove install state once finished + ExtensionInstallationStateStore.clearInstalling(id); + + // clean up + fse.remove(unpackingTempFolder).catch(noop); + fse.unlink(tempFile).catch(noop); + } +} + +/** + * + * @param request The information needed to install the extension + * @param fromUrl The optional URL + */ +async function requestInstall(request: InstallRequest, d?: Disposer): Promise { + const dispose = disposer(ExtensionInstallationStateStore.startPreInstall(), d); + const loadedRequest = await preloadExtension(request); + + if (!loadedRequest) { + return; + } + + const validatedRequest = await createTempFilesAndValidate(loadedRequest); + + if (!validatedRequest) { + return; + } + + 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 procede with this current install request.

+
+ ); + } + + const extensionFolder = getExtensionDestFolder(name); + const folderExists = await fse.pathExists(extensionFolder); + + if (!folderExists) { + // install extension if not yet exists + unpackExtension(validatedRequest, dispose); + } else { + // 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. +
+
+
+ ); + } +} + +async function requestInstalls(filePaths: string[]): Promise { + const promises: Promise[] = []; + + for (const filePath of filePaths) { + promises.push(requestInstall({ + fileName: path.basename(filePath), + filePath, + })); + } + + await Promise.allSettled(promises); +} + +async function installOnDrop(files: File[]) { + logger.info("Install from D&D"); + await requestInstalls(files.map(({ path }) => path)); +} + +async function installFromUrlOrPath(installPath: string) { + const fileName = path.basename(installPath); + + try { + // install via url + // fixme: improve error messages for non-tar-file URLs + if (InputValidators.isUrl.validate(installPath)) { + const disposer = ExtensionInstallationStateStore.startPreInstall(); + const { promise: filePromise } = downloadFile({ url: installPath, timeout: 60000 /*1m*/ }); + const data = await filePromise; + + await requestInstall({ fileName, data }, disposer); + } + // otherwise installing from system path + else if (InputValidators.isPath.validate(installPath)) { + await requestInstall({ fileName, filePath: installPath }); + } + } catch (error) { + Notifications.error( +

Installation has failed: {String(error)}

+ ); + } +} + +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 requestInstalls(filePaths); + } +} + @observer export class Extensions extends React.Component { - private static supportedFormats = ["tar", "tgz"]; - private static installPathValidator: InputValidator = { message: "Invalid URL or absolute path", validate(value: string) { @@ -50,71 +360,10 @@ export class Extensions extends React.Component { } }; - 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 +373,95 @@ 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)} + { + ExtensionInstallationStateStore.anyPreInstallingOrInstalling + &&
+ } + + ); } render() { @@ -486,7 +469,7 @@ export class Extensions extends React.Component { const { installPath } = this; return ( - +

Lens Extensions

@@ -500,19 +483,19 @@ export class Extensions extends React.Component { this.installPath = value} - onSubmit={this.installFromUrlOrPath} + onSubmit={() => installFromUrlOrPath(this.installPath)} iconLeft="link" iconRight={ } @@ -521,9 +504,8 @@ export class Extensions extends React.Component {