[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:
288
electron/plugins/pluginViewHost.cjs
Normal file
288
electron/plugins/pluginViewHost.cjs
Normal file
@@ -0,0 +1,288 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
|
||||
const { assertJsonValue } = require("./contributionService.cjs");
|
||||
const { PLUGIN_PROTOCOL_SCHEME } = require("./constants.cjs");
|
||||
|
||||
const VIEW_CHANNELS = Object.freeze({
|
||||
postMessage: "netcatty-plugin-view:post-message",
|
||||
executeCommand: "netcatty-plugin-view:execute-command",
|
||||
getState: "netcatty-plugin-view:get-state",
|
||||
getEnvironment: "netcatty-plugin-view:get-environment",
|
||||
setState: "netcatty-plugin-view:set-state",
|
||||
});
|
||||
|
||||
function normalizeBounds(bounds) {
|
||||
const value = bounds ?? { x: 0, y: 0, width: 480, height: 320 };
|
||||
const normalized = {};
|
||||
for (const key of ["x", "y", "width", "height"]) {
|
||||
if (!Number.isSafeInteger(value[key])) throw new TypeError("Plugin view bounds must use safe integers");
|
||||
normalized[key] = value[key];
|
||||
}
|
||||
if (normalized.width < 1 || normalized.height < 1 || normalized.width > 16_384 || normalized.height > 16_384) {
|
||||
throw new TypeError("Plugin view bounds are invalid");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
class PluginViewHost {
|
||||
constructor(options) {
|
||||
this.electron = options.electron;
|
||||
this.protocol = options.protocol;
|
||||
this.packageStore = options.packageStore;
|
||||
this.database = options.database;
|
||||
this.contributionService = options.contributionService;
|
||||
this.preloadPath = options.preloadPath ?? path.join(__dirname, "runtime", "viewPreload.cjs");
|
||||
this.instances = new Map();
|
||||
this.byWebContentsId = new Map();
|
||||
this.closeListeners = new Set();
|
||||
this.environment = { locale: "en", theme: "system", reducedMotion: false, highContrast: false };
|
||||
this.subscriptions = [];
|
||||
this.#registerIpc();
|
||||
this.subscriptions.push(this.contributionService.onDidPostViewMessage((event) => {
|
||||
for (const instance of this.instances.values()) {
|
||||
if (instance.pluginId === event.pluginId && instance.viewId === event.viewId) {
|
||||
instance.contents.send("netcatty-plugin-view:message", event.message);
|
||||
}
|
||||
}
|
||||
}));
|
||||
this.subscriptions.push(this.contributionService.onDidChange?.((event) => {
|
||||
if (!["plugin-disabled", "runtime-stopped", "runtime-error", "runtime-quarantined"].includes(event.reason)
|
||||
|| !event.pluginId) return;
|
||||
for (const instance of [...this.instances.values()]) {
|
||||
if (instance.pluginId === event.pluginId) void this.close(instance.instanceId, undefined, event.reason);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
onDidClose(listener) {
|
||||
this.closeListeners.add(listener);
|
||||
return { dispose: () => this.closeListeners.delete(listener) };
|
||||
}
|
||||
|
||||
#registerIpc() {
|
||||
const ipcMain = this.electron.ipcMain;
|
||||
if (!ipcMain?.handle) return;
|
||||
const instanceFor = (event) => {
|
||||
const instance = this.byWebContentsId.get(event.sender?.id);
|
||||
if (!instance || instance.contents !== event.sender) throw new Error("Untrusted plugin view sender");
|
||||
return instance;
|
||||
};
|
||||
ipcMain.handle(VIEW_CHANNELS.postMessage, async (event, message) => {
|
||||
const instance = instanceFor(event);
|
||||
assertJsonValue(message, "view message");
|
||||
await this.contributionService.runtimeSupervisor.notify(instance.pluginId, "plugin.view.message", {
|
||||
viewId: instance.viewId,
|
||||
message,
|
||||
});
|
||||
return null;
|
||||
});
|
||||
ipcMain.handle(VIEW_CHANNELS.executeCommand, (event, payload) => {
|
||||
const instance = instanceFor(event);
|
||||
return this.contributionService.executeCommand(payload?.command, payload?.args, {
|
||||
source: "view",
|
||||
callerPluginId: instance.pluginId,
|
||||
context: { "netcatty.view": instance.viewId },
|
||||
});
|
||||
});
|
||||
ipcMain.handle(VIEW_CHANNELS.getState, (event) => {
|
||||
const instance = instanceFor(event);
|
||||
return this.database.getViewState(instance.pluginId, instance.viewId, instance.scopeId);
|
||||
});
|
||||
ipcMain.handle(VIEW_CHANNELS.getEnvironment, (event) => {
|
||||
instanceFor(event);
|
||||
return structuredClone(this.environment);
|
||||
});
|
||||
ipcMain.handle(VIEW_CHANNELS.setState, (event, state) => {
|
||||
const instance = instanceFor(event);
|
||||
assertJsonValue(state, "view state");
|
||||
this.database.setViewState(instance.pluginId, instance.viewId, instance.scopeId, state);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
#ownedInstance(instanceId, sender) {
|
||||
const instance = this.instances.get(instanceId);
|
||||
if (!instance) throw new Error(`Plugin view was not found: ${instanceId}`);
|
||||
if (sender) {
|
||||
const owner = this.electron.BrowserWindow.fromWebContents(sender);
|
||||
if (!owner || instance.owner !== owner) throw new Error("Plugin view belongs to another window");
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
async open(payload, sender) {
|
||||
if (!payload || typeof payload !== "object") throw new TypeError("Plugin view request is invalid");
|
||||
const { plugin, view, identity } = await this.contributionService.activateView(
|
||||
payload.viewId,
|
||||
{ context: payload.context },
|
||||
);
|
||||
const activation = Object.freeze({
|
||||
pluginId: plugin.id,
|
||||
pluginVersion: plugin.activeVersion,
|
||||
viewId: view.id,
|
||||
runtimeId: identity?.runtimeId ?? null,
|
||||
});
|
||||
if (!activation.runtimeId) throw new Error("Plugin view runtime identity is unavailable");
|
||||
const scopeId = typeof payload.scopeId === "string" && payload.scopeId.length <= 256 ? payload.scopeId : null;
|
||||
if (!scopeId) throw new TypeError("Plugin view scope ID is required");
|
||||
const owner = this.electron.BrowserWindow.fromWebContents(sender);
|
||||
if (!owner || owner.isDestroyed()) throw new Error("Plugin view owner is unavailable");
|
||||
const instanceId = typeof payload.instanceId === "string" && payload.instanceId.length <= 128
|
||||
? payload.instanceId
|
||||
: randomUUID();
|
||||
if (this.instances.has(instanceId)) throw new Error(`Plugin view already exists: ${instanceId}`);
|
||||
const packageRoot = await this.packageStore.preparePackageRoot(plugin);
|
||||
this.contributionService.assertViewActivationCurrent(activation, { context: payload.context });
|
||||
const partition = `netcatty-plugin-view-${randomUUID()}`;
|
||||
const pluginSession = this.electron.session.fromPartition(partition, { cache: false });
|
||||
let registration;
|
||||
let sessionRegistration;
|
||||
try {
|
||||
registration = this.protocol.registerView({
|
||||
pluginId: plugin.id,
|
||||
packageRoot,
|
||||
entry: view.entry,
|
||||
});
|
||||
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>",
|
||||
});
|
||||
pluginSession.on("will-download", (event) => event.preventDefault());
|
||||
pluginSession.setPermissionCheckHandler?.(() => false);
|
||||
pluginSession.setPermissionRequestHandler?.((_contents, _permission, callback) => callback(false));
|
||||
pluginSession.setDevicePermissionHandler?.(() => false);
|
||||
pluginSession.webRequest.onBeforeRequest((details, callback) => {
|
||||
try {
|
||||
const url = new URL(details.url);
|
||||
callback({ cancel: url.protocol !== `${PLUGIN_PROTOCOL_SCHEME}:` || url.hostname !== registration.token });
|
||||
} catch { callback({ cancel: true }); }
|
||||
});
|
||||
const contentsView = new this.electron.WebContentsView({
|
||||
webPreferences: {
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
nodeIntegrationInSubFrames: false,
|
||||
nodeIntegrationInWorker: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: true,
|
||||
devTools: false,
|
||||
disableDialogs: true,
|
||||
navigateOnDragDrop: false,
|
||||
session: pluginSession,
|
||||
preload: this.preloadPath,
|
||||
},
|
||||
});
|
||||
const contents = contentsView.webContents;
|
||||
contents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
contents.on("will-navigate", (event, url) => {
|
||||
if (url !== registration.url) event.preventDefault();
|
||||
});
|
||||
contents.on("will-attach-webview", (event) => event.preventDefault());
|
||||
const ownerClosed = () => { void this.close(instanceId, undefined, "owner-closed"); };
|
||||
const instance = {
|
||||
instanceId,
|
||||
pluginId: plugin.id,
|
||||
viewId: view.id,
|
||||
scopeId,
|
||||
owner,
|
||||
ownerClosed,
|
||||
view: contentsView,
|
||||
contents,
|
||||
registration,
|
||||
sessionRegistration,
|
||||
state: "opening",
|
||||
attached: false,
|
||||
};
|
||||
this.instances.set(instanceId, instance);
|
||||
this.byWebContentsId.set(contents.id, instance);
|
||||
owner.once?.("closed", ownerClosed);
|
||||
contents.once("destroyed", () => { void this.close(instanceId, undefined, "contents-destroyed"); });
|
||||
await contents.loadURL(registration.url);
|
||||
this.contributionService.assertViewActivationCurrent(activation, { context: payload.context });
|
||||
if (this.instances.get(instanceId) !== instance || instance.state !== "opening"
|
||||
|| owner.isDestroyed() || contents.isDestroyed()) {
|
||||
throw new Error("Plugin view ownership changed while opening");
|
||||
}
|
||||
owner.contentView.addChildView(contentsView);
|
||||
instance.attached = true;
|
||||
contentsView.setBounds(normalizeBounds(payload.bounds));
|
||||
instance.state = "open";
|
||||
contents.send("netcatty-plugin-view:environment", this.environment);
|
||||
return { instanceId };
|
||||
} catch (error) {
|
||||
if (this.instances.has(instanceId)) await this.close(instanceId, undefined, "open-failed");
|
||||
else {
|
||||
try { registration?.dispose(); } catch {}
|
||||
try { sessionRegistration?.dispose(); } catch {}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
setBounds(instanceId, bounds, sender) {
|
||||
const instance = this.#ownedInstance(instanceId, sender);
|
||||
instance.view.setBounds(normalizeBounds(bounds));
|
||||
}
|
||||
|
||||
setVisible(instanceId, visible, sender) {
|
||||
if (typeof visible !== "boolean") throw new TypeError("Plugin view visibility must be a boolean");
|
||||
const instance = this.#ownedInstance(instanceId, sender);
|
||||
instance.view.setVisible(visible);
|
||||
}
|
||||
|
||||
async postMessage(instanceId, message, sender) {
|
||||
const instance = this.#ownedInstance(instanceId, sender);
|
||||
assertJsonValue(message, "view message");
|
||||
instance.contents.send("netcatty-plugin-view:message", message);
|
||||
}
|
||||
|
||||
setEnvironment(environment) {
|
||||
assertJsonValue(environment, "environment");
|
||||
this.environment = { ...environment };
|
||||
for (const instance of this.instances.values()) {
|
||||
instance.contents.send("netcatty-plugin-view:environment", this.environment);
|
||||
}
|
||||
}
|
||||
|
||||
async close(instanceId, sender, reason = sender ? "renderer-requested" : "host-requested") {
|
||||
const instance = this.instances.get(instanceId);
|
||||
if (!instance) return;
|
||||
if (sender) this.#ownedInstance(instanceId, sender);
|
||||
this.instances.delete(instanceId);
|
||||
this.byWebContentsId.delete(instance.contents.id);
|
||||
try { instance.owner.removeListener?.("closed", instance.ownerClosed); } catch {}
|
||||
if (instance.attached) {
|
||||
try { instance.owner.contentView.removeChildView(instance.view); } catch {}
|
||||
}
|
||||
try { instance.registration.dispose(); } catch {}
|
||||
try { instance.sessionRegistration.dispose(); } catch {}
|
||||
try {
|
||||
if (!instance.contents.isDestroyed()) {
|
||||
if (typeof instance.contents.close === "function") instance.contents.close();
|
||||
else instance.contents.destroy?.();
|
||||
}
|
||||
} catch {}
|
||||
const event = Object.freeze({
|
||||
instanceId: instance.instanceId,
|
||||
pluginId: instance.pluginId,
|
||||
viewId: instance.viewId,
|
||||
reason,
|
||||
});
|
||||
for (const listener of this.closeListeners) {
|
||||
try { listener(event); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
await Promise.all([...this.instances.keys()].map((instanceId) => this.close(instanceId)));
|
||||
for (const subscription of this.subscriptions.splice(0)) subscription?.dispose?.();
|
||||
this.closeListeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginViewHost, VIEW_CHANNELS, normalizeBounds };
|
||||
Reference in New Issue
Block a user