import { t, Trans } from "@lingui/macro"; import { remote, shell } from "electron"; import fse from "fs-extra"; import { map, omit } from "lodash"; import { computed, observable, ObservableMap, reaction } 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 { 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 logger from "../../../main/logger"; import { _i18n } from "../../i18n"; import { prevDefault } from "../../utils"; import { Button } from "../button"; 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 { TooltipPosition } from "../tooltip"; import "./extensions.scss"; interface InstallRequest { fileName: string; filePath?: string; data?: Buffer; } interface InstallRequestPreloaded extends InstallRequest { data: Buffer; } interface InstallRequestValidated extends InstallRequestPreloaded { manifest: LensExtensionManifest; tempFile: string; // temp system path to packed extension for unpacking } interface ExtensionState { displayName: string; // Possible states the extension can be state: "uninstalling"; } @observer export class Extensions extends React.Component { private supportedFormats = [".tar", ".tgz"]; private installPathValidator: InputValidator = { message: Invalid URL or absolute path, validate(value: string) { return InputValidators.isUrl.validate(value) || InputValidators.isPath.validate(value); } }; @observable extensionState = observable.map(); @observable search = ""; @observable installPath = ""; /** * Extensions that were removed from extensions but are still in "uninstalling" state */ @computed get removedUninstalling() { return Array.from(this.extensionState.entries()).filter(([id, extension]) => extension.state === "uninstalling" && !this.extensions.find(extension => extension.id === id) ).map(([id, extension]) => ({ ...extension, id })); } componentDidMount() { disposeOnUnmount(this, reaction(() => this.extensions, (extensions) => { const removedUninstalling = this.removedUninstalling; removedUninstalling.forEach(({ displayName }) => { Notifications.ok(

Extension {displayName} successfully uninstalled!

); }); removedUninstalling.forEach(({ id }) => { this.extensionState.delete(id); }); }) ); } @computed get extensions() { const searchText = this.search.toLowerCase(); return Array.from(extensionLoader.userExtensions.values()).filter(ext => { const { name, description } = ext.manifest; return [ name.toLowerCase().includes(searchText), description?.toLowerCase().includes(searchText), ].some(value => value); }); } 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: _i18n._(t`Select extensions to install (formats: ${this.supportedFormats.join(", ")}), `), buttonLabel: _i18n._(t`Use configuration`), filters: [ { name: "tarball", extensions: this.supportedFormats } ] }); if (!canceled && filePaths.length) { this.requestInstall( filePaths.map(filePath => ({ fileName: path.basename(filePath), filePath, })) ); } }; installFromUrlOrPath = async () => { const { installPath } = this; if (!installPath) return; 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; this.requestInstall({ fileName, data }); } // otherwise installing from system path else if (InputValidators.isPath.validate(installPath)) { this.requestInstall({ fileName, filePath: installPath }); } } catch (error) { 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(req => req.data); await Promise.all( requests .filter(req => !req.data && req.filePath) .map(req => { return fse.readFile(req.filePath).then(data => { req.data = data; preloadedRequests.push(req); }).catch(error => { if (showError) { Notifications.error(`Error while reading "${req.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 rootFolder = tarFiles[0].split("/")[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()); requests.forEach(req => { const tempFile = this.getExtensionPackageTemp(req.fileName); fse.writeFileSync(tempFile, req.data); }); // validate packages await Promise.all( requests.map(async req => { const tempFile = this.getExtensionPackageTemp(req.fileName); 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)}

); } } }) ); 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); validatedRequests.forEach(install => { const { name, version, description } = install.manifest; const extensionFolder = this.getExtensionDestFolder(name); const folderExists = fse.existsSync(extensionFolder); if (!folderExists) { // auto-install extension if not yet exists this.unpackExtension(install); } 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 unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) { const extName = extensionDisplayName(name, version); logger.info(`Unpacking extension ${extName}`, { fileName, tempFile }); const unpackingTempFolder = path.join(path.dirname(tempFile), path.basename(tempFile) + "-unpacked"); const extensionFolder = this.getExtensionDestFolder(name); 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 }); Notifications.ok(

Extension {extName} successfully installed!

); } catch (error) { Notifications.error(

Installing extension {extName} has failed: {error}

); } finally { // clean up fse.remove(unpackingTempFolder).catch(Function); fse.unlink(tempFile).catch(Function); } } async uninstallExtension(extension: InstalledExtension) { const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); try { this.extensionState.set(extension.id, { state: "uninstalling", displayName }); await extensionDiscovery.uninstallExtension(extension.absolutePath); } catch (error) { Notifications.error(

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

); // Remove uninstall state on uninstall failure this.extensionState.delete(extension.id); } } renderExtensions() { const { extensions, extensionsPath, search } = this; if (!extensions.length) { return (
{search &&

No search results found

} {!search &&

There are no extensions in {extensionsPath}

}
); } return extensions.map(ext => { const { id, isEnabled, manifest } = ext; const { name, description } = manifest; const isUninstalling = this.extensionState.get(id)?.state === "uninstalling"; return (
Name: {name}
Description: {description}
{!isEnabled && ( )} {isEnabled && ( )}
); }); } render() { const topHeader =

Manage Lens Extensions

; const { installPath } = this; return (

Lens Extensions

The features that Lens includes out-of-the-box are just the start. Lens extensions let you add new features to your installation to support your workflow. Rich extensibility model lets extension authors plug directly into the Lens UI and contribute functionality through the same APIs used by Lens itself. Check out documentation to learn more.
Install Extension:}/>
this.installPath = v} onSubmit={this.installFromUrlOrPath} iconLeft="link" iconRight={ Browse} /> } />

Installed Extensions

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