[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:
956
electron/preload.cjs
Normal file
956
electron/preload.cjs
Normal file
@@ -0,0 +1,956 @@
|
||||
const { ipcRenderer, contextBridge, webUtils } = require("electron");
|
||||
const os = require("node:os");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { SessionTombstones } = require("./preload/sessionTombstones.cjs");
|
||||
const { createPreloadApi } = require("./preload/api.cjs");
|
||||
const {
|
||||
clearTerminalDataBacklog,
|
||||
clearTerminalDataSession,
|
||||
createTerminalDataBacklog,
|
||||
createTerminalDataDispatcher,
|
||||
hasPluginPipelineIngress,
|
||||
hasPluginPipelineIngressMarker,
|
||||
} = require("./preload/terminalDataBacklog.cjs");
|
||||
const {
|
||||
createTerminalOutputPortRegistry,
|
||||
} = require("./preload/terminalOutputPorts.cjs");
|
||||
const {
|
||||
createTerminalUrgentInputPortRegistry,
|
||||
} = require("./preload/terminalUrgentInputPorts.cjs");
|
||||
const {
|
||||
mergeTerminalDataMeta,
|
||||
} = require("./preload/terminalDataMeta.cjs");
|
||||
|
||||
const dataListeners = new Map();
|
||||
const displayDataListeners = new Map();
|
||||
const terminalDataBacklog = createTerminalDataBacklog();
|
||||
const closedTerminalDataSessions = new SessionTombstones();
|
||||
const exitListeners = new Map();
|
||||
const globalSftpTransferListeners = new Set();
|
||||
const pluginContributionsChangedListeners = new Set();
|
||||
const chainProgressListeners = new Map();
|
||||
const connectionReuseFallbackListeners = new Set();
|
||||
const zmodemListeners = new Map();
|
||||
const zmodemOverwriteListeners = new Map(); // sessionId -> Set<cb>
|
||||
const sftpConnectionProgressListeners = new Set();
|
||||
const authFailedListeners = new Map();
|
||||
const telnetAutoLoginCompleteListeners = new Map();
|
||||
const telnetAutoLoginCancelledListeners = new Map();
|
||||
const telnetEchoModeListeners = new Map();
|
||||
const moshSessionReadyListeners = new Map();
|
||||
const languageChangeListeners = new Set();
|
||||
const fullscreenChangeListeners = new Set();
|
||||
const windowShownListeners = new Set();
|
||||
const windowFocusRequestedListeners = new Set();
|
||||
const windowWillHideListeners = new Set();
|
||||
const keyboardInteractiveListeners = new Set();
|
||||
const keyboardInteractiveCancelledListeners = new Set();
|
||||
const hostKeyVerificationListeners = new Set();
|
||||
const passphraseListeners = new Set();
|
||||
const passphraseTimeoutListeners = new Set();
|
||||
const passphraseCancelledListeners = new Set();
|
||||
const passphraseAuthFailedListeners = new Set();
|
||||
const updateDownloadProgressListeners = new Set();
|
||||
const updateDownloadedListeners = new Set();
|
||||
const updateAvailableListeners = new Set();
|
||||
const updateNotAvailableListeners = new Set();
|
||||
const updateErrorListeners = new Set();
|
||||
const updateNeedsSaveListeners = new Set();
|
||||
const terminalPopupConfigState = {
|
||||
pending: null,
|
||||
// Keep the last delivered payload so StrictMode remount (unsubscribe →
|
||||
// resubscribe) can replay config after the one-shot pending slot was drained.
|
||||
lastPayload: null,
|
||||
listeners: new Set(),
|
||||
};
|
||||
|
||||
function dispatchGlobalSftpTransferEvent(payload) {
|
||||
for (const cb of globalSftpTransferListeners) {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Global SFTP transfer callback failed", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchPluginContributionsChanged(payload) {
|
||||
for (const cb of pluginContributionsChangedListeners) {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Plugin contributions callback failed", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── MCP marker filter with per-session line buffering ──
|
||||
// PTY data arrives in arbitrary chunks; the marker string (__NCMCP_) can be
|
||||
// split across chunk boundaries so a simple data.includes() guard misses it.
|
||||
// We buffer the trailing fragment of each chunk and prepend it to the next
|
||||
// chunk, then filter complete lines that contain the marker.
|
||||
|
||||
const _mcpLineBufs = new Map(); // sessionId -> trailing fragment string
|
||||
const _mcpLineMetas = new Map(); // sessionId -> trailing fragment metadata
|
||||
const _mcpPendingMetas = new Map(); // sessionId -> metadata from filtered-empty chunks
|
||||
const _mcpFlushTimers = new Map(); // sessionId -> delayed-flush timer
|
||||
const _mcpDroppingWrappedLine = new Set(); // sessionIds with a split marker echo line in progress
|
||||
const _mcpProbePrompts = new Map(); // sessionId -> probe marker awaiting its command
|
||||
const _mcpAbortedProbes = new Map(); // sessionId -> bounded set of cancelled probe markers
|
||||
const MAX_MCP_BUFFERED_LINE_CHARS = 64 * 1024;
|
||||
|
||||
function clearMcpSessionState(sessionId) {
|
||||
if (!sessionId) return;
|
||||
const pendingTimer = _mcpFlushTimers.get(sessionId);
|
||||
if (pendingTimer) clearTimeout(pendingTimer);
|
||||
_mcpFlushTimers.delete(sessionId);
|
||||
_mcpLineBufs.delete(sessionId);
|
||||
_mcpLineMetas.delete(sessionId);
|
||||
_mcpPendingMetas.delete(sessionId);
|
||||
_mcpDroppingWrappedLine.delete(sessionId);
|
||||
_mcpProbePrompts.delete(sessionId);
|
||||
_mcpAbortedProbes.delete(sessionId);
|
||||
}
|
||||
|
||||
function filterProbePromptFragment(sessionId, fragment) {
|
||||
const reset = fragment.match(/(__NCMCP_(?:(?!__NCMCP_)[A-Za-z0-9_])+)_R/);
|
||||
if (reset) {
|
||||
const aborted = _mcpAbortedProbes.get(sessionId) || new Set();
|
||||
aborted.add(reset[1]);
|
||||
if (aborted.size > 32) aborted.delete(aborted.values().next().value);
|
||||
_mcpAbortedProbes.set(sessionId, aborted);
|
||||
if (_mcpProbePrompts.get(sessionId) === reset[1]) _mcpProbePrompts.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
const completion = fragment.match(/^\r?(__NCMCP_[A-Za-z0-9_]+)_Q/);
|
||||
if (completion && !_mcpAbortedProbes.get(sessionId)?.has(completion[1])) {
|
||||
_mcpProbePrompts.set(sessionId, completion[1]);
|
||||
}
|
||||
const marker = _mcpProbePrompts.get(sessionId);
|
||||
if (!marker) return false;
|
||||
const line = fragment.replace(/^\r/, "");
|
||||
if (line.startsWith(`${marker}_S`) || line.startsWith(`${marker}_E`)) {
|
||||
_mcpProbePrompts.delete(sessionId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns true if `s` ends with a non-empty prefix of "__NCMCP_"
|
||||
// (i.e. the next chunk might complete it into a marker-containing line).
|
||||
function _endsWithMarkerPrefix(s) {
|
||||
const p = "__NCMCP_";
|
||||
for (let i = 1; i < p.length; i++) {
|
||||
if (s.endsWith(p.slice(0, i))) return true;
|
||||
}
|
||||
// The shell-neutral live probe begins with this harmless builtin. Its
|
||||
// echo can arrive before the random marker (e.g. " tru"), so retain the
|
||||
// partial prefix just like a split marker. Ordinary text is still released
|
||||
// by the existing bounded flush if no marker follows.
|
||||
const probePrefix = " true __NCMCP_";
|
||||
for (let i = 2; i < probePrefix.length; i++) {
|
||||
if (s.endsWith(probePrefix.slice(0, i))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function filterMcpChunk(sessionId, chunk, meta) {
|
||||
// Cancel any pending delayed flush — new data arrived
|
||||
const pendingTimer = _mcpFlushTimers.get(sessionId);
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer);
|
||||
_mcpFlushTimers.delete(sessionId);
|
||||
}
|
||||
|
||||
// Prepend any buffered fragment from the previous chunk
|
||||
const held = _mcpLineBufs.get(sessionId) || "";
|
||||
const heldMeta = _mcpLineMetas.get(sessionId);
|
||||
const heldIngressAlreadyAcknowledged = heldMeta?.pluginPipelineIngressBytes === 0;
|
||||
const pendingMeta = _mcpPendingMetas.get(sessionId);
|
||||
const stateMeta = mergeTerminalDataMeta(mergeTerminalDataMeta(pendingMeta, heldMeta), meta);
|
||||
const sameChunkMeta = mergeTerminalDataMeta(mergeTerminalDataMeta(pendingMeta, heldMeta), meta, {
|
||||
preserveTerminalPerf: true,
|
||||
});
|
||||
const data = held + chunk;
|
||||
_mcpLineBufs.delete(sessionId);
|
||||
_mcpLineMetas.delete(sessionId);
|
||||
_mcpPendingMetas.delete(sessionId);
|
||||
|
||||
// Fast path: nothing suspicious in the combined data
|
||||
if (!_mcpDroppingWrappedLine.has(sessionId) && !_mcpProbePrompts.has(sessionId) && !data.includes("__NCMCP_") && !_endsWithMarkerPrefix(data)) {
|
||||
const deliveryMeta = held ? stateMeta : sameChunkMeta;
|
||||
return {
|
||||
data,
|
||||
meta: heldIngressAlreadyAcknowledged
|
||||
? {
|
||||
...(deliveryMeta || {}),
|
||||
pluginPipelineIngressBytes: Number.isFinite(meta?.pluginPipelineIngressBytes)
|
||||
? Math.max(0, Number(meta.pluginPipelineIngressBytes))
|
||||
: chunk.length,
|
||||
}
|
||||
: deliveryMeta,
|
||||
};
|
||||
}
|
||||
|
||||
// Slow path: scan line by line
|
||||
let result = "";
|
||||
let droppedAny = _mcpDroppingWrappedLine.has(sessionId);
|
||||
let pos = 0;
|
||||
while (pos < data.length) {
|
||||
const nlIdx = data.indexOf("\n", pos);
|
||||
if (nlIdx === -1) {
|
||||
// Incomplete trailing line — no newline yet.
|
||||
// If we dropped any marker line in this chunk, or the tail itself
|
||||
// looks like it could contain a marker, buffer it. Long command
|
||||
// echoes can wrap across PTY lines; wrapped fragments that don't
|
||||
// contain __NCMCP_ would otherwise leak through as garbage.
|
||||
const tail = data.slice(pos);
|
||||
const probePrompt = filterProbePromptFragment(sessionId, tail);
|
||||
if (probePrompt || droppedAny || tail.includes("__NCMCP_") || _endsWithMarkerPrefix(tail)) {
|
||||
let tailMeta = !held && tail === chunk ? sameChunkMeta : stateMeta;
|
||||
if (heldIngressAlreadyAcknowledged && !hasPluginPipelineIngress(tailMeta)) {
|
||||
tailMeta = { ...(tailMeta || {}), pluginPipelineIngressBytes: 0 };
|
||||
}
|
||||
if (tail.length <= MAX_MCP_BUFFERED_LINE_CHARS) {
|
||||
_mcpLineBufs.set(sessionId, tail);
|
||||
if (tailMeta) _mcpLineMetas.set(sessionId, tailMeta);
|
||||
} else {
|
||||
// A malformed/wrapped marker line can otherwise grow forever while
|
||||
// high-rate output keeps postponing the delayed flush. Its visible
|
||||
// content is already being suppressed, so discard the oversized
|
||||
// prefix and retain only the drop-until-newline state.
|
||||
_mcpLineBufs.delete(sessionId);
|
||||
_mcpLineMetas.delete(sessionId);
|
||||
_mcpDroppingWrappedLine.add(sessionId);
|
||||
}
|
||||
// The reserved prefix already identifies an internal line. Its
|
||||
// remaining marker or suffix may arrive after the timed flush.
|
||||
if (probePrompt || droppedAny || tail.includes("__NCMCP_")) {
|
||||
_mcpDroppingWrappedLine.add(sessionId);
|
||||
}
|
||||
} else {
|
||||
result += tail; // safe to display immediately
|
||||
}
|
||||
break;
|
||||
}
|
||||
const line = data.slice(pos, nlIdx + 1); // includes the \n
|
||||
const probePrompt = filterProbePromptFragment(sessionId, line);
|
||||
if (probePrompt || droppedAny || line.includes("__NCMCP_")) {
|
||||
droppedAny = false;
|
||||
_mcpDroppingWrappedLine.delete(sessionId);
|
||||
} else {
|
||||
result += line;
|
||||
}
|
||||
pos = nlIdx + 1;
|
||||
}
|
||||
|
||||
const deliveryMeta = !held && result === chunk ? sameChunkMeta : stateMeta;
|
||||
return {
|
||||
data: result,
|
||||
meta: heldIngressAlreadyAcknowledged
|
||||
? {
|
||||
...(deliveryMeta || {}),
|
||||
pluginPipelineIngressBytes: Number.isFinite(meta?.pluginPipelineIngressBytes)
|
||||
? Math.max(0, Number(meta.pluginPipelineIngressBytes))
|
||||
: chunk.length,
|
||||
}
|
||||
: deliveryMeta,
|
||||
};
|
||||
}
|
||||
|
||||
function consumeBufferedMcpIngress(sessionId) {
|
||||
const heldMeta = _mcpLineMetas.get(sessionId);
|
||||
if (!hasPluginPipelineIngress(heldMeta)) return;
|
||||
// Retain an explicit zero so a later safe flush does not fall back to the
|
||||
// visible chunk length and acknowledge the already-credited prefix twice.
|
||||
_mcpLineMetas.set(sessionId, { ...heldMeta, pluginPipelineIngressBytes: 0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver data to session listeners. Used both by the normal data path
|
||||
* and by the delayed-flush timer.
|
||||
*/
|
||||
const _deliverToListeners = createTerminalDataDispatcher({
|
||||
dataListeners,
|
||||
displayDataListeners,
|
||||
terminalDataBacklog,
|
||||
shouldDropSession: (sessionId) => closedTerminalDataSessions.has(sessionId),
|
||||
});
|
||||
|
||||
function flushMcpBufferedOutput(sessionId) {
|
||||
const timer = _mcpFlushTimers.get(sessionId);
|
||||
if (timer) clearTimeout(timer);
|
||||
const held = _mcpLineBufs.get(sessionId);
|
||||
const heldMeta = _mcpLineMetas.get(sessionId);
|
||||
_mcpLineBufs.delete(sessionId);
|
||||
_mcpLineMetas.delete(sessionId);
|
||||
_mcpFlushTimers.delete(sessionId);
|
||||
if (_mcpDroppingWrappedLine.has(sessionId)) {
|
||||
// Retain a bounded partial marker so a delayed Q/R suffix still updates
|
||||
// the probe lifecycle, rather than losing its identity at the timer.
|
||||
if (held && /^\r?__NCMCP_[A-Za-z0-9_]*$/.test(held) && held.length <= 256) {
|
||||
_mcpLineBufs.set(sessionId, held);
|
||||
if (heldMeta) _mcpLineMetas.set(sessionId, heldMeta);
|
||||
return;
|
||||
}
|
||||
// A timed flush is not a line boundary. Keep discarding a known
|
||||
// internal line until its newline arrives, including slow prompts
|
||||
// between the probe completion marker and an echo-disabled wrapper.
|
||||
if (heldMeta) _mcpPendingMetas.set(sessionId, mergeTerminalDataMeta(_mcpPendingMetas.get(sessionId), heldMeta));
|
||||
return;
|
||||
}
|
||||
if (held) {
|
||||
let deliveryMeta = mergeTerminalDataMeta(_mcpPendingMetas.get(sessionId), heldMeta, {
|
||||
preserveTerminalPerf: true,
|
||||
});
|
||||
if (heldMeta?.pluginPipelineIngressBytes === 0 && !hasPluginPipelineIngress(deliveryMeta)) {
|
||||
deliveryMeta = { ...(deliveryMeta || {}), pluginPipelineIngressBytes: 0 };
|
||||
}
|
||||
_deliverToListeners(sessionId, held, deliveryMeta);
|
||||
_mcpPendingMetas.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleMcpBufferedFlush(sessionId) {
|
||||
if (!_mcpLineBufs.has(sessionId)) return;
|
||||
_mcpFlushTimers.set(sessionId, setTimeout(() => flushMcpBufferedOutput(sessionId), 80));
|
||||
}
|
||||
|
||||
function deliverTerminalData(sessionId, data, options = {}) {
|
||||
if (!sessionId || (!data && !hasPluginPipelineIngressMarker(options.meta))) return;
|
||||
if (closedTerminalDataSessions.has(sessionId)) return;
|
||||
if (!data) {
|
||||
_deliverToListeners(sessionId, "", options.meta);
|
||||
return;
|
||||
}
|
||||
if (options.syntheticEcho) {
|
||||
_deliverToListeners(sessionId, data, options.meta);
|
||||
return;
|
||||
}
|
||||
const filtered = filterMcpChunk(sessionId, data, options.meta);
|
||||
if (filtered?.data) {
|
||||
_deliverToListeners(sessionId, filtered.data, filtered.meta);
|
||||
if (hasPluginPipelineIngress(filtered.meta)) consumeBufferedMcpIngress(sessionId);
|
||||
} else if (filtered?.meta) {
|
||||
if (hasPluginPipelineIngress(filtered.meta)) {
|
||||
// The legacy path must return flow credit even when MCP marker filtering
|
||||
// removes every display byte. Waiting for unrelated visible output can
|
||||
// otherwise leave a fully suppressed stream paused indefinitely.
|
||||
_deliverToListeners(sessionId, "", filtered.meta);
|
||||
consumeBufferedMcpIngress(sessionId);
|
||||
} else {
|
||||
_mcpPendingMetas.set(sessionId, mergeTerminalDataMeta(_mcpPendingMetas.get(sessionId), filtered.meta));
|
||||
}
|
||||
}
|
||||
// If there is buffered content waiting for more data (e.g. a prompt
|
||||
// right after a dropped marker line), schedule a delayed flush so it
|
||||
// appears after a short pause instead of staying hidden forever.
|
||||
scheduleMcpBufferedFlush(sessionId);
|
||||
}
|
||||
|
||||
const terminalOutputDrainListeners = new Map();
|
||||
const terminalOutputPorts = createTerminalOutputPortRegistry({
|
||||
ipcRenderer,
|
||||
deliverToListeners: _deliverToListeners,
|
||||
filterData(sessionId, data, message) {
|
||||
if (message?.syntheticEcho) return data;
|
||||
const filtered = filterMcpChunk(sessionId, data, message.meta);
|
||||
// Metadata-only plugin output is delivered immediately by the output-port
|
||||
// registry so renderer flow credit can be returned. Do not retain the same
|
||||
// ingress metadata for the next visible chunk or it would be acknowledged
|
||||
// twice. Non-ingress terminal-state metadata still follows the next output.
|
||||
if (hasPluginPipelineIngress(filtered?.meta)) {
|
||||
consumeBufferedMcpIngress(sessionId);
|
||||
} else if (!filtered?.data && filtered?.meta) {
|
||||
_mcpPendingMetas.set(sessionId, mergeTerminalDataMeta(_mcpPendingMetas.get(sessionId), filtered.meta));
|
||||
}
|
||||
scheduleMcpBufferedFlush(sessionId);
|
||||
return filtered;
|
||||
},
|
||||
closedTerminalDataSessions,
|
||||
onDrain(sessionId, requestId) {
|
||||
flushMcpBufferedOutput(sessionId);
|
||||
for (const listener of terminalOutputDrainListeners.get(sessionId) || []) {
|
||||
try { listener({ sessionId, requestId }); } catch (err) { console.error("Terminal drain callback failed", err); }
|
||||
}
|
||||
},
|
||||
});
|
||||
terminalOutputPorts.register();
|
||||
|
||||
const terminalUrgentInputPorts = createTerminalUrgentInputPortRegistry({
|
||||
ipcRenderer,
|
||||
});
|
||||
terminalUrgentInputPorts.register();
|
||||
|
||||
// ZMODEM file transfer events. Listener sets are owned by renderer subscribers
|
||||
// (disposed on unmount); delivery is gated by closedTerminalDataSessions so
|
||||
// events stop after exit/close and resume automatically when a session
|
||||
// restarts with the same id.
|
||||
ipcRenderer.on("netcatty:zmodem:detect", (_event, payload) => {
|
||||
if (closedTerminalDataSessions.has(payload.sessionId)) return;
|
||||
const set = zmodemListeners.get(payload.sessionId);
|
||||
if (!set) return;
|
||||
set.forEach((cb) => { try { cb({ type: "detect", ...payload }); } catch {} });
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:window:terminalPopupConfig", (_event, payload) => {
|
||||
// Always retain last config so a late/remounted subscriber (AppLockGate delay,
|
||||
// React Strict Mode remount) still receives it via lastPayload replay.
|
||||
terminalPopupConfigState.lastPayload = payload;
|
||||
if (terminalPopupConfigState.listeners.size === 0) {
|
||||
terminalPopupConfigState.pending = payload;
|
||||
return;
|
||||
}
|
||||
terminalPopupConfigState.pending = null;
|
||||
terminalPopupConfigState.listeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Terminal popup config callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
ipcRenderer.on("netcatty:zmodem:progress", (_event, payload) => {
|
||||
if (closedTerminalDataSessions.has(payload.sessionId)) return;
|
||||
const set = zmodemListeners.get(payload.sessionId);
|
||||
if (!set) return;
|
||||
set.forEach((cb) => { try { cb({ type: "progress", ...payload }); } catch {} });
|
||||
});
|
||||
ipcRenderer.on("netcatty:zmodem:complete", (_event, payload) => {
|
||||
if (closedTerminalDataSessions.has(payload.sessionId)) return;
|
||||
const set = zmodemListeners.get(payload.sessionId);
|
||||
if (!set) return;
|
||||
set.forEach((cb) => { try { cb({ type: "complete", ...payload }); } catch {} });
|
||||
});
|
||||
ipcRenderer.on("netcatty:zmodem:error", (_event, payload) => {
|
||||
if (closedTerminalDataSessions.has(payload.sessionId)) return;
|
||||
const set = zmodemListeners.get(payload.sessionId);
|
||||
if (!set) return;
|
||||
set.forEach((cb) => { try { cb({ type: "error", ...payload }); } catch {} });
|
||||
});
|
||||
ipcRenderer.on("netcatty:zmodem:overwrite-request", (_event, payload) => {
|
||||
if (closedTerminalDataSessions.has(payload.sessionId)) return;
|
||||
const set = zmodemOverwriteListeners.get(payload.sessionId);
|
||||
if (set) set.forEach((cb) => cb(payload));
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:data", (_event, payload) => {
|
||||
deliverTerminalData(payload?.sessionId, payload?.data, {
|
||||
syntheticEcho: payload?.syntheticEcho,
|
||||
meta: payload?.meta,
|
||||
});
|
||||
});
|
||||
|
||||
// ZMODEM listener sets deliberately survive exit: they are owned by renderer
|
||||
// subscribers and disposed on unmount. Delivery is gated by
|
||||
// closedTerminalDataSessions (see zmodem handlers above), so events stop after
|
||||
// exit/close and resume when a session restarts with the same id.
|
||||
ipcRenderer.on("netcatty:exit", (_event, payload) => {
|
||||
const sessionId = payload?.sessionId;
|
||||
if (!sessionId) return;
|
||||
const wasClosed = closedTerminalDataSessions.has(sessionId);
|
||||
closedTerminalDataSessions.add(sessionId);
|
||||
const set = wasClosed ? null : exitListeners.get(sessionId);
|
||||
if (set) {
|
||||
set.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Exit callback failed", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
clearTerminalDataBacklog({ terminalDataBacklog }, sessionId);
|
||||
terminalOutputPorts.closeSession(sessionId);
|
||||
telnetAutoLoginCompleteListeners.delete(sessionId);
|
||||
telnetAutoLoginCancelledListeners.delete(sessionId);
|
||||
telnetEchoModeListeners.delete(sessionId);
|
||||
moshSessionReadyListeners.delete(sessionId);
|
||||
authFailedListeners.delete(sessionId);
|
||||
clearMcpSessionState(sessionId);
|
||||
});
|
||||
|
||||
// Chain progress events (for jump host connections)
|
||||
ipcRenderer.on("netcatty:chain:progress", (_event, payload) => {
|
||||
const { sessionId, hop, total, label, status, error } = payload;
|
||||
// Notify all registered chain progress listeners
|
||||
chainProgressListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(sessionId, hop, total, label, status, error);
|
||||
} catch (err) {
|
||||
console.error("Chain progress callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:connection-reuse:fallback", (_event, payload) => {
|
||||
connectionReuseFallbackListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload.sessionId, payload.sourceSessionId);
|
||||
} catch (err) {
|
||||
console.error("Connection reuse fallback callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// SFTP connection progress events (auth method logs)
|
||||
ipcRenderer.on("netcatty:sftp:connection-progress", (_event, payload) => {
|
||||
sftpConnectionProgressListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload.sessionId, payload.label, payload.status, payload.detail);
|
||||
} catch (err) {
|
||||
console.error("SFTP connection progress callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:languageChanged", (_event, language) => {
|
||||
languageChangeListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(language);
|
||||
} catch (err) {
|
||||
console.error("Language changed callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:window:fullscreen-changed", (_event, isFullscreen) => {
|
||||
fullscreenChangeListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(isFullscreen);
|
||||
} catch (err) {
|
||||
console.error("Fullscreen changed callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:window:shown", () => {
|
||||
windowShownListeners.forEach((cb) => {
|
||||
try {
|
||||
cb();
|
||||
} catch (err) {
|
||||
console.error("Window shown callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:window:focus-requested", () => {
|
||||
windowFocusRequestedListeners.forEach((cb) => {
|
||||
try {
|
||||
cb();
|
||||
} catch (err) {
|
||||
console.error("Window focus-requested callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:window:will-hide", () => {
|
||||
windowWillHideListeners.forEach((cb) => {
|
||||
try {
|
||||
cb();
|
||||
} catch (err) {
|
||||
console.error("Window will-hide callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Authentication failed events
|
||||
ipcRenderer.on("netcatty:auth:failed", (_event, payload) => {
|
||||
const set = authFailedListeners.get(payload.sessionId);
|
||||
if (set) {
|
||||
set.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Auth failed callback failed", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:telnet:auto-login-complete", (_event, payload) => {
|
||||
const set = telnetAutoLoginCompleteListeners.get(payload.sessionId);
|
||||
if (!set) return;
|
||||
set.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Telnet auto-login callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:telnet:auto-login-cancelled", (_event, payload) => {
|
||||
const set = telnetAutoLoginCancelledListeners.get(payload.sessionId);
|
||||
if (!set) return;
|
||||
set.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Telnet auto-login cancellation callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:mosh:ready", (_event, payload) => {
|
||||
const set = moshSessionReadyListeners.get(payload.sessionId);
|
||||
if (!set) return;
|
||||
set.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Mosh session ready callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:telnet:echo-mode", (_event, payload) => {
|
||||
const set = telnetEchoModeListeners.get(payload.sessionId);
|
||||
if (!set) return;
|
||||
set.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Telnet echo mode callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Keyboard-interactive authentication events (2FA/MFA)
|
||||
ipcRenderer.on("netcatty:keyboard-interactive", (_event, payload) => {
|
||||
keyboardInteractiveListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Keyboard-interactive callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:keyboard-interactive-cancelled", (_event, payload) => {
|
||||
keyboardInteractiveCancelledListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Keyboard-interactive cancellation callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:host-key:verify", (_event, payload) => {
|
||||
hostKeyVerificationListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Host key verification callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Passphrase request events for encrypted SSH keys
|
||||
ipcRenderer.on("netcatty:passphrase-request", (_event, payload) => {
|
||||
passphraseListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Passphrase request callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Passphrase timeout events (request expired)
|
||||
ipcRenderer.on("netcatty:passphrase-timeout", (_event, payload) => {
|
||||
passphraseTimeoutListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Passphrase timeout callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Passphrase cancelled events (request ended because the owning operation stopped)
|
||||
ipcRenderer.on("netcatty:passphrase-cancelled", (_event, payload) => {
|
||||
passphraseCancelledListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Passphrase cancelled callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Passphrase auth failed events (saved passphrase was wrong)
|
||||
ipcRenderer.on("netcatty:passphrase-auth-failed", (_event, payload) => {
|
||||
passphraseAuthFailedListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Passphrase auth-failed callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-update events
|
||||
ipcRenderer.on("netcatty:update:update-available", (_event, payload) => {
|
||||
updateAvailableListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("onUpdateAvailable callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:update:update-not-available", () => {
|
||||
updateNotAvailableListeners.forEach((cb) => {
|
||||
try {
|
||||
cb();
|
||||
} catch (err) {
|
||||
console.error("onUpdateNotAvailable callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:update:download-progress", (_event, payload) => {
|
||||
updateDownloadProgressListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Update download-progress callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:update:downloaded", () => {
|
||||
updateDownloadedListeners.forEach((cb) => {
|
||||
try {
|
||||
cb();
|
||||
} catch (err) {
|
||||
console.error("Update downloaded callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:update:error", (_event, payload) => {
|
||||
updateErrorListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Update error callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Update can't install yet because there are unsaved editors (#1215).
|
||||
ipcRenderer.on("netcatty:update:needs-save", () => {
|
||||
updateNeedsSaveListeners.forEach((cb) => {
|
||||
try {
|
||||
cb();
|
||||
} catch (err) {
|
||||
console.error("Update needs-save callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:sftp:global-transfer", (_event, payload) => {
|
||||
dispatchGlobalSftpTransferEvent(payload);
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:plugins:contributions-changed", (_event, payload) => {
|
||||
dispatchPluginContributionsChanged(payload);
|
||||
});
|
||||
|
||||
// Port forwarding status listeners
|
||||
const portForwardStatusListeners = new Map();
|
||||
const portForwardRuntimeListeners = new Set();
|
||||
|
||||
ipcRenderer.on("netcatty:portforward:status", (_event, payload) => {
|
||||
const { tunnelId, status, error } = payload;
|
||||
const callbacks = portForwardStatusListeners.get(tunnelId);
|
||||
if (callbacks) {
|
||||
callbacks.forEach((cb) => {
|
||||
try {
|
||||
cb(status, error);
|
||||
} catch (err) {
|
||||
console.error("Port forward status callback failed", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:portforward:runtime", (_event, payload) => {
|
||||
portForwardRuntimeListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Port forward runtime callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// File watcher listeners (for auto-sync feature)
|
||||
const fileWatchSyncedListeners = new Set();
|
||||
const fileWatchErrorListeners = new Set();
|
||||
const fileWatchStoppedListeners = new Set();
|
||||
|
||||
ipcRenderer.on("netcatty:filewatch:synced", (_event, payload) => {
|
||||
fileWatchSyncedListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("File watch synced callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:filewatch:error", (_event, payload) => {
|
||||
fileWatchErrorListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("File watch error callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:filewatch:stopped", (_event, payload) => {
|
||||
fileWatchStoppedListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("File watch stopped callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Buffer the latest tray menu data so it can be replayed when the React
|
||||
// component subscribes after lazy-mount (avoiding the first-open race).
|
||||
let _lastTrayMenuData = null;
|
||||
ipcRenderer.on("netcatty:trayPanel:setMenuData", (_event, data) => {
|
||||
_lastTrayMenuData = data;
|
||||
});
|
||||
|
||||
const api = createPreloadApi({
|
||||
ipcRenderer,
|
||||
os,
|
||||
webUtils,
|
||||
randomUUID,
|
||||
dataListeners,
|
||||
displayDataListeners,
|
||||
exitListeners,
|
||||
closedTerminalDataSessions,
|
||||
globalSftpTransferListeners,
|
||||
pluginContributionsChangedListeners,
|
||||
chainProgressListeners,
|
||||
connectionReuseFallbackListeners,
|
||||
zmodemListeners,
|
||||
zmodemOverwriteListeners,
|
||||
sftpConnectionProgressListeners,
|
||||
authFailedListeners,
|
||||
telnetAutoLoginCompleteListeners,
|
||||
telnetAutoLoginCancelledListeners,
|
||||
telnetEchoModeListeners,
|
||||
moshSessionReadyListeners,
|
||||
terminalDataBacklog,
|
||||
terminalOutputPorts,
|
||||
clearTerminalOutputSessionState: clearMcpSessionState,
|
||||
terminalOutputDrainListeners,
|
||||
terminalUrgentInputPorts,
|
||||
languageChangeListeners,
|
||||
fullscreenChangeListeners,
|
||||
windowShownListeners,
|
||||
windowFocusRequestedListeners,
|
||||
windowWillHideListeners,
|
||||
keyboardInteractiveListeners,
|
||||
keyboardInteractiveCancelledListeners,
|
||||
hostKeyVerificationListeners,
|
||||
passphraseListeners,
|
||||
passphraseTimeoutListeners,
|
||||
passphraseCancelledListeners,
|
||||
passphraseAuthFailedListeners,
|
||||
updateDownloadProgressListeners,
|
||||
updateDownloadedListeners,
|
||||
updateAvailableListeners,
|
||||
updateNotAvailableListeners,
|
||||
updateErrorListeners,
|
||||
updateNeedsSaveListeners,
|
||||
terminalPopupConfigState,
|
||||
portForwardStatusListeners,
|
||||
portForwardRuntimeListeners,
|
||||
fileWatchSyncedListeners,
|
||||
fileWatchErrorListeners,
|
||||
fileWatchStoppedListeners,
|
||||
get _lastTrayMenuData() { return _lastTrayMenuData; },
|
||||
set _lastTrayMenuData(value) { _lastTrayMenuData = value; },
|
||||
});
|
||||
|
||||
// Fig autocomplete spec loading via main process
|
||||
const figSpecApi = {
|
||||
listFigSpecs: () => ipcRenderer.invoke("netcatty:figspec:list"),
|
||||
loadFigSpec: (commandName) => ipcRenderer.invoke("netcatty:figspec:load", commandName),
|
||||
listAutocompleteRemoteDir: (sessionId, dirPath, foldersOnly, filterPrefix, limit) => ipcRenderer.invoke("netcatty:ssh:listdir", {
|
||||
sessionId,
|
||||
path: dirPath,
|
||||
foldersOnly,
|
||||
filterPrefix,
|
||||
limit,
|
||||
}),
|
||||
listAutocompleteLocalDir: (dirPath, foldersOnly, filterPrefix, limit) => ipcRenderer.invoke("netcatty:local:listdir", {
|
||||
path: dirPath,
|
||||
foldersOnly,
|
||||
filterPrefix,
|
||||
limit,
|
||||
}),
|
||||
};
|
||||
|
||||
// Merge with existing netcatty (if any) to avoid stale objects on hot reload
|
||||
const existing = (typeof window !== "undefined" && window.netcatty) ? window.netcatty : {};
|
||||
|
||||
function getAllowedRendererOrigins() {
|
||||
const origins = new Set(["app://netcatty"]);
|
||||
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
|
||||
if (typeof devServerUrl === "string" && devServerUrl.length > 0) {
|
||||
try {
|
||||
const u = new URL(devServerUrl);
|
||||
origins.add(u.origin);
|
||||
// Vite often binds to 0.0.0.0, but Chromium navigates via localhost.
|
||||
if (
|
||||
u.hostname === "0.0.0.0" ||
|
||||
u.hostname === "127.0.0.1" ||
|
||||
u.hostname === "::1" ||
|
||||
u.hostname === "[::1]" ||
|
||||
u.hostname === "::" ||
|
||||
u.hostname === "[::]"
|
||||
) {
|
||||
u.hostname = "localhost";
|
||||
origins.add(u.origin);
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid dev URL
|
||||
}
|
||||
}
|
||||
return origins;
|
||||
}
|
||||
|
||||
function isTrustedRendererLocation(allowedOrigins) {
|
||||
try {
|
||||
const origin = window?.location?.origin;
|
||||
return typeof origin === "string" && allowedOrigins.has(origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const allowedOrigins = getAllowedRendererOrigins();
|
||||
if (isTrustedRendererLocation(allowedOrigins)) {
|
||||
contextBridge.exposeInMainWorld("netcatty", { ...existing, ...api, ...figSpecApi });
|
||||
} else {
|
||||
// If a window navigates to an untrusted origin, do NOT expose the bridge.
|
||||
try {
|
||||
console.warn("[Preload] Refusing to expose netcatty bridge to untrusted origin:", window?.location?.origin);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user