Implement ZMODEM drag-and-drop file upload support in terminal

Adds SFTP fallback when rz is unavailable and cleans up drag-drop upload edge cases.
This commit is contained in:
陈大猫
2026-06-12 16:58:50 +08:00
committed by GitHub
16 changed files with 1019 additions and 29 deletions

View File

@@ -89,7 +89,9 @@ export const enTerminalMessages: Messages = {
'terminal.dragDrop.localTitle': 'Drop to Insert Paths',
'terminal.dragDrop.localMessage': 'File paths will be inserted into the terminal',
'terminal.dragDrop.remoteTitle': 'Drop to Upload Files',
'terminal.dragDrop.remoteMessage': 'Files will be uploaded via SFTP',
'terminal.dragDrop.remoteZmodemMessage': 'Files will be uploaded via ZMODEM (PTY)',
'terminal.dragDrop.remoteSftpMessage': 'Files will be uploaded via SFTP',
'terminal.dragDrop.noFiles': 'No files to upload',
'terminal.dragDrop.notConnected': 'Cannot drop files - terminal is not connected',
'terminal.dragDrop.errorTitle': 'Drop Error',
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',

View File

@@ -110,7 +110,9 @@ export const ruTerminalMessages: Messages = {
'terminal.dragDrop.localTitle': 'Перетащите для вставки путей',
'terminal.dragDrop.localMessage': 'Пути к файлам будут вставлены в терминал',
'terminal.dragDrop.remoteTitle': 'Перетащите для загрузки файлов',
'terminal.dragDrop.remoteMessage': 'Файлы будут загружены через SFTP',
'terminal.dragDrop.remoteZmodemMessage': 'Файлы будут загружены через ZMODEM (PTY)',
'terminal.dragDrop.remoteSftpMessage': 'Файлы будут загружены через SFTP',
'terminal.dragDrop.noFiles': 'Нет файлов для загрузки',
'terminal.dragDrop.notConnected': 'Нельзя перетащить файлы — терминал не подключён',
'terminal.dragDrop.errorTitle': 'Ошибка перетаскивания',
'terminal.dragDrop.errorMessage': 'Не удалось обработать перетащенные файлы',

View File

@@ -282,7 +282,9 @@ export const zhCNVaultMessages: Messages = {
'terminal.dragDrop.localTitle': '拖放以插入路径',
'terminal.dragDrop.localMessage': '文件路径将被插入到终端',
'terminal.dragDrop.remoteTitle': '拖放以上传文件',
'terminal.dragDrop.remoteMessage': '文件将通过 SFTP 上传',
'terminal.dragDrop.remoteZmodemMessage': '文件将通过 ZMODEMPTY上传',
'terminal.dragDrop.remoteSftpMessage': '文件将通过 SFTP 上传',
'terminal.dragDrop.noFiles': '没有可上传的文件',
'terminal.dragDrop.notConnected': '无法拖放文件 - 终端未连接',
'terminal.dragDrop.errorTitle': '拖放错误',
'terminal.dragDrop.errorMessage': '处理拖放文件失败',

View File

@@ -223,6 +223,36 @@ export const useTerminalBackend = () => {
return bridge.selectDirectory(title, defaultPath);
}, []);
const startZmodemDragDropUpload = useCallback(async (
sessionId: string,
files: Array<{
path?: string;
name: string;
remoteName: string;
data?: ArrayBuffer;
}>,
uploadCommand?: string,
) => {
const bridge = netcattyBridge.get();
if (!bridge?.startZmodemDragDropUpload) {
return { success: false, error: "startZmodemDragDropUpload unavailable" };
}
return bridge.startZmodemDragDropUpload(sessionId, files, uploadCommand);
}, []);
const cancelZmodem = useCallback((sessionId: string, options?: { interrupt?: boolean }) => {
const bridge = netcattyBridge.get();
bridge?.cancelZmodem?.(sessionId, options);
}, []);
const onZmodemEvent = useCallback((
sessionId: string,
cb: Parameters<NonNullable<NetcattyBridge["onZmodemEvent"]>>[1],
) => {
const bridge = netcattyBridge.get();
return bridge?.onZmodemEvent?.(sessionId, cb) ?? (() => {});
}, []);
const getSessionPwd = useCallback(async (sessionId: string, options?: { allowHomeFallback?: boolean }) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
@@ -285,6 +315,9 @@ export const useTerminalBackend = () => {
receiveSerialYmodem,
selectFile,
selectDirectory,
startZmodemDragDropUpload,
cancelZmodem,
onZmodemEvent,
execCommand,
getSessionPwd,
getSessionRemoteInfo,
@@ -330,6 +363,9 @@ export const useTerminalBackend = () => {
receiveSerialYmodem,
selectFile,
selectDirectory,
startZmodemDragDropUpload,
cancelZmodem,
onZmodemEvent,
execCommand,
getSessionPwd,
getSessionRemoteInfo,

View File

@@ -25,6 +25,7 @@ import {
type TerminalHostUpdate,
} from "../domain/terminalAppearance";
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
@@ -470,6 +471,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const detectedDeviceClass = classifyDistroId(host.distro);
const isNetworkDevice =
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
const remoteDragDropUsesZmodem = supportsZmodemTerminalDragDrop(host, isNetworkDevice);
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
const isLocalConnection = host.protocol === "local";
@@ -1162,6 +1164,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
} = useTerminalDragDrop({
host,
isLocalConnection,
isNetworkDevice,
onOpenSftp,
resolveSftpInitialPath,
scrollToBottomAfterProgrammaticInput,
@@ -1257,7 +1260,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
};
const Terminal = memo(TerminalComponent, terminalPropsAreEqual);

View File

@@ -88,7 +88,7 @@ function terminalViewCtxEqual(
}
function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
const { Activity, Button, Clock3, Copy, Maximize2, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
const { Activity, Button, Clock3, Copy, Maximize2, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
const ymodemActionEnabled = shouldEnableYmodemAction({
isSerialConnection,
status,
@@ -159,7 +159,9 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
<div className="text-sm text-muted-foreground">
{isLocalConnection
? t("terminal.dragDrop.localMessage")
: t("terminal.dragDrop.remoteMessage")
: remoteDragDropUsesZmodem
? t("terminal.dragDrop.remoteZmodemMessage")
: t("terminal.dragDrop.remoteSftpMessage")
}
</div>
</div>

View File

@@ -3,6 +3,15 @@ import type React from "react";
import { useRef, useState } from "react";
import { logger } from "../../../lib/logger";
import {
buildZmodemDragDropFiles,
buildZmodemDragDropUploadCommand,
containsZmodemRzMissingMarker,
createZmodemRzMissingToken,
supportsZmodemDragDropSftpFallback,
supportsZmodemTerminalDragDrop,
type ZmodemDragDropFile,
} from "../../../lib/zmodemDragDrop";
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
import type { Host, TerminalSession } from "../../../types";
import { toast } from "../../ui/toast";
@@ -14,6 +23,7 @@ import {
interface UseTerminalDragDropOptions {
host: Host;
isLocalConnection: boolean;
isNetworkDevice?: boolean;
onOpenSftp?: TerminalProps["onOpenSftp"];
resolveSftpInitialPath: (options?: { preferFreshBackend?: boolean }) => Promise<string | undefined>;
scrollToBottomAfterProgrammaticInput: (data: string) => void;
@@ -23,37 +33,112 @@ interface UseTerminalDragDropOptions {
t: (key: string) => string;
terminalBackend: {
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean }) => void;
cancelZmodem?: (sessionId: string, options?: { interrupt?: boolean }) => void;
onSessionData?: (sessionId: string, cb: (chunk: string) => void) => () => void;
onZmodemEvent?: (
sessionId: string,
cb: (event: { type: string; transferType?: string }) => void,
) => () => void;
startZmodemDragDropUpload?: (
sessionId: string,
files: ZmodemDragDropFile[],
uploadCommand?: string,
) => Promise<{ success: boolean; error?: string }>;
};
rzMissingFallbackTimeoutMs?: number;
termRef: React.MutableRefObject<XTerm | null>;
}
const RZ_MISSING_FALLBACK_TIMEOUT_MS = 2500;
export async function resolveTerminalDropUploadInitialPath(
resolveSftpInitialPath: UseTerminalDragDropOptions["resolveSftpInitialPath"],
): Promise<string | undefined> {
return resolveSftpInitialPath({ preferFreshBackend: true });
}
function createRzMissingWatcher({
sessionId,
terminalBackend,
token,
timeoutMs = RZ_MISSING_FALLBACK_TIMEOUT_MS,
}: {
sessionId: string;
terminalBackend: Pick<UseTerminalDragDropOptions["terminalBackend"], "onSessionData" | "onZmodemEvent">;
token: string;
timeoutMs?: number;
}): { promise: Promise<"missing" | "detected" | "timeout">; stop: () => void } {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | undefined;
let buffer = "";
let unsubscribeData: (() => void) | undefined;
let unsubscribeZmodem: (() => void) | undefined;
let settle: (result: "missing" | "detected" | "timeout") => void = () => {};
const cleanup = () => {
if (timeout) clearTimeout(timeout);
timeout = undefined;
unsubscribeData?.();
unsubscribeData = undefined;
unsubscribeZmodem?.();
unsubscribeZmodem = undefined;
};
const promise = new Promise<"missing" | "detected" | "timeout">((resolve) => {
settle = (result) => {
if (settled) return;
settled = true;
cleanup();
resolve(result);
};
unsubscribeData = terminalBackend.onSessionData?.(sessionId, (chunk) => {
buffer = `${buffer}${chunk}`.slice(-512);
if (containsZmodemRzMissingMarker(buffer, token)) {
settle("missing");
}
});
unsubscribeZmodem = terminalBackend.onZmodemEvent?.(sessionId, (event) => {
if (event.type === "detect" && event.transferType === "upload") {
settle("detected");
}
});
timeout = setTimeout(() => settle("timeout"), timeoutMs);
});
return {
promise,
stop: () => settle("detected"),
};
}
export async function handleTerminalDropEntries({
dropEntries,
host,
isLocalConnection,
isNetworkDevice = false,
onOpenSftp,
resolveSftpInitialPath,
scrollToBottomAfterProgrammaticInput,
sessionId,
sessionRef,
terminalBackend,
rzMissingFallbackTimeoutMs,
termRef,
}: Pick<
UseTerminalDragDropOptions,
| "host"
| "isLocalConnection"
| "isNetworkDevice"
| "onOpenSftp"
| "resolveSftpInitialPath"
| "scrollToBottomAfterProgrammaticInput"
| "sessionId"
| "sessionRef"
| "terminalBackend"
| "rzMissingFallbackTimeoutMs"
| "termRef"
> & {
dropEntries: DropEntry[];
@@ -74,7 +159,67 @@ export async function handleTerminalDropEntries({
return;
}
if (onOpenSftp) {
const requiresSftpForDirectoryDrop = dropEntries.some((entry) => (
entry.isDirectory || /[\\/]/.test(entry.relativePath)
));
if (
requiresSftpForDirectoryDrop
&& onOpenSftp
&& supportsZmodemDragDropSftpFallback(host)
) {
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
onOpenSftp(host, initialPath, dropEntries, sessionId);
} else if (supportsZmodemTerminalDragDrop(host, isNetworkDevice)) {
const files = await buildZmodemDragDropFiles(dropEntries);
if (files.length === 0) {
throw new Error("No files to upload");
}
if (!terminalBackend.startZmodemDragDropUpload) {
throw new Error("ZMODEM drag-drop upload is unavailable");
}
const shouldFallbackToSftpWhenRzMissing = Boolean(
onOpenSftp
&& supportsZmodemDragDropSftpFallback(host)
&& terminalBackend.onSessionData
&& terminalBackend.cancelZmodem,
);
const rzMissingToken = shouldFallbackToSftpWhenRzMissing
? createZmodemRzMissingToken()
: undefined;
const rzMissingWatcher = rzMissingToken
? createRzMissingWatcher({
sessionId,
terminalBackend,
token: rzMissingToken,
timeoutMs: rzMissingFallbackTimeoutMs,
})
: undefined;
const uploadCommand = rzMissingToken
? buildZmodemDragDropUploadCommand(rzMissingToken)
: undefined;
let result: { success: boolean; error?: string };
try {
result = await terminalBackend.startZmodemDragDropUpload(sessionId, files, uploadCommand);
} catch (error) {
rzMissingWatcher?.stop();
throw error;
}
if (!result.success) {
rzMissingWatcher?.stop();
throw new Error(result.error || "ZMODEM upload failed");
}
const fallbackResult = rzMissingWatcher ? await rzMissingWatcher.promise : "detected";
if (fallbackResult === "missing" || fallbackResult === "timeout") {
terminalBackend.cancelZmodem?.(sessionId, { interrupt: fallbackResult === "timeout" });
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
onOpenSftp?.(host, initialPath, dropEntries, sessionId);
}
} else if (onOpenSftp) {
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
onOpenSftp(host, initialPath, dropEntries, sessionId);
}
@@ -83,6 +228,7 @@ export async function handleTerminalDropEntries({
export function useTerminalDragDrop({
host,
isLocalConnection,
isNetworkDevice = false,
onOpenSftp,
resolveSftpInitialPath,
scrollToBottomAfterProgrammaticInput,
@@ -91,6 +237,7 @@ export function useTerminalDragDrop({
status,
t,
terminalBackend,
rzMissingFallbackTimeoutMs,
termRef,
}: UseTerminalDragDropOptions) {
const [isDraggingOver, setIsDraggingOver] = useState(false);
@@ -143,17 +290,22 @@ export function useTerminalDragDrop({
dropEntries,
host,
isLocalConnection,
isNetworkDevice,
onOpenSftp,
resolveSftpInitialPath,
scrollToBottomAfterProgrammaticInput,
sessionId,
sessionRef,
terminalBackend,
rzMissingFallbackTimeoutMs,
termRef,
});
} catch (error) {
logger.error("Failed to handle file drop", error);
toast.error(t("terminal.dragDrop.errorMessage"), t("terminal.dragDrop.errorTitle"));
const message = error instanceof Error && error.message === "No files to upload"
? t("terminal.dragDrop.noFiles")
: t("terminal.dragDrop.errorMessage");
toast.error(message, t("terminal.dragDrop.errorTitle"));
}
};

View File

@@ -23,7 +23,166 @@ const dropEntries: DropEntry[] = [
},
];
test("remote terminal drop opens SFTP upload with a freshly resolved cwd", async () => {
test("remote SSH terminal drop triggers ZMODEM drag-drop upload", async () => {
let uploadedFiles: unknown;
let uploadedSessionId: string | undefined;
await handleTerminalDropEntries({
dropEntries: [
{
file: {
name: "report.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "report.txt",
isDirectory: false,
},
],
host,
isLocalConnection: false,
resolveSftpInitialPath: async () => "/srv/app/current",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
startZmodemDragDropUpload: async (sessionId, files) => {
uploadedSessionId = sessionId;
uploadedFiles = files;
return { success: true };
},
},
termRef: { current: null },
});
assert.equal(uploadedSessionId, "session-1");
assert.equal(Array.isArray(uploadedFiles), true);
const files = uploadedFiles as Array<{ name: string; remoteName: string; data?: ArrayBuffer }>;
assert.equal(files.length, 1);
assert.equal(files[0].name, "report.txt");
assert.equal(files[0].remoteName, "report.txt");
assert.ok(files[0].data);
});
test("remote SSH terminal drop stays on ZMODEM when rz starts", async () => {
let openedSftp = false;
let zmodemCallback: ((event: { type: string; transferType?: string }) => void) | undefined;
await handleTerminalDropEntries({
dropEntries: [
{
file: {
name: "report.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "report.txt",
isDirectory: false,
},
],
host,
isLocalConnection: false,
onOpenSftp: () => {
openedSftp = true;
},
resolveSftpInitialPath: async () => "/srv/app/current",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
cancelZmodem: () => {},
onSessionData: () => () => {},
onZmodemEvent: (_sessionId, cb) => {
zmodemCallback = cb;
return () => {
zmodemCallback = undefined;
};
},
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
assert.match(uploadCommand ?? "", /NetcattyRzMissing=/);
zmodemCallback?.({ type: "detect", transferType: "upload" });
return { success: true };
},
},
termRef: { current: null },
});
assert.equal(openedSftp, false);
});
test("serial terminal drop does not wrap rz with an SSH shell fallback", async () => {
let uploadCommandSeen: string | undefined;
await handleTerminalDropEntries({
dropEntries: [
{
file: {
name: "report.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "report.txt",
isDirectory: false,
},
],
host: { ...host, protocol: "serial" } as Host,
isLocalConnection: false,
onOpenSftp: () => {},
resolveSftpInitialPath: async () => "/srv/app/current",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
cancelZmodem: () => {},
onSessionData: () => () => {},
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
uploadCommandSeen = uploadCommand;
return { success: true };
},
},
termRef: { current: null },
});
assert.equal(uploadCommandSeen, undefined);
});
test("telnet terminal drop does not wrap rz with an SSH shell fallback", async () => {
let uploadCommandSeen: string | undefined;
await handleTerminalDropEntries({
dropEntries: [
{
file: {
name: "report.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "report.txt",
isDirectory: false,
},
],
host: { ...host, protocol: "telnet" } as Host,
isLocalConnection: false,
onOpenSftp: () => {},
resolveSftpInitialPath: async () => "/srv/app/current",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
cancelZmodem: () => {},
onSessionData: () => () => {},
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
uploadCommandSeen = uploadCommand;
return { success: true };
},
},
termRef: { current: null },
});
assert.equal(uploadCommandSeen, undefined);
});
test("network device drop falls back to SFTP upload with a freshly resolved cwd", async () => {
let receivedOptions: { preferFreshBackend?: boolean } | undefined;
let openedPath: string | undefined;
let openedEntries: DropEntry[] | undefined;
@@ -33,6 +192,7 @@ test("remote terminal drop opens SFTP upload with a freshly resolved cwd", async
dropEntries,
host,
isLocalConnection: false,
isNetworkDevice: true,
onOpenSftp: (_host, initialPath, pendingUploadEntries, sourceSessionId) => {
openedPath = initialPath;
openedEntries = pendingUploadEntries;
@@ -57,6 +217,163 @@ test("remote terminal drop opens SFTP upload with a freshly resolved cwd", async
assert.equal(openedSessionId, "session-1");
});
test("remote SSH terminal drop falls back to SFTP when rz is unavailable", async () => {
let receivedOptions: { preferFreshBackend?: boolean } | undefined;
let openedPath: string | undefined;
let openedEntries: DropEntry[] | undefined;
let openedSessionId: string | undefined;
let dataCallback: ((chunk: string) => void) | undefined;
let cancelled: { sessionId: string; interrupt?: boolean } | undefined;
await handleTerminalDropEntries({
dropEntries: [
{
file: {
name: "report.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "report.txt",
isDirectory: false,
},
],
host,
isLocalConnection: false,
onOpenSftp: (_host, initialPath, pendingUploadEntries, sourceSessionId) => {
openedPath = initialPath;
openedEntries = pendingUploadEntries;
openedSessionId = sourceSessionId;
},
resolveSftpInitialPath: async (options) => {
receivedOptions = options;
return "/srv/app/current";
},
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
onSessionData: (_sessionId: string, cb: (chunk: string) => void) => {
dataCallback = cb;
return () => {
dataCallback = undefined;
};
},
cancelZmodem: (sessionId: string, options?: { interrupt?: boolean }) => {
cancelled = { sessionId, interrupt: options?.interrupt };
},
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
assert.match(uploadCommand ?? "", /NetcattyRzMissing=/);
assert.equal((uploadCommand ?? "").includes("\u001b]1337;NetcattyRzMissing="), false);
const token = uploadCommand?.match(/NetcattyRzMissing=([A-Za-z0-9_-]+)/)?.[1];
assert.ok(token);
dataCallback?.(`\u001b]1337;NetcattyRzMissing=${token}\u0007`);
return { success: true };
},
},
termRef: { current: null },
});
assert.deepEqual(receivedOptions, { preferFreshBackend: true });
assert.equal(openedPath, "/srv/app/current");
assert.equal(openedEntries?.length, 1);
assert.equal(openedEntries?.[0].relativePath, "report.txt");
assert.equal(openedSessionId, "session-1");
assert.deepEqual(cancelled, { sessionId: "session-1", interrupt: false });
});
test("remote SSH terminal drop falls back to SFTP when rz never starts", async () => {
let receivedOptions: { preferFreshBackend?: boolean } | undefined;
let openedPath: string | undefined;
let openedEntries: DropEntry[] | undefined;
let cancelled: { sessionId: string; interrupt?: boolean } | undefined;
await handleTerminalDropEntries({
dropEntries: [
{
file: {
name: "report.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "report.txt",
isDirectory: false,
},
],
host,
isLocalConnection: false,
onOpenSftp: (_host, initialPath, pendingUploadEntries) => {
openedPath = initialPath;
openedEntries = pendingUploadEntries;
},
resolveSftpInitialPath: async (options) => {
receivedOptions = options;
return "/srv/app/current";
},
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
onSessionData: () => () => {},
cancelZmodem: (sessionId: string, options?: { interrupt?: boolean }) => {
cancelled = { sessionId, interrupt: options?.interrupt };
},
startZmodemDragDropUpload: async () => ({ success: true }),
},
rzMissingFallbackTimeoutMs: 1,
termRef: { current: null },
});
assert.deepEqual(receivedOptions, { preferFreshBackend: true });
assert.equal(openedPath, "/srv/app/current");
assert.equal(openedEntries?.length, 1);
assert.deepEqual(cancelled, { sessionId: "session-1", interrupt: true });
});
test("remote SSH folder drop uses SFTP to preserve directory structure", async () => {
let openedEntries: DropEntry[] | undefined;
let zmodemStarted = false;
const folderEntries: DropEntry[] = [
{
file: null,
relativePath: "docs",
isDirectory: true,
},
{
file: {
name: "guide.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "docs/guide.txt",
isDirectory: false,
},
];
await handleTerminalDropEntries({
dropEntries: folderEntries,
host,
isLocalConnection: false,
onOpenSftp: (_host, _initialPath, pendingUploadEntries) => {
openedEntries = pendingUploadEntries;
},
resolveSftpInitialPath: async () => "/srv/app/current",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
startZmodemDragDropUpload: async () => {
zmodemStarted = true;
return { success: true };
},
},
termRef: { current: null },
});
assert.equal(zmodemStarted, false);
assert.equal(openedEntries, folderEntries);
});
test("fresh cwd resolution falls back to the renderer cwd when backend probe has no real cwd", async () => {
const cwd = await resolvePreferredTerminalCwd({
rendererCwd: "/srv/app/current",

View File

@@ -14,6 +14,7 @@ const NETCATTY_TEMP_DIR_NAME = "Netcatty";
// Cached temp directory path
let cachedTempDir = null;
let tempFileCounter = 0;
/**
* Get the Netcatty temp directory path
@@ -143,8 +144,9 @@ async function clearTempDir() {
function getTempFilePath(fileName) {
const tempDir = getTempDir();
const timestamp = Date.now();
tempFileCounter = (tempFileCounter + 1) % 1000000;
const safeFileName = fileName.replace(/[<>:"/\\|?*]/g, "_");
return path.join(tempDir, `${timestamp}_${safeFileName}`);
return path.join(tempDir, `${timestamp}_${tempFileCounter}_${safeFileName}`);
}
/**

View File

@@ -0,0 +1,19 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const tempDirBridge = require("./tempDirBridge.cjs");
test("getTempFilePath is unique for duplicate names in the same millisecond", () => {
const originalNow = Date.now;
Date.now = () => 1234567890;
try {
const first = tempDirBridge.getTempFilePath("upload.txt");
const second = tempDirBridge.getTempFilePath("upload.txt");
assert.notEqual(first, second);
assert.equal(path.basename(first).endsWith("_upload.txt"), true);
assert.equal(path.basename(second).endsWith("_upload.txt"), true);
} finally {
Date.now = originalNow;
}
});

View File

@@ -112,9 +112,15 @@ function createZmodemSentry(opts) {
// 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) {
@@ -257,6 +263,39 @@ function createZmodemSentry(opts) {
}
}
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);
@@ -279,6 +318,38 @@ function createZmodemSentry(opts) {
}, 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 &&
@@ -351,6 +422,9 @@ function createZmodemSentry(opts) {
// underlying transport's write buffer is full.
const transferOpts = {
...opts,
getDragDropUpload: () => dragDropUpload,
takeDragDropUpload,
clearDragDropUpload,
waitForDrain: () => {
if (!_needsDrain) return Promise.resolve();
_needsDrain = false;
@@ -490,7 +564,7 @@ function createZmodemSentry(opts) {
},
/** Cancel the current ZMODEM transfer. */
cancel() {
cancel(options = {}) {
if (currentZSession) {
const transferRole = currentZSession.type;
console.log(`[ZMODEM][${label}] Cancelling transfer for session ${sessionId}`);
@@ -504,6 +578,48 @@ function createZmodemSentry(opts) {
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");
}
const uploadCommand = payload.uploadCommand || "rz\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;
}
},
};
@@ -561,6 +677,18 @@ async function handleUpload(zsession, opts) {
const { BrowserWindow, dialog } = getElectron();
const yieldToIO = () => new Promise((resolve) => setImmediate(resolve));
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 win = contents ? BrowserWindow.fromWebContents(contents) : null;
const result = await dialog.showOpenDialog(win || undefined, {
properties: ["openFile", "multiSelections"],
@@ -573,10 +701,12 @@ async function handleUpload(zsession, opts) {
throw new Error("Transfer cancelled");
}
const filePaths = result.filePaths;
const fileStats = filePaths.map((fp) => fs.statSync(fp));
filePaths = result.filePaths;
allNames = filePaths.map((fp) => path.basename(fp));
}
const allNames = filePaths.map((fp) => path.basename(fp));
try {
const fileStats = filePaths.map((fp) => fs.statSync(fp));
// Conflict handling (SSH only — callbacks absent on local/telnet/serial).
// On any failure we fall back to today's behavior (rz silently skips).
@@ -709,6 +839,18 @@ async function handleUpload(zsession, opts) {
}
}
}
} finally {
if (dragDropTempPaths.length) {
for (const tempPath of dragDropTempPaths) {
try {
fs.unlinkSync(tempPath);
} catch {
/* ignore */
}
}
}
}
}
/**

View File

@@ -1,6 +1,9 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { buildUploadPlan, buildModeRestores } = require("./zmodemHelper.cjs");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { createZmodemSentry, buildUploadPlan, buildModeRestores } = require("./zmodemHelper.cjs");
const never = () => { throw new Error("resolver should not be called"); };
@@ -72,3 +75,153 @@ test("buildModeRestores strips trailing slashes and dedupes duplicate basenames"
[{ path: "/srv/x", mode: "600" }],
);
});
test("queued drag-drop upload keeps temp files until cancel", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const tempPath = path.join(tempDir, "upload.txt");
fs.writeFileSync(tempPath, "payload");
const sentry = createZmodemSentry({
sessionId: "session-1",
onData: () => {},
writeToRemote: () => true,
getWebContents: () => null,
});
sentry.queueDragDropUpload({
filePaths: [tempPath],
remoteNames: ["upload.txt"],
tempPaths: [tempPath],
});
assert.equal(fs.existsSync(tempPath), true);
sentry.cancel();
assert.equal(fs.existsSync(tempPath), false);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("queued drag-drop upload interrupts the remote command when cancelled before detect", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const tempPath = path.join(tempDir, "upload.txt");
fs.writeFileSync(tempPath, "payload");
const writes = [];
let interrupted = false;
const sentry = createZmodemSentry({
sessionId: "session-1",
onData: () => {},
writeToRemote: (buf) => {
writes.push(Buffer.from(buf));
return true;
},
interruptRemote: () => {
interrupted = true;
},
getWebContents: () => null,
dragDropStartTimeoutMs: 0,
});
sentry.queueDragDropUpload({
filePaths: [tempPath],
remoteNames: ["upload.txt"],
tempPaths: [tempPath],
});
sentry.cancel();
assert.equal(fs.existsSync(tempPath), false);
assert.equal(interrupted, true);
assert.equal(writes[0].toString("utf8"), "rz\r");
assert.deepEqual([...writes[1]], [0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18]);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("queued drag-drop upload cleans temp files when rz never starts", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const tempPath = path.join(tempDir, "upload.txt");
fs.writeFileSync(tempPath, "payload");
const writes = [];
const sentry = createZmodemSentry({
sessionId: "session-1",
onData: () => {},
writeToRemote: (buf) => {
writes.push(Buffer.from(buf));
return true;
},
getWebContents: () => null,
dragDropStartTimeoutMs: 1,
});
sentry.queueDragDropUpload({
filePaths: [tempPath],
remoteNames: ["upload.txt"],
tempPaths: [tempPath],
});
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(fs.existsSync(tempPath), false);
assert.equal(writes[0].toString("utf8"), "rz\r");
assert.deepEqual([...writes[1]], [0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18]);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("queued drag-drop upload rejects a second pending upload", () => {
const sentry = createZmodemSentry({
sessionId: "session-1",
onData: () => {},
writeToRemote: () => true,
getWebContents: () => null,
});
sentry.queueDragDropUpload({
filePaths: ["/tmp/first.txt"],
remoteNames: ["first.txt"],
});
assert.throws(
() => sentry.queueDragDropUpload({
filePaths: ["/tmp/second.txt"],
remoteNames: ["second.txt"],
}),
/already pending/,
);
sentry.cancel({ interrupt: false });
});
test("queued drag-drop upload cleans temp files when command write fails", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const firstTempPath = path.join(tempDir, "first.txt");
const secondTempPath = path.join(tempDir, "second.txt");
fs.writeFileSync(firstTempPath, "first");
fs.writeFileSync(secondTempPath, "second");
const sentry = createZmodemSentry({
sessionId: "session-1",
onData: () => {},
writeToRemote: () => {
throw new Error("socket closed");
},
getWebContents: () => null,
});
assert.throws(
() => sentry.queueDragDropUpload({
filePaths: [firstTempPath],
remoteNames: ["first.txt"],
tempPaths: [firstTempPath],
}),
/socket closed/,
);
assert.equal(fs.existsSync(firstTempPath), false);
assert.throws(
() => sentry.queueDragDropUpload({
filePaths: [secondTempPath],
remoteNames: ["second.txt"],
tempPaths: [secondTempPath],
}),
/socket closed/,
);
assert.equal(fs.existsSync(secondTempPath), false);
fs.rmSync(tempDir, { recursive: true, force: true });
});

View File

@@ -199,7 +199,62 @@ function createBridgeRegistrar(context) {
ipcMain.on("netcatty:zmodem:cancel", (_event, payload) => {
const session = sessions.get(payload.sessionId);
if (session?.zmodemSentry) {
session.zmodemSentry.cancel();
session.zmodemSentry.cancel(payload.options);
}
});
ipcMain.handle("netcatty:zmodem:drag-drop-upload", async (_event, payload) => {
const { sessionId, files, uploadCommand } = payload || {};
const session = sessions.get(sessionId);
if (!session?.zmodemSentry?.queueDragDropUpload) {
return { success: false, error: "ZMODEM upload is not available for this session" };
}
if (session.zmodemSentry.isActive?.()) {
return { success: false, error: "ZMODEM transfer already in progress" };
}
const filePaths = [];
const remoteNames = [];
const tempPaths = [];
for (const file of files || []) {
if (!file?.name) continue;
let localPath = file.path;
if (!localPath && file.data) {
localPath = tempDirBridge.getTempFilePath(file.name);
await fs.promises.writeFile(localPath, Buffer.from(file.data));
tempPaths.push(localPath);
}
if (!localPath) continue;
try {
await fs.promises.access(localPath);
} catch {
continue;
}
filePaths.push(localPath);
remoteNames.push(file.remoteName || path.basename(localPath));
}
if (!filePaths.length) {
for (const tempPath of tempPaths) {
try { await fs.promises.unlink(tempPath); } catch { /* ignore */ }
}
return { success: false, error: "No readable files to upload" };
}
try {
session.zmodemSentry.queueDragDropUpload({
filePaths,
remoteNames,
uploadCommand: uploadCommand || "rz\r",
tempPaths,
});
return { success: true };
} catch (err) {
for (const tempPath of tempPaths) {
try { await fs.promises.unlink(tempPath); } catch { /* ignore */ }
}
return { success: false, error: err?.message || String(err) };
}
});

View File

@@ -183,8 +183,15 @@ function createPreloadApi(ctx) {
zmodemListeners.get(sessionId).add(cb);
return () => zmodemListeners.get(sessionId)?.delete(cb);
},
cancelZmodem: (sessionId) => {
ipcRenderer.send("netcatty:zmodem:cancel", { sessionId });
cancelZmodem: (sessionId, options) => {
ipcRenderer.send("netcatty:zmodem:cancel", { sessionId, options });
},
startZmodemDragDropUpload: (sessionId, files, uploadCommand) => {
return ipcRenderer.invoke("netcatty:zmodem:drag-drop-upload", {
sessionId,
files,
uploadCommand,
});
},
onZmodemOverwriteRequest: (sessionId, cb) => {
if (!zmodemOverwriteListeners.has(sessionId)) zmodemOverwriteListeners.set(sessionId, new Set());

86
lib/zmodemDragDrop.ts Normal file
View File

@@ -0,0 +1,86 @@
import type { DropEntry } from "./sftpFileUtils";
import { getPathForFile } from "./sftpFileUtils";
import type { Host } from "../types";
const ZMODEM_RZ_MISSING_MARKER_PREFIX = "\x1b]1337;NetcattyRzMissing=";
const ZMODEM_RZ_MISSING_MARKER_SUFFIX = "\x07";
export type ZmodemDragDropFile = {
path?: string;
name: string;
remoteName: string;
data?: ArrayBuffer;
};
export function supportsZmodemTerminalDragDrop(
host: Host,
isNetworkDevice = false,
): boolean {
if (host.protocol === "local" || isNetworkDevice) return false;
if (host.moshEnabled || host.etEnabled) return true;
return (
host.protocol === "ssh" ||
host.protocol === "telnet" ||
host.protocol === "serial"
);
}
export function supportsZmodemDragDropSftpFallback(host: Host): boolean {
return host.protocol === "ssh" || Boolean(host.moshEnabled || host.etEnabled);
}
export function getZmodemRemoteName(relativePath: string, fallbackName: string): string {
const normalized = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
if (!normalized) return fallbackName;
const segments = normalized.split("/").filter(Boolean);
return segments[segments.length - 1] || fallbackName;
}
function quotePosixShellArg(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`;
}
export function createZmodemRzMissingToken(): string {
return `rz-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
export function buildZmodemDragDropUploadCommand(rzMissingToken: string): string {
const markerFormat = `\\033]1337;NetcattyRzMissing=${rzMissingToken}\\007`;
const script = `if command -v rz >/dev/null 2>&1; then exec rz; else printf ${quotePosixShellArg(markerFormat)}; fi`;
return `sh -lc ${quotePosixShellArg(script)}\r`;
}
export function containsZmodemRzMissingMarker(chunk: string, rzMissingToken: string): boolean {
return chunk.includes(`${ZMODEM_RZ_MISSING_MARKER_PREFIX}${rzMissingToken}${ZMODEM_RZ_MISSING_MARKER_SUFFIX}`);
}
export async function buildZmodemDragDropFiles(
dropEntries: DropEntry[],
): Promise<ZmodemDragDropFile[]> {
const files: ZmodemDragDropFile[] = [];
for (const entry of dropEntries) {
if (entry.isDirectory || !entry.file) continue;
const remoteName = getZmodemRemoteName(entry.relativePath, entry.file.name);
const localPath = getPathForFile(entry.file);
if (localPath) {
files.push({
path: localPath,
name: entry.file.name,
remoteName,
});
continue;
}
const data = await entry.file.arrayBuffer();
files.push({
name: entry.file.name,
remoteName,
data,
});
}
return files;
}

View File

@@ -244,7 +244,17 @@ declare global {
error?: string;
}) => void
): () => void;
cancelZmodem?(sessionId: string): void;
cancelZmodem?(sessionId: string, options?: { interrupt?: boolean }): void;
startZmodemDragDropUpload?(
sessionId: string,
files: Array<{
path?: string;
name: string;
remoteName: string;
data?: ArrayBuffer;
}>,
uploadCommand?: string,
): Promise<{ success: boolean; error?: string }>;
onZmodemOverwriteRequest?(
sessionId: string,
cb: (payload: { sessionId: string; requestId: string; filename: string }) => void