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.
)}
{
removeNotification();
this.unpackExtension(install);
}}/>
);
})
}
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}
/>
Pro-Tip 1 : you can download packed extension from NPM via
npm pack %package-name
Pro-Tip 2 : you can drag & drop extension's tarball here to request installation
);
}
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 && (
ext.isEnabled = true}>Enable
)}
{isEnabled && (
ext.isEnabled = false}>Disable
)}
);
});
}
render() {
return (
Extensions}>
this.search = value}
/>
{this.renderExtensions()}
);
}
}