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

Add lens:// protocol handling with a routing mechanism

- 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:
Sebastian Malton 2021-01-11 16:33:19 -05:00
parent 21adda2c64
commit 8430b67d55
23 changed files with 983 additions and 106 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View 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:
![Lens Protocol Link Resolution](images/routing-diag.png)
Once matched, the handler would be called with the following argument (note both `"search"` and `"pathname"` will always be defined):
```json
{
"search": {
"text": "Hello"
},
"pathname": {
"type": "notification"
}
}
```
As the diagram above shows, the search (or query) params are not considered as part of the handler resolution.
If 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
View File

@ -0,0 +1,2 @@
fileAssociations:
- lens

View File

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

View 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 = {

View File

@ -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
View File

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

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

View File

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

View 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;
}

View File

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

View File

@ -0,0 +1,13 @@
/**
* Narrows `val` to include the property `key` (if true is returned)
* @param val The object to be tested
* @param key The key to test if it is present on the object
*/
export function hasOwnProperty<V extends object, K extends PropertyKey>(val: V, key: K): val is (V & { [key in K]: unknown }) {
// this call syntax is for when `val` was created by `Object.create(null)`
return Object.prototype.hasOwnProperty.call(val, key);
}
export function hasOwnProperties<V extends object, K extends PropertyKey>(val: V, ...keys: K[]): val is (V & { [key in K]: unknown}) {
return keys.every(key => hasOwnProperty(val, key));
}

View File

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

View File

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

View File

@ -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 = {

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

View File

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

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

View File

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

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

View File

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

View File

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