From 1470103fd486a269ad9c4fcccd3373275dba83b9 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Thu, 25 Feb 2021 09:32:40 -0500 Subject: [PATCH] Add lens:// protocol handling with a routing mechanism (#1949) - Add lens:// protocol handling with a routing mechanism - document the methods in an extension guide - remove handlers when an extension is deactivated or removed - make sure that the found extension when routing a request is currently enabled (as a backup) - added documentation about the above behaviour to the guide - tweaked the naming convention so that it is clearer that the router uses extension names as not IDs (which currently are folder paths) - Convert the extension API to use an array for registering handlers - switch design to execute both main and renderer handlers simultaneously, without any overlap checking - change open to be a dev dep - improve docs, export types for extensions, skip integration tests - switch to event emitting renderer being ready - Add logging and fix renderer:loaded send to main Signed-off-by: Sebastian Malton --- Makefile | 15 +- docs/extensions/guides/README.md | 3 +- .../extensions/guides/images/routing-diag.png | Bin 0 -> 25838 bytes docs/extensions/guides/protocol-handlers.md | 83 ++++++ docs/getting-started/README.md | 23 +- package.json | 17 +- scripts/test.sh | 1 + src/common/ipc/ipc.ts | 2 +- src/common/protocol-handler/error.ts | 36 +++ src/common/protocol-handler/index.ts | 2 + src/common/protocol-handler/router.ts | 218 +++++++++++++++ src/common/utils/delay.ts | 17 +- src/common/utils/index.ts | 2 +- src/common/utils/type-narrowing.ts | 13 + src/extensions/extension-loader.ts | 27 +- src/extensions/extensions-store.ts | 2 +- src/extensions/interfaces/registrations.ts | 1 + src/extensions/lens-extension.ts | 3 + src/extensions/lens-renderer-extension.ts | 3 +- .../registries/protocol-handler-registry.ts | 44 +++ src/main/app-updater.ts | 5 +- src/main/index.ts | 69 ++++- .../protocol-handler/__test__/router.test.ts | 259 ++++++++++++++++++ src/main/protocol-handler/index.ts | 1 + src/main/protocol-handler/router.ts | 112 ++++++++ src/main/window-manager.ts | 6 +- src/renderer/lens-app.tsx | 4 + src/renderer/navigation/index.ts | 2 + src/renderer/navigation/protocol-handlers.ts | 10 + src/renderer/protocol-handler/index.ts | 1 + src/renderer/protocol-handler/router.ts | 40 +++ src/renderer/utils/index.ts | 2 - yarn.lock | 27 +- 33 files changed, 1010 insertions(+), 40 deletions(-) create mode 100644 docs/extensions/guides/images/routing-diag.png create mode 100644 docs/extensions/guides/protocol-handlers.md create mode 100755 scripts/test.sh create mode 100644 src/common/protocol-handler/error.ts create mode 100644 src/common/protocol-handler/index.ts create mode 100644 src/common/protocol-handler/router.ts create mode 100644 src/common/utils/type-narrowing.ts create mode 100644 src/extensions/registries/protocol-handler-registry.ts create mode 100644 src/main/protocol-handler/__test__/router.test.ts create mode 100644 src/main/protocol-handler/index.ts create mode 100644 src/main/protocol-handler/router.ts create mode 100644 src/renderer/navigation/protocol-handlers.ts create mode 100644 src/renderer/protocol-handler/index.ts create mode 100644 src/renderer/protocol-handler/router.ts diff --git a/Makefile b/Makefile index 000682e039..ea7c84d018 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ else DETECTED_OS := $(shell uname) endif -binaries/client: +binaries/client: node_modules yarn download-bins node_modules: yarn.lock @@ -37,17 +37,24 @@ test: binaries/client yarn test .PHONY: integration-linux -integration-linux: build-extension-types build-extensions +integration-linux: binaries/client build-extension-types build-extensions +# ifdef XDF_CONFIG_HOME +# rm -rf ${XDG_CONFIG_HOME}/.config/Lens +# else +# rm -rf ${HOME}/.config/Lens +# endif yarn build:linux yarn integration .PHONY: integration-mac -integration-mac: build-extension-types build-extensions +integration-mac: binaries/client build-extension-types build-extensions + # rm ${HOME}/Library/Application\ Support/Lens yarn build:mac yarn integration .PHONY: integration-win -integration-win: build-extension-types build-extensions +integration-win: binaries/client build-extension-types build-extensions + # rm %APPDATA%/Lens yarn build:win yarn integration 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 0000000000000000000000000000000000000000..9185ce94d8bfa105e27df0b647f3c43382fb7aed GIT binary patch literal 25838 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

