diff --git a/src/common/protocol-handler/error.ts b/src/common/protocol-handler/error.ts index 4bd7f057f9..d25516bb22 100644 --- a/src/common/protocol-handler/error.ts +++ b/src/common/protocol-handler/error.ts @@ -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"); } diff --git a/src/common/protocol-handler/router.ts b/src/common/protocol-handler/router.ts index 34bc7b9ece..f3a238cb1e 100644 --- a/src/common/protocol-handler/router.ts +++ b/src/common/protocol-handler/router.ts @@ -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; + + /** + * the matching parts of the path. The dynamic parts of the URI path. + */ pathname: Record; + + /** + * 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; - -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(); + 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>, RouteHandler] { + const matches: [match>, 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 { + interface ExtensionUrlMatch { + [EXTENSION_PUBLISHER_MATCH]: string; + [EXTENSION_NAME_MATCH]: string; + } + + const match = matchPath(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 { + 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(a: match, b: match): 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)["/"]; } diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index 855d902c00..77e35c339d 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -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 { + 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( diff --git a/src/extensions/extensions-store.ts b/src/extensions/extensions-store.ts index 41602be6df..8e88d22f38 100644 --- a/src/extensions/extensions-store.ts +++ b/src/extensions/extensions-store.ts @@ -35,10 +35,6 @@ export class ExtensionsStore extends BaseStore { return Boolean(state?.enabled); } - isEnabledByName(extName: string): boolean { - return this.enabledExtensions.includes(extName); - } - @action mergeState(extensionsState: Record) { this.state.merge(extensionsState); diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index aaa6f60ac5..a00e289e17 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -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) => 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) { diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 4dd9f0f4da..982830d8af 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -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(); - - lprm.removeExtensionHandlers(this.name); - - return super.disable(); - } - /** * Defines if extension is enabled for a given cluster. Defaults to `true`. */ async isEnabledForCluster(cluster: Cluster): Promise { 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///your/defined/path?with=query` - * or `lens://extensions//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(); - - lprm.extensionOn(this.name, pathSchema, handler); - } } diff --git a/src/extensions/registries/protocol-handler-registry.ts b/src/extensions/registries/protocol-handler-registry.ts new file mode 100644 index 0000000000..51d5eac43e --- /dev/null +++ b/src/extensions/registries/protocol-handler-registry.ts @@ -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; +} diff --git a/src/main/index.ts b/src/main/index.ts index 74e31c96ef..55a603e7d8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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().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() + .route(rawUrl) + .catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl })); }); // Extensions-api runtime exports diff --git a/src/main/protocol-handler/__test__/router.test.ts b/src/main/protocol-handler/__test__/router.test.ts index 41fb6b1b06..8c8093a5d4 100644 --- a/src/main/protocol-handler/__test__/router.test.ts +++ b/src/main/protocol-handler/__test__/router.test.ts @@ -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(); }); @@ -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; }); diff --git a/src/main/protocol-handler/router.ts b/src/main/protocol-handler/router.ts index a26ae554b6..fccdab836c 100644 --- a/src/main/protocol-handler/router.ts +++ b/src/main/protocol-handler/router.ts @@ -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(a: match, b: match): 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().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(); - - 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().removeExtensionHandlers(args.extensionName); +export interface FallbackHandler { + (name: string): Promise; } export class LensProtocolRouterMain extends proto.LensProtocolRouter { - private extentionRoutes = new Map>(); - private internalRoutes = new Map(); - 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 { - if (url.protocol.toLowerCase() !== "lens:") { - throw new proto.RoutingError(proto.RoutingErrorType.INVALID_PROTOCOL, url); - } + public async route(rawUrl: string): Promise { + 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 { + for (const handler of this.missingExtensionHandlers) { + if (await handler(extensionName)) { + return true; + } + } + + return false; } - private async _routeToExtension(url: Url) { - const match = matchPath(url.pathname, LensProtocolRouterMain.ExtensionUrlSchema); + protected async _findMatchingExtensionByName(url: Url): Promise { + 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, url: Url, matchExtension = false): void { - const matches = Array.from(routes.entries()) - .map(([schema, handler]): [match>, 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().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 { + // 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().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); } } diff --git a/src/renderer/protocol-handler/router.ts b/src/renderer/protocol-handler/router.ts index 8a42b7cdb9..c267e42977 100644 --- a/src/renderer/protocol-handler/router.ts +++ b/src/renderer/protocol-handler/router.ts @@ -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>(); - // Map between generated UUIDs and the handlers - private internalHandlers = new Map(); - /** * 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]); } }