mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
308d825db7
* feat: add ZMODEM (lrzsz) file transfer support for terminal sessions Adds ZMODEM protocol detection and file transfer capability to all terminal session types (Local, SSH, Telnet, Mosh, Serial). Uses zmodem.js library with main-process sentry pattern to intercept binary data before string decoding, avoiding IPC pipeline changes. - zmodemHelper.cjs: shared ZMODEM sentry with Electron dialog integration - terminalBridge.cjs: encoding:null for PTY + sentry wrappers for all session types - sshBridge.cjs: sentry wrapper for SSH stream data - preload.cjs + global.d.ts: ZMODEM event IPC bridge and TypeScript types - useZmodemTransfer.ts: React hook for ZMODEM transfer state Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: preserve charset decoding and add ZMODEM progress UI - zmodemHelper: pass raw Buffer to onData, let callers handle decoding - terminalBridge: use StringDecoder for telnet/serial, UTF-8 for local/mosh - sshBridge: restore iconv decoder for SSH session charset support - ZmodemProgressIndicator: floating progress bar with cancel button - Terminal.tsx: wire useZmodemTransfer hook + toast notifications Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: ZMODEM listener cleanup, stream leak, and toast dedup - preload: clean up zmodemListeners on session exit (memory leak) - zmodemHelper: add ws.on('error') handler to close write stream on failure - Terminal: use ref guard to prevent duplicate toast notifications Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address code review findings for ZMODEM - cancel/consume error now send IPC event to renderer (prevents stuck UI) - sanitize download filename with path.basename (path traversal prevention) - add on_detect concurrency guard (deny if transfer already active) - formatBytes: handle negative, zero, and TB+ values safely - closeSession: cancel active ZMODEM before destroying transport Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: prevent double-notification on cancel and stream error resilience - Guard .then()/.catch() in promise chain: skip if cancel() already handled - Download: add writeAborted flag to stop on_input after stream error - Upload: pre-compute file stats to avoid O(N²) statSync calls Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use zsession.abort() instead of close() on dialog cancel close() is only available on Send sessions. Calling it on a Receive session throws, leaving the sentry's internal _zsession dangling and causing subsequent terminal data to be consumed by the abandoned ZMODEM session (terminal freeze). abort() is defined on the base ZmodemSession class and properly fires session_end to reset the sentry. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: handle ZFIN/OO mismatch as successful transfer When sz exits over SSH, the shell prompt often arrives before the ZMODEM "OO" end marker, causing zmodem.js to throw a protocol error. Since ZFIN was already exchanged (= all file data transferred), treat this specific error as a successful completion and forward the shell prompt data back to the terminal via sentry re-consume. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: codex review — UTF-8 decoder, ZFIN abort, session exit cleanup - terminalBridge: use StringDecoder for local/mosh PTY to handle multi-byte UTF-8 split across buffer boundaries (prevents garbled CJK/emoji output) - zmodemHelper: on ZFIN/OO success path, use _on_session_end() instead of abort() to avoid sending CAN (Ctrl-X) bytes to the remote shell - useZmodemTransfer: listen to onSessionExit to reset state when the session dies mid-transfer (prevents stuck progress indicator) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: codex review — file collision handling and stream flush - Download: auto-rename with (1), (2), etc. if file already exists in the target directory, preventing silent overwrite - Download: wait for all write streams to finish flushing before resolving the session_end promise, ensuring data is on disk when the UI reports completion Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: codex review — Windows PTY string compat and Telnet binary safety - Local/Mosh PTY: handle string data from Windows node-pty which ignores encoding: null; convert to Buffer before sentry.consume() - Telnet: bypass IAC negotiation during active ZMODEM transfer to preserve 0xFF bytes in binary data - Telnet writeToRemote: escape 0xFF as 0xFF 0xFF per Telnet spec so ZMODEM binary data is not treated as IAC commands Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: codex review — Windows PTY guard, Telnet IAC, stream cleanup - Local/Mosh: skip ZMODEM sentry on Windows where node-pty can't provide raw bytes; fall back to original string pipeline - Telnet: always run IAC negotiation (even during ZMODEM) since the Telnet layer still escapes 0xFF as IAC IAC; the existing handler already correctly collapses IAC IAC → single 0xFF - Download: destroy un-ended write streams on session_end to prevent hanging promises and leaked file descriptors on abort Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: codex review — early session start, progress throttle, no dup start - Download: call zsession.start() before showing folder picker dialog so lrzsz doesn't time out waiting for ZRINIT - Download: throttle progress IPC to ~10 updates/sec (100ms interval) to avoid overwhelming renderer on fast links - Download: remove duplicate zsession.start() at bottom of Promise Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: handle ZRPOS and prevent terminal flood after ZMODEM abort - Add 500ms cooldown after ZMODEM abort: suppress residual protocol bytes from remote rz/sz that would otherwise flood the terminal - Send 8x CAN (Ctrl-X) on abort/cancel/error to force remote end to stop transmitting even if the initial abort sequence was lost - Handles "Unhandled header: ZRPOS" gracefully (zmodem.js doesn't support error recovery, so abort is the correct response) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: send Ctrl+C after abort in all cancel/error paths Debian's rz stays attached to the TTY after receiving CAN sequences. The cancel() path already sent Ctrl+C via scheduleRemoteInterruptAfterCancel, but dialog-cancel and consume-error paths did not. Now all three abort paths (dialog cancel, consume error, explicit cancel) send Ctrl+C after 150ms to ensure the remote rz/sz process exits and the shell regains control. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add interruptRemote for SSH ZMODEM sentry Pass SSH stream.signal("INT") as interruptRemote callback so the ZMODEM helper can send SIGINT to the remote process when cancelling transfers, complementing the Ctrl+C byte sent via writeToRemote. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: dialog-cancel abort uses module-level helper to avoid ReferenceError sendExtraAbortBytes and writeToRemote are closure-scoped inside createZmodemSentry, not accessible from handleUpload/handleDownload. Extract abortRemoteProcess as a module-level function that takes writeToRemote as a parameter, used in both dialog-cancel paths. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: dialog cancel throws instead of returning to avoid false complete When user dismisses the file/folder picker, handleUpload/handleDownload now throw "Transfer cancelled" instead of returning normally. This ensures the .catch() handler fires (sending error event) rather than .then() (which would incorrectly send complete event). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: codex review — preserve transferType in progress events - useZmodemTransfer: copy transferType from progress events so the transfer direction is preserved if renderer re-subscribes after the initial detect event was missed - zmodemHelper: clean up upload loop comments (backpressure handled via 64KB chunks + setImmediate yield per iteration) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: codex review — guard stale session cleanup, delete partial downloads - Promise chain .then/.catch/.finally now compare currentZSession identity (=== zsession) instead of truthiness, preventing a new transfer from being clobbered by the old promise settling - Aborted/incomplete downloads are deleted from disk on session_end so users don't end up with corrupt partial files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: unconditional cooldown suppression after ZMODEM abort The previous cooldown checked if data "looks like residual ZMODEM" which fails for sz's file content (arbitrary printable bytes). Now cooldown unconditionally drops ALL incoming data for 2 seconds after abort, with repeated CAN bursts to ensure the remote sz stops. This prevents the terminal flood seen when cancelling large sz downloads on fast connections. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1384 lines
51 KiB
JavaScript
1384 lines
51 KiB
JavaScript
const { ipcRenderer, contextBridge, webUtils } = require("electron");
|
|
const os = require("node:os");
|
|
|
|
const dataListeners = new Map();
|
|
const exitListeners = new Map();
|
|
const transferProgressListeners = new Map();
|
|
const transferCompleteListeners = new Map();
|
|
const transferErrorListeners = new Map();
|
|
const transferCancelledListeners = new Map();
|
|
const chainProgressListeners = new Map();
|
|
const zmodemListeners = new Map();
|
|
const sftpConnectionProgressListeners = new Set();
|
|
const authFailedListeners = new Map();
|
|
const languageChangeListeners = new Set();
|
|
const fullscreenChangeListeners = new Set();
|
|
const keyboardInteractiveListeners = new Set();
|
|
const passphraseListeners = new Set();
|
|
const passphraseTimeoutListeners = new Set();
|
|
const updateDownloadProgressListeners = new Set();
|
|
const updateDownloadedListeners = new Set();
|
|
const updateAvailableListeners = new Set();
|
|
const updateNotAvailableListeners = new Set();
|
|
const updateErrorListeners = new Set();
|
|
|
|
function cleanupTransferListeners(transferId) {
|
|
transferProgressListeners.delete(transferId);
|
|
transferCompleteListeners.delete(transferId);
|
|
transferErrorListeners.delete(transferId);
|
|
transferCancelledListeners.delete(transferId);
|
|
}
|
|
|
|
// ── 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 _mcpFlushTimers = new Map(); // sessionId -> delayed-flush timer
|
|
|
|
// 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;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function filterMcpChunk(sessionId, chunk) {
|
|
// 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 data = held + chunk;
|
|
_mcpLineBufs.delete(sessionId);
|
|
|
|
// Fast path: nothing suspicious in the combined data
|
|
if (!data.includes("__NCMCP_") && !_endsWithMarkerPrefix(data)) {
|
|
return data;
|
|
}
|
|
|
|
// Slow path: scan line by line
|
|
let result = "";
|
|
let droppedAny = false;
|
|
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);
|
|
if (droppedAny || tail.includes("__NCMCP_") || _endsWithMarkerPrefix(tail)) {
|
|
_mcpLineBufs.set(sessionId, tail);
|
|
} else {
|
|
result += tail; // safe to display immediately
|
|
}
|
|
break;
|
|
}
|
|
const line = data.slice(pos, nlIdx + 1); // includes the \n
|
|
if (!line.includes("__NCMCP_")) {
|
|
result += line;
|
|
} else {
|
|
droppedAny = true;
|
|
}
|
|
pos = nlIdx + 1;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Deliver data to session listeners. Used both by the normal data path
|
|
* and by the delayed-flush timer.
|
|
*/
|
|
function _deliverToListeners(sessionId, data) {
|
|
const set = dataListeners.get(sessionId);
|
|
if (!set || !data) return;
|
|
set.forEach((cb) => {
|
|
try { cb(data); } catch (err) { console.error("Data callback failed", err); }
|
|
});
|
|
}
|
|
|
|
// ZMODEM file transfer events
|
|
ipcRenderer.on("netcatty:zmodem:detect", (_event, payload) => {
|
|
const set = zmodemListeners.get(payload.sessionId);
|
|
if (!set) return;
|
|
set.forEach((cb) => { try { cb({ type: "detect", ...payload }); } catch {} });
|
|
});
|
|
ipcRenderer.on("netcatty:zmodem:progress", (_event, payload) => {
|
|
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) => {
|
|
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) => {
|
|
const set = zmodemListeners.get(payload.sessionId);
|
|
if (!set) return;
|
|
set.forEach((cb) => { try { cb({ type: "error", ...payload }); } catch {} });
|
|
});
|
|
|
|
ipcRenderer.on("netcatty:data", (_event, payload) => {
|
|
const set = dataListeners.get(payload.sessionId);
|
|
if (!set) return;
|
|
if (payload?.syntheticEcho) {
|
|
_deliverToListeners(payload.sessionId, payload.data);
|
|
return;
|
|
}
|
|
const data = filterMcpChunk(payload.sessionId, payload.data);
|
|
if (data) {
|
|
set.forEach((cb) => {
|
|
try {
|
|
cb(data);
|
|
} catch (err) {
|
|
console.error("Data callback failed", err);
|
|
}
|
|
});
|
|
}
|
|
// 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.
|
|
if (_mcpLineBufs.has(payload.sessionId)) {
|
|
const sid = payload.sessionId;
|
|
_mcpFlushTimers.set(sid, setTimeout(() => {
|
|
const held = _mcpLineBufs.get(sid);
|
|
_mcpLineBufs.delete(sid);
|
|
_mcpFlushTimers.delete(sid);
|
|
if (held) _deliverToListeners(sid, held);
|
|
}, 80));
|
|
}
|
|
});
|
|
|
|
ipcRenderer.on("netcatty:exit", (_event, payload) => {
|
|
const set = exitListeners.get(payload.sessionId);
|
|
if (set) {
|
|
set.forEach((cb) => {
|
|
try {
|
|
cb(payload);
|
|
} catch (err) {
|
|
console.error("Exit callback failed", err);
|
|
}
|
|
});
|
|
}
|
|
dataListeners.delete(payload.sessionId);
|
|
exitListeners.delete(payload.sessionId);
|
|
zmodemListeners.delete(payload.sessionId);
|
|
const pendingTimer = _mcpFlushTimers.get(payload.sessionId);
|
|
if (pendingTimer) {
|
|
clearTimeout(pendingTimer);
|
|
_mcpFlushTimers.delete(payload.sessionId);
|
|
}
|
|
_mcpLineBufs.delete(payload.sessionId); // clean up any held fragment
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
});
|
|
|
|
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Transfer progress events
|
|
ipcRenderer.on("netcatty:transfer:progress", (_event, payload) => {
|
|
const cb = transferProgressListeners.get(payload.transferId);
|
|
if (cb) {
|
|
try {
|
|
cb(payload.transferred, payload.totalBytes, payload.speed);
|
|
} catch (err) {
|
|
console.error("Transfer progress callback failed", err);
|
|
}
|
|
}
|
|
});
|
|
|
|
ipcRenderer.on("netcatty:transfer:complete", (_event, payload) => {
|
|
const cb = transferCompleteListeners.get(payload.transferId);
|
|
if (cb) {
|
|
try {
|
|
cb();
|
|
} catch (err) {
|
|
console.error("Transfer complete callback failed", err);
|
|
}
|
|
}
|
|
cleanupTransferListeners(payload.transferId);
|
|
});
|
|
|
|
ipcRenderer.on("netcatty:transfer:error", (_event, payload) => {
|
|
const cb = transferErrorListeners.get(payload.transferId);
|
|
if (cb) {
|
|
try {
|
|
cb(payload.error);
|
|
} catch (err) {
|
|
console.error("Transfer error callback failed", err);
|
|
}
|
|
}
|
|
cleanupTransferListeners(payload.transferId);
|
|
});
|
|
|
|
ipcRenderer.on("netcatty:transfer:cancelled", (_event, payload) => {
|
|
const cb = transferCancelledListeners.get(payload.transferId);
|
|
if (cb) {
|
|
try { cb(); } catch { }
|
|
}
|
|
cleanupTransferListeners(payload.transferId);
|
|
});
|
|
|
|
// Upload with progress listeners
|
|
const uploadProgressListeners = new Map();
|
|
const uploadCompleteListeners = new Map();
|
|
const uploadErrorListeners = new Map();
|
|
|
|
// Compress upload listeners
|
|
const compressProgressListeners = new Map();
|
|
const compressCompleteListeners = new Map();
|
|
const compressErrorListeners = new Map();
|
|
|
|
ipcRenderer.on("netcatty:upload:progress", (_event, payload) => {
|
|
const cb = uploadProgressListeners.get(payload.transferId);
|
|
if (cb) {
|
|
try {
|
|
cb(payload.transferred, payload.totalBytes, payload.speed);
|
|
} catch (err) {
|
|
console.error("Upload progress callback failed", err);
|
|
}
|
|
}
|
|
});
|
|
|
|
ipcRenderer.on("netcatty:upload:complete", (_event, payload) => {
|
|
const cb = uploadCompleteListeners.get(payload.transferId);
|
|
if (cb) {
|
|
try {
|
|
cb();
|
|
} catch (err) {
|
|
console.error("Upload complete callback failed", err);
|
|
}
|
|
}
|
|
// Cleanup listeners
|
|
uploadProgressListeners.delete(payload.transferId);
|
|
uploadCompleteListeners.delete(payload.transferId);
|
|
uploadErrorListeners.delete(payload.transferId);
|
|
});
|
|
|
|
ipcRenderer.on("netcatty:upload:error", (_event, payload) => {
|
|
const cb = uploadErrorListeners.get(payload.transferId);
|
|
if (cb) {
|
|
try {
|
|
cb(payload.error);
|
|
} catch (err) {
|
|
console.error("Upload error callback failed", err);
|
|
}
|
|
}
|
|
// Cleanup listeners
|
|
uploadProgressListeners.delete(payload.transferId);
|
|
uploadCompleteListeners.delete(payload.transferId);
|
|
uploadErrorListeners.delete(payload.transferId);
|
|
});
|
|
|
|
// Compress upload events
|
|
ipcRenderer.on("netcatty:compress:progress", (_event, payload) => {
|
|
const cb = compressProgressListeners.get(payload.compressionId);
|
|
if (cb) {
|
|
try {
|
|
cb(payload.phase, payload.transferred, payload.total);
|
|
} catch (err) {
|
|
console.error("Compress progress callback failed", err);
|
|
}
|
|
}
|
|
});
|
|
|
|
ipcRenderer.on("netcatty:compress:complete", (_event, payload) => {
|
|
const cb = compressCompleteListeners.get(payload.compressionId);
|
|
if (cb) {
|
|
try {
|
|
cb();
|
|
} catch (err) {
|
|
console.error("Compress complete callback failed", err);
|
|
}
|
|
}
|
|
// Cleanup listeners
|
|
compressProgressListeners.delete(payload.compressionId);
|
|
compressCompleteListeners.delete(payload.compressionId);
|
|
compressErrorListeners.delete(payload.compressionId);
|
|
});
|
|
|
|
ipcRenderer.on("netcatty:compress:error", (_event, payload) => {
|
|
const cb = compressErrorListeners.get(payload.compressionId);
|
|
if (cb) {
|
|
try {
|
|
cb(payload.error);
|
|
} catch (err) {
|
|
console.error("Compress error callback failed", err);
|
|
}
|
|
}
|
|
// Cleanup listeners
|
|
compressProgressListeners.delete(payload.compressionId);
|
|
compressCompleteListeners.delete(payload.compressionId);
|
|
compressErrorListeners.delete(payload.compressionId);
|
|
});
|
|
|
|
ipcRenderer.on("netcatty:compress:cancelled", (_event, payload) => {
|
|
// Just cleanup listeners, the UI already knows it's cancelled
|
|
compressProgressListeners.delete(payload.compressionId);
|
|
compressCompleteListeners.delete(payload.compressionId);
|
|
compressErrorListeners.delete(payload.compressionId);
|
|
});
|
|
|
|
// Port forwarding status listeners
|
|
const portForwardStatusListeners = new Map();
|
|
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// File watcher listeners (for auto-sync feature)
|
|
const fileWatchSyncedListeners = new Set();
|
|
const fileWatchErrorListeners = 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);
|
|
}
|
|
});
|
|
});
|
|
|
|
// 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 = {
|
|
getWindowsPtyInfo: () => {
|
|
if (process.platform !== "win32") {
|
|
return null;
|
|
}
|
|
|
|
const releaseParts = os.release().split(".");
|
|
const buildNumber = Number.parseInt(releaseParts[2] || "", 10);
|
|
const hasBuildNumber = Number.isFinite(buildNumber);
|
|
const backend =
|
|
hasBuildNumber && buildNumber < 18309 ? "winpty" : "conpty";
|
|
|
|
return hasBuildNumber ? { backend, buildNumber } : { backend };
|
|
},
|
|
startSSHSession: async (options) => {
|
|
const result = await ipcRenderer.invoke("netcatty:start", options);
|
|
return result.sessionId;
|
|
},
|
|
startTelnetSession: async (options) => {
|
|
const result = await ipcRenderer.invoke("netcatty:telnet:start", options);
|
|
return result.sessionId;
|
|
},
|
|
startMoshSession: async (options) => {
|
|
const result = await ipcRenderer.invoke("netcatty:mosh:start", options);
|
|
return result.sessionId;
|
|
},
|
|
startLocalSession: async (options) => {
|
|
const result = await ipcRenderer.invoke("netcatty:local:start", options || {});
|
|
return result.sessionId;
|
|
},
|
|
startSerialSession: async (options) => {
|
|
const result = await ipcRenderer.invoke("netcatty:serial:start", options);
|
|
return result.sessionId;
|
|
},
|
|
listSerialPorts: async () => {
|
|
return ipcRenderer.invoke("netcatty:serial:list");
|
|
},
|
|
getDefaultShell: async () => {
|
|
return ipcRenderer.invoke("netcatty:local:defaultShell");
|
|
},
|
|
validatePath: async (path, type) => {
|
|
return ipcRenderer.invoke("netcatty:local:validatePath", { path, type });
|
|
},
|
|
writeToSession: (sessionId, data) => {
|
|
ipcRenderer.send("netcatty:write", { sessionId, data });
|
|
},
|
|
execCommand: async (options) => {
|
|
return ipcRenderer.invoke("netcatty:ssh:exec", options);
|
|
},
|
|
getSessionPwd: async (sessionId) => {
|
|
return ipcRenderer.invoke("netcatty:ssh:pwd", { sessionId });
|
|
},
|
|
getServerStats: async (sessionId) => {
|
|
return ipcRenderer.invoke("netcatty:ssh:stats", { sessionId });
|
|
},
|
|
generateKeyPair: async (options) => {
|
|
return ipcRenderer.invoke("netcatty:key:generate", options);
|
|
},
|
|
checkSshAgent: async () => {
|
|
return ipcRenderer.invoke("netcatty:ssh:check-agent");
|
|
},
|
|
getDefaultKeys: async () => {
|
|
return ipcRenderer.invoke("netcatty:ssh:get-default-keys");
|
|
},
|
|
resizeSession: (sessionId, cols, rows) => {
|
|
ipcRenderer.send("netcatty:resize", { sessionId, cols, rows });
|
|
},
|
|
closeSession: (sessionId) => {
|
|
ipcRenderer.send("netcatty:close", { sessionId });
|
|
},
|
|
setSessionEncoding: (sessionId, encoding) =>
|
|
ipcRenderer.invoke("netcatty:ssh:setEncoding", { sessionId, encoding }),
|
|
onZmodemEvent: (sessionId, cb) => {
|
|
if (!zmodemListeners.has(sessionId)) zmodemListeners.set(sessionId, new Set());
|
|
zmodemListeners.get(sessionId).add(cb);
|
|
return () => zmodemListeners.get(sessionId)?.delete(cb);
|
|
},
|
|
cancelZmodem: (sessionId) => {
|
|
ipcRenderer.send("netcatty:zmodem:cancel", { sessionId });
|
|
},
|
|
onSessionData: (sessionId, cb) => {
|
|
if (!dataListeners.has(sessionId)) dataListeners.set(sessionId, new Set());
|
|
dataListeners.get(sessionId).add(cb);
|
|
return () => dataListeners.get(sessionId)?.delete(cb);
|
|
},
|
|
onSessionExit: (sessionId, cb) => {
|
|
if (!exitListeners.has(sessionId)) exitListeners.set(sessionId, new Set());
|
|
exitListeners.get(sessionId).add(cb);
|
|
return () => exitListeners.get(sessionId)?.delete(cb);
|
|
},
|
|
onAuthFailed: (sessionId, cb) => {
|
|
if (!authFailedListeners.has(sessionId)) authFailedListeners.set(sessionId, new Set());
|
|
authFailedListeners.get(sessionId).add(cb);
|
|
return () => authFailedListeners.get(sessionId)?.delete(cb);
|
|
},
|
|
// Keyboard-interactive authentication (2FA/MFA)
|
|
onKeyboardInteractive: (cb) => {
|
|
keyboardInteractiveListeners.add(cb);
|
|
return () => keyboardInteractiveListeners.delete(cb);
|
|
},
|
|
respondKeyboardInteractive: async (requestId, responses, cancelled = false) => {
|
|
return ipcRenderer.invoke("netcatty:keyboard-interactive:respond", {
|
|
requestId,
|
|
responses,
|
|
cancelled,
|
|
});
|
|
},
|
|
// Passphrase request for encrypted SSH keys
|
|
onPassphraseRequest: (cb) => {
|
|
passphraseListeners.add(cb);
|
|
return () => passphraseListeners.delete(cb);
|
|
},
|
|
respondPassphrase: async (requestId, passphrase, cancelled = false) => {
|
|
return ipcRenderer.invoke("netcatty:passphrase:respond", {
|
|
requestId,
|
|
passphrase,
|
|
cancelled,
|
|
});
|
|
},
|
|
respondPassphraseSkip: async (requestId) => {
|
|
return ipcRenderer.invoke("netcatty:passphrase:respond", {
|
|
requestId,
|
|
passphrase: '',
|
|
skipped: true,
|
|
});
|
|
},
|
|
onPassphraseTimeout: (cb) => {
|
|
passphraseTimeoutListeners.add(cb);
|
|
return () => passphraseTimeoutListeners.delete(cb);
|
|
},
|
|
openSftp: async (options) => {
|
|
const result = await ipcRenderer.invoke("netcatty:sftp:open", options);
|
|
return result.sftpId;
|
|
},
|
|
listSftp: async (sftpId, path, encoding) => {
|
|
return ipcRenderer.invoke("netcatty:sftp:list", { sftpId, path, encoding });
|
|
},
|
|
readSftp: async (sftpId, path, encoding) => {
|
|
return ipcRenderer.invoke("netcatty:sftp:read", { sftpId, path, encoding });
|
|
},
|
|
readSftpBinary: async (sftpId, path, encoding) => {
|
|
return ipcRenderer.invoke("netcatty:sftp:readBinary", { sftpId, path, encoding });
|
|
},
|
|
writeSftp: async (sftpId, path, content, encoding) => {
|
|
return ipcRenderer.invoke("netcatty:sftp:write", { sftpId, path, content, encoding });
|
|
},
|
|
writeSftpBinary: async (sftpId, path, content, encoding) => {
|
|
return ipcRenderer.invoke("netcatty:sftp:writeBinary", { sftpId, path, content, encoding });
|
|
},
|
|
closeSftp: async (sftpId) => {
|
|
return ipcRenderer.invoke("netcatty:sftp:close", { sftpId });
|
|
},
|
|
mkdirSftp: async (sftpId, path, encoding) => {
|
|
return ipcRenderer.invoke("netcatty:sftp:mkdir", { sftpId, path, encoding });
|
|
},
|
|
deleteSftp: async (sftpId, path, encoding) => {
|
|
return ipcRenderer.invoke("netcatty:sftp:delete", { sftpId, path, encoding });
|
|
},
|
|
renameSftp: async (sftpId, oldPath, newPath, encoding) => {
|
|
return ipcRenderer.invoke("netcatty:sftp:rename", { sftpId, oldPath, newPath, encoding });
|
|
},
|
|
statSftp: async (sftpId, path, encoding) => {
|
|
return ipcRenderer.invoke("netcatty:sftp:stat", { sftpId, path, encoding });
|
|
},
|
|
chmodSftp: async (sftpId, path, mode, encoding) => {
|
|
return ipcRenderer.invoke("netcatty:sftp:chmod", { sftpId, path, mode, encoding });
|
|
},
|
|
getSftpHomeDir: async (sftpId) => {
|
|
return ipcRenderer.invoke("netcatty:sftp:homeDir", { sftpId });
|
|
},
|
|
// Write binary with real-time progress callback
|
|
writeSftpBinaryWithProgress: async (sftpId, path, content, transferId, encoding, onProgress, onComplete, onError) => {
|
|
// Register callbacks
|
|
if (onProgress) uploadProgressListeners.set(transferId, onProgress);
|
|
if (onComplete) uploadCompleteListeners.set(transferId, onComplete);
|
|
if (onError) uploadErrorListeners.set(transferId, onError);
|
|
|
|
return ipcRenderer.invoke("netcatty:sftp:writeBinaryWithProgress", {
|
|
sftpId,
|
|
path,
|
|
content,
|
|
transferId,
|
|
encoding,
|
|
});
|
|
},
|
|
// Cancel an in-progress SFTP upload
|
|
cancelSftpUpload: async (transferId) => {
|
|
// Cleanup listeners
|
|
uploadProgressListeners.delete(transferId);
|
|
uploadCompleteListeners.delete(transferId);
|
|
uploadErrorListeners.delete(transferId);
|
|
return ipcRenderer.invoke("netcatty:sftp:cancelUpload", { transferId });
|
|
},
|
|
// Local filesystem operations
|
|
listLocalDir: async (path) => {
|
|
return ipcRenderer.invoke("netcatty:local:list", { path });
|
|
},
|
|
readLocalFile: async (path) => {
|
|
return ipcRenderer.invoke("netcatty:local:read", { path });
|
|
},
|
|
writeLocalFile: async (path, content) => {
|
|
return ipcRenderer.invoke("netcatty:local:write", { path, content });
|
|
},
|
|
deleteLocalFile: async (path) => {
|
|
return ipcRenderer.invoke("netcatty:local:delete", { path });
|
|
},
|
|
renameLocalFile: async (oldPath, newPath) => {
|
|
return ipcRenderer.invoke("netcatty:local:rename", { oldPath, newPath });
|
|
},
|
|
mkdirLocal: async (path) => {
|
|
return ipcRenderer.invoke("netcatty:local:mkdir", { path });
|
|
},
|
|
statLocal: async (path) => {
|
|
return ipcRenderer.invoke("netcatty:local:stat", { path });
|
|
},
|
|
getHomeDir: async () => {
|
|
return ipcRenderer.invoke("netcatty:local:homedir");
|
|
},
|
|
getSystemInfo: async () => {
|
|
return ipcRenderer.invoke("netcatty:system:info");
|
|
},
|
|
// Read system known_hosts file
|
|
readKnownHosts: async () => {
|
|
return ipcRenderer.invoke("netcatty:known-hosts:read");
|
|
},
|
|
setTheme: async (theme) => {
|
|
return ipcRenderer.invoke("netcatty:setTheme", theme);
|
|
},
|
|
setBackgroundColor: async (color) => {
|
|
return ipcRenderer.invoke("netcatty:setBackgroundColor", color);
|
|
},
|
|
setLanguage: async (language) => {
|
|
return ipcRenderer.invoke("netcatty:setLanguage", language);
|
|
},
|
|
onLanguageChanged: (cb) => {
|
|
languageChangeListeners.add(cb);
|
|
return () => languageChangeListeners.delete(cb);
|
|
},
|
|
// Streaming transfer with real progress
|
|
startStreamTransfer: async (options, onProgress, onComplete, onError) => {
|
|
const { transferId } = options;
|
|
// Register callbacks
|
|
if (onProgress) transferProgressListeners.set(transferId, onProgress);
|
|
if (onComplete) transferCompleteListeners.set(transferId, onComplete);
|
|
if (onError) transferErrorListeners.set(transferId, onError);
|
|
|
|
return ipcRenderer.invoke("netcatty:transfer:start", options);
|
|
},
|
|
cancelTransfer: async (transferId) => {
|
|
cleanupTransferListeners(transferId);
|
|
return ipcRenderer.invoke("netcatty:transfer:cancel", { transferId });
|
|
},
|
|
sameHostCopyDirectory: async (sftpId, sourcePath, targetPath, encoding, transferId) => {
|
|
return ipcRenderer.invoke("netcatty:transfer:same-host-copy-dir", { sftpId, sourcePath, targetPath, encoding, transferId });
|
|
},
|
|
// Compressed folder upload
|
|
startCompressedUpload: async (options, onProgress, onComplete, onError) => {
|
|
const { compressionId } = options;
|
|
// Register callbacks
|
|
if (onProgress) compressProgressListeners.set(compressionId, onProgress);
|
|
if (onComplete) compressCompleteListeners.set(compressionId, onComplete);
|
|
if (onError) compressErrorListeners.set(compressionId, onError);
|
|
|
|
return ipcRenderer.invoke("netcatty:compress:start", options);
|
|
},
|
|
cancelCompressedUpload: async (compressionId) => {
|
|
// Cleanup listeners
|
|
compressProgressListeners.delete(compressionId);
|
|
compressCompleteListeners.delete(compressionId);
|
|
compressErrorListeners.delete(compressionId);
|
|
return ipcRenderer.invoke("netcatty:compress:cancel", { compressionId });
|
|
},
|
|
checkCompressedUploadSupport: async (sftpId) => {
|
|
return ipcRenderer.invoke("netcatty:compress:checkSupport", { sftpId });
|
|
},
|
|
// Window controls for custom title bar
|
|
windowMinimize: () => ipcRenderer.invoke("netcatty:window:minimize"),
|
|
windowMaximize: () => ipcRenderer.invoke("netcatty:window:maximize"),
|
|
windowClose: () => ipcRenderer.invoke("netcatty:window:close"),
|
|
windowIsMaximized: () => ipcRenderer.invoke("netcatty:window:isMaximized"),
|
|
windowIsFullscreen: () => ipcRenderer.invoke("netcatty:window:isFullscreen"),
|
|
onWindowFullScreenChanged: (cb) => {
|
|
fullscreenChangeListeners.add(cb);
|
|
return () => fullscreenChangeListeners.delete(cb);
|
|
},
|
|
|
|
// Settings window
|
|
openSettingsWindow: () => ipcRenderer.invoke("netcatty:settings:open"),
|
|
closeSettingsWindow: () => ipcRenderer.invoke("netcatty:settings:close"),
|
|
|
|
// Cross-window settings sync
|
|
notifySettingsChanged: (payload) => ipcRenderer.send("netcatty:settings:changed", payload),
|
|
onSettingsChanged: (callback) => {
|
|
const handler = (_event, payload) => callback(payload);
|
|
ipcRenderer.on("netcatty:settings:changed", handler);
|
|
return () => ipcRenderer.removeListener("netcatty:settings:changed", handler);
|
|
},
|
|
|
|
// Cloud sync session (in-memory only, shared across windows)
|
|
cloudSyncSetSessionPassword: (password) =>
|
|
ipcRenderer.invoke("netcatty:cloudSync:session:setPassword", password),
|
|
cloudSyncGetSessionPassword: () =>
|
|
ipcRenderer.invoke("netcatty:cloudSync:session:getPassword"),
|
|
cloudSyncClearSessionPassword: () =>
|
|
ipcRenderer.invoke("netcatty:cloudSync:session:clearPassword"),
|
|
|
|
// Cloud sync network operations (proxied via main process)
|
|
cloudSyncWebdavInitialize: (config) =>
|
|
ipcRenderer.invoke("netcatty:cloudSync:webdav:initialize", { config }),
|
|
cloudSyncWebdavUpload: (config, syncedFile) =>
|
|
ipcRenderer.invoke("netcatty:cloudSync:webdav:upload", { config, syncedFile }),
|
|
cloudSyncWebdavDownload: (config) =>
|
|
ipcRenderer.invoke("netcatty:cloudSync:webdav:download", { config }),
|
|
cloudSyncWebdavDelete: (config) =>
|
|
ipcRenderer.invoke("netcatty:cloudSync:webdav:delete", { config }),
|
|
|
|
cloudSyncS3Initialize: (config) =>
|
|
ipcRenderer.invoke("netcatty:cloudSync:s3:initialize", { config }),
|
|
cloudSyncS3Upload: (config, syncedFile) =>
|
|
ipcRenderer.invoke("netcatty:cloudSync:s3:upload", { config, syncedFile }),
|
|
cloudSyncS3Download: (config) =>
|
|
ipcRenderer.invoke("netcatty:cloudSync:s3:download", { config }),
|
|
cloudSyncS3Delete: (config) =>
|
|
ipcRenderer.invoke("netcatty:cloudSync:s3:delete", { config }),
|
|
|
|
// Open URL in default browser
|
|
openExternal: (url) => ipcRenderer.invoke("netcatty:openExternal", url),
|
|
|
|
// App info
|
|
getAppInfo: () => ipcRenderer.invoke("netcatty:app:getInfo"),
|
|
|
|
// Tell main process the renderer has mounted/painted (used to avoid initial blank screen).
|
|
rendererReady: () => ipcRenderer.send("netcatty:renderer:ready"),
|
|
|
|
// Port Forwarding API
|
|
startPortForward: async (options) => {
|
|
return ipcRenderer.invoke("netcatty:portforward:start", options);
|
|
},
|
|
stopPortForward: async (tunnelId) => {
|
|
return ipcRenderer.invoke("netcatty:portforward:stop", { tunnelId });
|
|
},
|
|
getPortForwardStatus: async (tunnelId) => {
|
|
return ipcRenderer.invoke("netcatty:portforward:status", { tunnelId });
|
|
},
|
|
listPortForwards: async () => {
|
|
return ipcRenderer.invoke("netcatty:portforward:list");
|
|
},
|
|
stopAllPortForwards: async () => {
|
|
return ipcRenderer.invoke("netcatty:portforward:stopAll");
|
|
},
|
|
stopPortForwardByRuleId: async (ruleId) => {
|
|
return ipcRenderer.invoke("netcatty:portforward:stopByRuleId", { ruleId });
|
|
},
|
|
onPortForwardStatus: (tunnelId, cb) => {
|
|
if (!portForwardStatusListeners.has(tunnelId)) {
|
|
portForwardStatusListeners.set(tunnelId, new Set());
|
|
}
|
|
portForwardStatusListeners.get(tunnelId).add(cb);
|
|
return () => {
|
|
portForwardStatusListeners.get(tunnelId)?.delete(cb);
|
|
if (portForwardStatusListeners.get(tunnelId)?.size === 0) {
|
|
portForwardStatusListeners.delete(tunnelId);
|
|
}
|
|
};
|
|
},
|
|
// Chain progress listener for jump host connections
|
|
onChainProgress: (cb) => {
|
|
const id = Date.now().toString() + Math.random().toString(16).slice(2);
|
|
chainProgressListeners.set(id, cb);
|
|
return () => {
|
|
chainProgressListeners.delete(id);
|
|
};
|
|
},
|
|
// SFTP connection progress listener (auth method logs)
|
|
onSftpConnectionProgress: (cb) => {
|
|
sftpConnectionProgressListeners.add(cb);
|
|
return () => {
|
|
sftpConnectionProgressListeners.delete(cb);
|
|
};
|
|
},
|
|
|
|
// OAuth callback server
|
|
startOAuthCallback: (expectedState) => ipcRenderer.invoke("oauth:startCallback", expectedState),
|
|
cancelOAuthCallback: () => ipcRenderer.invoke("oauth:cancelCallback"),
|
|
|
|
// GitHub Device Flow (proxied via main process to avoid CORS)
|
|
githubStartDeviceFlow: (options) => ipcRenderer.invoke("netcatty:github:deviceFlow:start", options),
|
|
githubPollDeviceFlowToken: (options) => ipcRenderer.invoke("netcatty:github:deviceFlow:poll", options),
|
|
|
|
// Google OAuth (proxied via main process to avoid CORS)
|
|
googleExchangeCodeForTokens: (options) =>
|
|
ipcRenderer.invoke("netcatty:google:oauth:exchange", options),
|
|
googleRefreshAccessToken: (options) =>
|
|
ipcRenderer.invoke("netcatty:google:oauth:refresh", options),
|
|
googleGetUserInfo: (options) =>
|
|
ipcRenderer.invoke("netcatty:google:oauth:userinfo", options),
|
|
|
|
// Google Drive API (proxied via main process to avoid CORS/COEP issues in renderer)
|
|
googleDriveFindSyncFile: (options) =>
|
|
ipcRenderer.invoke("netcatty:google:drive:findSyncFile", options),
|
|
googleDriveCreateSyncFile: (options) =>
|
|
ipcRenderer.invoke("netcatty:google:drive:createSyncFile", options),
|
|
googleDriveUpdateSyncFile: (options) =>
|
|
ipcRenderer.invoke("netcatty:google:drive:updateSyncFile", options),
|
|
googleDriveDownloadSyncFile: (options) =>
|
|
ipcRenderer.invoke("netcatty:google:drive:downloadSyncFile", options),
|
|
googleDriveDeleteSyncFile: (options) =>
|
|
ipcRenderer.invoke("netcatty:google:drive:deleteSyncFile", options),
|
|
|
|
// OneDrive OAuth + Graph (proxied via main process to avoid CORS)
|
|
onedriveExchangeCodeForTokens: (options) =>
|
|
ipcRenderer.invoke("netcatty:onedrive:oauth:exchange", options),
|
|
onedriveRefreshAccessToken: (options) =>
|
|
ipcRenderer.invoke("netcatty:onedrive:oauth:refresh", options),
|
|
onedriveGetUserInfo: (options) =>
|
|
ipcRenderer.invoke("netcatty:onedrive:oauth:userinfo", options),
|
|
onedriveFindSyncFile: (options) =>
|
|
ipcRenderer.invoke("netcatty:onedrive:drive:findSyncFile", options),
|
|
onedriveUploadSyncFile: (options) =>
|
|
ipcRenderer.invoke("netcatty:onedrive:drive:uploadSyncFile", options),
|
|
onedriveDownloadSyncFile: (options) =>
|
|
ipcRenderer.invoke("netcatty:onedrive:drive:downloadSyncFile", options),
|
|
onedriveDeleteSyncFile: (options) =>
|
|
ipcRenderer.invoke("netcatty:onedrive:drive:deleteSyncFile", options),
|
|
|
|
// File opener helpers (for "Open With" feature)
|
|
selectApplication: () =>
|
|
ipcRenderer.invoke("netcatty:selectApplication"),
|
|
openWithApplication: (filePath, appPath) =>
|
|
ipcRenderer.invoke("netcatty:openWithApplication", { filePath, appPath }),
|
|
downloadSftpToTemp: (sftpId, remotePath, fileName, encoding) =>
|
|
ipcRenderer.invoke("netcatty:sftp:downloadToTemp", { sftpId, remotePath, fileName, encoding }),
|
|
downloadSftpToTempWithProgress: (sftpId, remotePath, fileName, encoding, transferId, onProgress, onComplete, onError, onCancelled) => {
|
|
if (onProgress) transferProgressListeners.set(transferId, onProgress);
|
|
if (onComplete) transferCompleteListeners.set(transferId, onComplete);
|
|
if (onError) transferErrorListeners.set(transferId, onError);
|
|
if (onCancelled) transferCancelledListeners.set(transferId, onCancelled);
|
|
return ipcRenderer
|
|
.invoke("netcatty:sftp:downloadToTempWithProgress", { sftpId, remotePath, fileName, encoding, transferId })
|
|
.catch((err) => {
|
|
cleanupTransferListeners(transferId);
|
|
throw err;
|
|
});
|
|
},
|
|
|
|
// Save dialog for file downloads
|
|
showSaveDialog: (defaultPath, filters) =>
|
|
ipcRenderer.invoke("netcatty:showSaveDialog", { defaultPath, filters }),
|
|
selectDirectory: (title, defaultPath) =>
|
|
ipcRenderer.invoke("netcatty:selectDirectory", { title, defaultPath }),
|
|
selectFile: (title, defaultPath, filters) =>
|
|
ipcRenderer.invoke("netcatty:selectFile", { title, defaultPath, filters }),
|
|
|
|
// File watcher for auto-sync feature
|
|
startFileWatch: (localPath, remotePath, sftpId, encoding) =>
|
|
ipcRenderer.invoke("netcatty:filewatch:start", { localPath, remotePath, sftpId, encoding }),
|
|
stopFileWatch: (watchId, cleanupTempFile = false) =>
|
|
ipcRenderer.invoke("netcatty:filewatch:stop", { watchId, cleanupTempFile }),
|
|
listFileWatches: () =>
|
|
ipcRenderer.invoke("netcatty:filewatch:list"),
|
|
registerTempFile: (sftpId, localPath) =>
|
|
ipcRenderer.invoke("netcatty:filewatch:registerTempFile", { sftpId, localPath }),
|
|
onFileWatchSynced: (cb) => {
|
|
fileWatchSyncedListeners.add(cb);
|
|
return () => fileWatchSyncedListeners.delete(cb);
|
|
},
|
|
onFileWatchError: (cb) => {
|
|
fileWatchErrorListeners.add(cb);
|
|
return () => fileWatchErrorListeners.delete(cb);
|
|
},
|
|
|
|
// Temp file cleanup
|
|
deleteTempFile: (filePath) =>
|
|
ipcRenderer.invoke("netcatty:deleteTempFile", { filePath }),
|
|
|
|
// Temp directory management
|
|
getTempDirInfo: () =>
|
|
ipcRenderer.invoke("netcatty:tempdir:getInfo"),
|
|
clearTempDir: () =>
|
|
ipcRenderer.invoke("netcatty:tempdir:clear"),
|
|
getTempDirPath: () =>
|
|
ipcRenderer.invoke("netcatty:tempdir:getPath"),
|
|
openTempDir: () =>
|
|
ipcRenderer.invoke("netcatty:tempdir:open"),
|
|
|
|
// Session Logs
|
|
exportSessionLog: (payload) =>
|
|
ipcRenderer.invoke("netcatty:sessionLogs:export", payload),
|
|
selectSessionLogsDir: () =>
|
|
ipcRenderer.invoke("netcatty:sessionLogs:selectDir"),
|
|
autoSaveSessionLog: (payload) =>
|
|
ipcRenderer.invoke("netcatty:sessionLogs:autoSave", payload),
|
|
openSessionLogsDir: (directory) =>
|
|
ipcRenderer.invoke("netcatty:sessionLogs:openDir", { directory }),
|
|
|
|
// Crash Logs
|
|
getCrashLogs: () =>
|
|
ipcRenderer.invoke("netcatty:crashLogs:list"),
|
|
readCrashLog: (fileName) =>
|
|
ipcRenderer.invoke("netcatty:crashLogs:read", { fileName }),
|
|
clearCrashLogs: () =>
|
|
ipcRenderer.invoke("netcatty:crashLogs:clear"),
|
|
openCrashLogsDir: () =>
|
|
ipcRenderer.invoke("netcatty:crashLogs:openDir"),
|
|
|
|
// Global Toggle Hotkey (Quake Mode)
|
|
registerGlobalHotkey: (hotkey) =>
|
|
ipcRenderer.invoke("netcatty:globalHotkey:register", { hotkey }),
|
|
unregisterGlobalHotkey: () =>
|
|
ipcRenderer.invoke("netcatty:globalHotkey:unregister"),
|
|
getGlobalHotkeyStatus: () =>
|
|
ipcRenderer.invoke("netcatty:globalHotkey:status"),
|
|
|
|
// System Tray / Close to Tray
|
|
setCloseToTray: (enabled) =>
|
|
ipcRenderer.invoke("netcatty:tray:setCloseToTray", { enabled }),
|
|
isCloseToTray: () =>
|
|
ipcRenderer.invoke("netcatty:tray:isCloseToTray"),
|
|
updateTrayMenuData: (data) =>
|
|
ipcRenderer.invoke("netcatty:tray:updateMenuData", data),
|
|
// Listen for tray menu actions
|
|
onTrayFocusSession: (callback) => {
|
|
const handler = (_event, sessionId) => callback(sessionId);
|
|
ipcRenderer.on("netcatty:tray:focusSession", handler);
|
|
return () => ipcRenderer.removeListener("netcatty:tray:focusSession", handler);
|
|
},
|
|
onTrayTogglePortForward: (callback) => {
|
|
const handler = (_event, ruleId, start) => callback(ruleId, start);
|
|
ipcRenderer.on("netcatty:tray:togglePortForward", handler);
|
|
return () => ipcRenderer.removeListener("netcatty:tray:togglePortForward", handler);
|
|
},
|
|
|
|
// Tray panel actions forwarded to main window
|
|
onTrayPanelJumpToSession: (callback) => {
|
|
const handler = (_event, sessionId) => callback(sessionId);
|
|
ipcRenderer.on("netcatty:trayPanel:jumpToSession", handler);
|
|
return () => ipcRenderer.removeListener("netcatty:trayPanel:jumpToSession", handler);
|
|
},
|
|
onTrayPanelConnectToHost: (callback) => {
|
|
const handler = (_event, hostId) => callback(hostId);
|
|
ipcRenderer.on("netcatty:trayPanel:connectToHost", handler);
|
|
return () => ipcRenderer.removeListener("netcatty:trayPanel:connectToHost", handler);
|
|
},
|
|
|
|
// Tray panel window
|
|
hideTrayPanel: () => ipcRenderer.invoke("netcatty:trayPanel:hide"),
|
|
openMainWindow: () => ipcRenderer.invoke("netcatty:trayPanel:openMainWindow"),
|
|
quitApp: () => ipcRenderer.invoke("netcatty:trayPanel:quitApp"),
|
|
jumpToSessionFromTrayPanel: (sessionId) =>
|
|
ipcRenderer.invoke("netcatty:trayPanel:jumpToSession", sessionId),
|
|
connectToHostFromTrayPanel: (hostId) =>
|
|
ipcRenderer.invoke("netcatty:trayPanel:connectToHost", hostId),
|
|
onTrayPanelCloseRequest: (callback) => {
|
|
const handler = () => callback();
|
|
ipcRenderer.on("netcatty:trayPanel:closeRequest", handler);
|
|
return () => ipcRenderer.removeListener("netcatty:trayPanel:closeRequest", handler);
|
|
},
|
|
|
|
onTrayPanelRefresh: (callback) => {
|
|
const handler = () => callback();
|
|
ipcRenderer.on("netcatty:trayPanel:refresh", handler);
|
|
return () => ipcRenderer.removeListener("netcatty:trayPanel:refresh", handler);
|
|
},
|
|
|
|
onTrayPanelMenuData: (callback) => {
|
|
// Replay buffered data so late subscribers (e.g. after React lazy-mount) don't miss
|
|
// the initial payload that was sent before the useEffect listener was registered.
|
|
if (_lastTrayMenuData) {
|
|
queueMicrotask(() => callback(_lastTrayMenuData));
|
|
}
|
|
const handler = (_event, data) => {
|
|
_lastTrayMenuData = data;
|
|
callback(data);
|
|
};
|
|
ipcRenderer.on("netcatty:trayPanel:setMenuData", handler);
|
|
return () => ipcRenderer.removeListener("netcatty:trayPanel:setMenuData", handler);
|
|
},
|
|
|
|
// Get file path from File object (for drag-and-drop)
|
|
getPathForFile: (file) => {
|
|
try {
|
|
return webUtils.getPathForFile(file);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
},
|
|
|
|
// Clipboard fallback helpers
|
|
readClipboardText: async () => {
|
|
return ipcRenderer.invoke("netcatty:clipboard:readText");
|
|
},
|
|
|
|
// Credential encryption (field-level safeStorage)
|
|
credentialsAvailable: () => ipcRenderer.invoke("netcatty:credentials:available"),
|
|
credentialsEncrypt: (plaintext) => ipcRenderer.invoke("netcatty:credentials:encrypt", plaintext),
|
|
credentialsDecrypt: (value) => ipcRenderer.invoke("netcatty:credentials:decrypt", value),
|
|
|
|
// Auto-update
|
|
checkForUpdate: () => ipcRenderer.invoke("netcatty:update:check"),
|
|
downloadUpdate: () => ipcRenderer.invoke("netcatty:update:download"),
|
|
installUpdate: () => ipcRenderer.invoke("netcatty:update:install"),
|
|
getUpdateStatus: () => ipcRenderer.invoke("netcatty:update:getStatus"),
|
|
setAutoUpdate: (enabled) => ipcRenderer.invoke("netcatty:update:setAutoUpdate", { enabled }),
|
|
getAutoUpdate: () => ipcRenderer.invoke("netcatty:update:getAutoUpdate"),
|
|
onUpdateAvailable: (cb) => {
|
|
updateAvailableListeners.add(cb);
|
|
return () => updateAvailableListeners.delete(cb);
|
|
},
|
|
onUpdateNotAvailable: (cb) => {
|
|
updateNotAvailableListeners.add(cb);
|
|
return () => updateNotAvailableListeners.delete(cb);
|
|
},
|
|
onUpdateDownloadProgress: (cb) => {
|
|
updateDownloadProgressListeners.add(cb);
|
|
return () => updateDownloadProgressListeners.delete(cb);
|
|
},
|
|
onUpdateDownloaded: (cb) => {
|
|
updateDownloadedListeners.add(cb);
|
|
return () => updateDownloadedListeners.delete(cb);
|
|
},
|
|
onUpdateError: (cb) => {
|
|
updateErrorListeners.add(cb);
|
|
return () => updateErrorListeners.delete(cb);
|
|
},
|
|
|
|
// ── AI Bridge ──
|
|
aiSyncProviders: async (providers) => {
|
|
return ipcRenderer.invoke("netcatty:ai:sync-providers", { providers });
|
|
},
|
|
aiSyncWebSearch: async (apiHost, apiKey) => {
|
|
return ipcRenderer.invoke("netcatty:ai:sync-web-search", { apiHost, apiKey });
|
|
},
|
|
aiChatStream: async (requestId, url, headers, body, providerId) => {
|
|
return ipcRenderer.invoke("netcatty:ai:chat:stream", { requestId, url, headers, body, providerId });
|
|
},
|
|
aiChatCancel: async (requestId) => {
|
|
return ipcRenderer.invoke("netcatty:ai:chat:cancel", { requestId });
|
|
},
|
|
aiFetch: async (url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify) => {
|
|
return ipcRenderer.invoke("netcatty:ai:fetch", { url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify });
|
|
},
|
|
aiAllowlistAddHost: async (baseURL) => {
|
|
return ipcRenderer.invoke("netcatty:ai:allowlist:add-host", { baseURL });
|
|
},
|
|
aiExec: async (sessionId, command, chatSessionId) => {
|
|
return ipcRenderer.invoke("netcatty:ai:exec", { sessionId, command, chatSessionId });
|
|
},
|
|
aiCattyCancelExec: async (chatSessionId) => {
|
|
return ipcRenderer.invoke("netcatty:ai:catty:cancel", { chatSessionId });
|
|
},
|
|
aiDiscoverAgents: async () => {
|
|
return ipcRenderer.invoke("netcatty:ai:agents:discover");
|
|
},
|
|
aiResolveCli: async (params) => {
|
|
return ipcRenderer.invoke("netcatty:ai:resolve-cli", params);
|
|
},
|
|
aiCodexGetIntegration: async () => {
|
|
return ipcRenderer.invoke("netcatty:ai:codex:get-integration");
|
|
},
|
|
aiCodexStartLogin: async () => {
|
|
return ipcRenderer.invoke("netcatty:ai:codex:start-login");
|
|
},
|
|
aiCodexGetLoginSession: async (sessionId) => {
|
|
return ipcRenderer.invoke("netcatty:ai:codex:get-login-session", { sessionId });
|
|
},
|
|
aiCodexCancelLogin: async (sessionId) => {
|
|
return ipcRenderer.invoke("netcatty:ai:codex:cancel-login", { sessionId });
|
|
},
|
|
aiCodexLogout: async () => {
|
|
return ipcRenderer.invoke("netcatty:ai:codex:logout");
|
|
},
|
|
aiSpawnAgent: async (agentId, command, args, env, options) => {
|
|
return ipcRenderer.invoke("netcatty:ai:agent:spawn", { agentId, command, args, env, closeStdin: options?.closeStdin });
|
|
},
|
|
aiWriteToAgent: async (agentId, data) => {
|
|
return ipcRenderer.invoke("netcatty:ai:agent:write", { agentId, data });
|
|
},
|
|
aiCloseAgentStdin: async (agentId) => {
|
|
return ipcRenderer.invoke("netcatty:ai:agent:close-stdin", { agentId });
|
|
},
|
|
aiKillAgent: async (agentId) => {
|
|
return ipcRenderer.invoke("netcatty:ai:agent:kill", { agentId });
|
|
},
|
|
// MCP Server session metadata
|
|
aiMcpUpdateSessions: async (sessions, chatSessionId) => {
|
|
return ipcRenderer.invoke("netcatty:ai:mcp:update-sessions", { sessions, chatSessionId });
|
|
},
|
|
aiMcpSetCommandBlocklist: async (blocklist) => {
|
|
return ipcRenderer.invoke("netcatty:ai:mcp:set-command-blocklist", { blocklist });
|
|
},
|
|
aiMcpSetCommandTimeout: async (timeout) => {
|
|
return ipcRenderer.invoke("netcatty:ai:mcp:set-command-timeout", { timeout });
|
|
},
|
|
aiMcpSetMaxIterations: async (maxIterations) => {
|
|
return ipcRenderer.invoke("netcatty:ai:mcp:set-max-iterations", { maxIterations });
|
|
},
|
|
aiMcpSetPermissionMode: async (mode) => {
|
|
return ipcRenderer.invoke("netcatty:ai:mcp:set-permission-mode", { mode });
|
|
},
|
|
// MCP approval gate: renderer receives approval requests from main process
|
|
onMcpApprovalRequest: (cb) => {
|
|
const handler = (_event, payload) => cb(payload);
|
|
ipcRenderer.on("netcatty:ai:mcp:approval-request", handler);
|
|
return () => ipcRenderer.removeListener("netcatty:ai:mcp:approval-request", handler);
|
|
},
|
|
respondMcpApproval: async (approvalId, approved) => {
|
|
return ipcRenderer.invoke("netcatty:ai:mcp:approval-response", { approvalId, approved });
|
|
},
|
|
// MCP approval cleared: main process timed out or cancelled an approval
|
|
onMcpApprovalCleared: (cb) => {
|
|
const handler = (_event, payload) => cb(payload);
|
|
ipcRenderer.on("netcatty:ai:mcp:approval-cleared", handler);
|
|
return () => ipcRenderer.removeListener("netcatty:ai:mcp:approval-cleared", handler);
|
|
},
|
|
// ACP streaming
|
|
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images) => {
|
|
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images });
|
|
},
|
|
aiAcpListModels: async (acpCommand, acpArgs, cwd, providerId, chatSessionId) => {
|
|
return ipcRenderer.invoke("netcatty:ai:acp:list-models", { acpCommand, acpArgs, cwd, providerId, chatSessionId });
|
|
},
|
|
aiAcpCancel: async (requestId, chatSessionId) => {
|
|
return ipcRenderer.invoke("netcatty:ai:acp:cancel", { requestId, chatSessionId });
|
|
},
|
|
aiAcpCleanup: async (chatSessionId) => {
|
|
return ipcRenderer.invoke("netcatty:ai:acp:cleanup", { chatSessionId });
|
|
},
|
|
onAiAcpEvent: (requestId, cb) => {
|
|
const handler = (_event, payload) => {
|
|
if (payload.requestId === requestId) cb(payload.event);
|
|
};
|
|
ipcRenderer.on("netcatty:ai:acp:event", handler);
|
|
return () => ipcRenderer.removeListener("netcatty:ai:acp:event", handler);
|
|
},
|
|
onAiAcpDone: (requestId, cb) => {
|
|
const handler = (_event, payload) => {
|
|
if (payload.requestId === requestId) cb();
|
|
};
|
|
ipcRenderer.on("netcatty:ai:acp:done", handler);
|
|
return () => ipcRenderer.removeListener("netcatty:ai:acp:done", handler);
|
|
},
|
|
onAiAcpError: (requestId, cb) => {
|
|
const handler = (_event, payload) => {
|
|
if (payload.requestId === requestId) cb(payload.error);
|
|
};
|
|
ipcRenderer.on("netcatty:ai:acp:error", handler);
|
|
return () => ipcRenderer.removeListener("netcatty:ai:acp:error", handler);
|
|
},
|
|
onAiStreamData: (requestId, cb) => {
|
|
const handler = (_event, payload) => {
|
|
if (payload.requestId === requestId) cb(payload.data);
|
|
};
|
|
ipcRenderer.on("netcatty:ai:stream:data", handler);
|
|
return () => ipcRenderer.removeListener("netcatty:ai:stream:data", handler);
|
|
},
|
|
onAiStreamEnd: (requestId, cb) => {
|
|
const handler = (_event, payload) => {
|
|
if (payload.requestId === requestId) cb();
|
|
};
|
|
ipcRenderer.on("netcatty:ai:stream:end", handler);
|
|
return () => ipcRenderer.removeListener("netcatty:ai:stream:end", handler);
|
|
},
|
|
onAiStreamError: (requestId, cb) => {
|
|
const handler = (_event, payload) => {
|
|
if (payload.requestId === requestId) cb(payload.error);
|
|
};
|
|
ipcRenderer.on("netcatty:ai:stream:error", handler);
|
|
return () => ipcRenderer.removeListener("netcatty:ai:stream:error", handler);
|
|
},
|
|
onAiAgentStdout: (agentId, cb) => {
|
|
const handler = (_event, payload) => {
|
|
if (payload.agentId === agentId) cb(payload.data);
|
|
};
|
|
ipcRenderer.on("netcatty:ai:agent:stdout", handler);
|
|
return () => ipcRenderer.removeListener("netcatty:ai:agent:stdout", handler);
|
|
},
|
|
onAiAgentStderr: (agentId, cb) => {
|
|
const handler = (_event, payload) => {
|
|
if (payload.agentId === agentId) cb(payload.data);
|
|
};
|
|
ipcRenderer.on("netcatty:ai:agent:stderr", handler);
|
|
return () => ipcRenderer.removeListener("netcatty:ai:agent:stderr", handler);
|
|
},
|
|
onAiAgentExit: (agentId, cb) => {
|
|
const handler = (_event, payload) => {
|
|
if (payload.agentId === agentId) cb(payload.code);
|
|
};
|
|
ipcRenderer.on("netcatty:ai:agent:exit", handler);
|
|
return () => ipcRenderer.removeListener("netcatty:ai:agent:exit", handler);
|
|
},
|
|
};
|
|
|
|
// 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
|
|
}
|
|
}
|