diff --git a/docs/extensions/guides/README.md b/docs/extensions/guides/README.md
index 8db209dc82..d288f0cd64 100644
--- a/docs/extensions/guides/README.md
+++ b/docs/extensions/guides/README.md
@@ -21,12 +21,13 @@ Each guide or code sample includes the following:
| [Components](components.md) | |
| [KubeObjectListLayout](kube-object-list-layout.md) | |
| [Working with mobx](working-with-mobx.md) | |
+| [Protocol Handlers](protocol-handlers.md) | |
## Samples
| Sample | APIs |
| ----- | ----- |
-[helloworld](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps |
+[hello-world](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps |
[minikube](https://github.com/lensapp/lens-extension-samples/tree/master/minikube-sample) | LensMainExtension
Store.clusterStore
Store.workspaceStore |
[styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps |
[styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps |
diff --git a/docs/extensions/guides/images/routing-diag.png b/docs/extensions/guides/images/routing-diag.png
new file mode 100644
index 0000000000..9185ce94d8
Binary files /dev/null and b/docs/extensions/guides/images/routing-diag.png differ
diff --git a/docs/extensions/guides/protocol-handlers.md b/docs/extensions/guides/protocol-handlers.md
new file mode 100644
index 0000000000..ff8a727796
--- /dev/null
+++ b/docs/extensions/guides/protocol-handlers.md
@@ -0,0 +1,47 @@
+# Lens Protocol Handlers
+
+Lens has a file association with the `lens://` protocol.
+This means that Lens can be opened by external programs by providing a link that has `lens` as its protocol.
+Lens provides a routing mechanism that extensions can use to register custom handlers.
+
+## Registering A Protocol Handler
+
+The method `onProtocolRequest` exists both on [`LensMainExtension`](extensions/api/classes/lensmainextension/#onprotocolrequest) and on [`LensRendererExtension`](extensions/api/classes/lensrendererextension/#onprotocolrequest).
+This is how, as an extension developer, you can register handlers for your extension.
+The `pathSchema` argument must comply with the [path-to-regexp](https://www.npmjs.com/package/path-to-regexp) package's `compileToRegex` function.
+
+Once you have registered a handler it will be called when a user opens a link on their computer.
+The routing mechanism for extensions is quite straight forward.
+For example consider an extension `example-extension` which is published by the `@mirantis` org.
+If it were to register a handler with `"/display/:type"` as its corresponding link then we would match the following URI like this:
+
+
+
+Once matched, the handler would be called with the following argument (note both `"search"` and `"pathname"` will always be defined):
+
+```json
+{
+ "search": {
+ "text": "Hello"
+ },
+ "pathname": {
+ "type": "notification"
+ }
+}
+```
+
+As the diagram above shows, the search (or query) params are not considered as part of the handler resolution.
+If multiple `pathSchema`'s match a given URI then the most specific handler will be called.
+
+For example consider the following `pathSchema`'s:
+
+1. `"/"`
+1. `"/display"`
+1. `"/display/:type"`
+1. `"/show/:id"`
+
+The URI sub-path `"/display"` would be routed to #2 since it is an exact match.
+On the other hand, the subpath `"/display/notification"` would be routed to #3.
+
+The URI is routed to the most specific matching `pathSchema`.
+This way the `"/"` (root) `pathSchema` acts as a sort of catch all or default route if no other route matches.
diff --git a/electron-builder.yml b/electron-builder.yml
new file mode 100644
index 0000000000..6d53380860
--- /dev/null
+++ b/electron-builder.yml
@@ -0,0 +1,2 @@
+fileAssociations:
+ - lens
diff --git a/integration/__tests__/app.tests.ts b/integration/__tests__/app.tests.ts
index af029a23e9..2329b6fe79 100644
--- a/integration/__tests__/app.tests.ts
+++ b/integration/__tests__/app.tests.ts
@@ -2,6 +2,7 @@ import { Application } from "spectron";
import * as utils from "../helpers/utils";
import { listHelmRepositories } from "../helpers/utils";
import { fail } from "assert";
+import open from "open";
jest.setTimeout(60000);
@@ -28,6 +29,17 @@ describe("Lens integration tests", () => {
await app.client.waitUntilTextExists("h2", "Add Cluster");
});
+ describe("protocol app start", () => {
+ it ("should handle opening lens:// links", async () => {
+ await open("lens://internal/foobar?");
+ await new Promise(resolve => setTimeout(resolve, 5000));
+
+ const logs = await app.client.getMainProcessLogs();
+
+ expect(logs.some(log => log.includes("no handler") || log.includes("lens://internal/foobar?"))).toBe(true);
+ });
+ });
+
describe("preferences page", () => {
it('shows "preferences"', async () => {
const appName: string = process.platform === "darwin" ? "Lens" : "File";
diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts
index f7fbac5830..3020bece44 100644
--- a/integration/helpers/utils.ts
+++ b/integration/helpers/utils.ts
@@ -1,12 +1,35 @@
-import { Application } from "spectron";
+import { AppConstructorOptions, Application } from "spectron";
import * as util from "util";
import { exec } from "child_process";
+import fse from "fs-extra";
+import path from "path";
-const AppPaths: Partial> = {
- "win32": "./dist/win-unpacked/Lens.exe",
- "linux": "./dist/linux-unpacked/lens",
- "darwin": "./dist/mac/Lens.app/Contents/MacOS/Lens",
-};
+interface AppTestingPaths {
+ testingPath: string,
+ libraryPath: string,
+}
+
+function getAppTestingPaths(): AppTestingPaths {
+ switch (process.platform) {
+ case "win32":
+ return {
+ testingPath: "./dist/win-unpacked/Lens.exe",
+ libraryPath: path.join(process.env.APPDATA, "Lens"),
+ };
+ case "linux":
+ return {
+ testingPath: "./dist/linux-unpacked/lens",
+ libraryPath: path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME, ".config"), "Lens"),
+ };
+ case "darwin":
+ return {
+ testingPath: "./dist/mac/Lens.app/Contents/MacOS/Lens",
+ libraryPath: path.join(process.env.HOME, "Library/Application\ Support/Lens"),
+ };
+ default:
+ throw new TypeError(`platform ${process.platform} is not supported`);
+ }
+}
export function itIf(condition: boolean) {
return condition ? it : it.skip;
@@ -16,16 +39,20 @@ export function describeIf(condition: boolean) {
return condition ? describe : describe.skip;
}
-export function setup(): Application {
- return new Application({
- path: AppPaths[process.platform], // path to electron app
+export function setup(): AppConstructorOptions {
+ const appPath = getAppTestingPaths();
+
+ fse.removeSync(appPath.libraryPath); // remove old install config
+
+ return {
+ path: appPath.testingPath,
args: [],
startTimeout: 30000,
waitTimeout: 60000,
env: {
CICD: "true"
}
- });
+ };
}
export const keys = {
diff --git a/package.json b/package.json
index a929c5b59b..ae048485fb 100644
--- a/package.json
+++ b/package.json
@@ -25,7 +25,7 @@
"build:linux": "yarn run compile && electron-builder --linux --dir -c.productName=Lens",
"build:mac": "yarn run compile && electron-builder --mac --dir -c.productName=Lens",
"build:win": "yarn run compile && electron-builder --win --dir -c.productName=Lens",
- "test": "jest --env=jsdom src $@",
+ "test": "scripts/test.sh",
"integration": "jest --runInBand integration",
"dist": "yarn run compile && electron-builder --publish onTag",
"dist:win": "yarn run compile && electron-builder --publish onTag --x64 --ia32",
@@ -160,7 +160,7 @@
"nsis": {
"include": "build/installer.nsh",
"oneClick": false,
- "allowToChangeInstallationDirectory": true
+ "allowToChangeInstallationDirectory": true
},
"publish": [
{
@@ -212,8 +212,10 @@
"mobx-observable-history": "^1.0.3",
"mobx-react": "^6.2.2",
"mock-fs": "^4.12.0",
+ "moment": "^2.26.0",
"node-pty": "^0.9.0",
"npm": "^6.14.8",
+ "open": "^7.3.1",
"openid-client": "^3.15.2",
"p-limit": "^3.1.0",
"path-to-regexp": "^6.1.0",
@@ -230,6 +232,7 @@
"tar": "^6.0.5",
"tcp-port-used": "^1.0.1",
"tempy": "^0.5.0",
+ "url-parse": "^1.4.7",
"uuid": "^8.3.2",
"win-ca": "^3.2.0",
"winston": "^3.2.1",
@@ -285,6 +288,7 @@
"@types/tempy": "^0.3.0",
"@types/terser-webpack-plugin": "^3.0.0",
"@types/universal-analytics": "^0.4.4",
+ "@types/url-parse": "^1.4.3",
"@types/uuid": "^8.3.0",
"@types/webdriverio": "^4.13.0",
"@types/webpack": "^4.41.17",
@@ -321,7 +325,6 @@
"jest-mock-extended": "^1.0.10",
"make-plural": "^6.2.2",
"mini-css-extract-plugin": "^0.9.0",
- "moment": "^2.26.0",
"node-loader": "^0.6.0",
"node-sass": "^4.14.1",
"nodemon": "^2.0.4",
diff --git a/scripts/test.sh b/scripts/test.sh
new file mode 100755
index 0000000000..19c1f71c47
--- /dev/null
+++ b/scripts/test.sh
@@ -0,0 +1 @@
+jest --env=jsdom ${1:-src}
diff --git a/src/common/protocol-handler/error.ts b/src/common/protocol-handler/error.ts
new file mode 100644
index 0000000000..4bd7f057f9
--- /dev/null
+++ b/src/common/protocol-handler/error.ts
@@ -0,0 +1,33 @@
+import Url from "url-parse";
+
+export enum RoutingErrorType {
+ INVALID_PROTOCOL = "invalid-protocol",
+ INVALID_HOST = "invalid-host",
+ INVALID_PATHNAME = "invalid-pathname",
+ NO_HANDLER = "no-handler",
+ NO_EXTENSION_ID = "no-ext-id",
+ MISSING_EXTENSION = "missing-ext",
+}
+
+export class RoutingError extends Error {
+ constructor(public type: RoutingErrorType, public url: Url) {
+ super("routing error");
+ }
+
+ toString(): string {
+ switch (this.type) {
+ case RoutingErrorType.INVALID_HOST:
+ return "invalid host";
+ case RoutingErrorType.INVALID_PROTOCOL:
+ return "invalid protocol";
+ case RoutingErrorType.INVALID_PATHNAME:
+ return "invalid pathname";
+ case RoutingErrorType.NO_HANDLER:
+ return "no handler";
+ case RoutingErrorType.NO_EXTENSION_ID:
+ return "no extension ID";
+ case RoutingErrorType.MISSING_EXTENSION:
+ return "extension not found";
+ }
+ }
+}
diff --git a/src/common/protocol-handler/index.ts b/src/common/protocol-handler/index.ts
new file mode 100644
index 0000000000..887f549507
--- /dev/null
+++ b/src/common/protocol-handler/index.ts
@@ -0,0 +1,2 @@
+export * from "./error";
+export * from "./router";
diff --git a/src/common/protocol-handler/router.ts b/src/common/protocol-handler/router.ts
new file mode 100644
index 0000000000..eaba3e780f
--- /dev/null
+++ b/src/common/protocol-handler/router.ts
@@ -0,0 +1,205 @@
+import { LensExtensionId } from "../../extensions/lens-extension";
+import { hasOwnProperties, hasOwnProperty, Singleton } from "../utils";
+
+const ProtocolHandlerIpcPrefix = "protocol-handler";
+
+export const ProtocolHandlerRegister = `${ProtocolHandlerIpcPrefix}:register`;
+export const ProtocolHandlerDeregister = `${ProtocolHandlerIpcPrefix}:deregister`;
+export const ProtocolHandlerBackChannel = `${ProtocolHandlerIpcPrefix}:back-channel`;
+
+export interface RouteParams {
+ search: Record;
+ pathname: Record;
+}
+
+export type RouteHandler = (params: RouteParams) => void;
+export type FallbackHandler = (name: string) => Promise;
+
+export enum HandlerType {
+ INTERNAL = "internal",
+ EXTENSION = "extension",
+}
+
+interface ExtensionParams {
+ handlerType: HandlerType.EXTENSION,
+ extensionId: string,
+}
+
+interface InternalParams {
+ handlerType: HandlerType.INTERNAL,
+}
+
+type BaseParams = (ExtensionParams | InternalParams);
+
+export type RegisterParams = BaseParams & {
+ handlerId: string,
+ pathSchema: string,
+};
+
+export interface DeregisterParams {
+ extensionId: string,
+}
+
+export type BackChannelParams = BaseParams & {
+ params: RouteParams;
+ handlerId: string,
+};
+
+export abstract class LensProtocolRouter extends Singleton {
+ public static readonly LoggingPrefix = "[PROTOCOL ROUTER]";
+
+ public abstract on(urlSchema: string, handler: RouteHandler): void;
+ public abstract extensionOn(id: LensExtensionId, urlSchema: string, handler: RouteHandler): void;
+ public abstract removeExtensionHandlers(id: LensExtensionId): void;
+}
+
+/**
+ * This function validates that `options` is at least `BaseParams`
+ * @param args a deserialized value
+ */
+function validateBaseParams(args: unknown): args is BaseParams {
+ if (!args || typeof args !== "object") {
+ // it must be an object
+ return false;
+ }
+
+ if (!hasOwnProperty(args, "handlerType")) {
+ return false;
+ }
+
+ const { handlerType } = args;
+
+ if (handlerType === HandlerType.INTERNAL) {
+ // handlerType must either be HandlerType.INTERNAL
+ return true;
+ }
+
+ if (handlerType === HandlerType.EXTENSION) {
+ if (!hasOwnProperty(args, "extensionId")) {
+ return false;
+ }
+
+ // or handlerType must be HandlerType.EXTENSION
+ const { extensionId } = args;
+
+ // but if for an extension then the extensionId is required, must be a stirng, and must be non-empty
+ return Boolean(extensionId && typeof extensionId === "string");
+ }
+
+ // reject all other values of handlerType
+ return false;
+}
+
+/**
+ * This function validates that `options` is at least `RegisterParams`
+ * @param args a deserialized value
+ */
+export function validateRegisterParams(args: unknown): args is RegisterParams {
+ if (!validateBaseParams(args)) {
+ return false;
+ }
+
+ if (!hasOwnProperties(args, "handlerId", "pathSchema")) {
+ return false;
+ }
+
+ if (typeof args.handlerId !== "string" || args.handlerId.length === 0) {
+ // handlerId is required, must be a string, must be non-empty
+ return false;
+ }
+
+ if (typeof args.pathSchema !== "string" || args.pathSchema.length === 0) {
+ // pathSchema is required, must be a string, must be non-empty
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * This function validates that `args` is at least `DeregisterParams`
+ * @param args a deserialized value
+ */
+export function validateDeregisterParams(args: unknown): args is DeregisterParams {
+ if (args == null || typeof args !== "object") {
+ // it must be an object
+ return false;
+ }
+
+ if (!hasOwnProperties(args, "extensionId")) {
+ return false;
+ }
+
+ if (typeof args.extensionId !== "string" || args.extensionId.length === 0) {
+ // ipcChannel is required, must be a string, must be non-empty
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * This function validates that `args` is at least `RouteParams`
+ * @param args a deserialized value
+ */
+export function validateRouteParams(args: unknown): args is RouteParams {
+ if (args == null || typeof args !== "object") {
+ // it must be an object
+ return false;
+ }
+
+ if (!hasOwnProperties(args, "search", "pathname")) {
+ // must have `search` and `pathname` as keys
+ return false;
+ }
+
+ if (args.search == null || typeof args.search !== "object") {
+ // `search` must be a non-null object
+ return false;
+ }
+
+ if (args.pathname == null || typeof args.pathname !== "object") {
+ // `pathname` must be a non-null object
+ return false;
+ }
+
+ for (const key in args.search) {
+ if (!hasOwnProperty(args.search, key) || typeof args.search[key] !== "string") {
+ // all keys in `search` must be owned and their corresponding values must be strings
+ return false;
+ }
+ }
+
+ for (const key in args.pathname) {
+ if (!hasOwnProperty(args.pathname, key) || typeof args.pathname[key] !== "string") {
+ // all keys in `pathname` must be owned and their corresponding values must be strings
+ return false;
+ }
+ }
+
+ return true;
+}
+
+/**
+ * This function validates that `args` is at least `BackChannelParams`
+ * @param args a deserialized value
+ */
+export function validateBackChannelParams(args: unknown): args is BackChannelParams {
+ if (!validateBaseParams(args)) {
+ return false;
+ }
+
+ if (!hasOwnProperties(args, "handlerId", "params")) {
+ return false;
+ }
+
+ if (!validateRouteParams(args.params)) {
+ return false;
+ }
+
+ if (typeof args.handlerId !== "string" || args.handlerId.length === 0) {
+ return false;
+ }
+
+ return true;
+}
diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts
index 942c675f0a..6f26bab2da 100644
--- a/src/common/utils/index.ts
+++ b/src/common/utils/index.ts
@@ -18,3 +18,4 @@ export * from "./openExternal";
export * from "./downloadFile";
export * from "./escapeRegExp";
export * from "./tar";
+export * from "./type-narrowing";
diff --git a/src/common/utils/type-narrowing.ts b/src/common/utils/type-narrowing.ts
new file mode 100644
index 0000000000..6a239c43ee
--- /dev/null
+++ b/src/common/utils/type-narrowing.ts
@@ -0,0 +1,13 @@
+/**
+ * Narrows `val` to include the property `key` (if true is returned)
+ * @param val The object to be tested
+ * @param key The key to test if it is present on the object
+ */
+export function hasOwnProperty(val: V, key: K): val is (V & { [key in K]: unknown }) {
+ // this call syntax is for when `val` was created by `Object.create(null)`
+ return Object.prototype.hasOwnProperty.call(val, key);
+}
+
+export function hasOwnProperties(val: V, ...keys: K[]): val is (V & { [key in K]: unknown}) {
+ return keys.every(key => hasOwnProperty(val, key));
+}
diff --git a/src/extensions/lens-main-extension.ts b/src/extensions/lens-main-extension.ts
index f0e943540d..98dc70e6ec 100644
--- a/src/extensions/lens-main-extension.ts
+++ b/src/extensions/lens-main-extension.ts
@@ -2,6 +2,8 @@ import type { MenuRegistration } from "./registries/menu-registry";
import { LensExtension } from "./lens-extension";
import { WindowManager } from "../main/window-manager";
import { getExtensionPageUrl } from "./registries/page-registry";
+import { RouteHandler } from "../common/protocol-handler";
+import { LensProtocolRouterMain } from "../main/protocol-handler";
export class LensMainExtension extends LensExtension {
appMenus: MenuRegistration[] = [];
@@ -16,4 +18,15 @@ export class LensMainExtension extends LensExtension {
await windowManager.navigate(pageUrl, frameId);
}
+
+ /**
+ * Registers a handler to be called when a `lens://` link is called.
+ * @param pathSchema The path schema for the route.
+ * @param handler The function to call when this route has been matched
+ */
+ onProtocolRequest(pathSchema: string, handler: RouteHandler): void {
+ const lprm = LensProtocolRouterMain.getInstance();
+
+ lprm.extensionOn(this.name, pathSchema, handler);
+ }
}
diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts
index 8b9b132114..1e54bc0297 100644
--- a/src/extensions/lens-renderer-extension.ts
+++ b/src/extensions/lens-renderer-extension.ts
@@ -3,6 +3,8 @@ import type { Cluster } from "../main/cluster";
import { LensExtension } from "./lens-extension";
import { getExtensionPageUrl } from "./registries/page-registry";
import { CommandRegistration } from "./registries/command-registry";
+import { RouteHandler } from "../common/protocol-handler";
+import { LensProtocolRouterRenderer } from "../renderer/protocol-handler/router";
export class LensRendererExtension extends LensExtension {
globalPages: PageRegistration[] = [];
@@ -31,8 +33,24 @@ export class LensRendererExtension extends LensExtension {
/**
* Defines if extension is enabled for a given cluster. Defaults to `true`.
*/
- // eslint-disable-next-line unused-imports/no-unused-vars-ts
async isEnabledForCluster(cluster: Cluster): Promise {
- return true;
+ return (void cluster) || true;
+ }
+
+ /**
+ * Registers a handler to be called when a `lens://` link is called.
+ *
+ * See https://www.npmjs.com/package/path-to-regexp. To use this the link
+ * `lens://extensions///your/defined/path?with=query`
+ * or `lens://extensions//your/defined/path?with=query`
+ * (if this extension is not packaged behind an organization) needs to be
+ * opened.
+ * @param pathSchema The path schema for the route.
+ * @param handler The function to call when this route has been matched
+ */
+ onProtocolRequest(pathSchema: string, handler: RouteHandler): void {
+ const lprm = LensProtocolRouterRenderer.getInstance();
+
+ lprm.extensionOn(this.name, pathSchema, handler);
}
}
diff --git a/src/main/index.ts b/src/main/index.ts
index 2b7817f093..74e31c96ef 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -27,6 +27,9 @@ import type { LensExtensionId } from "../extensions/lens-extension";
import { installDeveloperTools } from "./developer-tools";
import { filesystemProvisionerStore } from "./extension-filesystem";
import { bindBroadcastHandlers } from "../common/ipc";
+import { LensProtocolRouterMain } from "./protocol-handler";
+import URLParse from "url-parse";
+import { RoutingError } from "../common/protocol-handler";
const workingDir = path.join(app.getPath("appData"), appName);
let proxyPort: number;
@@ -35,128 +38,141 @@ let clusterManager: ClusterManager;
let windowManager: WindowManager;
app.setName(appName);
+app.setAsDefaultProtocolClient("lens");
if (!process.env.CICD) {
app.setPath("userData", workingDir);
}
+if (process.env.LENS_DISABLE_GPU) {
+ app.disableHardwareAcceleration();
+}
+
mangleProxyEnv();
if (app.commandLine.getSwitchValue("proxy-server") !== "") {
process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server");
}
-const instanceLock = app.requestSingleInstanceLock();
-
-if (!instanceLock) {
+if (!app.requestSingleInstanceLock()) {
app.exit();
}
-app.on("second-instance", () => {
- windowManager?.ensureMainWindow();
-});
+app
+ .on("second-instance", () => {
+ windowManager?.ensureMainWindow();
+ })
+ .on("ready", async () => {
+ logger.info(`🚀 Starting Lens from "${workingDir}"`);
+ await shellSync();
-if (process.env.LENS_DISABLE_GPU) {
- app.disableHardwareAcceleration();
-}
+ bindBroadcastHandlers();
-app.on("ready", async () => {
- logger.info(`🚀 Starting Lens from "${workingDir}"`);
- await shellSync();
+ powerMonitor.on("shutdown", () => {
+ app.exit();
+ });
- bindBroadcastHandlers();
+ const updater = new AppUpdater();
- powerMonitor.on("shutdown", () => {
- app.exit();
- });
+ updater.start();
- const updater = new AppUpdater();
+ registerFileProtocol("static", __static);
- updater.start();
+ await installDeveloperTools();
- registerFileProtocol("static", __static);
+ // preload
+ await Promise.all([
+ userStore.load(),
+ clusterStore.load(),
+ workspaceStore.load(),
+ extensionsStore.load(),
+ filesystemProvisionerStore.load(),
+ ]);
- await installDeveloperTools();
+ // find free port
+ try {
+ proxyPort = await getFreePort();
+ } catch (error) {
+ logger.error(error);
+ dialog.showErrorBox("Lens Error", "Could not find a free port for the cluster proxy");
+ app.exit();
+ }
- // preload
- await Promise.all([
- userStore.load(),
- clusterStore.load(),
- workspaceStore.load(),
- extensionsStore.load(),
- filesystemProvisionerStore.load(),
- ]);
+ // create cluster manager
+ clusterManager = ClusterManager.getInstance(proxyPort);
- // find free port
- try {
- proxyPort = await getFreePort();
- } catch (error) {
- logger.error(error);
- dialog.showErrorBox("Lens Error", "Could not find a free port for the cluster proxy");
- app.exit();
- }
-
- // create cluster manager
- clusterManager = ClusterManager.getInstance(proxyPort);
-
- // run proxy
- try {
+ // run proxy
+ try {
// eslint-disable-next-line unused-imports/no-unused-vars-ts
- proxyServer = LensProxy.create(proxyPort, clusterManager);
- } catch (error) {
- logger.error(`Could not start proxy (127.0.0:${proxyPort}): ${error?.message}`);
- dialog.showErrorBox("Lens Error", `Could not start proxy (127.0.0:${proxyPort}): ${error?.message || "unknown error"}`);
- app.exit();
- }
+ proxyServer = LensProxy.create(proxyPort, clusterManager);
+ } catch (error) {
+ logger.error(`Could not start proxy (127.0.0:${proxyPort}): ${error?.message}`);
+ dialog.showErrorBox("Lens Error", `Could not start proxy (127.0.0:${proxyPort}): ${error?.message || "unknown error"}`);
+ app.exit();
+ }
- extensionLoader.init();
- extensionDiscovery.init();
- windowManager = WindowManager.getInstance(proxyPort);
+ extensionLoader.init();
+ extensionDiscovery.init();
+ windowManager = WindowManager.getInstance(proxyPort);
- // call after windowManager to see splash earlier
- try {
- const extensions = await extensionDiscovery.load();
+ // call after windowManager to see splash earlier
+ try {
+ const extensions = await extensionDiscovery.load();
- // Start watching after bundled extensions are loaded
- extensionDiscovery.watchExtensions();
+ // Start watching after bundled extensions are loaded
+ extensionDiscovery.watchExtensions();
- // Subscribe to extensions that are copied or deleted to/from the extensions folder
- extensionDiscovery.events.on("add", (extension: InstalledExtension) => {
- extensionLoader.addExtension(extension);
- });
- extensionDiscovery.events.on("remove", (lensExtensionId: LensExtensionId) => {
- extensionLoader.removeExtension(lensExtensionId);
- });
+ // Subscribe to extensions that are copied or deleted to/from the extensions folder
+ extensionDiscovery.events.on("add", (extension: InstalledExtension) => {
+ extensionLoader.addExtension(extension);
+ });
+ extensionDiscovery.events.on("remove", (lensExtensionId: LensExtensionId) => {
+ extensionLoader.removeExtension(lensExtensionId);
+ });
- extensionLoader.initExtensions(extensions);
- } catch (error) {
- dialog.showErrorBox("Lens Error", `Could not load extensions${error?.message ? `: ${error.message}` : ""}`);
- console.error(error);
- console.trace();
- }
+ extensionLoader.initExtensions(extensions);
+ } catch (error) {
+ dialog.showErrorBox("Lens Error", `Could not load extensions${error?.message ? `: ${error.message}` : ""}`);
+ console.error(error);
+ console.trace();
+ }
- setTimeout(() => {
- appEventBus.emit({ name: "service", action: "start" });
- }, 1000);
-});
+ setTimeout(() => {
+ appEventBus.emit({ name: "service", action: "start" });
+ }, 1000);
+ })
+ .on("activate", (event, hasVisibleWindows) => {
+ logger.info("APP:ACTIVATE", { hasVisibleWindows });
-app.on("activate", (event, hasVisibleWindows) => {
- logger.info("APP:ACTIVATE", { hasVisibleWindows });
+ if (!hasVisibleWindows) {
+ windowManager?.initMainWindow(false);
+ }
+ })
+ .on("will-quit", (event) => {
+ // Quit app on Cmd+Q (MacOS)
+ logger.info("APP:QUIT");
+ appEventBus.emit({name: "app", action: "close"});
+ event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.)
+ clusterManager?.stop(); // close cluster connections
- if (!hasVisibleWindows) {
- windowManager?.initMainWindow(false);
- }
-});
+ return; // skip exit to make tray work, to quit go to app's global menu or tray's menu
+ })
+ .on("open-url", async (event, rawUrl) => {
+ // protocol handler for macOS
+ event.preventDefault();
-// Quit app on Cmd+Q (MacOS)
-app.on("will-quit", (event) => {
- logger.info("APP:QUIT");
- appEventBus.emit({name: "app", action: "close"});
- event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.)
- clusterManager?.stop(); // close cluster connections
+ try {
+ const url = new URLParse(rawUrl, true);
- return; // skip exit to make tray work, to quit go to app's global menu or tray's menu
-});
+ await LensProtocolRouterMain.getInstance().route(url);
+ } catch (error) {
+ if (error instanceof RoutingError) {
+ logger.error(`${LensProtocolRouterMain.LoggingPrefix}: ${error}`, { url: error.url });
+ } else {
+ logger.error(`${LensProtocolRouterMain.LoggingPrefix}: ${error}`, { rawUrl });
+ }
+ }
+ });
// Extensions-api runtime exports
export const LensExtensionsApi = {
diff --git a/src/main/protocol-handler/__test__/router.test.ts b/src/main/protocol-handler/__test__/router.test.ts
new file mode 100644
index 0000000000..41fb6b1b06
--- /dev/null
+++ b/src/main/protocol-handler/__test__/router.test.ts
@@ -0,0 +1,155 @@
+import { LensProtocolRouterMain } from "../router";
+import Url from "url-parse";
+import { noop } from "../../../common/utils";
+
+function throwIfDefined(val: any): void {
+ if (val != null) {
+ throw val;
+ }
+}
+
+describe("protocol router tests", () => {
+ let lpr: LensProtocolRouterMain;
+
+ beforeEach(() => {
+ LensProtocolRouterMain.resetInstance();
+ lpr = LensProtocolRouterMain.getInstance();
+ });
+
+ it("should throw on non-lens URLS", async () => {
+ try {
+ expect(await lpr.route(Url("https://google.ca"))).toBeUndefined();
+ } catch (error) {
+ expect(error).toBeInstanceOf(Error);
+ }
+ });
+
+ it("should throw when host not internal or extension", async () => {
+ try {
+ expect(await lpr.route(Url("lens://foobar"))).toBeUndefined();
+ } catch (error) {
+ expect(error).toBeInstanceOf(Error);
+ }
+ });
+
+ it("should not throw when has valid host", async () => {
+ lpr.on("/", noop);
+ lpr.extensionOn("@mirantis/minikube", "/", noop);
+
+ try {
+ expect(await lpr.route(Url("lens://internal"))).toBeUndefined();
+ } catch (error) {
+ expect(throwIfDefined(error)).not.toThrow();
+
+ }
+
+ try {
+ expect(await lpr.route(Url("lens://extension/@mirantis/minikube"))).toBeUndefined();
+ } catch (error) {
+ expect(throwIfDefined(error)).not.toThrow();
+
+ }
+ });
+
+ it("should call handler if matches", async () => {
+ let called = false;
+
+ lpr.on("/page", () => { called = true; });
+
+ try {
+ expect(await lpr.route(Url("lens://internal/page"))).toBeUndefined();
+ } catch (error) {
+ expect(throwIfDefined(error)).not.toThrow();
+
+ }
+
+ expect(called).toBe(true);
+ });
+
+ it("should call most exact handler", async () => {
+ let called: any = 0;
+
+ lpr.on("/page", () => { called = 1; });
+ lpr.on("/page/:id", params => { called = params.pathname.id; });
+
+ try {
+ expect(await lpr.route(Url("lens://internal/page/foo"))).toBeUndefined();
+ } catch (error) {
+ expect(throwIfDefined(error)).not.toThrow();
+
+ }
+
+ expect(called).toBe("foo");
+ });
+
+ it("should call most exact handler for an extension", async () => {
+ let called: any = 0;
+
+ lpr.extensionOn("@foobar/icecream", "/page", () => { called = 1; });
+ lpr.extensionOn("@foobar/icecream", "/page/:id", params => { called = params.pathname.id; });
+
+ try {
+ expect(await lpr.route(Url("lens://extension/@foobar/icecream/page/foob"))).toBeUndefined();
+ } catch (error) {
+ expect(throwIfDefined(error)).not.toThrow();
+
+ }
+
+ expect(called).toBe("foob");
+ });
+
+ it("should work with non-org extensions", async () => {
+ let called: any = 0;
+
+ lpr.extensionOn("icecream", "/page", () => { called = 1; });
+ lpr.extensionOn("@foobar/icecream", "/page/:id", params => { called = params.pathname.id; });
+
+ try {
+ expect(await lpr.route(Url("lens://extension/icecream/page"))).toBeUndefined();
+ } catch (error) {
+ expect(throwIfDefined(error)).not.toThrow();
+ }
+
+ expect(called).toBe(1);
+ });
+
+ it("should throw if urlSchema is invalid", () => {
+ expect(() => lpr.on("/:@", noop)).toThrowError();
+ expect(() => lpr.extensionOn("@foobar/icecream", "/page/:@", noop)).toThrowError();
+ });
+
+ it("should call most exact handler with 3 found handlers", async () => {
+ let called: any = 0;
+
+ lpr.on("/", () => { called = 2; });
+ lpr.on("/page", () => { called = 1; });
+ lpr.on("/page/foo", () => { called = 3; });
+ lpr.on("/page/bar", () => { called = 4; });
+
+ try {
+ expect(await lpr.route(Url("lens://internal/page/foo/bar/bat"))).toBeUndefined();
+ } catch (error) {
+ expect(throwIfDefined(error)).not.toThrow();
+
+ }
+
+ expect(called).toBe(3);
+ });
+
+ it("should call most exact handler with 2 found handlers", async () => {
+ let called: any = 0;
+
+ lpr.on("/", () => { called = 2; });
+ lpr.on("/page", () => { called = 1; });
+ lpr.on("/page/bar", () => { called = 4; });
+
+ try {
+ expect(await lpr.route(Url("lens://internal/page/foo/bar/bat"))).toBeUndefined();
+ } catch (error) {
+ expect(throwIfDefined(error)).not.toThrow();
+
+ }
+
+ expect(called).toBe(1);
+ });
+});
diff --git a/src/main/protocol-handler/index.ts b/src/main/protocol-handler/index.ts
new file mode 100644
index 0000000000..9ca8201129
--- /dev/null
+++ b/src/main/protocol-handler/index.ts
@@ -0,0 +1 @@
+export * from "./router";
diff --git a/src/main/protocol-handler/router.ts b/src/main/protocol-handler/router.ts
new file mode 100644
index 0000000000..91635c00bd
--- /dev/null
+++ b/src/main/protocol-handler/router.ts
@@ -0,0 +1,223 @@
+import Url from "url-parse";
+import { match, matchPath } from "react-router";
+import { pathToRegexp } from "path-to-regexp";
+import logger from "../logger";
+import { countBy } from "lodash";
+import * as proto from "../../common/protocol-handler";
+import { LensExtensionId } from "../../extensions/lens-extension";
+import { ipcMain } from "electron";
+import { WindowManager } from "../window-manager";
+
+const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH";
+const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH";
+
+// IPC channel for protocol actions. Main broadcasts the open-url events to this channel.
+export const lensProtocolChannel = "protocol-handler";
+
+interface ExtensionUrlMatch {
+ [EXTENSION_PUBLISHER_MATCH]: string;
+ [EXTENSION_NAME_MATCH]: string;
+}
+
+/**
+ * a comparison function for `array.sort(...)`. Sort order should be most path
+ * parts to least path parts.
+ * @param a the left side to compare
+ * @param b the right side to compare
+ */
+function compareMatches(a: match, b: match): number {
+ if (a.path === "/") {
+ return 1;
+ }
+
+ if (b.path === "/") {
+ return -1;
+ }
+
+ return countBy(b.path)["/"] - countBy(a.path)["/"];
+}
+
+/**
+ * Generate a new function that sends an IPC message to the renderer on the given channel
+ * @param channel the IPC channel to send the notification back to the renderer
+ */
+function produceNotifyRenderer(handlerId: string): proto.RouteHandler {
+ return function (params: proto.RouteParams): void {
+ WindowManager.getInstance().sendToView({
+ channel: proto.ProtocolHandlerBackChannel,
+ data: [handlerId, params],
+ });
+ };
+}
+
+/**
+ *
+ * @param event data about the source of the IPC event
+ * @param ipcArgs the deserialized arguments passed to the IPC send method
+ */
+function registerIpcHandler(event: Electron.IpcMainEvent, ...ipcArgs: unknown[]): void {
+ const [args] = ipcArgs;
+
+ if(!proto.validateRegisterParams(args)) {
+ return void logger.warn(`${proto.LensProtocolRouter.LoggingPrefix}: ipc call to "${proto.ProtocolHandlerRegister}" invalid arguments`, { ipcArgs });
+ }
+
+ const lprm = LensProtocolRouterMain.getInstance();
+
+ switch (args.handlerType) {
+ case proto.HandlerType.INTERNAL:
+ return lprm.on(args.pathSchema, produceNotifyRenderer(args.handlerId));
+ case proto.HandlerType.EXTENSION:
+ return lprm.extensionOn(args.extensionId, args.pathSchema, produceNotifyRenderer(args.handlerId));
+ }
+}
+
+function deregisterIpcHandler(event: Electron.IpcMainEvent, ...ipcArgs: unknown[]): void {
+ const [args] = ipcArgs;
+
+ if(!proto.validateDeregisterParams(args)) {
+ return void logger.warn(`${proto.LensProtocolRouter.LoggingPrefix}: ipc call to "${proto.ProtocolHandlerDeregister}" invalid arguments`, { ipcArgs });
+ }
+
+ LensProtocolRouterMain.getInstance().removeExtensionHandlers(args.extensionId);
+}
+
+export class LensProtocolRouterMain extends proto.LensProtocolRouter {
+ private extentionRoutes = new Map>();
+ private internalRoutes = new Map();
+ private missingExtensionHandlers: proto.FallbackHandler[] = [];
+
+ private static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`;
+
+ /**
+ * route the given URL to
+ */
+ public async route(url: Url): Promise {
+ if (url.protocol.toLowerCase() !== "lens:") {
+ throw new proto.RoutingError(proto.RoutingErrorType.INVALID_PROTOCOL, url);
+ }
+
+ switch (url.host) {
+ case "internal":
+ return this._route(this.internalRoutes, url);
+ case "extension":
+ return this._routeToExtension(url);
+ default:
+ throw new proto.RoutingError(proto.RoutingErrorType.INVALID_HOST, url);
+
+ }
+ }
+
+ public registerIpcHandlers(): void {
+ ipcMain
+ .on(proto.ProtocolHandlerRegister, registerIpcHandler)
+ .on(proto.ProtocolHandlerDeregister, deregisterIpcHandler);
+ }
+
+ private async _routeToExtension(url: Url) {
+ const match = matchPath(url.pathname, LensProtocolRouterMain.ExtensionUrlSchema);
+
+ if (!match) {
+ throw new proto.RoutingError(proto.RoutingErrorType.NO_EXTENSION_ID, url);
+ }
+
+ const { [EXTENSION_PUBLISHER_MATCH]: publisher, [EXTENSION_NAME_MATCH]: partialName } = match.params;
+ const name = [publisher, partialName].filter(Boolean).join("/");
+
+ logger.info(`${proto.LensProtocolRouter.LoggingPrefix}: Extension ${name} matched`);
+
+ let routes = this.extentionRoutes.get(name);
+
+ if (!routes) {
+ if (this.missingExtensionHandlers.length === 0) {
+ throw new proto.RoutingError(proto.RoutingErrorType.MISSING_EXTENSION, url);
+ }
+
+ foundMissingHandler: {
+ for (const missingExtensionHandler of this.missingExtensionHandlers) {
+ if (await missingExtensionHandler(name)) {
+ break foundMissingHandler;
+ }
+ }
+
+ // if none of the handlers resolved to `true` then we have finished the loop
+ return;
+ }
+
+ routes = this.extentionRoutes.get(name);
+
+ if (!routes) {
+ logger.info(`${proto.LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but has no routes`);
+
+ return;
+ }
+ }
+
+ this._route(routes, url, true);
+ }
+
+ private _route(routes: Map, url: Url, matchExtension = false): void {
+ const matches = Array.from(routes.entries())
+ .map(([schema, handler]): [match>, proto.RouteHandler] => {
+ if (matchExtension) {
+ schema = `${LensProtocolRouterMain.ExtensionUrlSchema}/${schema}`.replace(/\/?\//g, "/");
+ }
+
+ return [matchPath(url.pathname, { path: schema }), handler];
+ })
+ .filter(([match]) => match);
+ // prefer an exact match, but if not pick the first route registered
+ const route = matches.find(([match]) => match.isExact)
+ ?? matches.sort(([a], [b]) => compareMatches(a, b))[0];
+
+ if (!route) {
+ throw new proto.RoutingError(proto.RoutingErrorType.NO_HANDLER, url);
+ }
+
+ logger.info(`${proto.LensProtocolRouter.LoggingPrefix}: routing ${url.toString()}`);
+
+ const [match, handler] = route;
+
+ delete match.params[EXTENSION_NAME_MATCH];
+ delete match.params[EXTENSION_PUBLISHER_MATCH];
+ handler({
+ pathname: match.params,
+ search: url.query,
+ });
+ }
+
+ public on(urlSchema: string, handler: proto.RouteHandler): void {
+ pathToRegexp(urlSchema); // verify now that the schema is valid
+ logger.info(`${proto.LensProtocolRouter.LoggingPrefix}: internal registering ${urlSchema}`);
+ this.internalRoutes.set(urlSchema, handler);
+ }
+
+ public extensionOn(id: LensExtensionId, urlSchema: string, handler: proto.RouteHandler): void {
+ logger.info(`${proto.LensProtocolRouter.LoggingPrefix}: extension ${id} registering ${urlSchema}`);
+ pathToRegexp(urlSchema); // verify now that the schema is valid
+
+ if (!this.extentionRoutes.has(id)) {
+ this.extentionRoutes.set(id, new Map());
+ }
+
+ if (urlSchema.includes(`:${EXTENSION_NAME_MATCH}`) || urlSchema.includes(`:${EXTENSION_PUBLISHER_MATCH}`)) {
+ throw new TypeError("Invalid url path schema");
+ }
+
+ this.extentionRoutes.get(id).set(urlSchema, handler);
+ }
+
+ public removeExtensionHandlers(id: LensExtensionId): void {
+ this.extentionRoutes.delete(id);
+ }
+
+ /**
+ * onMissingExtension registers a handler for when an extension is missing.
+ * These will be called in the order registered until one of them results in
+ * `true`.
+ * @param handler If the called handler resolves to true then the routes will be tried again
+ */
+ public onMissingExtension(handler: proto.FallbackHandler): void {
+ this.missingExtensionHandlers.push(handler);
+ }
+}
diff --git a/src/renderer/protocol-handler/index.ts b/src/renderer/protocol-handler/index.ts
new file mode 100644
index 0000000000..d18015da88
--- /dev/null
+++ b/src/renderer/protocol-handler/index.ts
@@ -0,0 +1 @@
+export * from "./router.ts";
diff --git a/src/renderer/protocol-handler/router.ts b/src/renderer/protocol-handler/router.ts
new file mode 100644
index 0000000000..310a30069b
--- /dev/null
+++ b/src/renderer/protocol-handler/router.ts
@@ -0,0 +1,92 @@
+import { ipcRenderer } from "electron";
+import * as proto from "../../common/protocol-handler";
+import { autobind } from "../utils";
+import * as uuid from "uuid";
+import logger from "../../main/logger";
+export class LensProtocolRouterRenderer extends proto.LensProtocolRouter {
+ // Map between extension IDs and a Map betweeen generated UUIDs and the handlers
+ private extensionHandlers = new Map>();
+ // Map between generated UUIDs and the handlers
+ private internalHandlers = new Map();
+
+ /**
+ * This function is needed to be called early on in the renderers lifetime.
+ */
+ public init(): void {
+ ipcRenderer.on(proto.ProtocolHandlerBackChannel, this.onBackChannelNotify);
+ }
+
+ @autobind()
+ private onBackChannelNotify(event: Electron.IpcRendererEvent, ...ipcArgs: unknown[]): void {
+ const [args] = ipcArgs;
+
+ if (!proto.validateBackChannelParams(args)) {
+ return void logger.warn(`${proto.LensProtocolRouter.LoggingPrefix}: ipc call to "${proto.ProtocolHandlerBackChannel}" invalid arguments`, { ipcArgs });
+ }
+
+ switch (args.handlerType) {
+ case proto.HandlerType.INTERNAL: {
+ const { handlerId, params } = args;
+ const handler = this.internalHandlers.get(handlerId);
+
+ if (!handler) {
+ return void logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ipc call to "${proto.ProtocolHandlerBackChannel}" unknown handlerId`, { args });
+ }
+
+ return handler(params);
+ }
+
+ case proto.HandlerType.EXTENSION: {
+ const { handlerId, params, extensionId } = args;
+ const handler = this.extensionHandlers.get(handlerId)?.get(extensionId);
+
+ if (!handler) {
+ return void logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ipc call to "${proto.ProtocolHandlerBackChannel}" unknown handlerId or unknown extensionId`, { args });
+ }
+
+ return handler(params);
+ }
+
+ }
+ }
+
+ public on(pathSchema: string, handler: proto.RouteHandler): void {
+ const handlerId = uuid.v4();
+ const args: proto.RegisterParams = {
+ handlerType: proto.HandlerType.INTERNAL,
+ pathSchema,
+ handlerId,
+ };
+
+ this.internalHandlers.set(handlerId, handler);
+
+ ipcRenderer.send(proto.ProtocolHandlerRegister, args);
+ }
+
+ public extensionOn(extensionId: string, pathSchema: string, handler: proto.RouteHandler): void {
+ const handlerId = uuid.v4();
+
+ const args: proto.RegisterParams = {
+ handlerType: proto.HandlerType.EXTENSION,
+ extensionId,
+ pathSchema,
+ handlerId,
+ };
+
+ this.extensionHandlers
+ .set(extensionId, this.extensionHandlers.get(extensionId) ?? new Map())
+ .get(extensionId)
+ .set(handlerId, handler);
+
+ ipcRenderer.send(proto.ProtocolHandlerRegister, args);
+ }
+
+ public removeExtensionHandlers(extensionId: string): void {
+ const args: proto.DeregisterParams = {
+ extensionId,
+ };
+
+ ipcRenderer.send(proto.ProtocolHandlerDeregister, args);
+ this.extensionHandlers.delete(extensionId);
+ }
+}
diff --git a/src/renderer/utils/index.ts b/src/renderer/utils/index.ts
index 517fd8f359..e546f94154 100755
--- a/src/renderer/utils/index.ts
+++ b/src/renderer/utils/index.ts
@@ -1,7 +1,5 @@
// Common usage utils & helpers
-export const isElectron = !!navigator.userAgent.match(/Electron/);
-
export * from "../../common/utils";
export * from "./cssVar";
diff --git a/yarn.lock b/yarn.lock
index dd9ec0c1c9..23252ebf9c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1756,6 +1756,11 @@
resolved "https://registry.yarnpkg.com/@types/universal-analytics/-/universal-analytics-0.4.4.tgz#496a52b92b599a0112bec7c12414062de6ea8449"
integrity sha512-9g3F0SGxVr4UDd6y07bWtFnkpSSX1Ake7U7AGHgSFrwM6pF53/fV85bfxT2JLWS/3sjLCcyzoYzQlCxpkVo7wA==
+"@types/url-parse@^1.4.3":
+ version "1.4.3"
+ resolved "https://registry.yarnpkg.com/@types/url-parse/-/url-parse-1.4.3.tgz#fba49d90f834951cb000a674efee3d6f20968329"
+ integrity sha512-4kHAkbV/OfW2kb5BLVUuUMoumB3CP8rHqlw48aHvFy5tf9ER0AfOonBlX29l/DD68G70DmyhRlSYfQPSYpC5Vw==
+
"@types/uuid@^8.3.0":
version "8.3.0"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f"
@@ -10081,6 +10086,14 @@ onetime@^5.1.0:
dependencies:
mimic-fn "^2.1.0"
+open@^7.3.1:
+ version "7.3.1"
+ resolved "https://registry.yarnpkg.com/open/-/open-7.3.1.tgz#111119cb919ca1acd988f49685c4fdd0f4755356"
+ integrity sha512-f2wt9DCBKKjlFbjzGb8MOAW8LH8F0mrs1zc7KTjAJ9PZNQbfenzWbNP1VZJvw6ICMG9r14Ah6yfwPn7T7i646A==
+ dependencies:
+ is-docker "^2.0.0"
+ is-wsl "^2.1.1"
+
opener@^1.5.1:
version "1.5.2"
resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598"
@@ -13743,7 +13756,7 @@ url-parse-lax@^3.0.0:
dependencies:
prepend-http "^2.0.0"
-url-parse@^1.4.3:
+url-parse@^1.4.3, url-parse@^1.4.7:
version "1.4.7"
resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278"
integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg==