[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
321
electron/plugins/browserPluginRuntime.cjs
Normal file
321
electron/plugins/browserPluginRuntime.cjs
Normal file
@@ -0,0 +1,321 @@
|
||||
"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,
|
||||
};
|
||||
Reference in New Issue
Block a user