Compare commits

...

23 Commits

Author SHA1 Message Date
陈大猫
4758345448 Merge pull request #136 from Nightsuki/fix/ssh-default-key-fallback-all-keys
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: try all default SSH keys for fallback authentication
2026-01-26 23:33:38 +08:00
Nightsuki
4d3fa93083 fix: try all default SSH keys for fallback authentication
Previously, when no explicit auth method was configured, the code would
only try the first available key (id_ed25519) even if the server only
accepted a different key (id_rsa). This caused authentication failures
when users had multiple SSH keys but only some were authorized.

Changes:
- Add findAllDefaultPrivateKeys() to discover all available keys
- Try ssh-agent first (matching regular SSH behavior)
- Try ALL default keys (id_ed25519, id_ecdsa, id_rsa) in order
- Add debug logging for ssh2 auth flow diagnostics
- Improve auth method ordering: agent -> keys -> password -> keyboard
2026-01-26 23:11:26 +08:00
陈大猫
2746aae274 Merge pull request #135 from binaricat/fix/sftp-local-files-freeze
fix: use async exec for Windows hidden file check to prevent UI freeze
2026-01-26 19:39:22 +08:00
bincxz
a7b22b3580 fix: use async exec for Windows hidden file check to prevent UI freeze
The isWindowsHiddenFile function was using execSync which blocks the
main process. When listing directories with many files on Windows,
this caused the app to freeze and show "No response" until all attrib
commands completed.

Changed to async exec with promisify to allow non-blocking execution.

Fixes #134

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 19:36:44 +08:00
陈大猫
a66fcdba02 Merge pull request #133 from binaricat/fix/mfa-partial-success-auth
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: handle partialSuccess in SSH multi-factor authentication
2026-01-26 16:10:08 +08:00
bincxz
73c95fa08e fix: handle partialSuccess in SSH multi-factor authentication
When servers require multi-step authentication (e.g., password + MFA, or
publickey + keyboard-interactive), the previous implementation did not
properly handle the partialSuccess flag from ssh2's authHandler callback.

This caused MFA-only servers to fail connection because keyboard-interactive
was not triggered after the initial auth method succeeded with partialSuccess.

Changes:
- Add partialSuccess handling to try server-requested auth methods
- Track attempted methods to avoid re-trying failed or already-used methods
- Cache the first successful method (not the last) for multi-step flows
  to ensure correct auth order on subsequent connections

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 16:08:55 +08:00
陈大猫
3337cd620e Merge pull request #132 from binaricat/fix/ssh-key-fallback-auth
fix: improve SSH authentication fallback to system keys
2026-01-26 15:37:04 +08:00
bincxz
97bd105564 fix: reorder auth methods - password before agent
Agent may be auto-configured via SSH_AUTH_SOCK rather than explicit
user choice. On servers with PubkeyAuthentication disabled or low
MaxAuthTries, the agent attempt could exhaust auth tries before the
valid password is attempted.

New order: user key -> password -> agent -> default key -> keyboard-interactive

This follows ssh2's default order (None -> Password -> Private Key -> Agent)
more closely and prioritizes explicit user configuration.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 15:29:53 +08:00
bincxz
554c43dfa8 fix: avoid logging agent object which may contain private keys
When connectOpts.agent is a NetcattyAgent (for certificate auth),
it contains _meta with privateKey/passphrase. Logging the full object
would leak credentials to log files. Now only logs a safe identifier.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 15:19:36 +08:00
bincxz
c678f36504 fix: set privateKey when adding publickey fallback in agent mode
ssh2's simple auth handler (array mode) only enables publickey auth
when connectOpts.privateKey is set. Without setting the key, the
"publickey" entry in auth order would be silently skipped.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 15:11:40 +08:00
bincxz
f40a3f075b fix: include agent auth method in dynamic authHandler fallback
The dynamic authHandler for fallback authentication was missing the
"agent" type, which broke agentForwarding functionality. This fix:
- Adds "agent" to the default availableMethods list
- Updates methodName mapping to treat "agent" as "publickey" (since
  agent-based auth uses publickey verification under the hood)
- Adds handler case for agent type that returns "agent" string
- Checks both methodName and method.type for availability

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 15:02:42 +08:00
bincxz
bb40ab464e fix: return string for keyboard-interactive in authHandler
ssh2 requires a prompt function when returning an object for
keyboard-interactive auth. Without it, the method is skipped.

Return the string "keyboard-interactive" instead, which lets ssh2
use its default handling and properly trigger the keyboard-interactive
event for 2FA/MFA prompts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 14:45:21 +08:00
bincxz
4977add389 fix: avoid retrying same default key twice
When no explicit auth method is configured, the default key was being
promoted to connectOpts.privateKey and then added again as publickey-default.
This caused the same key to be attempted twice, wasting auth slots.

Now track when default key is used as primary to skip redundant fallback.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 14:39:21 +08:00
bincxz
2d14655af4 fix: improve SSH authentication fallback to system keys
- Always search for default SSH keys (~/.ssh/id_ed25519, id_ecdsa, id_rsa)
  as fallback authentication method
- Add dynamic authHandler that tries multiple auth methods in sequence:
  user key -> password -> default system key -> keyboard-interactive
- Cache successful auth methods per host to speed up subsequent connections
- Clear auth cache on failure to retry all methods
- Fix password validation to only use non-empty strings
- Add detailed logging for auth flow debugging

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 14:30:55 +08:00
陈大猫
025df8788b Merge pull request #131 from binaricat/fix/context-menu-shortcuts-from-settings
fix: display actual user-configured shortcuts in terminal context menu
2026-01-26 14:08:51 +08:00
bincxz
9e6d110766 fix: display actual user-configured shortcuts in terminal context menu
Previously, the keyboard shortcuts shown in the right-click context menu
were hardcoded and did not reflect user's custom keybindings from settings.

Changes:
- Pass keyBindings prop from Terminal to TerminalContextMenu
- Dynamically look up shortcuts from user's configured keybindings
- Format shortcuts with spaces between keys for better readability
- Handle 'Disabled' shortcuts by hiding the shortcut hint

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 14:01:48 +08:00
陈大猫
347d0a445b Merge pull request #130 from binaricat/feat/copy-tab-context-menu
feat: add Copy Tab option to SSH session context menu
2026-01-26 13:48:28 +08:00
bincxz
e8be0d72de feat: add Copy Tab option to SSH session context menu
Add the ability to duplicate an SSH session by right-clicking on a tab
and selecting "Copy Tab". This creates a new session with the same
connection parameters (host, port, protocol, etc.).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 13:41:11 +08:00
陈大猫
ce34f1bba8 Merge pull request #129 from binaricat/fix/sftp-large-file-upload-and-cancel
fix: use stream-based transfers to prevent OOM and support cancellation
2026-01-26 13:32:24 +08:00
bincxz
9f4272f83c fix: use getPathForFile directly for nested folder files
The previous approach tried to reconstruct paths for nested files using
filePathMap keyed by f.name (base file names), but for folder drops
rootName is the folder name which doesn't exist in the map.

Now we call getPathForFile directly on each result.file, which should
work for all files in Electron. The filePathMap reconstruction is kept
as a fallback.

This ensures large files inside dropped folders use stream transfers
instead of falling back to arrayBuffer() which causes OOM.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 13:17:46 +08:00
bincxz
c158d52dd5 fix: handle close event for local writeStream cancellation
When fs.createWriteStream is destroyed, it emits 'close' but not 'finish'.
Added close event handlers for downloadWithStreams and local-to-local
copy to properly resolve the promise when cancelled.

The 'finished' flag in cleanup() ensures we don't call resolve/reject twice
when both finish and close fire during normal completion.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 13:08:50 +08:00
bincxz
ec8dba360c fix: ensure stream cancellation settles the promise
When streams are destroyed during cancellation, the close/finish event
handler was not calling cleanup if transfer.cancelled was true. This left
the promise pending forever, causing the UI to stay stuck in "uploading".

Now we call cleanup(new Error('Transfer cancelled')) when the stream
closes/finishes and the transfer was cancelled.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 13:00:33 +08:00
bincxz
8b5cc5c302 fix: use stream-based transfers to prevent OOM and support cancellation
- Replace memory-based file uploads with stream transfers for large files
- Add uploadWithStreams and downloadWithStreams functions in transferBridge
- Fix cancel transfer by properly destroying streams instead of throwing
  errors in callbacks (which corrupted SSH connection)
- Fix upload button not triggering upload by copying FileList before
  clearing input (clearing input also clears FileList reference)
- Export getPathForFile utility for obtaining local file paths
- Add startStreamTransfer and cancelTransfer bridge methods

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 12:24:58 +08:00
17 changed files with 1134 additions and 214 deletions

