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
1516 lines
52 KiB
JavaScript
1516 lines
52 KiB
JavaScript
/**
|
||
* ZMODEM Helper - Provides ZMODEM file transfer support for terminal sessions.
|
||
*
|
||
* Architecture: ZMODEM detection and transfer runs entirely in the main process.
|
||
* The Sentry wraps the raw data stream and routes data either to the normal
|
||
* string-based terminal pipeline (via `to_terminal`) or to the ZMODEM protocol
|
||
* handler. This avoids any changes to the IPC / preload / renderer data path.
|
||
*
|
||
* The renderer is only notified for progress display via lightweight IPC events.
|
||
*/
|
||
|
||
// Apply the Buffer fast paths to zmodem.js receive/send hot paths, plus the
|
||
// Send-session robustness fixes for `rz` uploads (see zmodemFastPath.cjs).
|
||
// NETCATTY_ZMODEM_FAST_PATH=0 disables only the performance paths.
|
||
const {
|
||
applyZmodemFastPath,
|
||
applyZmodemSendSessionFixes,
|
||
applyZmodemSendFastPath,
|
||
} = require("./zmodemFastPath.cjs");
|
||
const Zmodem = applyZmodemSendFastPath(
|
||
applyZmodemSendSessionFixes(applyZmodemFastPath(require("zmodem.js"))),
|
||
);
|
||
const fs = require("node:fs");
|
||
const path = require("node:path");
|
||
|
||
// Lazy-load electron to avoid issues when requiring from non-electron contexts
|
||
let _electron = null;
|
||
function getElectron() {
|
||
if (!_electron) _electron = require("electron");
|
||
return _electron;
|
||
}
|
||
|
||
/**
|
||
* Resolve per-file overwrite choices into an upload plan. Pure (no I/O):
|
||
* `resolveDecision(name, { signal })` is awaited only for files in
|
||
* `existingList`, in input order; `{ applyToRest: true }` reuses that action
|
||
* for the remaining conflicts.
|
||
* Returns indices into the original `names` array so callers preserve per-file
|
||
* identity even when two files share a basename.
|
||
* Actions: 'overwrite' (rm remote then send), 'skip' (don't send), 'cancel' (abort all).
|
||
*/
|
||
async function buildUploadPlan(names, existingList, resolveDecision, signal) {
|
||
const existing = new Set(existingList);
|
||
const offerIndices = [];
|
||
const removeIndices = [];
|
||
let bulkAction = null;
|
||
for (let idx = 0; idx < names.length; idx++) {
|
||
const name = names[idx];
|
||
if (!existing.has(name)) { offerIndices.push(idx); continue; }
|
||
let action = bulkAction;
|
||
if (!action) {
|
||
throwIfZmodemCancelled(signal);
|
||
const decision = (await racePromiseWithAbortSignal(
|
||
resolveDecision(name, { signal }),
|
||
signal,
|
||
)) || { action: "skip" };
|
||
action = decision.action;
|
||
if (decision.applyToRest && action !== "cancel") bulkAction = action;
|
||
}
|
||
if (action === "cancel") return { offerIndices: [], removeIndices: [], aborted: true };
|
||
if (action === "overwrite") { removeIndices.push(idx); offerIndices.push(idx); }
|
||
// 'skip' → omit from both
|
||
}
|
||
return { offerIndices, removeIndices, aborted: false };
|
||
}
|
||
|
||
/**
|
||
* Resolve which overwritten files need their original mode restored after rz
|
||
* re-creates them. rz writes new files with the remote umask, dropping the
|
||
* prior permission bits (issue #1079). Pure: returns absolute `{ path, mode }`
|
||
* entries for the overwritten files, skipping any whose mode wasn't captured
|
||
* and de-duplicating shared basenames.
|
||
*/
|
||
function buildModeRestores(dir, names, removeIndices, modes) {
|
||
const base = String(dir).replace(/\/+$/, "");
|
||
const seen = new Set();
|
||
const restores = [];
|
||
for (const i of removeIndices) {
|
||
const name = names[i];
|
||
const mode = modes && modes[name];
|
||
if (!mode) continue;
|
||
const target = `${base}/${name}`;
|
||
if (seen.has(target)) continue;
|
||
seen.add(target);
|
||
restores.push({ path: target, mode });
|
||
}
|
||
return restores;
|
||
}
|
||
|
||
/**
|
||
* Create a ZMODEM sentry that wraps a session's data stream.
|
||
*
|
||
* All raw data from the PTY / SSH stream / socket should be fed into
|
||
* `consume()`. The sentry transparently calls `onData(str)` for normal
|
||
* terminal output and handles ZMODEM transfers internally.
|
||
*
|
||
* @param {object} opts
|
||
* @param {string} opts.sessionId
|
||
* @param {(data: Buffer) => void} opts.onData
|
||
* Called with raw bytes during normal (non-ZMODEM) operation.
|
||
* The caller is responsible for charset-aware decoding (UTF-8, iconv, etc.).
|
||
* @param {(buf: Buffer) => boolean | void} opts.writeToRemote
|
||
* Write raw bytes back to the remote side (PTY / SSH stream / socket).
|
||
* Prefer returning the underlying stream.write() boolean so uploads can
|
||
* honor backpressure.
|
||
* @param {((opts?: { signal?: AbortSignal }) => Promise<void> | void)} [opts.waitForTransportDrain]
|
||
* Optional. When writeToRemote returns false, the upload loop awaits this
|
||
* before sending more file data (typically waitForWritableDrain(stream)).
|
||
* Callers should forward `opts.signal` so cancel can abort a blocked drain.
|
||
* @param {() => import('electron').WebContents | null} opts.getWebContents
|
||
* Returns the Electron WebContents for sending progress IPC events.
|
||
* @param {string} [opts.label]
|
||
* Human-readable label for log messages (e.g. "Local", "SSH").
|
||
* @returns {ZmodemSentryWrapper}
|
||
*/
|
||
function createZmodemSentry(opts) {
|
||
const {
|
||
sessionId,
|
||
onData,
|
||
writeToRemote,
|
||
getWebContents,
|
||
interruptRemote,
|
||
label = "Session",
|
||
} = opts;
|
||
|
||
let active = false;
|
||
let currentZSession = null;
|
||
let _needsDrain = false;
|
||
let _sawUploadBackpressure = false;
|
||
let transferAbortController = null;
|
||
const pendingEchoes = [];
|
||
let pendingTerminalSuppression = null;
|
||
let cancelInterruptTimer = null;
|
||
let ignoreDetectionUntil = 0;
|
||
// After aborting, suppress incoming data briefly so residual ZMODEM
|
||
// protocol bytes from the remote don't flood the terminal as garbage.
|
||
let cooldownUntil = 0;
|
||
/** Drag-drop upload queued before auto-triggering rz on the PTY. */
|
||
let dragDropUpload = null;
|
||
let dragDropStartTimer = null;
|
||
const COOLDOWN_MS = 2000;
|
||
const ECHO_TTL_MS = 1500;
|
||
const ECHO_MAX_BYTES = 256;
|
||
const dragDropStartTimeoutMs = Number.isFinite(opts.dragDropStartTimeoutMs)
|
||
? Math.max(0, opts.dragDropStartTimeoutMs)
|
||
: 15000;
|
||
|
||
function prunePendingEchoes(now = Date.now()) {
|
||
while (pendingEchoes.length && pendingEchoes[0].expiresAt <= now) {
|
||
pendingEchoes.shift();
|
||
}
|
||
}
|
||
|
||
function rememberOutgoingEcho(octets) {
|
||
if (!octets?.length || octets.length > ECHO_MAX_BYTES) return;
|
||
const buf = Buffer.from(octets);
|
||
prunePendingEchoes();
|
||
pendingEchoes.push({
|
||
buf,
|
||
expiresAt: Date.now() + ECHO_TTL_MS,
|
||
});
|
||
}
|
||
|
||
function stripEchoedOutgoingData(data) {
|
||
if (!pendingEchoes.length) return data;
|
||
|
||
prunePendingEchoes();
|
||
|
||
let buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||
let mutated = false;
|
||
|
||
while (pendingEchoes.length && buf.length) {
|
||
const nextEcho = pendingEchoes[0].buf;
|
||
if (buf.length < nextEcho.length) break;
|
||
if (!buf.subarray(0, nextEcho.length).equals(nextEcho)) break;
|
||
|
||
mutated = true;
|
||
buf = buf.subarray(nextEcho.length);
|
||
pendingEchoes.shift();
|
||
}
|
||
|
||
return mutated ? buf : data;
|
||
}
|
||
|
||
function stripPendingTerminalSuppression(data) {
|
||
if (!pendingTerminalSuppression?.length) return data;
|
||
|
||
let buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||
const fullMatchAt = buf.indexOf(pendingTerminalSuppression);
|
||
if (fullMatchAt !== -1) {
|
||
buf = Buffer.concat([
|
||
buf.subarray(0, fullMatchAt),
|
||
buf.subarray(fullMatchAt + pendingTerminalSuppression.length),
|
||
]);
|
||
pendingTerminalSuppression = null;
|
||
return buf;
|
||
}
|
||
|
||
const maxMatch = Math.min(pendingTerminalSuppression.length, buf.length);
|
||
let matchLen = 0;
|
||
while (matchLen < maxMatch && buf[matchLen] === pendingTerminalSuppression[matchLen]) {
|
||
matchLen += 1;
|
||
}
|
||
|
||
if (!matchLen) return buf;
|
||
|
||
buf = buf.subarray(matchLen);
|
||
pendingTerminalSuppression = matchLen === pendingTerminalSuppression.length
|
||
? null
|
||
: pendingTerminalSuppression.subarray(matchLen);
|
||
|
||
return buf;
|
||
}
|
||
|
||
function stripVisibleZmodemHeaders(data) {
|
||
let buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||
let searchFrom = 0;
|
||
|
||
while (searchFrom < buf.length) {
|
||
const prefixAt = buf.indexOf(Buffer.from([0x2a, 0x2a, 0x18, 0x42]), searchFrom);
|
||
if (prefixAt === -1) break;
|
||
|
||
const minHeaderLength = 20;
|
||
if (buf.length - prefixAt < minHeaderLength) break;
|
||
|
||
let isHexHeader = true;
|
||
for (let i = 0; i < 14; i += 1) {
|
||
const byte = buf[prefixAt + 4 + i];
|
||
const isHexDigit =
|
||
(byte >= 0x30 && byte <= 0x39) ||
|
||
(byte >= 0x41 && byte <= 0x46) ||
|
||
(byte >= 0x61 && byte <= 0x66);
|
||
if (!isHexDigit) {
|
||
isHexHeader = false;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!isHexHeader) {
|
||
searchFrom = prefixAt + 1;
|
||
continue;
|
||
}
|
||
|
||
let headerLength = 18;
|
||
if (buf[prefixAt + 18] === 0x0d && buf[prefixAt + 19] === 0x0a) {
|
||
headerLength = 20;
|
||
if (buf[prefixAt + 20] === 0x11) {
|
||
headerLength = 21;
|
||
}
|
||
}
|
||
|
||
buf = Buffer.concat([
|
||
buf.subarray(0, prefixAt),
|
||
buf.subarray(prefixAt + headerLength),
|
||
]);
|
||
searchFrom = prefixAt;
|
||
}
|
||
|
||
return buf;
|
||
}
|
||
|
||
function looksLikeResidualZmodemData(data) {
|
||
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||
if (!buf.length) return true;
|
||
|
||
for (const byte of buf) {
|
||
const isResidualControl =
|
||
byte === 0x18 || // CAN / ZDLE
|
||
byte === 0x08 || // backspace from abort sequence
|
||
byte === 0x11 || // XON
|
||
byte === 0x13 || // XOFF
|
||
byte === 0x0d ||
|
||
byte === 0x0a;
|
||
if (isResidualControl) continue;
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
function sendExtraAbortBytes() {
|
||
try {
|
||
writeToRemote(Buffer.from([0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18]));
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
function cleanupDragDropTempFiles(upload) {
|
||
if (!upload?.tempPaths?.length) return;
|
||
for (const tempPath of upload.tempPaths) {
|
||
try {
|
||
fs.unlinkSync(tempPath);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
}
|
||
|
||
function clearDragDropUpload() {
|
||
clearDragDropStartTimer();
|
||
if (dragDropUpload) {
|
||
cleanupDragDropTempFiles(dragDropUpload);
|
||
dragDropUpload = null;
|
||
}
|
||
}
|
||
|
||
function takeDragDropUpload() {
|
||
clearDragDropStartTimer();
|
||
const upload = dragDropUpload;
|
||
dragDropUpload = null;
|
||
return upload;
|
||
}
|
||
|
||
function clearDragDropStartTimer() {
|
||
if (dragDropStartTimer) {
|
||
clearTimeout(dragDropStartTimer);
|
||
dragDropStartTimer = null;
|
||
}
|
||
}
|
||
|
||
function scheduleRemoteInterruptAfterCancel(transferRole) {
|
||
if (cancelInterruptTimer) {
|
||
clearTimeout(cancelInterruptTimer);
|
||
cancelInterruptTimer = null;
|
||
}
|
||
|
||
if (transferRole !== "send") return;
|
||
ignoreDetectionUntil = Date.now() + 300;
|
||
|
||
try { interruptRemote?.(); } catch { /* ignore */ }
|
||
|
||
// Some rz builds (notably Debian's lrzsz) can stay attached to the tty
|
||
// after a protocol cancel. Follow up with Ctrl+C so the remote shell
|
||
// reliably regains control. If rz is already gone, this just refreshes
|
||
// the prompt like a normal interactive interrupt.
|
||
cancelInterruptTimer = setTimeout(() => {
|
||
cancelInterruptTimer = null;
|
||
try { interruptRemote?.(); } catch { /* ignore */ }
|
||
try { writeToRemote(Buffer.from("\x03")); } catch { /* ignore */ }
|
||
}, 120);
|
||
}
|
||
|
||
function interruptPendingDragDropCommand() {
|
||
ignoreDetectionUntil = Date.now() + 1000;
|
||
sendExtraAbortBytes();
|
||
try { interruptRemote?.(); } catch { /* ignore */ }
|
||
|
||
if (cancelInterruptTimer) {
|
||
clearTimeout(cancelInterruptTimer);
|
||
cancelInterruptTimer = null;
|
||
}
|
||
cancelInterruptTimer = setTimeout(() => {
|
||
cancelInterruptTimer = null;
|
||
try { interruptRemote?.(); } catch { /* ignore */ }
|
||
try { writeToRemote(Buffer.from("\x03")); } catch { /* ignore */ }
|
||
}, 120);
|
||
}
|
||
|
||
function scheduleDragDropStartTimeout() {
|
||
clearDragDropStartTimer();
|
||
if (!dragDropStartTimeoutMs) return;
|
||
dragDropStartTimer = setTimeout(() => {
|
||
dragDropStartTimer = null;
|
||
if (!dragDropUpload || active) return;
|
||
console.warn(`[ZMODEM][${label}] Drag-drop upload did not start before timeout; cancelling pending upload`);
|
||
interruptPendingDragDropCommand();
|
||
clearDragDropUpload();
|
||
safeSend(getWebContents(), "netcatty:zmodem:error", {
|
||
sessionId,
|
||
error: "ZMODEM drag-drop upload did not start",
|
||
});
|
||
}, dragDropStartTimeoutMs);
|
||
}
|
||
|
||
function isIgnorableSendKeepaliveError(errMsg) {
|
||
return Boolean(
|
||
active &&
|
||
currentZSession?.type === "send" &&
|
||
!currentZSession?._sending_file &&
|
||
errMsg.includes("Unhandled header: ZRINIT")
|
||
);
|
||
}
|
||
|
||
function isIgnorableSendResumePingError(errMsg) {
|
||
return Boolean(
|
||
active &&
|
||
currentZSession?.type === "send" &&
|
||
!currentZSession?._sending_file &&
|
||
currentZSession?._next_header_handler?.ZRINIT &&
|
||
errMsg.includes("Unhandled header: ZRPOS")
|
||
);
|
||
}
|
||
|
||
/**
|
||
* After an ignorable send-session consume error, the bytes that followed
|
||
* the offending header in the same chunk stay buffered in the session's
|
||
* _input_buffer — e.g. the post-file ZRINIT that rz sends right behind a
|
||
* final ZRPOS ping. Re-feed them so the pending handshake (xfer.end() /
|
||
* close()) resolves instead of stalling until its timeout. Best-effort:
|
||
* anything still unparseable is dropped or picked up by the next consume.
|
||
*/
|
||
function refeedRemainingSessionBytes() {
|
||
const zsession = currentZSession;
|
||
if (
|
||
!zsession ||
|
||
!Array.isArray(zsession._input_buffer) ||
|
||
!zsession._input_buffer.length
|
||
) {
|
||
return;
|
||
}
|
||
const rest = Buffer.from(zsession._input_buffer.splice(0));
|
||
try {
|
||
sentry.consume(rest);
|
||
} catch {
|
||
/* ignore — next regular consume retries whatever is left */
|
||
}
|
||
}
|
||
|
||
|
||
const sentry = new Zmodem.Sentry({
|
||
to_terminal(octets) {
|
||
// Normal data – pass raw bytes to the caller for charset-aware decoding.
|
||
let sanitizedOctets = stripPendingTerminalSuppression(Buffer.from(octets));
|
||
sanitizedOctets = stripVisibleZmodemHeaders(sanitizedOctets);
|
||
if (!sanitizedOctets.length) return;
|
||
onData(sanitizedOctets);
|
||
},
|
||
|
||
sender(octets) {
|
||
// ZMODEM protocol bytes – send raw to remote.
|
||
rememberOutgoingEcho(octets);
|
||
// Zero-copy view for the send fast path's Buffers; number arrays
|
||
// (non-data frames) still go through the Buffer.from() conversion.
|
||
const wireBuf =
|
||
octets instanceof Uint8Array
|
||
? Buffer.from(octets.buffer, octets.byteOffset, octets.byteLength)
|
||
: Buffer.from(octets);
|
||
const ok = writeToRemote(wireBuf);
|
||
// Track backpressure: if stream.write() returned false, the
|
||
// kernel TCP buffer is full. The upload loop should pause.
|
||
if (ok === false) {
|
||
_needsDrain = true;
|
||
_sawUploadBackpressure = true;
|
||
}
|
||
},
|
||
|
||
on_detect(detection) {
|
||
if (active) {
|
||
console.warn(`[ZMODEM][${label}] Detection while transfer active; denying`);
|
||
detection.deny();
|
||
return;
|
||
}
|
||
if (Date.now() < ignoreDetectionUntil) {
|
||
console.log(`[ZMODEM][${label}] Ignoring stray detection during cancel grace window`);
|
||
detection.deny();
|
||
return;
|
||
}
|
||
active = true;
|
||
const zsession = detection.confirm();
|
||
currentZSession = zsession;
|
||
pendingTerminalSuppression = zsession.type === "receive"
|
||
? Buffer.from(Zmodem.Header.build("ZRQINIT").to_hex())
|
||
: zsession._last_ZRINIT?.to_hex
|
||
? Buffer.from(zsession._last_ZRINIT.to_hex())
|
||
: null;
|
||
|
||
const transferType = zsession.type === "send" ? "upload" : "download";
|
||
|
||
console.log(`[ZMODEM][${label}] Detected ${transferType} for session ${sessionId}`);
|
||
|
||
safeSend(getWebContents(), "netcatty:zmodem:detect", {
|
||
sessionId,
|
||
transferType,
|
||
});
|
||
|
||
// Provide a drain helper so the upload loop can pause when the
|
||
// underlying transport's write buffer is full. Prefer a real stream
|
||
// drain (waitForTransportDrain) so large rz uploads do not flood SSH
|
||
// and drop the session before the remote has the full file (#2967).
|
||
const onUploadTimeout = () => {
|
||
ignoreDetectionUntil = Date.now() + 1000;
|
||
cooldownUntil = Date.now() + COOLDOWN_MS;
|
||
};
|
||
transferAbortController = new AbortController();
|
||
const transferSignal = transferAbortController.signal;
|
||
const transferOpts = {
|
||
...opts,
|
||
signal: transferSignal,
|
||
getDragDropUpload: () => dragDropUpload,
|
||
takeDragDropUpload,
|
||
clearDragDropUpload,
|
||
hasUploadBackpressure: () => _sawUploadBackpressure,
|
||
resetUploadBackpressure: () => {
|
||
_sawUploadBackpressure = false;
|
||
},
|
||
onUploadTimeout,
|
||
waitForDrain: createZmodemUploadDrainWaiter({
|
||
getNeedsDrain: () => _needsDrain,
|
||
clearNeedsDrain: () => {
|
||
_needsDrain = false;
|
||
},
|
||
signal: transferSignal,
|
||
waitForTransportDrain: typeof opts.waitForTransportDrain === "function"
|
||
? (drainOpts) => opts.waitForTransportDrain(drainOpts)
|
||
: undefined,
|
||
// Drain timeouts bypass waitForUploadHandshake; recover the same way.
|
||
onUploadTimeout,
|
||
writeToRemote: opts.writeToRemote,
|
||
}),
|
||
};
|
||
handleTransfer(zsession, transferType, transferOpts)
|
||
.then(() => {
|
||
// Only act if this is still the active session (not replaced by a new one)
|
||
if (currentZSession !== zsession) return;
|
||
console.log(`[ZMODEM][${label}] Transfer completed for session ${sessionId}`);
|
||
safeSend(getWebContents(), "netcatty:zmodem:complete", { sessionId });
|
||
})
|
||
.catch((err) => {
|
||
if (currentZSession !== zsession) return;
|
||
// cancel() already reported Transfer cancelled and cleared currentZSession.
|
||
if (isZmodemCancelledError(err)) return;
|
||
console.error(`[ZMODEM][${label}] Transfer error:`, err.message || err);
|
||
try { zsession.abort(); } catch { /* ignore */ }
|
||
safeSend(getWebContents(), "netcatty:zmodem:error", {
|
||
sessionId,
|
||
error: String(err.message || err),
|
||
});
|
||
})
|
||
.finally(() => {
|
||
if (transferAbortController?.signal === transferSignal) {
|
||
transferAbortController = null;
|
||
}
|
||
// Only clear state if this is still the active session
|
||
if (currentZSession === zsession) {
|
||
active = false;
|
||
currentZSession = null;
|
||
}
|
||
});
|
||
},
|
||
|
||
on_retract() {
|
||
// False positive – sentry automatically resumes passthrough.
|
||
},
|
||
});
|
||
|
||
return {
|
||
/**
|
||
* Feed raw bytes from the session into the sentry.
|
||
* @param {Buffer|Uint8Array} data
|
||
*/
|
||
consume(data) {
|
||
// During cooldown after abort, unconditionally suppress all incoming
|
||
// data. sz can stream large amounts of file data that's still in
|
||
// SSH/TCP buffers after we send CAN; checking content doesn't help
|
||
// because the residual data contains arbitrary printable bytes.
|
||
if (cooldownUntil) {
|
||
const now = Date.now();
|
||
if (now < cooldownUntil) {
|
||
// Keep sending CAN in case earlier ones were lost in the flood
|
||
if (now - (cooldownUntil - COOLDOWN_MS) > 200) {
|
||
sendExtraAbortBytes();
|
||
}
|
||
return; // drop everything during cooldown
|
||
}
|
||
cooldownUntil = 0;
|
||
// After cooldown, let this chunk through — it's likely the shell prompt
|
||
}
|
||
|
||
try {
|
||
const sanitizedData = stripEchoedOutgoingData(data);
|
||
if (!sanitizedData.length) return;
|
||
sentry.consume(sanitizedData);
|
||
} catch (err) {
|
||
const errMsg = String(err.message || err);
|
||
console.error(`[ZMODEM][${label}] Sentry consume error:`, errMsg);
|
||
|
||
const wasActive = active;
|
||
|
||
// lrzsz's `rz` may resend ZRINIT while we're waiting for the user
|
||
// to choose files. zmodem.js doesn't model that pre-offer keepalive,
|
||
// but the repeated header is harmless, so ignore it and keep waiting.
|
||
if (isIgnorableSendKeepaliveError(errMsg)) {
|
||
console.log(`[ZMODEM][${label}] Ignoring repeated pre-offer ZRINIT`);
|
||
refeedRemainingSessionBytes();
|
||
return;
|
||
}
|
||
|
||
// Some receivers emit a final ZRPOS ping right before they send the
|
||
// post-file ZRINIT. If that ping is processed a beat late, zmodem.js
|
||
// complains even though the transfer can continue normally.
|
||
// Re-feed buffered bytes (e.g. that ZRINIT) so the pending
|
||
// xfer.end() handshake resolves instead of stalling to its timeout.
|
||
if (isIgnorableSendResumePingError(errMsg)) {
|
||
console.log(`[ZMODEM][${label}] Ignoring late post-file ZRPOS`);
|
||
refeedRemainingSessionBytes();
|
||
return;
|
||
}
|
||
|
||
// ZFIN/OO mismatch: the file transfer completed (ZFIN exchanged)
|
||
// but the shell prompt arrived before the "OO" end marker. This
|
||
// is common over SSH because sz exits and the shell resumes before
|
||
// the "OO" acknowledgement is sent. Treat as successful transfer.
|
||
// Do NOT abort() here — that sends CAN bytes to the remote shell.
|
||
// Instead, manually clean up the sentry's internal session state.
|
||
if (wasActive && errMsg.includes("ZFIN") && errMsg.includes("OO")) {
|
||
console.log(`[ZMODEM][${label}] ZFIN/OO mismatch — treating as success`);
|
||
if (currentZSession) {
|
||
try { currentZSession._on_session_end(); } catch { /* ignore */ }
|
||
}
|
||
active = false;
|
||
currentZSession = null;
|
||
safeSend(getWebContents(), "netcatty:zmodem:complete", { sessionId });
|
||
try { sentry.consume(data); } catch { /* ignore */ }
|
||
return;
|
||
}
|
||
|
||
// For all other errors, abort and send extra CAN sequences to
|
||
// ensure the remote rz/sz process stops transmitting.
|
||
if (currentZSession) {
|
||
try { currentZSession.abort(); } catch { /* ignore */ }
|
||
}
|
||
sendExtraAbortBytes();
|
||
// Follow up with Ctrl+C after a short delay to kill rz/sz on
|
||
// Debian and other systems where it stays attached after CAN.
|
||
setTimeout(() => {
|
||
try { writeToRemote(Buffer.from("\x03")); } catch { /* ignore */ }
|
||
}, 150);
|
||
|
||
// If the upload loop is parked on SSH backpressure, wake it now.
|
||
// The ZMODEM session is already aborted and must not resume on a late
|
||
// drain event from the still-connected transport.
|
||
try { transferAbortController?.abort(); } catch { /* ignore */ }
|
||
transferAbortController = null;
|
||
active = false;
|
||
currentZSession = null;
|
||
// Enter cooldown: discard incoming data briefly while the remote
|
||
// processes our CAN sequence and stops sending ZMODEM frames.
|
||
cooldownUntil = Date.now() + COOLDOWN_MS;
|
||
|
||
if (wasActive) {
|
||
safeSend(getWebContents(), "netcatty:zmodem:error", {
|
||
sessionId,
|
||
error: errMsg,
|
||
});
|
||
}
|
||
}
|
||
},
|
||
|
||
/** Whether a ZMODEM transfer is currently in progress. */
|
||
isActive() {
|
||
return active;
|
||
},
|
||
|
||
/** Cancel the current ZMODEM transfer. */
|
||
cancel(options = {}) {
|
||
if (currentZSession) {
|
||
const transferRole = currentZSession.type;
|
||
console.log(`[ZMODEM][${label}] Cancelling transfer for session ${sessionId}`);
|
||
try { currentZSession.abort(); } catch { /* ignore */ }
|
||
sendExtraAbortBytes();
|
||
active = false;
|
||
currentZSession = null;
|
||
cooldownUntil = Date.now() + COOLDOWN_MS;
|
||
scheduleRemoteInterruptAfterCancel(transferRole);
|
||
// Unblock an SSH upload parked on transport drain so open file
|
||
// descriptors and drag-drop temporary files release promptly.
|
||
try { transferAbortController?.abort(); } catch { /* ignore */ }
|
||
transferAbortController = null;
|
||
safeSend(getWebContents(), "netcatty:zmodem:error", {
|
||
sessionId,
|
||
error: "Transfer cancelled",
|
||
});
|
||
} else if (dragDropUpload && options.interrupt !== false) {
|
||
interruptPendingDragDropCommand();
|
||
}
|
||
clearDragDropUpload();
|
||
},
|
||
|
||
/**
|
||
* Queue files from a terminal drag-drop and auto-trigger rz on the PTY.
|
||
* @param {{ filePaths: string[], remoteNames?: string[], uploadCommand?: string, tempPaths?: string[] }} payload
|
||
*/
|
||
queueDragDropUpload(payload) {
|
||
if (active) {
|
||
throw new Error("ZMODEM transfer already in progress");
|
||
}
|
||
const filePaths = payload?.filePaths;
|
||
if (!Array.isArray(filePaths) || filePaths.length === 0) {
|
||
throw new Error("No files to upload");
|
||
}
|
||
if (dragDropUpload) {
|
||
throw new Error("ZMODEM drag-drop upload already pending");
|
||
}
|
||
|
||
// -y: overwrite same-named remote files (lrzsz protect mode otherwise skips).
|
||
const uploadCommand = payload.uploadCommand || "rz -y\r";
|
||
dragDropUpload = {
|
||
filePaths,
|
||
remoteNames: payload.remoteNames,
|
||
uploadCommand,
|
||
tempPaths: payload.tempPaths || [],
|
||
};
|
||
|
||
const cmdBuf = Buffer.from(uploadCommand, "utf8");
|
||
const pendingEchoCount = pendingEchoes.length;
|
||
try {
|
||
rememberOutgoingEcho(cmdBuf);
|
||
pendingTerminalSuppression = Buffer.from(uploadCommand.replace(/\r$/, ""));
|
||
writeToRemote(cmdBuf);
|
||
scheduleDragDropStartTimeout();
|
||
} catch (err) {
|
||
pendingEchoes.length = pendingEchoCount;
|
||
pendingTerminalSuppression = null;
|
||
clearDragDropUpload();
|
||
throw err;
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shared helpers (module-level, usable from handleUpload / handleDownload)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const UPLOAD_FILE_END_TIMEOUT_MS = 45000;
|
||
const UPLOAD_BACKPRESSURE_FILE_END_TIMEOUT_MS = 120000;
|
||
const UPLOAD_SESSION_CLOSE_TIMEOUT_MS = 15000;
|
||
/** Max wait for a single transport drain after write() returned false. */
|
||
const UPLOAD_DRAIN_TIMEOUT_MS = 60000;
|
||
/** Upload read/send chunk size. */
|
||
// Keep the batch bounded so SSH backpressure is observed before another
|
||
// large group of 8192-byte wire subpackets enters the channel queue.
|
||
const UPLOAD_CHUNK_SIZE = 64 * 1024;
|
||
/** Default interval between non-final upload progress IPC events. */
|
||
const DEFAULT_UPLOAD_PROGRESS_THROTTLE_MS = 100;
|
||
|
||
function resolveTimeoutMs(value, fallback) {
|
||
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
||
}
|
||
|
||
function resolveProgressThrottleMs(value) {
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) && parsed >= 0
|
||
? parsed
|
||
: DEFAULT_UPLOAD_PROGRESS_THROTTLE_MS;
|
||
}
|
||
|
||
/**
|
||
* Race a promise against a timeout. If the promise doesn't settle within
|
||
* `ms`, reject instead of hanging forever. This prevents zmodem.js internal
|
||
* promises (xfer.end, zsession.close) from blocking indefinitely.
|
||
*/
|
||
function withTimeout(promise, ms, message = "ZMODEM handshake timeout") {
|
||
let timer;
|
||
return Promise.race([
|
||
Promise.resolve(promise),
|
||
new Promise((_, reject) => {
|
||
timer = setTimeout(() => {
|
||
const err = new Error(message);
|
||
err.code = "NETCATTY_ZMODEM_TIMEOUT";
|
||
reject(err);
|
||
}, ms);
|
||
}),
|
||
]).finally(() => clearTimeout(timer));
|
||
}
|
||
|
||
function isZmodemTimeoutError(err) {
|
||
return err && err.code === "NETCATTY_ZMODEM_TIMEOUT";
|
||
}
|
||
|
||
function isZmodemCancelledError(err) {
|
||
return err && err.code === "NETCATTY_ZMODEM_CANCELLED";
|
||
}
|
||
|
||
function createZmodemCancelledError() {
|
||
const err = new Error("Transfer cancelled");
|
||
err.code = "NETCATTY_ZMODEM_CANCELLED";
|
||
return err;
|
||
}
|
||
|
||
function throwIfZmodemCancelled(signal) {
|
||
if (signal?.aborted) throw createZmodemCancelledError();
|
||
}
|
||
|
||
/**
|
||
* Wait until a Node writable stream reports it can accept more data.
|
||
* Used after stream.write() returns false so ZMODEM uploads do not flood
|
||
* the SSH/TCP buffer (issue #2967).
|
||
*
|
||
* Resolves immediately when `writableNeedDrain` is already false (drain may
|
||
* have fired between write(false) and this call). Rejects on close/end/error
|
||
* so a dead transport stops the upload loop instead of looking like a successful
|
||
* drain (wrappers that catch write failures and return true would otherwise keep
|
||
* scanning the file until a later handshake timeout). When timeoutMs is greater
|
||
* than zero, rejects after that long without writableLength progress; slow SSH
|
||
* links may take longer overall while a fully stalled peer stays bounded. When
|
||
* `signal` aborts (user cancel), rejects with NETCATTY_ZMODEM_CANCELLED so a
|
||
* blocked drain wait cannot retain the open upload file after cancel.
|
||
*
|
||
* @param {NodeJS.WritableStream | null | undefined} stream
|
||
* @param {{
|
||
* timeoutMs?: number,
|
||
* progressIntervalMs?: number,
|
||
* getProgressValue?: () => unknown,
|
||
* signal?: AbortSignal,
|
||
* }} [opts]
|
||
* @returns {Promise<void>}
|
||
*/
|
||
function waitForWritableDrain(stream, opts = {}) {
|
||
if (!stream || typeof stream.once !== "function") {
|
||
return new Promise((resolve) => setImmediate(resolve));
|
||
}
|
||
if (stream.writableNeedDrain === false) {
|
||
return Promise.resolve();
|
||
}
|
||
|
||
const timeoutMs = resolveTimeoutMs(opts.timeoutMs, UPLOAD_DRAIN_TIMEOUT_MS);
|
||
const signal = opts.signal;
|
||
if (signal?.aborted) {
|
||
return Promise.reject(createZmodemCancelledError());
|
||
}
|
||
|
||
return new Promise((resolve, reject) => {
|
||
let settled = false;
|
||
let timer = null;
|
||
const readProgressValue = typeof opts.getProgressValue === "function"
|
||
? opts.getProgressValue
|
||
: () => Number.isFinite(stream.writableLength) ? Number(stream.writableLength) : null;
|
||
let lastProgressAt = Date.now();
|
||
let lastProgressValue = readProgressValue();
|
||
const onDrain = () => finish();
|
||
const onAbort = () => finish(createZmodemCancelledError());
|
||
const onTransportGone = (cause) => {
|
||
if (cause instanceof Error) {
|
||
finish(cause);
|
||
return;
|
||
}
|
||
const err = new Error("Transport closed during drain wait");
|
||
err.code = "NETCATTY_ZMODEM_TRANSPORT_CLOSED";
|
||
finish(err);
|
||
};
|
||
const finish = (err) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
if (timer) clearTimeout(timer);
|
||
try { stream.off("drain", onDrain); } catch { /* ignore */ }
|
||
try { stream.off("close", onTransportGone); } catch { /* ignore */ }
|
||
try { stream.off("end", onTransportGone); } catch { /* ignore */ }
|
||
try { stream.off("error", onTransportGone); } catch { /* ignore */ }
|
||
if (signal) {
|
||
try { signal.removeEventListener("abort", onAbort); } catch { /* ignore */ }
|
||
}
|
||
if (err) reject(err);
|
||
else resolve();
|
||
};
|
||
|
||
stream.once("drain", onDrain);
|
||
stream.once("close", onTransportGone);
|
||
stream.once("end", onTransportGone);
|
||
stream.once("error", onTransportGone);
|
||
if (signal) {
|
||
signal.addEventListener("abort", onAbort, { once: true });
|
||
}
|
||
|
||
const scheduleTimeoutCheck = () => {
|
||
if (timeoutMs <= 0 || settled) return;
|
||
const configuredInterval = Number(opts.progressIntervalMs);
|
||
const intervalMs = Number.isFinite(configuredInterval) && configuredInterval > 0
|
||
? Math.min(configuredInterval, timeoutMs)
|
||
: timeoutMs;
|
||
const idleMs = Date.now() - lastProgressAt;
|
||
timer = setTimeout(() => {
|
||
const currentProgressValue = readProgressValue();
|
||
if (!Object.is(currentProgressValue, lastProgressValue)) {
|
||
lastProgressAt = Date.now();
|
||
}
|
||
lastProgressValue = currentProgressValue;
|
||
if (Date.now() - lastProgressAt < timeoutMs) {
|
||
scheduleTimeoutCheck();
|
||
return;
|
||
}
|
||
const err = new Error("Transport drain timeout");
|
||
err.code = "NETCATTY_ZMODEM_TIMEOUT";
|
||
finish(err);
|
||
}, Math.max(1, Math.min(intervalMs, timeoutMs - idleMs)));
|
||
};
|
||
scheduleTimeoutCheck();
|
||
|
||
// Drain may have cleared between write(false) and listener attach.
|
||
if (stream.writableNeedDrain === false) finish();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Build the upload-loop drain waiter used by createZmodemSentry.
|
||
* When the transport provides waitForTransportDrain, pause until that
|
||
* promise settles. Otherwise fall back to one event-loop yield.
|
||
*
|
||
* Transport-drain timeouts reject with NETCATTY_ZMODEM_TIMEOUT and never
|
||
* pass through waitForUploadHandshake, so apply the same onUploadTimeout +
|
||
* abortRemoteProcess recovery here before rethrowing (stuck rz after cancel).
|
||
* Transfer cancellation rejects with NETCATTY_ZMODEM_CANCELLED and must not
|
||
* run that timeout recovery (cancel already aborted the remote).
|
||
*
|
||
* @param {{
|
||
* getNeedsDrain: () => boolean,
|
||
* clearNeedsDrain: () => void,
|
||
* waitForTransportDrain?: (opts?: { signal?: AbortSignal }) => Promise<void> | void,
|
||
* signal?: AbortSignal,
|
||
* onUploadTimeout?: () => void,
|
||
* writeToRemote?: (data: any) => void,
|
||
* }} opts
|
||
* @returns {() => Promise<void>}
|
||
*/
|
||
function createZmodemUploadDrainWaiter(opts) {
|
||
return async function waitForDrain() {
|
||
if (opts.signal?.aborted) {
|
||
throw createZmodemCancelledError();
|
||
}
|
||
if (!opts.getNeedsDrain()) return;
|
||
|
||
if (typeof opts.waitForTransportDrain === "function") {
|
||
try {
|
||
const drainOpts = opts.signal ? { signal: opts.signal } : undefined;
|
||
await racePromiseWithAbortSignal(
|
||
opts.waitForTransportDrain(drainOpts),
|
||
opts.signal,
|
||
);
|
||
} catch (err) {
|
||
if (isZmodemTimeoutError(err)) {
|
||
try { opts.onUploadTimeout?.(); } catch { /* ignore */ }
|
||
if (typeof opts.writeToRemote === "function") {
|
||
abortRemoteProcess(opts.writeToRemote);
|
||
}
|
||
}
|
||
throw err;
|
||
} finally {
|
||
opts.clearNeedsDrain();
|
||
}
|
||
return;
|
||
}
|
||
|
||
opts.clearNeedsDrain();
|
||
await racePromiseWithAbortSignal(
|
||
new Promise((resolve) => setImmediate(resolve)),
|
||
opts.signal,
|
||
);
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Resolve when `promise` settles, or reject with NETCATTY_ZMODEM_CANCELLED
|
||
* if `signal` aborts first. Used so cancel unblocks upload drain waits even
|
||
* when a transport helper ignores the AbortSignal.
|
||
*
|
||
* @param {Promise<void> | void} promise
|
||
* @param {AbortSignal | undefined} signal
|
||
* @returns {Promise<void>}
|
||
*/
|
||
function racePromiseWithAbortSignal(promise, signal) {
|
||
if (!signal) return Promise.resolve(promise);
|
||
if (signal.aborted) return Promise.reject(createZmodemCancelledError());
|
||
return new Promise((resolve, reject) => {
|
||
const onAbort = () => reject(createZmodemCancelledError());
|
||
signal.addEventListener("abort", onAbort, { once: true });
|
||
Promise.resolve(promise).then(
|
||
(value) => {
|
||
signal.removeEventListener("abort", onAbort);
|
||
resolve(value);
|
||
},
|
||
(err) => {
|
||
signal.removeEventListener("abort", onAbort);
|
||
reject(err);
|
||
},
|
||
);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Send CAN bytes + delayed Ctrl-C to kill the remote rz/sz process.
|
||
* Used from dialog-cancel paths that run outside the sentry closure.
|
||
*/
|
||
function abortRemoteProcess(writeToRemote) {
|
||
try { writeToRemote(Buffer.from([0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18])); } catch { /* ignore */ }
|
||
setTimeout(() => {
|
||
try { writeToRemote(Buffer.from("\x03")); } catch { /* ignore */ }
|
||
}, 150);
|
||
}
|
||
|
||
function resolveUploadFileEndTimeoutMs(opts) {
|
||
const normalTimeout = resolveTimeoutMs(
|
||
opts.uploadFileEndTimeoutMs,
|
||
UPLOAD_FILE_END_TIMEOUT_MS,
|
||
);
|
||
const slowTimeout = resolveTimeoutMs(
|
||
opts.slowUploadFileEndTimeoutMs,
|
||
UPLOAD_BACKPRESSURE_FILE_END_TIMEOUT_MS,
|
||
);
|
||
|
||
return opts.hasUploadBackpressure?.()
|
||
? Math.max(normalTimeout, slowTimeout)
|
||
: normalTimeout;
|
||
}
|
||
|
||
async function waitForUploadHandshake(promise, ms, message, opts) {
|
||
try {
|
||
return await withTimeout(
|
||
racePromiseWithAbortSignal(promise, opts?.signal),
|
||
ms,
|
||
message,
|
||
);
|
||
} catch (err) {
|
||
if (isZmodemTimeoutError(err)) {
|
||
try { opts.onUploadTimeout?.(); } catch { /* ignore */ }
|
||
abortRemoteProcess(opts.writeToRemote);
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Transfer handlers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function handleTransfer(zsession, transferType, opts) {
|
||
if (transferType === "upload") {
|
||
await handleUpload(zsession, opts);
|
||
} else {
|
||
await handleDownload(zsession, opts);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Upload files to the remote (remote executed `rz`).
|
||
*/
|
||
async function handleUpload(zsession, opts) {
|
||
const { sessionId, getWebContents } = opts;
|
||
const contents = getWebContents();
|
||
const { BrowserWindow, dialog } = getElectron();
|
||
const yieldToIO = () => new Promise((resolve) => setImmediate(resolve));
|
||
const uploadSessionCloseTimeoutMs = resolveTimeoutMs(
|
||
opts.uploadSessionCloseTimeoutMs,
|
||
UPLOAD_SESSION_CLOSE_TIMEOUT_MS,
|
||
);
|
||
|
||
const dragDrop = opts.takeDragDropUpload?.() ?? opts.getDragDropUpload?.();
|
||
let filePaths;
|
||
let allNames;
|
||
let dragDropTempPaths = [];
|
||
|
||
if (dragDrop?.filePaths?.length) {
|
||
filePaths = dragDrop.filePaths;
|
||
allNames = Array.isArray(dragDrop.remoteNames) && dragDrop.remoteNames.length === filePaths.length
|
||
? dragDrop.remoteNames
|
||
: filePaths.map((fp) => path.basename(fp));
|
||
dragDropTempPaths = dragDrop.tempPaths || [];
|
||
} else {
|
||
const result = opts.selectUploadFiles
|
||
? await opts.selectUploadFiles({ sessionId, contents })
|
||
: await (async () => {
|
||
const win = contents ? BrowserWindow.fromWebContents(contents) : null;
|
||
return dialog.showOpenDialog(win || undefined, {
|
||
properties: ["openFile", "multiSelections"],
|
||
title: "Select files to upload (ZMODEM)",
|
||
});
|
||
})();
|
||
|
||
if (result.canceled || !result.filePaths.length) {
|
||
try { zsession.abort(); } catch { /* ignore */ }
|
||
abortRemoteProcess(opts.writeToRemote);
|
||
throw new Error("Transfer cancelled");
|
||
}
|
||
|
||
filePaths = result.filePaths;
|
||
allNames = filePaths.map((fp) => path.basename(fp));
|
||
}
|
||
|
||
try {
|
||
throwIfZmodemCancelled(opts.signal);
|
||
const fileStats = filePaths.map((fp) => fs.statSync(fp));
|
||
|
||
// Conflict handling (SSH only — callbacks absent on local/telnet/serial).
|
||
// On probe failure we still offer files; rz -y (drag-drop) or an explicit
|
||
// overwrite decision should replace same-named remotes. If the receiver
|
||
// still ZSKIPs, we fail below instead of reporting a false success (#2863).
|
||
const isDragDropUpload = Boolean(dragDrop?.filePaths?.length);
|
||
let plan = { offerIndices: allNames.map((_, i) => i), removeIndices: [], aborted: false };
|
||
let probeDir = null;
|
||
let probeModes = null;
|
||
// Drag-drop already starts rz with -y, so let the receiver replace files in
|
||
// place. Pre-deleting a conflict would lose the original if the offer or
|
||
// transfer fails before the replacement is committed.
|
||
if (!isDragDropUpload && opts.probeReceiveConflicts && opts.requestOverwriteDecision) {
|
||
try {
|
||
const probe = await racePromiseWithAbortSignal(
|
||
opts.probeReceiveConflicts(allNames, { signal: opts.signal }),
|
||
opts.signal,
|
||
);
|
||
throwIfZmodemCancelled(opts.signal);
|
||
if (probe && probe.dir && Array.isArray(probe.existing) && probe.existing.length > 0) {
|
||
probeDir = probe.dir;
|
||
probeModes = probe.modes || {};
|
||
plan = await buildUploadPlan(
|
||
allNames,
|
||
probe.existing,
|
||
opts.requestOverwriteDecision,
|
||
opts.signal,
|
||
);
|
||
throwIfZmodemCancelled(opts.signal);
|
||
if (plan.aborted) {
|
||
try { zsession.abort(); } catch { /* ignore */ }
|
||
abortRemoteProcess(opts.writeToRemote);
|
||
throw new Error("Transfer cancelled");
|
||
}
|
||
if (plan.removeIndices.length && opts.removeRemoteFiles) {
|
||
throwIfZmodemCancelled(opts.signal);
|
||
const base = probe.dir.replace(/\/+$/, "");
|
||
const targets = [...new Set(plan.removeIndices.map((i) => `${base}/${allNames[i]}`))];
|
||
try {
|
||
await racePromiseWithAbortSignal(
|
||
opts.removeRemoteFiles(targets, { signal: opts.signal }),
|
||
opts.signal,
|
||
);
|
||
throwIfZmodemCancelled(opts.signal);
|
||
} catch (err) {
|
||
if (isZmodemCancelledError(err)) throw err;
|
||
console.warn("[ZMODEM] removeRemoteFiles failed; rz will skip:", err?.message || err);
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
if (
|
||
(err instanceof Error && err.message === "Transfer cancelled") ||
|
||
isZmodemCancelledError(err)
|
||
) throw err;
|
||
console.warn("[ZMODEM] conflict probe failed; proceeding:", err?.message || err);
|
||
}
|
||
}
|
||
|
||
const offers = plan.offerIndices.map((i) => ({
|
||
originalIndex: i,
|
||
filePath: filePaths[i],
|
||
stat: fileStats[i],
|
||
name: allNames[i],
|
||
}));
|
||
const skippedOfferIndices = [];
|
||
|
||
for (let i = 0; i < offers.length; i++) {
|
||
throwIfZmodemCancelled(opts.signal);
|
||
const { originalIndex, filePath, stat, name } = offers[i];
|
||
opts.resetUploadBackpressure?.();
|
||
|
||
safeSend(getWebContents(), "netcatty:zmodem:progress", {
|
||
sessionId,
|
||
filename: name,
|
||
transferred: 0,
|
||
total: stat.size,
|
||
fileIndex: i,
|
||
fileCount: offers.length,
|
||
transferType: "upload",
|
||
});
|
||
|
||
let bytesRemaining = 0;
|
||
for (let j = i; j < offers.length; j++) bytesRemaining += offers[j].stat.size;
|
||
|
||
// The offer handshake is the only upload step without a built-in
|
||
// deadline: if rz dies before answering ZFILE (crash, YMODEM fallback),
|
||
// send_offer() would park this loop forever and block all future
|
||
// ZMODEM transfers. Bound it like xfer.end() / zsession.close().
|
||
throwIfZmodemCancelled(opts.signal);
|
||
const xfer = await waitForUploadHandshake(
|
||
zsession.send_offer({
|
||
name,
|
||
size: stat.size,
|
||
mtime: new Date(stat.mtimeMs),
|
||
files_remaining: offers.length - i,
|
||
bytes_remaining: bytesRemaining,
|
||
}),
|
||
resolveUploadFileEndTimeoutMs(opts),
|
||
`Remote did not respond to the offer for ${name}. The upload was stopped so the terminal can recover.`,
|
||
opts,
|
||
);
|
||
|
||
if (!xfer) {
|
||
// Receiver protected/skipped this file (e.g. rz without -y).
|
||
skippedOfferIndices.push(originalIndex);
|
||
continue;
|
||
}
|
||
|
||
// Read and send in chunks
|
||
const fd = fs.openSync(filePath, "r");
|
||
const buf = Buffer.alloc(UPLOAD_CHUNK_SIZE);
|
||
let sent = 0;
|
||
// Progress IPC is throttled by default for every transport. Pass 0 only
|
||
// when a caller explicitly needs one event per chunk.
|
||
const progressThrottleMs = resolveProgressThrottleMs(opts.progressThrottleMs);
|
||
let lastProgressEmitAt = -Infinity;
|
||
|
||
try {
|
||
while (true) {
|
||
const bytesRead = fs.readSync(fd, buf, 0, UPLOAD_CHUNK_SIZE);
|
||
if (bytesRead === 0) break;
|
||
|
||
// zmodem.js send() is synchronous and triggers writeToRemote via
|
||
// the sentry's sender callback. Yield after each chunk so the
|
||
// event loop can flush buffered writes and process inbound control
|
||
// frames, preventing unbounded memory growth on slow links.
|
||
xfer.send(new Uint8Array(buf.buffer, buf.byteOffset, bytesRead));
|
||
sent += bytesRead;
|
||
|
||
const now = Date.now();
|
||
if (progressThrottleMs === 0 || now - lastProgressEmitAt >= progressThrottleMs) {
|
||
safeSend(getWebContents(), "netcatty:zmodem:progress", {
|
||
sessionId,
|
||
filename: name,
|
||
transferred: sent,
|
||
total: stat.size,
|
||
fileIndex: i,
|
||
fileCount: offers.length,
|
||
transferType: "upload",
|
||
});
|
||
lastProgressEmitAt = now;
|
||
}
|
||
|
||
// Wait for transport to drain if its buffer is full, then yield
|
||
// so inbound ZMODEM control frames can be processed.
|
||
if (opts.waitForDrain) await opts.waitForDrain();
|
||
await yieldToIO();
|
||
}
|
||
// All data written to Node.js buffer — but TCP may still be
|
||
// flushing to the remote. Show "finalizing" state while we
|
||
// wait for the remote to acknowledge.
|
||
safeSend(getWebContents(), "netcatty:zmodem:progress", {
|
||
sessionId,
|
||
filename: name,
|
||
transferred: stat.size,
|
||
total: stat.size,
|
||
fileIndex: i,
|
||
fileCount: offers.length,
|
||
transferType: "upload",
|
||
finalizing: true,
|
||
});
|
||
await waitForUploadHandshake(
|
||
xfer.end(),
|
||
resolveUploadFileEndTimeoutMs(opts),
|
||
`Remote did not confirm receiving ${name}. The upload was stopped so the terminal can recover.`,
|
||
opts,
|
||
);
|
||
} finally {
|
||
fs.closeSync(fd);
|
||
}
|
||
}
|
||
|
||
// rz re-creates overwritten files with the remote umask, dropping their
|
||
// original permission bits. Restore modes for files that landed on disk
|
||
// (including when a later offer is ZSKIP'd and we abort the batch — #1079).
|
||
// Filter by original offer index (not basename) so a duplicate-name ZSKIP
|
||
// does not suppress mode restore for an earlier accepted overwrite.
|
||
async function restoreAcceptedOverwriteModes(skippedIndices) {
|
||
if (!plan.removeIndices.length || !probeDir || !opts.restoreRemoteModes) return;
|
||
const skippedSet = skippedIndices?.length ? new Set(skippedIndices) : null;
|
||
const restoreIndices = skippedSet
|
||
? plan.removeIndices.filter((i) => !skippedSet.has(i))
|
||
: plan.removeIndices;
|
||
if (!restoreIndices.length) return;
|
||
const restores = buildModeRestores(probeDir, allNames, restoreIndices, probeModes);
|
||
if (!restores.length) return;
|
||
try {
|
||
await racePromiseWithAbortSignal(
|
||
opts.restoreRemoteModes(restores, { signal: opts.signal }),
|
||
opts.signal,
|
||
);
|
||
throwIfZmodemCancelled(opts.signal);
|
||
} catch (err) {
|
||
if (isZmodemCancelledError(err)) throw err;
|
||
console.warn("[ZMODEM] restoreRemoteModes failed:", err?.message || err);
|
||
}
|
||
}
|
||
|
||
if (skippedOfferIndices.length > 0) {
|
||
try { zsession.abort(); } catch { /* ignore */ }
|
||
abortRemoteProcess(opts.writeToRemote);
|
||
await restoreAcceptedOverwriteModes(skippedOfferIndices);
|
||
const listed = skippedOfferIndices.map((idx) => allNames[idx]).join(", ");
|
||
throw new Error(
|
||
skippedOfferIndices.length === offers.length
|
||
? `Remote protected existing files and skipped the upload (not overwritten): ${listed}`
|
||
: `Remote skipped some files (not overwritten): ${listed}`,
|
||
);
|
||
}
|
||
|
||
await waitForUploadHandshake(
|
||
zsession.close(),
|
||
uploadSessionCloseTimeoutMs,
|
||
"Remote did not finish the ZMODEM upload session in time. The upload was stopped so the terminal can recover.",
|
||
opts,
|
||
);
|
||
|
||
await restoreAcceptedOverwriteModes();
|
||
|
||
} finally {
|
||
if (dragDropTempPaths.length) {
|
||
for (const tempPath of dragDropTempPaths) {
|
||
try {
|
||
fs.unlinkSync(tempPath);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Download files from the remote (remote executed `sz <file>`).
|
||
*/
|
||
async function handleDownload(zsession, opts) {
|
||
const { sessionId, getWebContents } = opts;
|
||
const contents = getWebContents();
|
||
const { BrowserWindow, dialog } = getElectron();
|
||
|
||
let fileIndex = 0;
|
||
const pendingStreams = [];
|
||
const pendingOffers = [];
|
||
let lastProgressTime = 0;
|
||
let downloadDir = null;
|
||
let rejectSession = () => {};
|
||
|
||
const processOffer = (xfer, reject) => {
|
||
if (!downloadDir) {
|
||
pendingOffers.push(xfer);
|
||
return;
|
||
}
|
||
|
||
const detail = xfer.get_details();
|
||
// Sanitize filename to prevent path traversal attacks
|
||
const rawName = detail.name || `untitled_${Date.now()}`;
|
||
const name = path.basename(rawName);
|
||
const size = detail.size || 0;
|
||
const savePath = path.join(downloadDir, name);
|
||
const currentIndex = fileIndex++;
|
||
|
||
safeSend(getWebContents(), "netcatty:zmodem:progress", {
|
||
sessionId,
|
||
filename: name,
|
||
transferred: 0,
|
||
total: size,
|
||
fileIndex: currentIndex,
|
||
fileCount: -1, // unknown total until session ends
|
||
transferType: "download",
|
||
});
|
||
|
||
// Avoid overwriting existing files — append (1), (2), etc.
|
||
let finalPath = savePath;
|
||
if (fs.existsSync(savePath)) {
|
||
const ext = path.extname(name);
|
||
const base = path.basename(name, ext);
|
||
let n = 1;
|
||
do {
|
||
finalPath = path.join(downloadDir, `${base} (${n})${ext}`);
|
||
n++;
|
||
} while (fs.existsSync(finalPath));
|
||
}
|
||
|
||
const ws = fs.createWriteStream(finalPath);
|
||
let received = 0;
|
||
let writeAborted = false;
|
||
|
||
// Track pending write streams (and paths) for cleanup at session end
|
||
pendingStreams.push({ stream: ws, path: finalPath, completed: false });
|
||
|
||
ws.on("error", (err) => {
|
||
writeAborted = true;
|
||
console.error(`[ZMODEM] Write stream error for ${name}:`, err.message);
|
||
ws.destroy();
|
||
reject(err);
|
||
});
|
||
|
||
xfer.accept({
|
||
on_input(payload) {
|
||
if (writeAborted) return;
|
||
// payload is a number[] on the original zmodem.js pipeline and a
|
||
// Uint8Array on the fast path; take a zero-copy view in the fast
|
||
// case instead of copying every payload byte again.
|
||
const chunk = Array.isArray(payload)
|
||
? Buffer.from(payload)
|
||
: Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength);
|
||
ws.write(chunk);
|
||
received += chunk.length;
|
||
|
||
// Throttle progress IPC to ~10 updates/sec to avoid
|
||
// overwhelming the renderer on fast links.
|
||
const now = Date.now();
|
||
if (now - lastProgressTime >= 100) {
|
||
lastProgressTime = now;
|
||
safeSend(getWebContents(), "netcatty:zmodem:progress", {
|
||
sessionId,
|
||
filename: name,
|
||
transferred: received,
|
||
total: size,
|
||
fileIndex: currentIndex,
|
||
fileCount: -1,
|
||
transferType: "download",
|
||
});
|
||
}
|
||
},
|
||
}).catch((err) => {
|
||
ws.destroy();
|
||
reject(err);
|
||
});
|
||
|
||
xfer.on("complete", () => {
|
||
const entry = pendingStreams.find((e) => e.stream === ws);
|
||
if (entry) entry.completed = true;
|
||
ws.end();
|
||
});
|
||
};
|
||
|
||
const sessionPromise = new Promise((resolve, reject) => {
|
||
rejectSession = reject;
|
||
zsession.on("offer", (xfer) => {
|
||
try {
|
||
processOffer(xfer, reject);
|
||
} catch (err) {
|
||
reject(err);
|
||
}
|
||
});
|
||
|
||
// Wait for all write streams to finish flushing before resolving.
|
||
// If a stream never received end() (e.g. transfer was cancelled),
|
||
// destroy it so the fd is released and finish/close can fire.
|
||
zsession.on("session_end", async () => {
|
||
try {
|
||
await Promise.all(
|
||
pendingStreams.map((entry) => {
|
||
const { stream: s, path: filePath, completed } = entry;
|
||
if (s.writableFinished) {
|
||
// Delete partial files that never completed
|
||
if (!completed) {
|
||
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
|
||
}
|
||
return Promise.resolve();
|
||
}
|
||
if (!s.writableEnded) s.destroy();
|
||
return new Promise((r) => {
|
||
s.on("close", () => {
|
||
// Clean up partial downloads
|
||
if (!completed) {
|
||
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
|
||
}
|
||
r();
|
||
});
|
||
});
|
||
})
|
||
);
|
||
} catch { /* ignore — error handler already called reject */ }
|
||
resolve();
|
||
});
|
||
});
|
||
|
||
// Start the session BEFORE showing the dialog so lrzsz doesn't
|
||
// time out waiting for ZRINIT while the user browses for a folder.
|
||
zsession.start();
|
||
|
||
const result = opts.selectDownloadDirectory
|
||
? await opts.selectDownloadDirectory({ sessionId, contents })
|
||
: await (async () => {
|
||
const win = contents ? BrowserWindow.fromWebContents(contents) : null;
|
||
return dialog.showOpenDialog(win || undefined, {
|
||
properties: ["openDirectory", "createDirectory"],
|
||
title: "Select download directory (ZMODEM)",
|
||
});
|
||
})();
|
||
|
||
if (result.canceled || !result.filePaths.length) {
|
||
try { zsession.abort(); } catch { /* ignore */ }
|
||
abortRemoteProcess(opts.writeToRemote);
|
||
void sessionPromise.catch(() => {});
|
||
throw new Error("Transfer cancelled");
|
||
}
|
||
|
||
downloadDir = result.filePaths[0];
|
||
while (pendingOffers.length) {
|
||
processOffer(pendingOffers.shift(), rejectSession);
|
||
}
|
||
|
||
await sessionPromise;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Utilities
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function safeSend(contents, channel, data) {
|
||
try {
|
||
if (contents && !contents.isDestroyed()) {
|
||
contents.send(channel, data);
|
||
}
|
||
} catch {
|
||
// WebContents may have been destroyed between the check and the send
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
createZmodemSentry,
|
||
buildUploadPlan,
|
||
buildModeRestores,
|
||
handleUpload,
|
||
handleDownload,
|
||
waitForWritableDrain,
|
||
createZmodemUploadDrainWaiter,
|
||
UPLOAD_CHUNK_SIZE,
|
||
UPLOAD_DRAIN_TIMEOUT_MS,
|
||
};
|