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.
{
removeNotification();
- if (await uninstallExtension(validatedRequest.id, validatedRequest.manifest)) {
+ if (await uninstallExtension(validatedRequest.id)) {
await unpackExtension(validatedRequest, dispose);
} else {
dispose();
@@ -328,13 +379,13 @@ async function requestInstall(request: InstallRequest, d?: Disposer): Promise {
+async function attemptInstalls(filePaths: string[]): Promise {
const promises: Promise[] = [];
for (const filePath of filePaths) {
- promises.push(requestInstall({
+ promises.push(attemptInstall({
fileName: path.basename(filePath),
- filePath,
+ dataP: readFileNotify(filePath),
}));
}
@@ -343,31 +394,35 @@ async function requestInstalls(filePaths: string[]): Promise {
async function installOnDrop(files: File[]) {
logger.info("Install from D&D");
- await requestInstalls(files.map(({ path }) => path));
+ await attemptInstalls(files.map(({ path }) => path));
}
-async function installFromUrlOrPath(installPath: string) {
- const fileName = path.basename(installPath);
+async function installFromInput(input: string) {
let disposer: Disposer;
try {
- // install via url
// fixme: improve error messages for non-tar-file URLs
- if (InputValidators.isUrl.validate(installPath)) {
+ if (InputValidators.isUrl.validate(input)) {
+ // install via url
disposer = ExtensionInstallationStateStore.startPreInstall();
- const { promise: filePromise } = downloadFile({ url: installPath, timeout: 60000 /*1m*/ });
- const data = await filePromise;
+ const { promise } = downloadFile({ url: input, timeout: 60000 /*1m*/ });
+ const fileName = path.basename(input);
- await requestInstall({ fileName, data }, disposer);
- }
- // otherwise installing from system path
- else if (InputValidators.isPath.validate(installPath)) {
- await requestInstall({ fileName, filePath: installPath });
+ 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 });
+ logger.info(`[EXTENSION-INSTALL]: installation has failed: ${message}`, { error, installPath: input });
Notifications.error(Installation has failed: {message}
);
} finally {
disposer?.();
@@ -389,17 +444,23 @@ async function installFromSelectFileDialog() {
});
if (!canceled) {
- await requestInstalls(filePaths);
+ await attemptInstalls(filePaths);
}
}
@observer
export class Extensions extends React.Component {
- private static installPathValidator: InputValidator = {
- message: "Invalid URL or absolute path",
- validate(value: string) {
- return InputValidators.isUrl.validate(value) || InputValidators.isPath.validate(value);
- }
+ private static installInputValidators = [
+ InputValidators.isUrl,
+ InputValidators.isPath,
+ InputValidators.isExtensionNameInstall,
+ ];
+
+ private static installInputValidator: InputValidator = {
+ message: "Invalid URL, absolute path, or extension name",
+ validate: (value: string) => (
+ Extensions.installInputValidators.some(({ validate }) => validate(value))
+ ),
};
@observable search = "";
@@ -476,9 +537,15 @@ export class Extensions extends React.Component {
? extension.isEnabled = false}>Disable
: extension.isEnabled = true}>Enable
}
- {
- confirmUninstallExtension(extension);
- }}>Uninstall
+ confirmUninstallExtension(extension)}
+ >
+ Uninstall
+
);
@@ -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 });
});
}