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
289 lines
12 KiB
JavaScript
289 lines
12 KiB
JavaScript
"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 };
|