Compare commits

...
10 Commits
Author SHA1 Message Date
陈大猫 1dbda5bec3 Merge pull request #103 from binaricat/fix/skip-encrypted-default-key
fix: skip encrypted default SSH keys to allow password auth fallback
2026-01-21 02:03:14 +08:00
bincxzandClaude 2da63c0180 fix: also detect PKCS#8 encrypted keys
Add check for -----BEGIN ENCRYPTED PRIVATE KEY----- format to cover
PKCS#8 encrypted keys that were missed in the initial implementation.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 02:01:35 +08:00
bincxzandClaude 2af9cfccb3 fix: skip encrypted default SSH keys to allow password auth fallback
When no auth method is configured, the default key fallback now detects
and skips passphrase-protected keys. This allows password/keyboard-interactive
authentication to proceed instead of failing immediately with encrypted keys.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 01:55:22 +08:00
陈大猫 ffb736eeea Merge pull request #102 from binaricat/feature/ssh-default-key-and-agent-check
feat: add default SSH key fallback and Windows SSH Agent status check
2026-01-21 01:45:36 +08:00
bincxzandClaude 83cd65ef63 feat: add default SSH key fallback and Windows SSH Agent status check
- Auto-discover and use default SSH keys (~/.ssh/id_ed25519, id_ecdsa, id_rsa) when no credentials configured
- Add Windows SSH Agent service status detection with UI warning
- Remove file extension filter on key import to allow any file type
- Unify Host Details panel card styles with consistent icons and titles

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 01:43:05 +08:00
陈大猫 e46046081a Merge pull request #101 from binaricat/copilot/fix-sftp-file-transfer-issue 2026-01-21 01:15:03 +08:00
bincxz 7f75fadb31 Optimizes SFTP transfers and folder processing
Enhances SFTP file upload performance and responsiveness by:
- Throttling UI progress updates with `requestAnimationFrame` to reduce re-renders.
- Optimizing Electron-side binary writes with larger chunk sizes, `subarray` for buffers, and efficient buffer handling.
- Implementing IPC progress event throttling to reduce communication overhead between main and renderer processes.
- Speeding up folder processing by traversing directory entries in parallel batches, with yielding to maintain UI responsiveness.

Improves robustness by:
- Ensuring folder uploads stop immediately upon user cancellation.
- Preventing division-by-zero errors in the progress bar calculation.

Also updates Claude settings to allow `npm run build:*` commands.
2026-01-21 01:14:35 +08:00
copilot-swe-agent[bot]andbinaricat 2997ed6b3c Add logging for stream destruction errors during upload cancellation
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-20 16:24:55 +00:00
copilot-swe-agent[bot]andbinaricat 2b03db1142 Fix SFTP folder upload progress bar and cancel functionality
- Add byte-level progress tracking for current file upload
- Add cancellation support that aborts the current file transfer
- Update UI to show real-time progress bar with bytes/speed
- Add cancelSftpUpload IPC handler for immediate cancellation

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-20 16:22:24 +00:00
copilot-swe-agent[bot] 513309ba7c Initial plan 2026-01-20 16:09:49 +00:00
13 changed files with 467 additions and 45 deletions

No files matched your search

