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:
parent
e7ee7fe2b0
commit
2544ba9e09
@ -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");
|
||||
}
|
||||
|
||||
@ -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)["/"];
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
10
src/extensions/registries/protocol-handler-registry.ts
Normal file
10
src/extensions/registries/protocol-handler-registry.ts
Normal 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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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; });
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user