View File

@@ -242,6 +242,7 @@ function App({ settings }: { settings: SettingsState }) {
logViews,
openLogView,
closeLogView,
copySession,
} = useSessionState();
// isMacClient is used for window controls styling
@@ -864,6 +865,7 @@ function App({ settings }: { settings: SettingsState }) {
isMacClient={isMacClient}
onCloseSession={closeSession}
onRenameSession={startSessionRename}
onCopySession={copySession}
onRenameWorkspace={startWorkspaceRename}
onCloseWorkspace={closeWorkspace}
onCloseLogView={closeLogView}

View File

@@ -1110,6 +1110,7 @@ const en: Messages = {
'tabs.closeLogViewAria': 'Close log view',
'tabs.logPrefix': 'Log:',
'tabs.logLocal': 'Local',
'tabs.copyTab': 'Copy Tab',
'keychain.edit.labelRequired': 'Label *',
'keychain.edit.keyLabelPlaceholder': 'Key label',
'keychain.edit.privateKeyRequired': 'Private key *',

View File

@@ -1099,6 +1099,7 @@ const zhCN: Messages = {
'tabs.closeLogViewAria': '关闭日志视图',
'tabs.logPrefix': '日志:',
'tabs.logLocal': '本地',
'tabs.copyTab': '复制标签页',
'keychain.edit.labelRequired': 'Label *',
'keychain.edit.keyLabelPlaceholder': '密钥 Label',
'keychain.edit.privateKeyRequired': '私钥 *',

View File

@@ -344,6 +344,25 @@ export const useSftpExternalOperations = (
}
: undefined,
cancelSftpUpload: bridge?.cancelSftpUpload,
// Stream transfer for large files (avoids loading into memory)
startStreamTransfer: bridge?.startStreamTransfer
? async (options, onProgress, onComplete, onError) => {
const b = netcattyBridge.get();
if (!b?.startStreamTransfer) {
return { transferId: options.transferId, error: 'Stream transfer not available' };
}
try {
const result = await b.startStreamTransfer(options, onProgress, onComplete, onError);
return result;
} catch (error) {
return {
transferId: options.transferId,
error: error instanceof Error ? error.message : String(error),
};
}
}
: undefined,
cancelTransfer: bridge?.cancelTransfer,
};
}, []);

View File

