1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

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 <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2021-02-25 09:32:40 -05:00 committed by GitHub
parent 38c3734d5c
commit 1470103fd4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1010 additions and 40 deletions

View File

@ -9,7 +9,7 @@ else
DETECTED_OS := $(shell uname) DETECTED_OS := $(shell uname)
endif endif
binaries/client: binaries/client: node_modules
yarn download-bins yarn download-bins
node_modules: yarn.lock node_modules: yarn.lock
@ -37,17 +37,24 @@ test: binaries/client
yarn test yarn test
.PHONY: integration-linux .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 build:linux
yarn integration yarn integration
.PHONY: integration-mac .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 build:mac
yarn integration yarn integration
.PHONY: integration-win .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 build:win
yarn integration yarn integration

View File

@ -21,12 +21,13 @@ Each guide or code sample includes the following:
| [Components](components.md) | | | [Components](components.md) | |
| [KubeObjectListLayout](kube-object-list-layout.md) | | | [KubeObjectListLayout](kube-object-list-layout.md) | |
| [Working with mobx](working-with-mobx.md) | | | [Working with mobx](working-with-mobx.md) | |
| [Protocol Handlers](protocol-handlers.md) | |
## Samples ## Samples
| Sample | APIs | | Sample | APIs |
| ----- | ----- | | ----- | ----- |
[helloworld](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps | [hello-world](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps |
[minikube](https://github.com/lensapp/lens-extension-samples/tree/master/minikube-sample) | LensMainExtension <br> Store.clusterStore <br> Store.workspaceStore | [minikube](https://github.com/lensapp/lens-extension-samples/tree/master/minikube-sample) | LensMainExtension <br> Store.clusterStore <br> Store.workspaceStore |
[styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps | [styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps |
[styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps | [styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps |

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -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.

View File

@ -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. 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 `<path/to/executable>` being the absolute path to where you have installed the unpacked `Lens` executable:
```
[Desktop Entry]
Name=Lens
Exec=<path/to/executable> %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 ### 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) - [Add clusters](../clusters/adding-clusters.md)
- [Watch introductory videos](./introductory-videos.md) - [Watch introductory videos](./introductory-videos.md)

View File

@ -25,7 +25,7 @@
"build:linux": "yarn run compile && electron-builder --linux --dir -c.productName=Lens", "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:mac": "yarn run compile && electron-builder --mac --dir -c.productName=Lens",
"build:win": "yarn run compile && electron-builder --win --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", "integration": "jest --runInBand integration",
"dist": "yarn run compile && electron-builder --publish onTag", "dist": "yarn run compile && electron-builder --publish onTag",
"dist:win": "yarn run compile && electron-builder --publish onTag --x64 --ia32", "dist:win": "yarn run compile && electron-builder --publish onTag --x64 --ia32",
@ -170,7 +170,14 @@
"repo": "lens", "repo": "lens",
"owner": "lensapp" "owner": "lensapp"
} }
] ],
"protocols": {
"name": "Lens Protocol Handler",
"schemes": [
"lens"
],
"role": "Viewer"
}
}, },
"lens": { "lens": {
"extensions": [ "extensions": [
@ -187,6 +194,7 @@
"@hapi/call": "^8.0.0", "@hapi/call": "^8.0.0",
"@hapi/subtext": "^7.0.3", "@hapi/subtext": "^7.0.3",
"@kubernetes/client-node": "^0.12.0", "@kubernetes/client-node": "^0.12.0",
"abort-controller": "^3.0.0",
"array-move": "^3.0.0", "array-move": "^3.0.0",
"await-lock": "^2.1.0", "await-lock": "^2.1.0",
"byline": "^5.0.0", "byline": "^5.0.0",
@ -213,6 +221,7 @@
"mobx-observable-history": "^1.0.3", "mobx-observable-history": "^1.0.3",
"mobx-react": "^6.2.2", "mobx-react": "^6.2.2",
"mock-fs": "^4.12.0", "mock-fs": "^4.12.0",
"moment": "^2.26.0",
"node-pty": "^0.9.0", "node-pty": "^0.9.0",
"npm": "^6.14.8", "npm": "^6.14.8",
"openid-client": "^3.15.2", "openid-client": "^3.15.2",
@ -232,6 +241,7 @@
"tar": "^6.0.5", "tar": "^6.0.5",
"tcp-port-used": "^1.0.1", "tcp-port-used": "^1.0.1",
"tempy": "^0.5.0", "tempy": "^0.5.0",
"url-parse": "^1.4.7",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"win-ca": "^3.2.0", "win-ca": "^3.2.0",
"winston": "^3.2.1", "winston": "^3.2.1",
@ -289,6 +299,7 @@
"@types/tempy": "^0.3.0", "@types/tempy": "^0.3.0",
"@types/terser-webpack-plugin": "^3.0.0", "@types/terser-webpack-plugin": "^3.0.0",
"@types/universal-analytics": "^0.4.4", "@types/universal-analytics": "^0.4.4",
"@types/url-parse": "^1.4.3",
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.0",
"@types/webdriverio": "^4.13.0", "@types/webdriverio": "^4.13.0",
"@types/webpack": "^4.41.17", "@types/webpack": "^4.41.17",
@ -325,10 +336,10 @@
"jest-mock-extended": "^1.0.10", "jest-mock-extended": "^1.0.10",
"make-plural": "^6.2.2", "make-plural": "^6.2.2",
"mini-css-extract-plugin": "^0.9.0", "mini-css-extract-plugin": "^0.9.0",
"moment": "^2.26.0",
"node-loader": "^0.6.0", "node-loader": "^0.6.0",
"node-sass": "^4.14.1", "node-sass": "^4.14.1",
"nodemon": "^2.0.4", "nodemon": "^2.0.4",
"open": "^7.3.1",
"patch-package": "^6.2.2", "patch-package": "^6.2.2",
"postinstall-postinstall": "^2.1.0", "postinstall-postinstall": "^2.1.0",
"prettier": "^2.2.0", "prettier": "^2.2.0",

1
scripts/test.sh Executable file
View File

@ -0,0 +1 @@
jest --env=jsdom ${1:-src}

View File

@ -47,7 +47,7 @@ export async function broadcastMessage(channel: string, ...args: any[]) {
view.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args); view.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args);
} }
} catch (error) { } catch (error) {
logger.error("[IPC]: failed to send IPC message", { error }); logger.error("[IPC]: failed to send IPC message", { error: String(error) });
} }
} }
} }

View File

@ -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";
}
}
}

View File

@ -0,0 +1,2 @@
export * from "./error";
export * from "./router";

View File

@ -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<string, RouteHandler>();
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<Record<string, string>>, RouteHandler] {
const matches: [match<Record<string, string>>, 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<string, string> = { 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<LensExtension | string> {
interface ExtensionUrlMatch {
[EXTENSION_PUBLISHER_MATCH]: string;
[EXTENSION_NAME_MATCH]: string;
}
const match = matchPath<ExtensionUrlMatch>(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<void> {
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<T>(a: match<T>, b: match<T>): number {
if (a.path === "/") {
return 1;
}
if (b.path === "/") {
return -1;
}
return countBy(b.path)["/"] - countBy(a.path)["/"];
}

View File

@ -1,8 +1,19 @@
import { AbortController } from "abort-controller";
/** /**
* Return a promise that will be resolved after at least `timeout` ms have * 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 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<void> { export function delay(timeout = 1000, failFast?: AbortController): Promise<void> {
return new Promise(resolve => setTimeout(resolve, timeout)); return new Promise(resolve => {
const timeoutId = setTimeout(resolve, timeout);
failFast?.signal.addEventListener("abort", () => {
clearTimeout(timeoutId);
resolve();
});
});
} }

View File

@ -18,4 +18,4 @@ export * from "./openExternal";
export * from "./downloadFile"; export * from "./downloadFile";
export * from "./escapeRegExp"; export * from "./escapeRegExp";
export * from "./tar"; export * from "./tar";
export * from "./delay"; export * from "./type-narrowing";

View File

@ -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<V extends object, K extends PropertyKey>(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<V extends object, K extends PropertyKey>(val: V, ...keys: K[]): val is (V & { [key in K]: unknown}) {
return keys.every(key => hasOwnProperty(val, key));
}

View File

@ -14,7 +14,7 @@ import type { LensRendererExtension } from "./lens-renderer-extension";
import * as registries from "./registries"; import * as registries from "./registries";
import fs from "fs"; import fs from "fs";
// lazy load so that we get correct userData
export function extensionPackagesRoot() { export function extensionPackagesRoot() {
return path.join((app || remote.app).getPath("userData")); return path.join((app || remote.app).getPath("userData"));
} }
@ -52,6 +52,30 @@ export class ExtensionLoader {
return extensions; return extensions;
} }
@computed get userExtensionsByName(): Map<string, LensExtension> {
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 // Transform userExtensions to a state object for storing into ExtensionsStore
@computed get storeState() { @computed get storeState() {
return Object.fromEntries( return Object.fromEntries(
@ -102,7 +126,6 @@ export class ExtensionLoader {
} catch (error) { } catch (error) {
logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error }); logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error });
} }
} }
removeExtension(lensExtensionId: LensExtensionId) { removeExtension(lensExtensionId: LensExtensionId) {

View File

@ -27,7 +27,7 @@ export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> {
protected state = observable.map<LensExtensionId, LensExtensionState>(); protected state = observable.map<LensExtensionId, LensExtensionState>();
isEnabled(extId: LensExtensionId) { isEnabled(extId: LensExtensionId): boolean {
const state = this.state.get(extId); const state = this.state.get(extId);
// By default false, so that copied extensions are disabled by default. // By default false, so that copied extensions are disabled by default.

View File

@ -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 { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } from "../registries/page-registry";
export type { PageMenuRegistration, ClusterPageMenuRegistration, PageMenuComponents } from "../registries/page-menu-registry"; export type { PageMenuRegistration, ClusterPageMenuRegistration, PageMenuComponents } from "../registries/page-menu-registry";
export type { StatusBarRegistration } from "../registries/status-bar-registry"; export type { StatusBarRegistration } from "../registries/status-bar-registry";
export type { ProtocolHandlerRegistration, RouteParams as ProtocolRouteParams, RouteHandler as ProtocolRouteHandler } from "../registries/protocol-handler-registry";

View File

@ -2,6 +2,7 @@ import type { InstalledExtension } from "./extension-discovery";
import { action, observable, reaction } from "mobx"; import { action, observable, reaction } from "mobx";
import { filesystemProvisionerStore } from "../main/extension-filesystem"; import { filesystemProvisionerStore } from "../main/extension-filesystem";
import logger from "../main/logger"; import logger from "../main/logger";
import { ProtocolHandlerRegistration } from "./registries/protocol-handler-registry";
export type LensExtensionId = string; // path to manifest (package.json) export type LensExtensionId = string; // path to manifest (package.json)
export type LensExtensionConstructor = new (...args: ConstructorParameters<typeof LensExtension>) => LensExtension; export type LensExtensionConstructor = new (...args: ConstructorParameters<typeof LensExtension>) => LensExtension;
@ -21,6 +22,8 @@ export class LensExtension {
readonly manifestPath: string; readonly manifestPath: string;
readonly isBundled: boolean; readonly isBundled: boolean;
protocolHandlers: ProtocolHandlerRegistration[] = [];
@observable private isEnabled = false; @observable private isEnabled = false;
constructor({ id, manifest, manifestPath, isBundled }: InstalledExtension) { constructor({ id, manifest, manifestPath, isBundled }: InstalledExtension) {

View File

@ -31,8 +31,7 @@ export class LensRendererExtension extends LensExtension {
/** /**
* Defines if extension is enabled for a given cluster. Defaults to `true`. * 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<Boolean> { async isEnabledForCluster(cluster: Cluster): Promise<Boolean> {
return true; return (void cluster) || true;
} }
} }

View File

@ -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<string, string>;
/**
* the matching parts of the path. The dynamic parts of the URI path.
*/
pathname: Record<string, string>;
/**
* 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;
}

View File

@ -4,6 +4,7 @@ import { isDevelopment, isTestEnv } from "../common/vars";
import { delay } from "../common/utils"; import { delay } from "../common/utils";
import { areArgsUpdateAvailableToBackchannel, AutoUpdateLogPrefix, broadcastMessage, onceCorrect, UpdateAvailableChannel, UpdateAvailableToBackchannel } from "../common/ipc"; import { areArgsUpdateAvailableToBackchannel, AutoUpdateLogPrefix, broadcastMessage, onceCorrect, UpdateAvailableChannel, UpdateAvailableToBackchannel } from "../common/ipc";
import { ipcMain } from "electron"; import { ipcMain } from "electron";
import { once } from "lodash";
let installVersion: null | string = null; let installVersion: null | string = null;
@ -28,7 +29,7 @@ function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: Upd
* starts the automatic update checking * starts the automatic update checking
* @param interval milliseconds between interval to check on, defaults to 24h * @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) { if (isDevelopment || isTestEnv) {
return; return;
} }
@ -83,7 +84,7 @@ export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void {
} }
helper(); helper();
} });
export async function checkForUpdates(): Promise<void> { export async function checkForUpdates(): Promise<void> {
try { try {

View File

@ -4,7 +4,7 @@ import "../common/system-ca";
import "../common/prometheus-providers"; import "../common/prometheus-providers";
import * as Mobx from "mobx"; import * as Mobx from "mobx";
import * as LensExtensions from "../extensions/core-api"; 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 { appName } from "../common/vars";
import path from "path"; import path from "path";
import { LensProxy } from "./lens-proxy"; import { LensProxy } from "./lens-proxy";
@ -25,6 +25,7 @@ import { InstalledExtension, extensionDiscovery } from "../extensions/extension-
import type { LensExtensionId } from "../extensions/lens-extension"; import type { LensExtensionId } from "../extensions/lens-extension";
import { installDeveloperTools } from "./developer-tools"; import { installDeveloperTools } from "./developer-tools";
import { filesystemProvisionerStore } from "./extension-filesystem"; import { filesystemProvisionerStore } from "./extension-filesystem";
import { LensProtocolRouterMain } from "./protocol-handler";
import { getAppVersion, getAppVersionFromProxyServer } from "../common/utils"; import { getAppVersion, getAppVersionFromProxyServer } from "../common/utils";
import { bindBroadcastHandlers } from "../common/ipc"; import { bindBroadcastHandlers } from "../common/ipc";
import { startUpdateChecking } from "./app-updater"; import { startUpdateChecking } from "./app-updater";
@ -37,30 +38,54 @@ let windowManager: WindowManager;
app.setName(appName); 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) { if (!process.env.CICD) {
app.setPath("userData", workingDir); app.setPath("userData", workingDir);
} }
if (process.env.LENS_DISABLE_GPU) {
app.disableHardwareAcceleration();
}
mangleProxyEnv(); mangleProxyEnv();
if (app.commandLine.getSwitchValue("proxy-server") !== "") { if (app.commandLine.getSwitchValue("proxy-server") !== "") {
process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server"); process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server");
} }
const instanceLock = app.requestSingleInstanceLock(); if (!app.requestSingleInstanceLock()) {
if (!instanceLock) {
app.exit(); app.exit();
} else {
const lprm = LensProtocolRouterMain.getInstance<LensProtocolRouterMain>();
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<LensProtocolRouterMain>();
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(); windowManager?.ensureMainWindow();
}); });
if (process.env.LENS_DISABLE_GPU) {
app.disableHardwareAcceleration();
}
app.on("ready", async () => { app.on("ready", async () => {
logger.info(`🚀 Starting Lens from "${workingDir}"`); logger.info(`🚀 Starting Lens from "${workingDir}"`);
logger.info("🐚 Syncing shell environment"); logger.info("🐚 Syncing shell environment");
@ -128,7 +153,19 @@ app.on("ready", async () => {
logger.info("🖥️ Starting WindowManager"); logger.info("🖥️ Starting WindowManager");
windowManager = WindowManager.getInstance<WindowManager>(proxyPort); windowManager = WindowManager.getInstance<WindowManager>(proxyPort);
windowManager.whenLoaded.then(() => startUpdateChecking());
ipcMain.on("renderer:loaded", () => {
startUpdateChecking();
LensProtocolRouterMain
.getInstance<LensProtocolRouterMain>()
.rendererLoaded = true;
});
extensionLoader.whenLoaded.then(() => {
LensProtocolRouterMain
.getInstance<LensProtocolRouterMain>()
.extensionsLoaded = true;
});
logger.info("🧩 Initializing extensions"); logger.info("🧩 Initializing extensions");
@ -174,8 +211,8 @@ let blockQuit = true;
autoUpdater.on("before-quit-for-update", () => blockQuit = false); autoUpdater.on("before-quit-for-update", () => blockQuit = false);
// Quit app on Cmd+Q (MacOS)
app.on("will-quit", (event) => { app.on("will-quit", (event) => {
// Quit app on Cmd+Q (MacOS)
logger.info("APP:QUIT"); logger.info("APP:QUIT");
appEventBus.emit({name: "app", action: "close"}); 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<LensProtocolRouterMain>()
.route(rawUrl)
.catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl }));
});
// Extensions-api runtime exports // Extensions-api runtime exports
export const LensExtensionsApi = { export const LensExtensionsApi = {
...LensExtensions, ...LensExtensions,

View File

@ -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<LensProtocolRouterMain>();
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");
});
});

View File

@ -0,0 +1 @@
export * from "./router";

View File

@ -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<boolean>;
}
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<void> {
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<boolean> {
for (const handler of this.missingExtensionHandlers) {
if (await handler(extensionName)) {
return true;
}
}
return false;
}
protected async _findMatchingExtensionByName(url: Url): Promise<LensExtension | string> {
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<void> {
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<void> {
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);
}
}

View File

@ -1,5 +1,5 @@
import type { ClusterId } from "../common/cluster-store"; 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 { app, BrowserWindow, dialog, shell, webContents } from "electron";
import windowStateKeeper from "electron-window-state"; import windowStateKeeper from "electron-window-state";
import { appEventBus } from "../common/event-bus"; import { appEventBus } from "../common/event-bus";
@ -16,9 +16,6 @@ export class WindowManager extends Singleton {
protected windowState: windowStateKeeper.State; protected windowState: windowStateKeeper.State;
protected disposers: Record<string, Function> = {}; protected disposers: Record<string, Function> = {};
@observable mainViewInitiallyLoaded = false;
whenLoaded = when(() => this.mainViewInitiallyLoaded);
@observable activeClusterId: ClusterId; @observable activeClusterId: ClusterId;
constructor(protected proxyPort: number) { constructor(protected proxyPort: number) {
@ -104,7 +101,6 @@ export class WindowManager extends Singleton {
setTimeout(() => { setTimeout(() => {
appEventBus.emit({ name: "app", action: "start" }); appEventBus.emit({ name: "app", action: "start" });
}, 1000); }, 1000);
this.mainViewInitiallyLoaded = true;
} catch (err) { } catch (err) {
dialog.showErrorBox("ERROR!", err.toString()); dialog.showErrorBox("ERROR!", err.toString());
} }

View File

@ -12,12 +12,15 @@ import { ConfirmDialog } from "./components/confirm-dialog";
import { extensionLoader } from "../extensions/extension-loader"; import { extensionLoader } from "../extensions/extension-loader";
import { broadcastMessage } from "../common/ipc"; import { broadcastMessage } from "../common/ipc";
import { CommandContainer } from "./components/command-palette/command-container"; import { CommandContainer } from "./components/command-palette/command-container";
import { LensProtocolRouterRenderer } from "./protocol-handler/router";
import { registerIpcHandlers } from "./ipc"; import { registerIpcHandlers } from "./ipc";
import { ipcRenderer } from "electron";
@observer @observer
export class LensApp extends React.Component { export class LensApp extends React.Component {
static async init() { static async init() {
extensionLoader.loadOnClusterManagerRenderer(); extensionLoader.loadOnClusterManagerRenderer();
LensProtocolRouterRenderer.getInstance<LensProtocolRouterRenderer>().init();
window.addEventListener("offline", () => { window.addEventListener("offline", () => {
broadcastMessage("network:offline"); broadcastMessage("network:offline");
}); });
@ -26,6 +29,7 @@ export class LensApp extends React.Component {
}); });
registerIpcHandlers(); registerIpcHandlers();
ipcRenderer.send("renderer:loaded");
} }
render() { render() {

View File

@ -1,8 +1,10 @@
// Navigation (renderer) // Navigation (renderer)
import { bindEvents } from "./events"; import { bindEvents } from "./events";
import { bindProtocolHandlers } from "./protocol-handlers";
export * from "./history"; export * from "./history";
export * from "./helpers"; export * from "./helpers";
bindEvents(); bindEvents();
bindProtocolHandlers();

View File

@ -0,0 +1,10 @@
import { LensProtocolRouterRenderer } from "../protocol-handler/router";
import { navigate } from "./helpers";
export function bindProtocolHandlers() {
const lprr = LensProtocolRouterRenderer.getInstance<LensProtocolRouterRenderer>();
lprr.addInternalHandler("/preferences", () => {
navigate("/preferences");
});
}

View File

@ -0,0 +1 @@
export * from "./router.ts";

View File

@ -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);
}
}

View File

@ -1,7 +1,5 @@
// Common usage utils & helpers // Common usage utils & helpers
export const isElectron = !!navigator.userAgent.match(/Electron/);
export * from "../../common/utils"; export * from "../../common/utils";
export * from "./cssVar"; export * from "./cssVar";

View File

@ -1771,6 +1771,11 @@
resolved "https://registry.yarnpkg.com/@types/universal-analytics/-/universal-analytics-0.4.4.tgz#496a52b92b599a0112bec7c12414062de6ea8449" resolved "https://registry.yarnpkg.com/@types/universal-analytics/-/universal-analytics-0.4.4.tgz#496a52b92b599a0112bec7c12414062de6ea8449"
integrity sha512-9g3F0SGxVr4UDd6y07bWtFnkpSSX1Ake7U7AGHgSFrwM6pF53/fV85bfxT2JLWS/3sjLCcyzoYzQlCxpkVo7wA== 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": "@types/uuid@^8.3.0":
version "8.3.0" version "8.3.0"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f" 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" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== 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: accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
version "1.3.7" version "1.3.7"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" 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" resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= 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: eventemitter3@^4.0.0:
version "4.0.4" version "4.0.4"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.4.tgz#b5463ace635a083d018bdc7c917b4c5f10a85384" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.4.tgz#b5463ace635a083d018bdc7c917b4c5f10a85384"
@ -10092,6 +10109,14 @@ onetime@^5.1.0:
dependencies: dependencies:
mimic-fn "^2.1.0" 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: opener@^1.5.1:
version "1.5.2" version "1.5.2"
resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598"
@ -13754,7 +13779,7 @@ url-parse-lax@^3.0.0:
dependencies: dependencies:
prepend-http "^2.0.0" prepend-http "^2.0.0"
url-parse@^1.4.3: url-parse@^1.4.3, url-parse@^1.4.7:
version "1.4.7" version "1.4.7"
resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278"
integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg== integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg==