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 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..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:
+
+
+
+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==