diff --git a/application/i18n/locales/en/terminal.ts b/application/i18n/locales/en/terminal.ts index 4da0a542..53ca4e03 100644 --- a/application/i18n/locales/en/terminal.ts +++ b/application/i18n/locales/en/terminal.ts @@ -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', diff --git a/application/i18n/locales/ru/terminal.ts b/application/i18n/locales/ru/terminal.ts index aa0c1ca8..8bf2fcd1 100644 --- a/application/i18n/locales/ru/terminal.ts +++ b/application/i18n/locales/ru/terminal.ts @@ -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': 'Не удалось обработать перетащенные файлы', diff --git a/application/i18n/locales/zh-CN/vault.ts b/application/i18n/locales/zh-CN/vault.ts index de27a2ff..03772573 100644 --- a/application/i18n/locales/zh-CN/vault.ts +++ b/application/i18n/locales/zh-CN/vault.ts @@ -282,7 +282,9 @@ export const zhCNVaultMessages: 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': '处理拖放文件失败', diff --git a/application/state/useTerminalBackend.ts b/application/state/useTerminalBackend.ts index a110bcb8..20dee25c 100644 --- a/application/state/useTerminalBackend.ts +++ b/application/state/useTerminalBackend.ts @@ -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>[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, diff --git a/components/Terminal.tsx b/components/Terminal.tsx index d063341f..c457ed78 100644 --- a/components/Terminal.tsx +++ b/components/Terminal.tsx @@ -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 = ({ 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 = ({ } = useTerminalDragDrop({ host, isLocalConnection, + isNetworkDevice, onOpenSftp, resolveSftpInitialPath, scrollToBottomAfterProgrammaticInput, @@ -1257,7 +1260,7 @@ const TerminalComponent: React.FC = ({ 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 ; + return ; }; const Terminal = memo(TerminalComponent, terminalPropsAreEqual); diff --git a/components/terminal/TerminalView.tsx b/components/terminal/TerminalView.tsx index a3e37719..bba46170 100644 --- a/components/terminal/TerminalView.tsx +++ b/components/terminal/TerminalView.tsx @@ -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 }) {
{isLocalConnection ? t("terminal.dragDrop.localMessage") - : t("terminal.dragDrop.remoteMessage") + : remoteDragDropUsesZmodem + ? t("terminal.dragDrop.remoteZmodemMessage") + : t("terminal.dragDrop.remoteSftpMessage") }
diff --git a/components/terminal/hooks/useTerminalDragDrop.ts b/components/terminal/hooks/useTerminalDragDrop.ts index 6c9115da..4091e50e 100644 --- a/components/terminal/hooks/useTerminalDragDrop.ts +++ b/components/terminal/hooks/useTerminalDragDrop.ts @@ -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; 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; } +const RZ_MISSING_FALLBACK_TIMEOUT_MS = 2500; + export async function resolveTerminalDropUploadInitialPath( resolveSftpInitialPath: UseTerminalDragDropOptions["resolveSftpInitialPath"], ): Promise { return resolveSftpInitialPath({ preferFreshBackend: true }); } +function createRzMissingWatcher({ + sessionId, + terminalBackend, + token, + timeoutMs = RZ_MISSING_FALLBACK_TIMEOUT_MS, +}: { + sessionId: string; + terminalBackend: Pick; + token: string; + timeoutMs?: number; +}): { promise: Promise<"missing" | "detected" | "timeout">; stop: () => void } { + let settled = false; + let timeout: ReturnType | 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")); } }; diff --git a/components/terminal/terminalDragDropCwd.test.ts b/components/terminal/terminalDragDropCwd.test.ts index 9acc6445..1e113340 100644 --- a/components/terminal/terminalDragDropCwd.test.ts +++ b/components/terminal/terminalDragDropCwd.test.ts @@ -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", diff --git a/electron/bridges/tempDirBridge.cjs b/electron/bridges/tempDirBridge.cjs index b182239d..e6439a22 100644 --- a/electron/bridges/tempDirBridge.cjs +++ b/electron/bridges/tempDirBridge.cjs @@ -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}`); } /** diff --git a/electron/bridges/tempDirBridge.test.cjs b/electron/bridges/tempDirBridge.test.cjs new file mode 100644 index 00000000..ede9de93 --- /dev/null +++ b/electron/bridges/tempDirBridge.test.cjs @@ -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; + } +}); diff --git a/electron/bridges/zmodemHelper.cjs b/electron/bridges/zmodemHelper.cjs index 2d3fec18..89f399ae 100644 --- a/electron/bridges/zmodemHelper.cjs +++ b/electron/bridges/zmodemHelper.cjs @@ -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,22 +677,36 @@ async function handleUpload(zsession, opts) { 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)", - }); + const dragDrop = opts.takeDragDropUpload?.() ?? opts.getDragDropUpload?.(); + let filePaths; + let allNames; + let dragDropTempPaths = []; - if (result.canceled || !result.filePaths.length) { - try { zsession.abort(); } catch { /* ignore */ } - abortRemoteProcess(opts.writeToRemote); - throw new Error("Transfer cancelled"); + 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"], + title: "Select files to upload (ZMODEM)", + }); + + if (result.canceled || !result.filePaths.length) { + try { zsession.abort(); } catch { /* ignore */ } + abortRemoteProcess(opts.writeToRemote); + throw new Error("Transfer cancelled"); + } + + filePaths = result.filePaths; + allNames = filePaths.map((fp) => path.basename(fp)); } - const filePaths = result.filePaths; - const fileStats = filePaths.map((fp) => fs.statSync(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 */ + } + } + } + } } /** diff --git a/electron/bridges/zmodemHelper.test.cjs b/electron/bridges/zmodemHelper.test.cjs index 0c5ffb49..0155eb32 100644 --- a/electron/bridges/zmodemHelper.test.cjs +++ b/electron/bridges/zmodemHelper.test.cjs @@ -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 }); +}); diff --git a/electron/main/registerBridges.cjs b/electron/main/registerBridges.cjs index f3d5141c..24f7654a 100644 --- a/electron/main/registerBridges.cjs +++ b/electron/main/registerBridges.cjs @@ -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) }; } }); diff --git a/electron/preload/api.cjs b/electron/preload/api.cjs index 0d3b0b52..bec9195a 100644 --- a/electron/preload/api.cjs +++ b/electron/preload/api.cjs @@ -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()); diff --git a/lib/zmodemDragDrop.ts b/lib/zmodemDragDrop.ts new file mode 100644 index 00000000..1e2cf672 --- /dev/null +++ b/lib/zmodemDragDrop.ts @@ -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 { + 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; +} diff --git a/types/global/netcatty-bridge-session.d.ts b/types/global/netcatty-bridge-session.d.ts index cf5da097..daaad054 100644 --- a/types/global/netcatty-bridge-session.d.ts +++ b/types/global/netcatty-bridge-session.d.ts @@ -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