mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:31:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dbda5bec3 | ||
|
|
2da63c0180 | ||
|
|
2af9cfccb3 | ||
|
|
ffb736eeea | ||
|
|
83cd65ef63 | ||
|
|
e46046081a | ||
|
|
7f75fadb31 | ||
|
|
2997ed6b3c | ||
|
|
2b03db1142 | ||
|
|
513309ba7c |
No files matched your search
@@ -2,7 +2,8 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm run lint:*)"
|
||||
"Bash(npm run lint:*)",
|
||||
"Bash(npm run build:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '直连',
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -84,7 +84,6 @@ export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pem,.key,.pub,.ppk,*"
|
||||
className="hidden"
|
||||
onChange={handleFileImport}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user