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

Convert the extension API to use an array for registering handlers

- switch design to execute both main and renderer handlers
  simaltaneously, without any overlap checking

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2021-01-20 17:03:48 -05:00
parent e7ee7fe2b0
commit 2544ba9e09
11 changed files with 321 additions and 467 deletions

View File

@ -10,6 +10,11 @@ export enum RoutingErrorType {
}
export class RoutingError extends Error {
/**
* Will be set if the routing error originated in an extension route table
*/
public extensionName?: string;
constructor(public type: RoutingErrorType, public url: Url) {
super("routing error");
}

View File

@ -1,204 +1,247 @@
import { hasOwnProperties, hasOwnProperty, Singleton } from "../utils";
import { match, matchPath } from "react-router";
import { countBy } from "lodash";
import { Singleton } from "../utils";
import { pathToRegexp } from "path-to-regexp";
import logger from "../../main/logger";
import Url from "url-parse";
import { RoutingError, RoutingErrorType } from "./error";
import { extensionsStore } from "../../extensions/extensions-store";
import { extensionLoader } from "../../extensions/extension-loader";
import { LensExtension } from "../../extensions/lens-extension";
const ProtocolHandlerIpcPrefix = "protocol-handler";
// IPC channel for protocol actions. Main broadcasts the open-url events to this channel.
export const ProtocolHandlerIpcPrefix = "protocol-handler";
export const ProtocolHandlerRegister = `${ProtocolHandlerIpcPrefix}:register`;
export const ProtocolHandlerDeregister = `${ProtocolHandlerIpcPrefix}:deregister`;
export const ProtocolHandlerBackChannel = `${ProtocolHandlerIpcPrefix}:back-channel`;
export const ProtocolHandlerInternal = `${ProtocolHandlerIpcPrefix}:internal`;
export const ProtocolHandlerExtension= `${ProtocolHandlerIpcPrefix}:extension`;
/**
* These two names are long and cubersome by design so as to decrease the chances
* of an extension using the same names.
*
* Though under the current (2021/01/18) implementation, these are never matched
* against in the final matching so their names are less of a concern.
*/
const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH";
const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH";
/**
* The collection of the dynamic parts of a URI which initiated a `lens://`
* protocol request
*/
export interface RouteParams {
/**
* the parts of the URI query string
*/
search: Record<string, string>;
/**
* the matching parts of the path. The dynamic parts of the URI path.
*/
pathname: Record<string, string>;
/**
* if the most specific path schema that is matched does not cover the whole
* of the URI's path. Then this field will be set to the remaining path
* segments.
*
* Example:
*
* If the path schema `/landing/:type` is the matched schema for the URI
* `/landing/soft/easy` then this field will be set to `"/easy"`.
*/
tail?: string;
}
export type RouteHandler = (params: RouteParams) => void;
export type FallbackHandler = (name: string) => Promise<boolean>;
export enum HandlerType {
INTERNAL = "internal",
EXTENSION = "extension",
/**
* RouteHandler represents the function signature of the handler function for
* `lens://` protocol routing.
*/
export interface RouteHandler {
(params: RouteParams): void;
}
interface ExtensionParams {
handlerType: HandlerType.EXTENSION,
extensionName: string,
}
interface InternalParams {
handlerType: HandlerType.INTERNAL,
}
type BaseParams = (ExtensionParams | InternalParams);
export type RegisterParams = BaseParams & {
handlerId: string,
pathSchema: string,
};
export interface DeregisterParams {
extensionName: string,
}
export type BackChannelParams = BaseParams & {
params: RouteParams;
handlerId: string,
};
export abstract class LensProtocolRouter extends Singleton {
// Map between path schemas and the handlers
protected internalRoutes = new Map<string, RouteHandler>();
public static readonly LoggingPrefix = "[PROTOCOL ROUTER]";
public abstract on(urlSchema: string, handler: RouteHandler): void;
public abstract extensionOn(extName: string, urlSchema: string, handler: RouteHandler): void;
public abstract removeExtensionHandlers(extName: string): void;
}
protected static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`;
/**
* 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;
/**
*
* @param url the parsed URL that initiated the `lens://` protocol
*/
protected _routeToInternal(url: Url): void {
this._route(Array.from(this.internalRoutes.entries()), url);
}
if (!hasOwnProperty(args, "handlerType")) {
return false;
}
/**
* match against all matched URIs, returning either the first exact match or
* the most specific match if none are exact.
* @param routes the array of path schemas, handler pairs to match against
* @param url the url (in its current state)
*/
protected _findMatchingRoute(routes: [string, RouteHandler][], url: Url): null | [match<Record<string, string>>, RouteHandler] {
const matches: [match<Record<string, string>>, RouteHandler][] = [];
const { handlerType } = args;
for (const [schema, handler] of routes) {
const match = matchPath(url.pathname, { path: schema });
if (handlerType === HandlerType.INTERNAL) {
// handlerType must either be HandlerType.INTERNAL
return true;
}
if (!match) {
continue;
}
if (handlerType === HandlerType.EXTENSION) {
if (!hasOwnProperty(args, "extensionName")) {
return false;
// prefer an exact match
if (match.isExact) {
return [match, handler];
}
matches.push([match, handler]);
}
// or handlerType must be HandlerType.EXTENSION
const { extensionName } = args;
// but if for an extension then the extensionName is required, must be a stirng, and must be non-empty
return Boolean(extensionName && typeof extensionName === "string");
// if no exact match pick the one that is the most specific
return matches.sort(([a], [b]) => compareMatches(a, b))[0] ?? null;
}
// reject all other values of handlerType
return false;
}
/**
* find the most specific matching handler and call it
* @param routes the array of (path schemas, handler) paris to match against
* @param url the url (in its current state)
*/
protected _route(routes: [string, RouteHandler][], url: Url): void {
const route = this._findMatchingRoute(routes, url);
/**
* 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 (!route) {
throw new RoutingError(RoutingErrorType.NO_HANDLER, url);
}
const [match, handler] = route;
const params: RouteParams = {
pathname: match.params,
search: url.query,
};
if (!match.isExact) {
params.tail = url.pathname.slice(match.url.length);
}
handler(params);
}
if (!hasOwnProperties(args, "handlerId", "pathSchema")) {
return false;
/**
* Tries to find the matching LensExtension instance
*
* Note: this needs to be async so that `main`'s overloaded version can also be async
* @param url the protocol request URI that was "open"-ed
* @returns either the found name or the instance of `LensExtension`
*/
protected async _findMatchingExtensionByName(url: Url): Promise<LensExtension | string> {
interface ExtensionUrlMatch {
[EXTENSION_PUBLISHER_MATCH]: string;
[EXTENSION_NAME_MATCH]: string;
}
const match = matchPath<ExtensionUrlMatch>(url.pathname, LensProtocolRouter.ExtensionUrlSchema);
if (!match) {
throw new RoutingError(RoutingErrorType.NO_EXTENSION_ID, url);
}
const { [EXTENSION_PUBLISHER_MATCH]: publisher, [EXTENSION_NAME_MATCH]: partialName } = match.params;
const name = [publisher, partialName].filter(Boolean).join("/");
const extension = extensionLoader.userExtensionsByName.get(name);
if (!extension) {
logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not installed`);
return name;
}
if (!extensionsStore.isEnabled(extension.id)) {
logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not enabled`);
return name;
}
logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched`);
return extension;
}
if (typeof args.handlerId !== "string" || args.handlerId.length === 0) {
// handlerId is required, must be a string, must be non-empty
return false;
}
/**
* Find a matching extension by the first one or two path segments of `url` and then try to `_route`
* its correspondingly registered handlers.
*
* If no handlers are found or the extension is not enabled then `_missingHandlers` is called before
* checking if more handlers have been added.
*
* Note: this function modifies its argument, do not reuse
* @param url the protocol request URI that was "open"-ed
*/
protected async _routeToExtension(url: Url): Promise<void> {
const extension = await this._findMatchingExtensionByName(url);
if (typeof args.pathSchema !== "string" || args.pathSchema.length === 0) {
// pathSchema is required, must be a string, must be non-empty
return false;
}
if (typeof extension === "string") {
// failed to find an extension, it returned its name
return;
}
return true;
}
// remove the extension name from the path name so we don't need to match on it anymore
url.set("pathname", url.pathname.slice(extension.name.length));
/**
* This function validates that `args` is at least `DeregisterParams`
* @param args a deserialized value
*/
export function validateDeregisterParams(args: unknown): args is DeregisterParams {
if (!args || typeof args !== "object") {
// it must be an object
return false;
}
const handlers = extension
.protocolHandlers
.map<[string, RouteHandler]>(({ pathSchema, handler }) => [pathSchema, handler]);
if (!hasOwnProperties(args, "extensionName")) {
return false;
}
try {
this._route(handlers, url);
} catch (error) {
if (error instanceof RoutingError) {
error.extensionName = extension.name;
}
if (typeof args.extensionName !== "string" || args.extensionName.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 || 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;
throw error;
}
}
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;
}
/**
* Add a handler under the `lens://internal` tree of routing.
* @param pathSchema the URI path schema to match against for this handler
* @param handler a function that will be called if a protocol path matches
*/
public addInternalHandler(urlSchema: string, handler: RouteHandler): void {
pathToRegexp(urlSchema); // verify now that the schema is valid
logger.info(`${LensProtocolRouter.LoggingPrefix}: internal registering ${urlSchema}`);
this.internalRoutes.set(urlSchema, handler);
}
return true;
/**
* Remove an internal protocol handler.
* @param pathSchema the path schema that the handler was registered under
*/
public removeInternalHandler(urlSchema: string): void {
this.internalRoutes.delete(urlSchema);
}
}
/**
* This function validates that `args` is at least `BackChannelParams`
* @param args a deserialized value
* 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
*/
export function validateBackChannelParams(args: unknown): args is BackChannelParams {
if (!validateBaseParams(args)) {
return false;
function compareMatches<T>(a: match<T>, b: match<T>): number {
if (a.path === "/") {
return 1;
}
if (!hasOwnProperties(args, "handlerId", "params")) {
return false;
if (b.path === "/") {
return -1;
}
if (!validateRouteParams(args.params)) {
return false;
}
if (typeof args.handlerId !== "string" || args.handlerId.length === 0) {
return false;
}
return true;
return countBy(b.path)["/"] - countBy(a.path)["/"];
}

View File

@ -14,7 +14,7 @@ import type { LensRendererExtension } from "./lens-renderer-extension";
import * as registries from "./registries";
import fs from "fs";
// lazy load so that we get correct userData
export function extensionPackagesRoot() {
return path.join((app || remote.app).getPath("userData"));
}
@ -52,6 +52,20 @@ export class ExtensionLoader {
return extensions;
}
@computed get userExtensionsByName(): Map<string, LensExtension> {
const res = new Map();
for (const [, val] of this.instances) {
if (val.isBundled) {
continue;
}
res.set(val.manifest.name, val);
}
return res;
}
// Transform userExtensions to a state object for storing into ExtensionsStore
@computed get storeState() {
return Object.fromEntries(

View File

@ -35,10 +35,6 @@ export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> {
return Boolean(state?.enabled);
}
isEnabledByName(extName: string): boolean {
return this.enabledExtensions.includes(extName);
}
@action
mergeState(extensionsState: Record<LensExtensionId, LensExtensionState>) {
this.state.merge(extensionsState);

View File

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

View File

@ -3,8 +3,6 @@ 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[] = [];
@ -30,35 +28,10 @@ export class LensRendererExtension extends LensExtension {
navigate(pageUrl);
}
async disable() {
const lprm = LensProtocolRouterRenderer.getInstance<LensProtocolRouterRenderer>();
lprm.removeExtensionHandlers(this.name);
return super.disable();
}
/**
* Defines if extension is enabled for a given cluster. Defaults to `true`.
*/
async isEnabledForCluster(cluster: Cluster): Promise<Boolean> {
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

@ -0,0 +1,10 @@
import { RouteHandler } from "../../common/protocol-handler";
/**
* ProtocolHandlerRegistration is the data required for an extension to register
* a handler to a specific path or dynamic path.
*/
export interface ProtocolHandlerRegistration {
pathSchema: string;
handler: RouteHandler;
}

View File

@ -28,8 +28,6 @@ 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;
@ -157,21 +155,14 @@ app
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
.on("open-url", (event, rawUrl) => {
// lens:// protocol handler
event.preventDefault();
try {
const url = new URLParse(rawUrl, true);
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 });
}
}
LensProtocolRouterMain
.getInstance<LensProtocolRouterMain>()
.route(rawUrl)
.catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl }));
});
// Extensions-api runtime exports

View File

@ -1,6 +1,7 @@
import { LensProtocolRouterMain } from "../router";
import Url from "url-parse";
import { noop } from "../../../common/utils";
import { extensionsStore } from "../../../extensions/extensions-store";
function throwIfDefined(val: any): void {
if (val != null) {
@ -12,6 +13,7 @@ describe("protocol router tests", () => {
let lpr: LensProtocolRouterMain;
beforeEach(() => {
(extensionsStore as any).state.clear();
LensProtocolRouterMain.resetInstance();
lpr = LensProtocolRouterMain.getInstance<LensProtocolRouterMain>();
});
@ -33,6 +35,7 @@ describe("protocol router tests", () => {
});
it("should not throw when has valid host", async () => {
(extensionsStore as any).state.set("@mirantis/minikube", { enabled: true, name: "@mirantis/minikube" });
lpr.on("/", noop);
lpr.extensionOn("@mirantis/minikube", "/", noop);
@ -83,6 +86,7 @@ describe("protocol router tests", () => {
});
it("should call most exact handler for an extension", async () => {
(extensionsStore as any).state.set("@foobar/icecream", { enabled: true, name: "@foobar/icecream" });
let called: any = 0;
lpr.extensionOn("@foobar/icecream", "/page", () => { called = 1; });
@ -99,6 +103,8 @@ describe("protocol router tests", () => {
});
it("should work with non-org extensions", async () => {
(extensionsStore as any).state.set("@foobar/icecream", { enabled: true, name: "@foobar/icecream" });
(extensionsStore as any).state.set("icecream", { enabled: true, name: "icecream" });
let called: any = 0;
lpr.extensionOn("icecream", "/page", () => { called = 1; });

View File

@ -1,224 +1,100 @@
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";
import { extensionsStore } from "../../extensions/extensions-store";
import Url from "url-parse";
import { LensExtension } from "../../extensions/lens-extension";
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.extensionName, 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.extensionName);
export interface FallbackHandler {
(name: string): Promise<boolean>;
}
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}`;
private missingExtensionHandlers: FallbackHandler[] = [];
/**
* route the given URL to
* Find the most specific registered handler, if it exists, and invoke it.
*
* This will send an IPC message to the renderer router to do the same
* in the renderer.
*/
public async route(url: Url): Promise<void> {
if (url.protocol.toLowerCase() !== "lens:") {
throw new proto.RoutingError(proto.RoutingErrorType.INVALID_PROTOCOL, url);
}
public async route(rawUrl: string): Promise<void> {
try {
const url = new Url(rawUrl, true);
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);
if (url.protocol.toLowerCase() !== "lens:") {
throw new proto.RoutingError(proto.RoutingErrorType.INVALID_PROTOCOL, url);
}
logger.info(`${proto.LensProtocolRouter.LoggingPrefix}: routing ${url.toString()}`);
switch (url.host) {
case "internal":
return this._routeToInternal(url);
case "extension":
return this._routeToExtension(url);
default:
throw new proto.RoutingError(proto.RoutingErrorType.INVALID_HOST, url);
}
} catch (error) {
if (error instanceof proto.RoutingError) {
logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ${error}`, { url: error.url });
} else {
logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ${error}`, { rawUrl });
}
}
}
public registerIpcHandlers(): void {
ipcMain
.on(proto.ProtocolHandlerRegister, registerIpcHandler)
.on(proto.ProtocolHandlerDeregister, deregisterIpcHandler);
protected async _executeMissingExtensionHandlers(extensionName: string): Promise<boolean> {
for (const handler of this.missingExtensionHandlers) {
if (await handler(extensionName)) {
return true;
}
}
return false;
}
private async _routeToExtension(url: Url) {
const match = matchPath<ExtensionUrlMatch>(url.pathname, LensProtocolRouterMain.ExtensionUrlSchema);
protected async _findMatchingExtensionByName(url: Url): Promise<LensExtension | string> {
const firstAttempt = await super._findMatchingExtensionByName(url);
if (!match) {
throw new proto.RoutingError(proto.RoutingErrorType.NO_EXTENSION_ID, url);
if (typeof firstAttempt !== "string") {
return firstAttempt;
}
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 || !extensionsStore.isEnabledByName(name)) {
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;
}
if (await this._executeMissingExtensionHandlers(firstAttempt)) {
return super._findMatchingExtensionByName(url);
}
this._route(routes, url, true);
return "";
}
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, "/");
}
protected _routeToInternal(url: Url): void {
super._routeToExtension(url);
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,
WindowManager.getInstance<WindowManager>().sendToView({
channel: proto.ProtocolHandlerInternal,
data: [url],
});
}
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);
}
protected async _routeToExtension(url: Url): Promise<void> {
// this needs to be done first, so that the missing extension handlers can
// be called before notifying the renderer.
await super._routeToExtension(url);
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);
WindowManager.getInstance<WindowManager>().sendToView({
channel: proto.ProtocolHandlerExtension,
data: [url],
});
}
/**
* 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
* Add a function to the list which will be sequentially called if an extension
* is not found while routing to the extensions
* @param handler A function that tries to find an extension
*/
public onMissingExtension(handler: proto.FallbackHandler): void {
public addMissingExtensionHandler(handler: FallbackHandler): void {
this.missingExtensionHandlers.push(handler);
}
}

View File

@ -1,92 +1,29 @@
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 names 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);
ipcRenderer
.on(proto.ProtocolHandlerInternal, this.ipcInternalHandler)
.on(proto.ProtocolHandlerExtension, this.ipcExtensionHandler);
}
@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 });
private ipcInternalHandler(event: Electron.IpcRendererEvent, ...args: any[]): void {
if (args.length !== 1) {
return void logger.warn(`${proto.LensProtocolRouter.LoggingPrefix}: unexpected number of args`, { args });
}
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, extensionName } = args;
const handler = this.extensionHandlers.get(handlerId)?.get(extensionName);
if (!handler) {
return void logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ipc call to "${proto.ProtocolHandlerBackChannel}" unknown handlerId or unknown extensionId`, { args });
}
return handler(params);
}
console.log(args[0]);
}
private ipcExtensionHandler(event: Electron.IpcRendererEvent, ...args: any[]): void {
if (args.length !== 1) {
return void logger.warn(`${proto.LensProtocolRouter.LoggingPrefix}: unexpected number of args`, { args });
}
}
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(extensionName: string, pathSchema: string, handler: proto.RouteHandler): void {
const handlerId = uuid.v4();
const args: proto.RegisterParams = {
handlerType: proto.HandlerType.EXTENSION,
extensionName,
pathSchema,
handlerId,
};
this.extensionHandlers
.set(extensionName, this.extensionHandlers.get(extensionName) ?? new Map())
.get(extensionName)
.set(handlerId, handler);
ipcRenderer.send(proto.ProtocolHandlerRegister, args);
}
public removeExtensionHandlers(extensionName: string): void {
const args: proto.DeregisterParams = {
extensionName,
};
ipcRenderer.send(proto.ProtocolHandlerDeregister, args);
this.extensionHandlers.delete(extensionName);
console.log(args[0]);
}
}