[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:
32
electron/plugins/runtime/browserPreload.cjs
Normal file
32
electron/plugins/runtime/browserPreload.cjs
Normal file
@@ -0,0 +1,32 @@
|
||||
"use strict";
|
||||
|
||||
const { ipcRenderer } = require("electron");
|
||||
|
||||
let hostPort;
|
||||
let runtimeReady = false;
|
||||
|
||||
function connectRuntime() {
|
||||
if (!hostPort || !runtimeReady) return;
|
||||
const port = hostPort;
|
||||
hostPort = undefined;
|
||||
window.postMessage({
|
||||
type: "netcatty-plugin:connect",
|
||||
runtimeToken: window.location.hostname,
|
||||
}, "*", [port]);
|
||||
}
|
||||
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.data?.runtimeToken !== window.location.hostname) return;
|
||||
if (event.data?.type === "netcatty-plugin:runtime-ready") {
|
||||
runtimeReady = true;
|
||||
connectRuntime();
|
||||
} else if (event.data?.type === "netcatty-plugin:runtime-connected") {
|
||||
ipcRenderer.send("netcatty-plugin:runtime-connected");
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.once("netcatty-plugin:connect", (event) => {
|
||||
hostPort = event.ports?.[0];
|
||||
connectRuntime();
|
||||
});
|
||||
ipcRenderer.send("netcatty-plugin:preload-ready");
|
||||
74
electron/plugins/runtime/browserRuntime.mjs
Normal file
74
electron/plugins/runtime/browserRuntime.mjs
Normal file
@@ -0,0 +1,74 @@
|
||||
import { startPluginRuntime } from "./runtimePeer.mjs";
|
||||
|
||||
function disableDirectBrowserCapability(name) {
|
||||
try {
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
value: undefined,
|
||||
writable: false,
|
||||
});
|
||||
} catch {
|
||||
try { globalThis[name] = undefined; } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function lockDirectBrowserCapabilities() {
|
||||
for (const name of [
|
||||
"EventSource",
|
||||
"RTCPeerConnection",
|
||||
"SharedWorker",
|
||||
"WebSocket",
|
||||
"WebSocketStream",
|
||||
"WebTransport",
|
||||
"Worker",
|
||||
"XMLHttpRequest",
|
||||
"fetch",
|
||||
"webkitRTCPeerConnection",
|
||||
]) disableDirectBrowserCapability(name);
|
||||
try {
|
||||
Object.defineProperty(Navigator.prototype, "sendBeacon", {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
value: () => false,
|
||||
writable: false,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function waitForPort() {
|
||||
return new Promise((resolve) => {
|
||||
const acceptPort = (event) => {
|
||||
if (
|
||||
event.data?.type !== "netcatty-plugin:connect"
|
||||
|| event.data?.runtimeToken !== window.location.hostname
|
||||
) return;
|
||||
const port = event.ports?.[0];
|
||||
if (!port) return;
|
||||
window.removeEventListener("message", acceptPort);
|
||||
resolve(port);
|
||||
};
|
||||
window.addEventListener("message", acceptPort);
|
||||
window.postMessage({
|
||||
type: "netcatty-plugin:runtime-ready",
|
||||
runtimeToken: window.location.hostname,
|
||||
}, "*");
|
||||
});
|
||||
}
|
||||
|
||||
const [configResponse, port] = await Promise.all([
|
||||
fetch(new URL("./config.json", import.meta.url), { cache: "no-store", credentials: "omit" }),
|
||||
waitForPort(),
|
||||
]);
|
||||
if (!configResponse.ok) throw new Error("Unable to load plugin runtime configuration");
|
||||
const config = await configResponse.json();
|
||||
lockDirectBrowserCapabilities();
|
||||
await startPluginRuntime({
|
||||
port,
|
||||
config,
|
||||
loadPlugin: (entryUrl) => import(entryUrl),
|
||||
});
|
||||
window.postMessage({
|
||||
type: "netcatty-plugin:runtime-connected",
|
||||
runtimeToken: window.location.hostname,
|
||||
}, "*");
|
||||
11
electron/plugins/runtime/pluginModuleLoader.mjs
Normal file
11
electron/plugins/runtime/pluginModuleLoader.mjs
Normal file
@@ -0,0 +1,11 @@
|
||||
let mappings = new Map();
|
||||
|
||||
export function initialize(data) {
|
||||
mappings = new Map(Object.entries(data?.mappings ?? {}));
|
||||
}
|
||||
|
||||
export function resolve(specifier, context, nextResolve) {
|
||||
const mapped = mappings.get(specifier);
|
||||
if (mapped) return { url: mapped, shortCircuit: true };
|
||||
return nextResolve(specifier, context);
|
||||
}
|
||||
385
electron/plugins/runtime/pluginStreamEndpoint.mjs
Normal file
385
electron/plugins/runtime/pluginStreamEndpoint.mjs
Normal file
@@ -0,0 +1,385 @@
|
||||
import {
|
||||
PLUGIN_STREAM_MAX_CHUNK_BYTES,
|
||||
PLUGIN_STREAM_MAX_ID_LENGTH,
|
||||
PLUGIN_STREAM_MAX_WINDOW_BYTES,
|
||||
PLUGIN_STREAM_MIN_WINDOW_BYTES,
|
||||
createMessagePortStreamEnvelope,
|
||||
materializeStreamChunk,
|
||||
} from "@netcatty/plugin-contract";
|
||||
import { PluginError } from "@netcatty/plugin-sdk";
|
||||
|
||||
function assertStreamId(streamId) {
|
||||
if (typeof streamId !== "string"
|
||||
|| streamId.length < 1
|
||||
|| streamId.length > PLUGIN_STREAM_MAX_ID_LENGTH
|
||||
|| streamId.includes("\0")) {
|
||||
throw new PluginError("invalid_argument", "Plugin stream ID is invalid");
|
||||
}
|
||||
return streamId;
|
||||
}
|
||||
|
||||
function assertWindowBytes(windowBytes) {
|
||||
if (!Number.isSafeInteger(windowBytes)
|
||||
|| windowBytes < PLUGIN_STREAM_MIN_WINDOW_BYTES
|
||||
|| windowBytes > PLUGIN_STREAM_MAX_WINDOW_BYTES) {
|
||||
throw new PluginError("invalid_argument", "Plugin stream window is invalid");
|
||||
}
|
||||
return windowBytes;
|
||||
}
|
||||
|
||||
function copyBytes(value) {
|
||||
let source;
|
||||
if (value instanceof Uint8Array) source = value;
|
||||
else if (value instanceof ArrayBuffer) source = new Uint8Array(value);
|
||||
else throw new PluginError("invalid_argument", "Plugin stream writes require Uint8Array or ArrayBuffer");
|
||||
const copy = new Uint8Array(source.byteLength);
|
||||
copy.set(source);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function closedError(streamId) {
|
||||
return new PluginError("unavailable", `Plugin stream is closed: ${streamId}`);
|
||||
}
|
||||
|
||||
export function createPluginStreamEndpoint(transport, options = {}) {
|
||||
const maxStreams = options.maxStreams ?? 128;
|
||||
const incoming = new Map();
|
||||
const outgoing = new Map();
|
||||
const pendingIncoming = new Map();
|
||||
let closed = false;
|
||||
|
||||
const sendEnvelope = (frame, transfer) => {
|
||||
const envelope = createMessagePortStreamEnvelope(frame, transfer);
|
||||
transport.post(envelope, transfer ? [transfer] : []);
|
||||
};
|
||||
|
||||
const activeStreams = () => incoming.size + outgoing.size;
|
||||
const reservedStreams = () => activeStreams() + pendingIncoming.size;
|
||||
|
||||
function releaseCurrent(state) {
|
||||
if (!state.currentCreditBytes || closed) return;
|
||||
const creditBytes = state.currentCreditBytes;
|
||||
state.currentCreditBytes = 0;
|
||||
state.availableBytes += creditBytes;
|
||||
state.updateSequence += 1;
|
||||
sendEnvelope({
|
||||
streamId: state.streamId,
|
||||
sequence: state.updateSequence,
|
||||
kind: "windowUpdate",
|
||||
creditBytes,
|
||||
});
|
||||
}
|
||||
|
||||
function settleIncomingWaiters(state) {
|
||||
while (state.readers.length > 0) {
|
||||
if (state.queue.length > 0) {
|
||||
const reader = state.readers.shift();
|
||||
const chunk = state.queue.shift();
|
||||
state.currentCreditBytes = chunk.creditBytes;
|
||||
reader.resolve(chunk.data);
|
||||
continue;
|
||||
}
|
||||
if (!state.closed) break;
|
||||
const reader = state.readers.shift();
|
||||
if (state.error) reader.reject(state.error);
|
||||
else reader.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
function closeIncoming(state, error, notify = false) {
|
||||
if (state.closed) return;
|
||||
state.closed = true;
|
||||
state.error = error ?? null;
|
||||
incoming.delete(state.streamId);
|
||||
if (notify) {
|
||||
try {
|
||||
sendEnvelope({
|
||||
streamId: state.streamId,
|
||||
sequence: state.nextSequence,
|
||||
kind: "cancel",
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
settleIncomingWaiters(state);
|
||||
}
|
||||
|
||||
function readableHandle(state) {
|
||||
return Object.freeze({
|
||||
id: state.streamId,
|
||||
async read() {
|
||||
if (state.readers.length > 0) {
|
||||
throw new PluginError("failed_precondition", "Plugin stream does not allow concurrent reads");
|
||||
}
|
||||
if (state.currentCreditBytes) releaseCurrent(state);
|
||||
if (state.queue.length > 0) {
|
||||
const chunk = state.queue.shift();
|
||||
state.currentCreditBytes = chunk.creditBytes;
|
||||
return chunk.data;
|
||||
}
|
||||
if (state.closed) {
|
||||
if (state.error) throw state.error;
|
||||
return null;
|
||||
}
|
||||
return new Promise((resolve, reject) => state.readers.push({ resolve, reject }));
|
||||
},
|
||||
cancel() { closeIncoming(state, new PluginError("cancelled", "Plugin stream was cancelled"), true); },
|
||||
dispose() { closeIncoming(state, new PluginError("cancelled", "Plugin stream was disposed"), true); },
|
||||
});
|
||||
}
|
||||
|
||||
function flushOutgoing(state) {
|
||||
while (!state.closed && state.queue.length > 0) {
|
||||
const pending = state.queue[0];
|
||||
if (pending.bytes.byteLength > state.availableBytes) break;
|
||||
state.queue.shift();
|
||||
state.queuedBytes -= pending.bytes.byteLength;
|
||||
state.availableBytes -= pending.bytes.byteLength;
|
||||
state.nextSequence += 1;
|
||||
try {
|
||||
sendEnvelope({
|
||||
streamId: state.streamId,
|
||||
sequence: state.nextSequence,
|
||||
kind: "chunk",
|
||||
data: { encoding: "transfer", byteLength: pending.bytes.byteLength },
|
||||
}, pending.bytes.buffer);
|
||||
pending.resolve();
|
||||
} catch (error) {
|
||||
state.closed = true;
|
||||
outgoing.delete(state.streamId);
|
||||
pending.reject(error);
|
||||
for (const queued of state.queue.splice(0)) queued.reject(error);
|
||||
state.queuedBytes = 0;
|
||||
state.endReject?.(error);
|
||||
}
|
||||
}
|
||||
if (!state.closed && state.terminal === "end" && state.queue.length === 0 && !state.terminalSent) {
|
||||
state.terminalSent = true;
|
||||
state.nextSequence += 1;
|
||||
try {
|
||||
sendEnvelope({ streamId: state.streamId, sequence: state.nextSequence, kind: "end" });
|
||||
state.endResolve?.();
|
||||
if (state.availableBytes === state.windowBytes) {
|
||||
state.closed = true;
|
||||
outgoing.delete(state.streamId);
|
||||
}
|
||||
} catch (error) {
|
||||
state.closed = true;
|
||||
outgoing.delete(state.streamId);
|
||||
state.endReject?.(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writableHandle(state) {
|
||||
return Object.freeze({
|
||||
id: state.streamId,
|
||||
write(value) {
|
||||
if (state.closed || state.terminal) return Promise.reject(closedError(state.streamId));
|
||||
const bytes = copyBytes(value);
|
||||
if (bytes.byteLength < 1 || bytes.byteLength > PLUGIN_STREAM_MAX_CHUNK_BYTES) {
|
||||
return Promise.reject(new PluginError("out_of_range", "Plugin stream chunk size is invalid"));
|
||||
}
|
||||
if (state.queuedBytes + bytes.byteLength > state.windowBytes) {
|
||||
return Promise.reject(new PluginError("resource_exhausted", "Plugin stream pending writes exceed its window"));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
state.queue.push({ bytes, resolve, reject });
|
||||
state.queuedBytes += bytes.byteLength;
|
||||
flushOutgoing(state);
|
||||
});
|
||||
},
|
||||
end() {
|
||||
if (state.terminal === "end") return state.endPromise;
|
||||
if (state.closed || state.terminal) return Promise.reject(closedError(state.streamId));
|
||||
state.terminal = "end";
|
||||
state.endPromise = new Promise((resolve, reject) => {
|
||||
state.endResolve = resolve;
|
||||
state.endReject = reject;
|
||||
});
|
||||
flushOutgoing(state);
|
||||
return state.endPromise;
|
||||
},
|
||||
fail(error) {
|
||||
if (state.closed || state.terminal) return;
|
||||
state.terminal = "error";
|
||||
state.closed = true;
|
||||
outgoing.delete(state.streamId);
|
||||
const failure = new PluginError("data_loss", String(error?.message ?? "Plugin stream failed"));
|
||||
for (const pending of state.queue.splice(0)) pending.reject(failure);
|
||||
state.queuedBytes = 0;
|
||||
state.nextSequence += 1;
|
||||
sendEnvelope({
|
||||
streamId: state.streamId,
|
||||
sequence: state.nextSequence,
|
||||
kind: "error",
|
||||
error: {
|
||||
code: -32013,
|
||||
message: String(error?.message ?? "Plugin stream failed").slice(0, 2048),
|
||||
},
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
if (state.closed) return;
|
||||
state.closed = true;
|
||||
outgoing.delete(state.streamId);
|
||||
const error = new PluginError("cancelled", "Plugin stream was cancelled");
|
||||
for (const pending of state.queue.splice(0)) pending.reject(error);
|
||||
state.queuedBytes = 0;
|
||||
state.endReject?.(error);
|
||||
sendEnvelope({
|
||||
streamId: state.streamId,
|
||||
sequence: state.nextSequence + 1,
|
||||
kind: "cancel",
|
||||
});
|
||||
},
|
||||
dispose() { this.cancel(); },
|
||||
});
|
||||
}
|
||||
|
||||
function accept(message) {
|
||||
if (!message || typeof message !== "object" || !Object.hasOwn(message, "frame")) return false;
|
||||
const envelope = createMessagePortStreamEnvelope(message.frame, message.transfer);
|
||||
const frame = envelope.frame;
|
||||
if (frame.kind === "open") {
|
||||
if (!pendingIncoming.has(frame.streamId)) return false;
|
||||
if (closed || activeStreams() >= maxStreams || incoming.has(frame.streamId) || outgoing.has(frame.streamId)) {
|
||||
throw new PluginError("resource_exhausted", `Plugin stream cannot be opened: ${frame.streamId}`);
|
||||
}
|
||||
const state = {
|
||||
streamId: frame.streamId,
|
||||
availableBytes: frame.windowBytes,
|
||||
nextSequence: 1,
|
||||
updateSequence: -1,
|
||||
currentCreditBytes: 0,
|
||||
queue: [],
|
||||
readers: [],
|
||||
closed: false,
|
||||
error: null,
|
||||
};
|
||||
incoming.set(frame.streamId, state);
|
||||
const waiter = pendingIncoming.get(frame.streamId);
|
||||
pendingIncoming.delete(frame.streamId);
|
||||
waiter.resolve(readableHandle(state));
|
||||
return true;
|
||||
}
|
||||
const output = outgoing.get(frame.streamId);
|
||||
if (output && frame.kind === "windowUpdate") {
|
||||
if (frame.sequence !== output.lastUpdateSequence + 1) {
|
||||
throw new PluginError("data_loss", `Plugin stream credit is out of order: ${frame.streamId}`);
|
||||
}
|
||||
output.lastUpdateSequence = frame.sequence;
|
||||
output.availableBytes += frame.creditBytes;
|
||||
if (output.availableBytes > output.windowBytes) {
|
||||
throw new PluginError("data_loss", `Plugin stream credit exceeds its window: ${frame.streamId}`);
|
||||
}
|
||||
if (output.terminalSent && output.availableBytes === output.windowBytes) {
|
||||
output.closed = true;
|
||||
outgoing.delete(frame.streamId);
|
||||
} else flushOutgoing(output);
|
||||
return true;
|
||||
}
|
||||
if (output && frame.kind === "cancel") {
|
||||
output.closed = true;
|
||||
outgoing.delete(frame.streamId);
|
||||
const error = new PluginError("cancelled", `Plugin stream peer cancelled: ${frame.streamId}`);
|
||||
for (const pending of output.queue.splice(0)) pending.reject(error);
|
||||
output.queuedBytes = 0;
|
||||
output.endReject?.(error);
|
||||
return true;
|
||||
}
|
||||
const input = incoming.get(frame.streamId);
|
||||
if (!input || input.closed) throw new PluginError("data_loss", `Unknown Plugin stream: ${frame.streamId}`);
|
||||
if (frame.sequence !== input.nextSequence) {
|
||||
throw new PluginError("data_loss", `Plugin stream frame is out of order: ${frame.streamId}`);
|
||||
}
|
||||
input.nextSequence += 1;
|
||||
if (frame.kind === "chunk") {
|
||||
const materialized = materializeStreamChunk(frame.data, envelope.transfer);
|
||||
if (materialized.encoding !== "binary" || !(materialized.bytes instanceof Uint8Array)) {
|
||||
throw new PluginError("data_loss", "Plugin byte stream received a non-binary chunk");
|
||||
}
|
||||
const data = materialized.bytes;
|
||||
if (data.byteLength > input.availableBytes) {
|
||||
throw new PluginError("resource_exhausted", `Plugin stream exceeded receive credit: ${frame.streamId}`);
|
||||
}
|
||||
input.availableBytes -= data.byteLength;
|
||||
input.queue.push({ data, creditBytes: data.byteLength });
|
||||
settleIncomingWaiters(input);
|
||||
return true;
|
||||
}
|
||||
closeIncoming(
|
||||
input,
|
||||
frame.kind === "error"
|
||||
? new PluginError("data_loss", frame.error.message)
|
||||
: frame.kind === "cancel"
|
||||
? new PluginError("cancelled", `Plugin stream peer cancelled: ${frame.streamId}`)
|
||||
: null,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
accept,
|
||||
async acceptReadable(streamId) {
|
||||
const id = assertStreamId(streamId);
|
||||
const existing = incoming.get(id);
|
||||
if (existing) return readableHandle(existing);
|
||||
if (closed || pendingIncoming.has(id) || outgoing.has(id)) {
|
||||
throw new PluginError("failed_precondition", `Plugin stream cannot be accepted: ${id}`);
|
||||
}
|
||||
if (reservedStreams() >= maxStreams) {
|
||||
throw new PluginError("resource_exhausted", "Plugin stream limit is exhausted");
|
||||
}
|
||||
return new Promise((resolve, reject) => pendingIncoming.set(id, { resolve, reject }));
|
||||
},
|
||||
rejectReadable(streamId, error = new PluginError("cancelled", "Plugin stream acceptance was cancelled")) {
|
||||
const id = assertStreamId(streamId);
|
||||
const waiter = pendingIncoming.get(id);
|
||||
if (!waiter) return false;
|
||||
pendingIncoming.delete(id);
|
||||
waiter.reject(error);
|
||||
return true;
|
||||
},
|
||||
async openWritable(streamId, windowBytes = 256 * 1024) {
|
||||
const id = assertStreamId(streamId);
|
||||
const window = assertWindowBytes(windowBytes);
|
||||
if (closed || reservedStreams() >= maxStreams || incoming.has(id) || outgoing.has(id) || pendingIncoming.has(id)) {
|
||||
throw new PluginError("resource_exhausted", `Plugin stream cannot be opened: ${id}`);
|
||||
}
|
||||
const state = {
|
||||
streamId: id,
|
||||
windowBytes: window,
|
||||
availableBytes: window,
|
||||
nextSequence: 0,
|
||||
lastUpdateSequence: -1,
|
||||
queue: [],
|
||||
queuedBytes: 0,
|
||||
terminal: null,
|
||||
terminalSent: false,
|
||||
endPromise: null,
|
||||
endResolve: null,
|
||||
endReject: null,
|
||||
closed: false,
|
||||
};
|
||||
outgoing.set(id, state);
|
||||
sendEnvelope({ streamId: id, sequence: 0, kind: "open", windowBytes: window });
|
||||
return writableHandle(state);
|
||||
},
|
||||
close(error = new PluginError("unavailable", "Plugin stream transport closed")) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const state of incoming.values()) closeIncoming(state, error);
|
||||
for (const state of outgoing.values()) {
|
||||
state.closed = true;
|
||||
for (const pending of state.queue.splice(0)) pending.reject(error);
|
||||
state.queuedBytes = 0;
|
||||
state.endReject?.(error);
|
||||
}
|
||||
outgoing.clear();
|
||||
for (const waiter of pendingIncoming.values()) waiter.reject(error);
|
||||
pendingIncoming.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { assertStreamId, assertWindowBytes };
|
||||
957
electron/plugins/runtime/runtimePeer.mjs
Normal file
957
electron/plugins/runtime/runtimePeer.mjs
Normal file
@@ -0,0 +1,957 @@
|
||||
import {
|
||||
CancellationTokenSource,
|
||||
DisposableStore,
|
||||
PluginError,
|
||||
PLUGIN_ERROR_WIRE_CODES,
|
||||
pluginErrorToRpcError,
|
||||
} from "@netcatty/plugin-sdk";
|
||||
import { createMessagePortStreamEnvelope } from "@netcatty/plugin-contract";
|
||||
import { createPluginStreamEndpoint } from "./pluginStreamEndpoint.mjs";
|
||||
|
||||
let terminalInterceptorTransportPromise;
|
||||
|
||||
function loadTerminalInterceptorTransport() {
|
||||
terminalInterceptorTransportPromise ??= import("../terminalInterceptorTransport.cjs")
|
||||
.then((module) => module.default);
|
||||
return terminalInterceptorTransportPromise;
|
||||
}
|
||||
|
||||
const RPC_ERRORS = {
|
||||
methodNotFound: -32601,
|
||||
invalidParams: -32602,
|
||||
internal: -32603,
|
||||
cancelled: -32001,
|
||||
unsupported: -32012,
|
||||
};
|
||||
|
||||
const PLUGIN_ERROR_NAMES_BY_WIRE_CODE = new Map(
|
||||
Object.entries(PLUGIN_ERROR_WIRE_CODES).map(([name, code]) => [code, name]),
|
||||
);
|
||||
|
||||
const PROVIDER_KINDS = new Set([
|
||||
"terminal.completion",
|
||||
"terminal.decoration",
|
||||
"terminal.link",
|
||||
"terminal.hover",
|
||||
"terminal.matcher",
|
||||
"terminal.semantic",
|
||||
"terminal.prompt",
|
||||
"terminal.background",
|
||||
"terminal.theme",
|
||||
"terminal.interceptor.input",
|
||||
"terminal.interceptor.output",
|
||||
"connection",
|
||||
"authentication",
|
||||
"sync",
|
||||
"importer",
|
||||
]);
|
||||
|
||||
const CONNECTION_PROVIDER_OPERATIONS = Object.freeze([
|
||||
"validateConfiguration",
|
||||
"probe",
|
||||
"open",
|
||||
"resize",
|
||||
"signal",
|
||||
"reconnect",
|
||||
"close",
|
||||
"getStatus",
|
||||
]);
|
||||
const IMPORTER_PROVIDER_OPERATIONS = Object.freeze(["detect", "parse"]);
|
||||
const SYNC_PROVIDER_OPERATIONS = Object.freeze([
|
||||
"connect",
|
||||
"disconnect",
|
||||
"getAccount",
|
||||
"getCapabilities",
|
||||
"readObject",
|
||||
"writeObject",
|
||||
"deleteObject",
|
||||
]);
|
||||
const OPERATION_MAP_PROVIDER_KINDS = Object.freeze(new Set(["connection", "importer", "sync"]));
|
||||
|
||||
function pluginErrorNameFromRpcError(error) {
|
||||
if (typeof error?.data?.pluginCode === "string") return error.data.pluginCode;
|
||||
if (error?.code === RPC_ERRORS.methodNotFound) return "unsupported";
|
||||
if (error?.code === RPC_ERRORS.invalidParams) return "invalid_argument";
|
||||
if (error?.code === RPC_ERRORS.internal) return "internal";
|
||||
return PLUGIN_ERROR_NAMES_BY_WIRE_CODE.get(error?.code) ?? "unknown";
|
||||
}
|
||||
|
||||
function freezeRuntimeJson(value) {
|
||||
const clone = structuredClone(value);
|
||||
const freeze = (item) => {
|
||||
if (!item || typeof item !== "object" || Object.isFrozen(item)) return item;
|
||||
for (const child of Array.isArray(item) ? item : Object.values(item)) freeze(child);
|
||||
return Object.freeze(item);
|
||||
};
|
||||
return freeze(clone);
|
||||
}
|
||||
|
||||
function messageData(value) {
|
||||
return value && typeof value === "object" && "data" in value ? value.data : value;
|
||||
}
|
||||
|
||||
function closeTransferredPorts(ports) {
|
||||
for (const port of ports ?? []) port?.close?.();
|
||||
}
|
||||
|
||||
function createTransportAdapter(port) {
|
||||
const listeners = new Set();
|
||||
const handle = (event) => {
|
||||
const value = messageData(event);
|
||||
for (const listener of listeners) listener(value, event?.ports ?? []);
|
||||
};
|
||||
if (typeof port.addEventListener === "function") port.addEventListener("message", handle);
|
||||
else port.on("message", handle);
|
||||
port.start?.();
|
||||
return {
|
||||
post(message, transfer = []) {
|
||||
port.postMessage(message, transfer);
|
||||
},
|
||||
onMessage(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
close() {
|
||||
listeners.clear();
|
||||
port.close?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeRpcFailure(id, code, message, data) {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
error: { code, message, ...(data === undefined ? {} : { data }) },
|
||||
};
|
||||
}
|
||||
|
||||
function cancelUnhandledStream(transport, message) {
|
||||
const keys = Reflect.ownKeys(message);
|
||||
if (keys.some((key) => typeof key !== "string" || (key !== "frame" && key !== "transfer"))) {
|
||||
throw new TypeError("Plugin stream envelope contains unknown properties");
|
||||
}
|
||||
for (const key of keys) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(message, key);
|
||||
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
||||
throw new TypeError("Plugin stream envelope must contain enumerable data properties");
|
||||
}
|
||||
}
|
||||
const envelope = createMessagePortStreamEnvelope(message.frame, message.transfer);
|
||||
if (envelope.frame.kind !== "open") {
|
||||
throw new Error(`Unknown incoming plugin stream: ${envelope.frame.streamId}`);
|
||||
}
|
||||
transport.post(createMessagePortStreamEnvelope({
|
||||
streamId: envelope.frame.streamId,
|
||||
sequence: 1,
|
||||
kind: "cancel",
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeError(error) {
|
||||
if (error instanceof PluginError) {
|
||||
return pluginErrorToRpcError(error);
|
||||
}
|
||||
return { code: RPC_ERRORS.internal, message: "Plugin operation failed" };
|
||||
}
|
||||
|
||||
function createHostClient(transport) {
|
||||
let nextId = 0;
|
||||
const pending = new Map();
|
||||
function accept(message) {
|
||||
if (!message || typeof message !== "object" || !Object.hasOwn(message, "id")) return false;
|
||||
if (!Object.hasOwn(message, "result") && !Object.hasOwn(message, "error")) return false;
|
||||
const request = pending.get(`${typeof message.id}:${String(message.id)}`);
|
||||
if (!request) return false;
|
||||
pending.delete(`${typeof message.id}:${String(message.id)}`);
|
||||
if (message.error) {
|
||||
request.reject(new PluginError(
|
||||
pluginErrorNameFromRpcError(message.error),
|
||||
message.error.message,
|
||||
message.error.data?.details,
|
||||
));
|
||||
} else request.resolve(message.result);
|
||||
return true;
|
||||
}
|
||||
function request(method, params, options = {}) {
|
||||
const id = nextId;
|
||||
nextId = nextId === Number.MAX_SAFE_INTEGER ? 0 : nextId + 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(`${typeof id}:${String(id)}`, { resolve, reject });
|
||||
transport.post({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
...(options.deadlineMs === undefined ? {} : { deadlineMs: options.deadlineMs }),
|
||||
});
|
||||
});
|
||||
}
|
||||
function notify(method, params) {
|
||||
transport.post({ jsonrpc: "2.0", method, params });
|
||||
}
|
||||
function close() {
|
||||
const error = new PluginError("unavailable", "Plugin host disconnected");
|
||||
for (const request of pending.values()) request.reject(error);
|
||||
pending.clear();
|
||||
}
|
||||
return { accept, request, notify, close };
|
||||
}
|
||||
|
||||
function assertStorageKey(key) {
|
||||
if (typeof key !== "string" || key.length < 1 || key.length > 256 || key.includes("\0")) {
|
||||
throw new PluginError("invalid_argument", "Plugin storage key is invalid");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function assertCredentialRef(credential) {
|
||||
if (
|
||||
!credential
|
||||
|| typeof credential !== "object"
|
||||
|| (credential.kind !== "secret" && credential.kind !== "credential")
|
||||
|| typeof credential.id !== "string"
|
||||
|| credential.id.length < 16
|
||||
|| credential.id.length > 256
|
||||
) throw new PluginError("invalid_argument", "Credential reference is invalid");
|
||||
if (
|
||||
credential.kind === "secret"
|
||||
&& (
|
||||
typeof credential.key !== "string"
|
||||
|| credential.key.length < 1
|
||||
|| credential.key.length > 256
|
||||
|| credential.key.includes("\0")
|
||||
)
|
||||
) throw new PluginError("invalid_argument", "Credential reference is invalid");
|
||||
return credential.kind === "secret"
|
||||
? { kind: "secret", id: credential.id, key: credential.key }
|
||||
: { kind: "credential", id: credential.id };
|
||||
}
|
||||
|
||||
function forwardedDeadline(value, maximum) {
|
||||
return Number.isSafeInteger(value) && value >= 1 && value <= maximum ? value : undefined;
|
||||
}
|
||||
|
||||
function assertCompanionId(companionId) {
|
||||
if (typeof companionId !== "string" || companionId.length < 5 || companionId.length > 192) {
|
||||
throw new PluginError("invalid_argument", "Companion ID is invalid");
|
||||
}
|
||||
return companionId;
|
||||
}
|
||||
|
||||
function assertOwnedContributionId(pluginId, id, label) {
|
||||
if (typeof id !== "string" || !id.startsWith(`${pluginId}.`) || id.length > 256) {
|
||||
throw new PluginError("invalid_argument", `${label} ID is invalid`);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function assertProviderKind(kind) {
|
||||
if (!PROVIDER_KINDS.has(kind)) {
|
||||
throw new PluginError("invalid_argument", "Plugin Provider kind is invalid");
|
||||
}
|
||||
return kind;
|
||||
}
|
||||
|
||||
function operationMapLabel(kind) {
|
||||
if (kind === "connection") return "Connection";
|
||||
if (kind === "importer") return "Importer";
|
||||
if (kind === "sync") return "Sync";
|
||||
return kind;
|
||||
}
|
||||
|
||||
function operationsForProviderKind(kind) {
|
||||
if (kind === "connection") return CONNECTION_PROVIDER_OPERATIONS;
|
||||
if (kind === "importer") return IMPORTER_PROVIDER_OPERATIONS;
|
||||
if (kind === "sync") return SYNC_PROVIDER_OPERATIONS;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeProviderHandler(kind, handler) {
|
||||
if (!OPERATION_MAP_PROVIDER_KINDS.has(kind)) {
|
||||
if (typeof handler !== "function") {
|
||||
throw new PluginError("invalid_argument", "Plugin Provider handler must be a function");
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
const label = operationMapLabel(kind);
|
||||
if (!handler || typeof handler !== "object" || Array.isArray(handler)) {
|
||||
throw new PluginError("invalid_argument", `${label} Provider handler must be an operation map`);
|
||||
}
|
||||
const normalized = {};
|
||||
const operations = operationsForProviderKind(kind);
|
||||
for (const operation of operations) {
|
||||
if (typeof handler[operation] !== "function") {
|
||||
throw new PluginError("invalid_argument", `${label} Provider handler is missing operation: ${operation}`);
|
||||
}
|
||||
normalized[operation] = handler[operation].bind(handler);
|
||||
}
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
function assertOwnedContextKey(pluginId, key) {
|
||||
const prefix = `${pluginId}.`;
|
||||
const suffix = typeof key === "string" && key.startsWith(prefix)
|
||||
? key.slice(prefix.length)
|
||||
: "";
|
||||
if (!/^[A-Za-z0-9_][A-Za-z0-9_:-]{0,255}$/u.test(suffix)
|
||||
|| key.length > 256) {
|
||||
throw new PluginError("invalid_argument", "Plugin Context Key ID is invalid");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function normalizeRuntimeEnvironment(value) {
|
||||
const source = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
const sourceTokens = source.themeTokens && typeof source.themeTokens === "object"
|
||||
&& !Array.isArray(source.themeTokens)
|
||||
? source.themeTokens
|
||||
: {};
|
||||
const themeTokens = Object.freeze(Object.fromEntries(
|
||||
Object.entries(sourceTokens).filter(([key, token]) => key.length > 0 && typeof token === "string"),
|
||||
));
|
||||
return Object.freeze({
|
||||
locale: typeof source.locale === "string" ? source.locale : "en",
|
||||
theme: typeof source.theme === "string" ? source.theme : "system",
|
||||
reducedMotion: source.reducedMotion === true,
|
||||
highContrast: source.highContrast === true,
|
||||
themeTokens,
|
||||
});
|
||||
}
|
||||
|
||||
function createEmitter() {
|
||||
const listeners = new Set();
|
||||
return {
|
||||
event(listener) {
|
||||
if (typeof listener !== "function") throw new PluginError("invalid_argument", "Plugin event listener must be a function");
|
||||
listeners.add(listener);
|
||||
return Object.freeze({ dispose: () => listeners.delete(listener) });
|
||||
},
|
||||
fire(value) {
|
||||
for (const listener of [...listeners]) {
|
||||
try { listener(value); } catch {}
|
||||
}
|
||||
},
|
||||
clear() { listeners.clear(); },
|
||||
};
|
||||
}
|
||||
|
||||
function createPluginContext(config, client, runtimeApi) {
|
||||
const subscriptions = new DisposableStore();
|
||||
const storage = {
|
||||
get: (key) => client.request("storage.get", { key: assertStorageKey(key) })
|
||||
.then((result) => result?.found ? result.value : undefined),
|
||||
set: (key, value) => client.request("storage.set", { key: assertStorageKey(key), value })
|
||||
.then(() => undefined),
|
||||
delete: (key) => client.request("storage.delete", { key: assertStorageKey(key) })
|
||||
.then(() => undefined),
|
||||
keys: () => client.request("storage.keys", {}).then((result) => result?.keys ?? []),
|
||||
};
|
||||
const secrets = {
|
||||
get: (key) => client.request("secrets.get", { key: assertStorageKey(key) })
|
||||
.then((result) => result?.found ? result.secret : undefined),
|
||||
set: (key, value) => client.request("secrets.set", {
|
||||
key: assertStorageKey(key),
|
||||
value,
|
||||
}).then((result) => result.secret),
|
||||
delete: (key) => client.request("secrets.delete", { key: assertStorageKey(key) })
|
||||
.then(() => undefined),
|
||||
};
|
||||
const credentials = {
|
||||
createLease: (secret, options) => client.request("credentials.createLease", {
|
||||
secret: assertCredentialRef(secret),
|
||||
operationId: options?.operationId,
|
||||
purpose: options?.purpose,
|
||||
...(options?.ttlMs === undefined ? {} : { ttlMs: options.ttlMs }),
|
||||
}),
|
||||
};
|
||||
const network = {
|
||||
request: (request) => client.request("network.request", request, {
|
||||
deadlineMs: forwardedDeadline(request?.timeoutMs, 300_000),
|
||||
}),
|
||||
};
|
||||
const filesystem = {
|
||||
readFile: (filePath, options = {}) => client.request("filesystem.readFile", {
|
||||
path: filePath,
|
||||
...options,
|
||||
}).then((result) => result.data),
|
||||
writeFile: (filePath, data, options = {}) => client.request("filesystem.writeFile", {
|
||||
path: filePath,
|
||||
data,
|
||||
...options,
|
||||
}).then(() => undefined),
|
||||
stat: (filePath) => client.request("filesystem.stat", { path: filePath }),
|
||||
readDirectory: (directoryPath) => client.request("filesystem.readDirectory", { path: directoryPath })
|
||||
.then((result) => result.entries),
|
||||
};
|
||||
const companions = {
|
||||
start: async (companionId) => {
|
||||
const result = await client.request("companion.start", {
|
||||
companionId: assertCompanionId(companionId),
|
||||
});
|
||||
let stopped = false;
|
||||
let stopPromise = null;
|
||||
const stop = () => {
|
||||
if (stopped) return Promise.resolve();
|
||||
if (stopPromise) return stopPromise;
|
||||
stopPromise = client.request("companion.stop", { handleId: result.handleId })
|
||||
.then(() => { stopped = true; })
|
||||
.finally(() => { stopPromise = null; });
|
||||
return stopPromise;
|
||||
};
|
||||
return Object.freeze({
|
||||
id: result.handleId,
|
||||
request: (method, params, options = {}) => client.request(
|
||||
"companion.request",
|
||||
{
|
||||
handleId: result.handleId,
|
||||
method,
|
||||
...(params === undefined ? {} : { params }),
|
||||
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
||||
...(options.credentialLeases === undefined ? {} : {
|
||||
credentialLeases: options.credentialLeases,
|
||||
operationId: options.operationId,
|
||||
}),
|
||||
},
|
||||
{ deadlineMs: forwardedDeadline(options.timeoutMs, 60_000) },
|
||||
),
|
||||
stop,
|
||||
dispose() { void stop().catch(() => {}); },
|
||||
});
|
||||
},
|
||||
};
|
||||
const streams = {
|
||||
acceptReadable: (streamId) => runtimeApi.streams.acceptReadable(streamId),
|
||||
openWritable: (streamId, options = {}) => runtimeApi.streams.openWritable(
|
||||
streamId,
|
||||
options.windowBytes,
|
||||
),
|
||||
};
|
||||
const settings = {
|
||||
get: (settingId, options = {}) => client.request("settings.get", {
|
||||
settingId: assertOwnedContributionId(config.pluginId, settingId, "Plugin setting"),
|
||||
...(options.scopeId === undefined ? {} : { scopeId: options.scopeId }),
|
||||
}).then((result) => result?.found ? result.value : undefined),
|
||||
update: (settingId, value, options = {}) => client.request("settings.update", {
|
||||
settingId: assertOwnedContributionId(config.pluginId, settingId, "Plugin setting"),
|
||||
value,
|
||||
...(options.scopeId === undefined ? {} : { scopeId: options.scopeId }),
|
||||
}).then((result) => result ?? { restartRequired: false }),
|
||||
onDidChange: runtimeApi.settingsChanged.event,
|
||||
};
|
||||
const commands = {
|
||||
registerCommand(commandId, handler) {
|
||||
const id = assertOwnedContributionId(config.pluginId, commandId, "Plugin command");
|
||||
if (typeof handler !== "function") throw new PluginError("invalid_argument", "Plugin command handler must be a function");
|
||||
if (runtimeApi.commandHandlers.has(id)) throw new PluginError("already_exists", `Plugin command is already registered: ${id}`);
|
||||
runtimeApi.commandHandlers.set(id, handler);
|
||||
return Object.freeze({
|
||||
dispose() {
|
||||
if (runtimeApi.commandHandlers.get(id) === handler) {
|
||||
runtimeApi.commandHandlers.delete(id);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
executeCommand: (commandId, args) => client.request("commands.execute", {
|
||||
command: assertOwnedContributionId(config.pluginId, commandId, "Plugin command"),
|
||||
...(args === undefined ? {} : { args }),
|
||||
}),
|
||||
};
|
||||
const contextKeys = {
|
||||
set: (key, value) => client.request("contextKeys.set", {
|
||||
key: assertOwnedContextKey(config.pluginId, key),
|
||||
value,
|
||||
}).then(() => undefined),
|
||||
};
|
||||
const views = {
|
||||
onDidReceiveMessage(viewId, listener) {
|
||||
const id = assertOwnedContributionId(config.pluginId, viewId, "Plugin view");
|
||||
let emitter = runtimeApi.viewMessages.get(id);
|
||||
if (!emitter) {
|
||||
emitter = createEmitter();
|
||||
runtimeApi.viewMessages.set(id, emitter);
|
||||
}
|
||||
return emitter.event(listener);
|
||||
},
|
||||
postMessage: (viewId, message) => client.notify("views.postMessage", {
|
||||
viewId: assertOwnedContributionId(config.pluginId, viewId, "Plugin view"),
|
||||
message,
|
||||
}),
|
||||
getState: (viewId, scopeId) => client.request("views.getState", {
|
||||
viewId: assertOwnedContributionId(config.pluginId, viewId, "Plugin view"),
|
||||
scopeId,
|
||||
}).then((result) => result?.state),
|
||||
setState: (viewId, scopeId, state) => client.request("views.setState", {
|
||||
viewId: assertOwnedContributionId(config.pluginId, viewId, "Plugin view"),
|
||||
scopeId,
|
||||
state,
|
||||
}).then(() => undefined),
|
||||
};
|
||||
const providers = {
|
||||
register(providerId, kind, handler) {
|
||||
const id = assertOwnedContributionId(config.pluginId, providerId, "Plugin Provider");
|
||||
const normalizedKind = assertProviderKind(kind);
|
||||
const normalizedHandler = normalizeProviderHandler(normalizedKind, handler);
|
||||
if (runtimeApi.providerHandlers.has(id)) {
|
||||
throw new PluginError("already_exists", `Plugin Provider is already registered: ${id}`);
|
||||
}
|
||||
const registration = Object.freeze({ kind: normalizedKind, handler: normalizedHandler });
|
||||
runtimeApi.providerHandlers.set(id, registration);
|
||||
return Object.freeze({
|
||||
dispose() {
|
||||
if (runtimeApi.providerHandlers.get(id) === registration) {
|
||||
runtimeApi.providerHandlers.delete(id);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
const terminals = {
|
||||
onDidChange: runtimeApi.terminalEvents.event,
|
||||
};
|
||||
const environment = {
|
||||
get locale() { return runtimeApi.environment.locale ?? "en"; },
|
||||
get theme() { return runtimeApi.environment.theme ?? "system"; },
|
||||
get reducedMotion() { return runtimeApi.environment.reducedMotion === true; },
|
||||
get highContrast() { return runtimeApi.environment.highContrast === true; },
|
||||
get themeTokens() { return runtimeApi.environment.themeTokens; },
|
||||
onDidChange: runtimeApi.environmentChanged.event,
|
||||
};
|
||||
const logger = Object.fromEntries(["debug", "info", "warn", "error"].map((level) => [
|
||||
level,
|
||||
(message, fields) => client.notify("log.write", {
|
||||
level,
|
||||
message: String(message).slice(0, 2_048),
|
||||
...(fields === undefined ? {} : { fields }),
|
||||
}),
|
||||
]));
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
netcattyVersion: config.netcattyVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: new Set(config.enabledFeatures),
|
||||
subscriptions,
|
||||
storage,
|
||||
settings,
|
||||
commands,
|
||||
contextKeys,
|
||||
views,
|
||||
providers,
|
||||
terminals,
|
||||
environment,
|
||||
secrets,
|
||||
credentials,
|
||||
network,
|
||||
filesystem,
|
||||
companions,
|
||||
streams,
|
||||
logger,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startPluginRuntime({
|
||||
port,
|
||||
config,
|
||||
loadPlugin,
|
||||
loadTerminalInterceptorTransport: loadTerminalInterceptorTransportForRuntime = loadTerminalInterceptorTransport,
|
||||
}) {
|
||||
const transport = createTransportAdapter(port);
|
||||
const client = createHostClient(transport);
|
||||
const cancellation = new Map();
|
||||
let plugin;
|
||||
let context;
|
||||
let activated = false;
|
||||
let deactivated = false;
|
||||
const runtimeApi = {
|
||||
commandHandlers: new Map(),
|
||||
providerHandlers: new Map(),
|
||||
settingsChanged: createEmitter(),
|
||||
environmentChanged: createEmitter(),
|
||||
terminalEvents: createEmitter(),
|
||||
environment: normalizeRuntimeEnvironment(config.environment),
|
||||
viewMessages: new Map(),
|
||||
terminalInterceptorPorts: new Set(),
|
||||
streams: null,
|
||||
};
|
||||
runtimeApi.streams = createPluginStreamEndpoint(transport);
|
||||
const pluginModule = await loadPlugin(config.entryUrl);
|
||||
plugin = pluginModule?.default;
|
||||
if (!plugin || typeof plugin.activate !== "function") {
|
||||
throw new Error("Plugin entrypoint must default-export a plugin with activate(context)");
|
||||
}
|
||||
|
||||
async function handleRequest(message, cancellationToken, ports = []) {
|
||||
const isTerminalAttachment = message.method === "plugin.terminal.interceptor.attach";
|
||||
if (!isTerminalAttachment) closeTransferredPorts(ports);
|
||||
if (message.method === "plugin.initialize") {
|
||||
if (context) throw new PluginError("failed_precondition", "Plugin is already initialized");
|
||||
context = createPluginContext(config, client, runtimeApi);
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: [...config.enabledFeatures],
|
||||
};
|
||||
}
|
||||
if (message.method === "plugin.activate") {
|
||||
if (!context) throw new PluginError("failed_precondition", "Plugin must be initialized first");
|
||||
if (!activated) {
|
||||
if (message.params?.environment && typeof message.params.environment === "object"
|
||||
&& !Array.isArray(message.params.environment)) {
|
||||
runtimeApi.environment = normalizeRuntimeEnvironment(message.params.environment);
|
||||
}
|
||||
const disposable = await plugin.activate(context);
|
||||
if (disposable && typeof disposable.dispose === "function") context.subscriptions.add(disposable);
|
||||
activated = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (message.method === "plugin.deactivate") {
|
||||
if (!deactivated) {
|
||||
deactivated = true;
|
||||
await plugin.deactivate?.();
|
||||
context?.subscriptions.dispose();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (message.method === "plugin.terminal.interceptor.attach") {
|
||||
if (ports.length !== 1) {
|
||||
closeTransferredPorts(ports);
|
||||
throw new PluginError("invalid_argument", "Terminal interceptor attachment requires exactly one port");
|
||||
}
|
||||
try {
|
||||
await attachTerminalInterceptor(message.params, ports, cancellationToken);
|
||||
return { accepted: true };
|
||||
} catch (error) {
|
||||
closeTransferredPorts(ports);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (message.method === "plugin.command.execute") {
|
||||
if (!activated || !context) throw new PluginError("failed_precondition", "Plugin is not activated");
|
||||
const command = assertOwnedContributionId(config.pluginId, message.params?.command, "Plugin command");
|
||||
const handler = runtimeApi.commandHandlers.get(command);
|
||||
if (!handler) throw new PluginError("failed_precondition", `Plugin command has no registered handler: ${command}`);
|
||||
return await handler(message.params?.args, message.params?.invocation);
|
||||
}
|
||||
if (message.method === "provider.invoke") {
|
||||
if (!activated || !context) throw new PluginError("failed_precondition", "Plugin is not activated");
|
||||
const providerId = assertOwnedContributionId(config.pluginId, message.params?.providerId, "Plugin Provider");
|
||||
const kind = assertProviderKind(message.params?.kind);
|
||||
const registration = runtimeApi.providerHandlers.get(providerId);
|
||||
if (!registration) {
|
||||
throw new PluginError("failed_precondition", `Plugin Provider has no registered handler: ${providerId}`);
|
||||
}
|
||||
if (registration.kind !== kind) {
|
||||
throw new PluginError("failed_precondition", `Plugin Provider kind changed: ${providerId}`);
|
||||
}
|
||||
const requestId = message.params?.requestId;
|
||||
if (typeof requestId !== "string" || requestId.length < 1 || requestId.length > 128) {
|
||||
throw new PluginError("invalid_argument", "Plugin Provider request ID is invalid");
|
||||
}
|
||||
const operation = message.params?.operation;
|
||||
if (typeof operation !== "string" || operation.length < 1 || operation.length > 128) {
|
||||
throw new PluginError("invalid_argument", "Plugin Provider operation is invalid");
|
||||
}
|
||||
const deadlineMs = message.params?.deadlineMs;
|
||||
if (deadlineMs != null && (!Number.isInteger(deadlineMs) || deadlineMs < 1 || deadlineMs > 300_000)) {
|
||||
throw new PluginError("invalid_argument", "Plugin Provider deadline is invalid");
|
||||
}
|
||||
const payload = message.params?.payload === undefined
|
||||
? undefined
|
||||
: freezeRuntimeJson(message.params.payload);
|
||||
const connectionOpen = kind === "connection" && operation === "open";
|
||||
const importerParse = kind === "importer" && operation === "parse";
|
||||
const syncReadStream = kind === "sync" && operation === "readObject" && typeof payload?.outputStreamId === "string";
|
||||
const syncWriteStream = kind === "sync" && operation === "writeObject" && typeof payload?.inputStreamId === "string";
|
||||
const streamed = connectionOpen || importerParse || syncReadStream || syncWriteStream;
|
||||
let input;
|
||||
let output;
|
||||
let cancelStreams;
|
||||
if (streamed) {
|
||||
const inputStreamId = payload?.inputStreamId;
|
||||
const outputStreamId = payload?.outputStreamId;
|
||||
const windowBytes = payload?.windowBytes;
|
||||
if (connectionOpen || importerParse) {
|
||||
if (typeof inputStreamId !== "string" || typeof outputStreamId !== "string") {
|
||||
throw new PluginError("invalid_argument", "Streamed Provider invocation requires input and output stream IDs");
|
||||
}
|
||||
} else if (syncReadStream && typeof outputStreamId !== "string") {
|
||||
throw new PluginError("invalid_argument", "Sync readObject stream requires an output stream ID");
|
||||
} else if (syncWriteStream && typeof inputStreamId !== "string") {
|
||||
throw new PluginError("invalid_argument", "Sync writeObject stream requires an input stream ID");
|
||||
}
|
||||
if (typeof inputStreamId === "string") {
|
||||
input = runtimeApi.streams.acceptReadable(inputStreamId);
|
||||
// Prevent a cancelled request from creating an unhandled rejected
|
||||
// promise when the Provider never awaited its input stream.
|
||||
void input.catch(() => {});
|
||||
}
|
||||
if (typeof outputStreamId === "string") {
|
||||
try {
|
||||
output = await runtimeApi.streams.openWritable(outputStreamId, windowBytes);
|
||||
} catch (error) {
|
||||
if (typeof inputStreamId === "string") {
|
||||
runtimeApi.streams.rejectReadable(inputStreamId, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
cancelStreams = () => {
|
||||
const error = new PluginError("cancelled", "Streamed Provider invocation was cancelled");
|
||||
if (typeof inputStreamId === "string") {
|
||||
runtimeApi.streams.rejectReadable(inputStreamId, error);
|
||||
void input?.then((stream) => stream.cancel(), () => {});
|
||||
}
|
||||
output?.cancel?.();
|
||||
};
|
||||
}
|
||||
const cancellationDisposable = cancelStreams
|
||||
? cancellationToken.onCancellationRequested(cancelStreams)
|
||||
: null;
|
||||
const invocation = Object.freeze({
|
||||
providerId,
|
||||
kind,
|
||||
operation,
|
||||
requestId,
|
||||
payload,
|
||||
deadlineMs,
|
||||
cancellationToken,
|
||||
...(input !== undefined ? { input } : {}),
|
||||
...(output !== undefined ? { output } : {}),
|
||||
});
|
||||
try {
|
||||
const handler = OPERATION_MAP_PROVIDER_KINDS.has(registration.kind)
|
||||
? registration.handler[operation]
|
||||
: registration.handler;
|
||||
if (typeof handler !== "function") {
|
||||
throw new PluginError("invalid_argument", `${kind} Provider operation is not implemented: ${operation}`);
|
||||
}
|
||||
const result = await handler(invocation);
|
||||
if (cancellationToken.isCancellationRequested) {
|
||||
return { requestId, status: "cancelled" };
|
||||
}
|
||||
// Sync readObject may return inline base64 or found:false while the host
|
||||
// still opened an output stream for large-object fallback — release it.
|
||||
if (syncReadStream && output && !(result && result.streamed === true)) {
|
||||
try { output.cancel?.(); } catch { /* best-effort */ }
|
||||
}
|
||||
return { requestId, status: "ok", result: result === undefined ? null : result };
|
||||
} catch (error) {
|
||||
cancelStreams?.();
|
||||
if (cancellationToken.isCancellationRequested) {
|
||||
return { requestId, status: "cancelled" };
|
||||
}
|
||||
const rpcError = normalizeError(error);
|
||||
return {
|
||||
requestId,
|
||||
status: "failed",
|
||||
error: { code: rpcError.code, message: rpcError.message, ...(rpcError.data === undefined ? {} : { data: rpcError.data }) },
|
||||
};
|
||||
} finally {
|
||||
cancellationDisposable?.dispose();
|
||||
if (streamed && cancellationToken.isCancellationRequested) cancelStreams();
|
||||
}
|
||||
}
|
||||
throw new PluginError("unsupported", `Unsupported host method: ${message.method}`);
|
||||
}
|
||||
|
||||
function handleNotification(message) {
|
||||
if (message.method === "plugin.settings.changed") {
|
||||
runtimeApi.settingsChanged.fire(message.params);
|
||||
return true;
|
||||
}
|
||||
if (message.method === "plugin.environment.changed") {
|
||||
runtimeApi.environment = normalizeRuntimeEnvironment(message.params);
|
||||
runtimeApi.environmentChanged.fire(runtimeApi.environment);
|
||||
return true;
|
||||
}
|
||||
if (message.method === "plugin.view.message") {
|
||||
const viewId = assertOwnedContributionId(config.pluginId, message.params?.viewId, "Plugin view");
|
||||
runtimeApi.viewMessages.get(viewId)?.fire(message.params?.message);
|
||||
return true;
|
||||
}
|
||||
if (message.method === "plugin.terminal.event") {
|
||||
runtimeApi.terminalEvents.fire(freezeRuntimeJson(message.params));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const attachTerminalInterceptor = async (message, ports, cancellationToken) => {
|
||||
const descriptor = message?.descriptor;
|
||||
const providerId = assertOwnedContributionId(config.pluginId, descriptor?.providerId, "Terminal interceptor");
|
||||
const direction = descriptor?.direction;
|
||||
const kind = direction === "input" ? "terminal.interceptor.input"
|
||||
: direction === "output" ? "terminal.interceptor.output" : null;
|
||||
const sessionId = descriptor?.session?.sessionId;
|
||||
if (!kind || typeof sessionId !== "string" || sessionId.length < 1 || sessionId.length > 256) {
|
||||
throw new PluginError("invalid_argument", "Terminal interceptor descriptor is invalid");
|
||||
}
|
||||
const registration = runtimeApi.providerHandlers.get(providerId);
|
||||
const interceptorPort = ports?.[0];
|
||||
if (!activated || registration?.kind !== kind || !interceptorPort?.postMessage) {
|
||||
interceptorPort?.close?.();
|
||||
throw new PluginError("failed_precondition", "Terminal interceptor provider is not registered");
|
||||
}
|
||||
runtimeApi.terminalInterceptorPorts.add(interceptorPort);
|
||||
let attached = false;
|
||||
try {
|
||||
const {
|
||||
TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES,
|
||||
createTerminalInterceptorEnvelope,
|
||||
} = await loadTerminalInterceptorTransportForRuntime();
|
||||
if (cancellationToken?.isCancellationRequested
|
||||
|| deactivated
|
||||
|| !activated
|
||||
|| !runtimeApi.terminalInterceptorPorts.has(interceptorPort)
|
||||
|| runtimeApi.providerHandlers.get(providerId) !== registration) {
|
||||
throw new PluginError("failed_precondition", "Terminal interceptor attachment was cancelled or became stale");
|
||||
}
|
||||
const handleChunk = (event) => {
|
||||
let envelope;
|
||||
try {
|
||||
const message = messageData(event);
|
||||
envelope = createTerminalInterceptorEnvelope(message?.frame, message?.transfer);
|
||||
} catch {
|
||||
interceptorPort.close?.();
|
||||
return;
|
||||
}
|
||||
const chunk = envelope.frame;
|
||||
if (chunk?.type === "netcatty:terminal-interceptor:ready") return;
|
||||
if (chunk.type !== "netcatty:terminal-interceptor:chunk"
|
||||
|| chunk.direction !== direction) {
|
||||
interceptorPort.close?.();
|
||||
return;
|
||||
}
|
||||
const invocation = Object.freeze({
|
||||
providerId,
|
||||
kind,
|
||||
direction,
|
||||
sequence: chunk.sequence,
|
||||
session: freezeRuntimeJson(descriptor.session),
|
||||
data: new Uint8Array(envelope.transfer),
|
||||
});
|
||||
// Resolve the registration for every chunk so disposal/re-registration
|
||||
// cannot keep a captured stale handler alive. Starting from a resolved
|
||||
// promise also converts synchronous handler throws into the ordinary
|
||||
// failed response path instead of tearing down the utility runtime.
|
||||
void Promise.resolve().then(() => {
|
||||
const currentRegistration = runtimeApi.providerHandlers.get(providerId);
|
||||
if (currentRegistration?.kind !== kind) {
|
||||
throw new PluginError("failed_precondition", "Terminal interceptor provider is no longer registered");
|
||||
}
|
||||
return currentRegistration.handler(invocation);
|
||||
}).then(
|
||||
(result) => {
|
||||
let bytes;
|
||||
if (result instanceof Uint8Array) bytes = result;
|
||||
else if (result instanceof ArrayBuffer) bytes = new Uint8Array(result);
|
||||
else throw new PluginError("data_loss", "Terminal interceptor must return Uint8Array or ArrayBuffer");
|
||||
if (bytes.byteLength > TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES) {
|
||||
throw new PluginError("resource_exhausted", "Terminal interceptor result is too large");
|
||||
}
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
const resultEnvelope = createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:result",
|
||||
sequence: chunk.sequence,
|
||||
status: "ok",
|
||||
creditBytes: chunk.byteLength,
|
||||
byteLength: copy.byteLength,
|
||||
}, copy.buffer);
|
||||
interceptorPort.postMessage(resultEnvelope, [copy.buffer]);
|
||||
},
|
||||
() => interceptorPort.postMessage(createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:result",
|
||||
sequence: chunk.sequence,
|
||||
status: "failed",
|
||||
})),
|
||||
).catch(() => interceptorPort.close?.());
|
||||
};
|
||||
if (typeof interceptorPort.addEventListener === "function") {
|
||||
interceptorPort.addEventListener("message", handleChunk);
|
||||
} else {
|
||||
interceptorPort.on("message", handleChunk);
|
||||
}
|
||||
interceptorPort.start?.();
|
||||
interceptorPort.on?.("close", () => runtimeApi.terminalInterceptorPorts.delete(interceptorPort));
|
||||
attached = true;
|
||||
} finally {
|
||||
if (!attached) {
|
||||
runtimeApi.terminalInterceptorPorts.delete(interceptorPort);
|
||||
interceptorPort.close?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dispose = transport.onMessage((message, ports) => {
|
||||
if (message && typeof message === "object" && Object.hasOwn(message, "frame")) {
|
||||
for (const port of ports) port?.close?.();
|
||||
try {
|
||||
if (!runtimeApi.streams.accept(message)) cancelUnhandledStream(transport, message);
|
||||
}
|
||||
catch { transport.close(); }
|
||||
return;
|
||||
}
|
||||
if (client.accept(message)) {
|
||||
for (const port of ports) port?.close?.();
|
||||
return;
|
||||
}
|
||||
if (!message || message.jsonrpc !== "2.0") {
|
||||
for (const port of ports) port?.close?.();
|
||||
return;
|
||||
}
|
||||
if (message.method === "$/cancelRequest") {
|
||||
for (const port of ports) port?.close?.();
|
||||
cancellation.get(message.params?.cancellationId)?.cancel();
|
||||
return;
|
||||
}
|
||||
if (!Object.hasOwn(message, "id")) {
|
||||
for (const port of ports) port?.close?.();
|
||||
try { handleNotification(message); } catch { transport.close(); }
|
||||
return;
|
||||
}
|
||||
const cancellationId = message.cancellationId;
|
||||
const source = new CancellationTokenSource();
|
||||
if (cancellationId) cancellation.set(cancellationId, source);
|
||||
void handleRequest(message, source.token, ports).then(
|
||||
(result) => transport.post({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: result === undefined ? null : result,
|
||||
}),
|
||||
(error) => {
|
||||
const rpcError = normalizeError(error);
|
||||
transport.post(makeRpcFailure(message.id, rpcError.code, rpcError.message, rpcError.data));
|
||||
},
|
||||
).finally(() => {
|
||||
if (cancellationId) cancellation.delete(cancellationId);
|
||||
source.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
async dispose() {
|
||||
dispose();
|
||||
client.close();
|
||||
for (const source of cancellation.values()) source.cancel();
|
||||
runtimeApi.commandHandlers.clear();
|
||||
runtimeApi.providerHandlers.clear();
|
||||
runtimeApi.settingsChanged.clear();
|
||||
runtimeApi.environmentChanged.clear();
|
||||
runtimeApi.terminalEvents.clear();
|
||||
for (const interceptorPort of runtimeApi.terminalInterceptorPorts) interceptorPort.close?.();
|
||||
runtimeApi.terminalInterceptorPorts.clear();
|
||||
runtimeApi.streams.close();
|
||||
for (const emitter of runtimeApi.viewMessages.values()) emitter.clear();
|
||||
cancellation.clear();
|
||||
if (!deactivated) {
|
||||
await plugin.deactivate?.();
|
||||
context?.subscriptions.dispose();
|
||||
}
|
||||
transport.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const {
|
||||
createTerminalInterceptorEnvelope,
|
||||
} = require("../terminalInterceptorTransport.cjs");
|
||||
|
||||
class FakePort {
|
||||
constructor() {
|
||||
this.listeners = new Set();
|
||||
this.messages = [];
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
addEventListener(type, listener) {
|
||||
if (type === "message") this.listeners.add(listener);
|
||||
}
|
||||
|
||||
removeEventListener(type, listener) {
|
||||
if (type === "message") this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
postMessage(message, transfer = []) {
|
||||
this.messages.push({ message, transfer });
|
||||
}
|
||||
|
||||
start() {}
|
||||
|
||||
close() { this.closed = true; }
|
||||
|
||||
emit(data, ports = []) {
|
||||
for (const listener of this.listeners) listener({ data, ports });
|
||||
}
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
test("utility runtime dispatches dedicated terminal ports to the exact registered interceptor", async () => {
|
||||
const { startPluginRuntime } = await import("./runtimePeer.mjs");
|
||||
const control = new FakePort();
|
||||
const runtime = await startPluginRuntime({
|
||||
port: control,
|
||||
config: {
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "1.0.0",
|
||||
enabledFeatures: [],
|
||||
environment: {},
|
||||
entryUrl: "file:///plugin.js",
|
||||
},
|
||||
loadPlugin: async () => ({
|
||||
default: {
|
||||
activate(context) {
|
||||
context.providers.register(
|
||||
"com.example.input",
|
||||
"terminal.interceptor.input",
|
||||
({ data }) => Uint8Array.from([...data].map((byte) => byte >= 97 && byte <= 122 ? byte - 32 : byte)),
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
control.emit({ jsonrpc: "2.0", id: 1, method: "plugin.initialize", params: {} });
|
||||
await tick();
|
||||
control.emit({ jsonrpc: "2.0", id: 2, method: "plugin.activate", params: {} });
|
||||
await tick();
|
||||
|
||||
const dataPort = new FakePort();
|
||||
control.emit({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "plugin.terminal.interceptor.attach",
|
||||
params: {
|
||||
descriptor: {
|
||||
providerId: "com.example.input",
|
||||
direction: "input",
|
||||
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
|
||||
},
|
||||
},
|
||||
}, [dataPort]);
|
||||
await tick();
|
||||
assert.equal(
|
||||
control.messages.some(({ message }) => (
|
||||
message.jsonrpc === "2.0"
|
||||
&& message.id === 3
|
||||
&& message.result?.accepted === true
|
||||
)),
|
||||
true,
|
||||
);
|
||||
dataPort.emit(createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:ready",
|
||||
sessionId: "session-1",
|
||||
direction: "input",
|
||||
windowBytes: 64 * 1024,
|
||||
}));
|
||||
assert.equal(dataPort.closed, false);
|
||||
const data = Uint8Array.from(Buffer.from("hello")).buffer;
|
||||
dataPort.emit(createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:chunk",
|
||||
sequence: 1,
|
||||
direction: "input",
|
||||
creditBytes: 64 * 1024,
|
||||
byteLength: data.byteLength,
|
||||
}, data));
|
||||
await tick();
|
||||
assert.equal(dataPort.messages.length, 1);
|
||||
const result = createTerminalInterceptorEnvelope(
|
||||
dataPort.messages[0].message.frame,
|
||||
dataPort.messages[0].message.transfer,
|
||||
);
|
||||
assert.equal(result.frame.status, "ok");
|
||||
assert.equal(result.frame.creditBytes, 5);
|
||||
assert.equal(Buffer.from(result.transfer).toString("utf8"), "HELLO");
|
||||
assert.deepEqual(dataPort.messages[0].transfer, [result.transfer]);
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
test("utility runtime closes a terminal port when provider ownership or kind is invalid", async () => {
|
||||
const { startPluginRuntime } = await import("./runtimePeer.mjs");
|
||||
const control = new FakePort();
|
||||
const runtime = await startPluginRuntime({
|
||||
port: control,
|
||||
config: {
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "1.0.0",
|
||||
enabledFeatures: [],
|
||||
environment: {},
|
||||
entryUrl: "file:///plugin.js",
|
||||
},
|
||||
loadPlugin: async () => ({ default: { activate() {} } }),
|
||||
});
|
||||
control.emit({ jsonrpc: "2.0", id: 1, method: "plugin.initialize", params: {} });
|
||||
await tick();
|
||||
control.emit({ jsonrpc: "2.0", id: 2, method: "plugin.activate", params: {} });
|
||||
await tick();
|
||||
const dataPort = new FakePort();
|
||||
control.emit({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "plugin.terminal.interceptor.attach",
|
||||
params: {
|
||||
descriptor: {
|
||||
providerId: "com.example.missing",
|
||||
direction: "input",
|
||||
session: { sessionId: "session-1" },
|
||||
},
|
||||
},
|
||||
}, [dataPort]);
|
||||
await tick();
|
||||
assert.equal(dataPort.closed, true);
|
||||
assert.equal(
|
||||
control.messages.some(({ message }) => (
|
||||
message.jsonrpc === "2.0"
|
||||
&& message.id === 3
|
||||
&& message.error?.code === -32009
|
||||
)),
|
||||
true,
|
||||
);
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
test("utility runtime closes the transferred terminal port when the provider belongs to another plugin", async () => {
|
||||
const { startPluginRuntime } = await import("./runtimePeer.mjs");
|
||||
const control = new FakePort();
|
||||
const runtime = await startPluginRuntime({
|
||||
port: control,
|
||||
config: {
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "1.0.0",
|
||||
enabledFeatures: [],
|
||||
environment: {},
|
||||
entryUrl: "file:///plugin.js",
|
||||
},
|
||||
loadPlugin: async () => ({ default: { activate() {} } }),
|
||||
});
|
||||
control.emit({ jsonrpc: "2.0", id: 1, method: "plugin.initialize", params: {} });
|
||||
await tick();
|
||||
control.emit({ jsonrpc: "2.0", id: 2, method: "plugin.activate", params: {} });
|
||||
await tick();
|
||||
const dataPort = new FakePort();
|
||||
control.emit({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "plugin.terminal.interceptor.attach",
|
||||
params: {
|
||||
descriptor: {
|
||||
providerId: "other.plugin.input",
|
||||
direction: "input",
|
||||
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
|
||||
},
|
||||
},
|
||||
}, [dataPort]);
|
||||
await tick();
|
||||
assert.equal(dataPort.closed, true);
|
||||
assert.equal(
|
||||
control.messages.some(({ message }) => (
|
||||
message.jsonrpc === "2.0"
|
||||
&& message.id === 3
|
||||
&& message.error?.code === -32003
|
||||
)),
|
||||
true,
|
||||
);
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
test("utility runtime closes unexpected transferred ports on every lifecycle request", async () => {
|
||||
const { startPluginRuntime } = await import("./runtimePeer.mjs");
|
||||
const control = new FakePort();
|
||||
const runtime = await startPluginRuntime({
|
||||
port: control,
|
||||
config: {
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "1.0.0",
|
||||
enabledFeatures: [],
|
||||
environment: {},
|
||||
entryUrl: "file:///plugin.js",
|
||||
},
|
||||
loadPlugin: async () => ({ default: { activate() {} } }),
|
||||
});
|
||||
const lifecycleRequests = [
|
||||
{ id: 1, method: "plugin.initialize", params: {} },
|
||||
{ id: 2, method: "plugin.activate", params: {} },
|
||||
{ id: 3, method: "plugin.deactivate", params: {} },
|
||||
];
|
||||
for (const request of lifecycleRequests) {
|
||||
const unexpectedPort = new FakePort();
|
||||
control.emit({ jsonrpc: "2.0", ...request }, [unexpectedPort]);
|
||||
await tick();
|
||||
assert.equal(unexpectedPort.closed, true, `${request.method} retained an unexpected port`);
|
||||
assert.equal(
|
||||
control.messages.some(({ message }) => (
|
||||
message.jsonrpc === "2.0"
|
||||
&& message.id === request.id
|
||||
&& Object.hasOwn(message, "result")
|
||||
)),
|
||||
true,
|
||||
`${request.method} did not complete after closing its unexpected port`,
|
||||
);
|
||||
}
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
test("utility runtime rejects the retired private terminal attachment protocol", async () => {
|
||||
const { startPluginRuntime } = await import("./runtimePeer.mjs");
|
||||
const control = new FakePort();
|
||||
const runtime = await startPluginRuntime({
|
||||
port: control,
|
||||
config: {
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "1.0.0",
|
||||
enabledFeatures: [],
|
||||
environment: {},
|
||||
entryUrl: "file:///plugin.js",
|
||||
},
|
||||
loadPlugin: async () => ({ default: { activate() {} } }),
|
||||
});
|
||||
const dataPort = new FakePort();
|
||||
control.emit({
|
||||
type: "netcatty-plugin:terminal-interceptor:attach",
|
||||
attachmentId: 1,
|
||||
descriptor: { providerId: "com.example.input", direction: "input" },
|
||||
}, [dataPort]);
|
||||
assert.equal(dataPort.closed, true);
|
||||
assert.equal(control.messages.length, 0);
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
test("terminal ports convert synchronous throws to failures and stop using disposed handlers", async () => {
|
||||
const { startPluginRuntime } = await import("./runtimePeer.mjs");
|
||||
const control = new FakePort();
|
||||
let registration;
|
||||
let calls = 0;
|
||||
const runtime = await startPluginRuntime({
|
||||
port: control,
|
||||
config: {
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "1.0.0",
|
||||
enabledFeatures: [],
|
||||
environment: {},
|
||||
entryUrl: "file:///plugin.js",
|
||||
},
|
||||
loadPlugin: async () => ({
|
||||
default: {
|
||||
activate(context) {
|
||||
registration = context.providers.register(
|
||||
"com.example.input",
|
||||
"terminal.interceptor.input",
|
||||
() => {
|
||||
calls += 1;
|
||||
throw new Error("synchronous failure");
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
control.emit({ jsonrpc: "2.0", id: 1, method: "plugin.initialize", params: {} });
|
||||
await tick();
|
||||
control.emit({ jsonrpc: "2.0", id: 2, method: "plugin.activate", params: {} });
|
||||
await tick();
|
||||
|
||||
const dataPort = new FakePort();
|
||||
control.emit({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "plugin.terminal.interceptor.attach",
|
||||
params: {
|
||||
descriptor: {
|
||||
providerId: "com.example.input",
|
||||
direction: "input",
|
||||
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
|
||||
},
|
||||
},
|
||||
}, [dataPort]);
|
||||
await tick();
|
||||
const send = (sequence) => {
|
||||
const data = Uint8Array.from([sequence]).buffer;
|
||||
dataPort.emit(createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:chunk",
|
||||
sequence,
|
||||
direction: "input",
|
||||
creditBytes: 64 * 1024,
|
||||
byteLength: data.byteLength,
|
||||
}, data));
|
||||
};
|
||||
send(1);
|
||||
await tick();
|
||||
assert.equal(dataPort.messages[0].message.frame.status, "failed");
|
||||
assert.equal(calls, 1);
|
||||
|
||||
registration.dispose();
|
||||
send(2);
|
||||
await tick();
|
||||
assert.equal(dataPort.messages[1].message.frame.status, "failed");
|
||||
assert.equal(calls, 1);
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
test("the utility peer closes a port whose chunk bypasses the canonical terminal frame schema", async () => {
|
||||
const { startPluginRuntime } = await import("./runtimePeer.mjs");
|
||||
const control = new FakePort();
|
||||
const runtime = await startPluginRuntime({
|
||||
port: control,
|
||||
config: {
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "1.0.0",
|
||||
enabledFeatures: [],
|
||||
environment: {},
|
||||
entryUrl: "file:///plugin.js",
|
||||
},
|
||||
loadPlugin: async () => ({
|
||||
default: {
|
||||
activate(context) {
|
||||
context.providers.register(
|
||||
"com.example.input",
|
||||
"terminal.interceptor.input",
|
||||
({ data }) => data,
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
control.emit({ jsonrpc: "2.0", id: 1, method: "plugin.initialize", params: {} });
|
||||
await tick();
|
||||
control.emit({ jsonrpc: "2.0", id: 2, method: "plugin.activate", params: {} });
|
||||
await tick();
|
||||
const dataPort = new FakePort();
|
||||
control.emit({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "plugin.terminal.interceptor.attach",
|
||||
params: {
|
||||
descriptor: {
|
||||
providerId: "com.example.input",
|
||||
direction: "input",
|
||||
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
|
||||
},
|
||||
},
|
||||
}, [dataPort]);
|
||||
await tick();
|
||||
const data = Uint8Array.from(Buffer.from("unsafe")).buffer;
|
||||
dataPort.emit({
|
||||
frame: {
|
||||
type: "netcatty:terminal-interceptor:chunk",
|
||||
sequence: 1,
|
||||
direction: "input",
|
||||
creditBytes: 64 * 1024,
|
||||
byteLength: data.byteLength,
|
||||
extra: true,
|
||||
},
|
||||
transfer: data,
|
||||
});
|
||||
assert.equal(dataPort.closed, true);
|
||||
assert.equal(dataPort.messages.length, 0);
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
test("utility runtime disposal closes a terminal port while its transport helper is loading", async () => {
|
||||
const { startPluginRuntime } = await import("./runtimePeer.mjs");
|
||||
const control = new FakePort();
|
||||
let resolveTransport;
|
||||
const transportLoading = new Promise((resolve) => { resolveTransport = resolve; });
|
||||
const runtime = await startPluginRuntime({
|
||||
port: control,
|
||||
config: {
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "1.0.0",
|
||||
enabledFeatures: [],
|
||||
environment: {},
|
||||
entryUrl: "file:///plugin.js",
|
||||
},
|
||||
loadPlugin: async () => ({
|
||||
default: {
|
||||
activate(context) {
|
||||
context.providers.register(
|
||||
"com.example.input",
|
||||
"terminal.interceptor.input",
|
||||
({ data }) => data,
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
loadTerminalInterceptorTransport: () => transportLoading,
|
||||
});
|
||||
control.emit({ jsonrpc: "2.0", id: 1, method: "plugin.initialize", params: {} });
|
||||
await tick();
|
||||
control.emit({ jsonrpc: "2.0", id: 2, method: "plugin.activate", params: {} });
|
||||
await tick();
|
||||
|
||||
const dataPort = new FakePort();
|
||||
control.emit({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "plugin.terminal.interceptor.attach",
|
||||
params: {
|
||||
descriptor: {
|
||||
providerId: "com.example.input",
|
||||
direction: "input",
|
||||
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
|
||||
},
|
||||
},
|
||||
}, [dataPort]);
|
||||
await tick();
|
||||
assert.equal(dataPort.closed, false);
|
||||
|
||||
await runtime.dispose();
|
||||
assert.equal(dataPort.closed, true);
|
||||
resolveTransport({
|
||||
TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES: 64 * 1024,
|
||||
createTerminalInterceptorEnvelope,
|
||||
});
|
||||
await tick();
|
||||
assert.equal(
|
||||
control.messages.some(({ message }) => message.id === 3 && message.result?.accepted === true),
|
||||
false,
|
||||
);
|
||||
assert.equal(dataPort.closed, true);
|
||||
});
|
||||
24
electron/plugins/runtime/utilityRuntime.mjs
Normal file
24
electron/plugins/runtime/utilityRuntime.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
import { register } from "node:module";
|
||||
import process from "node:process";
|
||||
|
||||
function messageData(value) {
|
||||
return value && typeof value === "object" && "data" in value ? value.data : value;
|
||||
}
|
||||
|
||||
const bootstrap = await new Promise((resolve) => {
|
||||
process.parentPort.once("message", (event) => resolve(messageData(event)));
|
||||
});
|
||||
if (bootstrap?.type !== "netcatty-plugin:bootstrap") {
|
||||
throw new Error("Missing plugin utility runtime bootstrap");
|
||||
}
|
||||
register(new URL("./pluginModuleLoader.mjs", import.meta.url), {
|
||||
parentURL: import.meta.url,
|
||||
data: { mappings: bootstrap.config.moduleMappings },
|
||||
});
|
||||
const { startPluginRuntime } = await import("./runtimePeer.mjs");
|
||||
await startPluginRuntime({
|
||||
port: process.parentPort,
|
||||
config: bootstrap.config,
|
||||
loadPlugin: (entryUrl) => import(entryUrl),
|
||||
});
|
||||
process.parentPort.postMessage({ type: "netcatty-plugin:ready" });
|
||||
65
electron/plugins/runtime/viewPreload.cjs
Normal file
65
electron/plugins/runtime/viewPreload.cjs
Normal file
@@ -0,0 +1,65 @@
|
||||
"use strict";
|
||||
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
function subscribe(channel, listener) {
|
||||
if (typeof listener !== "function") throw new TypeError("Plugin view listener must be a function");
|
||||
const handler = (_event, value) => listener(value);
|
||||
ipcRenderer.on(channel, handler);
|
||||
return Object.freeze({ dispose: () => ipcRenderer.removeListener(channel, handler) });
|
||||
}
|
||||
|
||||
const ENVIRONMENT_CHANNEL = "netcatty-plugin-view:environment";
|
||||
const environmentListeners = new Set();
|
||||
let latestEnvironment;
|
||||
let environmentGeneration = 0;
|
||||
let initialEnvironmentRequest;
|
||||
|
||||
function publishEnvironment(value) {
|
||||
latestEnvironment = value;
|
||||
environmentGeneration += 1;
|
||||
for (const listener of environmentListeners) listener(value);
|
||||
}
|
||||
|
||||
ipcRenderer.on(ENVIRONMENT_CHANNEL, (_event, value) => publishEnvironment(value));
|
||||
|
||||
function subscribeEnvironment(listener) {
|
||||
if (typeof listener !== "function") throw new TypeError("Plugin view listener must be a function");
|
||||
environmentListeners.add(listener);
|
||||
if (latestEnvironment !== undefined) {
|
||||
const value = latestEnvironment;
|
||||
const generation = environmentGeneration;
|
||||
queueMicrotask(() => {
|
||||
if (environmentListeners.has(listener) && generation === environmentGeneration) listener(value);
|
||||
});
|
||||
} else if (!initialEnvironmentRequest) {
|
||||
initialEnvironmentRequest = ipcRenderer.invoke("netcatty-plugin-view:get-environment")
|
||||
.then((value) => {
|
||||
if (latestEnvironment === undefined) publishEnvironment(value);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { initialEnvironmentRequest = undefined; });
|
||||
}
|
||||
return Object.freeze({ dispose: () => environmentListeners.delete(listener) });
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("netcattyView", Object.freeze({
|
||||
postMessage(message) {
|
||||
return ipcRenderer.invoke("netcatty-plugin-view:post-message", message);
|
||||
},
|
||||
executeCommand(command, args) {
|
||||
return ipcRenderer.invoke("netcatty-plugin-view:execute-command", { command, args });
|
||||
},
|
||||
getState() {
|
||||
return ipcRenderer.invoke("netcatty-plugin-view:get-state");
|
||||
},
|
||||
setState(state) {
|
||||
return ipcRenderer.invoke("netcatty-plugin-view:set-state", state);
|
||||
},
|
||||
onDidReceiveMessage(listener) {
|
||||
return subscribe("netcatty-plugin-view:message", listener);
|
||||
},
|
||||
onDidChangeEnvironment(listener) {
|
||||
return subscribeEnvironment(listener);
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user