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) | |
|
| [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 |
|
||||||
|
|||||||
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 * as utils from "../helpers/utils";
|
||||||
import { listHelmRepositories } from "../helpers/utils";
|
import { listHelmRepositories } from "../helpers/utils";
|
||||||
import { fail } from "assert";
|
import { fail } from "assert";
|
||||||
|
import open from "open";
|
||||||
|
|
||||||
|
|
||||||
jest.setTimeout(60000);
|
jest.setTimeout(60000);
|
||||||
@ -28,6 +29,17 @@ describe("Lens integration tests", () => {
|
|||||||
await app.client.waitUntilTextExists("h2", "Add Cluster");
|
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", () => {
|
describe("preferences page", () => {
|
||||||
it('shows "preferences"', async () => {
|
it('shows "preferences"', async () => {
|
||||||
const appName: string = process.platform === "darwin" ? "Lens" : "File";
|
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 * as util from "util";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
|
import fse from "fs-extra";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
const AppPaths: Partial<Record<NodeJS.Platform, string>> = {
|
interface AppTestingPaths {
|
||||||
"win32": "./dist/win-unpacked/Lens.exe",
|
testingPath: string,
|
||||||
"linux": "./dist/linux-unpacked/lens",
|
libraryPath: string,
|
||||||
"darwin": "./dist/mac/Lens.app/Contents/MacOS/Lens",
|
}
|
||||||
};
|
|
||||||
|
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) {
|
export function itIf(condition: boolean) {
|
||||||
return condition ? it : it.skip;
|
return condition ? it : it.skip;
|
||||||
@ -16,16 +39,20 @@ export function describeIf(condition: boolean) {
|
|||||||
return condition ? describe : describe.skip;
|
return condition ? describe : describe.skip;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setup(): Application {
|
export function setup(): AppConstructorOptions {
|
||||||
return new Application({
|
const appPath = getAppTestingPaths();
|
||||||
path: AppPaths[process.platform], // path to electron app
|
|
||||||
|
fse.removeSync(appPath.libraryPath); // remove old install config
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: appPath.testingPath,
|
||||||
args: [],
|
args: [],
|
||||||
startTimeout: 30000,
|
startTimeout: 30000,
|
||||||
waitTimeout: 60000,
|
waitTimeout: 60000,
|
||||||
env: {
|
env: {
|
||||||
CICD: "true"
|
CICD: "true"
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const keys = {
|
export const keys = {
|
||||||
|
|||||||
@ -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",
|
||||||
@ -160,7 +160,7 @@
|
|||||||
"nsis": {
|
"nsis": {
|
||||||
"include": "build/installer.nsh",
|
"include": "build/installer.nsh",
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"allowToChangeInstallationDirectory": true
|
"allowToChangeInstallationDirectory": true
|
||||||
},
|
},
|
||||||
"publish": [
|
"publish": [
|
||||||
{
|
{
|
||||||
@ -212,8 +212,10 @@
|
|||||||
"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",
|
||||||
|
"open": "^7.3.1",
|
||||||
"openid-client": "^3.15.2",
|
"openid-client": "^3.15.2",
|
||||||
"p-limit": "^3.1.0",
|
"p-limit": "^3.1.0",
|
||||||
"path-to-regexp": "^6.1.0",
|
"path-to-regexp": "^6.1.0",
|
||||||
@ -230,6 +232,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",
|
||||||
@ -285,6 +288,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",
|
||||||
@ -321,7 +325,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
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 "./downloadFile";
|
||||||
export * from "./escapeRegExp";
|
export * from "./escapeRegExp";
|
||||||
export * from "./tar";
|
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 { LensExtension } from "./lens-extension";
|
||||||
import { WindowManager } from "../main/window-manager";
|
import { WindowManager } from "../main/window-manager";
|
||||||
import { getExtensionPageUrl } from "./registries/page-registry";
|
import { getExtensionPageUrl } from "./registries/page-registry";
|
||||||
|
import { RouteHandler } from "../common/protocol-handler";
|
||||||
|
import { LensProtocolRouterMain } from "../main/protocol-handler";
|
||||||
|
|
||||||
export class LensMainExtension extends LensExtension {
|
export class LensMainExtension extends LensExtension {
|
||||||
appMenus: MenuRegistration[] = [];
|
appMenus: MenuRegistration[] = [];
|
||||||
@ -16,4 +18,15 @@ export class LensMainExtension extends LensExtension {
|
|||||||
|
|
||||||
await windowManager.navigate(pageUrl, frameId);
|
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 { LensExtension } from "./lens-extension";
|
||||||
import { getExtensionPageUrl } from "./registries/page-registry";
|
import { getExtensionPageUrl } from "./registries/page-registry";
|
||||||
import { CommandRegistration } from "./registries/command-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 {
|
export class LensRendererExtension extends LensExtension {
|
||||||
globalPages: PageRegistration[] = [];
|
globalPages: PageRegistration[] = [];
|
||||||
@ -31,8 +33,24 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 { installDeveloperTools } from "./developer-tools";
|
||||||
import { filesystemProvisionerStore } from "./extension-filesystem";
|
import { filesystemProvisionerStore } from "./extension-filesystem";
|
||||||
import { bindBroadcastHandlers } from "../common/ipc";
|
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);
|
const workingDir = path.join(app.getPath("appData"), appName);
|
||||||
let proxyPort: number;
|
let proxyPort: number;
|
||||||
@ -35,128 +38,141 @@ let clusterManager: ClusterManager;
|
|||||||
let windowManager: WindowManager;
|
let windowManager: WindowManager;
|
||||||
|
|
||||||
app.setName(appName);
|
app.setName(appName);
|
||||||
|
app.setAsDefaultProtocolClient("lens");
|
||||||
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
app.on("second-instance", () => {
|
app
|
||||||
windowManager?.ensureMainWindow();
|
.on("second-instance", () => {
|
||||||
});
|
windowManager?.ensureMainWindow();
|
||||||
|
})
|
||||||
|
.on("ready", async () => {
|
||||||
|
logger.info(`🚀 Starting Lens from "${workingDir}"`);
|
||||||
|
await shellSync();
|
||||||
|
|
||||||
if (process.env.LENS_DISABLE_GPU) {
|
bindBroadcastHandlers();
|
||||||
app.disableHardwareAcceleration();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.on("ready", async () => {
|
powerMonitor.on("shutdown", () => {
|
||||||
logger.info(`🚀 Starting Lens from "${workingDir}"`);
|
app.exit();
|
||||||
await shellSync();
|
});
|
||||||
|
|
||||||
bindBroadcastHandlers();
|
const updater = new AppUpdater();
|
||||||
|
|
||||||
powerMonitor.on("shutdown", () => {
|
updater.start();
|
||||||
app.exit();
|
|
||||||
});
|
|
||||||
|
|
||||||
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
|
// create cluster manager
|
||||||
await Promise.all([
|
clusterManager = ClusterManager.getInstance<ClusterManager>(proxyPort);
|
||||||
userStore.load(),
|
|
||||||
clusterStore.load(),
|
|
||||||
workspaceStore.load(),
|
|
||||||
extensionsStore.load(),
|
|
||||||
filesystemProvisionerStore.load(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// find free port
|
// run proxy
|
||||||
try {
|
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 {
|
|
||||||
// eslint-disable-next-line unused-imports/no-unused-vars-ts
|
// eslint-disable-next-line unused-imports/no-unused-vars-ts
|
||||||
proxyServer = LensProxy.create(proxyPort, clusterManager);
|
proxyServer = LensProxy.create(proxyPort, clusterManager);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Could not start proxy (127.0.0:${proxyPort}): ${error?.message}`);
|
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"}`);
|
dialog.showErrorBox("Lens Error", `Could not start proxy (127.0.0:${proxyPort}): ${error?.message || "unknown error"}`);
|
||||||
app.exit();
|
app.exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
extensionLoader.init();
|
extensionLoader.init();
|
||||||
extensionDiscovery.init();
|
extensionDiscovery.init();
|
||||||
windowManager = WindowManager.getInstance<WindowManager>(proxyPort);
|
windowManager = WindowManager.getInstance<WindowManager>(proxyPort);
|
||||||
|
|
||||||
// call after windowManager to see splash earlier
|
// call after windowManager to see splash earlier
|
||||||
try {
|
try {
|
||||||
const extensions = await extensionDiscovery.load();
|
const extensions = await extensionDiscovery.load();
|
||||||
|
|
||||||
// Start watching after bundled extensions are loaded
|
// Start watching after bundled extensions are loaded
|
||||||
extensionDiscovery.watchExtensions();
|
extensionDiscovery.watchExtensions();
|
||||||
|
|
||||||
// Subscribe to extensions that are copied or deleted to/from the extensions folder
|
// Subscribe to extensions that are copied or deleted to/from the extensions folder
|
||||||
extensionDiscovery.events.on("add", (extension: InstalledExtension) => {
|
extensionDiscovery.events.on("add", (extension: InstalledExtension) => {
|
||||||
extensionLoader.addExtension(extension);
|
extensionLoader.addExtension(extension);
|
||||||
});
|
});
|
||||||
extensionDiscovery.events.on("remove", (lensExtensionId: LensExtensionId) => {
|
extensionDiscovery.events.on("remove", (lensExtensionId: LensExtensionId) => {
|
||||||
extensionLoader.removeExtension(lensExtensionId);
|
extensionLoader.removeExtension(lensExtensionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
extensionLoader.initExtensions(extensions);
|
extensionLoader.initExtensions(extensions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dialog.showErrorBox("Lens Error", `Could not load extensions${error?.message ? `: ${error.message}` : ""}`);
|
dialog.showErrorBox("Lens Error", `Could not load extensions${error?.message ? `: ${error.message}` : ""}`);
|
||||||
console.error(error);
|
console.error(error);
|
||||||
console.trace();
|
console.trace();
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
appEventBus.emit({ name: "service", action: "start" });
|
appEventBus.emit({ name: "service", action: "start" });
|
||||||
}, 1000);
|
}, 1000);
|
||||||
});
|
})
|
||||||
|
.on("activate", (event, hasVisibleWindows) => {
|
||||||
|
logger.info("APP:ACTIVATE", { hasVisibleWindows });
|
||||||
|
|
||||||
app.on("activate", (event, hasVisibleWindows) => {
|
if (!hasVisibleWindows) {
|
||||||
logger.info("APP:ACTIVATE", { 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) {
|
return; // skip exit to make tray work, to quit go to app's global menu or tray's menu
|
||||||
windowManager?.initMainWindow(false);
|
})
|
||||||
}
|
.on("open-url", async (event, rawUrl) => {
|
||||||
});
|
// protocol handler for macOS
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
// Quit app on Cmd+Q (MacOS)
|
try {
|
||||||
app.on("will-quit", (event) => {
|
const url = new URLParse(rawUrl, true);
|
||||||
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
|
|
||||||
|
|
||||||
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
|
// Extensions-api runtime exports
|
||||||
export const LensExtensionsApi = {
|
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
|
// 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";
|
||||||
|
|||||||
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"
|
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"
|
||||||
@ -10081,6 +10086,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"
|
||||||
@ -13743,7 +13756,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==
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user