mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-13 18:21:00 +00:00
305 lines
13 KiB
JavaScript
305 lines
13 KiB
JavaScript
"use strict";
|
|
|
|
const { isPluginDevelopmentEnabled } = require("./constants.cjs");
|
|
|
|
const CHANNELS = Object.freeze({
|
|
status: "netcatty:plugins:status",
|
|
list: "netcatty:plugins:list",
|
|
install: "netcatty:plugins:install",
|
|
setEnabled: "netcatty:plugins:set-enabled",
|
|
restart: "netcatty:plugins:restart",
|
|
uninstall: "netcatty:plugins:uninstall",
|
|
contributions: "netcatty:plugins:contributions",
|
|
contributionIcon: "netcatty:plugins:contribution-icon",
|
|
contributionsChanged: "netcatty:plugins:contributions-changed",
|
|
executeCommand: "netcatty:plugins:execute-command",
|
|
updateSetting: "netcatty:plugins:update-setting",
|
|
resetSetting: "netcatty:plugins:reset-setting",
|
|
setEnvironment: "netcatty:plugins:set-environment",
|
|
terminalProviders: "netcatty:plugins:terminal-providers",
|
|
terminalProvide: "netcatty:plugins:terminal-provide",
|
|
terminalCancel: "netcatty:plugins:terminal-cancel",
|
|
terminalSessionEvent: "netcatty:plugins:terminal-session-event",
|
|
openView: "netcatty:plugins:open-view",
|
|
closeView: "netcatty:plugins:close-view",
|
|
setViewBounds: "netcatty:plugins:set-view-bounds",
|
|
setViewVisibility: "netcatty:plugins:set-view-visibility",
|
|
viewMessage: "netcatty:plugins:view-message",
|
|
viewMessagePosted: "netcatty:plugins:view-message-posted",
|
|
viewClosed: "netcatty:plugins:view-closed",
|
|
getScopeCatalog: "netcatty:plugins:get-scope-catalog",
|
|
setScopeCatalog: "netcatty:plugins:set-scope-catalog",
|
|
scopeCatalogChanged: "netcatty:plugins:scope-catalog-changed",
|
|
});
|
|
|
|
const SCOPE_KINDS = Object.freeze(["workspace", "host", "session", "device"]);
|
|
const MAX_ACTIVE_TERMINAL_REQUESTS_PER_SENDER = 64;
|
|
|
|
function normalizePluginScopeCatalog(value) {
|
|
const source = value && typeof value === "object" ? value : {};
|
|
const result = {};
|
|
let total = 0;
|
|
for (const kind of SCOPE_KINDS) {
|
|
const entries = Array.isArray(source[kind]) ? source[kind] : [];
|
|
const seen = new Set();
|
|
result[kind] = [];
|
|
for (const entry of entries) {
|
|
const id = typeof entry?.id === "string" ? entry.id.trim() : "";
|
|
const label = typeof entry?.label === "string" ? entry.label.trim() : "";
|
|
if (!id || id.length > 256 || id.includes("\0") || !label || label.length > 512 || seen.has(id)) continue;
|
|
if (++total > 4096) throw new TypeError("Plugin setting scope catalog is too large");
|
|
seen.add(id);
|
|
result[kind].push(Object.freeze({ id, label }));
|
|
}
|
|
Object.freeze(result[kind]);
|
|
}
|
|
return Object.freeze(result);
|
|
}
|
|
|
|
function mergePluginScopeCatalogs(catalogs) {
|
|
const result = {};
|
|
let total = 0;
|
|
for (const kind of SCOPE_KINDS) {
|
|
const seen = new Set();
|
|
result[kind] = [];
|
|
for (const catalog of catalogs) {
|
|
for (const entry of catalog?.[kind] ?? []) {
|
|
if (seen.has(entry.id) || total >= 4096) continue;
|
|
seen.add(entry.id);
|
|
total += 1;
|
|
result[kind].push(entry);
|
|
}
|
|
}
|
|
Object.freeze(result[kind]);
|
|
}
|
|
return Object.freeze(result);
|
|
}
|
|
|
|
function createTrustedPluginBridgeSender(options = {}) {
|
|
const devServerOrigin = options.devServerUrl ? new URL(options.devServerUrl).origin : null;
|
|
return (event) => {
|
|
const senderUrl = event?.senderFrame?.url || event?.sender?.getURL?.() || "";
|
|
try {
|
|
const url = new URL(senderUrl);
|
|
if (url.protocol === "app:" && url.hostname === "netcatty") return true;
|
|
return Boolean(devServerOrigin && url.origin === devServerOrigin);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
}
|
|
|
|
function registerPluginBridge(ipcMain, options) {
|
|
const manager = options.manager;
|
|
const contributionService = options.contributionService;
|
|
const terminalProviderService = options.terminalProviderService;
|
|
const viewHost = options.viewHost;
|
|
const env = options.env ?? process.env;
|
|
const isTrustedSender = options.isTrustedSender;
|
|
const defaultScopeCatalog = normalizePluginScopeCatalog({ device: [{ id: "device", label: "This device" }] });
|
|
const scopeCatalogs = new Map();
|
|
const scopeCatalogOwners = new Map();
|
|
const observedScopeCatalogSenders = new WeakSet();
|
|
const terminalRequestsBySender = new WeakMap();
|
|
const observedTerminalRequestSenders = new WeakSet();
|
|
const scopeCatalogSenderKey = (event) => {
|
|
const id = event?.sender?.id;
|
|
return Number.isSafeInteger(id) && id > 0 ? id : "default";
|
|
};
|
|
const currentScopeCatalog = () => mergePluginScopeCatalogs([
|
|
...scopeCatalogs.values(),
|
|
defaultScopeCatalog,
|
|
]);
|
|
const publishScopeCatalog = (event) => {
|
|
const catalog = currentScopeCatalog();
|
|
if (typeof options.broadcast === "function") options.broadcast(CHANNELS.scopeCatalogChanged, catalog);
|
|
else event?.sender?.send?.(CHANNELS.scopeCatalogChanged, catalog);
|
|
};
|
|
const observeScopeCatalogSender = (event, key) => {
|
|
const sender = event?.sender;
|
|
if (!sender || typeof sender !== "object" || observedScopeCatalogSenders.has(sender)) return;
|
|
observedScopeCatalogSenders.add(sender);
|
|
sender.once?.("destroyed", () => {
|
|
if (scopeCatalogOwners.get(key) !== sender) return;
|
|
scopeCatalogOwners.delete(key);
|
|
if (scopeCatalogs.delete(key)) publishScopeCatalog();
|
|
});
|
|
};
|
|
const configured = isPluginDevelopmentEnabled(env)
|
|
&& Boolean(manager)
|
|
&& typeof manager.initialize === "function";
|
|
const resolveManager = async () => {
|
|
if (!configured) throw new Error("Plugin development runtime is disabled or unavailable");
|
|
try {
|
|
await manager.initialize();
|
|
} catch (cause) {
|
|
throw new Error("Plugin development runtime is disabled or unavailable", { cause });
|
|
}
|
|
return manager;
|
|
};
|
|
const handle = (channel, callback) => {
|
|
ipcMain.handle(channel, async (event, payload) => {
|
|
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
|
|
const activeManager = await resolveManager();
|
|
return callback(activeManager, payload, event);
|
|
});
|
|
};
|
|
const terminalRequestMap = (sender) => {
|
|
if (!sender || typeof sender !== "object") throw new Error("Plugin terminal request sender is unavailable");
|
|
let requests = terminalRequestsBySender.get(sender);
|
|
if (!requests) {
|
|
requests = new Map();
|
|
terminalRequestsBySender.set(sender, requests);
|
|
}
|
|
if (!observedTerminalRequestSenders.has(sender)) {
|
|
observedTerminalRequestSenders.add(sender);
|
|
sender.once?.("destroyed", () => {
|
|
for (const controller of requests.values()) controller.abort();
|
|
requests.clear();
|
|
});
|
|
}
|
|
return requests;
|
|
};
|
|
ipcMain.handle(CHANNELS.status, async (event) => {
|
|
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
|
|
let available = false;
|
|
try {
|
|
await resolveManager();
|
|
available = true;
|
|
} catch {}
|
|
return { available, experimental: true };
|
|
});
|
|
handle(CHANNELS.list, async (activeManager) => activeManager.list());
|
|
handle(CHANNELS.install, async (activeManager, payload) => activeManager.install(
|
|
payload?.archivePath,
|
|
{ enable: payload?.enable === true },
|
|
));
|
|
handle(CHANNELS.setEnabled, async (activeManager, payload) => activeManager.setEnabled(
|
|
payload?.pluginId,
|
|
payload?.enabled === true,
|
|
));
|
|
handle(CHANNELS.restart, async (activeManager, payload) => activeManager.restart(payload?.pluginId));
|
|
handle(CHANNELS.uninstall, async (activeManager, payload) => activeManager.uninstall(payload?.pluginId));
|
|
handle(CHANNELS.contributions, async (_activeManager, payload) => {
|
|
if (!contributionService) throw new Error("Plugin contributions are unavailable");
|
|
return contributionService.snapshot(payload ?? {});
|
|
});
|
|
handle(CHANNELS.contributionIcon, async (_activeManager, payload) => {
|
|
if (!options.resolveContributionIcon) throw new Error("Plugin contribution icons are unavailable");
|
|
return options.resolveContributionIcon(payload);
|
|
});
|
|
handle(CHANNELS.executeCommand, async (_activeManager, payload) => {
|
|
if (!contributionService) throw new Error("Plugin contributions are unavailable");
|
|
return contributionService.executeCommand(payload?.command, payload?.args, {
|
|
source: "renderer",
|
|
context: payload?.context,
|
|
});
|
|
});
|
|
handle(CHANNELS.updateSetting, async (_activeManager, payload) => {
|
|
if (!contributionService) throw new Error("Plugin contributions are unavailable");
|
|
return contributionService.updateSetting(
|
|
payload?.pluginId,
|
|
payload?.settingId,
|
|
payload?.value,
|
|
payload?.scopeId,
|
|
{ source: "host" },
|
|
);
|
|
});
|
|
handle(CHANNELS.resetSetting, async (_activeManager, payload) => {
|
|
if (!contributionService) throw new Error("Plugin contributions are unavailable");
|
|
return contributionService.resetSetting(payload?.pluginId, payload?.settingId, payload?.scopeId);
|
|
});
|
|
handle(CHANNELS.setEnvironment, async (_activeManager, payload) => {
|
|
if (!contributionService) throw new Error("Plugin contributions are unavailable");
|
|
await contributionService.setEnvironment(payload ?? {});
|
|
viewHost?.setEnvironment?.(payload ?? {});
|
|
return null;
|
|
});
|
|
handle(CHANNELS.terminalProviders, async (_activeManager, payload) => {
|
|
if (!terminalProviderService) throw new Error("Plugin Terminal Providers are unavailable");
|
|
return terminalProviderService.listProviders(payload ?? {});
|
|
});
|
|
ipcMain.handle(CHANNELS.terminalProvide, async (event, payload) => {
|
|
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
|
|
if (!terminalProviderService) throw new Error("Plugin Terminal Providers are unavailable");
|
|
const requestId = payload?.requestId;
|
|
if (typeof requestId !== "string" || requestId.length < 1 || requestId.length > 128 || requestId.includes("\0")) {
|
|
throw new TypeError("Plugin terminal request ID is invalid");
|
|
}
|
|
const requests = terminalRequestMap(event.sender);
|
|
if (requests.has(requestId)) throw new Error("Plugin terminal request ID is already active");
|
|
if (requests.size >= MAX_ACTIVE_TERMINAL_REQUESTS_PER_SENDER) {
|
|
throw new Error("Too many active Plugin terminal requests");
|
|
}
|
|
const controller = new AbortController();
|
|
requests.set(requestId, controller);
|
|
try {
|
|
await resolveManager();
|
|
const { requestId: _requestId, ...providerRequest } = payload;
|
|
return await terminalProviderService.provide(providerRequest, { signal: controller.signal });
|
|
} finally {
|
|
if (requests.get(requestId) === controller) requests.delete(requestId);
|
|
}
|
|
});
|
|
ipcMain.handle(CHANNELS.terminalCancel, async (event, payload) => {
|
|
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
|
|
const requestId = payload?.requestId;
|
|
if (typeof requestId !== "string" || requestId.length < 1 || requestId.length > 128) {
|
|
throw new TypeError("Plugin terminal request ID is invalid");
|
|
}
|
|
const requests = terminalRequestMap(event.sender);
|
|
const controller = requests.get(requestId);
|
|
controller?.abort();
|
|
return controller != null;
|
|
});
|
|
handle(CHANNELS.terminalSessionEvent, async (_activeManager, payload) => {
|
|
if (!terminalProviderService) throw new Error("Plugin Terminal Providers are unavailable");
|
|
return terminalProviderService.publishSessionEvent(payload);
|
|
});
|
|
handle(CHANNELS.openView, async (_activeManager, payload, event) => {
|
|
if (!viewHost) throw new Error("Plugin views are unavailable");
|
|
return viewHost.open(payload, event.sender);
|
|
});
|
|
handle(CHANNELS.closeView, async (_activeManager, payload, event) => {
|
|
if (!viewHost) throw new Error("Plugin views are unavailable");
|
|
await viewHost.close(payload?.instanceId, event.sender);
|
|
return null;
|
|
});
|
|
handle(CHANNELS.setViewBounds, async (_activeManager, payload, event) => {
|
|
if (!viewHost) throw new Error("Plugin views are unavailable");
|
|
viewHost.setBounds(payload?.instanceId, payload?.bounds, event.sender);
|
|
return null;
|
|
});
|
|
handle(CHANNELS.setViewVisibility, async (_activeManager, payload, event) => {
|
|
if (!viewHost) throw new Error("Plugin views are unavailable");
|
|
viewHost.setVisible(payload?.instanceId, payload?.visible, event.sender);
|
|
return null;
|
|
});
|
|
handle(CHANNELS.viewMessage, async (_activeManager, payload, event) => {
|
|
if (!viewHost) throw new Error("Plugin views are unavailable");
|
|
await viewHost.postMessage(payload?.instanceId, payload?.message, event.sender);
|
|
return null;
|
|
});
|
|
handle(CHANNELS.getScopeCatalog, async () => currentScopeCatalog());
|
|
handle(CHANNELS.setScopeCatalog, async (_activeManager, payload, event) => {
|
|
const key = scopeCatalogSenderKey(event);
|
|
const scopeCatalog = normalizePluginScopeCatalog(payload);
|
|
scopeCatalogs.set(key, scopeCatalog);
|
|
scopeCatalogOwners.set(key, event?.sender);
|
|
observeScopeCatalogSender(event, key);
|
|
publishScopeCatalog(event);
|
|
return null;
|
|
});
|
|
contributionService?.onDidChange?.((event) => options.broadcast?.(CHANNELS.contributionsChanged, event));
|
|
contributionService?.onDidPostViewMessage?.((event) => options.broadcast?.(CHANNELS.viewMessagePosted, event));
|
|
viewHost?.onDidClose?.((event) => options.broadcast?.(CHANNELS.viewClosed, event));
|
|
}
|
|
|
|
module.exports = {
|
|
CHANNELS,
|
|
createTrustedPluginBridgeSender,
|
|
mergePluginScopeCatalogs,
|
|
normalizePluginScopeCatalog,
|
|
registerPluginBridge,
|
|
};
|