[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
670
electron/bridges/ymodemTransfer.cjs
Normal file
670
electron/bridges/ymodemTransfer.cjs
Normal file
@@ -0,0 +1,670 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const YMODEM = Object.freeze({
|
||||
SOH: 0x01,
|
||||
STX: 0x02,
|
||||
EOT: 0x04,
|
||||
ACK: 0x06,
|
||||
NAK: 0x15,
|
||||
CAN: 0x18,
|
||||
CRC16: 0x43,
|
||||
BACKSPACE: 0x08,
|
||||
PACKET_SIZE_128: 128,
|
||||
PACKET_SIZE_1024: 1024,
|
||||
});
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_RETRY_LIMIT = 10;
|
||||
const YMODEM_CANCEL_SEQUENCE = Buffer.from([
|
||||
YMODEM.CAN,
|
||||
YMODEM.CAN,
|
||||
YMODEM.CAN,
|
||||
YMODEM.CAN,
|
||||
YMODEM.CAN,
|
||||
YMODEM.BACKSPACE,
|
||||
YMODEM.BACKSPACE,
|
||||
YMODEM.BACKSPACE,
|
||||
YMODEM.BACKSPACE,
|
||||
YMODEM.BACKSPACE,
|
||||
]);
|
||||
|
||||
class YmodemTransferError extends Error {
|
||||
constructor(message, code = "YMODEM_TRANSFER_ERROR") {
|
||||
super(message);
|
||||
this.name = "YmodemTransferError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function crc16Xmodem(buffer, start = 0, length = buffer.length - start) {
|
||||
let crc = 0;
|
||||
for (let i = start; i < start + length; i += 1) {
|
||||
let code = ((crc >>> 8) ^ buffer[i]) & 0xff;
|
||||
code ^= code >>> 4;
|
||||
crc = ((crc << 8) ^ (code << 12) ^ (code << 5) ^ code) & 0xffff;
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
function createPacket(header, blockNumber, payload) {
|
||||
const size = payload.length;
|
||||
const packet = Buffer.alloc(3 + size + 2, 0x00);
|
||||
packet[0] = header;
|
||||
packet[1] = blockNumber & 0xff;
|
||||
packet[2] = 0xff - packet[1];
|
||||
payload.copy(packet, 3);
|
||||
packet.writeUInt16BE(crc16Xmodem(packet, 3, size), packet.length - 2);
|
||||
return packet;
|
||||
}
|
||||
|
||||
function createYmodemFileInfoPacket({ filename, size, mtime = 0, mode = 0o100644 }) {
|
||||
const baseName = path.basename(filename || "file");
|
||||
const metadata = `${baseName}\0${size} ${Math.trunc(mtime).toString(8)} ${mode.toString(8)}`;
|
||||
const metadataBytes = Buffer.from(metadata, "utf8");
|
||||
const packetSize = metadataBytes.length >= YMODEM.PACKET_SIZE_128
|
||||
? YMODEM.PACKET_SIZE_1024
|
||||
: YMODEM.PACKET_SIZE_128;
|
||||
const payload = Buffer.alloc(packetSize, 0x00);
|
||||
metadataBytes.copy(payload, 0, 0, Math.min(metadataBytes.length, packetSize));
|
||||
return createPacket(
|
||||
packetSize === YMODEM.PACKET_SIZE_1024 ? YMODEM.STX : YMODEM.SOH,
|
||||
0,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
function createYmodemDataPackets(buffer) {
|
||||
if (!Buffer.isBuffer(buffer)) {
|
||||
buffer = Buffer.from(buffer);
|
||||
}
|
||||
const packets = [];
|
||||
let blockNumber = 1;
|
||||
for (let offset = 0; offset < buffer.length; offset += YMODEM.PACKET_SIZE_1024) {
|
||||
const payload = Buffer.alloc(YMODEM.PACKET_SIZE_1024, 0x1a);
|
||||
buffer.copy(payload, 0, offset, Math.min(offset + YMODEM.PACKET_SIZE_1024, buffer.length));
|
||||
packets.push(createPacket(YMODEM.STX, blockNumber, payload));
|
||||
blockNumber = (blockNumber + 1) & 0xff;
|
||||
}
|
||||
return packets;
|
||||
}
|
||||
|
||||
function createYmodemEndSessionPacket() {
|
||||
return createPacket(YMODEM.SOH, 0, Buffer.alloc(YMODEM.PACKET_SIZE_128, 0x00));
|
||||
}
|
||||
|
||||
function sanitizeYmodemFilename(filename) {
|
||||
const normalized = String(filename || "").replace(/\\/g, "/");
|
||||
const baseName = path.basename(normalized).replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").trim();
|
||||
if (!baseName || baseName === "." || baseName === "..") {
|
||||
return "ymodem-received.bin";
|
||||
}
|
||||
return baseName;
|
||||
}
|
||||
|
||||
function parseYmodemFileInfoPayload(payload) {
|
||||
const separatorIndex = payload.indexOf(0x00);
|
||||
const rawName = (separatorIndex >= 0 ? payload.subarray(0, separatorIndex) : payload).toString("utf8");
|
||||
if (!rawName) return null;
|
||||
|
||||
const metadata = separatorIndex >= 0
|
||||
? payload.subarray(separatorIndex + 1).toString("ascii").replace(/\0.*$/u, "").trim()
|
||||
: "";
|
||||
const [sizeText] = metadata.split(/\s+/u);
|
||||
if (!/^\d+$/u.test(sizeText || "")) {
|
||||
throw new YmodemTransferError("YMODEM file header has an invalid size", "YMODEM_INVALID_SIZE");
|
||||
}
|
||||
const parsedSize = Number.parseInt(sizeText, 10);
|
||||
|
||||
return {
|
||||
fileName: sanitizeYmodemFilename(rawName),
|
||||
totalBytes: Number.isFinite(parsedSize) && parsedSize >= 0 ? parsedSize : 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveUniqueDestinationPath(destinationDir, fileName) {
|
||||
const parsed = path.parse(fileName);
|
||||
for (let attempt = 0; attempt < 10_000; attempt += 1) {
|
||||
const candidateName = attempt === 0
|
||||
? fileName
|
||||
: `${parsed.name} (${attempt})${parsed.ext}`;
|
||||
const candidatePath = path.join(destinationDir, candidateName);
|
||||
try {
|
||||
await fs.promises.access(candidatePath);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return candidatePath;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new YmodemTransferError("Could not choose a destination file name", "YMODEM_DESTINATION_EXISTS");
|
||||
}
|
||||
|
||||
function writeAndDrain(serialPort, buffer) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const done = (err) => {
|
||||
if (settled) return;
|
||||
if (err) {
|
||||
settled = true;
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
if (typeof serialPort.drain !== "function") {
|
||||
settled = true;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
serialPort.drain((drainErr) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
drainErr ? reject(drainErr) : resolve();
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
serialPort.write(buffer, done);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createSerialByteReader(serialPort, abortSignal) {
|
||||
const queue = [];
|
||||
const waiters = [];
|
||||
|
||||
const rejectAll = (error) => {
|
||||
while (waiters.length) {
|
||||
const waiter = waiters.shift();
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const onData = (chunk) => {
|
||||
for (const byte of Buffer.from(chunk)) {
|
||||
if (waiters.length) {
|
||||
const waiter = waiters.shift();
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.resolve(byte);
|
||||
} else {
|
||||
queue.push(byte);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
rejectAll(new YmodemTransferError("YMODEM transfer cancelled", "YMODEM_CANCELLED"));
|
||||
};
|
||||
const onError = (error) => {
|
||||
rejectAll(error instanceof Error ? error : new Error(String(error)));
|
||||
};
|
||||
const onClose = () => {
|
||||
rejectAll(new YmodemTransferError("Serial port closed during YMODEM transfer", "YMODEM_SERIAL_CLOSED"));
|
||||
};
|
||||
|
||||
serialPort.on("data", onData);
|
||||
serialPort.on?.("error", onError);
|
||||
serialPort.on?.("close", onClose);
|
||||
abortSignal?.addEventListener?.("abort", onAbort, { once: true });
|
||||
|
||||
return {
|
||||
unreadByte(byte) {
|
||||
queue.unshift(byte);
|
||||
},
|
||||
readByte(timeoutMs) {
|
||||
if (abortSignal?.aborted) {
|
||||
return Promise.reject(new YmodemTransferError("YMODEM transfer cancelled", "YMODEM_CANCELLED"));
|
||||
}
|
||||
if (queue.length) {
|
||||
return Promise.resolve(queue.shift());
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const index = waiters.findIndex((waiter) => waiter.timer === timer);
|
||||
if (index >= 0) waiters.splice(index, 1);
|
||||
reject(new YmodemTransferError("Timed out waiting for YMODEM receiver", "YMODEM_TIMEOUT"));
|
||||
}, timeoutMs);
|
||||
waiters.push({ resolve, reject, timer });
|
||||
});
|
||||
},
|
||||
cleanup() {
|
||||
serialPort.off("data", onData);
|
||||
serialPort.off?.("error", onError);
|
||||
serialPort.off?.("close", onClose);
|
||||
abortSignal?.removeEventListener?.("abort", onAbort);
|
||||
rejectAll(new YmodemTransferError("YMODEM reader closed", "YMODEM_CLOSED"));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sendYmodemCancel(serialPort) {
|
||||
if (!serialPort || typeof serialPort.write !== "function") return Promise.resolve();
|
||||
return writeAndDrain(serialPort, YMODEM_CANCEL_SEQUENCE).catch(() => {});
|
||||
}
|
||||
|
||||
async function readExpected(reader, expected, timeoutMs, label) {
|
||||
for (;;) {
|
||||
const byte = await reader.readByte(timeoutMs);
|
||||
if (byte === YMODEM.CAN) {
|
||||
const next = await reader.readByte(1_000).catch(() => null);
|
||||
if (next === YMODEM.CAN) {
|
||||
throw new YmodemTransferError("YMODEM transfer cancelled by receiver", "YMODEM_REMOTE_CANCELLED");
|
||||
}
|
||||
if (next !== null) {
|
||||
reader.unreadByte?.(next);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (expected.includes(byte)) return byte;
|
||||
if (byte === YMODEM.CRC16 && expected.includes(YMODEM.CRC16)) return byte;
|
||||
if (byte === YMODEM.NAK && expected.includes(YMODEM.NAK)) return byte;
|
||||
if (byte === YMODEM.ACK && expected.includes(YMODEM.ACK)) return byte;
|
||||
if (byte === YMODEM.CRC16 || byte === YMODEM.ACK || byte === YMODEM.NAK) continue;
|
||||
throw new YmodemTransferError(`Unexpected YMODEM byte while waiting for ${label}: 0x${byte.toString(16)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPacketWithRetry({ serialPort, reader, packet, timeoutMs, retryLimit, onProgress }) {
|
||||
for (let attempt = 0; attempt < retryLimit; attempt += 1) {
|
||||
await writeAndDrain(serialPort, packet);
|
||||
onProgress?.();
|
||||
const response = await readExpected(reader, [YMODEM.ACK, YMODEM.NAK], timeoutMs, "packet ACK");
|
||||
if (response === YMODEM.ACK) return;
|
||||
}
|
||||
throw new YmodemTransferError("YMODEM receiver rejected the packet too many times", "YMODEM_RETRY_LIMIT");
|
||||
}
|
||||
|
||||
async function readYmodemFrame(reader, timeoutMs) {
|
||||
const header = await reader.readByte(timeoutMs);
|
||||
if (header === YMODEM.CAN) {
|
||||
const next = await reader.readByte(1_000).catch(() => null);
|
||||
if (next === YMODEM.CAN) {
|
||||
throw new YmodemTransferError("YMODEM transfer cancelled by sender", "YMODEM_REMOTE_CANCELLED");
|
||||
}
|
||||
if (next !== null) {
|
||||
reader.unreadByte?.(next);
|
||||
}
|
||||
return readYmodemFrame(reader, timeoutMs);
|
||||
}
|
||||
if (header === YMODEM.EOT) {
|
||||
return { type: "eot" };
|
||||
}
|
||||
if (header !== YMODEM.SOH && header !== YMODEM.STX) {
|
||||
throw new YmodemTransferError(`Unexpected YMODEM packet header: 0x${header.toString(16)}`);
|
||||
}
|
||||
|
||||
const payloadSize = header === YMODEM.SOH ? YMODEM.PACKET_SIZE_128 : YMODEM.PACKET_SIZE_1024;
|
||||
const blockNumber = await reader.readByte(timeoutMs);
|
||||
const blockComplement = await reader.readByte(timeoutMs);
|
||||
const payload = Buffer.alloc(payloadSize);
|
||||
for (let i = 0; i < payloadSize; i += 1) {
|
||||
payload[i] = await reader.readByte(timeoutMs);
|
||||
}
|
||||
const sentCrc = ((await reader.readByte(timeoutMs)) << 8) | await reader.readByte(timeoutMs);
|
||||
const expectedCrc = crc16Xmodem(payload);
|
||||
const valid = blockComplement === (0xff - blockNumber) && sentCrc === expectedCrc;
|
||||
|
||||
return {
|
||||
type: "packet",
|
||||
blockNumber,
|
||||
payload,
|
||||
valid,
|
||||
};
|
||||
}
|
||||
|
||||
async function readYmodemPacketWithRetry({
|
||||
serialPort,
|
||||
reader,
|
||||
expectedBlockNumber,
|
||||
requestByte,
|
||||
timeoutMs,
|
||||
retryLimit,
|
||||
label,
|
||||
}) {
|
||||
for (let attempt = 0; attempt < retryLimit; attempt += 1) {
|
||||
if (requestByte !== undefined) {
|
||||
await writeAndDrain(serialPort, Buffer.from([requestByte]));
|
||||
}
|
||||
|
||||
let frame;
|
||||
try {
|
||||
frame = await readYmodemFrame(reader, timeoutMs);
|
||||
} catch (error) {
|
||||
if (error?.code === "YMODEM_TIMEOUT" && attempt < retryLimit - 1) {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (frame.type === "eot") {
|
||||
return frame;
|
||||
}
|
||||
if (frame.valid && frame.blockNumber === expectedBlockNumber) {
|
||||
return frame;
|
||||
}
|
||||
if (frame.valid && frame.blockNumber === ((expectedBlockNumber - 1) & 0xff)) {
|
||||
await writeAndDrain(serialPort, Buffer.from([YMODEM.ACK]));
|
||||
continue;
|
||||
}
|
||||
await writeAndDrain(serialPort, Buffer.from([YMODEM.NAK]));
|
||||
}
|
||||
|
||||
throw new YmodemTransferError(`YMODEM sender did not provide a valid ${label}`, "YMODEM_RETRY_LIMIT");
|
||||
}
|
||||
|
||||
async function receiveYmodemFileData({
|
||||
serialPort,
|
||||
reader,
|
||||
fileHandle,
|
||||
totalBytes,
|
||||
timeoutMs,
|
||||
retryLimit,
|
||||
onProgress,
|
||||
}) {
|
||||
let expectedBlockNumber = 1;
|
||||
let writtenBytes = 0;
|
||||
let rejectedPackets = 0;
|
||||
|
||||
for (;;) {
|
||||
let frame;
|
||||
try {
|
||||
frame = await readYmodemFrame(reader, timeoutMs);
|
||||
} catch (error) {
|
||||
if (error?.code === "YMODEM_TIMEOUT" && rejectedPackets < retryLimit) {
|
||||
rejectedPackets += 1;
|
||||
await writeAndDrain(serialPort, Buffer.from([YMODEM.NAK]));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (frame.type === "eot") {
|
||||
await writeAndDrain(serialPort, Buffer.from([YMODEM.NAK]));
|
||||
const secondEot = await readYmodemFrame(reader, timeoutMs);
|
||||
if (secondEot.type !== "eot") {
|
||||
throw new YmodemTransferError("YMODEM sender did not confirm end of file", "YMODEM_EOT_EXPECTED");
|
||||
}
|
||||
await writeAndDrain(serialPort, Buffer.from([YMODEM.ACK]));
|
||||
if (writtenBytes !== totalBytes) {
|
||||
throw new YmodemTransferError(
|
||||
`YMODEM received incomplete file (${writtenBytes}/${totalBytes} bytes)`,
|
||||
"YMODEM_INCOMPLETE_FILE",
|
||||
);
|
||||
}
|
||||
return writtenBytes;
|
||||
}
|
||||
|
||||
if (!frame.valid) {
|
||||
rejectedPackets += 1;
|
||||
if (rejectedPackets > retryLimit) {
|
||||
throw new YmodemTransferError("YMODEM sender sent too many invalid packets", "YMODEM_RETRY_LIMIT");
|
||||
}
|
||||
await writeAndDrain(serialPort, Buffer.from([YMODEM.NAK]));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (frame.blockNumber === ((expectedBlockNumber - 1) & 0xff)) {
|
||||
await writeAndDrain(serialPort, Buffer.from([YMODEM.ACK]));
|
||||
continue;
|
||||
}
|
||||
if (frame.blockNumber !== expectedBlockNumber) {
|
||||
rejectedPackets += 1;
|
||||
if (rejectedPackets > retryLimit) {
|
||||
throw new YmodemTransferError("YMODEM sender sent packets out of order", "YMODEM_RETRY_LIMIT");
|
||||
}
|
||||
await writeAndDrain(serialPort, Buffer.from([YMODEM.NAK]));
|
||||
continue;
|
||||
}
|
||||
|
||||
const remainingBytes = Math.max(0, totalBytes - writtenBytes);
|
||||
const bytesToWrite = Math.min(frame.payload.length, remainingBytes);
|
||||
if (bytesToWrite > 0) {
|
||||
await fileHandle.write(frame.payload, 0, bytesToWrite);
|
||||
writtenBytes += bytesToWrite;
|
||||
}
|
||||
|
||||
rejectedPackets = 0;
|
||||
expectedBlockNumber = (expectedBlockNumber + 1) & 0xff;
|
||||
await writeAndDrain(serialPort, Buffer.from([YMODEM.ACK]));
|
||||
onProgress?.({ transferredBytes: writtenBytes, totalBytes, stage: "data" });
|
||||
}
|
||||
}
|
||||
|
||||
async function receiveYmodemFiles(serialPort, {
|
||||
destinationDir,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
retryLimit = DEFAULT_RETRY_LIMIT,
|
||||
abortSignal,
|
||||
onProgress,
|
||||
} = {}) {
|
||||
if (!serialPort) {
|
||||
throw new YmodemTransferError("Serial session is not available", "YMODEM_NO_SERIAL");
|
||||
}
|
||||
if (!destinationDir || typeof destinationDir !== "string") {
|
||||
throw new YmodemTransferError("No destination directory selected", "YMODEM_NO_DESTINATION");
|
||||
}
|
||||
|
||||
const resolvedDestinationDir = path.resolve(destinationDir);
|
||||
let destinationStat;
|
||||
try {
|
||||
destinationStat = await fs.promises.stat(resolvedDestinationDir);
|
||||
} catch (error) {
|
||||
throw new YmodemTransferError("Selected destination is not a directory", "YMODEM_DESTINATION_NOT_DIRECTORY");
|
||||
}
|
||||
if (!destinationStat.isDirectory()) {
|
||||
throw new YmodemTransferError("Selected destination is not a directory", "YMODEM_DESTINATION_NOT_DIRECTORY");
|
||||
}
|
||||
|
||||
const reader = createSerialByteReader(serialPort, abortSignal);
|
||||
const files = [];
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const headerFrame = await readYmodemPacketWithRetry({
|
||||
serialPort,
|
||||
reader,
|
||||
expectedBlockNumber: 0,
|
||||
requestByte: YMODEM.CRC16,
|
||||
timeoutMs,
|
||||
retryLimit,
|
||||
label: "file header",
|
||||
});
|
||||
|
||||
if (headerFrame.type === "eot") {
|
||||
await writeAndDrain(serialPort, Buffer.from([YMODEM.ACK]));
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileInfo = parseYmodemFileInfoPayload(headerFrame.payload);
|
||||
await writeAndDrain(serialPort, Buffer.from([YMODEM.ACK]));
|
||||
if (!fileInfo) {
|
||||
break;
|
||||
}
|
||||
|
||||
await writeAndDrain(serialPort, Buffer.from([YMODEM.CRC16]));
|
||||
onProgress?.({ transferredBytes: 0, totalBytes: fileInfo.totalBytes, stage: "header" });
|
||||
|
||||
const filePath = await resolveUniqueDestinationPath(resolvedDestinationDir, fileInfo.fileName);
|
||||
let fileHandle;
|
||||
let closeNeeded = false;
|
||||
let createdFile = false;
|
||||
try {
|
||||
fileHandle = await fs.promises.open(filePath, "wx");
|
||||
closeNeeded = true;
|
||||
createdFile = true;
|
||||
const writtenBytes = await receiveYmodemFileData({
|
||||
serialPort,
|
||||
reader,
|
||||
fileHandle,
|
||||
totalBytes: fileInfo.totalBytes,
|
||||
timeoutMs,
|
||||
retryLimit,
|
||||
onProgress,
|
||||
});
|
||||
await fileHandle.close();
|
||||
closeNeeded = false;
|
||||
|
||||
files.push({
|
||||
fileName: path.basename(filePath),
|
||||
filePath,
|
||||
totalBytes: fileInfo.totalBytes,
|
||||
writtenBytes,
|
||||
});
|
||||
} catch (error) {
|
||||
if (closeNeeded) {
|
||||
await fileHandle.close().catch(() => {});
|
||||
}
|
||||
if (createdFile) {
|
||||
await fs.promises.rm(filePath, { force: true }).catch(() => {});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const totalBytes = files.reduce((sum, file) => sum + file.totalBytes, 0);
|
||||
const writtenBytes = files.reduce((sum, file) => sum + file.writtenBytes, 0);
|
||||
return {
|
||||
files,
|
||||
fileCount: files.length,
|
||||
totalBytes,
|
||||
writtenBytes,
|
||||
fileName: files[0]?.fileName,
|
||||
filePath: files[0]?.filePath,
|
||||
};
|
||||
} finally {
|
||||
reader.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function sendYmodemBuffer(serialPort, {
|
||||
filename,
|
||||
buffer,
|
||||
mtime = 0,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
retryLimit = DEFAULT_RETRY_LIMIT,
|
||||
abortSignal,
|
||||
onProgress,
|
||||
} = {}) {
|
||||
if (!serialPort) {
|
||||
throw new YmodemTransferError("Serial session is not available", "YMODEM_NO_SERIAL");
|
||||
}
|
||||
const fileBuffer = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || []);
|
||||
const dataPackets = createYmodemDataPackets(fileBuffer);
|
||||
const packets = [
|
||||
createYmodemFileInfoPacket({ filename, size: fileBuffer.length, mtime }),
|
||||
...dataPackets,
|
||||
];
|
||||
const reader = createSerialByteReader(serialPort, abortSignal);
|
||||
let packetsSent = 0;
|
||||
|
||||
try {
|
||||
await readExpected(reader, [YMODEM.CRC16], timeoutMs, "initial receiver request");
|
||||
await sendPacketWithRetry({
|
||||
serialPort,
|
||||
reader,
|
||||
packet: packets[0],
|
||||
timeoutMs,
|
||||
retryLimit,
|
||||
onProgress: () => {
|
||||
packetsSent += 1;
|
||||
onProgress?.({ transferredBytes: 0, totalBytes: fileBuffer.length, stage: "header" });
|
||||
},
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < retryLimit; attempt += 1) {
|
||||
const response = await readExpected(reader, [YMODEM.CRC16, YMODEM.NAK], timeoutMs, "data receiver request");
|
||||
if (response === YMODEM.CRC16) break;
|
||||
await sendPacketWithRetry({
|
||||
serialPort,
|
||||
reader,
|
||||
packet: packets[0],
|
||||
timeoutMs,
|
||||
retryLimit,
|
||||
onProgress: () => {
|
||||
packetsSent += 1;
|
||||
onProgress?.({ transferredBytes: 0, totalBytes: fileBuffer.length, stage: "header" });
|
||||
},
|
||||
});
|
||||
if (attempt === retryLimit - 1) {
|
||||
throw new YmodemTransferError("YMODEM receiver did not request data after header", "YMODEM_RETRY_LIMIT");
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 1; i < packets.length; i += 1) {
|
||||
await sendPacketWithRetry({
|
||||
serialPort,
|
||||
reader,
|
||||
packet: packets[i],
|
||||
timeoutMs,
|
||||
retryLimit,
|
||||
onProgress: () => {
|
||||
packetsSent += 1;
|
||||
onProgress?.({
|
||||
transferredBytes: Math.min(i * YMODEM.PACKET_SIZE_1024, fileBuffer.length),
|
||||
totalBytes: fileBuffer.length,
|
||||
stage: "data",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await writeAndDrain(serialPort, Buffer.from([YMODEM.EOT]));
|
||||
const eotResponse = await readExpected(reader, [YMODEM.NAK, YMODEM.ACK], timeoutMs, "first EOT response");
|
||||
if (eotResponse === YMODEM.NAK) {
|
||||
await writeAndDrain(serialPort, Buffer.from([YMODEM.EOT]));
|
||||
await readExpected(reader, [YMODEM.ACK], timeoutMs, "second EOT ACK");
|
||||
}
|
||||
|
||||
await readExpected(reader, [YMODEM.CRC16], timeoutMs, "end session request");
|
||||
await sendPacketWithRetry({
|
||||
serialPort,
|
||||
reader,
|
||||
packet: createYmodemEndSessionPacket(),
|
||||
timeoutMs,
|
||||
retryLimit,
|
||||
});
|
||||
|
||||
onProgress?.({
|
||||
transferredBytes: fileBuffer.length,
|
||||
totalBytes: fileBuffer.length,
|
||||
stage: "complete",
|
||||
});
|
||||
|
||||
return {
|
||||
fileName: path.basename(filename || "file"),
|
||||
totalBytes: fileBuffer.length,
|
||||
writtenBytes: fileBuffer.length,
|
||||
packetsSent,
|
||||
};
|
||||
} finally {
|
||||
reader.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function sendYmodemFile(serialPort, filePath, options = {}) {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (!stat.isFile()) {
|
||||
throw new YmodemTransferError("Selected path is not a file", "YMODEM_NOT_FILE");
|
||||
}
|
||||
const buffer = await fs.promises.readFile(filePath);
|
||||
return sendYmodemBuffer(serialPort, {
|
||||
...options,
|
||||
filename: filePath,
|
||||
buffer,
|
||||
mtime: Math.floor(stat.mtimeMs / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
YMODEM,
|
||||
YmodemTransferError,
|
||||
crc16Xmodem,
|
||||
createYmodemFileInfoPacket,
|
||||
createYmodemDataPackets,
|
||||
createYmodemEndSessionPacket,
|
||||
receiveYmodemFiles,
|
||||
sendYmodemCancel,
|
||||
sendYmodemBuffer,
|
||||
sendYmodemFile,
|
||||
};
|
||||
Reference in New Issue
Block a user