mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Change notification when extension is not found (#5184)
* Change notification when extension is not found - Removes legacy downloadFile and downloadJson in favour of using `node-fetch` - A notification will be displayed if the URL provided results in a status of 404 Signed-off-by: Sebastian Malton <sebastian@malton.name> * Resolve PR comments Signed-off-by: Sebastian Malton <sebastian@malton.name> Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
parent
da91347121
commit
f021d9d0db
56
src/common/fetch/download-binary.injectable.ts
Normal file
56
src/common/fetch/download-binary.injectable.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||||
|
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||||
|
*/
|
||||||
|
import { getInjectable } from "@ogre-tools/injectable";
|
||||||
|
import type { RequestInit, Response } from "node-fetch";
|
||||||
|
import type { AsyncResult } from "../utils/async-result";
|
||||||
|
import fetchInjectable from "./fetch.injectable";
|
||||||
|
|
||||||
|
export interface DownloadBinaryOptions {
|
||||||
|
signal?: AbortSignal | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DownloadBinary = (url: string, opts?: DownloadBinaryOptions) => Promise<AsyncResult<Buffer, string>>;
|
||||||
|
|
||||||
|
const downloadBinaryInjectable = getInjectable({
|
||||||
|
id: "download-binary",
|
||||||
|
instantiate: (di): DownloadBinary => {
|
||||||
|
const fetch = di.inject(fetchInjectable);
|
||||||
|
|
||||||
|
return async (url, opts) => {
|
||||||
|
let result: Response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: upgrade node-fetch once we switch to ESM
|
||||||
|
result = await fetch(url, opts as RequestInit);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
callWasSuccessful: false,
|
||||||
|
error: String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status < 200 || 300 <= result.status) {
|
||||||
|
return {
|
||||||
|
callWasSuccessful: false,
|
||||||
|
error: result.statusText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
callWasSuccessful: true,
|
||||||
|
response: await result.buffer(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
callWasSuccessful: false,
|
||||||
|
error: String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default downloadBinaryInjectable;
|
||||||
56
src/common/fetch/download-json.injectable.ts
Normal file
56
src/common/fetch/download-json.injectable.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||||
|
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||||
|
*/
|
||||||
|
import { getInjectable } from "@ogre-tools/injectable";
|
||||||
|
import type { RequestInit, Response } from "node-fetch";
|
||||||
|
import type { JsonValue } from "type-fest";
|
||||||
|
import type { AsyncResult } from "../utils/async-result";
|
||||||
|
import fetchInjectable from "./fetch.injectable";
|
||||||
|
|
||||||
|
export interface DownloadJsonOptions {
|
||||||
|
signal?: AbortSignal | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DownloadJson = (url: string, opts?: DownloadJsonOptions) => Promise<AsyncResult<JsonValue, string>>;
|
||||||
|
|
||||||
|
const downloadJsonInjectable = getInjectable({
|
||||||
|
id: "download-json",
|
||||||
|
instantiate: (di): DownloadJson => {
|
||||||
|
const fetch = di.inject(fetchInjectable);
|
||||||
|
|
||||||
|
return async (url, opts) => {
|
||||||
|
let result: Response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await fetch(url, opts as RequestInit);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
callWasSuccessful: false,
|
||||||
|
error: String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status < 200 || 300 <= result.status) {
|
||||||
|
return {
|
||||||
|
callWasSuccessful: false,
|
||||||
|
error: result.statusText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
callWasSuccessful: true,
|
||||||
|
response: await result.json(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
callWasSuccessful: false,
|
||||||
|
error: String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default downloadJsonInjectable;
|
||||||
@ -3,10 +3,10 @@
|
|||||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||||
*/
|
*/
|
||||||
import { getInjectable } from "@ogre-tools/injectable";
|
import { getInjectable } from "@ogre-tools/injectable";
|
||||||
import type { RequestInfo, RequestInit, Response } from "node-fetch";
|
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
|
import type { RequestInit, Response } from "node-fetch";
|
||||||
|
|
||||||
export type Fetch = (url: RequestInfo, init?: RequestInit) => Promise<Response>;
|
export type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
const fetchInjectable = getInjectable({
|
const fetchInjectable = getInjectable({
|
||||||
id: "fetch",
|
id: "fetch",
|
||||||
|
|||||||
17
src/common/fetch/timeout-controller.ts
Normal file
17
src/common/fetch/timeout-controller.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||||
|
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an AbortController with an associated timeout
|
||||||
|
* @param timeout The number of milliseconds before this controller will auto abort
|
||||||
|
*/
|
||||||
|
export function withTimeout(timeout: number): AbortController {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const id = setTimeout(() => controller.abort(), timeout);
|
||||||
|
|
||||||
|
controller.signal.addEventListener("abort", () => clearTimeout(id));
|
||||||
|
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
@ -6,7 +6,8 @@
|
|||||||
// Convert object's keys to camelCase format
|
// Convert object's keys to camelCase format
|
||||||
import { camelCase } from "lodash";
|
import { camelCase } from "lodash";
|
||||||
import type { SingleOrMany } from "./types";
|
import type { SingleOrMany } from "./types";
|
||||||
import { isObject } from "./type-narrowing";
|
import { isObject, isString } from "./type-narrowing";
|
||||||
|
import * as object from "./objects";
|
||||||
|
|
||||||
export function toCamelCase<T extends Record<string, unknown>[]>(obj: T): T;
|
export function toCamelCase<T extends Record<string, unknown>[]>(obj: T): T;
|
||||||
export function toCamelCase<T extends Record<string, unknown>>(obj: T): T;
|
export function toCamelCase<T extends Record<string, unknown>>(obj: T): T;
|
||||||
@ -17,11 +18,10 @@ export function toCamelCase(obj: SingleOrMany<Record<string, unknown> | unknown>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isObject(obj)) {
|
if (isObject(obj)) {
|
||||||
return Object.fromEntries(
|
return object.fromEntries(
|
||||||
Object.entries(obj)
|
object.entries(obj)
|
||||||
.map(([key, value]) => {
|
.filter((pair): pair is [string, unknown] => isString(pair[0]))
|
||||||
return [camelCase(key), toCamelCase(value)];
|
.map(([key, value]) => [camelCase(key), isObject(value) ? toCamelCase(value) : value]),
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,53 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
|
||||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import request from "request";
|
|
||||||
import type { JsonValue } from "type-fest";
|
|
||||||
import { parse } from "./json";
|
|
||||||
|
|
||||||
export interface DownloadFileOptions {
|
|
||||||
url: string;
|
|
||||||
gzip?: boolean;
|
|
||||||
timeout?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DownloadFileTicket<T> {
|
|
||||||
url: string;
|
|
||||||
promise: Promise<T>;
|
|
||||||
cancel(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket<Buffer> {
|
|
||||||
const fileChunks: Buffer[] = [];
|
|
||||||
const req = request(url, { gzip, timeout });
|
|
||||||
const promise: Promise<Buffer> = new Promise((resolve, reject) => {
|
|
||||||
req.on("data", (chunk: Buffer) => {
|
|
||||||
fileChunks.push(chunk);
|
|
||||||
});
|
|
||||||
req.once("error", err => {
|
|
||||||
reject({ url, err });
|
|
||||||
});
|
|
||||||
req.once("complete", () => {
|
|
||||||
resolve(Buffer.concat(fileChunks));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
promise,
|
|
||||||
cancel() {
|
|
||||||
req.abort();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function downloadJson(args: DownloadFileOptions): DownloadFileTicket<JsonValue> {
|
|
||||||
const { promise, ...rest } = downloadFile(args);
|
|
||||||
|
|
||||||
return {
|
|
||||||
promise: promise.then(res => parse(res.toString())),
|
|
||||||
...rest,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -14,7 +14,6 @@ export * from "./convertMemory";
|
|||||||
export * from "./debouncePromise";
|
export * from "./debouncePromise";
|
||||||
export * from "./delay";
|
export * from "./delay";
|
||||||
export * from "./disposer";
|
export * from "./disposer";
|
||||||
export * from "./downloadFile";
|
|
||||||
export * from "./escapeRegExp";
|
export * from "./escapeRegExp";
|
||||||
export * from "./formatDuration";
|
export * from "./formatDuration";
|
||||||
export * from "./getRandId";
|
export * from "./getRandId";
|
||||||
|
|||||||
@ -103,7 +103,7 @@ export function isBoolean(val: unknown): val is boolean {
|
|||||||
* checks if val is of type object and isn't null
|
* checks if val is of type object and isn't null
|
||||||
* @param val the value to be checked
|
* @param val the value to be checked
|
||||||
*/
|
*/
|
||||||
export function isObject(val: unknown): val is object {
|
export function isObject(val: unknown): val is Record<string | symbol | number, unknown> {
|
||||||
return typeof val === "object" && val !== null;
|
return typeof val === "object" && val !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,8 @@
|
|||||||
import type { RenderResult } from "@testing-library/react";
|
import type { RenderResult } from "@testing-library/react";
|
||||||
import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
|
import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
|
||||||
import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
|
import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
|
||||||
|
import downloadBinaryInjectable, { type DownloadBinary } from "../../common/fetch/download-binary.injectable";
|
||||||
|
import downloadJsonInjectable, { type DownloadJson } from "../../common/fetch/download-json.injectable";
|
||||||
import focusWindowInjectable from "../../renderer/navigation/focus-window.injectable";
|
import focusWindowInjectable from "../../renderer/navigation/focus-window.injectable";
|
||||||
|
|
||||||
// TODO: Make components free of side effects by making them deterministic
|
// TODO: Make components free of side effects by making them deterministic
|
||||||
@ -15,14 +17,20 @@ describe("extensions - navigation using application menu", () => {
|
|||||||
let builder: ApplicationBuilder;
|
let builder: ApplicationBuilder;
|
||||||
let rendered: RenderResult;
|
let rendered: RenderResult;
|
||||||
let focusWindowMock: jest.Mock;
|
let focusWindowMock: jest.Mock;
|
||||||
|
let downloadJson: jest.MockedFunction<DownloadJson>;
|
||||||
|
let downloadBinary: jest.MockedFunction<DownloadBinary>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
builder = getApplicationBuilder();
|
builder = getApplicationBuilder();
|
||||||
|
|
||||||
builder.beforeWindowStart((windowDi) => {
|
builder.beforeWindowStart((windowDi) => {
|
||||||
focusWindowMock = jest.fn();
|
focusWindowMock = jest.fn();
|
||||||
|
downloadJson = jest.fn().mockImplementation((url) => { throw new Error(`Unexpected call to downloadJson for url=${url}`); });
|
||||||
|
downloadBinary = jest.fn().mockImplementation((url) => { throw new Error(`Unexpected call to downloadJson for url=${url}`); });
|
||||||
|
|
||||||
windowDi.override(focusWindowInjectable, () => focusWindowMock);
|
windowDi.override(focusWindowInjectable, () => focusWindowMock);
|
||||||
|
windowDi.override(downloadJsonInjectable, () => downloadJson);
|
||||||
|
windowDi.override(downloadBinaryInjectable, () => downloadBinary);
|
||||||
});
|
});
|
||||||
|
|
||||||
rendered = await builder.render();
|
rendered = await builder.render();
|
||||||
|
|||||||
@ -25,23 +25,10 @@ import extensionInstallationStateStoreInjectable from "../../../../extensions/ex
|
|||||||
import { observable, when } from "mobx";
|
import { observable, when } from "mobx";
|
||||||
import type { DeleteFile } from "../../../../common/fs/delete-file.injectable";
|
import type { DeleteFile } from "../../../../common/fs/delete-file.injectable";
|
||||||
import deleteFileInjectable from "../../../../common/fs/delete-file.injectable";
|
import deleteFileInjectable from "../../../../common/fs/delete-file.injectable";
|
||||||
|
import type { DownloadJson } from "../../../../common/fetch/download-json.injectable";
|
||||||
jest.mock("../../notifications");
|
import type { DownloadBinary } from "../../../../common/fetch/download-binary.injectable";
|
||||||
|
import downloadJsonInjectable from "../../../../common/fetch/download-json.injectable";
|
||||||
jest.mock("../../../../common/utils/downloadFile", () => ({
|
import downloadBinaryInjectable from "../../../../common/fetch/download-binary.injectable";
|
||||||
downloadFile: jest.fn(({ url }) => ({
|
|
||||||
promise: Promise.resolve(),
|
|
||||||
url,
|
|
||||||
cancel: () => {},
|
|
||||||
})),
|
|
||||||
downloadJson: jest.fn(({ url }) => ({
|
|
||||||
promise: Promise.resolve({}),
|
|
||||||
url,
|
|
||||||
cancel: () => { },
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock("../../../../common/utils/tar");
|
|
||||||
|
|
||||||
describe("Extensions", () => {
|
describe("Extensions", () => {
|
||||||
let extensionLoader: ExtensionLoader;
|
let extensionLoader: ExtensionLoader;
|
||||||
@ -50,6 +37,8 @@ describe("Extensions", () => {
|
|||||||
let extensionInstallationStateStore: ExtensionInstallationStateStore;
|
let extensionInstallationStateStore: ExtensionInstallationStateStore;
|
||||||
let render: DiRender;
|
let render: DiRender;
|
||||||
let deleteFileMock: jest.MockedFunction<DeleteFile>;
|
let deleteFileMock: jest.MockedFunction<DeleteFile>;
|
||||||
|
let downloadJson: jest.MockedFunction<DownloadJson>;
|
||||||
|
let downloadBinary: jest.MockedFunction<DownloadBinary>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const di = getDiForUnitTesting({ doGeneralOverrides: true });
|
const di = getDiForUnitTesting({ doGeneralOverrides: true });
|
||||||
@ -65,6 +54,12 @@ describe("Extensions", () => {
|
|||||||
deleteFileMock = jest.fn();
|
deleteFileMock = jest.fn();
|
||||||
di.override(deleteFileInjectable, () => deleteFileMock);
|
di.override(deleteFileInjectable, () => deleteFileMock);
|
||||||
|
|
||||||
|
downloadJson = jest.fn().mockImplementation((url) => { throw new Error(`Unexpected call to downloadJson for url=${url}`); });
|
||||||
|
di.override(downloadJsonInjectable, () => downloadJson);
|
||||||
|
|
||||||
|
downloadBinary = jest.fn().mockImplementation((url) => { throw new Error(`Unexpected call to downloadJson for url=${url}`); });
|
||||||
|
di.override(downloadBinaryInjectable, () => downloadBinary);
|
||||||
|
|
||||||
extensionLoader = di.inject(extensionLoaderInjectable);
|
extensionLoader = di.inject(extensionLoaderInjectable);
|
||||||
extensionDiscovery = di.inject(extensionDiscoveryInjectable);
|
extensionDiscovery = di.inject(extensionDiscoveryInjectable);
|
||||||
extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable);
|
extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable);
|
||||||
@ -110,7 +105,7 @@ describe("Extensions", () => {
|
|||||||
// Approve confirm dialog
|
// Approve confirm dialog
|
||||||
fireEvent.click(await screen.findByText("Yes"));
|
fireEvent.click(await screen.findByText("Yes"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(async () => {
|
||||||
expect(extensionDiscovery.uninstallExtension).toHaveBeenCalled();
|
expect(extensionDiscovery.uninstallExtension).toHaveBeenCalled();
|
||||||
fireEvent.click(menuTrigger);
|
fireEvent.click(menuTrigger);
|
||||||
expect(screen.getByText("Disable")).toHaveAttribute("aria-disabled", "true");
|
expect(screen.getByText("Disable")).toHaveAttribute("aria-disabled", "true");
|
||||||
@ -124,6 +119,7 @@ describe("Extensions", () => {
|
|||||||
render(<Extensions />);
|
render(<Extensions />);
|
||||||
|
|
||||||
const resolveInstall = observable.box(false);
|
const resolveInstall = observable.box(false);
|
||||||
|
const url = "https://test.extensionurl/package.tgz";
|
||||||
|
|
||||||
deleteFileMock.mockReturnValue(Promise.resolve());
|
deleteFileMock.mockReturnValue(Promise.resolve());
|
||||||
installExtensionFromInput.mockImplementation(async (input) => {
|
installExtensionFromInput.mockImplementation(async (input) => {
|
||||||
@ -139,13 +135,26 @@ describe("Extensions", () => {
|
|||||||
exact: false,
|
exact: false,
|
||||||
}), {
|
}), {
|
||||||
target: {
|
target: {
|
||||||
value: "https://test.extensionurl/package.tgz",
|
value: url,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const doResolve = observable.box(false);
|
||||||
|
|
||||||
|
downloadBinary.mockImplementation(async (targetUrl) => {
|
||||||
|
expect(targetUrl).toBe(url);
|
||||||
|
|
||||||
|
await when(() => doResolve.get());
|
||||||
|
|
||||||
|
return {
|
||||||
|
callWasSuccessful: false,
|
||||||
|
error: "unknown location",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
fireEvent.click(await screen.findByText("Install"));
|
fireEvent.click(await screen.findByText("Install"));
|
||||||
expect((await screen.findByText("Install")).closest("button")).toBeDisabled();
|
expect((await screen.findByText("Install")).closest("button")).toBeDisabled();
|
||||||
resolveInstall.set(true);
|
doResolve.set(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("displays spinner while extensions are loading", () => {
|
it("displays spinner while extensions are loading", () => {
|
||||||
|
|||||||
@ -2,8 +2,7 @@
|
|||||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||||
*/
|
*/
|
||||||
import { downloadFile, downloadJson } from "../../../common/utils";
|
import { isObject } from "../../../common/utils";
|
||||||
import { Notifications } from "../notifications";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { SemVer } from "semver";
|
import { SemVer } from "semver";
|
||||||
import URLParse from "url-parse";
|
import URLParse from "url-parse";
|
||||||
@ -14,6 +13,12 @@ import extensionInstallationStateStoreInjectable from "../../../extensions/exten
|
|||||||
import confirmInjectable from "../confirm-dialog/confirm.injectable";
|
import confirmInjectable from "../confirm-dialog/confirm.injectable";
|
||||||
import { reduce } from "lodash";
|
import { reduce } from "lodash";
|
||||||
import getBasenameOfPathInjectable from "../../../common/path/get-basename.injectable";
|
import getBasenameOfPathInjectable from "../../../common/path/get-basename.injectable";
|
||||||
|
import { withTimeout } from "../../../common/fetch/timeout-controller";
|
||||||
|
import downloadBinaryInjectable from "../../../common/fetch/download-binary.injectable";
|
||||||
|
import downloadJsonInjectable from "../../../common/fetch/download-json.injectable";
|
||||||
|
import type { PackageJson } from "type-fest";
|
||||||
|
import showErrorNotificationInjectable from "../notifications/show-error-notification.injectable";
|
||||||
|
import loggerInjectable from "../../../common/logger.injectable";
|
||||||
|
|
||||||
export interface ExtensionInfo {
|
export interface ExtensionInfo {
|
||||||
name: string;
|
name: string;
|
||||||
@ -21,6 +26,19 @@ export interface ExtensionInfo {
|
|||||||
requireConfirmation?: boolean;
|
requireConfirmation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NpmPackageVersionDescriptor extends PackageJson {
|
||||||
|
dist: {
|
||||||
|
integrity: string;
|
||||||
|
shasum: string;
|
||||||
|
tarball: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NpmRegistryPackageDescriptor {
|
||||||
|
versions: Partial<Record<string, NpmPackageVersionDescriptor>>;
|
||||||
|
"dist-tags"?: Partial<Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
export type AttemptInstallByInfo = (info: ExtensionInfo) => Promise<void>;
|
export type AttemptInstallByInfo = (info: ExtensionInfo) => Promise<void>;
|
||||||
|
|
||||||
const attemptInstallByInfoInjectable = getInjectable({
|
const attemptInstallByInfoInjectable = getInjectable({
|
||||||
@ -31,84 +49,131 @@ const attemptInstallByInfoInjectable = getInjectable({
|
|||||||
const extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable);
|
const extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable);
|
||||||
const confirm = di.inject(confirmInjectable);
|
const confirm = di.inject(confirmInjectable);
|
||||||
const getBasenameOfPath = di.inject(getBasenameOfPathInjectable);
|
const getBasenameOfPath = di.inject(getBasenameOfPathInjectable);
|
||||||
|
const downloadJson = di.inject(downloadJsonInjectable);
|
||||||
|
const downloadBinary = di.inject(downloadBinaryInjectable);
|
||||||
|
const showErrorNotification = di.inject(showErrorNotificationInjectable);
|
||||||
|
const logger = di.inject(loggerInjectable);
|
||||||
|
|
||||||
return async (info) => {
|
return async (info) => {
|
||||||
const { name, version, requireConfirmation = false } = info;
|
const { name, version: versionOrTagName, requireConfirmation = false } = info;
|
||||||
const disposer = extensionInstallationStateStore.startPreInstall();
|
const disposer = extensionInstallationStateStore.startPreInstall();
|
||||||
const baseUrl = await getBaseRegistryUrl();
|
const baseUrl = await getBaseRegistryUrl();
|
||||||
const registryUrl = new URLParse(baseUrl).set("pathname", name).toString();
|
const registryUrl = new URLParse(baseUrl).set("pathname", name).toString();
|
||||||
let json: any;
|
let json: NpmRegistryPackageDescriptor;
|
||||||
let finalVersion = version;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
json = await downloadJson({ url: registryUrl }).promise;
|
const result = await downloadJson(registryUrl);
|
||||||
|
|
||||||
if (!json || json.error || typeof json.versions !== "object" || !json.versions) {
|
if (!result.callWasSuccessful) {
|
||||||
const message = json?.error ? `: ${json.error}` : "";
|
showErrorNotification(`Failed to get registry information for extension: ${result.error}`);
|
||||||
|
|
||||||
Notifications.error(`Failed to get registry information for that extension${message}`);
|
|
||||||
|
|
||||||
return disposer();
|
return disposer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isObject(result.response) || Array.isArray(result.response)) {
|
||||||
|
showErrorNotification("Failed to get registry information for extension");
|
||||||
|
|
||||||
|
return disposer();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.response.error || !isObject(result.response.versions)) {
|
||||||
|
const message = result.response.error ? `: ${result.response.error}` : "";
|
||||||
|
|
||||||
|
showErrorNotification(`Failed to get registry information for extension${message}`);
|
||||||
|
|
||||||
|
return disposer();
|
||||||
|
}
|
||||||
|
|
||||||
|
json = result.response as unknown as NpmRegistryPackageDescriptor;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SyntaxError) {
|
if (error instanceof SyntaxError) {
|
||||||
// assume invalid JSON
|
// assume invalid JSON
|
||||||
console.warn("Set registry has invalid json", { url: baseUrl }, error);
|
logger.warn("Set registry has invalid json", { url: baseUrl }, error);
|
||||||
Notifications.error("Failed to get valid registry information for that extension. Registry did not return valid JSON");
|
showErrorNotification("Failed to get valid registry information for extension. Registry did not return valid JSON");
|
||||||
} else {
|
} else {
|
||||||
console.error("Failed to download registry information", error);
|
logger.error("Failed to download registry information", error);
|
||||||
Notifications.error(`Failed to get valid registry information for that extension. ${error}`);
|
showErrorNotification(`Failed to get valid registry information for extension. ${error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return disposer();
|
return disposer();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (version) {
|
let version = versionOrTagName;
|
||||||
if (!json.versions[version]) {
|
|
||||||
if (json["dist-tags"][version]) {
|
|
||||||
finalVersion = json["dist-tags"][version];
|
|
||||||
} else {
|
|
||||||
Notifications.error((
|
|
||||||
<p>
|
|
||||||
{"The "}
|
|
||||||
<em>{name}</em>
|
|
||||||
{" extension does not have a version or tag "}
|
|
||||||
<code>{version}</code>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
));
|
|
||||||
|
|
||||||
return disposer();
|
if (versionOrTagName) {
|
||||||
|
validDistTagName:
|
||||||
|
if (!json.versions[versionOrTagName]) {
|
||||||
|
if (json["dist-tags"]) {
|
||||||
|
const potentialVersion = json["dist-tags"][versionOrTagName];
|
||||||
|
|
||||||
|
if (potentialVersion) {
|
||||||
|
if (!json.versions[potentialVersion]) {
|
||||||
|
showErrorNotification((
|
||||||
|
<p>
|
||||||
|
Configured registry claims to have tag
|
||||||
|
{" "}
|
||||||
|
<code>{versionOrTagName}</code>
|
||||||
|
.
|
||||||
|
{" "}
|
||||||
|
But does not have version infomation for the reference.
|
||||||
|
</p>
|
||||||
|
));
|
||||||
|
|
||||||
|
return disposer();
|
||||||
|
}
|
||||||
|
|
||||||
|
version = potentialVersion;
|
||||||
|
break validDistTagName;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showErrorNotification((
|
||||||
|
<p>
|
||||||
|
{"The "}
|
||||||
|
<em>{name}</em>
|
||||||
|
{" extension does not have a version or tag "}
|
||||||
|
<code>{versionOrTagName}</code>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
));
|
||||||
|
|
||||||
|
return disposer();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const versions = Object.keys(json.versions)
|
const versions = Object.keys(json.versions)
|
||||||
.map(version => new SemVer(version, { loose: true }))
|
.map(version => new SemVer(version, { loose: true }))
|
||||||
// ignore pre-releases for auto picking the version
|
// ignore pre-releases for auto picking the version
|
||||||
.filter(version => version.prerelease.length === 0);
|
.filter(version => version.prerelease.length === 0);
|
||||||
|
|
||||||
const latestVersion = reduce(versions, (prev, curr) => prev.compareMain(curr) === -1 ? curr : prev);
|
const latestVersion = reduce(versions, (prev, curr) => prev.compareMain(curr) === -1 ? curr : prev);
|
||||||
|
|
||||||
if (!latestVersion) {
|
version = latestVersion?.format();
|
||||||
console.error("No versions supplied for that extension", { name });
|
}
|
||||||
Notifications.error(`No versions found for ${name}`);
|
|
||||||
|
|
||||||
return disposer();
|
if (!version) {
|
||||||
}
|
logger.error("No versions supplied for extension", { name });
|
||||||
|
showErrorNotification(`No versions found for ${name}`);
|
||||||
|
|
||||||
finalVersion = latestVersion.format();
|
return disposer();
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionInfo = json.versions[version];
|
||||||
|
const tarballUrl = versionInfo?.dist.tarball;
|
||||||
|
|
||||||
|
if (!tarballUrl) {
|
||||||
|
showErrorNotification("Configured registry has invalid data model. Please verify that it is like NPM's.");
|
||||||
|
logger.warn(`[ATTEMPT-INSTALL-BY-INFO]: registry returned unexpected data, final version is ${version} but the versions object is missing .dist.tarball as a string`, versionInfo);
|
||||||
|
|
||||||
|
return disposer();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requireConfirmation) {
|
if (requireConfirmation) {
|
||||||
const proceed = await confirm({
|
const proceed = await confirm({
|
||||||
message: (
|
message: (
|
||||||
<p>
|
<p>
|
||||||
Are you sure you want to install
|
{"Are you sure you want to install "}
|
||||||
{" "}
|
|
||||||
<b>
|
<b>
|
||||||
{name}
|
{`${name}@${version}`}
|
||||||
@
|
|
||||||
{finalVersion}
|
|
||||||
</b>
|
</b>
|
||||||
?
|
?
|
||||||
</p>
|
</p>
|
||||||
@ -122,12 +187,17 @@ const attemptInstallByInfoInjectable = getInjectable({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
const fileName = getBasenameOfPath(tarballUrl);
|
||||||
const url = json.versions[finalVersion!].dist.tarball;
|
const { signal } = withTimeout(10 * 60 * 1000);
|
||||||
const fileName = getBasenameOfPath(url);
|
const request = await downloadBinary(tarballUrl, { signal });
|
||||||
const { promise: dataP } = downloadFile({ url, timeout: 10 * 60 * 1000 });
|
|
||||||
|
|
||||||
return attemptInstall({ fileName, dataP }, disposer);
|
if (!request.callWasSuccessful) {
|
||||||
|
showErrorNotification(`Failed to download extension: ${request.error}`);
|
||||||
|
|
||||||
|
return disposer();
|
||||||
|
}
|
||||||
|
|
||||||
|
return attemptInstall({ fileName, data: request.response }, disposer);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,30 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
|
||||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
|
||||||
*/
|
|
||||||
import { getInjectable } from "@ogre-tools/injectable";
|
|
||||||
import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable";
|
|
||||||
import uninstallExtensionInjectable from "../uninstall-extension/uninstall-extension.injectable";
|
|
||||||
import { attemptInstall } from "./attempt-install";
|
|
||||||
import unpackExtensionInjectable from "./unpack-extension/unpack-extension.injectable";
|
|
||||||
import getExtensionDestFolderInjectable
|
|
||||||
from "./get-extension-dest-folder/get-extension-dest-folder.injectable";
|
|
||||||
import createTempFilesAndValidateInjectable from "./create-temp-files-and-validate/create-temp-files-and-validate.injectable";
|
|
||||||
import extensionInstallationStateStoreInjectable
|
|
||||||
from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
|
|
||||||
|
|
||||||
const attemptInstallInjectable = getInjectable({
|
|
||||||
id: "attempt-install",
|
|
||||||
|
|
||||||
instantiate: (di) =>
|
|
||||||
attemptInstall({
|
|
||||||
extensionLoader: di.inject(extensionLoaderInjectable),
|
|
||||||
uninstallExtension: di.inject(uninstallExtensionInjectable),
|
|
||||||
unpackExtension: di.inject(unpackExtensionInjectable),
|
|
||||||
createTempFilesAndValidate: di.inject(createTempFilesAndValidateInjectable),
|
|
||||||
getExtensionDestFolder: di.inject(getExtensionDestFolderInjectable),
|
|
||||||
extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default attemptInstallInjectable;
|
|
||||||
@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||||
|
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||||
|
*/
|
||||||
|
import { getInjectable } from "@ogre-tools/injectable";
|
||||||
|
import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable";
|
||||||
|
import uninstallExtensionInjectable from "../uninstall-extension/uninstall-extension.injectable";
|
||||||
|
import type { UnpackExtension } from "./unpack-extension.injectable";
|
||||||
|
import unpackExtensionInjectable from "./unpack-extension.injectable";
|
||||||
|
import type { GetExtensionDestFolder } from "./get-extension-dest-folder.injectable";
|
||||||
|
import getExtensionDestFolderInjectable from "./get-extension-dest-folder.injectable";
|
||||||
|
import type { CreateTempFilesAndValidate } from "./create-temp-files-and-validate.injectable";
|
||||||
|
import createTempFilesAndValidateInjectable from "./create-temp-files-and-validate.injectable";
|
||||||
|
import extensionInstallationStateStoreInjectable from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
|
||||||
|
import type { Disposer } from "../../../../common/utils";
|
||||||
|
import { disposer } from "../../../../common/utils";
|
||||||
|
import { Notifications } from "../../notifications";
|
||||||
|
import { Button } from "../../button";
|
||||||
|
import type { ExtensionLoader } from "../../../../extensions/extension-loader";
|
||||||
|
import type { LensExtensionId } from "../../../../extensions/lens-extension";
|
||||||
|
import React from "react";
|
||||||
|
import { remove as removeDir } from "fs-extra";
|
||||||
|
import { shell } from "electron";
|
||||||
|
import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store";
|
||||||
|
import { ExtensionInstallationState } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store";
|
||||||
|
|
||||||
|
export interface InstallRequest {
|
||||||
|
fileName: string;
|
||||||
|
data: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Dependencies {
|
||||||
|
extensionLoader: ExtensionLoader;
|
||||||
|
uninstallExtension: (id: LensExtensionId) => Promise<boolean>;
|
||||||
|
unpackExtension: UnpackExtension;
|
||||||
|
createTempFilesAndValidate: CreateTempFilesAndValidate;
|
||||||
|
getExtensionDestFolder: GetExtensionDestFolder;
|
||||||
|
installStateStore: ExtensionInstallationStateStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AttemptInstall = (request: InstallRequest, cleanup?: Disposer) => Promise<void>;
|
||||||
|
|
||||||
|
const attemptInstall = ({
|
||||||
|
extensionLoader,
|
||||||
|
uninstallExtension,
|
||||||
|
unpackExtension,
|
||||||
|
createTempFilesAndValidate,
|
||||||
|
getExtensionDestFolder,
|
||||||
|
installStateStore,
|
||||||
|
}: Dependencies): AttemptInstall =>
|
||||||
|
async (request, cleanup) => {
|
||||||
|
const dispose = disposer(
|
||||||
|
installStateStore.startPreInstall(),
|
||||||
|
cleanup,
|
||||||
|
);
|
||||||
|
|
||||||
|
const validatedRequest = await createTempFilesAndValidate(request);
|
||||||
|
|
||||||
|
if (!validatedRequest) {
|
||||||
|
return dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, version, description } = validatedRequest.manifest;
|
||||||
|
const curState = installStateStore.getInstallationState(validatedRequest.id);
|
||||||
|
|
||||||
|
if (curState !== ExtensionInstallationState.IDLE) {
|
||||||
|
dispose();
|
||||||
|
|
||||||
|
return void Notifications.error(
|
||||||
|
<div className="flex column gaps">
|
||||||
|
<b>Extension Install Collision:</b>
|
||||||
|
<p>
|
||||||
|
{"The "}
|
||||||
|
<em>{name}</em>
|
||||||
|
{` extension is currently ${curState.toLowerCase()}.`}
|
||||||
|
</p>
|
||||||
|
<p>Will not proceed with this current install request.</p>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionFolder = getExtensionDestFolder(name);
|
||||||
|
const installedExtension = extensionLoader.getExtension(validatedRequest.id);
|
||||||
|
|
||||||
|
if (installedExtension) {
|
||||||
|
const { version: oldVersion } = installedExtension.manifest;
|
||||||
|
|
||||||
|
// confirm to uninstall old version before installing new version
|
||||||
|
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>
|
||||||
|
{` ${name}@${oldVersion} will be removed before installation.`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
autoFocus
|
||||||
|
label="Install"
|
||||||
|
onClick={async () => {
|
||||||
|
removeNotification();
|
||||||
|
|
||||||
|
if (await uninstallExtension(validatedRequest.id)) {
|
||||||
|
await unpackExtension(validatedRequest, dispose);
|
||||||
|
} else {
|
||||||
|
dispose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
{
|
||||||
|
onClose: dispose,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// clean up old data if still around
|
||||||
|
await removeDir(extensionFolder);
|
||||||
|
|
||||||
|
// install extension if not yet exists
|
||||||
|
await unpackExtension(validatedRequest, dispose);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const attemptInstallInjectable = getInjectable({
|
||||||
|
id: "attempt-install",
|
||||||
|
instantiate: (di) => attemptInstall({
|
||||||
|
extensionLoader: di.inject(extensionLoaderInjectable),
|
||||||
|
uninstallExtension: di.inject(uninstallExtensionInjectable),
|
||||||
|
unpackExtension: di.inject(unpackExtensionInjectable),
|
||||||
|
createTempFilesAndValidate: di.inject(createTempFilesAndValidateInjectable),
|
||||||
|
getExtensionDestFolder: di.inject(getExtensionDestFolderInjectable),
|
||||||
|
installStateStore: di.inject(extensionInstallationStateStoreInjectable),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default attemptInstallInjectable;
|
||||||
@ -1,126 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
|
||||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
|
||||||
*/
|
|
||||||
import type { ExtendableDisposer } from "../../../../common/utils";
|
|
||||||
import { disposer } from "../../../../common/utils";
|
|
||||||
import { Notifications } from "../../notifications";
|
|
||||||
import { Button } from "../../button";
|
|
||||||
import type { ExtensionLoader } from "../../../../extensions/extension-loader";
|
|
||||||
import type { LensExtensionId } from "../../../../extensions/lens-extension";
|
|
||||||
import React from "react";
|
|
||||||
import { remove as removeDir } from "fs-extra";
|
|
||||||
import { shell } from "electron";
|
|
||||||
import type { InstallRequest } from "./install-request";
|
|
||||||
import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store";
|
|
||||||
import { ExtensionInstallationState } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store";
|
|
||||||
import type { UnpackExtension } from "./unpack-extension/unpack-extension.injectable";
|
|
||||||
import type { CreateTempFilesAndValidate } from "./create-temp-files-and-validate/create-temp-files-and-validate.injectable";
|
|
||||||
import type { GetExtensionDestFolder } from "./get-extension-dest-folder/get-extension-dest-folder.injectable";
|
|
||||||
|
|
||||||
interface Dependencies {
|
|
||||||
extensionLoader: ExtensionLoader;
|
|
||||||
uninstallExtension: (id: LensExtensionId) => Promise<boolean>;
|
|
||||||
unpackExtension: UnpackExtension;
|
|
||||||
createTempFilesAndValidate: CreateTempFilesAndValidate;
|
|
||||||
getExtensionDestFolder: GetExtensionDestFolder;
|
|
||||||
extensionInstallationStateStore: ExtensionInstallationStateStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const attemptInstall =
|
|
||||||
({
|
|
||||||
extensionLoader,
|
|
||||||
uninstallExtension,
|
|
||||||
unpackExtension,
|
|
||||||
createTempFilesAndValidate,
|
|
||||||
getExtensionDestFolder,
|
|
||||||
extensionInstallationStateStore,
|
|
||||||
}: Dependencies) =>
|
|
||||||
async (request: InstallRequest, d?: ExtendableDisposer): Promise<void> => {
|
|
||||||
console.log("Attempting to install extension");
|
|
||||||
|
|
||||||
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 void Notifications.error(
|
|
||||||
<div className="flex column gaps">
|
|
||||||
<b>Extension Install Collision:</b>
|
|
||||||
<p>
|
|
||||||
{"The "}
|
|
||||||
<em>{name}</em>
|
|
||||||
{` extension is currently ${curState.toLowerCase()}.`}
|
|
||||||
</p>
|
|
||||||
<p>Will not proceed with this current install request.</p>
|
|
||||||
</div>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const extensionFolder = getExtensionDestFolder(name);
|
|
||||||
const installedExtension = extensionLoader.getExtension(validatedRequest.id);
|
|
||||||
|
|
||||||
if (installedExtension) {
|
|
||||||
const { version: oldVersion } = installedExtension.manifest;
|
|
||||||
|
|
||||||
// confirm to uninstall old version before installing new version
|
|
||||||
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>
|
|
||||||
{` ${name}@${oldVersion} will be removed before installation.`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
autoFocus
|
|
||||||
label="Install"
|
|
||||||
onClick={async () => {
|
|
||||||
removeNotification();
|
|
||||||
|
|
||||||
if (await uninstallExtension(validatedRequest.id)) {
|
|
||||||
await unpackExtension(validatedRequest, dispose);
|
|
||||||
} else {
|
|
||||||
dispose();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
{
|
|
||||||
onClose: dispose,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// clean up old data if still around
|
|
||||||
await removeDir(extensionFolder);
|
|
||||||
|
|
||||||
// install extension if not yet exists
|
|
||||||
await unpackExtension(validatedRequest, dispose);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||||
|
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||||
|
*/
|
||||||
|
import { getInjectable } from "@ogre-tools/injectable";
|
||||||
|
import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable";
|
||||||
|
import { validatePackage } from "./validate-package";
|
||||||
|
import type { ExtensionDiscovery } from "../../../../extensions/extension-discovery/extension-discovery";
|
||||||
|
import { getMessageFromError } from "../get-message-from-error/get-message-from-error";
|
||||||
|
import logger from "../../../../main/logger";
|
||||||
|
import { Notifications } from "../../notifications";
|
||||||
|
import path from "path";
|
||||||
|
import fse from "fs-extra";
|
||||||
|
import React from "react";
|
||||||
|
import os from "os";
|
||||||
|
import type { LensExtensionId, LensExtensionManifest } from "../../../../extensions/lens-extension";
|
||||||
|
import type { InstallRequest } from "./attempt-install.injectable";
|
||||||
|
|
||||||
|
export interface InstallRequestValidated {
|
||||||
|
fileName: string;
|
||||||
|
data: Buffer;
|
||||||
|
id: LensExtensionId;
|
||||||
|
manifest: LensExtensionManifest;
|
||||||
|
tempFile: string; // temp system path to packed extension for unpacking
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Dependencies {
|
||||||
|
extensionDiscovery: ExtensionDiscovery;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateTempFilesAndValidate = (request: InstallRequest) => Promise<InstallRequestValidated | null>;
|
||||||
|
|
||||||
|
const createTempFilesAndValidate = ({
|
||||||
|
extensionDiscovery,
|
||||||
|
}: Dependencies): CreateTempFilesAndValidate => (
|
||||||
|
async ({ fileName, data }) => {
|
||||||
|
// copy files to temp
|
||||||
|
await fse.ensureDir(getExtensionPackageTemp());
|
||||||
|
|
||||||
|
// validate packages
|
||||||
|
const tempFile = getExtensionPackageTemp(fileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
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) {
|
||||||
|
const message = getMessageFromError(error);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[EXTENSION-INSTALLATION]: installing ${fileName} has failed: ${message}`,
|
||||||
|
{ error },
|
||||||
|
);
|
||||||
|
Notifications.error((
|
||||||
|
<div className="flex column gaps">
|
||||||
|
<p>
|
||||||
|
{"Installing "}
|
||||||
|
<em>{fileName}</em>
|
||||||
|
{" has failed, skipping."}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{"Reason: "}
|
||||||
|
<em>{message}</em>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
function getExtensionPackageTemp(fileName = "") {
|
||||||
|
return path.join(os.tmpdir(), "lens-extensions", fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTempFilesAndValidateInjectable = getInjectable({
|
||||||
|
id: "create-temp-files-and-validate",
|
||||||
|
instantiate: (di) => createTempFilesAndValidate({
|
||||||
|
extensionDiscovery: di.inject(extensionDiscoveryInjectable),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default createTempFilesAndValidateInjectable;
|
||||||
@ -1,104 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
|
||||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
|
||||||
*/
|
|
||||||
import { getInjectable } from "@ogre-tools/injectable";
|
|
||||||
import extensionDiscoveryInjectable from "../../../../../extensions/extension-discovery/extension-discovery.injectable";
|
|
||||||
import React from "react";
|
|
||||||
import type { LensExtensionId, LensExtensionManifest } from "../../../../../extensions/lens-extension";
|
|
||||||
import { getMessageFromError } from "../../get-message-from-error/get-message-from-error";
|
|
||||||
import type { InstallRequest } from "../install-request";
|
|
||||||
import { validatePackage } from "../validate-package/validate-package";
|
|
||||||
import joinPathsInjectable from "../../../../../common/path/join-paths.injectable";
|
|
||||||
import tempDirectoryPathInjectable from "../../../../../common/os/temp-directory-path.injectable";
|
|
||||||
import ensureDirInjectable from "../../../../../common/fs/ensure-dir.injectable";
|
|
||||||
import writeFileInjectable from "../../../../../common/fs/write-file.injectable";
|
|
||||||
import loggerInjectable from "../../../../../common/logger.injectable";
|
|
||||||
import showErrorNotificationInjectable from "../../../notifications/show-error-notification.injectable";
|
|
||||||
|
|
||||||
export interface InstallRequestValidated {
|
|
||||||
fileName: string;
|
|
||||||
data: Buffer;
|
|
||||||
id: LensExtensionId;
|
|
||||||
manifest: LensExtensionManifest;
|
|
||||||
tempFile: string; // temp system path to packed extension for unpacking
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CreateTempFilesAndValidate = (req: InstallRequest) => Promise<InstallRequestValidated | null>;
|
|
||||||
|
|
||||||
const createTempFilesAndValidateInjectable = getInjectable({
|
|
||||||
id: "create-temp-files-and-validate",
|
|
||||||
|
|
||||||
instantiate: (di) => {
|
|
||||||
const extensionDiscovery = di.inject(extensionDiscoveryInjectable);
|
|
||||||
const joinPaths = di.inject(joinPathsInjectable);
|
|
||||||
const tempDirectoryPath = di.inject(tempDirectoryPathInjectable);
|
|
||||||
const ensureDir = di.inject(ensureDirInjectable);
|
|
||||||
const writeFile = di.inject(writeFileInjectable);
|
|
||||||
const logger = di.inject(loggerInjectable);
|
|
||||||
const showErrorNotification = di.inject(showErrorNotificationInjectable);
|
|
||||||
|
|
||||||
const baseTempExtensionsDirectory = joinPaths(tempDirectoryPath, "lens-extensions");
|
|
||||||
const getExtensionPackageTemp = (fileName: string) => joinPaths(baseTempExtensionsDirectory, fileName);
|
|
||||||
|
|
||||||
return async ({
|
|
||||||
fileName,
|
|
||||||
dataP,
|
|
||||||
}: InstallRequest): Promise<InstallRequestValidated | null> => {
|
|
||||||
// copy files to temp
|
|
||||||
await ensureDir(baseTempExtensionsDirectory);
|
|
||||||
|
|
||||||
// validate packages
|
|
||||||
const tempFile = getExtensionPackageTemp(fileName);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await dataP;
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(tempFile, data);
|
|
||||||
logger.info("validating package", tempFile);
|
|
||||||
const manifest = await validatePackage(tempFile);
|
|
||||||
const id = joinPaths(
|
|
||||||
extensionDiscovery.nodeModulesPath,
|
|
||||||
manifest.name,
|
|
||||||
"package.json",
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
fileName,
|
|
||||||
data,
|
|
||||||
manifest,
|
|
||||||
tempFile,
|
|
||||||
id,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const message = getMessageFromError(error);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`[EXTENSION-INSTALLATION]: installing ${fileName} has failed: ${message}`,
|
|
||||||
{ error },
|
|
||||||
);
|
|
||||||
showErrorNotification(
|
|
||||||
<div className="flex column gaps">
|
|
||||||
<p>
|
|
||||||
{"Installing "}
|
|
||||||
<em>{fileName}</em>
|
|
||||||
{" has failed, skipping."}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{"Reason: "}
|
|
||||||
<em>{message}</em>
|
|
||||||
</p>
|
|
||||||
</div>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default createTempFilesAndValidateInjectable;
|
|
||||||
@ -3,20 +3,19 @@
|
|||||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||||
*/
|
*/
|
||||||
import { getInjectable } from "@ogre-tools/injectable";
|
import { getInjectable } from "@ogre-tools/injectable";
|
||||||
import joinPathsInjectable from "../../../../../common/path/join-paths.injectable";
|
import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable";
|
||||||
import extensionDiscoveryInjectable from "../../../../../extensions/extension-discovery/extension-discovery.injectable";
|
import { sanitizeExtensionName } from "../../../../extensions/lens-extension";
|
||||||
import { sanitizeExtensionName } from "../../../../../extensions/lens-extension";
|
import path from "path";
|
||||||
|
|
||||||
export type GetExtensionDestFolder = (extensionName: string) => string;
|
export type GetExtensionDestFolder = (name: string) => string;
|
||||||
|
|
||||||
const getExtensionDestFolderInjectable = getInjectable({
|
const getExtensionDestFolderInjectable = getInjectable({
|
||||||
id: "get-extension-dest-folder",
|
id: "get-extension-dest-folder",
|
||||||
|
|
||||||
instantiate: (di): GetExtensionDestFolder => {
|
instantiate: (di): GetExtensionDestFolder => {
|
||||||
const extensionDiscovery = di.inject(extensionDiscoveryInjectable);
|
const extensionDiscovery = di.inject(extensionDiscoveryInjectable);
|
||||||
const joinPaths = di.inject(joinPathsInjectable);
|
|
||||||
|
|
||||||
return (name) => joinPaths(extensionDiscovery.localFolderPath, sanitizeExtensionName(name));
|
return (name) => path.join(extensionDiscovery.localFolderPath, sanitizeExtensionName(name));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
|
||||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
|
||||||
*/
|
|
||||||
export interface InstallRequest {
|
|
||||||
fileName: string;
|
|
||||||
dataP: Promise<Buffer | null>;
|
|
||||||
}
|
|
||||||
@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||||
|
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||||
|
*/
|
||||||
|
import { getInjectable } from "@ogre-tools/injectable";
|
||||||
|
import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable";
|
||||||
|
import getExtensionDestFolderInjectable from "./get-extension-dest-folder.injectable";
|
||||||
|
import extensionInstallationStateStoreInjectable from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
|
||||||
|
import type { Disposer } from "../../../../common/utils";
|
||||||
|
import { noop } from "../../../../common/utils";
|
||||||
|
import { extensionDisplayName } from "../../../../extensions/lens-extension";
|
||||||
|
import { getMessageFromError } from "../get-message-from-error/get-message-from-error";
|
||||||
|
import path from "path";
|
||||||
|
import fse from "fs-extra";
|
||||||
|
import { when } from "mobx";
|
||||||
|
import React from "react";
|
||||||
|
import type { InstallRequestValidated } from "./create-temp-files-and-validate.injectable";
|
||||||
|
import extractTarInjectable from "../../../../common/fs/extract-tar.injectable";
|
||||||
|
import loggerInjectable from "../../../../common/logger.injectable";
|
||||||
|
import showInfoNotificationInjectable from "../../notifications/show-info-notification.injectable";
|
||||||
|
import showErrorNotificationInjectable from "../../notifications/show-error-notification.injectable";
|
||||||
|
|
||||||
|
export type UnpackExtension = (request: InstallRequestValidated, disposeDownloading?: Disposer) => Promise<void>;
|
||||||
|
|
||||||
|
const unpackExtensionInjectable = getInjectable({
|
||||||
|
id: "unpack-extension",
|
||||||
|
instantiate: (di): UnpackExtension => {
|
||||||
|
const extensionLoader = di.inject(extensionLoaderInjectable);
|
||||||
|
const getExtensionDestFolder = di.inject(getExtensionDestFolderInjectable);
|
||||||
|
const extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable);
|
||||||
|
const extractTar = di.inject(extractTarInjectable);
|
||||||
|
const logger = di.inject(loggerInjectable);
|
||||||
|
const showInfoNotification = di.inject(showInfoNotificationInjectable);
|
||||||
|
const showErrorNotification = di.inject(showErrorNotificationInjectable);
|
||||||
|
|
||||||
|
return async (request, disposeDownloading) => {
|
||||||
|
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.setIsEnabled(id, true);
|
||||||
|
|
||||||
|
showInfoNotification((
|
||||||
|
<p>
|
||||||
|
{"Extension "}
|
||||||
|
<b>{displayName}</b>
|
||||||
|
{" successfully installed!"}
|
||||||
|
</p>
|
||||||
|
));
|
||||||
|
} catch (error) {
|
||||||
|
const message = getMessageFromError(error);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`,
|
||||||
|
{ error },
|
||||||
|
);
|
||||||
|
showErrorNotification((
|
||||||
|
<p>
|
||||||
|
{"Installing extension "}
|
||||||
|
<b>{displayName}</b>
|
||||||
|
{" has failed: "}
|
||||||
|
<em>{message}</em>
|
||||||
|
</p>
|
||||||
|
));
|
||||||
|
} finally {
|
||||||
|
// Remove install state once finished
|
||||||
|
extensionInstallationStateStore.clearInstalling(id);
|
||||||
|
|
||||||
|
// clean up
|
||||||
|
fse.remove(unpackingTempFolder).catch(noop);
|
||||||
|
fse.unlink(tempFile).catch(noop);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default unpackExtensionInjectable;
|
||||||
@ -1,130 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
|
||||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
|
||||||
*/
|
|
||||||
import React from "react";
|
|
||||||
import { getInjectable } from "@ogre-tools/injectable";
|
|
||||||
import extensionLoaderInjectable from "../../../../../extensions/extension-loader/extension-loader.injectable";
|
|
||||||
import getExtensionDestFolderInjectable from "../get-extension-dest-folder/get-extension-dest-folder.injectable";
|
|
||||||
import extensionInstallationStateStoreInjectable from "../../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
|
|
||||||
import type { InstallRequestValidated } from "../create-temp-files-and-validate/create-temp-files-and-validate.injectable";
|
|
||||||
import type { Disposer } from "../../../../utils";
|
|
||||||
import { noop } from "../../../../utils";
|
|
||||||
import { extensionDisplayName } from "../../../../../extensions/lens-extension";
|
|
||||||
import joinPathsInjectable from "../../../../../common/path/join-paths.injectable";
|
|
||||||
import loggerInjectable from "../../../../../common/logger.injectable";
|
|
||||||
import { when } from "mobx";
|
|
||||||
import { getMessageFromError } from "../../get-message-from-error/get-message-from-error";
|
|
||||||
import showSuccessNotificationInjectable from "../../../notifications/show-success-notification.injectable";
|
|
||||||
import showErrorNotificationInjectable from "../../../notifications/show-error-notification.injectable";
|
|
||||||
import getDirnameOfPathInjectable from "../../../../../common/path/get-dirname.injectable";
|
|
||||||
import getBasenameOfPathInjectable from "../../../../../common/path/get-basename.injectable";
|
|
||||||
import extractTarInjectable from "../../../../../common/fs/extract-tar.injectable";
|
|
||||||
import ensureDirInjectable from "../../../../../common/fs/ensure-dir.injectable";
|
|
||||||
import removePathInjectable from "../../../../../common/fs/remove-path.injectable";
|
|
||||||
import deleteFileInjectable from "../../../../../common/fs/delete-file.injectable";
|
|
||||||
import readDirectoryInjectable from "../../../../../common/fs/read-directory.injectable";
|
|
||||||
import moveInjectable from "../../../../../common/fs/move.injectable";
|
|
||||||
|
|
||||||
export type UnpackExtension = (request: InstallRequestValidated, disposeDownloading?: Disposer) => Promise<void>;
|
|
||||||
|
|
||||||
const unpackExtensionInjectable = getInjectable({
|
|
||||||
id: "unpack-extension",
|
|
||||||
|
|
||||||
instantiate: (di): UnpackExtension => {
|
|
||||||
const extensionLoader = di.inject(extensionLoaderInjectable);
|
|
||||||
const getExtensionDestFolder = di.inject(getExtensionDestFolderInjectable);
|
|
||||||
const extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable);
|
|
||||||
const joinPaths = di.inject(joinPathsInjectable);
|
|
||||||
const getDirnameOfPath = di.inject(getDirnameOfPathInjectable);
|
|
||||||
const getBasenameOfPath = di.inject(getBasenameOfPathInjectable);
|
|
||||||
const logger = di.inject(loggerInjectable);
|
|
||||||
const showOkNotification = di.inject(showSuccessNotificationInjectable);
|
|
||||||
const showErrorNotification = di.inject(showErrorNotificationInjectable);
|
|
||||||
const extractTar = di.inject(extractTarInjectable);
|
|
||||||
const ensureDir = di.inject(ensureDirInjectable);
|
|
||||||
const removePath = di.inject(removePathInjectable);
|
|
||||||
const deleteFile = di.inject(deleteFileInjectable);
|
|
||||||
const readDirectory = di.inject(readDirectoryInjectable);
|
|
||||||
const move = di.inject(moveInjectable);
|
|
||||||
|
|
||||||
return async (request, disposeDownloading) => {
|
|
||||||
const {
|
|
||||||
id,
|
|
||||||
fileName,
|
|
||||||
tempFile,
|
|
||||||
manifest: { name, version },
|
|
||||||
} = request;
|
|
||||||
|
|
||||||
extensionInstallationStateStore.setInstalling(id);
|
|
||||||
disposeDownloading?.();
|
|
||||||
|
|
||||||
const displayName = extensionDisplayName(name, version);
|
|
||||||
const extensionFolder = getExtensionDestFolder(name);
|
|
||||||
const unpackingTempFolder = joinPaths(
|
|
||||||
getDirnameOfPath(tempFile),
|
|
||||||
`${getBasenameOfPath(tempFile)}-unpacked`,
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile });
|
|
||||||
|
|
||||||
try {
|
|
||||||
// extract to temp folder first
|
|
||||||
await removePath(unpackingTempFolder).catch(noop);
|
|
||||||
await ensureDir(unpackingTempFolder);
|
|
||||||
await extractTar(tempFile, { cwd: unpackingTempFolder });
|
|
||||||
|
|
||||||
// move contents to extensions folder
|
|
||||||
const unpackedFiles = await readDirectory(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 = joinPaths(unpackingTempFolder, unpackedFiles[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensureDir(extensionFolder);
|
|
||||||
await 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.setIsEnabled(id, true);
|
|
||||||
|
|
||||||
showOkNotification(
|
|
||||||
<p>
|
|
||||||
{"Extension "}
|
|
||||||
<b>{displayName}</b>
|
|
||||||
{" successfully installed!"}
|
|
||||||
</p>,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
const message = getMessageFromError(error);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`,
|
|
||||||
{ error },
|
|
||||||
);
|
|
||||||
showErrorNotification(
|
|
||||||
<p>
|
|
||||||
{"Installing extension "}
|
|
||||||
<b>{displayName}</b>
|
|
||||||
{" has failed: "}
|
|
||||||
<em>{message}</em>
|
|
||||||
</p>,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
// Remove install state once finished
|
|
||||||
extensionInstallationStateStore.clearInstalling(id);
|
|
||||||
|
|
||||||
// clean up
|
|
||||||
removePath(unpackingTempFolder).catch(noop);
|
|
||||||
deleteFile(tempFile).catch(noop);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default unpackExtensionInjectable;
|
|
||||||
@ -2,27 +2,23 @@
|
|||||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||||
*/
|
*/
|
||||||
import type { LensExtensionManifest } from "../../../../../extensions/lens-extension";
|
import type { LensExtensionManifest } from "../../../../extensions/lens-extension";
|
||||||
import { hasTypedProperty, isObject, isString, listTarEntries, readFileFromTar } from "../../../../../common/utils";
|
import { hasTypedProperty, isObject, isString, listTarEntries, readFileFromTar } from "../../../../common/utils";
|
||||||
import { manifestFilename } from "../../../../../extensions/extension-discovery/extension-discovery";
|
import { manifestFilename } from "../../../../extensions/extension-discovery/extension-discovery";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
export const validatePackage = async (
|
export async function validatePackage(filePath: string): Promise<LensExtensionManifest> {
|
||||||
filePath: string,
|
|
||||||
): Promise<LensExtensionManifest> => {
|
|
||||||
const tarFiles = await listTarEntries(filePath);
|
const tarFiles = await listTarEntries(filePath);
|
||||||
|
|
||||||
// tarball from npm contains single root folder "package/*"
|
// tarball from npm contains single root folder "package/*"
|
||||||
const firstFile = tarFiles[0];
|
const firstFile = tarFiles[0];
|
||||||
|
|
||||||
if (!firstFile) {
|
if (!firstFile) {
|
||||||
throw new Error(`invalid extension bundle, ${manifestFilename} not found`);
|
throw new Error(`invalid extension bundle, ${manifestFilename} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootFolder = path.normalize(firstFile).split(path.sep)[0];
|
const rootFolder = path.normalize(firstFile).split(path.sep)[0];
|
||||||
const packedInRootFolder = tarFiles.every(entry =>
|
const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder));
|
||||||
entry.startsWith(rootFolder),
|
|
||||||
);
|
|
||||||
const manifestLocation = packedInRootFolder
|
const manifestLocation = packedInRootFolder
|
||||||
? path.join(rootFolder, manifestFilename)
|
? path.join(rootFolder, manifestFilename)
|
||||||
: manifestFilename;
|
: manifestFilename;
|
||||||
@ -48,4 +44,4 @@ export const validatePackage = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`);
|
throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`);
|
||||||
};
|
}
|
||||||
@ -3,9 +3,9 @@
|
|||||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||||
*/
|
*/
|
||||||
import { getInjectable } from "@ogre-tools/injectable";
|
import { getInjectable } from "@ogre-tools/injectable";
|
||||||
import getBasenameOfPathInjectable from "../../../../common/path/get-basename.injectable";
|
import attemptInstallInjectable from "./attempt-install/attempt-install.injectable";
|
||||||
import attemptInstallInjectable from "../attempt-install/attempt-install.injectable";
|
import path from "path";
|
||||||
import readFileNotifyInjectable from "../read-file-notify/read-file-notify.injectable";
|
import readFileNotifyInjectable from "./read-file-notify/read-file-notify.injectable";
|
||||||
|
|
||||||
export type AttemptInstalls = (filePaths: string[]) => Promise<void>;
|
export type AttemptInstalls = (filePaths: string[]) => Promise<void>;
|
||||||
|
|
||||||
@ -14,16 +14,23 @@ const attemptInstallsInjectable = getInjectable({
|
|||||||
|
|
||||||
instantiate: (di): AttemptInstalls => {
|
instantiate: (di): AttemptInstalls => {
|
||||||
const attemptInstall = di.inject(attemptInstallInjectable);
|
const attemptInstall = di.inject(attemptInstallInjectable);
|
||||||
const getBasenameOfPath = di.inject(getBasenameOfPathInjectable);
|
|
||||||
const readFileNotify = di.inject(readFileNotifyInjectable);
|
const readFileNotify = di.inject(readFileNotifyInjectable);
|
||||||
|
|
||||||
return async (filePaths) => {
|
return async (filePaths) => {
|
||||||
await Promise.allSettled(filePaths.map(filePath => (
|
await Promise.allSettled(
|
||||||
attemptInstall({
|
filePaths.map(async filePath => {
|
||||||
fileName: getBasenameOfPath(filePath),
|
const data = await readFileNotify(filePath);
|
||||||
dataP: readFileNotify(filePath),
|
|
||||||
})
|
if (!data) {
|
||||||
)));
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return attemptInstall({
|
||||||
|
fileName: path.basename(filePath),
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -32,7 +32,8 @@ import type { InstallExtensionFromInput } from "./install-extension-from-input.i
|
|||||||
import installExtensionFromInputInjectable from "./install-extension-from-input.injectable";
|
import installExtensionFromInputInjectable from "./install-extension-from-input.injectable";
|
||||||
import installFromSelectFileDialogInjectable from "./install-from-select-file-dialog.injectable";
|
import installFromSelectFileDialogInjectable from "./install-from-select-file-dialog.injectable";
|
||||||
import type { LensExtensionId } from "../../../extensions/lens-extension";
|
import type { LensExtensionId } from "../../../extensions/lens-extension";
|
||||||
import installOnDropInjectable from "./install-on-drop/install-on-drop.injectable";
|
import type { InstallOnDrop } from "./install-on-drop.injectable";
|
||||||
|
import installOnDropInjectable from "./install-on-drop.injectable";
|
||||||
import { supportedExtensionFormats } from "./supported-extension-formats";
|
import { supportedExtensionFormats } from "./supported-extension-formats";
|
||||||
import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
|
import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
|
||||||
import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store";
|
import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store";
|
||||||
@ -44,7 +45,7 @@ interface Dependencies {
|
|||||||
confirmUninstallExtension: ConfirmUninstallExtension;
|
confirmUninstallExtension: ConfirmUninstallExtension;
|
||||||
installExtensionFromInput: InstallExtensionFromInput;
|
installExtensionFromInput: InstallExtensionFromInput;
|
||||||
installFromSelectFileDialog: () => Promise<void>;
|
installFromSelectFileDialog: () => Promise<void>;
|
||||||
installOnDrop: (files: File[]) => Promise<void>;
|
installOnDrop: InstallOnDrop;
|
||||||
extensionInstallationStateStore: ExtensionInstallationStateStore;
|
extensionInstallationStateStore: ExtensionInstallationStateStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import type { ExtendableDisposer } from "../../../common/utils";
|
import type { ExtendableDisposer } from "../../../common/utils";
|
||||||
import { downloadFile } from "../../../common/utils";
|
|
||||||
import { InputValidators } from "../input";
|
import { InputValidators } from "../input";
|
||||||
import { getMessageFromError } from "./get-message-from-error/get-message-from-error";
|
import { getMessageFromError } from "./get-message-from-error/get-message-from-error";
|
||||||
import { getInjectable } from "@ogre-tools/injectable";
|
import { getInjectable } from "@ogre-tools/injectable";
|
||||||
@ -15,6 +14,8 @@ import readFileNotifyInjectable from "./read-file-notify/read-file-notify.inject
|
|||||||
import getBasenameOfPathInjectable from "../../../common/path/get-basename.injectable";
|
import getBasenameOfPathInjectable from "../../../common/path/get-basename.injectable";
|
||||||
import showErrorNotificationInjectable from "../notifications/show-error-notification.injectable";
|
import showErrorNotificationInjectable from "../notifications/show-error-notification.injectable";
|
||||||
import loggerInjectable from "../../../common/logger.injectable";
|
import loggerInjectable from "../../../common/logger.injectable";
|
||||||
|
import downloadBinaryInjectable from "../../../common/fetch/download-binary.injectable";
|
||||||
|
import { withTimeout } from "../../../common/fetch/timeout-controller";
|
||||||
|
|
||||||
export type InstallExtensionFromInput = (input: string) => Promise<void>;
|
export type InstallExtensionFromInput = (input: string) => Promise<void>;
|
||||||
|
|
||||||
@ -29,19 +30,28 @@ const installExtensionFromInputInjectable = getInjectable({
|
|||||||
const getBasenameOfPath = di.inject(getBasenameOfPathInjectable);
|
const getBasenameOfPath = di.inject(getBasenameOfPathInjectable);
|
||||||
const showErrorNotification = di.inject(showErrorNotificationInjectable);
|
const showErrorNotification = di.inject(showErrorNotificationInjectable);
|
||||||
const logger = di.inject(loggerInjectable);
|
const logger = di.inject(loggerInjectable);
|
||||||
|
const downloadBinary = di.inject(downloadBinaryInjectable);
|
||||||
|
|
||||||
return async (input) => {
|
return async (input) => {
|
||||||
let disposer: ExtendableDisposer | undefined = undefined;
|
let disposer: ExtendableDisposer | undefined = undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// fixme: improve error messages for non-tar-file URLs
|
// fixme: improve error messages for non-tar-file URLs
|
||||||
if (InputValidators.isUrl.validate(input)) {
|
if (InputValidators.isUrl.validate(input)) {
|
||||||
// install via url
|
// install via url
|
||||||
disposer = extensionInstallationStateStore.startPreInstall();
|
disposer = extensionInstallationStateStore.startPreInstall();
|
||||||
const { promise } = downloadFile({ url: input, timeout: 10 * 60 * 1000 });
|
const { signal } = withTimeout(10 * 60 * 1000);
|
||||||
|
const result = await downloadBinary(input, { signal });
|
||||||
|
|
||||||
|
if (!result.callWasSuccessful) {
|
||||||
|
showErrorNotification(`Failed to download extension: ${result.error}`);
|
||||||
|
|
||||||
|
return disposer();
|
||||||
|
}
|
||||||
|
|
||||||
const fileName = getBasenameOfPath(input);
|
const fileName = getBasenameOfPath(input);
|
||||||
|
|
||||||
return await attemptInstall({ fileName, dataP: promise }, disposer);
|
return await attemptInstall({ fileName, data: result.response }, disposer);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -49,8 +59,13 @@ const installExtensionFromInputInjectable = getInjectable({
|
|||||||
|
|
||||||
// install from system path
|
// install from system path
|
||||||
const fileName = getBasenameOfPath(input);
|
const fileName = getBasenameOfPath(input);
|
||||||
|
const data = await readFileNotify(input);
|
||||||
|
|
||||||
return await attemptInstall({ fileName, dataP: readFileNotify(input) });
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await attemptInstall({ fileName, data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const extNameCaptures = InputValidators.isExtensionNameInstallRegex.captures(input);
|
const extNameCaptures = InputValidators.isExtensionNameInstallRegex.captures(input);
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
import { getInjectable } from "@ogre-tools/injectable";
|
import { getInjectable } from "@ogre-tools/injectable";
|
||||||
import { requestOpenFilePickingDialog } from "../../ipc";
|
import { requestOpenFilePickingDialog } from "../../ipc";
|
||||||
import { supportedExtensionFormats } from "./supported-extension-formats";
|
import { supportedExtensionFormats } from "./supported-extension-formats";
|
||||||
import attemptInstallsInjectable from "./attempt-installs/attempt-installs.injectable";
|
import attemptInstallsInjectable from "./attempt-installs.injectable";
|
||||||
import directoryForDownloadsInjectable from "../../../common/app-paths/directory-for-downloads/directory-for-downloads.injectable";
|
import directoryForDownloadsInjectable from "../../../common/app-paths/directory-for-downloads/directory-for-downloads.injectable";
|
||||||
|
|
||||||
interface Dependencies {
|
interface Dependencies {
|
||||||
|
|||||||
@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||||
|
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||||
|
*/
|
||||||
|
import { getInjectable } from "@ogre-tools/injectable";
|
||||||
|
import loggerInjectable from "../../../common/logger.injectable";
|
||||||
|
import attemptInstallsInjectable from "./attempt-installs.injectable";
|
||||||
|
|
||||||
|
export type InstallOnDrop = (files: File[]) => Promise<void>;
|
||||||
|
|
||||||
|
const installOnDropInjectable = getInjectable({
|
||||||
|
id: "install-on-drop",
|
||||||
|
|
||||||
|
instantiate: (di): InstallOnDrop => {
|
||||||
|
const attemptInstalls = di.inject(attemptInstallsInjectable);
|
||||||
|
const logger = di.inject(loggerInjectable);
|
||||||
|
|
||||||
|
return (files) => {
|
||||||
|
logger.info("Install from D&D");
|
||||||
|
|
||||||
|
return attemptInstalls(files.map(({ path }) => path));
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default installOnDropInjectable;
|
||||||
@ -1,18 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
|
||||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
|
||||||
*/
|
|
||||||
import { getInjectable } from "@ogre-tools/injectable";
|
|
||||||
import { installOnDrop } from "./install-on-drop";
|
|
||||||
import attemptInstallsInjectable from "../attempt-installs/attempt-installs.injectable";
|
|
||||||
|
|
||||||
const installOnDropInjectable = getInjectable({
|
|
||||||
id: "install-on-drop",
|
|
||||||
|
|
||||||
instantiate: (di) =>
|
|
||||||
installOnDrop({
|
|
||||||
attemptInstalls: di.inject(attemptInstallsInjectable),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default installOnDropInjectable;
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
|
||||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
|
||||||
*/
|
|
||||||
import logger from "../../../../main/logger";
|
|
||||||
|
|
||||||
interface Dependencies {
|
|
||||||
attemptInstalls: (filePaths: string[]) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const installOnDrop =
|
|
||||||
({ attemptInstalls }: Dependencies) =>
|
|
||||||
async (files: File[]) => {
|
|
||||||
logger.info("Install from D&D");
|
|
||||||
await attemptInstalls(files.map(({ path }) => path));
|
|
||||||
};
|
|
||||||
@ -56,9 +56,7 @@ const NonInjectedInstall: React.FC<Dependencies & InstallProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
theme="round-black"
|
theme="round-black"
|
||||||
disabled={
|
disabled={extensionInstallationStateStore.anyPreInstallingOrInstalling}
|
||||||
extensionInstallationStateStore.anyPreInstallingOrInstalling
|
|
||||||
}
|
|
||||||
placeholder={"Name or file path or URL"}
|
placeholder={"Name or file path or URL"}
|
||||||
showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }}
|
showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }}
|
||||||
validators={installPath ? installInputValidator : undefined}
|
validators={installPath ? installInputValidator : undefined}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import "./input.scss";
|
|||||||
|
|
||||||
import type { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
import type { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import type { SingleOrMany } from "../../utils";
|
||||||
import { autoBind, cssNames, debouncePromise, getRandId, isPromiseSettledFulfilled } from "../../utils";
|
import { autoBind, cssNames, debouncePromise, getRandId, isPromiseSettledFulfilled } from "../../utils";
|
||||||
import { Icon } from "../icon";
|
import { Icon } from "../icon";
|
||||||
import type { TooltipProps } from "../tooltip";
|
import type { TooltipProps } from "../tooltip";
|
||||||
@ -72,7 +73,7 @@ export type InputProps = Omit<InputElementProps, "onChange" | "onSubmit"> & {
|
|||||||
iconLeft?: IconData;
|
iconLeft?: IconData;
|
||||||
iconRight?: IconData;
|
iconRight?: IconData;
|
||||||
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
|
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
|
||||||
validators?: InputValidator<boolean> | InputValidator<boolean>[];
|
validators?: SingleOrMany<InputValidator>;
|
||||||
blurOnEnter?: boolean;
|
blurOnEnter?: boolean;
|
||||||
onChange?(value: string, evt: React.ChangeEvent<InputElement>): void;
|
onChange?(value: string, evt: React.ChangeEvent<InputElement>): void;
|
||||||
onSubmit?(value: string, evt: React.KeyboardEvent<InputElement>): void;
|
onSubmit?(value: string, evt: React.KeyboardEvent<InputElement>): void;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user