diff --git a/src/common/fetch/download-binary.injectable.ts b/src/common/fetch/download-binary.injectable.ts new file mode 100644 index 0000000000..27ef43d59b --- /dev/null +++ b/src/common/fetch/download-binary.injectable.ts @@ -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>; + +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; diff --git a/src/common/fetch/download-json.injectable.ts b/src/common/fetch/download-json.injectable.ts new file mode 100644 index 0000000000..503cd373ec --- /dev/null +++ b/src/common/fetch/download-json.injectable.ts @@ -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>; + +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; diff --git a/src/common/fetch/fetch.injectable.ts b/src/common/fetch/fetch.injectable.ts index c6d2a7e1af..c4c30bc2d8 100644 --- a/src/common/fetch/fetch.injectable.ts +++ b/src/common/fetch/fetch.injectable.ts @@ -3,10 +3,10 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import type { RequestInfo, RequestInit, Response } from "node-fetch"; import fetch from "node-fetch"; +import type { RequestInit, Response } from "node-fetch"; -export type Fetch = (url: RequestInfo, init?: RequestInit) => Promise; +export type Fetch = (url: string, init?: RequestInit) => Promise; const fetchInjectable = getInjectable({ id: "fetch", diff --git a/src/common/fetch/timeout-controller.ts b/src/common/fetch/timeout-controller.ts new file mode 100644 index 0000000000..702becdc9d --- /dev/null +++ b/src/common/fetch/timeout-controller.ts @@ -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; +} diff --git a/src/common/utils/camelCase.ts b/src/common/utils/camelCase.ts index 3ef727a2e5..a37e4c7f5c 100644 --- a/src/common/utils/camelCase.ts +++ b/src/common/utils/camelCase.ts @@ -6,7 +6,8 @@ // Convert object's keys to camelCase format import { camelCase } from "lodash"; import type { SingleOrMany } from "./types"; -import { isObject } from "./type-narrowing"; +import { isObject, isString } from "./type-narrowing"; +import * as object from "./objects"; export function toCamelCase[]>(obj: T): T; export function toCamelCase>(obj: T): T; @@ -17,11 +18,10 @@ export function toCamelCase(obj: SingleOrMany | unknown> } if (isObject(obj)) { - return Object.fromEntries( - Object.entries(obj) - .map(([key, value]) => { - return [camelCase(key), toCamelCase(value)]; - }), + return object.fromEntries( + object.entries(obj) + .filter((pair): pair is [string, unknown] => isString(pair[0])) + .map(([key, value]) => [camelCase(key), isObject(value) ? toCamelCase(value) : value]), ); } diff --git a/src/common/utils/downloadFile.ts b/src/common/utils/downloadFile.ts deleted file mode 100644 index 5f3e658aa6..0000000000 --- a/src/common/utils/downloadFile.ts +++ /dev/null @@ -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 { - url: string; - promise: Promise; - cancel(): void; -} - -export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket { - const fileChunks: Buffer[] = []; - const req = request(url, { gzip, timeout }); - const promise: Promise = 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 { - const { promise, ...rest } = downloadFile(args); - - return { - promise: promise.then(res => parse(res.toString())), - ...rest, - }; -} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index a9acaede86..36e77c6e79 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -14,7 +14,6 @@ export * from "./convertMemory"; export * from "./debouncePromise"; export * from "./delay"; export * from "./disposer"; -export * from "./downloadFile"; export * from "./escapeRegExp"; export * from "./formatDuration"; export * from "./getRandId"; diff --git a/src/common/utils/type-narrowing.ts b/src/common/utils/type-narrowing.ts index eb5b6996cf..d2d33929a2 100644 --- a/src/common/utils/type-narrowing.ts +++ b/src/common/utils/type-narrowing.ts @@ -103,7 +103,7 @@ export function isBoolean(val: unknown): val is boolean { * checks if val is of type object and isn't null * @param val the value to be checked */ -export function isObject(val: unknown): val is object { +export function isObject(val: unknown): val is Record { return typeof val === "object" && val !== null; } diff --git a/src/features/extensions/navigation-using-application-menu.test.ts b/src/features/extensions/navigation-using-application-menu.test.ts index a9e7e23b74..8533b216f7 100644 --- a/src/features/extensions/navigation-using-application-menu.test.ts +++ b/src/features/extensions/navigation-using-application-menu.test.ts @@ -6,6 +6,8 @@ import type { RenderResult } from "@testing-library/react"; import type { ApplicationBuilder } 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"; // 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 rendered: RenderResult; let focusWindowMock: jest.Mock; + let downloadJson: jest.MockedFunction; + let downloadBinary: jest.MockedFunction; beforeEach(async () => { builder = getApplicationBuilder(); builder.beforeWindowStart((windowDi) => { 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(downloadJsonInjectable, () => downloadJson); + windowDi.override(downloadBinaryInjectable, () => downloadBinary); }); rendered = await builder.render(); diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx index 52843d8b3d..27db8da81a 100644 --- a/src/renderer/components/+extensions/__tests__/extensions.test.tsx +++ b/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -25,23 +25,10 @@ import extensionInstallationStateStoreInjectable from "../../../../extensions/ex import { observable, when } from "mobx"; import type { DeleteFile } from "../../../../common/fs/delete-file.injectable"; import deleteFileInjectable from "../../../../common/fs/delete-file.injectable"; - -jest.mock("../../notifications"); - -jest.mock("../../../../common/utils/downloadFile", () => ({ - downloadFile: jest.fn(({ url }) => ({ - promise: Promise.resolve(), - url, - cancel: () => {}, - })), - downloadJson: jest.fn(({ url }) => ({ - promise: Promise.resolve({}), - url, - cancel: () => { }, - })), -})); - -jest.mock("../../../../common/utils/tar"); +import type { DownloadJson } from "../../../../common/fetch/download-json.injectable"; +import type { DownloadBinary } from "../../../../common/fetch/download-binary.injectable"; +import downloadJsonInjectable from "../../../../common/fetch/download-json.injectable"; +import downloadBinaryInjectable from "../../../../common/fetch/download-binary.injectable"; describe("Extensions", () => { let extensionLoader: ExtensionLoader; @@ -50,6 +37,8 @@ describe("Extensions", () => { let extensionInstallationStateStore: ExtensionInstallationStateStore; let render: DiRender; let deleteFileMock: jest.MockedFunction; + let downloadJson: jest.MockedFunction; + let downloadBinary: jest.MockedFunction; beforeEach(() => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); @@ -65,6 +54,12 @@ describe("Extensions", () => { deleteFileMock = jest.fn(); 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); extensionDiscovery = di.inject(extensionDiscoveryInjectable); extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable); @@ -110,7 +105,7 @@ describe("Extensions", () => { // Approve confirm dialog fireEvent.click(await screen.findByText("Yes")); - await waitFor(() => { + await waitFor(async () => { expect(extensionDiscovery.uninstallExtension).toHaveBeenCalled(); fireEvent.click(menuTrigger); expect(screen.getByText("Disable")).toHaveAttribute("aria-disabled", "true"); @@ -124,6 +119,7 @@ describe("Extensions", () => { render(); const resolveInstall = observable.box(false); + const url = "https://test.extensionurl/package.tgz"; deleteFileMock.mockReturnValue(Promise.resolve()); installExtensionFromInput.mockImplementation(async (input) => { @@ -139,13 +135,26 @@ describe("Extensions", () => { exact: false, }), { 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")); expect((await screen.findByText("Install")).closest("button")).toBeDisabled(); - resolveInstall.set(true); + doResolve.set(true); }); it("displays spinner while extensions are loading", () => { diff --git a/src/renderer/components/+extensions/attempt-install-by-info.injectable.tsx b/src/renderer/components/+extensions/attempt-install-by-info.injectable.tsx index 36f6b2afc4..317162cc9f 100644 --- a/src/renderer/components/+extensions/attempt-install-by-info.injectable.tsx +++ b/src/renderer/components/+extensions/attempt-install-by-info.injectable.tsx @@ -2,8 +2,7 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { downloadFile, downloadJson } from "../../../common/utils"; -import { Notifications } from "../notifications"; +import { isObject } from "../../../common/utils"; import React from "react"; import { SemVer } from "semver"; import URLParse from "url-parse"; @@ -14,6 +13,12 @@ import extensionInstallationStateStoreInjectable from "../../../extensions/exten import confirmInjectable from "../confirm-dialog/confirm.injectable"; import { reduce } from "lodash"; 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 { name: string; @@ -21,6 +26,19 @@ export interface ExtensionInfo { requireConfirmation?: boolean; } +interface NpmPackageVersionDescriptor extends PackageJson { + dist: { + integrity: string; + shasum: string; + tarball: string; + }; +} + +interface NpmRegistryPackageDescriptor { + versions: Partial>; + "dist-tags"?: Partial>; +} + export type AttemptInstallByInfo = (info: ExtensionInfo) => Promise; const attemptInstallByInfoInjectable = getInjectable({ @@ -31,84 +49,131 @@ const attemptInstallByInfoInjectable = getInjectable({ const extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable); const confirm = di.inject(confirmInjectable); 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) => { - const { name, version, requireConfirmation = false } = info; + const { name, version: versionOrTagName, requireConfirmation = false } = info; const disposer = extensionInstallationStateStore.startPreInstall(); const baseUrl = await getBaseRegistryUrl(); const registryUrl = new URLParse(baseUrl).set("pathname", name).toString(); - let json: any; - let finalVersion = version; + let json: NpmRegistryPackageDescriptor; try { - json = await downloadJson({ url: registryUrl }).promise; + const result = await downloadJson(registryUrl); - if (!json || json.error || typeof json.versions !== "object" || !json.versions) { - const message = json?.error ? `: ${json.error}` : ""; - - Notifications.error(`Failed to get registry information for that extension${message}`); + if (!result.callWasSuccessful) { + showErrorNotification(`Failed to get registry information for extension: ${result.error}`); 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) { if (error instanceof SyntaxError) { - // assume invalid JSON - console.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"); + // assume invalid JSON + logger.warn("Set registry has invalid json", { url: baseUrl }, error); + showErrorNotification("Failed to get valid registry information for extension. Registry did not return valid JSON"); } else { - console.error("Failed to download registry information", error); - Notifications.error(`Failed to get valid registry information for that extension. ${error}`); + logger.error("Failed to download registry information", error); + showErrorNotification(`Failed to get valid registry information for extension. ${error}`); } return disposer(); } - if (version) { - if (!json.versions[version]) { - if (json["dist-tags"][version]) { - finalVersion = json["dist-tags"][version]; - } else { - Notifications.error(( -

- {"The "} - {name} - {" extension does not have a version or tag "} - {version} - . -

- )); + let version = versionOrTagName; - 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(( +

+ Configured registry claims to have tag + {" "} + {versionOrTagName} + . + {" "} + But does not have version infomation for the reference. +

+ )); + + return disposer(); + } + + version = potentialVersion; + break validDistTagName; + } } + + showErrorNotification(( +

+ {"The "} + {name} + {" extension does not have a version or tag "} + {versionOrTagName} + . +

+ )); + + return disposer(); } } else { const versions = Object.keys(json.versions) .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); const latestVersion = reduce(versions, (prev, curr) => prev.compareMain(curr) === -1 ? curr : prev); - if (!latestVersion) { - console.error("No versions supplied for that extension", { name }); - Notifications.error(`No versions found for ${name}`); + version = latestVersion?.format(); + } - 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) { const proceed = await confirm({ message: (

- Are you sure you want to install - {" "} + {"Are you sure you want to install "} - {name} - @ - {finalVersion} + {`${name}@${version}`} ?

@@ -122,12 +187,17 @@ const attemptInstallByInfoInjectable = getInjectable({ } } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const url = json.versions[finalVersion!].dist.tarball; - const fileName = getBasenameOfPath(url); - const { promise: dataP } = downloadFile({ url, timeout: 10 * 60 * 1000 }); + const fileName = getBasenameOfPath(tarballUrl); + const { signal } = withTimeout(10 * 60 * 1000); + const request = await downloadBinary(tarballUrl, { signal }); - return attemptInstall({ fileName, dataP }, disposer); + if (!request.callWasSuccessful) { + showErrorNotification(`Failed to download extension: ${request.error}`); + + return disposer(); + } + + return attemptInstall({ fileName, data: request.response }, disposer); }; }, }); diff --git a/src/renderer/components/+extensions/attempt-install/attempt-install.injectable.ts b/src/renderer/components/+extensions/attempt-install/attempt-install.injectable.ts deleted file mode 100644 index 21ceb73ecf..0000000000 --- a/src/renderer/components/+extensions/attempt-install/attempt-install.injectable.ts +++ /dev/null @@ -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; diff --git a/src/renderer/components/+extensions/attempt-install/attempt-install.injectable.tsx b/src/renderer/components/+extensions/attempt-install/attempt-install.injectable.tsx new file mode 100644 index 0000000000..cb73df7237 --- /dev/null +++ b/src/renderer/components/+extensions/attempt-install/attempt-install.injectable.tsx @@ -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; + unpackExtension: UnpackExtension; + createTempFilesAndValidate: CreateTempFilesAndValidate; + getExtensionDestFolder: GetExtensionDestFolder; + installStateStore: ExtensionInstallationStateStore; +} + +export type AttemptInstall = (request: InstallRequest, cleanup?: Disposer) => Promise; + +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( +
+ Extension Install Collision: +

+ {"The "} + {name} + {` extension is currently ${curState.toLowerCase()}.`} +

+

Will not proceed with this current install request.

+
, + ); + } + + 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( +
+
+

+ {"Install extension "} + {`${name}@${version}`} + ? +

+

+ {"Description: "} + {description} +

+
shell.openPath(extensionFolder)} + > + Warning: + {` ${name}@${oldVersion} will be removed before installation.`} +
+
+
, + { + 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; diff --git a/src/renderer/components/+extensions/attempt-install/attempt-install.tsx b/src/renderer/components/+extensions/attempt-install/attempt-install.tsx deleted file mode 100644 index de8c968201..0000000000 --- a/src/renderer/components/+extensions/attempt-install/attempt-install.tsx +++ /dev/null @@ -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; - unpackExtension: UnpackExtension; - createTempFilesAndValidate: CreateTempFilesAndValidate; - getExtensionDestFolder: GetExtensionDestFolder; - extensionInstallationStateStore: ExtensionInstallationStateStore; -} - -export const attemptInstall = - ({ - extensionLoader, - uninstallExtension, - unpackExtension, - createTempFilesAndValidate, - getExtensionDestFolder, - extensionInstallationStateStore, - }: Dependencies) => - async (request: InstallRequest, d?: ExtendableDisposer): Promise => { - 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( -
- Extension Install Collision: -

- {"The "} - {name} - {` extension is currently ${curState.toLowerCase()}.`} -

-

Will not proceed with this current install request.

-
, - ); - } - - 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( -
-
-

- {"Install extension "} - {`${name}@${version}`} - ? -

-

- {"Description: "} - {description} -

-
shell.openPath(extensionFolder)} - > - Warning: - {` ${name}@${oldVersion} will be removed before installation.`} -
-
-
, - { - onClose: dispose, - }, - ); - } else { - // clean up old data if still around - await removeDir(extensionFolder); - - // install extension if not yet exists - await unpackExtension(validatedRequest, dispose); - } - }; diff --git a/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate.injectable.tsx b/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate.injectable.tsx new file mode 100644 index 0000000000..e9fea96e44 --- /dev/null +++ b/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate.injectable.tsx @@ -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; + +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(( +
+

+ {"Installing "} + {fileName} + {" has failed, skipping."} +

+

+ {"Reason: "} + {message} +

+
+ )); + } + + 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; diff --git a/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate/create-temp-files-and-validate.injectable.tsx b/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate/create-temp-files-and-validate.injectable.tsx deleted file mode 100644 index 951c45d246..0000000000 --- a/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate/create-temp-files-and-validate.injectable.tsx +++ /dev/null @@ -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; - -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 => { - // 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( -
-

- {"Installing "} - {fileName} - {" has failed, skipping."} -

-

- {"Reason: "} - {message} -

-
, - ); - } - - return null; - }; - }, -}); - -export default createTempFilesAndValidateInjectable; diff --git a/src/renderer/components/+extensions/attempt-install/get-extension-dest-folder/get-extension-dest-folder.injectable.ts b/src/renderer/components/+extensions/attempt-install/get-extension-dest-folder.injectable.ts similarity index 51% rename from src/renderer/components/+extensions/attempt-install/get-extension-dest-folder/get-extension-dest-folder.injectable.ts rename to src/renderer/components/+extensions/attempt-install/get-extension-dest-folder.injectable.ts index 2c46b595a4..6d6dbfbef3 100644 --- a/src/renderer/components/+extensions/attempt-install/get-extension-dest-folder/get-extension-dest-folder.injectable.ts +++ b/src/renderer/components/+extensions/attempt-install/get-extension-dest-folder.injectable.ts @@ -3,20 +3,19 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import joinPathsInjectable from "../../../../../common/path/join-paths.injectable"; -import extensionDiscoveryInjectable from "../../../../../extensions/extension-discovery/extension-discovery.injectable"; -import { sanitizeExtensionName } from "../../../../../extensions/lens-extension"; +import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable"; +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({ id: "get-extension-dest-folder", instantiate: (di): GetExtensionDestFolder => { 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)); }, }); diff --git a/src/renderer/components/+extensions/attempt-install/install-request.ts b/src/renderer/components/+extensions/attempt-install/install-request.ts deleted file mode 100644 index c5af4e5f93..0000000000 --- a/src/renderer/components/+extensions/attempt-install/install-request.ts +++ /dev/null @@ -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; -} diff --git a/src/renderer/components/+extensions/attempt-install/unpack-extension.injectable.tsx b/src/renderer/components/+extensions/attempt-install/unpack-extension.injectable.tsx new file mode 100644 index 0000000000..c81a69f9b0 --- /dev/null +++ b/src/renderer/components/+extensions/attempt-install/unpack-extension.injectable.tsx @@ -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; + +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(( +

