Files
NetMesh/electron/plugins/browserPluginRuntime.cjs

322 lines
12 KiB
JavaScript
Raw Permalink Normal View History

"use strict";
const { randomUUID } = require("node:crypto");
const path = require("node:path");
const {
PLUGIN_ACTIVATION_TIMEOUT_MS,
PLUGIN_DEACTIVATION_TIMEOUT_MS,
PLUGIN_PROTOCOL_SCHEME,
} = require("./constants.cjs");
const { PluginRpcRouter } = require("./rpcRouter.cjs");
function encodePackagePath(packagePath) {
return packagePath.split("/").map(encodeURIComponent).join("/");
}
function waitForLoad(webContents) {
return new Promise((resolve, reject) => {
const cleanup = () => {
webContents.removeListener("did-finish-load", loaded);
webContents.removeListener("did-fail-load", failed);
};
const loaded = () => { cleanup(); resolve(); };
const failed = (_event, code, description) => {
cleanup();
reject(new Error(`Plugin browser failed to load (${code}): ${description}`));
};
webContents.once("did-finish-load", loaded);
webContents.once("did-fail-load", failed);
});
}
function normalizeConsoleLevel(level) {
if (level === "error" || (typeof level === "number" && level >= 3)) return "error";
if (level === "warning" || level === "warn" || level === 2) return "warn";
if (level === "debug" || level === 0) return "debug";
return "info";
}
function sanitizePluginSource(source) {
try { return new URL(String(source)).pathname.slice(0, 512); }
catch { return "[plugin resource]"; }
}
class BrowserPluginRuntime {
constructor(options) {
this.electron = options.electron;
this.protocol = options.protocol;
this.plugin = options.plugin;
this.packageRoot = options.packageRoot;
this.preloadPath = options.preloadPath;
this.requestHandlers = options.requestHandlers ?? options.handlers;
this.notificationHandlers = options.notificationHandlers ?? options.handlers;
this.onIncomingStream = options.onIncomingStream;
this.onBeforeMessage = options.onBeforeMessage;
this.onProgress = options.onProgress;
this.onProcessReady = options.onProcessReady ?? (() => {});
this.logger = options.logger ?? { write() {} };
this.onExit = options.onExit ?? (() => {});
this.onProtocolError = options.onProtocolError ?? (() => {});
this.window = null;
this.router = null;
this.registration = null;
this.sessionRegistration = null;
this.port = null;
this.stopping = false;
}
#assertStarting(signal) {
signal?.throwIfAborted();
if (this.stopping) throw new Error("Plugin browser runtime startup was stopped");
}
async start(runtimeConfig, options = {}) {
const { signal, getActivationEnvironment } = options;
this.#assertStarting(signal);
const { BrowserWindow, MessageChannelMain, session } = this.electron;
if (!BrowserWindow || !MessageChannelMain || !session?.fromPartition) {
throw new Error("Electron browser plugin runtime is unavailable");
}
const partition = `netcatty-plugin-${randomUUID()}`;
const pluginSession = session.fromPartition(partition, { cache: false });
const config = { ...runtimeConfig };
this.registration = this.protocol.registerRuntime({
pluginId: this.plugin.id,
packageRoot: this.packageRoot,
config,
});
config.entryUrl = `${PLUGIN_PROTOCOL_SCHEME}://${this.registration.token}/package/${encodePackagePath(
this.plugin.manifest.main.browser,
)}`;
this.sessionRegistration = this.protocol.registerSession(pluginSession);
await pluginSession.setProxy({
mode: "fixed_servers",
proxyRules: "http=127.0.0.1:9;https=127.0.0.1:9;socks=127.0.0.1:9",
proxyBypassRules: "<-loopback>",
});
this.#assertStarting(signal);
pluginSession.enableNetworkEmulation({ offline: true });
pluginSession.on("will-download", (event) => event.preventDefault());
pluginSession.setPermissionCheckHandler?.(() => false);
pluginSession.setPermissionRequestHandler?.((_webContents, _permission, callback) => callback(false));
pluginSession.setDevicePermissionHandler?.(() => false);
pluginSession.webRequest.onBeforeRequest((details, callback) => {
try {
const url = new URL(details.url);
const cancel = url.protocol !== `${PLUGIN_PROTOCOL_SCHEME}:` || url.hostname !== this.registration.token;
callback({ cancel });
} catch {
callback({ cancel: true });
}
});
pluginSession.webRequest.onErrorOccurred((details) => {
let requestPath = "unknown";
try { requestPath = new URL(details.url).pathname.slice(0, 1_024); } catch {}
void this.logger.write("error", "Plugin browser resource failed", {
error: details.error,
requestPath,
resourceType: details.resourceType,
});
});
pluginSession.webRequest.onCompleted((details) => {
if (details.statusCode < 400) return;
let requestPath = "unknown";
try { requestPath = new URL(details.url).pathname.slice(0, 1_024); } catch {}
void this.logger.write("error", "Plugin browser resource returned an error", {
requestPath,
resourceType: details.resourceType,
statusCode: details.statusCode,
});
});
this.window = new BrowserWindow({
show: false,
skipTaskbar: true,
paintWhenInitiallyHidden: false,
webPreferences: {
sandbox: true,
nodeIntegration: false,
nodeIntegrationInSubFrames: false,
nodeIntegrationInWorker: false,
contextIsolation: true,
webSecurity: true,
javascript: true,
images: false,
webgl: false,
plugins: false,
devTools: false,
disableDialogs: true,
safeDialogs: true,
navigateOnDragDrop: false,
backgroundThrottling: false,
spellcheck: false,
session: pluginSession,
preload: this.preloadPath,
},
});
this.onProcessReady();
const contents = this.window.webContents;
contents.setWebRTCIPHandlingPolicy("disable_non_proxied_udp");
contents.on("console-message", (details) => {
const resolvedMessage = details?.message ?? "Plugin renderer console message";
const resolvedLevel = details?.level ?? "info";
const logLevel = normalizeConsoleLevel(resolvedLevel);
void this.logger.write(logLevel, resolvedMessage, {
line: details?.lineNumber ?? 0,
source: sanitizePluginSource(details?.sourceId ?? ""),
});
});
contents.setWindowOpenHandler(() => ({ action: "deny" }));
contents.on("will-navigate", (event, url) => {
if (url !== this.registration.url) event.preventDefault();
});
contents.on("will-attach-webview", (event) => event.preventDefault());
contents.on("render-process-gone", (_event, details) => {
this.#handleExit(new Error(`Plugin renderer exited: ${details.reason}`));
});
this.window.on("closed", () => this.#handleExit(new Error("Plugin renderer closed")));
const { port1, port2 } = new MessageChannelMain();
this.port = port1;
this.router = new PluginRpcRouter({
pluginId: this.plugin.id,
send: (message, transfer = []) => port1.postMessage(message, transfer),
requestHandlers: this.requestHandlers,
notificationHandlers: this.notificationHandlers,
onBeforeMessage: this.onBeforeMessage,
onIncomingStream: this.onIncomingStream,
onProgress: this.onProgress,
onProtocolError: (error) => {
this.onProtocolError(error);
this.#handleExit(error);
},
});
port1.on("message", (event) => this.router?.accept(event.data));
port1.on("close", () => this.#handleExit(new Error("Plugin message port closed")));
port1.start();
let resolvePreloadReady;
const preloadReady = new Promise((resolve) => { resolvePreloadReady = resolve; });
let resolveRuntimeConnected;
const runtimeConnected = new Promise((resolve) => { resolveRuntimeConnected = resolve; });
const onPreloadMessage = (_event, channel) => {
if (channel === "netcatty-plugin:preload-ready") {
void this.logger.write("debug", "Plugin browser preload ready");
resolvePreloadReady();
}
if (channel === "netcatty-plugin:runtime-connected") {
void this.logger.write("debug", "Plugin browser message port connected");
resolveRuntimeConnected();
}
};
contents.on("ipc-message", onPreloadMessage);
const loading = contents.loadURL(this.registration.url);
let preloadTimer;
const preloadDeadline = new Promise((_, reject) => {
preloadTimer = setTimeout(
() => reject(new Error("Plugin browser preload did not become ready")),
PLUGIN_ACTIVATION_TIMEOUT_MS,
);
});
try {
await Promise.race([
preloadReady,
preloadDeadline,
loading.then(() => preloadReady),
]);
} finally {
clearTimeout(preloadTimer);
}
this.#assertStarting(signal);
contents.postMessage("netcatty-plugin:connect", null, [port2]);
let connectionTimer;
const connectionDeadline = new Promise((_, reject) => {
connectionTimer = setTimeout(
() => reject(new Error("Plugin browser runtime did not accept its message port")),
PLUGIN_ACTIVATION_TIMEOUT_MS,
);
});
try {
await Promise.all([
loading,
Promise.race([runtimeConnected, connectionDeadline]),
]);
} finally {
clearTimeout(connectionTimer);
contents.removeListener("ipc-message", onPreloadMessage);
}
this.#assertStarting(signal);
const initialized = await this.router.request("plugin.initialize", {
netcattyVersion: config.netcattyVersion,
apiVersion: config.apiVersion,
supportedFeatures: config.supportedFeatures,
}, { timeoutMs: PLUGIN_ACTIVATION_TIMEOUT_MS });
this.#assertStarting(signal);
const activationEnvironment = typeof getActivationEnvironment === "function"
? getActivationEnvironment()
: config.environment;
await this.router.request("plugin.activate", activationEnvironment == null
? {}
: { environment: activationEnvironment }, { timeoutMs: PLUGIN_ACTIVATION_TIMEOUT_MS });
this.#assertStarting(signal);
return initialized;
}
async stop() {
if (this.stopping) return;
this.stopping = true;
try {
await this.router?.request("plugin.deactivate", {}, {
timeoutMs: PLUGIN_DEACTIVATION_TIMEOUT_MS,
});
} finally {
const router = this.router;
this.router = null;
router?.close();
this.port?.close();
this.registration?.dispose();
this.sessionRegistration?.dispose();
if (this.window && !this.window.isDestroyed()) this.window.destroy();
}
}
request(method, params, options) {
if (!this.router) return Promise.reject(new Error("Plugin browser runtime is not connected"));
return this.router.request(method, params, options);
}
notify(method, params) {
if (!this.router) throw new Error("Plugin browser runtime is not connected");
this.router.notify(method, params);
}
openStream(streamId, windowBytes) {
if (!this.router) return Promise.reject(new Error("Plugin browser runtime is not connected"));
return this.router.streams.openOutgoing(streamId, windowBytes);
}
getProcessId() {
if (!this.window || this.window.isDestroyed()) return null;
return this.window.webContents.getOSProcessId?.() ?? null;
}
#handleExit(error) {
if (!this.router) return;
const router = this.router;
this.router = null;
router.close(error);
this.port?.close();
this.registration?.dispose();
this.sessionRegistration?.dispose();
if (this.window && !this.window.isDestroyed()) this.window.destroy();
const expected = this.stopping;
this.onExit({ expected, error });
}
}
module.exports = {
BrowserPluginRuntime,
encodePackagePath,
normalizeConsoleLevel,
sanitizePluginSource,
waitForLoad,
};