import "./extensions.scss"; import { remote, shell } from "electron"; import os from "os"; import path from "path"; import fse from "fs-extra"; import React from "react"; import { computed, observable } from "mobx"; import { observer } from "mobx-react"; import { t, Trans } from "@lingui/macro"; import { _i18n } from "../../i18n"; import { Button } from "../button"; import { WizardLayout } from "../layout/wizard-layout"; import { DropFileInput, Input, InputValidators, SearchInput } from "../input"; import { Icon } from "../icon"; import { PageLayout } from "../layout/page-layout"; import { Clipboard } from "../clipboard"; import logger from "../../../main/logger"; import { extensionLoader } from "../../../extensions/extension-loader"; import { extensionManager } from "../../../extensions/extension-manager"; import { LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension"; import { Notifications } from "../notifications"; import { downloadFile } from "../../../common/utils"; import { extractTar, readFileFromTar } from "../../../common/utils/tar"; interface InstallRequest { fileName: string; filePath?: string; data?: Buffer; } interface InstallRequestValidated extends InstallRequest { manifest: LensExtensionManifest; tmpFile: string; // temp file for unpacking } @observer export class Extensions extends React.Component { private supportedFormats = [".tar", ".tgz"]; @observable search = ""; @observable downloadUrl = ""; @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(v => v); }); } get extensionsPath() { return extensionManager.localFolderPath; } 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: filePath, })) ); } } installExtensions = () => { if (this.downloadUrl) { this.installFromNpmOrUrl(this.downloadUrl); this.downloadUrl = ""; } else { this.installFromSelectFileDialog(); } } installFromNpmOrUrl = async (url = this.downloadUrl) => { if (!InputValidators.isUrl.validate(url)) { url = extensionManager.getNpmPackageTarballUrl(url); if (!url) { Notifications.error(`Error: npm package "${url}" not found!`); return; } } try { const { promise: filePromise } = downloadFile({ url }); this.requestInstall([{ fileName: path.basename(url), data: await filePromise, }]); } catch (err) { Notifications.error(

Installation from URL has failed: {String(err)}

URL: {url}

); } } installOnDrop = (files: File[]) => { logger.info('Install from D&D'); return this.requestInstall( files.map(file => ({ fileName: path.basename(file.path), filePath: file.path, })) ); } async requestInstall(installRequests: InstallRequest[]) { const pendingFiles: Promise[] = []; // read extensions with provided system path if any installRequests.forEach(ext => { if (ext.data) return; const promise = fse.readFile(ext.filePath) .then(data => ext.data = data) .catch(err => { Notifications.error(`Error while reading "${ext.filePath}": ${String(err)}`); }); pendingFiles.push(promise) }); await Promise.all(pendingFiles); installRequests = installRequests.filter(item => item.data); // remove items with reading errors // prepare temp folder const tempFolder = path.join(os.tmpdir(), "lens-extensions"); await fse.ensureDir(tempFolder); // copy files to temp, get extension info from package.json and do basic validation let validatedInstalls: Promise[] = installRequests.map(async installReq => { const { fileName, data } = installReq; const tempFile = path.join(tempFolder, fileName); await fse.writeFileSync(tempFile, data); // copy to temp try { const packageJson: Buffer = await readFileFromTar(tempFile, { // tarball from npm contains single root folder "package/*" fileMatcher: (path: string) => !!path.match(/(\w+\/)?package\.json$/), notFoundMessage: "Extension's manifest file (package.json) not found", }); const manifest: LensExtensionManifest = JSON.parse(packageJson.toString("utf8")); if (!manifest.lens && !manifest.renderer) { throw `package.json must specify "main" and/or "renderer" fields`; } return { ...installReq, manifest: manifest, tmpFile: tempFile, } } catch (err) { fse.unlink(tempFile).catch(() => null); // remove invalid temp file Notifications.error(

Installing {fileName} has failed, skipping.

Reason: {String(err)}

); } }); // final step, provide UI with extension info for reviewing and confirming installation const extensions = await Promise.all(validatedInstalls); extensions.forEach(install => { if (!install) { return; // skip validating errors if any } const { fileName, manifest } = install; const { name, version, description } = manifest; const extensionFolder = this.getExtensionDestFolder(name); const folderExists = fse.existsSync(extensionFolder); const removeNotification = Notifications.info(

Install extension {name}@{version}?

Description: {description}

{folderExists && (
shell.openPath(extensionFolder)}>

Warning: {extensionFolder} will be removed before installation.

)}
); }) } async unpackExtension({ fileName, tmpFile, manifest: { name, version } }: InstallRequestValidated) { logger.info(`Unpacking extension ${name} from ${fileName}`); const unpackingTempFolder = path.join(path.dirname(tmpFile), path.basename(tmpFile) + "-unpacked"); const extensionFolder = this.getExtensionDestFolder(name); try { // extract to temp folder first await fse.remove(unpackingTempFolder).catch(Function); await fse.ensureDir(unpackingTempFolder); await extractTar(tmpFile, { cwd: unpackingTempFolder }); // move contents to extensions folder const unpackedFiles = await fse.readdir(unpackingTempFolder); let unpackedRootFolder = unpackingTempFolder; if (unpackedFiles.length === 1) { // handle case when extension.tgz packed with top root 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 {name}/{version} successfully installed!

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

Installing extension {name} has failed: {err}

); } finally { // clean up fse.remove(unpackingTempFolder).catch(Function); fse.unlink(tmpFile).catch(Function); } } renderInfo() { return (

Lens Extension API

The Extensions API in Lens allows users to customize and enhance the Lens experience by creating their own menus or page content that is extended from the existing pages. Many of the core features of Lens are built as extensions and use the same Extension API.

Extensions loaded from:

shell.openPath(this.extensionsPath)}> {this.extensionsPath}
Install extensions from tarball ({this.supportedFormats.join(", ")}): this.downloadUrl = v} onSubmit={this.installExtensions} />

Check out documentation to learn more

); } 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 { manifestPath: extId, isEnabled, manifest } = ext; const { name, description } = manifest; return (
Name: {name}
Description: {description}
{!isEnabled && ( )} {isEnabled && ( )}
); }); } render() { return ( Extensions}> this.search = value} />
{this.renderExtensions()}
); } }