+2 -1
View File
@@ -2,7 +2,8 @@
"permissions": {
"allow": [
"Bash(npx tsc:*)",
"Bash(npm run lint:*)"
"Bash(npm run lint:*)",
"Bash(npm run build:*)"
]
}
}
+3
View File
@@ -634,6 +634,9 @@ const en: Messages = {
'hostDetails.certs.empty': 'No certificates available',
'hostDetails.agentForwarding': 'Forward SSH Agent',
'hostDetails.agentForwarding.desc': 'Allow remote server to use your local SSH keys (e.g., for git operations)',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not running',
'hostDetails.agentForwarding.agentNotRunningHint': 'Enable OpenSSH Authentication Agent service in Windows Services (services.msc) for agent forwarding to work.',
'hostDetails.section.agentForwarding': 'SSH Agent',
'hostDetails.jumpHosts': 'Proxy via Hosts',
'hostDetails.jumpHosts.hops': '{count} hop(s)',
'hostDetails.jumpHosts.direct': 'Direct',
+3
View File
@@ -394,6 +394,9 @@ const zhCN: Messages = {
'hostDetails.certs.empty': '暂无证书',
'hostDetails.agentForwarding': '转发 SSH 密钥',
'hostDetails.agentForwarding.desc': '允许远程服务器使用本地 SSH 密钥(例如用于 git 操作)',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 未运行',
'hostDetails.agentForwarding.agentNotRunningHint': '请在 Windows 服务管理器 (services.msc) 中启用 OpenSSH Authentication Agent 服务。',
'hostDetails.section.agentForwarding': 'SSH 代理',
'hostDetails.jumpHosts': '通过主机代理',
'hostDetails.jumpHosts.hops': '{count} 跳',
'hostDetails.jumpHosts.direct': '直连',
+13 -1
View File
@@ -7,6 +7,12 @@ export type ApplicationInfo = {
platform: string;
};
export type SshAgentStatus = {
running: boolean;
startupType: string | null;
error: string | null;
};
export const useApplicationBackend = () => {
const openExternal = useCallback(async (url: string) => {
try {
@@ -27,6 +33,12 @@ export const useApplicationBackend = () => {
return info ?? null;
}, []);
return { openExternal, getApplicationInfo };
const checkSshAgent = useCallback(async (): Promise<SshAgentStatus | null> => {
const bridge = netcattyBridge.get();
const status = await bridge?.checkSshAgent?.();
return status ?? null;
}, []);
return { openExternal, getApplicationInfo, checkSshAgent };
};
+98 -3
View File
@@ -166,6 +166,10 @@ export interface FolderUploadProgress {
currentIndex: number; // 1-indexed for display
totalFiles: number; // Total files (excluding directories)
cancelled: boolean; // Flag to cancel upload
currentFileBytes: number; // Bytes transferred for current file
currentFileTotalBytes: number; // Total bytes for current file
currentFileSpeed: number; // Transfer speed in bytes/sec
currentTransferId: string; // Transfer ID for current file (for cancellation)
}
export interface SftpStateOptions {
@@ -405,8 +409,13 @@ export const useSftpState = (
currentIndex: 0,
totalFiles: 0,
cancelled: false,
currentFileBytes: 0,
currentFileTotalBytes: 0,
currentFileSpeed: 0,
currentTransferId: "",
});
const cancelFolderUploadRef = useRef(false);
const currentFolderUploadTransferIdRef = useRef<string>("");
// SFTP session refs
const sftpSessionsRef = useRef<Map<string, string>>(new Map()); // connectionId -> sftpId
@@ -2898,6 +2907,7 @@ export const useSftpState = (
// Reset cancellation flag and set initial progress state
cancelFolderUploadRef.current = false;
currentFolderUploadTransferIdRef.current = "";
if (totalFiles > 1) {
setFolderUploadProgress({
isUploading: true,
@@ -2905,6 +2915,10 @@ export const useSftpState = (
currentIndex: 0,
totalFiles,
cancelled: false,
currentFileBytes: 0,
currentFileTotalBytes: 0,
currentFileSpeed: 0,
currentTransferId: "",
});
}
@@ -2933,6 +2947,12 @@ export const useSftpState = (
} else if (entry.file) {
// Update progress before processing this file
fileIndex++;
const transferId = crypto.randomUUID();
const fileTotalBytes = entry.file.size;
// Store current transfer ID for potential cancellation
currentFolderUploadTransferIdRef.current = transferId;
if (totalFiles > 1) {
setFolderUploadProgress({
isUploading: true,
@@ -2940,6 +2960,10 @@ export const useSftpState = (
currentIndex: fileIndex,
totalFiles,
cancelled: false,
currentFileBytes: 0,
currentFileTotalBytes: fileTotalBytes,
currentFileSpeed: 0,
currentTransferId: transferId,
});
}
@@ -2965,16 +2989,54 @@ export const useSftpState = (
} else if (sftpId) {
// Try progress API first, fallback to basic binary write
if (bridge.writeSftpBinaryWithProgress) {
// Progress callback with throttling using requestAnimationFrame
// This prevents excessive React re-renders during fast uploads
let pendingProgressUpdate: { transferred: number; total: number; speed: number } | null = null;
let rafScheduled = false;
const onProgress = (transferred: number, total: number, speed: number) => {
if (totalFiles > 1 && !cancelFolderUploadRef.current) {
// Store the latest progress data
pendingProgressUpdate = { transferred, total, speed };
// Only schedule RAF if not already scheduled
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
rafScheduled = false;
// Capture the update value before clearing it to avoid race conditions
const update = pendingProgressUpdate;
pendingProgressUpdate = null;
if (update && !cancelFolderUploadRef.current) {
setFolderUploadProgress(prev => ({
...prev,
currentFileBytes: update.transferred,
currentFileTotalBytes: update.total,
currentFileSpeed: update.speed,
}));
}
});
}
}
};
const result = await bridge.writeSftpBinaryWithProgress(
sftpId,
targetPath,
arrayBuffer,
crypto.randomUUID(),
undefined, // onProgress
transferId,
onProgress,
undefined, // onComplete
undefined, // onError
);
// Check if upload was cancelled
if (result?.cancelled) {
logger.info("[SFTP] File upload cancelled:", entry.relativePath);
// Break out of the loop immediately - cancelFolderUploadRef.current should already be true
break;
}
if (!result || result.success === false) {
if (bridge.writeSftpBinary) {
await bridge.writeSftpBinary(sftpId, targetPath, arrayBuffer);
@@ -2988,11 +3050,16 @@ export const useSftpState = (
throw new Error("No SFTP write method available");
}
}
// Clear current transfer ID after successful upload
currentFolderUploadTransferIdRef.current = "";
// Only add file uploads to results (not directories)
results.push({ fileName: entry.relativePath, success: true });
}
} catch (error) {
// Clear current transfer ID on error
currentFolderUploadTransferIdRef.current = "";
// Only log file upload errors (directory errors are expected for existing dirs)
if (!entry.isDirectory) {
logger.error(`Failed to upload ${entry.relativePath}:`, error);
@@ -3005,6 +3072,8 @@ export const useSftpState = (
}
}
} finally {
// Clear the current transfer ID
currentFolderUploadTransferIdRef.current = "";
// Always reset progress state when done
setFolderUploadProgress({
isUploading: false,
@@ -3012,6 +3081,10 @@ export const useSftpState = (
currentIndex: 0,
totalFiles: 0,
cancelled: cancelFolderUploadRef.current,
currentFileBytes: 0,
currentFileTotalBytes: 0,
currentFileSpeed: 0,
currentTransferId: "",
});
}
@@ -3024,8 +3097,30 @@ export const useSftpState = (
);
// Cancel folder upload in progress
const cancelFolderUpload = useCallback(() => {
const cancelFolderUpload = useCallback(async () => {
// Set the flag to stop processing new files
cancelFolderUploadRef.current = true;
// Cancel the current file transfer if one is in progress
const currentTransferId = currentFolderUploadTransferIdRef.current;
if (currentTransferId) {
const bridge = netcattyBridge.get();
if (bridge?.cancelSftpUpload) {
try {
await bridge.cancelSftpUpload(currentTransferId);
logger.info("[SFTP] Current file upload cancelled:", currentTransferId);
} catch (err) {
logger.warn("[SFTP] Failed to cancel current file upload:", err);
}
}
}
// Update progress state to show cancelled
setFolderUploadProgress(prev => ({
...prev,
cancelled: true,
isUploading: false,
}));
}, []);
// Select an application from system file picker
+77 -16
View File
@@ -1,20 +1,29 @@
import {
AlertTriangle,
Check,
ChevronDown,
FolderLock,
FolderPlus,
Forward,
Globe,
Key,
KeyRound,
Link2,
MapPin,
Palette,
Plus,
Settings2,
Shield,
Tag,
TerminalSquare,
User,
Variable,
Wifi,
X,
} from "lucide-react";
import React, { useEffect, useMemo, useState, useCallback } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useApplicationBackend } from "../application/state/useApplicationBackend";
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
import { cn } from "../lib/utils";
@@ -81,6 +90,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onCreateTag,
}) => {
const { t } = useI18n();
const { checkSshAgent } = useApplicationBackend();
const [form, setForm] = useState<Host>(
() =>
initialData ||
@@ -116,6 +126,22 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
const [newGroupName, setNewGroupName] = useState("");
const [newGroupParent, setNewGroupParent] = useState("");
// SSH Agent status for Windows (only checked when agentForwarding is enabled)
const [sshAgentStatus, setSshAgentStatus] = useState<{
running: boolean;
startupType: string | null;
error: string | null;
} | null>(null);
// Check SSH Agent status when agentForwarding is toggled on (Windows only)
useEffect(() => {
if (form.agentForwarding) {
checkSshAgent().then(setSshAgentStatus);
} else {
setSshAgentStatus(null);
}
}, [form.agentForwarding, checkSshAgent]);
// Group input state for inline creation suggestion
const [groupInputValue, setGroupInputValue] = useState(form.group || "");
@@ -481,9 +507,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
>
<AsidePanelContent>
<Card className="p-3 space-y-2 bg-card border-border/80">
<p className="text-xs font-semibold">
{t("hostDetails.section.address")}
</p>
<div className="flex items-center gap-2">
<MapPin size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.section.address")}
</p>
</div>
<div className="flex items-center gap-2">
<DistroAvatar
host={form as Host}
@@ -504,9 +533,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<p className="text-xs font-semibold">
{t("hostDetails.section.general")}
</p>
<div className="flex items-center gap-2">
<Settings2 size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.section.general")}
</p>
</div>
<Input
placeholder={t("hostDetails.label.placeholder")}
value={form.label}
@@ -557,9 +589,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<p className="text-xs font-semibold">
{t("hostDetails.section.portCredentials")}
</p>
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.section.portCredentials")}
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0 h-10 flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-3">
<span className="text-xs text-muted-foreground">SSH on</span>
@@ -927,9 +962,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<p className="text-xs font-semibold">
{t("hostDetails.section.sftp")}
</p>
<div className="flex items-center gap-2">
<FolderLock size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.section.sftp")}
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="text-sm font-medium">
@@ -952,9 +990,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<p className="text-xs font-semibold">
{t("hostDetails.section.appearance")}
</p>
<div className="flex items-center gap-2">
<Palette size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.section.appearance")}
</p>
</div>
{/* SSH Theme Selection */}
<button
@@ -1041,7 +1082,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<p className="text-xs font-semibold">{t("hostDetails.section.mosh")}</p>
<div className="flex items-center gap-2">
<Wifi size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.mosh")}</p>
</div>
<ToggleRow
label="Mosh"
enabled={!!form.moshEnabled}
@@ -1051,6 +1095,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{/* Agent Forwarding */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<Forward size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.agentForwarding")}</p>
</div>
<ToggleRow
label={t("hostDetails.agentForwarding")}
enabled={!!form.agentForwarding}
@@ -1059,6 +1107,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<p className="text-xs text-muted-foreground">
{t("hostDetails.agentForwarding.desc")}
</p>
{form.agentForwarding && sshAgentStatus && !sshAgentStatus.running && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-xs text-yellow-600 dark:text-yellow-400 font-medium">
{t("hostDetails.agentForwarding.agentNotRunning")}
</p>
<p className="text-xs text-muted-foreground">
{t("hostDetails.agentForwarding.agentNotRunningHint")}
</p>
</div>
</div>
)}
</Card>
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
+18
View File
@@ -2656,7 +2656,25 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
total: sftp.folderUploadProgress.totalFiles,
})}
</span>
{sftp.folderUploadProgress.currentFileTotalBytes > 0 && (
<span className="text-xs text-muted-foreground ml-2 flex-shrink-0">
{sftp.formatFileSize(sftp.folderUploadProgress.currentFileBytes)} / {sftp.formatFileSize(sftp.folderUploadProgress.currentFileTotalBytes)}
{sftp.folderUploadProgress.currentFileSpeed > 0 && (
<> ({sftp.formatFileSize(sftp.folderUploadProgress.currentFileSpeed)}/s)</>
)}
</span>
)}
</div>
{sftp.folderUploadProgress.currentFileTotalBytes > 0 && (
<div className="w-full bg-muted/30 rounded-full h-1.5 mt-1">
<div
className="bg-primary h-1.5 rounded-full transition-all duration-150 ease-out"
style={{
width: `${Math.min((sftp.folderUploadProgress.currentFileBytes / Math.max(sftp.folderUploadProgress.currentFileTotalBytes, 1)) * 100, 100)}%`,
}}
/>
</div>
)}
{sftp.folderUploadProgress.currentFile && (
<div className="text-xs text-muted-foreground truncate mt-0.5">
{sftp.folderUploadProgress.currentFile}
-1
View File
@@ -84,7 +84,6 @@ export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
<input
ref={fileInputRef}
type="file"
accept=".pem,.key,.pub,.ppk,*"
className="hidden"
onChange={handleFileImport}
/>
+80 -12
View File
@@ -29,6 +29,9 @@ let electronModule = null;
// Storage for jump host connections that need to be cleaned up
const jumpConnectionsMap = new Map(); // connId -> { connections: SSHClient[], socket: stream }
// Storage for active SFTP uploads that can be cancelled
const activeSftpUploads = new Map(); // transferId -> { cancelled: boolean, stream: Readable }
/**
* Send message to renderer safely
*/
@@ -737,25 +740,45 @@ async function writeSftp(event, payload) {
/**
* Write binary data with progress callback
* Supports cancellation via activeSftpUploads map
* Optimized for performance with throttled progress updates
*/
async function writeSftpBinaryWithProgress(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const { sftpId, path: remotePath, content, transferId } = payload;
const buffer = Buffer.from(content);
// Optimize: Use Buffer.isBuffer to avoid unnecessary copy if already a Buffer
// For ArrayBuffer from renderer, we still need to convert but use a more efficient method
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content);
const totalBytes = buffer.length;
let transferredBytes = 0;
let lastProgressTime = Date.now();
let lastTransferredBytes = 0;
let lastProgressSentTime = 0;
// Throttle settings: send progress at most every 100ms or every 1MB
const PROGRESS_THROTTLE_MS = 100;
const PROGRESS_THROTTLE_BYTES = 1024 * 1024; // 1MB
let lastProgressSentBytes = 0;
const { Readable } = require("stream");
const readableStream = new Readable({
read() {
const chunkSize = 65536;
// Check for cancellation
const uploadState = activeSftpUploads.get(transferId);
if (uploadState?.cancelled) {
this.destroy(new Error("Upload cancelled"));
return;
}
// Use larger chunk size for better performance (256KB instead of 64KB)
const chunkSize = 262144;
if (transferredBytes < totalBytes) {
const end = Math.min(transferredBytes + chunkSize, totalBytes);
const chunk = buffer.slice(transferredBytes, end);
// Use subarray instead of slice to avoid copying
const chunk = buffer.subarray(transferredBytes, end);
transferredBytes = end;
const now = Date.now();
@@ -767,13 +790,22 @@ async function writeSftpBinaryWithProgress(event, payload) {
lastTransferredBytes = transferredBytes;
}
const contents = electronModule.webContents.fromId(event.sender.id);
contents?.send("netcatty:upload:progress", {
transferId,
transferred: transferredBytes,
totalBytes,
speed,
});
// Throttle IPC progress events: only send if enough time or bytes have passed
const timeSinceLastProgress = now - lastProgressSentTime;
const bytesSinceLastProgress = transferredBytes - lastProgressSentBytes;
const isComplete = transferredBytes >= totalBytes;
if (isComplete || timeSinceLastProgress >= PROGRESS_THROTTLE_MS || bytesSinceLastProgress >= PROGRESS_THROTTLE_BYTES) {
const contents = electronModule.webContents.fromId(event.sender.id);
contents?.send("netcatty:upload:progress", {
transferId,
transferred: transferredBytes,
totalBytes,
speed,
});
lastProgressSentTime = now;
lastProgressSentBytes = transferredBytes;
}
this.push(chunk);
} else {
@@ -782,6 +814,9 @@ async function writeSftpBinaryWithProgress(event, payload) {
}
});
// Register this upload for potential cancellation
activeSftpUploads.set(transferId, { cancelled: false, stream: readableStream });
try {
await client.put(readableStream, remotePath);
@@ -791,11 +826,42 @@ async function writeSftpBinaryWithProgress(event, payload) {
return { success: true, transferId };
} catch (err) {
const contents = electronModule.webContents.fromId(event.sender.id);
contents?.send("netcatty:upload:error", { transferId, error: err.message });
throw err;
// Only send error if it's not a cancellation
if (err.message !== "Upload cancelled") {
contents?.send("netcatty:upload:error", { transferId, error: err.message });
throw err;
}
contents?.send("netcatty:upload:cancelled", { transferId });
return { success: false, transferId, cancelled: true };
} finally {
// Cleanup
activeSftpUploads.delete(transferId);
}
}
/**
* Cancel an in-progress SFTP upload
* Note: We only set the cancelled flag and destroy the stream here.
* The cleanup (deleting from activeSftpUploads) is handled by writeSftpBinaryWithProgress's finally block
* to avoid race conditions.
*/
async function cancelSftpUpload(event, payload) {
const { transferId } = payload;
const uploadState = activeSftpUploads.get(transferId);
if (uploadState) {
uploadState.cancelled = true;
try {
uploadState.stream?.destroy();
} catch (err) {
// Log but continue - stream may already be destroyed
console.warn("[SFTP] Error destroying upload stream:", err.message);
}
// Don't delete here - let the finally block in writeSftpBinaryWithProgress handle cleanup
// This avoids race conditions where the upload might still be in progress
}
return { success: true };
}
/**
* Close an SFTP connection
* Also cleans up any jump host connections and file watchers if present
@@ -905,6 +971,7 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:sftp:readBinary", readSftpBinary);
ipcMain.handle("netcatty:sftp:write", writeSftp);
ipcMain.handle("netcatty:sftp:writeBinaryWithProgress", writeSftpBinaryWithProgress);
ipcMain.handle("netcatty:sftp:cancelUpload", cancelSftpUpload);
ipcMain.handle("netcatty:sftp:close", closeSftp);
ipcMain.handle("netcatty:sftp:mkdir", mkdirSftp);
ipcMain.handle("netcatty:sftp:delete", deleteSftp);
@@ -930,6 +997,7 @@ module.exports = {
readSftpBinary,
writeSftp,
writeSftpBinaryWithProgress,
cancelSftpUpload,
closeSftp,
mkdirSftp,
deleteSftp,
+135
View File
@@ -6,11 +6,119 @@
const net = require("node:net");
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { exec } = require("node:child_process");
const { Client: SSHClient, utils: sshUtils } = require("ssh2");
const { NetcattyAgent } = require("./netcattyAgent.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
// Default SSH key names in priority order
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
/**
* Check if an SSH private key is encrypted (requires passphrase)
* @param {string} keyContent - The content of the private key file
* @returns {boolean} - True if the key is encrypted
*/
function isKeyEncrypted(keyContent) {
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
return true;
}
// Check for legacy PEM format encryption (e.g., RSA PRIVATE KEY with encryption)
if (keyContent.includes("Proc-Type:") && keyContent.includes("ENCRYPTED")) {
return true;
}
// Check for OpenSSH format keys
if (keyContent.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
try {
// Extract the base64 content between the markers
const base64Match = keyContent.match(
/-----BEGIN OPENSSH PRIVATE KEY-----\s*([\s\S]*?)\s*-----END OPENSSH PRIVATE KEY-----/
);
if (base64Match) {
const base64Content = base64Match[1].replace(/\s/g, "");
const keyBuffer = Buffer.from(base64Content, "base64");
// OpenSSH key format: "openssh-key-v1\0" followed by cipher name
// If ciphername is "none", the key is not encrypted
const authMagic = "openssh-key-v1\0";
if (keyBuffer.toString("ascii", 0, authMagic.length) === authMagic) {
// After magic, read ciphername (length-prefixed string)
let offset = authMagic.length;
const cipherNameLen = keyBuffer.readUInt32BE(offset);
offset += 4;
const cipherName = keyBuffer.toString("ascii", offset, offset + cipherNameLen);
return cipherName !== "none";
}
}
} catch {
// If parsing fails, assume it might be encrypted to be safe
return true;
}
}
return false;
}
/**
* Find default SSH private key from user's ~/.ssh directory
* Skips encrypted keys that require a passphrase to allow password/keyboard-interactive auth
* @returns {{ privateKey: string, keyPath: string, keyName: string } | null}
*/
function findDefaultPrivateKey() {
const sshDir = path.join(os.homedir(), ".ssh");
for (const name of DEFAULT_KEY_NAMES) {
const keyPath = path.join(sshDir, name);
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)) {
log("Skipping encrypted default key", { keyPath, keyName: name });
continue;
}
log("Found default key", { keyPath, keyName: name });
return { privateKey, keyPath, keyName: name };
} catch (e) {
log("Failed to read default key", { keyPath, error: e.message });
continue;
}
}
}
return null;
}
/**
* Check if Windows SSH Agent service is running
* @returns {Promise<{ running: boolean, startupType: string | null, error: string | null }>}
*/
function checkWindowsSshAgent() {
return new Promise((resolve) => {
if (process.platform !== "win32") {
resolve({ running: true, startupType: null, error: null });
return;
}
exec("sc query ssh-agent", (err, stdout) => {
if (err) {
resolve({ running: false, startupType: null, error: "SSH Agent service not found" });
return;
}
const running = stdout.includes("RUNNING");
const stopped = stdout.includes("STOPPED");
resolve({
running,
startupType: stopped ? "stopped" : (running ? "running" : "unknown"),
error: null,
});
});
});
}
// Simple file logger for debugging
const logFile = path.join(require("os").tmpdir(), "netcatty-ssh.log");
const log = (msg, data) => {
@@ -304,6 +412,17 @@ async function startSSHSession(event, options) {
connectOpts.password = options.password;
}
// Fallback to default SSH key if no authentication method is configured
let usedDefaultKey = null;
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;
}
}
// Agent forwarding
if (options.agentForwarding) {
connectOpts.agentForward = true;
@@ -831,6 +950,20 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:ssh:exec", execCommand);
ipcMain.handle("netcatty:ssh:pwd", getSessionPwd);
ipcMain.handle("netcatty:key:generate", generateKeyPair);
ipcMain.handle("netcatty:ssh:check-agent", async () => {
return await checkWindowsSshAgent();
});
ipcMain.handle("netcatty:ssh:get-default-keys", async () => {
const sshDir = path.join(os.homedir(), ".ssh");
const keys = [];
for (const name of DEFAULT_KEY_NAMES) {
const keyPath = path.join(sshDir, name);
if (fs.existsSync(keyPath)) {
keys.push({ name, path: keyPath });
}
}
return keys;
});
// Register the shared keyboard-interactive response handler
keyboardInteractiveHandler.registerHandler(ipcMain);
}
@@ -843,4 +976,6 @@ module.exports = {
execCommand,
getSessionPwd,
generateKeyPair,
checkWindowsSshAgent,
findDefaultPrivateKey,
};
+14
View File
@@ -276,6 +276,12 @@ const api = {
generateKeyPair: async (options) => {
return ipcRenderer.invoke("netcatty:key:generate", options);
},
checkSshAgent: async () => {
return ipcRenderer.invoke("netcatty:ssh:check-agent");
},
getDefaultKeys: async () => {
return ipcRenderer.invoke("netcatty:ssh:get-default-keys");
},
resizeSession: (sessionId, cols, rows) => {
ipcRenderer.send("netcatty:resize", { sessionId, cols, rows });
},
@@ -357,6 +363,14 @@ const api = {
transferId
});
},
// Cancel an in-progress SFTP upload
cancelSftpUpload: async (transferId) => {
// Cleanup listeners
uploadProgressListeners.delete(transferId);
uploadCompleteListeners.delete(transferId);
uploadErrorListeners.delete(transferId);
return ipcRenderer.invoke("netcatty:sftp:cancelUpload", { transferId });
},
// Local filesystem operations
listLocalDir: async (path) => {
return ipcRenderer.invoke("netcatty:local:list", { path });
Vendored
+6 -1
View File
@@ -163,6 +163,8 @@ declare global {
bits?: number;
comment?: string;
}): Promise<{ success: boolean; privateKey?: string; publicKey?: string; error?: string }>;
checkSshAgent?(): Promise<{ running: boolean; startupType: string | null; error: string | null }>;
getDefaultKeys?(): Promise<Array<{ name: string; path: string }>>;
execCommand(options: {
hostname: string;
username: string;
@@ -228,7 +230,10 @@ declare global {
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void
): Promise<{ success: boolean; transferId: string }>;
): Promise<{ success: boolean; transferId: string; cancelled?: boolean }>;
// Cancel an in-progress SFTP upload
cancelSftpUpload?(transferId: string): Promise<{ success: boolean }>;
// Transfer with progress
uploadFile?(sftpId: string, localPath: string, remotePath: string, transferId: string): Promise<void>;
+18 -10
View File
@@ -475,6 +475,7 @@ function entryToFile(entry: FileSystemFileEntry): Promise<File> {
/**
* Recursively process a FileSystemEntry and collect all files
* Optimized with parallel processing for faster folder traversal
* @param entry - The file system entry to process
* @param basePath - The base path (folder name) to prepend
* @returns Array of DropEntry objects with files and their relative paths
@@ -512,18 +513,25 @@ async function processEntry(
const reader = dirEntry.createReader();
const entries = await readDirectoryEntries(reader);
// Helper to yield to main thread - prevents UI freezing during large folder parsing
const yieldToMain = () => new Promise<void>(resolve => setTimeout(resolve, 0));
// Process entries in parallel batches for better performance
// Use a concurrency limit to avoid overwhelming the browser
const BATCH_SIZE = 50;
// Process all entries in the directory with periodic yielding
for (let i = 0; i < entries.length; i++) {
// Yield every 10 entries to keep UI responsive
if (i > 0 && i % 10 === 0) {
await yieldToMain();
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
const batch = entries.slice(i, i + BATCH_SIZE);
// Process batch in parallel
const batchResults = await Promise.all(
batch.map(childEntry => processEntry(childEntry, currentPath))
);
// Flatten and add results
for (const childResults of batchResults) {
results.push(...childResults);
}
// Yield to main thread between batches to keep UI responsive
if (i + BATCH_SIZE < entries.length) {
await new Promise<void>(resolve => setTimeout(resolve, 0));
}
const childEntry = entries[i];
const childResults = await processEntry(childEntry, currentPath);
results.push(...childResults);
}
} catch (error) {
console.warn(`Failed to read directory: ${entry.name}`, error);