[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:
425
electron/terminalWorker/process.cjs
Normal file
425
electron/terminalWorker/process.cjs
Normal file
@@ -0,0 +1,425 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { createTerminalWorkerRuntime } = require("./runtime.cjs");
|
||||
const tempDirBridge = require("../bridges/tempDirBridge.cjs");
|
||||
|
||||
// The worker owns SSH sessions in the default runtime path. Install the same
|
||||
// DH compatibility shim as the main process before loading ssh2-backed bridges.
|
||||
require("../bridges/boringSslDhCompat.cjs").installBoringSslDhCompat();
|
||||
|
||||
function createWorkerSender(parentPort, webContentsId) {
|
||||
return {
|
||||
id: webContentsId,
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
send(channel, payload) {
|
||||
if (channel === "netcatty:data") {
|
||||
const message = {
|
||||
kind: "output",
|
||||
sessionId: payload?.sessionId,
|
||||
data: payload?.data,
|
||||
};
|
||||
if (payload?.meta) message.meta = payload.meta;
|
||||
parentPort.postMessage(message);
|
||||
return;
|
||||
}
|
||||
parentPort.postMessage({
|
||||
kind: "renderer-event",
|
||||
webContentsId,
|
||||
channel,
|
||||
payload,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeParentPortMessage(eventOrMessage) {
|
||||
if (eventOrMessage && typeof eventOrMessage === "object" && "data" in eventOrMessage) {
|
||||
return eventOrMessage.data;
|
||||
}
|
||||
return eventOrMessage;
|
||||
}
|
||||
|
||||
function createZmodemUploadFileSelector(parentPort, options = {}) {
|
||||
const randomUUIDFn = options.randomUUID || randomUUID;
|
||||
const pendingRequests = new Map();
|
||||
|
||||
parentPort.on("message", (eventOrMessage) => {
|
||||
const message = normalizeParentPortMessage(eventOrMessage);
|
||||
if (message?.kind !== "zmodem-upload-dialog-result") return;
|
||||
const pending = pendingRequests.get(message.requestId);
|
||||
if (!pending) return;
|
||||
pendingRequests.delete(message.requestId);
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error));
|
||||
} else {
|
||||
pending.resolve(message.result || { canceled: true, filePaths: [] });
|
||||
}
|
||||
});
|
||||
|
||||
return function selectZmodemUploadFiles(webContentsId, sessionId) {
|
||||
const requestId = randomUUIDFn();
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
pendingRequests.set(requestId, { resolve, reject });
|
||||
});
|
||||
parentPort.postMessage({
|
||||
kind: "zmodem-upload-dialog",
|
||||
requestId,
|
||||
webContentsId,
|
||||
sessionId,
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
}
|
||||
|
||||
function createZmodemDownloadDirectorySelector(parentPort, options = {}) {
|
||||
const randomUUIDFn = options.randomUUID || randomUUID;
|
||||
const pendingRequests = new Map();
|
||||
|
||||
parentPort.on("message", (eventOrMessage) => {
|
||||
const message = normalizeParentPortMessage(eventOrMessage);
|
||||
if (message?.kind !== "zmodem-download-dialog-result") return;
|
||||
const pending = pendingRequests.get(message.requestId);
|
||||
if (!pending) return;
|
||||
pendingRequests.delete(message.requestId);
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error));
|
||||
} else {
|
||||
pending.resolve(message.result || { canceled: true, filePaths: [] });
|
||||
}
|
||||
});
|
||||
|
||||
return function selectZmodemDownloadDirectory(webContentsId, sessionId) {
|
||||
const requestId = randomUUIDFn();
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
pendingRequests.set(requestId, { resolve, reject });
|
||||
});
|
||||
parentPort.postMessage({
|
||||
kind: "zmodem-download-dialog",
|
||||
requestId,
|
||||
webContentsId,
|
||||
sessionId,
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
}
|
||||
|
||||
function registerExternalSessionHandlers(ipcMain, options) {
|
||||
const {
|
||||
sessions,
|
||||
parentPort,
|
||||
sessionLogStreamManager,
|
||||
} = options;
|
||||
|
||||
const stopSessionLog = async (sessionId, session) => {
|
||||
const token = session?.sessionLogToken;
|
||||
if (!token) return;
|
||||
session.sessionLogToken = null;
|
||||
await sessionLogStreamManager.stopStream(sessionId, token);
|
||||
};
|
||||
|
||||
ipcMain.handle("netcatty:external:start", async (event, payload) => {
|
||||
const sessionId = payload?.sessionId;
|
||||
if (typeof sessionId !== "string" || sessionId.length < 1 || sessionId.length > 128) {
|
||||
throw new TypeError("External terminal session ID is invalid");
|
||||
}
|
||||
if (sessions.has(sessionId)) throw new Error("Terminal session already exists");
|
||||
const columns = Number(payload?.columns);
|
||||
const rows = Number(payload?.rows);
|
||||
if (!Number.isInteger(columns) || columns < 1 || columns > 16_384
|
||||
|| !Number.isInteger(rows) || rows < 1 || rows > 16_384) {
|
||||
throw new TypeError("External terminal dimensions are invalid");
|
||||
}
|
||||
const postEvent = (message) => parentPort.postMessage({
|
||||
kind: "external-session-event",
|
||||
sessionId,
|
||||
...message,
|
||||
});
|
||||
let session;
|
||||
const stream = {
|
||||
write(data) {
|
||||
postEvent({ event: "input", data });
|
||||
return true;
|
||||
},
|
||||
setWindow(nextRows, nextColumns) {
|
||||
postEvent({ event: "resize", columns: nextColumns, rows: nextRows });
|
||||
},
|
||||
pause() {
|
||||
postEvent({ event: "flow", paused: true });
|
||||
},
|
||||
resume() {
|
||||
postEvent({ event: "flow", paused: false });
|
||||
},
|
||||
close() {
|
||||
void stopSessionLog(sessionId, session).catch(() => {});
|
||||
postEvent({ event: "close", reason: "closed" });
|
||||
},
|
||||
};
|
||||
let sessionLogToken = null;
|
||||
if (payload?.sessionLog?.enabled && payload.sessionLog.directory) {
|
||||
sessionLogToken = sessionLogStreamManager.startStream(sessionId, {
|
||||
hostLabel: payload?.hostLabel || payload?.hostname || payload?.protocol || "Plugin",
|
||||
hostname: payload?.hostname || payload?.protocol || "plugin",
|
||||
directory: payload.sessionLog.directory,
|
||||
format: payload.sessionLog.format || "txt",
|
||||
timestampsEnabled: Boolean(payload.sessionLog.timestampsEnabled),
|
||||
startTime: Date.now(),
|
||||
});
|
||||
}
|
||||
session = {
|
||||
type: "plugin",
|
||||
protocol: typeof payload?.protocol === "string" ? payload.protocol : "plugin",
|
||||
stream,
|
||||
cols: columns,
|
||||
rows,
|
||||
webContentsId: event.sender.id,
|
||||
closed: false,
|
||||
sessionLogToken,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
return { sessionId };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:external:output", async (event, payload) => {
|
||||
const session = sessions.get(payload?.sessionId);
|
||||
if (!session || session.type !== "plugin" || session.closed) {
|
||||
throw new Error("External terminal session is unavailable");
|
||||
}
|
||||
if (typeof payload?.data !== "string") {
|
||||
throw new TypeError("External terminal output is invalid");
|
||||
}
|
||||
if (payload.data.length > 0) sessionLogStreamManager.appendData(payload.sessionId, payload.data);
|
||||
await event.sender.send("netcatty:data", {
|
||||
sessionId: payload.sessionId,
|
||||
data: payload.data,
|
||||
...(payload.meta === undefined ? {} : { meta: payload.meta }),
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:external:finish", async (event, payload) => {
|
||||
const session = sessions.get(payload?.sessionId);
|
||||
if (!session || session.type !== "plugin") return null;
|
||||
session.closed = true;
|
||||
sessions.delete(payload.sessionId);
|
||||
await stopSessionLog(payload.sessionId, session);
|
||||
event.sender.send("netcatty:exit", {
|
||||
sessionId: payload.sessionId,
|
||||
exitCode: payload?.reason === "error" ? 1 : 0,
|
||||
reason: payload?.reason || "closed",
|
||||
...(payload?.error ? { error: payload.error } : {}),
|
||||
...(Array.isArray(payload?.diagnostics) ? { diagnostics: payload.diagnostics } : {}),
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function registerPortForwardingWorkerBridge(ipcMain) {
|
||||
const portForwardingBridge = require("../bridges/portForwardingBridge.cjs");
|
||||
portForwardingBridge.registerHandlers(ipcMain);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const parentPort = process.parentPort;
|
||||
if (!parentPort) {
|
||||
throw new Error("Terminal worker requires process.parentPort");
|
||||
}
|
||||
|
||||
// Every session lives in this shared worker, so a single stray async error
|
||||
// must never hit Node's default exit-with-code-1 behavior. Install the same
|
||||
// guards as the main process; without them one unhandled error disconnects
|
||||
// all sessions simultaneously ("Terminal worker exited with code 1").
|
||||
// Errors are reported to main so they still land in the crash log.
|
||||
// Startup errors (before the bridges load and runtime.start() completes)
|
||||
// are not suppressed: the guards re-throw those so the worker exits and the
|
||||
// manager can reject or replace it instead of hanging pending requests.
|
||||
const { installTerminalWorkerErrorGuards } = require("./workerProcessGuards.cjs");
|
||||
|
||||
const reportWorkerError = (origin, err, reason) => {
|
||||
try {
|
||||
parentPort.postMessage({
|
||||
kind: "worker-error",
|
||||
origin,
|
||||
reason,
|
||||
message: err?.message || String(err),
|
||||
...(err?.stack ? { stack: err.stack } : {}),
|
||||
...(err?.code ? { code: err.code } : {}),
|
||||
...(err?.level ? { level: err.level } : {}),
|
||||
});
|
||||
} catch {
|
||||
// Reporting must never be able to escalate into a worker crash.
|
||||
}
|
||||
};
|
||||
|
||||
let startupComplete = false;
|
||||
installTerminalWorkerErrorGuards({
|
||||
isRuntimeStarted: () => startupComplete,
|
||||
report(origin, err, decision) {
|
||||
reportWorkerError(origin, err, decision?.reason);
|
||||
},
|
||||
});
|
||||
|
||||
const sessions = new Map();
|
||||
const sftpClients = new Map();
|
||||
const { createTerminalDataPipeline } = require("./terminalDataPipeline.cjs");
|
||||
const terminalDataPipeline = createTerminalDataPipeline({
|
||||
onWarning: (warning) => parentPort.postMessage({
|
||||
kind: "terminal-interceptor-warning",
|
||||
warning,
|
||||
}),
|
||||
});
|
||||
let runtime = null;
|
||||
const electronModule = {
|
||||
webContents: {
|
||||
fromId(webContentsId) {
|
||||
if (runtime?.createSender) {
|
||||
return runtime.createSender(webContentsId);
|
||||
}
|
||||
return createWorkerSender(parentPort, webContentsId);
|
||||
},
|
||||
},
|
||||
};
|
||||
const selectZmodemUploadFiles = createZmodemUploadFileSelector(parentPort);
|
||||
const selectZmodemDownloadDirectory = createZmodemDownloadDirectorySelector(parentPort);
|
||||
|
||||
const terminalBridge = require("../bridges/terminalBridge.cjs");
|
||||
const sshBridge = require("../bridges/sshBridge.cjs");
|
||||
const sftpBridge = require("../bridges/sftpBridge.cjs");
|
||||
const transferBridge = require("../bridges/transferBridge.cjs");
|
||||
const fileWatcherBridge = require("../bridges/fileWatcherBridge.cjs");
|
||||
const compressUploadBridge = require("../bridges/compressUploadBridge.cjs");
|
||||
const sessionLogStreamManager = require("../bridges/sessionLogStreamManager.cjs");
|
||||
const { registerWorkerAiExecHandlers } = require("./aiExec.cjs");
|
||||
const deps = {
|
||||
sessions,
|
||||
sftpClients,
|
||||
electronModule,
|
||||
selectZmodemUploadFiles,
|
||||
selectZmodemDownloadDirectory,
|
||||
terminalDataPipeline,
|
||||
};
|
||||
|
||||
runtime = createTerminalWorkerRuntime({
|
||||
parentPort,
|
||||
terminalDataPipeline,
|
||||
reportSuppressedError(origin, err, reason) {
|
||||
reportWorkerError(origin, err, reason);
|
||||
},
|
||||
registerBridges(ipcMain) {
|
||||
sshBridge.init(deps);
|
||||
terminalBridge.init(deps);
|
||||
sftpBridge.init(deps);
|
||||
transferBridge.init(deps);
|
||||
fileWatcherBridge.init({
|
||||
...deps,
|
||||
transferBridge,
|
||||
});
|
||||
compressUploadBridge.init({
|
||||
...deps,
|
||||
transferBridge,
|
||||
});
|
||||
sshBridge.registerHandlers(ipcMain);
|
||||
terminalBridge.registerHandlers(ipcMain);
|
||||
sftpBridge.registerHandlers(ipcMain);
|
||||
registerPortForwardingWorkerBridge(ipcMain);
|
||||
transferBridge.registerHandlers(ipcMain);
|
||||
fileWatcherBridge.registerHandlers(ipcMain);
|
||||
compressUploadBridge.registerHandlers(ipcMain);
|
||||
registerWorkerAiExecHandlers(ipcMain, { sessions });
|
||||
registerExternalSessionHandlers(ipcMain, {
|
||||
sessions,
|
||||
parentPort,
|
||||
sessionLogStreamManager,
|
||||
});
|
||||
// Expose worker-owned active log paths so main-process clear-all can
|
||||
// skip live auto-save streams (separate module instance from main).
|
||||
require("../bridges/sessionLogsBridge.cjs").registerWorkerHandlers(ipcMain);
|
||||
const { createSystemManagerBridge } = require("../bridges/systemManagerBridge.cjs");
|
||||
createSystemManagerBridge({
|
||||
getSessions: () => sessions,
|
||||
execOnEtSession: (...args) => terminalBridge.execOnEtSession(...args),
|
||||
ensureMoshStatsConnection: (...args) => sshBridge.ensureMoshStatsConnection(...args),
|
||||
process,
|
||||
}).registerHandlers(ipcMain);
|
||||
ipcMain.on("netcatty:zmodem:cancel", (_event, payload) => {
|
||||
sessions.get(payload?.sessionId)?.zmodemSentry?.cancel(payload?.options);
|
||||
});
|
||||
ipcMain.handle("netcatty:zmodem:drag-drop-upload", async (_event, payload) => {
|
||||
const { sessionId, files, uploadCommand } = payload || {};
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session?.zmodemSentry?.queueDragDropUpload) {
|
||||
return { success: false, error: "ZMODEM upload is not available for this session" };
|
||||
}
|
||||
if (session.zmodemSentry.isActive?.()) {
|
||||
return { success: false, error: "ZMODEM transfer already in progress" };
|
||||
}
|
||||
|
||||
const filePaths = [];
|
||||
const remoteNames = [];
|
||||
const tempPaths = [];
|
||||
|
||||
for (const file of files || []) {
|
||||
if (!file?.name) continue;
|
||||
let localPath = file.path;
|
||||
if (!localPath && file.data) {
|
||||
localPath = tempDirBridge.getTempFilePath(file.name);
|
||||
await fs.promises.writeFile(localPath, Buffer.from(file.data));
|
||||
tempPaths.push(localPath);
|
||||
}
|
||||
if (!localPath) continue;
|
||||
try {
|
||||
await fs.promises.access(localPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
filePaths.push(localPath);
|
||||
remoteNames.push(file.remoteName || path.basename(localPath));
|
||||
}
|
||||
|
||||
if (!filePaths.length) {
|
||||
for (const tempPath of tempPaths) {
|
||||
try { await fs.promises.unlink(tempPath); } catch { /* ignore */ }
|
||||
}
|
||||
return { success: false, error: "No readable files to upload" };
|
||||
}
|
||||
|
||||
try {
|
||||
session.zmodemSentry.queueDragDropUpload({
|
||||
filePaths,
|
||||
remoteNames,
|
||||
uploadCommand: uploadCommand || "rz -y\r",
|
||||
tempPaths,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
for (const tempPath of tempPaths) {
|
||||
try { await fs.promises.unlink(tempPath); } catch { /* ignore */ }
|
||||
}
|
||||
return { success: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
runtime.start();
|
||||
// Only after the message listener is installed and all bridges registered
|
||||
// is it safe to suppress process-level errors.
|
||||
startupComplete = true;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createWorkerSender,
|
||||
createZmodemDownloadDirectorySelector,
|
||||
createZmodemUploadFileSelector,
|
||||
normalizeParentPortMessage,
|
||||
registerExternalSessionHandlers,
|
||||
registerPortForwardingWorkerBridge,
|
||||
main,
|
||||
};
|
||||
Reference in New Issue
Block a user