Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf9f84767c | ||
|
|
3a862cbd0c | ||
|
|
6af2a99680 | ||
|
|
b3d37d134a | ||
|
|
a9e561ee51 | ||
|
|
e808b1709e | ||
|
|
d75b58e4d8 | ||
|
|
e2430cdcab | ||
|
|
8e6ac8de10 | ||
|
|
5495877e5a | ||
|
|
5078b3776e | ||
|
|
f5d6b8b4d8 | ||
|
|
1c560dbc16 | ||
|
|
4b8b0ed74c | ||
|
|
308d825db7 |
@@ -44,6 +44,8 @@ import { TerminalToolbar } from "./terminal/TerminalToolbar";
|
||||
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
|
||||
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
|
||||
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
|
||||
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
||||
@@ -500,6 +502,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isVisible,
|
||||
});
|
||||
|
||||
const zmodem = useZmodemTransfer(sessionId);
|
||||
|
||||
const zmodemToastedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (zmodem.active) {
|
||||
zmodemToastedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (zmodemToastedRef.current) return;
|
||||
if (zmodem.error) {
|
||||
zmodemToastedRef.current = true;
|
||||
toast.error(zmodem.error, 'ZMODEM');
|
||||
} else if (zmodem.filename) {
|
||||
zmodemToastedRef.current = true;
|
||||
toast.success(
|
||||
`${zmodem.transferType === 'upload' ? 'Uploaded' : 'Downloaded'}: ${zmodem.filename}`,
|
||||
'ZMODEM',
|
||||
);
|
||||
}
|
||||
}, [zmodem.active, zmodem.error, zmodem.filename, zmodem.transferType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) {
|
||||
lastToastedErrorRef.current = null;
|
||||
@@ -2048,6 +2071,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ZMODEM transfer progress indicator */}
|
||||
{zmodem.active && (
|
||||
<div className="absolute bottom-4 right-4 z-[25] pointer-events-auto">
|
||||
<ZmodemProgressIndicator
|
||||
transferType={zmodem.transferType}
|
||||
filename={zmodem.filename}
|
||||
transferred={zmodem.transferred}
|
||||
total={zmodem.total}
|
||||
fileIndex={zmodem.fileIndex}
|
||||
fileCount={zmodem.fileCount}
|
||||
finalizing={zmodem.finalizing}
|
||||
onCancel={zmodem.cancel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}
|
||||
|
||||
79
components/terminal/ZmodemProgressIndicator.tsx
Normal file
79
components/terminal/ZmodemProgressIndicator.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ArrowDownToLine, ArrowUpFromLine, X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface ZmodemProgressIndicatorProps {
|
||||
transferType: 'upload' | 'download' | null;
|
||||
filename: string | null;
|
||||
transferred: number;
|
||||
total: number;
|
||||
fileIndex: number;
|
||||
fileCount: number;
|
||||
finalizing: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes <= 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export const ZmodemProgressIndicator: React.FC<ZmodemProgressIndicatorProps> = ({
|
||||
transferType,
|
||||
filename,
|
||||
transferred,
|
||||
total,
|
||||
fileIndex,
|
||||
fileCount,
|
||||
finalizing,
|
||||
onCancel,
|
||||
}) => {
|
||||
const percent = total > 0 ? Math.min(100, Math.round((transferred / total) * 100)) : 0;
|
||||
const Icon = transferType === 'upload' ? ArrowUpFromLine : ArrowDownToLine;
|
||||
const label = finalizing ? 'Waiting for remote...' : transferType === 'upload' ? 'Uploading' : 'Downloading';
|
||||
const fileInfo = fileCount > 0 ? ` (${fileIndex + 1}/${fileCount})` : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-3 py-2 rounded-lg shadow-lg backdrop-blur-sm min-w-[240px] max-w-[360px]"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg, #000000) 90%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 15%, var(--terminal-ui-bg, #000000))',
|
||||
color: 'var(--terminal-ui-fg, #ffffff)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0 opacity-60" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-xs font-medium truncate">
|
||||
{filename || label}{fileInfo}
|
||||
</span>
|
||||
<span className="text-[10px] opacity-60 flex-shrink-0">{percent}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1 rounded-full overflow-hidden" style={{ backgroundColor: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 10%, transparent)' }}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-150"
|
||||
style={{
|
||||
width: `${percent}%`,
|
||||
backgroundColor: transferType === 'upload' ? '#3b82f6' : '#22c55e',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] opacity-50 mt-0.5">
|
||||
{formatBytes(transferred)} / {formatBytes(total)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-shrink-0 p-1 rounded transition-colors hover:bg-white/10"
|
||||
title="Cancel transfer (Ctrl+C)"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 opacity-60" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
102
components/terminal/hooks/useZmodemTransfer.ts
Normal file
102
components/terminal/hooks/useZmodemTransfer.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export interface ZmodemTransferState {
|
||||
active: boolean;
|
||||
transferType: 'upload' | 'download' | null;
|
||||
filename: string | null;
|
||||
transferred: number;
|
||||
total: number;
|
||||
fileIndex: number;
|
||||
fileCount: number;
|
||||
finalizing: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: ZmodemTransferState = {
|
||||
active: false,
|
||||
transferType: null,
|
||||
filename: null,
|
||||
transferred: 0,
|
||||
total: 0,
|
||||
fileIndex: 0,
|
||||
fileCount: 0,
|
||||
finalizing: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export function useZmodemTransfer(sessionId: string | null) {
|
||||
const [state, setState] = useState<ZmodemTransferState>(initialState);
|
||||
const disposeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const disposeExitRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onZmodemEvent) return;
|
||||
|
||||
disposeRef.current = bridge.onZmodemEvent(sessionId, (event) => {
|
||||
switch (event.type) {
|
||||
case 'detect':
|
||||
setState({
|
||||
active: true,
|
||||
transferType: event.transferType ?? null,
|
||||
filename: null,
|
||||
transferred: 0,
|
||||
total: 0,
|
||||
fileIndex: 0,
|
||||
fileCount: 0,
|
||||
error: null,
|
||||
});
|
||||
break;
|
||||
case 'progress':
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
active: true,
|
||||
transferType: event.transferType ?? prev.transferType,
|
||||
filename: event.filename ?? prev.filename,
|
||||
transferred: event.transferred ?? prev.transferred,
|
||||
total: event.total ?? prev.total,
|
||||
fileIndex: event.fileIndex ?? prev.fileIndex,
|
||||
fileCount: event.fileCount ?? prev.fileCount,
|
||||
finalizing: !!((event as Record<string, unknown>).finalizing),
|
||||
}));
|
||||
break;
|
||||
case 'complete':
|
||||
setState((prev) => ({ ...prev, active: false }));
|
||||
break;
|
||||
case 'error':
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
active: false,
|
||||
error: event.error ?? 'Unknown error',
|
||||
}));
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// If the session exits mid-transfer (disconnect, shell exit, etc.),
|
||||
// reset state so the progress indicator doesn't stay stuck.
|
||||
disposeExitRef.current = bridge.onSessionExit(sessionId, () => {
|
||||
setState(initialState);
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposeRef.current?.();
|
||||
disposeRef.current = null;
|
||||
disposeExitRef.current?.();
|
||||
disposeExitRef.current = null;
|
||||
setState(initialState);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (!sessionId) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.cancelZmodem?.(sessionId);
|
||||
}, [sessionId]);
|
||||
|
||||
return { ...state, cancel };
|
||||
}
|
||||
@@ -172,7 +172,7 @@ const attachSessionToTerminal = (
|
||||
term: XTerm,
|
||||
id: string,
|
||||
opts?: {
|
||||
onExitMessage?: (evt: { exitCode?: number; signal?: number }) => string;
|
||||
onExitMessage?: (evt: { exitCode?: number; signal?: number; error?: string; reason?: string }) => string;
|
||||
onConnected?: () => void;
|
||||
// For serial: convert lone LF to CRLF to avoid "staircase effect"
|
||||
convertLfToCrlf?: boolean;
|
||||
@@ -209,6 +209,9 @@ const attachSessionToTerminal = (
|
||||
|
||||
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, (evt) => {
|
||||
ctx.updateStatus("disconnected");
|
||||
if (evt.error) {
|
||||
ctx.setError(evt.error);
|
||||
}
|
||||
term.writeln(opts?.onExitMessage?.(evt) ?? "\r\n[session closed]");
|
||||
|
||||
if (ctx.onTerminalDataCapture && ctx.serializeAddonRef.current) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"use strict";
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { existsSync } = require("node:fs");
|
||||
const { existsSync, statSync } = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// ── ANSI / URL regexes ──
|
||||
@@ -93,7 +93,11 @@ function normalizeCliPathForPlatform(filePath) {
|
||||
if (!normalized) return null;
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
return existsSync(normalized) ? normalized : null;
|
||||
// Reject directories (e.g. /Applications/Codex.app) — must be a file
|
||||
try {
|
||||
if (existsSync(normalized) && statSync(normalized).isFile()) return normalized;
|
||||
} catch { /* stat failed */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
const ext = path.extname(normalized).toLowerCase();
|
||||
|
||||
@@ -1453,9 +1453,12 @@ function registerHandlers(ipcMain) {
|
||||
const result = await runCommand(probeCmd, probeArgs, { env: shellEnv });
|
||||
version = (result.stdout || result.stderr || "").trim().split("\n")[0];
|
||||
} catch {
|
||||
version = "";
|
||||
// --version failed: not a valid CLI executable (e.g. .app bundle)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!version) continue;
|
||||
|
||||
const { resolveAcp: _unused, ...agentInfo } = agent;
|
||||
agents.push({
|
||||
...agentInfo,
|
||||
@@ -1494,7 +1497,12 @@ function registerHandlers(ipcMain) {
|
||||
const result = await runCommand(resolvedPath, ["--version"], { env: shellEnv });
|
||||
version = (result.stdout || result.stderr || "").trim().split("\n")[0];
|
||||
} catch {
|
||||
version = "";
|
||||
// --version failed: not a valid CLI executable
|
||||
return { path: resolvedPath, version: null, available: false };
|
||||
}
|
||||
|
||||
if (!version) {
|
||||
return { path: resolvedPath, version: null, available: false };
|
||||
}
|
||||
|
||||
return { path: resolvedPath, version, available: true };
|
||||
|
||||
@@ -24,6 +24,7 @@ const {
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
const { createZmodemSentry } = require("./zmodemHelper.cjs");
|
||||
|
||||
// Default SSH key names in priority order (preferred keys tried first)
|
||||
const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
@@ -410,9 +411,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
username: jump.username || 'root',
|
||||
readyTimeout: 120000, // 2 minutes to allow for keyboard-interactive (2FA/MFA)
|
||||
// Use user-configured keepalive interval from options (in seconds -> convert to ms)
|
||||
// If 0 or not provided, use 10000ms as default
|
||||
keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
|
||||
keepaliveCountMax: 3,
|
||||
// 0 = disabled (no keepalive packets sent)
|
||||
keepaliveInterval: options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 0,
|
||||
keepaliveCountMax: options.keepaliveInterval > 0 ? 3 : 0,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: buildAlgorithms(options.legacyAlgorithms),
|
||||
@@ -680,9 +681,9 @@ async function startSSHSession(event, options) {
|
||||
// `readyTimeout` covers the entire connection + authentication flow in ssh2.
|
||||
readyTimeout: 20000, // Fast failure for non-interactive auth
|
||||
// Use user-configured keepalive interval (in seconds -> convert to ms)
|
||||
// If 0 or not provided, use 10000ms as default
|
||||
keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
|
||||
keepaliveCountMax: 3,
|
||||
// 0 = disabled (no keepalive packets sent)
|
||||
keepaliveInterval: options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 0,
|
||||
keepaliveCountMax: options.keepaliveInterval > 0 ? 3 : 0,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: buildAlgorithms(options.legacyAlgorithms),
|
||||
@@ -1246,15 +1247,36 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
};
|
||||
|
||||
const sshZmodemSentry = createZmodemSentry({
|
||||
sessionId,
|
||||
onData(buf) {
|
||||
const decoder = getSessionDecoder(sessionId, "stdout");
|
||||
const decoded = decoder.write(buf);
|
||||
trackSessionIdlePrompt(session, decoded);
|
||||
bufferData(decoded);
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
},
|
||||
writeToRemote(buf) {
|
||||
try { return stream.write(buf); } catch { return true; /* ignore */ }
|
||||
},
|
||||
interruptRemote() {
|
||||
try { stream.signal?.("INT"); } catch { /* ignore */ }
|
||||
},
|
||||
getWebContents() {
|
||||
return event.sender;
|
||||
},
|
||||
label: "SSH",
|
||||
});
|
||||
session.zmodemSentry = sshZmodemSentry;
|
||||
|
||||
stream.on("data", (data) => {
|
||||
const decoder = getSessionDecoder(sessionId, "stdout");
|
||||
const decoded = decoder.write(data);
|
||||
trackSessionIdlePrompt(session, decoded);
|
||||
bufferData(decoded);
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
// data is Buffer from ssh2 — feed raw bytes to ZMODEM sentry.
|
||||
// In normal mode, sentry's onData callback handles decoding and buffering.
|
||||
sshZmodemSentry.consume(data);
|
||||
});
|
||||
|
||||
stream.stderr?.on("data", (data) => {
|
||||
// stderr is not used for ZMODEM — decode normally
|
||||
const decoder = getSessionDecoder(sessionId, "stderr");
|
||||
const decoded = decoder.write(data);
|
||||
bufferData(decoded);
|
||||
@@ -1294,6 +1316,7 @@ async function startSSHSession(event, options) {
|
||||
} else {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
|
||||
}
|
||||
sessions.get(sessionId)?.zmodemSentry?.cancel();
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
@@ -1362,6 +1385,7 @@ async function startSSHSession(event, options) {
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'error', err.message);
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.get(sessionId)?.zmodemSentry?.cancel();
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
@@ -1382,6 +1406,7 @@ async function startSSHSession(event, options) {
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'error', err.message);
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "timeout" });
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.get(sessionId)?.zmodemSentry?.cancel();
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
@@ -1412,6 +1437,7 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
}
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.get(sessionId)?.zmodemSentry?.cancel();
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
|
||||
@@ -14,6 +14,7 @@ const { SerialPort } = require("serialport");
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { detectShellKind } = require("./ai/ptyExec.cjs");
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
const { createZmodemSentry } = require("./zmodemHelper.cjs");
|
||||
|
||||
// Shared references
|
||||
let sessions = null;
|
||||
@@ -286,6 +287,7 @@ function startLocalSession(event, payload) {
|
||||
rows: payload?.rows || 24,
|
||||
env,
|
||||
cwd,
|
||||
encoding: null, // Return Buffer for ZMODEM binary support
|
||||
});
|
||||
|
||||
const session = {
|
||||
@@ -329,11 +331,40 @@ function startLocalSession(event, payload) {
|
||||
});
|
||||
session.flushPendingData = flushLocal;
|
||||
|
||||
proc.onData((data) => {
|
||||
trackSessionIdlePrompt(session, data);
|
||||
bufferLocalData(data);
|
||||
sessionLogStreamManager.appendData(sessionId, data);
|
||||
});
|
||||
// On Windows, node-pty ignores encoding: null and still emits UTF-8
|
||||
// strings, making raw-byte ZMODEM impossible for local PTY sessions.
|
||||
// Only wire up the sentry on platforms where encoding: null works.
|
||||
if (process.platform !== "win32") {
|
||||
const localDecoder = new StringDecoder("utf8");
|
||||
const zmodemSentry = createZmodemSentry({
|
||||
sessionId,
|
||||
onData(buf) {
|
||||
const str = localDecoder.write(buf);
|
||||
if (!str) return;
|
||||
trackSessionIdlePrompt(session, str);
|
||||
bufferLocalData(str);
|
||||
sessionLogStreamManager.appendData(sessionId, str);
|
||||
},
|
||||
writeToRemote(buf) {
|
||||
try { return proc.write(buf); } catch { return true; }
|
||||
},
|
||||
getWebContents() {
|
||||
return electronModule.webContents.fromId(session.webContentsId);
|
||||
},
|
||||
label: "Local",
|
||||
});
|
||||
session.zmodemSentry = zmodemSentry;
|
||||
|
||||
proc.onData((data) => {
|
||||
zmodemSentry.consume(data);
|
||||
});
|
||||
} else {
|
||||
proc.onData((data) => {
|
||||
trackSessionIdlePrompt(session, data);
|
||||
bufferLocalData(data);
|
||||
sessionLogStreamManager.appendData(sessionId, data);
|
||||
});
|
||||
}
|
||||
|
||||
proc.onExit((evt) => {
|
||||
flushLocal();
|
||||
@@ -535,19 +566,57 @@ async function startTelnetSession(event, options) {
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
|
||||
const telnetZmodemSentry = createZmodemSentry({
|
||||
sessionId,
|
||||
onData(buf) {
|
||||
const decoded = telnetDecoder.write(buf);
|
||||
if (!decoded) return;
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) trackSessionIdlePrompt(session, decoded);
|
||||
bufferTelnetData(decoded);
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
},
|
||||
writeToRemote(buf) {
|
||||
// Escape 0xFF bytes as 0xFF 0xFF per Telnet spec so binary
|
||||
// ZMODEM data passes through without being treated as IAC.
|
||||
try {
|
||||
let hasFF = false;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
if (buf[i] === 0xff) { hasFF = true; break; }
|
||||
}
|
||||
if (hasFF) {
|
||||
const escaped = [];
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
escaped.push(buf[i]);
|
||||
if (buf[i] === 0xff) escaped.push(0xff);
|
||||
}
|
||||
return socket.write(Buffer.from(escaped));
|
||||
} else {
|
||||
return socket.write(buf);
|
||||
}
|
||||
} catch { return true; }
|
||||
},
|
||||
getWebContents() {
|
||||
return electronModule.webContents.fromId(telnetWebContentsId);
|
||||
},
|
||||
label: "Telnet",
|
||||
});
|
||||
// Attach sentry to session once created (connect callback runs after this)
|
||||
const attachTelnetSentry = () => {
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) session.zmodemSentry = telnetZmodemSentry;
|
||||
};
|
||||
socket.once('connect', attachTelnetSentry);
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
// Always run Telnet negotiation — even during ZMODEM, the Telnet
|
||||
// layer still escapes 0xFF as IAC IAC and sends control sequences.
|
||||
const cleanData = handleTelnetNegotiation(data);
|
||||
|
||||
if (cleanData.length > 0) {
|
||||
const decoded = telnetDecoder.write(cleanData);
|
||||
if (decoded) {
|
||||
trackSessionIdlePrompt(session, decoded);
|
||||
bufferTelnetData(decoded);
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
}
|
||||
telnetZmodemSentry.consume(cleanData);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -562,6 +631,7 @@ async function startTelnetSession(event, options) {
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.zmodemSentry?.cancel();
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
|
||||
}
|
||||
@@ -577,6 +647,7 @@ async function startTelnetSession(event, options) {
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.zmodemSentry?.cancel();
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: hadError ? 1 : 0, reason: hadError ? "error" : "closed" });
|
||||
}
|
||||
@@ -645,6 +716,7 @@ async function startMoshSession(event, options) {
|
||||
rows,
|
||||
env,
|
||||
cwd: os.homedir(),
|
||||
encoding: null, // Return Buffer for ZMODEM binary support
|
||||
});
|
||||
|
||||
const session = {
|
||||
@@ -682,11 +754,37 @@ async function startMoshSession(event, options) {
|
||||
});
|
||||
session.flushPendingData = flushMosh;
|
||||
|
||||
proc.onData((data) => {
|
||||
trackSessionIdlePrompt(session, data);
|
||||
bufferMoshData(data);
|
||||
sessionLogStreamManager.appendData(sessionId, data);
|
||||
});
|
||||
if (process.platform !== "win32") {
|
||||
const moshDecoder = new StringDecoder("utf8");
|
||||
const moshZmodemSentry = createZmodemSentry({
|
||||
sessionId,
|
||||
onData(buf) {
|
||||
const str = moshDecoder.write(buf);
|
||||
if (!str) return;
|
||||
trackSessionIdlePrompt(session, str);
|
||||
bufferMoshData(str);
|
||||
sessionLogStreamManager.appendData(sessionId, str);
|
||||
},
|
||||
writeToRemote(buf) {
|
||||
try { return proc.write(buf); } catch { return true; }
|
||||
},
|
||||
getWebContents() {
|
||||
return electronModule.webContents.fromId(session.webContentsId);
|
||||
},
|
||||
label: "Mosh",
|
||||
});
|
||||
session.zmodemSentry = moshZmodemSentry;
|
||||
|
||||
proc.onData((data) => {
|
||||
moshZmodemSentry.consume(data);
|
||||
});
|
||||
} else {
|
||||
proc.onData((data) => {
|
||||
trackSessionIdlePrompt(session, data);
|
||||
bufferMoshData(data);
|
||||
sessionLogStreamManager.appendData(sessionId, data);
|
||||
});
|
||||
}
|
||||
|
||||
proc.onExit((evt) => {
|
||||
flushMosh();
|
||||
@@ -790,17 +888,33 @@ async function startSerialSession(event, options) {
|
||||
});
|
||||
}
|
||||
|
||||
serialPort.on('data', (data) => {
|
||||
const decoded = serialDecoder.write(data);
|
||||
if (decoded) {
|
||||
const serialZmodemSentry = createZmodemSentry({
|
||||
sessionId,
|
||||
onData(buf) {
|
||||
const decoded = serialDecoder.write(buf);
|
||||
if (!decoded) return;
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data: decoded });
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
}
|
||||
},
|
||||
writeToRemote(buf) {
|
||||
try { return serialPort.write(buf); } catch { return true; }
|
||||
},
|
||||
getWebContents() {
|
||||
return electronModule.webContents.fromId(session.webContentsId);
|
||||
},
|
||||
label: "Serial",
|
||||
});
|
||||
session.zmodemSentry = serialZmodemSentry;
|
||||
|
||||
serialPort.on('data', (data) => {
|
||||
// data is already Buffer from serialport — feed to sentry
|
||||
serialZmodemSentry.consume(data);
|
||||
});
|
||||
|
||||
serialPort.on('error', (err) => {
|
||||
console.error(`[Serial] Port error: ${err.message}`);
|
||||
session.zmodemSentry?.cancel();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
|
||||
@@ -809,6 +923,7 @@ async function startSerialSession(event, options) {
|
||||
|
||||
serialPort.on('close', () => {
|
||||
console.log(`[Serial] Port closed`);
|
||||
session.zmodemSentry?.cancel();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
|
||||
@@ -830,7 +945,15 @@ async function startSerialSession(event, options) {
|
||||
function writeToSession(event, payload) {
|
||||
const session = sessions.get(payload.sessionId);
|
||||
if (!session) return;
|
||||
|
||||
|
||||
// During ZMODEM transfer, block terminal input (Ctrl+C cancels the transfer)
|
||||
if (session.zmodemSentry?.isActive()) {
|
||||
if (payload.data === '\x03') {
|
||||
session.zmodemSentry.cancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (session.stream) {
|
||||
session.stream.write(payload.data);
|
||||
@@ -887,6 +1010,7 @@ function closeSession(event, payload) {
|
||||
if (!session) return;
|
||||
|
||||
try {
|
||||
session.zmodemSentry?.cancel();
|
||||
session.flushPendingData?.();
|
||||
if (session.stream) {
|
||||
session.stream.close();
|
||||
@@ -999,6 +1123,7 @@ function cleanupAllSessions() {
|
||||
console.log(`[Terminal] Cleaning up ${sessions.size} sessions before quit`);
|
||||
for (const [sessionId, session] of sessions) {
|
||||
try {
|
||||
session.zmodemSentry?.cancel();
|
||||
if (session.stream) {
|
||||
session.stream.close();
|
||||
session.conn?.end();
|
||||
|
||||
794
electron/bridges/zmodemHelper.cjs
Normal file
794
electron/bridges/zmodemHelper.cjs
Normal file
@@ -0,0 +1,794 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const Zmodem = 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => void} opts.writeToRemote
|
||||
* Write raw bytes back to the remote side (PTY / SSH stream / socket).
|
||||
* @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;
|
||||
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;
|
||||
const COOLDOWN_MS = 2000;
|
||||
const ECHO_TTL_MS = 1500;
|
||||
const ECHO_MAX_BYTES = 256;
|
||||
|
||||
function prunePendingEchoes(now = Date.now()) {
|
||||
while (pendingEchoes.length && pendingEchoes[0].expiresAt <= now) {
|
||||
pendingEchoes.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function rememberOutgoingEcho(octets) {
|
||||
const buf = Buffer.from(octets);
|
||||
if (!buf.length || buf.length > ECHO_MAX_BYTES) return;
|
||||
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 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 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")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
const ok = writeToRemote(Buffer.from(octets));
|
||||
// Track backpressure: if stream.write() returned false, the
|
||||
// kernel TCP buffer is full. The upload loop should pause.
|
||||
if (ok === false) _needsDrain = 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 contents = getWebContents();
|
||||
const transferType = zsession.type === "send" ? "upload" : "download";
|
||||
|
||||
console.log(`[ZMODEM][${label}] Detected ${transferType} for session ${sessionId}`);
|
||||
|
||||
safeSend(contents, "netcatty:zmodem:detect", {
|
||||
sessionId,
|
||||
transferType,
|
||||
});
|
||||
|
||||
// Provide a drain helper so the upload loop can pause when the
|
||||
// underlying transport's write buffer is full.
|
||||
const transferOpts = {
|
||||
...opts,
|
||||
waitForDrain: () => {
|
||||
if (!_needsDrain) return Promise.resolve();
|
||||
_needsDrain = false;
|
||||
// Yield to the event loop so Node can flush buffered writes to
|
||||
// the kernel. Using setImmediate (not setTimeout) avoids any
|
||||
// fixed delay — we resume as soon as the I/O phase completes.
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
},
|
||||
};
|
||||
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(contents, "netcatty:zmodem:complete", { sessionId });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (currentZSession !== zsession) return;
|
||||
console.error(`[ZMODEM][${label}] Transfer error:`, err.message || err);
|
||||
try { zsession.abort(); } catch { /* ignore */ }
|
||||
safeSend(contents, "netcatty:zmodem:error", {
|
||||
sessionId,
|
||||
error: String(err.message || err),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
// 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`);
|
||||
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.
|
||||
if (isIgnorableSendResumePingError(errMsg)) {
|
||||
console.log(`[ZMODEM][${label}] Ignoring late post-file ZRPOS`);
|
||||
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);
|
||||
|
||||
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() {
|
||||
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);
|
||||
safeSend(getWebContents(), "netcatty:zmodem:error", {
|
||||
sessionId,
|
||||
error: "Transfer cancelled",
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers (module-level, usable from handleUpload / handleDownload)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Race a promise against a timeout. If the promise doesn't settle within
|
||||
* `ms`, resolve with undefined instead of hanging forever. This prevents
|
||||
* zmodem.js internal promises (xfer.end, zsession.close) from blocking
|
||||
* indefinitely after cancel/abort.
|
||||
*/
|
||||
function withTimeout(promise, ms) {
|
||||
let timer;
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("ZMODEM handshake timeout")), ms);
|
||||
}),
|
||||
]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 win = contents ? BrowserWindow.fromWebContents(contents) : null;
|
||||
const result = await 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");
|
||||
}
|
||||
|
||||
const filePaths = result.filePaths;
|
||||
const fileStats = filePaths.map((fp) => fs.statSync(fp));
|
||||
|
||||
for (let i = 0; i < filePaths.length; i++) {
|
||||
const filePath = filePaths[i];
|
||||
const stat = fileStats[i];
|
||||
const name = path.basename(filePath);
|
||||
|
||||
safeSend(contents, "netcatty:zmodem:progress", {
|
||||
sessionId,
|
||||
filename: name,
|
||||
transferred: 0,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: filePaths.length,
|
||||
transferType: "upload",
|
||||
});
|
||||
|
||||
let bytesRemaining = 0;
|
||||
for (let j = i; j < fileStats.length; j++) bytesRemaining += fileStats[j].size;
|
||||
|
||||
const xfer = await zsession.send_offer({
|
||||
name,
|
||||
size: stat.size,
|
||||
mtime: new Date(stat.mtimeMs),
|
||||
files_remaining: filePaths.length - i,
|
||||
bytes_remaining: bytesRemaining,
|
||||
});
|
||||
|
||||
if (!xfer) {
|
||||
// Receiver skipped this file
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read and send in chunks
|
||||
const CHUNK_SIZE = 64 * 1024; // Leave room for inbound ZMODEM control frames
|
||||
const fd = fs.openSync(filePath, "r");
|
||||
const buf = Buffer.alloc(CHUNK_SIZE);
|
||||
let sent = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const bytesRead = fs.readSync(fd, buf, 0, 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;
|
||||
|
||||
safeSend(contents, "netcatty:zmodem:progress", {
|
||||
sessionId,
|
||||
filename: name,
|
||||
transferred: sent,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: filePaths.length,
|
||||
transferType: "upload",
|
||||
});
|
||||
|
||||
// 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(contents, "netcatty:zmodem:progress", {
|
||||
sessionId,
|
||||
filename: name,
|
||||
transferred: stat.size,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: filePaths.length,
|
||||
transferType: "upload",
|
||||
finalizing: true,
|
||||
});
|
||||
await withTimeout(xfer.end(), 120000);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
await withTimeout(zsession.close(), 120000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
const win = contents ? BrowserWindow.fromWebContents(contents) : null;
|
||||
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(contents, "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;
|
||||
const chunk = Buffer.from(payload);
|
||||
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(contents, "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 = await 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 };
|
||||
@@ -505,6 +505,14 @@ const registerBridges = (win) => {
|
||||
aiBridge.registerHandlers(ipcMain);
|
||||
crashLogBridge.registerHandlers(ipcMain);
|
||||
|
||||
// ZMODEM cancel handler
|
||||
ipcMain.on("netcatty:zmodem:cancel", (_event, payload) => {
|
||||
const session = sessions.get(payload.sessionId);
|
||||
if (session?.zmodemSentry) {
|
||||
session.zmodemSentry.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
// Fig autocomplete spec loader — uses dynamic import() since @withfig/autocomplete is ESM
|
||||
ipcMain.handle("netcatty:figspec:list", async () => {
|
||||
try {
|
||||
|
||||
@@ -8,6 +8,7 @@ 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();
|
||||
@@ -109,6 +110,28 @@ function _deliverToListeners(sessionId, data) {
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -153,6 +176,7 @@ ipcRenderer.on("netcatty:exit", (_event, payload) => {
|
||||
}
|
||||
dataListeners.delete(payload.sessionId);
|
||||
exitListeners.delete(payload.sessionId);
|
||||
zmodemListeners.delete(payload.sessionId);
|
||||
const pendingTimer = _mcpFlushTimers.get(payload.sessionId);
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer);
|
||||
@@ -569,6 +593,14 @@ const api = {
|
||||
},
|
||||
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);
|
||||
|
||||
17
global.d.ts
vendored
17
global.d.ts
vendored
@@ -263,6 +263,23 @@ declare global {
|
||||
writeToSession(sessionId: string, data: string): void;
|
||||
resizeSession(sessionId: string, cols: number, rows: number): void;
|
||||
closeSession(sessionId: string): void;
|
||||
// ZMODEM file transfer
|
||||
onZmodemEvent?(
|
||||
sessionId: string,
|
||||
cb: (event: {
|
||||
type: 'detect' | 'progress' | 'complete' | 'error';
|
||||
sessionId: string;
|
||||
transferType?: 'upload' | 'download';
|
||||
filename?: string;
|
||||
transferred?: number;
|
||||
total?: number;
|
||||
fileIndex?: number;
|
||||
fileCount?: number;
|
||||
finalizing?: boolean;
|
||||
error?: string;
|
||||
}) => void
|
||||
): () => void;
|
||||
cancelZmodem?(sessionId: string): void;
|
||||
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
|
||||
onSessionExit(
|
||||
sessionId: string,
|
||||
|
||||
22
package-lock.json
generated
22
package-lock.json
generated
@@ -57,6 +57,7 @@
|
||||
"use-stick-to-bottom": "^1.1.3",
|
||||
"uuid": "^13.0.0",
|
||||
"webdav": "^5.8.0",
|
||||
"zmodem.js": "^0.1.10",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -8282,6 +8283,18 @@
|
||||
"buffer": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-dirname": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
|
||||
@@ -16276,6 +16289,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zmodem.js": {
|
||||
"version": "0.1.10",
|
||||
"resolved": "https://registry.npmjs.org/zmodem.js/-/zmodem.js-0.1.10.tgz",
|
||||
"integrity": "sha512-Z1DWngunZ/j3BmIzSJpFZVNV73iHkj89rxXX4IciJdU9ga3nZ7rJ5LkfjV/QDsKhc7bazDWTTJCLJ+iRXD82dw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"crc-32": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"use-stick-to-bottom": "^1.1.3",
|
||||
"uuid": "^13.0.0",
|
||||
"webdav": "^5.8.0",
|
||||
"zmodem.js": "^0.1.10",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Reference in New Issue
Block a user