import "./extensions.scss"; import { remote, shell } from "electron"; import fse from "fs-extra"; 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 { 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"; import { extensionDisplayName, LensExtensionId, LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension"; import logger from "../../../main/logger"; import { prevDefault } from "../../utils"; import { Button } from "../button"; import { ConfirmDialog } from "../confirm-dialog"; import { Icon } from "../icon"; import { DropFileInput, Input, InputValidator, InputValidators, SearchInput } from "../input"; import { PageLayout } from "../layout/page-layout"; import { SubTitle } from "../layout/sub-title"; 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") { 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; dataP: Promise; } interface InstallRequestValidated { fileName: string; data: Buffer; 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.lens && !manifest.renderer) { throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`); } return manifest; } async function createTempFilesAndValidate({ fileName, dataP }: InstallRequest, { showErrors = true } = {}): Promise { // copy files to temp await fse.ensureDir(getExtensionPackageTemp()); // validate packages const tempFile = getExtensionPackageTemp(fileName); 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) { fse.unlink(tempFile).catch(noop); // remove invalid temp package if (showErrors) { 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 }); let json; try { json = await promise; } catch (error) { console.error(error); Notifications.error("Failed to get registry information for that extension"); 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({ 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?: Disposer): Promise { const dispose = disposer(ExtensionInstallationStateStore.startPreInstall(), d); const validatedRequest = await createTempFilesAndValidate(request); 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 procede 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: Disposer; 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 installInputValidators = [ InputValidators.isUrl, InputValidators.isPath, InputValidators.isExtensionNameInstall, ]; private static installInputValidator: InputValidator = { message: "Invalid URL, absolute path, or extension name", validate: (value: string) => ( Extensions.installInputValidators.some(({ validate }) => validate(value)) ), }; @observable search = ""; @observable installPath = ""; @computed get searchedForExtensions() { const searchText = this.search.toLowerCase(); return Array.from(extensionLoader.userExtensions.values()) .filter(({ manifest: { name, description }}) => ( name.toLowerCase().includes(searchText) || description?.toLowerCase().includes(searchText) )); } componentDidMount() { // TODO: change this after upgrading to mobx6 as that versions' reactions have this functionality let prevSize = extensionLoader.userExtensions.size; disposeOnUnmount(this, [ reaction(() => extensionLoader.userExtensions.size, curSize => { try { if (curSize > prevSize) { when(() => !ExtensionInstallationStateStore.anyInstalling) .then(() => this.installPath = ""); } } finally { prevSize = curSize; } }) ]); } renderNoExtensionsHelpText() { if (this.search) { return

No search results found

; } return (

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

); } renderNoExtensions() { return (
{this.renderNoExtensionsHelpText()}
); } @autobind() renderExtension(extension: InstalledExtension) { const { id, isEnabled, manifest } = extension; const { name, description, version } = manifest; const isUninstalling = ExtensionInstallationStateStore.isExtensionUninstalling(id); return (
{name}
{version}

{description}

{ isEnabled ? : }
); } renderExtensions() { if (!extensionDiscovery.isLoaded) { return
; } const { searchedForExtensions } = this; if (!searchedForExtensions.length) { return this.renderNoExtensions(); } return ( <> {...searchedForExtensions.map(this.renderExtension)} ); } render() { const topHeader =

Manage Lens Extensions

; const { installPath } = this; return (

Lens Extensions

Add new features and functionality via Lens Extensions. Check out documentation to learn more or see the list of available extensions.
this.installPath = value} onSubmit={() => installFromInput(this.installPath)} iconLeft="link" iconRight={ } />

Installed Extensions

this.search = value} /> {this.renderExtensions()}
); } }