@@ -547,6 +547,31 @@ export const useSessionState = () => {
});
}, [setActiveTabId]);
// Copy a session - creates a new session with the same host connection
const copySession = useCallback((sessionId: string) => {
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session) return prevSessions;
// Create a new session with the same connection info
const newSession: TerminalSession = {
id: crypto.randomUUID(),
hostId: session.hostId,
hostLabel: session.hostLabel,
hostname: session.hostname,
username: session.username,
status: 'connecting',
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
serialConfig: session.serialConfig,
};
setActiveTabId(newSession.id);
return [...prevSessions, newSession];
});
}, [setActiveTabId]);
// Toggle broadcast mode for a workspace
const toggleBroadcast = useCallback((workspaceId: string) => {
setBroadcastWorkspaceIds(prev => {
@@ -662,5 +687,7 @@ export const useSessionState = () => {
logViews,
openLogView,
closeLogView,
// Copy session
copySession,
};
};

View File

@@ -76,6 +76,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
selectApplication,
downloadSftpToTempAndOpen,
cancelSftpUpload,
startStreamTransfer,
cancelTransfer,
} = useSftpBackend();
const { t } = useI18n();
const { sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
@@ -365,6 +367,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
mkdirLocal,
mkdirSftp: mkdirSftpWithEncoding,
cancelSftpUpload,
startStreamTransfer,
cancelTransfer,
setLoading,
t,
});

View File

@@ -919,6 +919,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<TerminalContextMenu
hasSelection={hasSelection}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
rightClickBehavior={terminalSettings?.rightClickBehavior}
onCopy={terminalContextActions.onCopy}
onPaste={terminalContextActions.onPaste}

View File

@@ -25,6 +25,7 @@ interface TopTabsProps {
isMacClient: boolean;
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
onRenameSession: (sessionId: string) => void;
onCopySession: (sessionId: string) => void;
onRenameWorkspace: (workspaceId: string) => void;
onCloseWorkspace: (workspaceId: string) => void;
onCloseLogView: (logViewId: string) => void;
@@ -121,6 +122,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
isMacClient,
onCloseSession,
onRenameSession,
onCopySession,
onRenameWorkspace,
onCloseWorkspace,
onCloseLogView,
@@ -410,6 +412,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<ContextMenuItem onClick={() => onRenameSession(session.id)}>
{t('common.rename')}
</ContextMenuItem>
<ContextMenuItem onClick={() => onCopySession(session.id)}>
{t('tabs.copyTab')}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(session.id)}>
{t('common.close')}
</ContextMenuItem>

View File

@@ -49,6 +49,22 @@ interface UseSftpModalTransfersParams {
mkdirLocal: (path: string) => Promise<void>;
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
cancelSftpUpload?: (taskId: string) => Promise<unknown>;
startStreamTransfer?: (
options: {
transferId: string;
sourcePath: string;
targetPath: string;
sourceType: 'local' | 'sftp';
targetType: 'local' | 'sftp';
sourceSftpId?: string;
targetSftpId?: string;
totalBytes?: number;
},
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void
) => Promise<{ transferId: string; totalBytes?: number; error?: string }>;
cancelTransfer?: (transferId: string) => Promise<void>;
setLoading: (loading: boolean) => void;
t: (key: string, params?: Record<string, unknown>) => string;
}
@@ -81,6 +97,8 @@ export const useSftpModalTransfers = ({
mkdirLocal,
mkdirSftp,
cancelSftpUpload,
startStreamTransfer,
cancelTransfer,
setLoading,
t,
}: UseSftpModalTransfersParams): UseSftpModalTransfersResult => {
@@ -174,8 +192,35 @@ export const useSftpModalTransfers = ({
}
},
cancelSftpUpload,
startStreamTransfer: startStreamTransfer ? async (
options,
onProgress,
onComplete,
onError
) => {
try {
const result = await startStreamTransfer(options, onProgress, onComplete, onError);
const wasCancelled = cancelledTransferIdsRef.current.has(options.transferId);
if (wasCancelled) {
cancelledTransferIdsRef.current.delete(options.transferId);
}
// Handle case where result might be undefined (bridge not available)
if (!result) {
return { transferId: options.transferId, error: 'Stream transfer not available' };
}
return { ...result, cancelled: wasCancelled };
} catch (error) {
const wasCancelled = cancelledTransferIdsRef.current.has(options.transferId);
if (wasCancelled) {
cancelledTransferIdsRef.current.delete(options.transferId);
return { transferId: options.transferId, cancelled: true };
}
return { transferId: options.transferId, error: error instanceof Error ? error.message : String(error) };
}
} : undefined,
cancelTransfer,
};
}, [writeLocalFile, mkdirLocal, mkdirSftp, writeSftpBinary, writeSftpBinaryWithProgress, cancelSftpUpload]);
}, [writeLocalFile, mkdirLocal, mkdirSftp, writeSftpBinary, writeSftpBinaryWithProgress, cancelSftpUpload, startStreamTransfer, cancelTransfer]);
// Create upload callbacks
const createUploadCallbacks = useCallback((): UploadCallbacks => {
@@ -290,6 +335,11 @@ export const useSftpModalTransfers = ({
const handleUploadMultiple = useCallback(
async (fileList: FileList) => {
console.log('[useSftpModalTransfers] handleUploadMultiple called', {
length: fileList.length,
currentPath,
isLocalSession
});
if (fileList.length === 0) return;
setUploading(true);
@@ -392,14 +442,85 @@ export const useSftpModalTransfers = ({
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t],
);
// Handle upload from File array (used by file input after copying files)
const handleUploadFromFiles = useCallback(
async (files: File[]) => {
console.log('[useSftpModalTransfers] handleUploadFromFiles called', {
length: files.length,
currentPath,
isLocalSession
});
if (files.length === 0) return;
setUploading(true);
// Get SFTP ID for remote sessions
let sftpId: string | null = null;
if (!isLocalSession) {
sftpId = await ensureSftp();
cachedSftpIdRef.current = sftpId;
}
// Create controller for cancellation
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks();
try {
await uploadFromFileList(
files,
{
targetPath: currentPath,
sftpId,
isLocal: isLocalSession,
bridge: createUploadBridge,
joinPath,
callbacks,
},
controller
);
await loadFiles(currentPath, { force: true });
// Auto-clear completed tasks after 3 seconds
setTimeout(() => {
setUploadTasks(prev => prev.filter(t => t.status !== "completed"));
}, 3000);
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP"
);
} finally {
setUploading(false);
uploadControllerRef.current = null;
cachedSftpIdRef.current = null;
}
},
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t],
);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
console.log('[useSftpModalTransfers] handleFileSelect called', {
files: e.target.files,
length: e.target.files?.length
});
if (e.target.files && e.target.files.length > 0) {
void handleUploadMultiple(e.target.files);
console.log('[useSftpModalTransfers] Starting upload for', e.target.files.length, 'files');
// Copy the files before clearing the input, because clearing the input
// will also clear the FileList reference
const files = Array.from(e.target.files);
// Clear input first to allow selecting the same file again
e.target.value = "";
// Now start the upload with the copied files
void handleUploadFromFiles(files);
} else {
e.target.value = "";
}
e.target.value = "";
},
[handleUploadMultiple],
[handleUploadFromFiles],
);
const handleDrag = useCallback((e: React.DragEvent) => {
@@ -425,14 +546,19 @@ export const useSftpModalTransfers = ({
);
const cancelUpload = useCallback(async () => {
console.log('[useSftpModalTransfers] cancelUpload called');
const controller = uploadControllerRef.current;
if (controller) {
// Mark all active transfer IDs as cancelled before calling cancel
const activeIds = controller.getActiveTransferIds();
console.log('[useSftpModalTransfers] Active transfer IDs:', activeIds);
for (const id of activeIds) {
cancelledTransferIdsRef.current.add(id);
}
await controller.cancel();
console.log('[useSftpModalTransfers] controller.cancel() completed');
} else {
console.log('[useSftpModalTransfers] No controller found');
}
// Always clear all uploading/pending tasks immediately, even without controller

View File

@@ -12,7 +12,7 @@ import {
} from 'lucide-react';
import React, { useCallback } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { RightClickBehavior } from '../../domain/models';
import { KeyBinding, RightClickBehavior } from '../../domain/models';
import {
ContextMenu,
ContextMenuContent,
@@ -26,6 +26,7 @@ export interface TerminalContextMenuProps {
children: React.ReactNode;
hasSelection?: boolean;
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
keyBindings?: KeyBinding[];
rightClickBehavior?: RightClickBehavior;
onCopy?: () => void;
onPaste?: () => void;
@@ -41,6 +42,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
children,
hasSelection = false,
hotkeyScheme = 'mac',
keyBindings,
rightClickBehavior = 'context-menu',
onCopy,
onPaste,
@@ -54,12 +56,22 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
const { t } = useI18n();
const isMac = hotkeyScheme === 'mac';
const copyShortcut = isMac ? '⌘C' : 'Ctrl+Shift+C';
const pasteShortcut = isMac ? '⌘V' : 'Ctrl+Shift+V';
const selectAllShortcut = isMac ? '⌘A' : 'Ctrl+Shift+A';
const splitHShortcut = isMac ? '⌘D' : 'Ctrl+Shift+D';
const splitVShortcut = isMac ? '⌘E' : 'Ctrl+Shift+E';
const clearShortcut = isMac ? '⌘K' : 'Ctrl+L';
// Helper to get shortcut from keyBindings and format for display
const getShortcut = (bindingId: string): string => {
const binding = keyBindings?.find(b => b.id === bindingId);
if (!binding) return '';
const key = isMac ? binding.mac : binding.pc;
if (!key || key === 'Disabled') return '';
// Replace " + " with space for cleaner display (e.g., "⌘ + Shift + D" → "⌘ Shift D")
return key.replace(/\s*\+\s*/g, ' ').trim();
};
const copyShortcut = getShortcut('copy');
const pasteShortcut = getShortcut('paste');
const selectAllShortcut = getShortcut('select-all');
const splitHShortcut = getShortcut('split-horizontal');
const splitVShortcut = getShortcut('split-vertical');
const clearShortcut = getShortcut('clear-buffer');
const showContextMenu = rightClickBehavior === 'context-menu';

View File

@@ -6,19 +6,23 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { execSync } = require("node:child_process");
const { exec } = require("node:child_process");
const { promisify } = require("node:util");
const execAsync = promisify(exec);
/**
* Check if a file is hidden on Windows using the attrib command
* Returns true if the file has the hidden attribute set
* Uses async exec to avoid blocking the main process
*/
function isWindowsHiddenFile(filePath) {
async function isWindowsHiddenFile(filePath) {
if (process.platform !== "win32") return false;
try {
const output = execSync(`attrib "${filePath}"`, { encoding: "utf8" });
const { stdout } = await execAsync(`attrib "${filePath}"`);
// attrib output format: " H R filename" where H = hidden, R = read-only, etc.
// The attributes appear in the first ~10 characters before the path
const attrPart = output.substring(0, output.indexOf(filePath)).toUpperCase();
const attrPart = stdout.substring(0, stdout.indexOf(filePath)).toUpperCase();
return attrPart.includes("H");
} catch (err) {
console.warn(`Could not check hidden attribute for ${filePath}:`, err.message);
@@ -67,7 +71,7 @@ async function listLocalDir(event, payload) {
}
// Check for Windows hidden attribute
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
result[i] = {
name: entry.name,
@@ -86,7 +90,7 @@ async function listLocalDir(event, payload) {
const lstat = await fs.promises.lstat(fullPath);
if (lstat.isSymbolicLink()) {
// Broken symlink
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
result[i] = {
name: brokenEntry.name,
type: "symlink",

View File

@@ -71,14 +71,18 @@ function isKeyEncrypted(keyContent) {
*/
function findDefaultPrivateKey() {
const sshDir = path.join(os.homedir(), ".ssh");
log("Searching for default SSH keys", { sshDir, keyNames: DEFAULT_KEY_NAMES });
for (const name of DEFAULT_KEY_NAMES) {
const keyPath = path.join(sshDir, name);
log("Checking key file", { keyPath, exists: fs.existsSync(keyPath) });
if (fs.existsSync(keyPath)) {
try {
const privateKey = fs.readFileSync(keyPath, "utf8");
// Skip encrypted keys - they require a passphrase and would abort
// authentication before password/keyboard-interactive can be tried
if (isKeyEncrypted(privateKey)) {
const encrypted = isKeyEncrypted(privateKey);
log("Key file read", { keyPath, keyName: name, encrypted, keyLength: privateKey.length });
if (encrypted) {
log("Skipping encrypted default key", { keyPath, keyName: name });
continue;
}
@@ -90,9 +94,40 @@ function findDefaultPrivateKey() {
}
}
}
log("No suitable default SSH key found");
return null;
}
/**
* Find ALL default SSH private keys from user's ~/.ssh directory
* Returns all non-encrypted keys for fallback authentication
* @returns {Array<{ privateKey: string, keyPath: string, keyName: string }>}
*/
function findAllDefaultPrivateKeys() {
const sshDir = path.join(os.homedir(), ".ssh");
const keys = [];
log("Searching for ALL default SSH keys", { sshDir, keyNames: DEFAULT_KEY_NAMES });
for (const name of DEFAULT_KEY_NAMES) {
const keyPath = path.join(sshDir, name);
if (fs.existsSync(keyPath)) {
try {
const privateKey = fs.readFileSync(keyPath, "utf8");
const encrypted = isKeyEncrypted(privateKey);
if (!encrypted) {
keys.push({ privateKey, keyPath, keyName: name });
log("Found default key for fallback", { keyPath, keyName: name });
} else {
log("Skipping encrypted key", { keyPath, keyName: name });
}
} catch (e) {
log("Failed to read key", { keyPath, error: e.message });
}
}
}
log("Found default SSH keys", { count: keys.length, keyNames: keys.map(k => k.keyName) });
return keys;
}
/**
* Check if Windows SSH Agent service is running
* @returns {Promise<{ running: boolean, startupType: string | null, error: string | null }>}
@@ -124,13 +159,45 @@ const logFile = path.join(require("os").tmpdir(), "netcatty-ssh.log");
const log = (msg, data) => {
const line = `[${new Date().toISOString()}] ${msg} ${data ? JSON.stringify(data) : ""}\n`;
try { fs.appendFileSync(logFile, line); } catch { }
console.log("[SSH]", msg, data || "");
console.log("[SSH]", msg, data ? JSON.stringify(data, null, 2) : "");
};
// Session storage - shared reference passed from main
let sessions = null;
let electronModule = null;
// Authentication method cache - remembers successful auth methods per host
// Key format: "username@hostname:port"
// Value: { method: "password" | "publickey" | "publickey-default" }
// Cache persists until auth failure, then cleared to retry all methods
const authMethodCache = new Map();
function getAuthCacheKey(username, hostname, port) {
return `${username}@${hostname}:${port || 22}`;
}
function getCachedAuthMethod(username, hostname, port) {
const key = getAuthCacheKey(username, hostname, port);
const cached = authMethodCache.get(key);
if (cached) {
log("Using cached auth method", { key, method: cached.method });
return cached.method;
}
return null;
}
function setCachedAuthMethod(username, hostname, port, method) {
const key = getAuthCacheKey(username, hostname, port);
log("Caching successful auth method", { key, method });
authMethodCache.set(key, { method });
}
function clearCachedAuthMethod(username, hostname, port) {
const key = getAuthCacheKey(username, hostname, port);
log("Clearing cached auth method", { key });
authMethodCache.delete(key);
}
// Normalize charset inputs (often provided as bare encodings like "UTF-8")
// into a usable LANG locale for remote shells.
function resolveLangFromCharset(charset) {
@@ -408,21 +475,53 @@ async function startSSHSession(event, options) {
}
}
if (options.password) {
if (options.password && typeof options.password === "string" && options.password.trim().length > 0) {
connectOpts.password = options.password;
}
// Fallback to default SSH key if no authentication method is configured
let usedDefaultKey = null;
// Always try to find default SSH keys for fallback authentication
// This allows fallback even when password auth fails
let defaultKeyInfo = null;
let allDefaultKeys = [];
let usedDefaultKeyAsPrimary = false;
const defaultKey = findDefaultPrivateKey();
if (defaultKey) {
defaultKeyInfo = defaultKey;
log("Found default SSH key for fallback", { keyPath: defaultKey.keyPath, keyName: defaultKey.keyName });
}
// Also find ALL default keys for comprehensive fallback
allDefaultKeys = findAllDefaultPrivateKeys();
// If no primary auth method configured, try ssh-agent first, then ALL default keys
if (!connectOpts.privateKey && !connectOpts.password && !connectOpts.agent) {
const defaultKey = findDefaultPrivateKey();
if (defaultKey) {
log("Using default SSH key as fallback", { keyPath: defaultKey.keyPath });
connectOpts.privateKey = defaultKey.privateKey;
usedDefaultKey = defaultKey;
// First, try to use ssh-agent if available (this is what regular SSH does)
const sshAgentSocket = process.platform === "win32"
? "\\\\.\\pipe\\openssh-ssh-agent"
: process.env.SSH_AUTH_SOCK;
if (sshAgentSocket) {
log("No auth method configured, trying ssh-agent first", { agentSocket: sshAgentSocket });
connectOpts.agent = sshAgentSocket;
}
// Mark that we need to try all default keys (handled in authMethods below)
if (allDefaultKeys.length > 0) {
log("Will try all default SSH keys as fallback", { count: allDefaultKeys.length, keyNames: allDefaultKeys.map(k => k.keyName) });
// Set first key for connectOpts.privateKey (required for ssh2 to allow publickey auth)
connectOpts.privateKey = allDefaultKeys[0].privateKey;
usedDefaultKeyAsPrimary = true;
} else {
log("No default SSH key found in ~/.ssh directory");
}
}
log("Final auth configuration", {
hasPrivateKey: !!connectOpts.privateKey,
hasPassword: !!connectOpts.password,
hasAgent: !!connectOpts.agent,
hasDefaultKeyFallback: !!defaultKeyInfo,
});
// Agent forwarding
if (options.agentForwarding) {
connectOpts.agentForward = true;
@@ -435,12 +534,237 @@ async function startSSHSession(event, options) {
}
}
// Prefer agent-based auth when we created an in-process agent (cert)
// Build authentication handler with fallback support
// ssh2 authHandler can be a function that returns the next auth method to try
// Check if we have a cached successful auth method for this host
const cachedMethod = getCachedAuthMethod(connectOpts.username, options.hostname, options.port);
// Track which method succeeded for caching
let lastTriedMethod = null;
if (authAgent) {
const order = ["agent"];
// Allow password fallback if provided
if (connectOpts.password) order.push("password");
// Add default key fallback if available and no user key configured
// Must also set connectOpts.privateKey for ssh2 to actually try publickey auth
if (defaultKeyInfo && !options.privateKey) {
connectOpts.privateKey = defaultKeyInfo.privateKey;
order.push("publickey");
}
order.push("keyboard-interactive");
connectOpts.authHandler = order;
log("Auth order (agent mode)", { order });
} else {
// Build dynamic auth handler for fallback support
const authMethods = [];
// First try user-configured key if available (explicit user choice)
if (connectOpts.privateKey && !usedDefaultKeyAsPrimary) {
authMethods.push({ type: "publickey", key: connectOpts.privateKey, passphrase: connectOpts.passphrase, id: "publickey-user" });
}
// Then try agent if configured (try agent before password since it's usually faster)
if (connectOpts.agent) {
authMethods.push({ type: "agent", id: "agent" });
}
// Then try password if available (explicit user choice)
if (connectOpts.password) {
authMethods.push({ type: "password", id: "password" });
}
// Then try ALL default SSH keys as fallback (not just the first one!)
// This is critical because different servers may have different keys in authorized_keys
if (usedDefaultKeyAsPrimary && allDefaultKeys.length > 0) {
for (const keyInfo of allDefaultKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
isDefault: true,
id: `publickey-default-${keyInfo.keyName}`
});
}
} else if (defaultKeyInfo && !options.privateKey && !usedDefaultKeyAsPrimary) {
// Single default key fallback (when user has configured other auth methods)
authMethods.push({ type: "publickey", key: defaultKeyInfo.privateKey, isDefault: true, id: "publickey-default" });
}
// Finally try keyboard-interactive
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
log("Auth methods configured", {
methods: authMethods.map(m => ({ type: m.type, id: m.id, isDefault: m.isDefault || false })),
cachedMethod,
usedDefaultKeyAsPrimary
});
// Reorder methods based on cached successful method
if (cachedMethod) {
const cachedIndex = authMethods.findIndex(m => m.id === cachedMethod);
if (cachedIndex > 0) {
const [cachedAuthMethod] = authMethods.splice(cachedIndex, 1);
authMethods.unshift(cachedAuthMethod);
log("Reordered auth methods based on cache", {
methods: authMethods.map(m => m.id)
});
}
}
// Use dynamic authHandler if we have multiple auth options
if (authMethods.length > 1) {
let authIndex = 0;
// Track methods that have been attempted (to avoid re-trying on failure)
// This prevents reusing the same key when server requires multiple publickey auth steps
// and also prevents re-attempting failed methods
const attemptedMethodIds = new Set();
// Track the first successful method for caching (not the last one in multi-step flows)
let firstSuccessfulMethod = null;
// Track if we've gone through a partialSuccess flow (multi-step auth)
let hadPartialSuccess = false;
connectOpts.authHandler = (methodsLeft, partialSuccess, callback) => {
log("authHandler called", { methodsLeft, partialSuccess, authIndex, attemptedMethodIds: Array.from(attemptedMethodIds) });
// methodsLeft can be null on first call (before server responds with available methods)
// Include "agent" for SSH agent-based auth (used with agentForwarding)
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
// Handle partialSuccess case (e.g., password succeeded but server requires additional auth like MFA)
// When partialSuccess is true, we should try the remaining methods the server is asking for
if (partialSuccess && methodsLeft && methodsLeft.length > 0) {
hadPartialSuccess = true;
// Record the first successful method (the one that triggered partialSuccess)
if (lastTriedMethod && !firstSuccessfulMethod) {
firstSuccessfulMethod = lastTriedMethod;
log("Recorded first successful method for caching", { method: firstSuccessfulMethod });
}
// Mark the last tried method as attempted (it succeeded, so we shouldn't retry it)
if (lastTriedMethod) {
attemptedMethodIds.add(lastTriedMethod);
log("Marked method as attempted (partial success)", { method: lastTriedMethod });
}
log("Partial success - server requires additional auth", { methodsLeft, attemptedMethodIds: Array.from(attemptedMethodIds) });
// Find a method from our list that matches what the server wants
// Skip methods that have already been attempted
for (const serverMethod of methodsLeft) {
// Map server method names to our method types
const matchingMethod = authMethods.find(m => {
// Skip already attempted methods
if (attemptedMethodIds.has(m.id)) return false;
if (serverMethod === "keyboard-interactive" && m.type === "keyboard-interactive") return true;
if (serverMethod === "password" && m.type === "password") return true;
if (serverMethod === "publickey" && (m.type === "publickey" || m.type === "agent")) return true;
return false;
});
if (matchingMethod) {
log("Found matching method for partial success", { serverMethod, matchingMethod: matchingMethod.id });
// Mark as attempted BEFORE returning to prevent re-use on failure
attemptedMethodIds.add(matchingMethod.id);
lastTriedMethod = matchingMethod.id;
if (matchingMethod.type === "keyboard-interactive") {
log("Trying keyboard-interactive auth (partial success)", { id: matchingMethod.id });
return callback("keyboard-interactive");
} else if (matchingMethod.type === "password") {
log("Trying password auth (partial success)", { id: matchingMethod.id });
return callback({
type: "password",
username: connectOpts.username,
password: connectOpts.password,
});
} else if (matchingMethod.type === "agent") {
const agentType = typeof connectOpts.agent === "string" ? "path" : "NetcattyAgent";
log("Trying agent auth (partial success)", { id: matchingMethod.id, agentType });
return callback("agent");
} else if (matchingMethod.type === "publickey") {
log("Trying publickey auth (partial success)", { id: matchingMethod.id });
return callback({
type: "publickey",
username: connectOpts.username,
key: matchingMethod.key,
passphrase: matchingMethod.passphrase,
});
}
}
}
// No matching method found for partial success
log("No matching method found for partial success requirements", { methodsLeft });
return callback(false);
}
while (authIndex < authMethods.length) {
const method = authMethods[authIndex];
authIndex++;
// Skip methods that have already been attempted (e.g., during partial success handling)
if (attemptedMethodIds.has(method.id)) {
log("Skipping already attempted method", { method: method.id });
continue;
}
// Check if this method is still available on server
// Note: "agent" uses "publickey" as the underlying method type
const methodName = method.type === "password" ? "password" :
method.type === "publickey" ? "publickey" :
method.type === "agent" ? "publickey" : "keyboard-interactive";
if (!availableMethods.includes(methodName) && !availableMethods.includes(method.type)) {
log("Auth method not available on server, skipping", { method: method.id });
continue;
}
// Mark as attempted BEFORE returning
attemptedMethodIds.add(method.id);
lastTriedMethod = method.id;
if (method.type === "agent") {
// Only log safe identifier, not the full agent object which may contain private keys
const agentType = typeof connectOpts.agent === "string" ? "path" : "NetcattyAgent";
log("Trying agent auth", { id: method.id, agentType });
// Return "agent" string to use SSH agent for authentication
return callback("agent");
} else if (method.type === "publickey") {
log("Trying publickey auth", { id: method.id, isDefault: method.isDefault || false });
return callback({
type: "publickey",
username: connectOpts.username,
key: method.key,
passphrase: method.passphrase,
});
} else if (method.type === "password") {
log("Trying password auth", { id: method.id });
return callback({
type: "password",
username: connectOpts.username,
password: connectOpts.password,
});
} else if (method.type === "keyboard-interactive") {
log("Trying keyboard-interactive auth", { id: method.id });
// Return string instead of object - ssh2 requires a prompt function
// for keyboard-interactive objects. Returning the string lets ssh2
// use its default handling and trigger the keyboard-interactive event.
return callback("keyboard-interactive");
}
}
log("All auth methods exhausted");
return callback(false);
};
// Store method reference for success callback
// For multi-step auth (partialSuccess), cache the first successful method, not the last
// This ensures next connection starts with the correct first factor
connectOpts._lastTriedMethodRef = () => {
if (hadPartialSuccess && firstSuccessfulMethod) {
log("Using first successful method for cache (multi-step auth)", { firstSuccessfulMethod });
return firstSuccessfulMethod;
}
return lastTriedMethod;
};
}
}
// Handle chain/proxy connections
@@ -476,6 +800,15 @@ async function startSSHSession(event, options) {
const logPrefix = hasJumpHosts ? '[Chain]' : '[SSH]';
conn.on("ready", () => {
console.log(`${logPrefix} ${options.hostname} ready`);
// Cache the successful auth method
if (connectOpts._lastTriedMethodRef) {
const successMethod = connectOpts._lastTriedMethodRef();
if (successMethod) {
setCachedAuthMethod(connectOpts.username, options.hostname, options.port, successMethod);
}
}
if (hasJumpHosts || hasProxy) {
sendProgress(totalHops, totalHops, options.hostname, 'connected');
}
@@ -584,8 +917,9 @@ async function startSSHSession(event, options) {
err.message?.toLowerCase().includes('password') ||
err.level === 'client-authentication';
// Use log instead of error for auth failures (normal fallback scenario)
// Clear cached auth method on auth failure so next attempt tries all methods
if (isAuthError) {
clearCachedAuthMethod(connectOpts.username, options.hostname, options.port);
console.log(`${logPrefix} ${options.hostname} auth failed:`, err.message);
safeSend(contents, "netcatty:auth:failed", {
sessionId,
@@ -670,23 +1004,39 @@ async function startSSHSession(event, options) {
// Enable keyboard-interactive authentication in authHandler
if (connectOpts.authHandler) {
// Note: If authHandler is a function (for fallback support), keyboard-interactive
// is already included in the auth methods list
if (Array.isArray(connectOpts.authHandler)) {
// Add keyboard-interactive after the existing methods
if (!connectOpts.authHandler.includes("keyboard-interactive")) {
connectOpts.authHandler.push("keyboard-interactive");
}
} else {
} else if (typeof connectOpts.authHandler !== "function") {
// Create authHandler with keyboard-interactive support
// This path is taken when usedDefaultKeyAsPrimary=true (only keyboard-interactive in authMethods)
// Using array format is more reliable - ssh2 uses connectOpts credentials directly
const authMethods = [];
// Try agent FIRST (this is what regular SSH does - it checks ssh-agent before key files)
if (connectOpts.agent) authMethods.push("agent");
if (connectOpts.privateKey) authMethods.push("publickey");
if (connectOpts.password) authMethods.push("password");
authMethods.push("keyboard-interactive");
connectOpts.authHandler = authMethods;
log("Using simple array authHandler", { authMethods, usedDefaultKeyAsPrimary });
}
// If authHandler is a function, it already handles keyboard-interactive
// Increase timeout to allow for keyboard-interactive auth
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
// Enable debug logging for ssh2 to diagnose auth issues
connectOpts.debug = (msg) => {
// Only log auth-related messages to avoid noise
if (msg.includes('Auth') || msg.includes('auth') || msg.includes('publickey') || msg.includes('keyboard')) {
log("ssh2 debug", { msg });
}
};
console.log(`${logPrefix} Connecting to ${options.hostname}...`);
conn.connect(connectOpts);
});

View File

@@ -23,6 +23,138 @@ function init(deps) {
electronModule = deps.electronModule;
}
/**
* Upload a local file to SFTP using streams (supports cancellation)
*/
async function uploadWithStreams(localPath, remotePath, client, fileSize, transfer, sendProgress) {
return new Promise((resolve, reject) => {
const readStream = fs.createReadStream(localPath);
// Get the underlying sftp object from ssh2-sftp-client
const sftp = client.sftp;
if (!sftp) {
reject(new Error("SFTP client not ready"));
return;
}
const writeStream = sftp.createWriteStream(remotePath);
let transferred = 0;
let finished = false;
// Store streams for cancellation
transfer.readStream = readStream;
transfer.writeStream = writeStream;
const cleanup = (err) => {
if (finished) return;
finished = true;
// Remove listeners to prevent memory leaks
readStream.removeAllListeners();
writeStream.removeAllListeners();
if (err) {
// Destroy streams on error
try { readStream.destroy(); } catch {}
try { writeStream.destroy(); } catch {}
reject(err);
} else {
resolve();
}
};
readStream.on('data', (chunk) => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
return;
}
transferred += chunk.length;
sendProgress(transferred, fileSize);
});
readStream.on('error', (err) => cleanup(err));
writeStream.on('error', (err) => cleanup(err));
writeStream.on('close', () => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
} else {
cleanup(null);
}
});
readStream.pipe(writeStream);
});
}
/**
* Download from SFTP to local file using streams (supports cancellation)
*/
async function downloadWithStreams(remotePath, localPath, client, fileSize, transfer, sendProgress) {
return new Promise((resolve, reject) => {
// Get the underlying sftp object from ssh2-sftp-client
const sftp = client.sftp;
if (!sftp) {
reject(new Error("SFTP client not ready"));
return;
}
const readStream = sftp.createReadStream(remotePath);
const writeStream = fs.createWriteStream(localPath);
let transferred = 0;
let finished = false;
// Store streams for cancellation
transfer.readStream = readStream;
transfer.writeStream = writeStream;
const cleanup = (err) => {
if (finished) return;
finished = true;
// Remove listeners to prevent memory leaks
readStream.removeAllListeners();
writeStream.removeAllListeners();
if (err) {
// Destroy streams on error
try { readStream.destroy(); } catch {}
try { writeStream.destroy(); } catch {}
reject(err);
} else {
resolve();
}
};
readStream.on('data', (chunk) => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
return;
}
transferred += chunk.length;
sendProgress(transferred, fileSize);
});
readStream.on('error', (err) => cleanup(err));
writeStream.on('error', (err) => cleanup(err));
// Handle normal completion
writeStream.on('finish', () => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
} else {
cleanup(null);
}
});
// Handle stream destruction (destroy() emits 'close' but not 'finish')
writeStream.on('close', () => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
}
});
readStream.pipe(writeStream);
});
}
/**
* Start a file transfer
*/
@@ -40,17 +172,18 @@ async function startTransfer(event, payload) {
targetEncoding,
} = payload;
const sender = event.sender;
// Register transfer for cancellation
activeTransfers.set(transferId, { cancelled: false });
const transfer = { cancelled: false, readStream: null, writeStream: null };
activeTransfers.set(transferId, transfer);
let lastTime = Date.now();
let lastTransferred = 0;
let speed = 0;
const sendProgress = (transferred, total) => {
if (activeTransfers.get(transferId)?.cancelled) return;
if (transfer.cancelled) return;
const now = Date.now();
const elapsed = now - lastTime;
if (elapsed >= 100) {
@@ -58,25 +191,23 @@ async function startTransfer(event, payload) {
lastTime = now;
lastTransferred = transferred;
}
sender.send("netcatty:transfer:progress", { transferId, transferred, speed, totalBytes: total });
};
const sendComplete = () => {
activeTransfers.delete(transferId);
sender.send("netcatty:transfer:complete", { transferId });
};
const sendError = (error) => {
activeTransfers.delete(transferId);
sender.send("netcatty:transfer:error", { transferId, error: error.message || String(error) });
};
const isCancelled = () => activeTransfers.get(transferId)?.cancelled;
try {
let fileSize = totalBytes || 0;
// Get file size if not provided
if (!fileSize) {
if (sourceType === 'local') {
@@ -90,123 +221,124 @@ async function startTransfer(event, payload) {
fileSize = stat.size;
}
}
// Send initial progress
sendProgress(0, fileSize);
// Handle different transfer scenarios
if (sourceType === 'local' && targetType === 'sftp') {
// Upload: Local -> SFTP
// Upload: Local -> SFTP using streams (supports cancellation)
const client = sftpClients.get(targetSftpId);
if (!client) throw new Error("Target SFTP session not found");
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch {}
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
await client.fastPut(sourcePath, encodedTargetPath, {
step: (totalTransferred, chunk, total) => {
if (isCancelled()) {
throw new Error('Transfer cancelled');
}
sendProgress(totalTransferred, total);
}
});
await uploadWithStreams(sourcePath, encodedTargetPath, client, fileSize, transfer, sendProgress);
} else if (sourceType === 'sftp' && targetType === 'local') {
// Download: SFTP -> Local
// Download: SFTP -> Local using streams (supports cancellation)
const client = sftpClients.get(sourceSftpId);
if (!client) throw new Error("Source SFTP session not found");
const dir = path.dirname(targetPath);
await fs.promises.mkdir(dir, { recursive: true });
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
await client.fastGet(encodedSourcePath, targetPath, {
step: (totalTransferred, chunk, total) => {
if (isCancelled()) {
throw new Error('Transfer cancelled');
}
sendProgress(totalTransferred, total);
}
});
await downloadWithStreams(encodedSourcePath, targetPath, client, fileSize, transfer, sendProgress);
} else if (sourceType === 'local' && targetType === 'local') {
// Local copy: use streams
const dir = path.dirname(targetPath);
await fs.promises.mkdir(dir, { recursive: true });
await new Promise((resolve, reject) => {
const readStream = fs.createReadStream(sourcePath);
const writeStream = fs.createWriteStream(targetPath);
let transferred = 0;
const transfer = activeTransfers.get(transferId);
if (transfer) {
transfer.readStream = readStream;
transfer.writeStream = writeStream;
}
let finished = false;
transfer.readStream = readStream;
transfer.writeStream = writeStream;
const cleanup = (err) => {
if (finished) return;
finished = true;
readStream.removeAllListeners();
writeStream.removeAllListeners();
if (err) {
try { readStream.destroy(); } catch {}
try { writeStream.destroy(); } catch {}
reject(err);
} else {
resolve();
}
};
readStream.on('data', (chunk) => {
if (isCancelled()) {
readStream.destroy();
writeStream.destroy();
reject(new Error('Transfer cancelled'));
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
return;
}
transferred += chunk.length;
sendProgress(transferred, fileSize);
});
readStream.on('error', reject);
writeStream.on('error', reject);
writeStream.on('finish', resolve);
readStream.on('error', cleanup);
writeStream.on('error', cleanup);
// Handle normal completion
writeStream.on('finish', () => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
} else {
cleanup(null);
}
});
// Handle stream destruction (destroy() emits 'close' but not 'finish')
writeStream.on('close', () => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
}
});
readStream.pipe(writeStream);
});
} else if (sourceType === 'sftp' && targetType === 'sftp') {
// SFTP to SFTP: download to temp then upload
// SFTP to SFTP: download to temp then upload using streams
const tempPath = path.join(os.tmpdir(), `netcatty-transfer-${transferId}`);
const sourceClient = sftpClients.get(sourceSftpId);
const targetClient = sftpClients.get(targetSftpId);
if (!sourceClient) throw new Error("Source SFTP session not found");
if (!targetClient) throw new Error("Target SFTP session not found");
// Download phase (0-50%)
// Download phase (0-50%) - wrap progress to show 0-50%
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
await sourceClient.fastGet(encodedSourcePath, tempPath, {
step: (totalTransferred, chunk, total) => {
if (isCancelled()) {
throw new Error('Transfer cancelled');
}
sendProgress(Math.floor(totalTransferred / 2), fileSize);
}
});
if (isCancelled()) {
const downloadProgress = (transferred, total) => {
sendProgress(Math.floor(transferred / 2), fileSize);
};
await downloadWithStreams(encodedSourcePath, tempPath, sourceClient, fileSize, transfer, downloadProgress);
if (transfer.cancelled) {
try { await fs.promises.unlink(tempPath); } catch {}
throw new Error('Transfer cancelled');
}
// Upload phase (50-100%)
// Upload phase (50-100%) - wrap progress to show 50-100%
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch {}
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
await targetClient.fastPut(tempPath, encodedTargetPath, {
step: (totalTransferred, chunk, total) => {
if (isCancelled()) {
throw new Error('Transfer cancelled');
}
sendProgress(Math.floor(fileSize / 2) + Math.floor(totalTransferred / 2), fileSize);
}
});
const uploadProgress = (transferred, total) => {
sendProgress(Math.floor(fileSize / 2) + Math.floor(transferred / 2), fileSize);
};
await uploadWithStreams(tempPath, encodedTargetPath, targetClient, fileSize, transfer, uploadProgress);
// Cleanup temp file
try { await fs.promises.unlink(tempPath); } catch {}
} else {
throw new Error("Invalid transfer configuration");
}
@@ -232,16 +364,24 @@ async function startTransfer(event, payload) {
*/
async function cancelTransfer(event, payload) {
const { transferId } = payload;
console.log('[transferBridge] cancelTransfer called for:', transferId);
const transfer = activeTransfers.get(transferId);
console.log('[transferBridge] Found transfer:', !!transfer, 'activeTransfers keys:', Array.from(activeTransfers.keys()));
if (transfer) {
transfer.cancelled = true;
console.log('[transferBridge] Set cancelled=true for transfer:', transferId);
// Destroy streams to immediately stop the transfer
if (transfer.readStream) {
try { transfer.readStream.destroy(); } catch {}
console.log('[transferBridge] Destroying read stream');
try { transfer.readStream.destroy(); } catch (e) { console.log('[transferBridge] Error destroying readStream:', e); }
}
if (transfer.writeStream) {
try { transfer.writeStream.destroy(); } catch {}
console.log('[transferBridge] Destroying write stream');
try { transfer.writeStream.destroy(); } catch (e) { console.log('[transferBridge] Error destroying writeStream:', e); }
}
activeTransfers.delete(transferId);
console.log('[transferBridge] Transfer marked for cancellation');
}
return { success: true };
}

View File

@@ -1,4 +1,4 @@
const { ipcRenderer, contextBridge } = require("electron");
const { ipcRenderer, contextBridge, webUtils } = require("electron");
const dataListeners = new Map();
const exitListeners = new Map();
@@ -626,6 +626,15 @@ const api = {
ipcRenderer.invoke("netcatty:sessionLogs:autoSave", payload),
openSessionLogsDir: (directory) =>
ipcRenderer.invoke("netcatty:sessionLogs:openDir", { directory }),
// Get file path from File object (for drag-and-drop)
getPathForFile: (file) => {
try {
return webUtils.getPathForFile(file);
} catch {
return undefined;
}
},
};
// Merge with existing netcatty (if any) to avoid stale objects on hot reload

3
global.d.ts vendored
View File

@@ -520,6 +520,9 @@ declare global {
directory: string;
}): Promise<{ success: boolean; error?: string; filePath?: string }>;
openSessionLogsDir?(directory: string): Promise<{ success: boolean; error?: string }>;
// Get file path from File object (for drag-and-drop, uses Electron's webUtils)
getPathForFile?(file: File): string | undefined;
}
interface Window {

View File

@@ -3,6 +3,8 @@
* Helper functions for file type detection and extension handling
*/
import { netcattyBridge } from "../infrastructure/services/netcattyBridge";
// Common text file extensions
const TEXT_EXTENSIONS = new Set([
// Code/Scripts
@@ -538,6 +540,22 @@ async function processEntriesIteratively(
return results;
}
/**
* Get the local file path for a File object using Electron's webUtils API
* Falls back to the legacy file.path property if webUtils is not available
*/
export function getPathForFile(file: File): string | undefined {
try {
// Try Electron's webUtils API (exposed via preload)
const path = netcattyBridge.get()?.getPathForFile?.(file);
if (path) return path;
// Fallback: try legacy file.path property
return (file as File & { path?: string }).path;
} catch {
return undefined;
}
}
/**
* Extract all files and directories from a DataTransfer object
* Supports both regular files and folders dropped from the OS
@@ -553,6 +571,20 @@ export async function extractDropEntries(
): Promise<DropEntry[]> {
const items = dataTransfer.items;
// Build a map of file/folder name to path from the original files in DataTransfer.files
const filePathMap = new Map<string, string>();
const filesWithPath = dataTransfer.files;
console.log('[extractDropEntries] DataTransfer.files count:', filesWithPath.length);
for (let i = 0; i < filesWithPath.length; i++) {
const f = filesWithPath[i];
const path = getPathForFile(f);
console.log('[extractDropEntries] File:', { name: f.name, path, size: f.size });
if (path) {
filePathMap.set(f.name, path);
}
}
console.log('[extractDropEntries] filePathMap:', Object.fromEntries(filePathMap));
// Check if webkitGetAsEntry is supported (for folder access)
if (items && items.length > 0 && typeof items[0].webkitGetAsEntry === 'function') {
// Collect all entries first (getAsEntry must be called synchronously)
@@ -568,9 +600,46 @@ export async function extractDropEntries(
}
// Process entries iteratively (non-recursive) to avoid stack overflow
return await processEntriesIteratively(entries);
const results = await processEntriesIteratively(entries);
// Restore the 'path' property for all files
// Try to get the path directly from webUtils.getPathForFile for each file
// This is more reliable than trying to reconstruct from folder paths
for (const result of results) {
if (result.file) {
// First try to get path directly from the file
const directPath = getPathForFile(result.file);
if (directPath) {
(result.file as File & { path?: string }).path = directPath;
console.log('[extractDropEntries] Direct path for:', { relativePath: result.relativePath, path: directPath });
} else {
// Fallback: try to reconstruct from root folder path
const pathParts = result.relativePath.split('/');
const rootName = pathParts[0];
const rootPath = filePathMap.get(rootName);
console.log('[extractDropEntries] Fallback matching:', { relativePath: result.relativePath, rootName, rootPath });
if (rootPath) {
if (pathParts.length === 1) {
// Root-level file: use the path directly
(result.file as File & { path?: string }).path = rootPath;
} else {
// Nested file in a folder: construct full path
// rootPath is the path to the root folder, we need to append the rest
const restOfPath = pathParts.slice(1).join('/');
const separator = rootPath.includes('\\') ? '\\' : '/';
const fullPath = rootPath + separator + restOfPath.replace(/\//g, separator);
(result.file as File & { path?: string }).path = fullPath;
}
}
}
}
}
return results;
} else {
// Fallback: use regular FileList (no folder support)
// Files from FileList in Electron already have the 'path' property
const results: DropEntry[] = [];
const files = dataTransfer.files;
for (let i = 0; i < files.length; i++) {

View File

@@ -6,7 +6,7 @@
* cancellation support, and works for both local and remote (SFTP) uploads.
*/
import { extractDropEntries, DropEntry } from "./sftpFileUtils";
import { extractDropEntries, DropEntry, getPathForFile } from "./sftpFileUtils";
// ============================================================================
// Types
@@ -72,6 +72,23 @@ export interface UploadBridge {
onError?: (error: string) => void
) => Promise<{ success: boolean; cancelled?: boolean } | undefined>;
cancelSftpUpload?: (taskId: string) => Promise<unknown>;
/** Stream transfer using local file path (avoids loading file into memory) */
startStreamTransfer?: (
options: {
transferId: string;
sourcePath: string;
targetPath: string;
sourceType: 'local' | 'sftp';
targetType: 'local' | 'sftp';
sourceSftpId?: string;
targetSftpId?: string;
totalBytes?: number;
},
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void
) => Promise<{ transferId: string; totalBytes?: number; error?: string; cancelled?: boolean }>;
cancelTransfer?: (transferId: string) => Promise<void>;
}
export interface UploadConfig {
@@ -150,17 +167,24 @@ export class UploadController {
*/
async cancel(): Promise<void> {
this.cancelled = true;
if (!this.bridge?.cancelSftpUpload) {
return;
}
console.log('[UploadController] Cancelling uploads, active IDs:', Array.from(this.activeFileTransferIds));
// Cancel all active file uploads
const activeIds = Array.from(this.activeFileTransferIds);
for (const transferId of activeIds) {
try {
await this.bridge.cancelSftpUpload(transferId);
} catch {
// Try cancelTransfer first (for stream transfers)
if (this.bridge?.cancelTransfer) {
console.log('[UploadController] Calling cancelTransfer for:', transferId);
await this.bridge.cancelTransfer(transferId);
}
// Also try cancelSftpUpload (for legacy uploads)
if (this.bridge?.cancelSftpUpload) {
console.log('[UploadController] Calling cancelSftpUpload for:', transferId);
await this.bridge.cancelSftpUpload(transferId);
}
} catch (e) {
console.log('[UploadController] Cancel error:', e);
// Ignore cancel errors
}
}
@@ -168,8 +192,16 @@ export class UploadController {
// Also cancel current one if not in the set
if (this.currentTransferId && !activeIds.includes(this.currentTransferId)) {
try {
await this.bridge.cancelSftpUpload(this.currentTransferId);
} catch {
if (this.bridge?.cancelTransfer) {
console.log('[UploadController] Calling cancelTransfer for current:', this.currentTransferId);
await this.bridge.cancelTransfer(this.currentTransferId);
}
if (this.bridge?.cancelSftpUpload) {
console.log('[UploadController] Calling cancelSftpUpload for current:', this.currentTransferId);
await this.bridge.cancelSftpUpload(this.currentTransferId);
}
} catch (e) {
console.log('[UploadController] Cancel current error:', e);
// Ignore cancel errors
}
}
@@ -279,14 +311,16 @@ export async function uploadFromDataTransfer(
}
/**
* Upload a FileList with bundled folder support
* Upload a FileList or File array with bundled folder support
*/
export async function uploadFromFileList(
fileList: FileList,
fileList: FileList | File[],
config: UploadConfig,
controller?: UploadController
): Promise<UploadResult[]> {
console.log('[uploadFromFileList] Called with', fileList.length, 'files');
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks } = config;
console.log('[uploadFromFileList] Config:', { targetPath, sftpId, isLocal });
if (controller) {
controller.reset();
@@ -294,16 +328,29 @@ export async function uploadFromFileList(
}
// Convert FileList to DropEntry array (simple files, no folders)
const entries: DropEntry[] = Array.from(fileList).map(file => ({
file,
relativePath: file.name,
isDirectory: false,
}));
// Use getPathForFile to get the local file path for stream transfer
const entries: DropEntry[] = Array.from(fileList).map(file => {
const localPath = getPathForFile(file);
console.log('[uploadFromFileList] File:', { name: file.name, size: file.size, localPath });
if (localPath) {
// Set the path property on the file for stream transfer
(file as File & { path?: string }).path = localPath;
}
return {
file,
relativePath: file.name,
isDirectory: false,
};
});
console.log('[uploadFromFileList] Created', entries.length, 'entries');
if (entries.length === 0) {
console.log('[uploadFromFileList] No entries, returning empty');
return [];
}
console.log('[uploadFromFileList] Calling uploadEntries');
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
@@ -470,96 +517,196 @@ async function uploadEntries(
}
}
const arrayBuffer = await entry.file.arrayBuffer();
// Check if file has a local path (Electron provides file.path for dropped files)
const localFilePath = (entry.file as File & { path?: string }).path;
if (isLocal) {
if (!bridge.writeLocalFile) {
throw new Error("writeLocalFile not available");
}
await bridge.writeLocalFile(entryTargetPath, arrayBuffer);
} else if (sftpId) {
if (bridge.writeSftpBinaryWithProgress) {
let pendingProgressUpdate: { transferred: number; total: number; speed: number } | null = null;
let rafScheduled = false;
console.log('[UploadService] Processing file:', {
relativePath: entry.relativePath,
localFilePath,
hasStreamTransfer: !!bridge.startStreamTransfer,
sftpId,
isLocal,
fileSize: fileTotalBytes,
});
const onProgress = (transferred: number, total: number, speed: number) => {
if (controller?.isCancelled()) return;
// Use stream transfer if available and we have a local file path (avoids loading file into memory)
if (localFilePath && bridge.startStreamTransfer && sftpId && !isLocal) {
console.log('[UploadService] Using stream transfer for:', localFilePath);
let pendingProgressUpdate: { transferred: number; total: number; speed: number } | null = null;
let rafScheduled = false;
pendingProgressUpdate = { transferred, total, speed };
const onProgress = (transferred: number, total: number, speed: number) => {
if (controller?.isCancelled()) return;
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
rafScheduled = false;
const update = pendingProgressUpdate;
pendingProgressUpdate = null;
pendingProgressUpdate = { transferred, total, speed };
if (update && !controller?.isCancelled() && callbacks?.onTaskProgress) {
if (bundleTaskId) {
const progress = bundleProgress.get(bundleTaskId);
if (progress) {
const newTransferred = progress.completedFilesBytes + update.transferred;
progress.transferredBytes = newTransferred;
progress.currentSpeed = update.speed;
callbacks.onTaskProgress(bundleTaskId, {
transferred: newTransferred,
total: progress.totalBytes,
speed: update.speed,
percent: progress.totalBytes > 0 ? (newTransferred / progress.totalBytes) * 100 : 0,
});
}
} else if (standaloneTransferId) {
callbacks.onTaskProgress(standaloneTransferId, {
transferred: update.transferred,
total: update.total,
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
rafScheduled = false;
const update = pendingProgressUpdate;
pendingProgressUpdate = null;
if (update && !controller?.isCancelled() && callbacks?.onTaskProgress) {
if (bundleTaskId) {
const progress = bundleProgress.get(bundleTaskId);
if (progress) {
const newTransferred = progress.completedFilesBytes + update.transferred;
progress.transferredBytes = newTransferred;
progress.currentSpeed = update.speed;
callbacks.onTaskProgress(bundleTaskId, {
transferred: newTransferred,
total: progress.totalBytes,
speed: update.speed,
percent: update.total > 0 ? (update.transferred / update.total) * 100 : 0,
percent: progress.totalBytes > 0 ? (newTransferred / progress.totalBytes) * 100 : 0,
});
}
} else if (standaloneTransferId) {
callbacks.onTaskProgress(standaloneTransferId, {
transferred: update.transferred,
total: update.total,
speed: update.speed,
percent: update.total > 0 ? (update.transferred / update.total) * 100 : 0,
});
}
});
}
};
// Use unique file transfer ID for backend cancellation tracking
const fileTransferId = crypto.randomUUID();
controller?.addActiveTransfer(fileTransferId);
let result;
try {
result = await bridge.writeSftpBinaryWithProgress(
sftpId,
entryTargetPath,
arrayBuffer,
fileTransferId,
onProgress,
undefined,
undefined
);
} finally {
controller?.removeActiveTransfer(fileTransferId);
}
});
}
};
if (result?.cancelled) {
wasCancelled = true;
const taskId = bundleTaskId || standaloneTransferId;
if (taskId) {
callbacks?.onTaskCancelled?.(taskId);
}
break;
}
const fileTransferId = crypto.randomUUID();
controller?.addActiveTransfer(fileTransferId);
if (!result || result.success === false) {
if (bridge.writeSftpBinary) {
await bridge.writeSftpBinary(sftpId, entryTargetPath, arrayBuffer);
} else {
throw new Error("Upload failed and no fallback method available");
}
let streamResult: { transferId: string; totalBytes?: number; error?: string; cancelled?: boolean } | undefined;
try {
streamResult = await bridge.startStreamTransfer(
{
transferId: fileTransferId,
sourcePath: localFilePath,
targetPath: entryTargetPath,
sourceType: 'local',
targetType: 'sftp',
targetSftpId: sftpId,
totalBytes: fileTotalBytes,
},
onProgress,
undefined,
undefined
);
} finally {
controller?.removeActiveTransfer(fileTransferId);
}
if (streamResult?.cancelled || streamResult?.error?.includes('cancelled')) {
wasCancelled = true;
const taskId = bundleTaskId || standaloneTransferId;
if (taskId) {
callbacks?.onTaskCancelled?.(taskId);
}
break;
}
if (streamResult?.error) {
throw new Error(streamResult.error);
}
} else {
// Fallback: load file into memory (for small files or when stream transfer is not available)
console.log('[UploadService] FALLBACK: Loading file into memory:', {
relativePath: entry.relativePath,
fileSize: fileTotalBytes,
reason: !localFilePath ? 'no local path' : !bridge.startStreamTransfer ? 'no stream transfer' : 'other',
});
const arrayBuffer = await entry.file.arrayBuffer();
if (isLocal) {
if (!bridge.writeLocalFile) {
throw new Error("writeLocalFile not available");
}
await bridge.writeLocalFile(entryTargetPath, arrayBuffer);
} else if (sftpId) {
if (bridge.writeSftpBinaryWithProgress) {
let pendingProgressUpdate: { transferred: number; total: number; speed: number } | null = null;
let rafScheduled = false;
const onProgress = (transferred: number, total: number, speed: number) => {
if (controller?.isCancelled()) return;
pendingProgressUpdate = { transferred, total, speed };
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
rafScheduled = false;
const update = pendingProgressUpdate;
pendingProgressUpdate = null;
if (update && !controller?.isCancelled() && callbacks?.onTaskProgress) {
if (bundleTaskId) {
const progress = bundleProgress.get(bundleTaskId);
if (progress) {
const newTransferred = progress.completedFilesBytes + update.transferred;
progress.transferredBytes = newTransferred;
progress.currentSpeed = update.speed;
callbacks.onTaskProgress(bundleTaskId, {
transferred: newTransferred,
total: progress.totalBytes,
speed: update.speed,
percent: progress.totalBytes > 0 ? (newTransferred / progress.totalBytes) * 100 : 0,
});
}
} else if (standaloneTransferId) {
callbacks.onTaskProgress(standaloneTransferId, {
transferred: update.transferred,
total: update.total,
speed: update.speed,
percent: update.total > 0 ? (update.transferred / update.total) * 100 : 0,
});
}
}
});
}
};
// Use unique file transfer ID for backend cancellation tracking
const fileTransferId = crypto.randomUUID();
controller?.addActiveTransfer(fileTransferId);
let result;
try {
result = await bridge.writeSftpBinaryWithProgress(
sftpId,
entryTargetPath,
arrayBuffer,
fileTransferId,
onProgress,
undefined,
undefined
);
} finally {
controller?.removeActiveTransfer(fileTransferId);
}
if (result?.cancelled) {
wasCancelled = true;
const taskId = bundleTaskId || standaloneTransferId;
if (taskId) {
callbacks?.onTaskCancelled?.(taskId);
}
break;
}
if (!result || result.success === false) {
if (bridge.writeSftpBinary) {
await bridge.writeSftpBinary(sftpId, entryTargetPath, arrayBuffer);
} else {
throw new Error("Upload failed and no fallback method available");
}
}
} else if (bridge.writeSftpBinary) {
await bridge.writeSftpBinary(sftpId, entryTargetPath, arrayBuffer);
} else {
throw new Error("No SFTP write method available");
}
} else if (bridge.writeSftpBinary) {
await bridge.writeSftpBinary(sftpId, entryTargetPath, arrayBuffer);
} else {
throw new Error("No SFTP write method available");
}
}