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: + +![Lens Protocol Link Resolution](images/routing-diag.png) + +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==