+ {"Extension "} + {displayName} + {" successfully installed!"} +

+ )); + } catch (error) { + const message = getMessageFromError(error); + + logger.info( + `[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`, + { error }, + ); + showErrorNotification(( +

+ {"Installing extension "} + {displayName} + {" has failed: "} + {message} +

+ )); + } finally { + // Remove install state once finished + extensionInstallationStateStore.clearInstalling(id); + + // clean up + fse.remove(unpackingTempFolder).catch(noop); + fse.unlink(tempFile).catch(noop); + } + }; + }, +}); + +export default unpackExtensionInjectable; diff --git a/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.injectable.tsx b/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.injectable.tsx deleted file mode 100644 index 9a10062f7e..0000000000 --- a/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.injectable.tsx +++ /dev/null @@ -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; - -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( -

- {"Extension "} - {displayName} - {" successfully installed!"} -

, - ); - } catch (error) { - const message = getMessageFromError(error); - - logger.info( - `[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`, - { error }, - ); - showErrorNotification( -

- {"Installing extension "} - {displayName} - {" has failed: "} - {message} -

, - ); - } finally { - // Remove install state once finished - extensionInstallationStateStore.clearInstalling(id); - - // clean up - removePath(unpackingTempFolder).catch(noop); - deleteFile(tempFile).catch(noop); - } - }; - }, -}); - -export default unpackExtensionInjectable; diff --git a/src/renderer/components/+extensions/attempt-install/validate-package/validate-package.tsx b/src/renderer/components/+extensions/attempt-install/validate-package.tsx similarity index 69% rename from src/renderer/components/+extensions/attempt-install/validate-package/validate-package.tsx rename to src/renderer/components/+extensions/attempt-install/validate-package.tsx index ad3432574b..e9597b2d10 100644 --- a/src/renderer/components/+extensions/attempt-install/validate-package/validate-package.tsx +++ b/src/renderer/components/+extensions/attempt-install/validate-package.tsx @@ -2,27 +2,23 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { LensExtensionManifest } from "../../../../../extensions/lens-extension"; -import { hasTypedProperty, isObject, isString, listTarEntries, readFileFromTar } from "../../../../../common/utils"; -import { manifestFilename } from "../../../../../extensions/extension-discovery/extension-discovery"; +import type { LensExtensionManifest } from "../../../../extensions/lens-extension"; +import { hasTypedProperty, isObject, isString, listTarEntries, readFileFromTar } from "../../../../common/utils"; +import { manifestFilename } from "../../../../extensions/extension-discovery/extension-discovery"; import path from "path"; -export const validatePackage = async ( - filePath: string, -): Promise => { +export async function validatePackage(filePath: string): Promise { const tarFiles = await listTarEntries(filePath); // tarball from npm contains single root folder "package/*" const firstFile = tarFiles[0]; 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 packedInRootFolder = tarFiles.every(entry => - entry.startsWith(rootFolder), - ); + const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder)); const manifestLocation = packedInRootFolder ? path.join(rootFolder, manifestFilename) : manifestFilename; @@ -48,4 +44,4 @@ export const validatePackage = async ( } throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`); -}; +} diff --git a/src/renderer/components/+extensions/attempt-installs/attempt-installs.injectable.ts b/src/renderer/components/+extensions/attempt-installs.injectable.ts similarity index 53% rename from src/renderer/components/+extensions/attempt-installs/attempt-installs.injectable.ts rename to src/renderer/components/+extensions/attempt-installs.injectable.ts index 78939ef264..8f2a066492 100644 --- a/src/renderer/components/+extensions/attempt-installs/attempt-installs.injectable.ts +++ b/src/renderer/components/+extensions/attempt-installs.injectable.ts @@ -3,9 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import getBasenameOfPathInjectable from "../../../../common/path/get-basename.injectable"; -import attemptInstallInjectable from "../attempt-install/attempt-install.injectable"; -import readFileNotifyInjectable from "../read-file-notify/read-file-notify.injectable"; +import attemptInstallInjectable from "./attempt-install/attempt-install.injectable"; +import path from "path"; +import readFileNotifyInjectable from "./read-file-notify/read-file-notify.injectable"; export type AttemptInstalls = (filePaths: string[]) => Promise; @@ -14,16 +14,23 @@ const attemptInstallsInjectable = getInjectable({ instantiate: (di): AttemptInstalls => { const attemptInstall = di.inject(attemptInstallInjectable); - const getBasenameOfPath = di.inject(getBasenameOfPathInjectable); const readFileNotify = di.inject(readFileNotifyInjectable); return async (filePaths) => { - await Promise.allSettled(filePaths.map(filePath => ( - attemptInstall({ - fileName: getBasenameOfPath(filePath), - dataP: readFileNotify(filePath), - }) - ))); + await Promise.allSettled( + filePaths.map(async filePath => { + const data = await readFileNotify(filePath); + + if (!data) { + return; + } + + return attemptInstall({ + fileName: path.basename(filePath), + data, + }); + }), + ); }; }, }); diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index 933dab8a9a..b9dfdb791b 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -32,7 +32,8 @@ import type { InstallExtensionFromInput } from "./install-extension-from-input.i import installExtensionFromInputInjectable from "./install-extension-from-input.injectable"; import installFromSelectFileDialogInjectable from "./install-from-select-file-dialog.injectable"; 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 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"; @@ -44,7 +45,7 @@ interface Dependencies { confirmUninstallExtension: ConfirmUninstallExtension; installExtensionFromInput: InstallExtensionFromInput; installFromSelectFileDialog: () => Promise; - installOnDrop: (files: File[]) => Promise; + installOnDrop: InstallOnDrop; extensionInstallationStateStore: ExtensionInstallationStateStore; } diff --git a/src/renderer/components/+extensions/install-extension-from-input.injectable.tsx b/src/renderer/components/+extensions/install-extension-from-input.injectable.tsx index 7b7d783283..3f9e5b0949 100644 --- a/src/renderer/components/+extensions/install-extension-from-input.injectable.tsx +++ b/src/renderer/components/+extensions/install-extension-from-input.injectable.tsx @@ -4,7 +4,6 @@ */ import React from "react"; import type { ExtendableDisposer } from "../../../common/utils"; -import { downloadFile } from "../../../common/utils"; import { InputValidators } from "../input"; import { getMessageFromError } from "./get-message-from-error/get-message-from-error"; 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 showErrorNotificationInjectable from "../notifications/show-error-notification.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; @@ -29,19 +30,28 @@ const installExtensionFromInputInjectable = getInjectable({ const getBasenameOfPath = di.inject(getBasenameOfPathInjectable); const showErrorNotification = di.inject(showErrorNotificationInjectable); const logger = di.inject(loggerInjectable); + const downloadBinary = di.inject(downloadBinaryInjectable); return async (input) => { let disposer: ExtendableDisposer | undefined = undefined; try { - // fixme: improve error messages for non-tar-file URLs + // fixme: improve error messages for non-tar-file URLs if (InputValidators.isUrl.validate(input)) { - // install via url + // install via url 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); - return await attemptInstall({ fileName, dataP: promise }, disposer); + return await attemptInstall({ fileName, data: result.response }, disposer); } try { @@ -49,8 +59,13 @@ const installExtensionFromInputInjectable = getInjectable({ // install from system path 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) { const extNameCaptures = InputValidators.isExtensionNameInstallRegex.captures(input); diff --git a/src/renderer/components/+extensions/install-from-select-file-dialog.injectable.ts b/src/renderer/components/+extensions/install-from-select-file-dialog.injectable.ts index 1ae77d664a..ae9dddc299 100644 --- a/src/renderer/components/+extensions/install-from-select-file-dialog.injectable.ts +++ b/src/renderer/components/+extensions/install-from-select-file-dialog.injectable.ts @@ -5,7 +5,7 @@ import { getInjectable } from "@ogre-tools/injectable"; import { requestOpenFilePickingDialog } from "../../ipc"; 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"; interface Dependencies { diff --git a/src/renderer/components/+extensions/install-on-drop.injectable.ts b/src/renderer/components/+extensions/install-on-drop.injectable.ts new file mode 100644 index 0000000000..e1779eb223 --- /dev/null +++ b/src/renderer/components/+extensions/install-on-drop.injectable.ts @@ -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; + +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; diff --git a/src/renderer/components/+extensions/install-on-drop/install-on-drop.injectable.ts b/src/renderer/components/+extensions/install-on-drop/install-on-drop.injectable.ts deleted file mode 100644 index 1a693fa7a6..0000000000 --- a/src/renderer/components/+extensions/install-on-drop/install-on-drop.injectable.ts +++ /dev/null @@ -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; diff --git a/src/renderer/components/+extensions/install-on-drop/install-on-drop.tsx b/src/renderer/components/+extensions/install-on-drop/install-on-drop.tsx deleted file mode 100644 index 0a887f7c6f..0000000000 --- a/src/renderer/components/+extensions/install-on-drop/install-on-drop.tsx +++ /dev/null @@ -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; -} - -export const installOnDrop = - ({ attemptInstalls }: Dependencies) => - async (files: File[]) => { - logger.info("Install from D&D"); - await attemptInstalls(files.map(({ path }) => path)); - }; diff --git a/src/renderer/components/+extensions/install.tsx b/src/renderer/components/+extensions/install.tsx index b3d33dc7dd..607ed74aaa 100644 --- a/src/renderer/components/+extensions/install.tsx +++ b/src/renderer/components/+extensions/install.tsx @@ -56,9 +56,7 @@ const NonInjectedInstall: React.FC = ({
& { iconLeft?: IconData; iconRight?: IconData; contentRight?: string | React.ReactNode; // Any component of string goes after iconRight - validators?: InputValidator | InputValidator[]; + validators?: SingleOrMany; blurOnEnter?: boolean; onChange?(value: string, evt: React.ChangeEvent): void; onSubmit?(value: string, evt: React.KeyboardEvent): void;