mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
285 lines
9.5 KiB
TypeScript
285 lines
9.5 KiB
TypeScript
/**
|
|
* Copyright (c) OpenLens Authors. All rights reserved.
|
|
* Licensed under MIT License. See LICENSE in root directory for more information.
|
|
*/
|
|
|
|
import type { match } from "react-router";
|
|
import { matchPath } from "react-router";
|
|
import { countBy } from "lodash";
|
|
import { isDefined, iter } from "../utils";
|
|
import { pathToRegexp } from "path-to-regexp";
|
|
import logger from "../../main/logger";
|
|
import type Url from "url-parse";
|
|
import { RoutingError, RoutingErrorType } from "./error";
|
|
import type { ExtensionsStore } from "../../extensions/extensions-store/extensions-store";
|
|
import type { ExtensionLoader } from "../../extensions/extension-loader";
|
|
import type { LensExtension } from "../../extensions/lens-extension";
|
|
import type { RouteHandler, RouteParams } from "../../extensions/registries/protocol-handler";
|
|
import { when } from "mobx";
|
|
import { ipcRenderer } from "electron";
|
|
|
|
// IPC channel for protocol actions. Main broadcasts the open-url events to this channel.
|
|
export const ProtocolHandlerIpcPrefix = "protocol-handler";
|
|
|
|
export const ProtocolHandlerInternal = `${ProtocolHandlerIpcPrefix}:internal`;
|
|
export const ProtocolHandlerExtension = `${ProtocolHandlerIpcPrefix}:extension`;
|
|
export const ProtocolHandlerInvalid = `${ProtocolHandlerIpcPrefix}:invalid`;
|
|
|
|
/**
|
|
* These two names are long and cumbersome 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.
|
|
*/
|
|
export const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH";
|
|
export const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH";
|
|
|
|
/**
|
|
* Returned from routing attempts
|
|
*/
|
|
export enum RouteAttempt {
|
|
/**
|
|
* A handler was found in the set of registered routes
|
|
*/
|
|
MATCHED = "matched",
|
|
/**
|
|
* A handler was not found within the set of registered routes
|
|
*/
|
|
MISSING = "missing",
|
|
/**
|
|
* The extension that was matched in the route was not activated
|
|
*/
|
|
MISSING_EXTENSION = "no-extension",
|
|
}
|
|
|
|
export function foldAttemptResults(mainAttempt: RouteAttempt, rendererAttempt: RouteAttempt): RouteAttempt {
|
|
switch (mainAttempt) {
|
|
case RouteAttempt.MATCHED:
|
|
return RouteAttempt.MATCHED;
|
|
case RouteAttempt.MISSING:
|
|
case RouteAttempt.MISSING_EXTENSION:
|
|
return rendererAttempt;
|
|
}
|
|
}
|
|
|
|
export interface LensProtocolRouterDependencies {
|
|
readonly extensionLoader: ExtensionLoader;
|
|
readonly extensionsStore: ExtensionsStore;
|
|
}
|
|
|
|
export abstract class LensProtocolRouter {
|
|
// Map between path schemas and the handlers
|
|
protected internalRoutes = new Map<string, RouteHandler>();
|
|
|
|
public static readonly LoggingPrefix = "[PROTOCOL ROUTER]";
|
|
|
|
static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`;
|
|
|
|
constructor(protected readonly dependencies: LensProtocolRouterDependencies) {}
|
|
|
|
/**
|
|
* Attempts to route the given URL to all internal routes that have been registered
|
|
* @param url the parsed URL that initiated the `lens://` protocol
|
|
* @returns true if a route has been found
|
|
*/
|
|
protected _routeToInternal(url: Url<Record<string, string | undefined>>): RouteAttempt {
|
|
return this._route(this.internalRoutes.entries(), url);
|
|
}
|
|
|
|
/**
|
|
* 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: Iterable<[string, RouteHandler]>, url: Url<Record<string, string | undefined>>): null | [match<Record<string, string>>, RouteHandler] {
|
|
const matches: [match<Record<string, string>>, RouteHandler][] = [];
|
|
|
|
for (const [schema, handler] of routes) {
|
|
const match = matchPath(url.pathname, { path: schema });
|
|
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
|
|
// prefer an exact match
|
|
if (match.isExact) {
|
|
return [match, handler];
|
|
}
|
|
|
|
matches.push([match, handler]);
|
|
}
|
|
|
|
// if no exact match pick the one that is the most specific
|
|
return matches.sort(([a], [b]) => compareMatches(a, b))[0] ?? null;
|
|
}
|
|
|
|
/**
|
|
* find the most specific matching handler and call it
|
|
* @param routes the array of (path schemas, handler) pairs to match against
|
|
* @param url the url (in its current state)
|
|
*/
|
|
protected _route(routes: Iterable<[string, RouteHandler]>, url: Url<Record<string, string | undefined>>, extensionName?: string): RouteAttempt {
|
|
const route = this._findMatchingRoute(routes, url);
|
|
|
|
if (!route) {
|
|
const data: Record<string, string> = { url: url.toString() };
|
|
|
|
if (extensionName) {
|
|
data.extensionName = extensionName;
|
|
}
|
|
|
|
logger.info(`${LensProtocolRouter.LoggingPrefix}: No handler found`, data);
|
|
|
|
return RouteAttempt.MISSING;
|
|
}
|
|
|
|
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);
|
|
|
|
return RouteAttempt.MATCHED;
|
|
}
|
|
|
|
/**
|
|
* 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<Record<string, string | undefined>>): 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(isDefined).join("/");
|
|
|
|
const extensionLoader = this.dependencies.extensionLoader;
|
|
|
|
try {
|
|
/**
|
|
* Note, if `getInstanceByName` returns `null` that means we won't be getting an instance
|
|
*/
|
|
await when(() => extensionLoader.getInstanceByName(name) !== void 0, {
|
|
timeout: 5_000,
|
|
});
|
|
} catch (error) {
|
|
logger.info(
|
|
`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not installed (${error})`,
|
|
);
|
|
|
|
return name;
|
|
}
|
|
|
|
const extension = extensionLoader.getInstanceByName(name);
|
|
|
|
if (!extension) {
|
|
logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but does not have a class for ${ipcRenderer ? "renderer" : "main"}`);
|
|
|
|
return name;
|
|
}
|
|
|
|
if (!this.dependencies.extensionsStore.isEnabled(extension)) {
|
|
logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not enabled`);
|
|
|
|
return name;
|
|
}
|
|
|
|
logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched`);
|
|
|
|
return extension;
|
|
}
|
|
|
|
/**
|
|
* 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<Record<string, string | undefined>>): Promise<RouteAttempt> {
|
|
const extension = await this._findMatchingExtensionByName(url);
|
|
|
|
if (typeof extension === "string") {
|
|
// failed to find an extension, it returned its name
|
|
return RouteAttempt.MISSING_EXTENSION;
|
|
}
|
|
|
|
// 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 + 1));
|
|
|
|
try {
|
|
const handlers = iter.map(extension.protocolHandlers, ({ pathSchema, handler }) => [pathSchema, handler] as [string, RouteHandler]);
|
|
|
|
return this._route(handlers, url, extension.name);
|
|
} catch (error) {
|
|
if (error instanceof RoutingError) {
|
|
error.extensionName = extension.name;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a handler under the `lens://app` 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): this {
|
|
pathToRegexp(urlSchema); // verify now that the schema is valid
|
|
logger.info(`${LensProtocolRouter.LoggingPrefix}: internal registering ${urlSchema}`);
|
|
this.internalRoutes.set(urlSchema, handler);
|
|
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)["/"];
|
|
}
|