d#<2>jMen+c&Kq#EHyv-PUGDM_etgI zIR0w98Nm354>!uGH?OsXXOb|F!ia}7+f#0kcIx6AORFoIO3yU@YSg|G|L{ z_qIcs1P$yeW>SI@M>InBd=9;+pr|S2XA`n<5Fv1&YSTrqT^IitrR*R38VSqEX_$c% z1p9h>k0R6{ehWcV#SkHZHd%D_po2eZpJ+rk@<7SAV?r`DERoY^I6z&U3E};!vojT6 zKzJ)%uxwjxFnSRGInqt>oD2+!@5ZVCW^x) zU{L3N4a7-%ww&HmK4+S<0JkjSj^ABc3I;=z3c}18+f{vzN3AJi|46tOrTLIPDF zf*%CHxMTnHA|aW%xw%2222-`1xD+}X1R4p*N4Ko*iLV{TU6Md5Jq#YTC5C!HK*E_w znwXf_eW>&jOjYk-C{8Y(ho2gzAt^jW*}`wH-C|-qa2%5e#$tp!;#c1aP#Dku8q*#D z2>?EEva?s95XWGyfAO4zpvl1qA-eN3Y7_Wn7dUcYG)Y6X^TVKLn5h(ta*0Zt?NA

r@7sC59Q|<%IMu9(H!#{z$E@BpDqG5-#4l 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 literal 0 HcmV?d00001 diff --git a/docs/extensions/guides/protocol-handlers.md b/docs/extensions/guides/protocol-handlers.md new file mode 100644 index 0000000000..8e13c8436a --- /dev/null +++ b/docs/extensions/guides/protocol-handlers.md @@ -0,0 +1,83 @@ +# 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 field `protocolHandlers` exists both on [`LensMainExtension`](extensions/api/classes/lensmainextension/#protocolhandlers) and on [`LensRendererExtension`](extensions/api/classes/lensrendererextension/#protocolhandlers). +This field will be iterated through every time a `lens://` request gets sent to the application. +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. +Handlers will be run in both `main` and `renderer` in parallel with no synchronization between the two processes. +Furthermore, both `main` and `renderer` are routed separately. +In other words, which handler is selected in either process is independent from the list of possible handlers in the other. + +Example of registering a handler: + +```typescript +import { LensMainExtension, Interface } from "@k8slens/extensions"; + +function rootHandler(params: Iterface.ProtocolRouteParams) { + console.log("routed to ExampleExtension", params); +} + +export default class ExampleExtensionMain extends LensMainExtension { + protocolHandlers = [ + pathSchema: "/", + handler: rootHandler, + ] +} +``` + +For testing the routing of URIs the `open` (on macOS) or `xdg-open` (on most linux) CLI utilities can be used. +For the above handler, the following URI would be always routed to it: + +``` +open lens://extension/example-extension/ +``` + +## Deregistering A Protocol Handler + +All that is needed to deregister a handler is to remove it from the array of handlers. + +## Routing Algorithm + +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 the URI had instead been `lens://extension/@mirantis/example-extension/display/notification/green` then a third (and optional) field will have the rest of the path. +The `tail` field would be filled with `"/green"`. +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/docs/getting-started/README.md b/docs/getting-started/README.md index bfb990378d..ed6537bd76 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -28,6 +28,28 @@ Review the [System Requirements](../supporting/requirements.md) to check if your See the [Download Lens](https://github.com/lensapp/lens/releases) page for a complete list of available installation options. +After installing Lens manually (not using a package manager file such as `.deb` or `.rpm`) the following will need to be done to allow protocol handling. +This assumes that your linux distribution uses `xdg-open` and the `xdg-*` suite of programs for determining which application can handle custom URIs. + +1. Create a file called `lens.desktop` in either `~/.local/share/applications/` or `/usr/share/applications` (if you have permissions and are installing Lens for all users). +1. That file should have the following contents, with `` being the absolute path to where you have installed the unpacked `Lens` executable: + ``` + [Desktop Entry] + Name=Lens + Exec= %U + Terminal=false + Type=Application + Icon=lens + StartupWMClass=Lens + Comment=Lens - The Kubernetes IDE + MimeType=x-scheme-handler/lens; + Categories=Network; + ``` +1. Then run the following command: + ``` + xdg-settings set default-url-scheme-handler lens lens.desktop + ``` +1. If that succeeds (exits with code `0`) then your Lens install should be set up to handle `lens://` URIs. ### Snap @@ -52,4 +74,3 @@ To stay current with the Lens features, you can review the [release notes](https - [Add clusters](../clusters/adding-clusters.md) - [Watch introductory videos](./introductory-videos.md) - diff --git a/package.json b/package.json index e575e48520..3639366298 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", @@ -170,7 +170,14 @@ "repo": "lens", "owner": "lensapp" } - ] + ], + "protocols": { + "name": "Lens Protocol Handler", + "schemes": [ + "lens" + ], + "role": "Viewer" + } }, "lens": { "extensions": [ @@ -187,6 +194,7 @@ "@hapi/call": "^8.0.0", "@hapi/subtext": "^7.0.3", "@kubernetes/client-node": "^0.12.0", + "abort-controller": "^3.0.0", "array-move": "^3.0.0", "await-lock": "^2.1.0", "byline": "^5.0.0", @@ -213,6 +221,7 @@ "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", "openid-client": "^3.15.2", @@ -232,6 +241,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", @@ -289,6 +299,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", @@ -325,10 +336,10 @@ "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", + "open": "^7.3.1", "patch-package": "^6.2.2", "postinstall-postinstall": "^2.1.0", "prettier": "^2.2.0", 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/ipc/ipc.ts b/src/common/ipc/ipc.ts index 48b0b89153..b104b31f4a 100644 --- a/src/common/ipc/ipc.ts +++ b/src/common/ipc/ipc.ts @@ -47,7 +47,7 @@ export async function broadcastMessage(channel: string, ...args: any[]) { view.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args); } } catch (error) { - logger.error("[IPC]: failed to send IPC message", { error }); + logger.error("[IPC]: failed to send IPC message", { error: String(error) }); } } } diff --git a/src/common/protocol-handler/error.ts b/src/common/protocol-handler/error.ts new file mode 100644 index 0000000000..ebe7adccd7 --- /dev/null +++ b/src/common/protocol-handler/error.ts @@ -0,0 +1,36 @@ +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 { + /** + * Will be set if the routing error originated in an extension route table + */ + public extensionName?: string; + + 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_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..b18eb84368 --- /dev/null +++ b/src/common/protocol-handler/router.ts @@ -0,0 +1,218 @@ +import { match, matchPath } from "react-router"; +import { countBy } from "lodash"; +import { Singleton } from "../utils"; +import { pathToRegexp } from "path-to-regexp"; +import logger from "../../main/logger"; +import Url from "url-parse"; +import { RoutingError, RoutingErrorType } from "./error"; +import { extensionsStore } from "../../extensions/extensions-store"; +import { extensionLoader } from "../../extensions/extension-loader"; +import { LensExtension } from "../../extensions/lens-extension"; +import { RouteHandler, RouteParams } from "../../extensions/registries/protocol-handler-registry"; + +// IPC channel for protocol actions. Main broadcasts the open-url events to this channel. +export const ProtocolHandlerIpcPrefix = "protocol-handler"; + +export const ProtocolHandlerInternal = `${ProtocolHandlerIpcPrefix}:internal`; +export const ProtocolHandlerExtension= `${ProtocolHandlerIpcPrefix}:extension`; + +/** + * These two names are long and cumbersome by design so as to decrease the chances + * of an extension using the same names. + * + * Though under the current (2021/01/18) implementation, these are never matched + * against in the final matching so their names are less of a concern. + */ +const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH"; +const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH"; + +export abstract class LensProtocolRouter extends Singleton { + // Map between path schemas and the handlers + protected internalRoutes = new Map(); + + public static readonly LoggingPrefix = "[PROTOCOL ROUTER]"; + + protected static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`; + + /** + * + * @param url the parsed URL that initiated the `lens://` protocol + */ + protected _routeToInternal(url: Url): void { + this._route(Array.from(this.internalRoutes.entries()), url); + } + + /** + * match against all matched URIs, returning either the first exact match or + * the most specific match if none are exact. + * @param routes the array of path schemas, handler pairs to match against + * @param url the url (in its current state) + */ + protected _findMatchingRoute(routes: [string, RouteHandler][], url: Url): null | [match>, RouteHandler] { + const matches: [match>, RouteHandler][] = []; + + for (const [schema, handler] of routes) { + const match = matchPath(url.pathname, { path: schema }); + + if (!match) { + continue; + } + + // prefer an exact match + if (match.isExact) { + return [match, handler]; + } + + matches.push([match, handler]); + } + + // if no exact match pick the one that is the most specific + return matches.sort(([a], [b]) => compareMatches(a, b))[0] ?? null; + } + + /** + * find the most specific matching handler and call it + * @param routes the array of (path schemas, handler) paris to match against + * @param url the url (in its current state) + */ + protected _route(routes: [string, RouteHandler][], url: Url, extensionName?: string): void { + const route = this._findMatchingRoute(routes, url); + + if (!route) { + const data: Record = { url: url.toString() }; + + if (extensionName) { + data.extensionName = extensionName; + } + + return void logger.info(`${LensProtocolRouter.LoggingPrefix}: No handler found`, data); + } + + const [match, handler] = route; + + const params: RouteParams = { + pathname: match.params, + search: url.query, + }; + + if (!match.isExact) { + params.tail = url.pathname.slice(match.url.length); + } + + handler(params); + } + + /** + * Tries to find the matching LensExtension instance + * + * Note: this needs to be async so that `main`'s overloaded version can also be async + * @param url the protocol request URI that was "open"-ed + * @returns either the found name or the instance of `LensExtension` + */ + protected async _findMatchingExtensionByName(url: Url): Promise { + interface ExtensionUrlMatch { + [EXTENSION_PUBLISHER_MATCH]: string; + [EXTENSION_NAME_MATCH]: string; + } + + const match = matchPath(url.pathname, LensProtocolRouter.ExtensionUrlSchema); + + if (!match) { + throw new RoutingError(RoutingErrorType.NO_EXTENSION_ID, url); + } + + const { [EXTENSION_PUBLISHER_MATCH]: publisher, [EXTENSION_NAME_MATCH]: partialName } = match.params; + const name = [publisher, partialName].filter(Boolean).join("/"); + + const extension = extensionLoader.userExtensionsByName.get(name); + + if (!extension) { + logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not installed`); + + return name; + } + + if (!extensionsStore.isEnabled(extension.id)) { + logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not enabled`); + + return name; + } + + logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched`); + + return extension; + } + + /** + * Find a matching extension by the first one or two path segments of `url` and then try to `_route` + * its correspondingly registered handlers. + * + * If no handlers are found or the extension is not enabled then `_missingHandlers` is called before + * checking if more handlers have been added. + * + * Note: this function modifies its argument, do not reuse + * @param url the protocol request URI that was "open"-ed + */ + protected async _routeToExtension(url: Url): Promise { + const extension = await this._findMatchingExtensionByName(url); + + if (typeof extension === "string") { + // failed to find an extension, it returned its name + return; + } + + // remove the extension name from the path name so we don't need to match on it anymore + url.set("pathname", url.pathname.slice(extension.name.length + 1)); + + const handlers = extension + .protocolHandlers + .map<[string, RouteHandler]>(({ pathSchema, handler }) => [pathSchema, handler]); + + try { + this._route(handlers, url, extension.name); + } catch (error) { + if (error instanceof RoutingError) { + error.extensionName = extension.name; + } + + throw error; + } + } + + /** + * Add a handler under the `lens://app` tree of routing. + * @param pathSchema the URI path schema to match against for this handler + * @param handler a function that will be called if a protocol path matches + */ + public addInternalHandler(urlSchema: string, handler: RouteHandler): void { + pathToRegexp(urlSchema); // verify now that the schema is valid + logger.info(`${LensProtocolRouter.LoggingPrefix}: internal registering ${urlSchema}`); + this.internalRoutes.set(urlSchema, handler); + } + + /** + * Remove an internal protocol handler. + * @param pathSchema the path schema that the handler was registered under + */ + public removeInternalHandler(urlSchema: string): void { + this.internalRoutes.delete(urlSchema); + } +} + +/** + * 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)["/"]; +} diff --git a/src/common/utils/delay.ts b/src/common/utils/delay.ts index 7d0686d29b..f19839538a 100644 --- a/src/common/utils/delay.ts +++ b/src/common/utils/delay.ts @@ -1,8 +1,19 @@ +import { AbortController } from "abort-controller"; + /** * Return a promise that will be resolved after at least `timeout` ms have - * passed + * passed. If `failFast` is provided then the promise is also resolved if it has + * been aborted. * @param timeout The number of milliseconds before resolving + * @param failFast An abort controller instance to cause the delay to short-circuit */ -export function delay(timeout = 1000): Promise { - return new Promise(resolve => setTimeout(resolve, timeout)); +export function delay(timeout = 1000, failFast?: AbortController): Promise { + return new Promise(resolve => { + const timeoutId = setTimeout(resolve, timeout); + + failFast?.signal.addEventListener("abort", () => { + clearTimeout(timeoutId); + resolve(); + }); + }); } diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 2b8147fad9..6f26bab2da 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -18,4 +18,4 @@ export * from "./openExternal"; export * from "./downloadFile"; export * from "./escapeRegExp"; export * from "./tar"; -export * from "./delay"; +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/extension-loader.ts b/src/extensions/extension-loader.ts index 98697d252c..b4aedaf274 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -14,7 +14,7 @@ import type { LensRendererExtension } from "./lens-renderer-extension"; import * as registries from "./registries"; import fs from "fs"; -// lazy load so that we get correct userData + export function extensionPackagesRoot() { return path.join((app || remote.app).getPath("userData")); } @@ -52,6 +52,30 @@ export class ExtensionLoader { return extensions; } + @computed get userExtensionsByName(): Map { + const extensions = new Map(); + + for (const [, val] of this.instances.toJS()) { + if (val.isBundled) { + continue; + } + + extensions.set(val.manifest.name, val); + } + + return extensions; + } + + getExtensionByName(name: string): LensExtension | null { + for (const [, val] of this.instances) { + if (val.name === name) { + return val; + } + } + + return null; + } + // Transform userExtensions to a state object for storing into ExtensionsStore @computed get storeState() { return Object.fromEntries( @@ -102,7 +126,6 @@ export class ExtensionLoader { } catch (error) { logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error }); } - } removeExtension(lensExtensionId: LensExtensionId) { diff --git a/src/extensions/extensions-store.ts b/src/extensions/extensions-store.ts index 0885bbb730..8e88d22f38 100644 --- a/src/extensions/extensions-store.ts +++ b/src/extensions/extensions-store.ts @@ -27,7 +27,7 @@ export class ExtensionsStore extends BaseStore { protected state = observable.map(); - isEnabled(extId: LensExtensionId) { + isEnabled(extId: LensExtensionId): boolean { const state = this.state.get(extId); // By default false, so that copied extensions are disabled by default. diff --git a/src/extensions/interfaces/registrations.ts b/src/extensions/interfaces/registrations.ts index ff51d9a824..10a55d1b78 100644 --- a/src/extensions/interfaces/registrations.ts +++ b/src/extensions/interfaces/registrations.ts @@ -6,3 +6,4 @@ export type { KubeObjectStatusRegistration } from "../registries/kube-object-sta export type { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } from "../registries/page-registry"; export type { PageMenuRegistration, ClusterPageMenuRegistration, PageMenuComponents } from "../registries/page-menu-registry"; export type { StatusBarRegistration } from "../registries/status-bar-registry"; +export type { ProtocolHandlerRegistration, RouteParams as ProtocolRouteParams, RouteHandler as ProtocolRouteHandler } from "../registries/protocol-handler-registry"; diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index aaa6f60ac5..a00e289e17 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -2,6 +2,7 @@ import type { InstalledExtension } from "./extension-discovery"; import { action, observable, reaction } from "mobx"; import { filesystemProvisionerStore } from "../main/extension-filesystem"; import logger from "../main/logger"; +import { ProtocolHandlerRegistration } from "./registries/protocol-handler-registry"; export type LensExtensionId = string; // path to manifest (package.json) export type LensExtensionConstructor = new (...args: ConstructorParameters) => LensExtension; @@ -21,6 +22,8 @@ export class LensExtension { readonly manifestPath: string; readonly isBundled: boolean; + protocolHandlers: ProtocolHandlerRegistration[] = []; + @observable private isEnabled = false; constructor({ id, manifest, manifestPath, isBundled }: InstalledExtension) { diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 8b9b132114..982830d8af 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -31,8 +31,7 @@ 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; } } diff --git a/src/extensions/registries/protocol-handler-registry.ts b/src/extensions/registries/protocol-handler-registry.ts new file mode 100644 index 0000000000..dd637818a3 --- /dev/null +++ b/src/extensions/registries/protocol-handler-registry.ts @@ -0,0 +1,44 @@ +/** + * ProtocolHandlerRegistration is the data required for an extension to register + * a handler to a specific path or dynamic path. + */ +export interface ProtocolHandlerRegistration { + pathSchema: string; + handler: RouteHandler; +} + +/** + * The collection of the dynamic parts of a URI which initiated a `lens://` + * protocol request + */ +export interface RouteParams { + /** + * the parts of the URI query string + */ + search: Record; + + /** + * the matching parts of the path. The dynamic parts of the URI path. + */ + pathname: Record; + + /** + * if the most specific path schema that is matched does not cover the whole + * of the URI's path. Then this field will be set to the remaining path + * segments. + * + * Example: + * + * If the path schema `/landing/:type` is the matched schema for the URI + * `/landing/soft/easy` then this field will be set to `"/easy"`. + */ + tail?: string; +} + +/** + * RouteHandler represents the function signature of the handler function for + * `lens://` protocol routing. + */ +export interface RouteHandler { + (params: RouteParams): void; +} diff --git a/src/main/app-updater.ts b/src/main/app-updater.ts index ed2bf4250b..9893abfa81 100644 --- a/src/main/app-updater.ts +++ b/src/main/app-updater.ts @@ -4,6 +4,7 @@ import { isDevelopment, isTestEnv } from "../common/vars"; import { delay } from "../common/utils"; import { areArgsUpdateAvailableToBackchannel, AutoUpdateLogPrefix, broadcastMessage, onceCorrect, UpdateAvailableChannel, UpdateAvailableToBackchannel } from "../common/ipc"; import { ipcMain } from "electron"; +import { once } from "lodash"; let installVersion: null | string = null; @@ -28,7 +29,7 @@ function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: Upd * starts the automatic update checking * @param interval milliseconds between interval to check on, defaults to 24h */ -export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void { +export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24): void { if (isDevelopment || isTestEnv) { return; } @@ -83,7 +84,7 @@ export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void { } helper(); -} +}); export async function checkForUpdates(): Promise { try { diff --git a/src/main/index.ts b/src/main/index.ts index c98595fa35..ddee53bfea 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,7 +4,7 @@ import "../common/system-ca"; import "../common/prometheus-providers"; import * as Mobx from "mobx"; import * as LensExtensions from "../extensions/core-api"; -import { app, autoUpdater, dialog, powerMonitor } from "electron"; +import { app, autoUpdater, ipcMain, dialog, powerMonitor } from "electron"; import { appName } from "../common/vars"; import path from "path"; import { LensProxy } from "./lens-proxy"; @@ -25,6 +25,7 @@ import { InstalledExtension, extensionDiscovery } from "../extensions/extension- import type { LensExtensionId } from "../extensions/lens-extension"; import { installDeveloperTools } from "./developer-tools"; import { filesystemProvisionerStore } from "./extension-filesystem"; +import { LensProtocolRouterMain } from "./protocol-handler"; import { getAppVersion, getAppVersionFromProxyServer } from "../common/utils"; import { bindBroadcastHandlers } from "../common/ipc"; import { startUpdateChecking } from "./app-updater"; @@ -37,30 +38,54 @@ let windowManager: WindowManager; app.setName(appName); +logger.info("📟 Setting as Lens as protocol client for lens://"); + +if (app.setAsDefaultProtocolClient("lens")) { + logger.info("📟 succeeded ✅"); +} else { + logger.info("📟 failed ❗"); +} + 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(); +} else { + const lprm = LensProtocolRouterMain.getInstance(); + + for (const arg of process.argv) { + if (arg.toLowerCase().startsWith("lens://")) { + lprm.route(arg) + .catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg })); + } + } } -app.on("second-instance", () => { +app.on("second-instance", (event, argv) => { + const lprm = LensProtocolRouterMain.getInstance(); + + for (const arg of argv) { + if (arg.toLowerCase().startsWith("lens://")) { + lprm.route(arg) + .catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg })); + } + } + windowManager?.ensureMainWindow(); }); -if (process.env.LENS_DISABLE_GPU) { - app.disableHardwareAcceleration(); -} - app.on("ready", async () => { logger.info(`🚀 Starting Lens from "${workingDir}"`); logger.info("🐚 Syncing shell environment"); @@ -128,7 +153,19 @@ app.on("ready", async () => { logger.info("🖥️ Starting WindowManager"); windowManager = WindowManager.getInstance(proxyPort); - windowManager.whenLoaded.then(() => startUpdateChecking()); + + ipcMain.on("renderer:loaded", () => { + startUpdateChecking(); + LensProtocolRouterMain + .getInstance() + .rendererLoaded = true; + }); + + extensionLoader.whenLoaded.then(() => { + LensProtocolRouterMain + .getInstance() + .extensionsLoaded = true; + }); logger.info("🧩 Initializing extensions"); @@ -174,8 +211,8 @@ let blockQuit = true; autoUpdater.on("before-quit-for-update", () => blockQuit = false); -// Quit app on Cmd+Q (MacOS) app.on("will-quit", (event) => { + // Quit app on Cmd+Q (MacOS) logger.info("APP:QUIT"); appEventBus.emit({name: "app", action: "close"}); @@ -188,6 +225,16 @@ app.on("will-quit", (event) => { } }); +app.on("open-url", (event, rawUrl) => { + // lens:// protocol handler + event.preventDefault(); + + LensProtocolRouterMain + .getInstance() + .route(rawUrl) + .catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl })); +}); + // Extensions-api runtime exports export const LensExtensionsApi = { ...LensExtensions, 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..6b3f668079 --- /dev/null +++ b/src/main/protocol-handler/__test__/router.test.ts @@ -0,0 +1,259 @@ +import { LensProtocolRouterMain } from "../router"; +import { noop } from "../../../common/utils"; +import { extensionsStore } from "../../../extensions/extensions-store"; +import { extensionLoader } from "../../../extensions/extension-loader"; +import * as uuid from "uuid"; +import { LensMainExtension } from "../../../extensions/core-api"; +import { broadcastMessage } from "../../../common/ipc"; +import { ProtocolHandlerExtension, ProtocolHandlerInternal } from "../../../common/protocol-handler"; + +jest.mock("../../../common/ipc"); + +function throwIfDefined(val: any): void { + if (val != null) { + throw val; + } +} + +describe("protocol router tests", () => { + let lpr: LensProtocolRouterMain; + + beforeEach(() => { + jest.clearAllMocks(); + (extensionsStore as any).state.clear(); + (extensionLoader as any).instances.clear(); + LensProtocolRouterMain.resetInstance(); + lpr = LensProtocolRouterMain.getInstance(); + lpr.extensionsLoaded = true; + lpr.rendererLoaded = true; + }); + + it("should throw on non-lens URLS", async () => { + try { + expect(await lpr.route("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("lens://foobar")).toBeUndefined(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + } + }); + + it("should not throw when has valid host", async () => { + const extId = uuid.v4(); + const ext = new LensMainExtension({ + id: extId, + manifestPath: "/foo/bar", + manifest: { + name: "@mirantis/minikube", + version: "0.1.1", + }, + isBundled: false, + isEnabled: true, + absolutePath: "/foo/bar", + }); + + ext.protocolHandlers.push({ + pathSchema: "/", + handler: noop, + }); + + (extensionLoader as any).instances.set(extId, ext); + (extensionsStore as any).state.set(extId, { enabled: true, name: "@mirantis/minikube" }); + + lpr.addInternalHandler("/", noop); + + try { + expect(await lpr.route("lens://app")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + + try { + expect(await lpr.route("lens://extension/@mirantis/minikube")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(broadcastMessage).toHaveBeenNthCalledWith(1, ProtocolHandlerInternal, "lens://app"); + expect(broadcastMessage).toHaveBeenNthCalledWith(2, ProtocolHandlerExtension, "lens://extension/@mirantis/minikube"); + }); + + it("should call handler if matches", async () => { + let called = false; + + lpr.addInternalHandler("/page", () => { called = true; }); + + try { + expect(await lpr.route("lens://app/page")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(called).toBe(true); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page"); + }); + + it("should call most exact handler", async () => { + let called: any = 0; + + lpr.addInternalHandler("/page", () => { called = 1; }); + lpr.addInternalHandler("/page/:id", params => { called = params.pathname.id; }); + + try { + expect(await lpr.route("lens://app/page/foo")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(called).toBe("foo"); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo"); + }); + + it("should call most exact handler for an extension", async () => { + let called: any = 0; + + const extId = uuid.v4(); + const ext = new LensMainExtension({ + id: extId, + manifestPath: "/foo/bar", + manifest: { + name: "@foobar/icecream", + version: "0.1.1", + }, + isBundled: false, + isEnabled: true, + absolutePath: "/foo/bar", + }); + + ext.protocolHandlers + .push({ + pathSchema: "/page", + handler: () => { called = 1; }, + }, { + pathSchema: "/page/:id", + handler: params => { called = params.pathname.id; }, + }); + + (extensionLoader as any).instances.set(extId, ext); + (extensionsStore as any).state.set(extId, { enabled: true, name: "@foobar/icecream" }); + + try { + expect(await lpr.route("lens://extension/@foobar/icecream/page/foob")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(called).toBe("foob"); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/@foobar/icecream/page/foob"); + }); + + it("should work with non-org extensions", async () => { + let called: any = 0; + + { + const extId = uuid.v4(); + const ext = new LensMainExtension({ + id: extId, + manifestPath: "/foo/bar", + manifest: { + name: "@foobar/icecream", + version: "0.1.1", + }, + isBundled: false, + isEnabled: true, + absolutePath: "/foo/bar", + }); + + ext.protocolHandlers + .push({ + pathSchema: "/page/:id", + handler: params => { called = params.pathname.id; }, + }); + + (extensionLoader as any).instances.set(extId, ext); + (extensionsStore as any).state.set(extId, { enabled: true, name: "@foobar/icecream" }); + } + + { + const extId = uuid.v4(); + const ext = new LensMainExtension({ + id: extId, + manifestPath: "/foo/bar", + manifest: { + name: "icecream", + version: "0.1.1", + }, + isBundled: false, + isEnabled: true, + absolutePath: "/foo/bar", + }); + + ext.protocolHandlers + .push({ + pathSchema: "/page", + handler: () => { called = 1; }, + }); + + (extensionLoader as any).instances.set(extId, ext); + (extensionsStore as any).state.set(extId, { enabled: true, name: "icecream" }); + } + + (extensionsStore as any).state.set("@foobar/icecream", { enabled: true, name: "@foobar/icecream" }); + (extensionsStore as any).state.set("icecream", { enabled: true, name: "icecream" }); + + try { + expect(await lpr.route("lens://extension/icecream/page")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(called).toBe(1); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/icecream/page"); + }); + + it("should throw if urlSchema is invalid", () => { + expect(() => lpr.addInternalHandler("/:@", noop)).toThrowError(); + }); + + it("should call most exact handler with 3 found handlers", async () => { + let called: any = 0; + + lpr.addInternalHandler("/", () => { called = 2; }); + lpr.addInternalHandler("/page", () => { called = 1; }); + lpr.addInternalHandler("/page/foo", () => { called = 3; }); + lpr.addInternalHandler("/page/bar", () => { called = 4; }); + + try { + expect(await lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(called).toBe(3); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat"); + }); + + it("should call most exact handler with 2 found handlers", async () => { + let called: any = 0; + + lpr.addInternalHandler("/", () => { called = 2; }); + lpr.addInternalHandler("/page", () => { called = 1; }); + lpr.addInternalHandler("/page/bar", () => { called = 4; }); + + try { + expect(await lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(called).toBe(1); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat"); + }); +}); 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..20962372b2 --- /dev/null +++ b/src/main/protocol-handler/router.ts @@ -0,0 +1,112 @@ +import logger from "../logger"; +import * as proto from "../../common/protocol-handler"; +import Url from "url-parse"; +import { LensExtension } from "../../extensions/lens-extension"; +import { broadcastMessage } from "../../common/ipc"; +import { observable, when } from "mobx"; + +export interface FallbackHandler { + (name: string): Promise; +} + +export class LensProtocolRouterMain extends proto.LensProtocolRouter { + private missingExtensionHandlers: FallbackHandler[] = []; + + @observable rendererLoaded = false; + @observable extensionsLoaded = false; + + /** + * Find the most specific registered handler, if it exists, and invoke it. + * + * This will send an IPC message to the renderer router to do the same + * in the renderer. + */ + public async route(rawUrl: string): Promise { + try { + const url = new Url(rawUrl, true); + + if (url.protocol.toLowerCase() !== "lens:") { + throw new proto.RoutingError(proto.RoutingErrorType.INVALID_PROTOCOL, url); + } + + logger.info(`${proto.LensProtocolRouter.LoggingPrefix}: routing ${url.toString()}`); + + switch (url.host) { + case "app": + return this._routeToInternal(url); + case "extension": + await when(() => this.extensionsLoaded); + + return this._routeToExtension(url); + default: + throw new proto.RoutingError(proto.RoutingErrorType.INVALID_HOST, url); + } + + } catch (error) { + if (error instanceof proto.RoutingError) { + logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ${error}`, { url: error.url }); + } else { + logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ${error}`, { rawUrl }); + } + } + } + + protected async _executeMissingExtensionHandlers(extensionName: string): Promise { + for (const handler of this.missingExtensionHandlers) { + if (await handler(extensionName)) { + return true; + } + } + + return false; + } + + protected async _findMatchingExtensionByName(url: Url): Promise { + const firstAttempt = await super._findMatchingExtensionByName(url); + + if (typeof firstAttempt !== "string") { + return firstAttempt; + } + + if (await this._executeMissingExtensionHandlers(firstAttempt)) { + return super._findMatchingExtensionByName(url); + } + + return ""; + } + + protected async _routeToInternal(url: Url): Promise { + const rawUrl = url.toString(); // for sending to renderer + + super._routeToInternal(url); + + await when(() => this.rendererLoaded); + + return broadcastMessage(proto.ProtocolHandlerInternal, rawUrl); + } + + protected async _routeToExtension(url: Url): Promise { + const rawUrl = url.toString(); // for sending to renderer + + /** + * This needs to be done first, so that the missing extension handlers can + * be called before notifying the renderer. + * + * Note: this needs to clone the url because _routeToExtension modifies its + * argument. + */ + await super._routeToExtension(new Url(url.toString(), true)); + await when(() => this.rendererLoaded); + + return broadcastMessage(proto.ProtocolHandlerExtension, rawUrl); + } + + /** + * Add a function to the list which will be sequentially called if an extension + * is not found while routing to the extensions + * @param handler A function that tries to find an extension + */ + public addMissingExtensionHandler(handler: FallbackHandler): void { + this.missingExtensionHandlers.push(handler); + } +} diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index c092e186cb..691aa7c66b 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -1,5 +1,5 @@ import type { ClusterId } from "../common/cluster-store"; -import { observable, when } from "mobx"; +import { observable } from "mobx"; import { app, BrowserWindow, dialog, shell, webContents } from "electron"; import windowStateKeeper from "electron-window-state"; import { appEventBus } from "../common/event-bus"; @@ -16,9 +16,6 @@ export class WindowManager extends Singleton { protected windowState: windowStateKeeper.State; protected disposers: Record = {}; - @observable mainViewInitiallyLoaded = false; - whenLoaded = when(() => this.mainViewInitiallyLoaded); - @observable activeClusterId: ClusterId; constructor(protected proxyPort: number) { @@ -104,7 +101,6 @@ export class WindowManager extends Singleton { setTimeout(() => { appEventBus.emit({ name: "app", action: "start" }); }, 1000); - this.mainViewInitiallyLoaded = true; } catch (err) { dialog.showErrorBox("ERROR!", err.toString()); } diff --git a/src/renderer/lens-app.tsx b/src/renderer/lens-app.tsx index 963bd43e4e..c6f55872a9 100644 --- a/src/renderer/lens-app.tsx +++ b/src/renderer/lens-app.tsx @@ -12,12 +12,15 @@ import { ConfirmDialog } from "./components/confirm-dialog"; import { extensionLoader } from "../extensions/extension-loader"; import { broadcastMessage } from "../common/ipc"; import { CommandContainer } from "./components/command-palette/command-container"; +import { LensProtocolRouterRenderer } from "./protocol-handler/router"; import { registerIpcHandlers } from "./ipc"; +import { ipcRenderer } from "electron"; @observer export class LensApp extends React.Component { static async init() { extensionLoader.loadOnClusterManagerRenderer(); + LensProtocolRouterRenderer.getInstance().init(); window.addEventListener("offline", () => { broadcastMessage("network:offline"); }); @@ -26,6 +29,7 @@ export class LensApp extends React.Component { }); registerIpcHandlers(); + ipcRenderer.send("renderer:loaded"); } render() { diff --git a/src/renderer/navigation/index.ts b/src/renderer/navigation/index.ts index 94930fc994..adf2577f4e 100644 --- a/src/renderer/navigation/index.ts +++ b/src/renderer/navigation/index.ts @@ -1,8 +1,10 @@ // Navigation (renderer) import { bindEvents } from "./events"; +import { bindProtocolHandlers } from "./protocol-handlers"; export * from "./history"; export * from "./helpers"; bindEvents(); +bindProtocolHandlers(); diff --git a/src/renderer/navigation/protocol-handlers.ts b/src/renderer/navigation/protocol-handlers.ts new file mode 100644 index 0000000000..423cc70fd0 --- /dev/null +++ b/src/renderer/navigation/protocol-handlers.ts @@ -0,0 +1,10 @@ +import { LensProtocolRouterRenderer } from "../protocol-handler/router"; +import { navigate } from "./helpers"; + +export function bindProtocolHandlers() { + const lprr = LensProtocolRouterRenderer.getInstance(); + + lprr.addInternalHandler("/preferences", () => { + navigate("/preferences"); + }); +} 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..d1dc0ceafd --- /dev/null +++ b/src/renderer/protocol-handler/router.ts @@ -0,0 +1,40 @@ +import { ipcRenderer } from "electron"; +import * as proto from "../../common/protocol-handler"; +import logger from "../../main/logger"; +import Url from "url-parse"; +import { autobind } from "../utils"; + +export class LensProtocolRouterRenderer extends proto.LensProtocolRouter { + /** + * This function is needed to be called early on in the renderers lifetime. + */ + public init(): void { + ipcRenderer + .on(proto.ProtocolHandlerInternal, this.ipcInternalHandler) + .on(proto.ProtocolHandlerExtension, this.ipcExtensionHandler); + } + + @autobind() + private ipcInternalHandler(event: Electron.IpcRendererEvent, ...args: any[]): void { + if (args.length !== 1) { + return void logger.warn(`${proto.LensProtocolRouter.LoggingPrefix}: unexpected number of args`, { args }); + } + + const [rawUrl] = args; + const url = new Url(rawUrl, true); + + this._routeToInternal(url); + } + + @autobind() + private ipcExtensionHandler(event: Electron.IpcRendererEvent, ...args: any[]): void { + if (args.length !== 1) { + return void logger.warn(`${proto.LensProtocolRouter.LoggingPrefix}: unexpected number of args`, { args }); + } + + const [rawUrl] = args; + const url = new Url(rawUrl, true); + + this._routeToExtension(url); + } +} 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 2cc97b2383..bc2e606b71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1771,6 +1771,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" @@ -2137,6 +2142,13 @@ abbrev@1, abbrev@~1.1.1: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7: version "1.3.7" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" @@ -5336,6 +5348,11 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + eventemitter3@^4.0.0: version "4.0.4" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.4.tgz#b5463ace635a083d018bdc7c917b4c5f10a85384" @@ -10092,6 +10109,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" @@ -13754,7 +13779,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==