Allow to install packed extensions from URL or local file (#1456)

* Option to install an extension from filesystem/url #1227 -- part 1 (UI)

Signed-off-by: Roman <ixrock@gmail.com>

* DropFileInput: common component to handle droped files (replaced also in add-cluster-page)

Signed-off-by: Roman <ixrock@gmail.com>

* fix: install via url-string on input.submit

Signed-off-by: Roman <ixrock@gmail.com>

* ui tweaks & minor fixes

Signed-off-by: Roman <ixrock@gmail.com>

* more ui/ux tweaks & fixes

Signed-off-by: Roman <ixrock@gmail.com>

* layout fixes

Signed-off-by: Roman <ixrock@gmail.com>

* component renaming: `copy-to-click` => `copy-to-clipboard` => `clipboard`

Signed-off-by: Roman <ixrock@gmail.com>

* reworks -- part 1

Signed-off-by: Roman <ixrock@gmail.com>

* fix downloading file, added common/utils/downloadFile

Signed-off-by: Roman <ixrock@gmail.com>

* confirm before install, unpack tar first steps

Signed-off-by: Roman <ixrock@gmail.com>

* installation flow, extracting .tgz

Signed-off-by: Roman <ixrock@gmail.com>

* clean up, fix lint issues

Signed-off-by: Roman <ixrock@gmail.com>

* update .azure-pipelines.yml

Signed-off-by: Roman <ixrock@gmail.com>

* fixes & refactoring

Signed-off-by: Roman <ixrock@gmail.com>

* fix lint harder :/

Signed-off-by: Roman <ixrock@gmail.com>

* fix validation

Signed-off-by: Roman <ixrock@gmail.com>

* fix validation harder

Signed-off-by: Roman <ixrock@gmail.com>

* responding to comments, fixed package validation

Signed-off-by: Roman <ixrock@gmail.com>

* common/utils/tar.ts: reject with Error-type

Signed-off-by: Roman <ixrock@gmail.com>

* fix: unit-tests

Signed-off-by: Roman <ixrock@gmail.com>
This commit is contained in:
Roman authored and GitHub committed 2020-11-25 09:55:28 +02:00
1 parent 2da598b66e
commit 77ae31550a
36 files changed
+799 -193

No files matched your search

@@ -1,22 +1,54 @@
.Extensions {
$spacing: $padding * 2;
--width: 100%;
--max-width: auto;
.extension-list {
.extensions-list {
.extension {
--flex-gap: $padding / 3;
padding: $padding $padding * 2;
padding: $padding $spacing;
background: $colorVague;
border-radius: $radius;
&:not(:first-of-type) {
margin-top: $padding * 2;
margin-top: $spacing;
}
}
}
.extensions-info {
--flex-gap: #{$spacing};
> .flex.gaps {
--flex-gap: #{$padding};
}
}
.extensions-path {
word-break: break-all;
&:hover code {
color: $textColorSecondary;
cursor: pointer;
}
}
.Clipboard {
display: inline;
vertical-align: baseline;
font-size: $font-size-small;
&:hover {
color: $textColorSecondary;
}
}
.SearchInput {
--spacing: #{$padding};
}
.SubTitle {
font-style: italic;
}
.WizardLayout {
@@ -27,15 +59,28 @@
align-self: flex-start;
}
}
}
.SearchInput {
margin-top: $margin / 2;
margin-bottom: $margin * 2;
max-width: none;
.InstallingExtensionNotification {
.remove-folder-warning {
font-size: $font-size-small;
font-style: italic;
opacity: .8;
cursor: pointer;
> label {
padding: $padding $padding * 2;
border-radius: $radius;
&:hover {
opacity: 1;
}
code {
display: inline;
color: inherit;
}
}
.Button {
background-color: unset;
border: 1px solid currentColor;
box-shadow: none !important;
}
}
@@ -1,5 +1,8 @@
import "./extensions.scss";
import { shell } from "electron";
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";
@@ -7,15 +10,39 @@ import { t, Trans } from "@lingui/macro";
import { _i18n } from "../../i18n";
import { Button } from "../button";
import { WizardLayout } from "../layout/wizard-layout";
import { Input } from "../input";
import { DropFileInput, Input, InputValidators, SearchInput } from "../input";
import { Icon } from "../icon";
import { SubTitle } from "../layout/sub-title";
import { PageLayout } from "../layout/page-layout";
import { Clipboard } from "../clipboard";
import logger from "../../../main/logger";
import { extensionLoader } from "../../../extensions/extension-loader";
import { extensionDiscovery } from "../../../extensions/extension-discovery";
import { extensionDiscovery, manifestFilename } from "../../../extensions/extension-discovery";
import { LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension";
import { Notifications } from "../notifications";
import { downloadFile, extractTar, listTarEntries, readFileFromTar } from "../../../common/utils";
import { docsUrl } from "../../../common/vars";
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
}
@observer
export class Extensions extends React.Component {
private supportedFormats = [".tar", ".tgz"];
@observable search = "";
@observable downloadUrl = "";
@computed get extensions() {
const searchText = this.search.toLowerCase();
@@ -32,27 +59,265 @@ export class Extensions extends React.Component {
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,
}))
);
}
};
addExtensions = () => {
const { downloadUrl } = this;
if (downloadUrl && InputValidators.isUrl.validate(downloadUrl)) {
this.installFromUrl(downloadUrl);
} else {
this.installFromSelectFileDialog();
}
};
installFromUrl = async (url: string) => {
try {
const { promise: filePromise } = downloadFile({ url });
this.requestInstall([{
fileName: path.basename(url),
data: await filePromise,
}]);
} catch (err) {
Notifications.error(
<p>Installation via URL has failed: <b>{String(err)}</b></p>
);
}
};
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(err => {
if (showError) {
Notifications.error(`Error while reading "${req.filePath}": ${String(err)}`);
}
});
})
);
return preloadedRequests as InstallRequestPreloaded[];
}
async validatePackage(filePath: string): Promise<LensExtensionManifest> {
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<LensExtensionManifest>({
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 (err) {
fse.unlink(tempFile).catch(() => null); // remove invalid temp package
if (showErrors) {
Notifications.error(
<div className="flex column gaps">
<p>Installing <em>{req.fileName}</em> has failed, skipping.</p>
<p>Reason: <em>{String(err)}</em></p>
</div>
);
}
}
})
);
return validatedRequests;
}
async requestInstall(requests: InstallRequest[]) {
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(
<div className="InstallingExtensionNotification flex gaps align-center">
<div className="flex column gaps">
<p>Install extension <b>{name}@{version}</b>?</p>
<p>Description: <em>{description}</em></p>
<div className="remove-folder-warning" onClick={() => shell.openPath(extensionFolder)}>
<b>Warning:</b> <code>{extensionFolder}</code> will be removed before installation.
</div>
</div>
<Button autoFocus label="Install" onClick={() => {
removeNotification();
this.unpackExtension(install);
}}/>
</div>
);
}
});
}
async unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) {
const extName = `${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(
<p>Extension <b>{extName}</b> successfully installed!</p>
);
} catch (err) {
Notifications.error(
<p>Installing extension <b>{extName}</b> has failed: <em>{err}</em></p>
);
} finally {
// clean up
fse.remove(unpackingTempFolder).catch(Function);
fse.unlink(tempFile).catch(Function);
}
}
renderInfo() {
return (
<div className="flex column gaps">
<div className="extensions-info flex column gaps">
<h2>Lens Extension API</h2>
<div>
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.
</div>
<div>
Extensions loaded from:
<div className="extensions-path flex inline">
<SubTitle title="Extensions loaded from:"/>
<div className="extensions-path flex inline" onClick={() => shell.openPath(this.extensionsPath)}>
<Icon material="folder" tooltip={{ children: "Open folder", preferredPositions: "bottom" }}/>
<code>{this.extensionsPath}</code>
<Icon
material="folder"
tooltip="Open folder"
onClick={() => shell.openPath(this.extensionsPath)}
/>
</div>
</div>
<div>
Check out documentation to <a href="https://docs.k8slens.dev/" target="_blank">learn more</a>
<div className="install-extension flex column gaps">
<SubTitle title="Install extensions:"/>
<Input
showErrorsAsTooltip={true}
className="box grow"
theme="round-black"
iconLeft="link"
placeholder={`URL to packed extension (${this.supportedFormats.join(", ")})`}
validators={InputValidators.isUrl}
value={this.downloadUrl}
onChange={v => this.downloadUrl = v}
onSubmit={this.addExtensions}
/>
<Button
primary
label="Add extensions"
onClick={this.addExtensions}
/>
<p className="hint">
<Trans><b>Pro-Tip 1</b>: you can obtain package tarball from NPM via</Trans>{" "}
<Clipboard showNotification>
<code>npm view %package dist.tarball</code>
</Clipboard>
<span> or download package first with </span>
<Clipboard showNotification>
<code>npm pack %package</code>
</Clipboard>
</p>
<p className="hint">
<Trans><b>Pro-Tip 2</b>: you can drag & drop extension's tarball here to request installation</Trans>
</p>
</div>
<div className="more-info flex inline gaps align-center">
<Icon material="local_fire_department"/>
<p>
Check out documentation to <a href={docsUrl} target="_blank">learn more</a>
</p>
</div>
</div>
);
@@ -95,19 +360,18 @@ export class Extensions extends React.Component {
render() {
return (
<PageLayout showOnTop className="Extensions" header={<h2>Extensions</h2>}>
<WizardLayout infoPanel={this.renderInfo()}>
<Input
autoFocus
theme="round-black"
className="SearchInput"
placeholder={_i18n._(t`Search extensions`)}
value={this.search}
onChange={(value) => this.search = value}
/>
<div className="extension-list">
{this.renderExtensions()}
</div>
</WizardLayout>
<DropFileInput onDropFiles={this.installOnDrop}>
<WizardLayout infoPanel={this.renderInfo()}>
<SearchInput
placeholder={_i18n._(t`Search extensions`)}
value={this.search}
onChange={(value) => this.search = value}
/>
<div className="extensions-list">
{this.renderExtensions()}
</div>
</WizardLayout>
</DropFileInput>
</PageLayout>
);
}