mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Add lens:// protocol handling with a routing mechanism (#1949)
- Add lens:// protocol handling with a routing mechanism - document the methods in an extension guide - remove handlers when an extension is deactivated or removed - make sure that the found extension when routing a request is currently enabled (as a backup) - added documentation about the above behaviour to the guide - tweaked the naming convention so that it is clearer that the router uses extension names as not IDs (which currently are folder paths) - Convert the extension API to use an array for registering handlers - switch design to execute both main and renderer handlers simultaneously, without any overlap checking - change open to be a dev dep - improve docs, export types for extensions, skip integration tests - switch to event emitting renderer being ready - Add logging and fix renderer:loaded send to main Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
1 parent
38c3734d5c
commit
1470103fd4
33 files changed
+1010
-40
No files matched your search
@@ -47,7 +47,7 @@ export async function broadcastMessage(channel: string, ...args: any[]) {
|
||||
view.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[IPC]: failed to send IPC message", { error });
|
||||
logger.error("[IPC]: failed to send IPC message", { error: String(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import Url from "url-parse";
|
||||
|
||||
export enum RoutingErrorType {
|
||||
INVALID_PROTOCOL = "invalid-protocol",
|
||||
INVALID_HOST = "invalid-host",
|
||||
INVALID_PATHNAME = "invalid-pathname",
|
||||
NO_HANDLER = "no-handler",
|
||||
NO_EXTENSION_ID = "no-ext-id",
|
||||
MISSING_EXTENSION = "missing-ext",
|
||||
}
|
||||
|
||||
export class RoutingError extends Error {
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
switch (this.type) {
|
||||
case RoutingErrorType.INVALID_HOST:
|
||||
return "invalid host";
|
||||
case RoutingErrorType.INVALID_PROTOCOL:
|
||||
return "invalid protocol";
|
||||
case RoutingErrorType.INVALID_PATHNAME:
|
||||
return "invalid pathname";
|
||||
case RoutingErrorType.NO_EXTENSION_ID:
|
||||
return "no extension ID";
|
||||
case RoutingErrorType.MISSING_EXTENSION:
|
||||
return "extension not found";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./error";
|
||||
export * from "./router";
|
||||
@@ -0,0 +1,218 @@
|
||||
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";
|
||||
import { RouteHandler, RouteParams } from "../../extensions/registries/protocol-handler-registry";
|
||||
|
||||
// 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`;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH";
|
||||
const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH";
|
||||
|
||||
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]";
|
||||
|
||||
protected static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param url the parsed URL that initiated the `lens://` protocol
|
||||
*/
|
||||
protected _routeToInternal(url: Url): void {
|
||||
this._route(Array.from(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: [string, RouteHandler][], url: Url): 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) paris to match against
|
||||
* @param url the url (in its current state)
|
||||
*/
|
||||
protected _route(routes: [string, RouteHandler][], url: Url, extensionName?: string): void {
|
||||
const route = this._findMatchingRoute(routes, url);
|
||||
|
||||
if (!route) {
|
||||
const data: Record<string, string> = { url: url.toString() };
|
||||
|
||||
if (extensionName) {
|
||||
data.extensionName = extensionName;
|
||||
}
|
||||
|
||||
return void logger.info(`${LensProtocolRouter.LoggingPrefix}: No handler found`, data);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 extension === "string") {
|
||||
// failed to find an extension, it returned its name
|
||||
return;
|
||||
}
|
||||
|
||||
// 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));
|
||||
|
||||
const handlers = extension
|
||||
.protocolHandlers
|
||||
.map<[string, RouteHandler]>(({ pathSchema, handler }) => [pathSchema, handler]);
|
||||
|
||||
try {
|
||||
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): void {
|
||||
pathToRegexp(urlSchema); // verify now that the schema is valid
|
||||
logger.info(`${LensProtocolRouter.LoggingPrefix}: internal registering ${urlSchema}`);
|
||||
this.internalRoutes.set(urlSchema, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)["/"];
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
import { AbortController } from "abort-controller";
|
||||
|
||||
/**
|
||||
* Return a promise that will be resolved after at least `timeout` ms have
|
||||
* passed
|
||||
* passed. If `failFast` is provided then the promise is also resolved if it has
|
||||
* been aborted.
|
||||
* @param timeout The number of milliseconds before resolving
|
||||
* @param failFast An abort controller instance to cause the delay to short-circuit
|
||||
*/
|
||||
export function delay(timeout = 1000): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, timeout));
|
||||
export function delay(timeout = 1000, failFast?: AbortController): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
const timeoutId = setTimeout(resolve, timeout);
|
||||
|
||||
failFast?.signal.addEventListener("abort", () => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -18,4 +18,4 @@ export * from "./openExternal";
|
||||
export * from "./downloadFile";
|
||||
export * from "./escapeRegExp";
|
||||
export * from "./tar";
|
||||
export * from "./delay";
|
||||
export * from "./type-narrowing";
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Narrows `val` to include the property `key` (if true is returned)
|
||||
* @param val The object to be tested
|
||||
* @param key The key to test if it is present on the object
|
||||
*/
|
||||
export function hasOwnProperty<V extends object, K extends PropertyKey>(val: V, key: K): val is (V & { [key in K]: unknown }) {
|
||||
// this call syntax is for when `val` was created by `Object.create(null)`
|
||||
return Object.prototype.hasOwnProperty.call(val, key);
|
||||
}
|
||||
|
||||
export function hasOwnProperties<V extends object, K extends PropertyKey>(val: V, ...keys: K[]): val is (V & { [key in K]: unknown}) {
|
||||
return keys.every(key => hasOwnProperty(val, key));
|
||||
}
|
||||
Reference in new issue
Block a user