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
- document the methods in an extension guide - add integration test to make sure that all tested OSes work as intended Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
parent
21adda2c64
commit
8430b67d55
@ -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 <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 |
|
||||
[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 |
|
||||
|
||||
BIN
docs/extensions/guides/images/routing-diag.png
Normal file
BIN
docs/extensions/guides/images/routing-diag.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
47
docs/extensions/guides/protocol-handlers.md
Normal file
47
docs/extensions/guides/protocol-handlers.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Lens Protocol Handlers
|
||||
|
||||
Lens has a file association with the `lens://` protocol.
|
||||
This means that Lens can be opened by external programs by providing a link that has `lens` as its protocol.
|
||||
Lens provides a routing mechanism that extensions can use to register custom handlers.
|
||||
|
||||
## Registering A Protocol Handler
|
||||
|
||||
The method `onProtocolRequest` exists both on [`LensMainExtension`](extensions/api/classes/lensmainextension/#onprotocolrequest) and on [`LensRendererExtension`](extensions/api/classes/lensrendererextension/#onprotocolrequest).
|
||||
This is how, as an extension developer, you can register handlers for your extension.
|
||||
The `pathSchema` argument must comply with the [path-to-regexp](https://www.npmjs.com/package/path-to-regexp) package's `compileToRegex` function.
|
||||
|
||||
Once you have registered a handler it will be called when a user opens a link on their computer.
|
||||
The routing mechanism for extensions is quite straight forward.
|
||||
For example consider an extension `example-extension` which is published by the `@mirantis` org.
|
||||
If it were to register a handler with `"/display/:type"` as its corresponding link then we would match the following URI like this:
|
||||
|
||||

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