From 4f9b5a8a3afdafb73e1e180c5b2545580e5b5603 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Thu, 1 Apr 2021 16:27:38 -0400 Subject: [PATCH] Add support for installing extensions by name - Optionally specify the version, defaults to latest non-prerelease version - Usable via the protocol handlers, but requires user confirmation to actually install Signed-off-by: Sebastian Malton --- src/common/protocol-handler/router.ts | 6 +- src/common/utils/downloadFile.ts | 15 +- .../components/+extensions/extensions.tsx | 211 ++++++++++++------ .../confirm-dialog/confirm-dialog.tsx | 32 ++- src/renderer/components/input/input.tsx | 1 + .../components/input/input_validators.ts | 8 + src/renderer/protocol-handler/app-handlers.ts | 13 +- 7 files changed, 201 insertions(+), 85 deletions(-) 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/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/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index 93e847f6ca..8fd5b111c1 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -6,7 +6,7 @@ import { disposeOnUnmount, observer } from "mobx-react"; import os from "os"; import path from "path"; import React from "react"; -import { autobind, disposer, Disposer, downloadFile, extractTar, listTarEntries, noop, readFileFromTar } from "../../../common/utils"; +import { autobind, disposer, Disposer, downloadFile, downloadJson, 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"; @@ -23,6 +23,9 @@ import { Notifications } from "../notifications"; import { Spinner } from "../spinner/spinner"; import { TooltipPosition } from "../tooltip"; 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") { @@ -46,23 +49,27 @@ function getMessageFromError(error: any): string { 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, manifest: LensExtensionManifest): Promise { +async function uninstallExtension(extensionId: LensExtensionId): Promise { + const { manifest } = extensionLoader.getExtension(extensionId); const displayName = extensionDisplayName(manifest.name, manifest.version); try { @@ -92,18 +99,17 @@ async function uninstallExtension(extensionId: LensExtensionId, manifest: LensEx } } -function confirmUninstallExtension(extension: InstalledExtension): void { +async function confirmUninstallExtension(extension: InstalledExtension): Promise { const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); - - ConfirmDialog.open({ + const confirmed = await ConfirmDialog.confirm({ 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); - } }); + + if (confirmed) { + await uninstallExtension(extension.id); + } } function getExtensionDestFolder(name: string) { @@ -114,16 +120,10 @@ 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 }; - } - +async function readFileNotify(filePath: string, showError = true): Promise { try { - const data = await fse.readFile(filePath); - - return { filePath, data, fileName }; - } catch(error) { + return await fse.readFile(filePath); + } catch (error) { if (showError) { const message = getMessageFromError(error); @@ -166,20 +166,27 @@ async function validatePackage(filePath: string): Promise return manifest; } -async function createTempFilesAndValidate(request: InstallRequestPreloaded, { showErrors = true } = {}): Promise { +async function createTempFilesAndValidate({ fileName, dataP }: InstallRequest, { showErrors = true } = {}): Promise { // copy files to temp await fse.ensureDir(getExtensionPackageTemp()); // validate packages - const tempFile = getExtensionPackageTemp(request.fileName); + const tempFile = getExtensionPackageTemp(fileName); try { - await fse.writeFile(tempFile, request.data); + 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 { - ...request, + fileName, + data, manifest, tempFile, id, @@ -190,10 +197,10 @@ async function createTempFilesAndValidate(request: InstallRequestPreloaded, { sh if (showErrors) { const message = getMessageFromError(error); - logger.info(`[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`, { error }); + logger.info(`[EXTENSION-INSTALLATION]: installing ${fileName} has failed: ${message}`, { error }); Notifications.error(
-

Installing {request.fileName} has failed, skipping.

+

Installing {fileName} has failed, skipping.

Reason: {message}

); @@ -258,20 +265,62 @@ async function unpackExtension(request: InstallRequestValidated, disposeDownload } } -/** - * - * @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); +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 }); - if (!loadedRequest) { - return dispose(); + let json; + + try { + json = await promise; + } catch (error) { + console.error(error); + Notifications.error("Failed to get registry information for that extension"); + + return disposer(); } - const validatedRequest = await createTempFilesAndValidate(loadedRequest); + 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({ + 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: 60000 /*1m*/ }); + + return attemptInstall({ fileName, dataP }, disposer); +} + +async function attemptInstall(request: InstallRequest, d?: Disposer): Promise { + const dispose = disposer(ExtensionInstallationStateStore.startPreInstall(), d); + const validatedRequest = await createTempFilesAndValidate(request); if (!validatedRequest) { return dispose(); @@ -299,6 +348,8 @@ async function requestInstall(request: InstallRequest, d?: Disposer): Promise @@ -306,13 +357,13 @@ async function requestInstall(request: InstallRequest, d?: Disposer): PromiseInstall extension {name}@{version}?

Description: {description}

shell.openPath(extensionFolder)}> - Warning: {extensionFolder} will be removed before installation. + Warning: {name}@{oldVersion} will be removed before installation.
: } - + ); @@ -524,10 +591,10 @@ export class Extensions extends React.Component { disabled={ExtensionInstallationStateStore.anyPreInstallingOrInstalling} placeholder={`Path or URL to an extension package (${supportedFormats.join(", ")})`} showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }} - validators={installPath ? Extensions.installPathValidator : undefined} + validators={installPath ? Extensions.installInputValidator : undefined} value={installPath} onChange={value => this.installPath = value} - onSubmit={() => installFromUrlOrPath(this.installPath)} + onSubmit={() => installFromInput(this.installPath)} iconLeft="link" iconRight={ installFromUrlOrPath(this.installPath)} + onClick={() => installFromInput(this.installPath)} /> Pro-Tip: you can also drag-n-drop tarball-file to this area diff --git a/src/renderer/components/confirm-dialog/confirm-dialog.tsx b/src/renderer/components/confirm-dialog/confirm-dialog.tsx index 721ee36e45..617f53fe86 100644 --- a/src/renderer/components/confirm-dialog/confirm-dialog.tsx +++ b/src/renderer/components/confirm-dialog/confirm-dialog.tsx @@ -11,14 +11,19 @@ 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; icon?: ReactNode; okButtonProps?: Partial cancelButtonProps?: Partial + } @observer @@ -33,7 +38,17 @@ export class ConfirmDialog extends React.Component { ConfirmDialog.params = params; } - static close() { + static confirm(params: ConfirmDialogBooleanParams): Promise { + return new Promise(resolve => { + ConfirmDialog.open({ + ok: () => resolve(true), + cancel: () => resolve(false), + ...params, + }); + }); + } + + private static closeDialog() { ConfirmDialog.isOpen = false; } @@ -54,16 +69,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..203f5fc6f3 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${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 }); }); }