Compare commits

...

17 Commits

Author SHA1 Message Date
陈大猫
1dbda5bec3 Merge pull request #103 from binaricat/fix/skip-encrypted-default-key
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: skip encrypted default SSH keys to allow password auth fallback
2026-01-21 02:03:14 +08:00
bincxz
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
bincxz
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
bincxz
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]
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]
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
bincxz
5918f91132 Improves 2FA and SSH authentication handling
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
Enhances keyboard-interactive (2FA/MFA) authentication by implementing a queue-based system, allowing multiple concurrent requests to be processed sequentially.

Previously, password prompts during keyboard-interactive authentication were auto-filled if a saved password was available. This change removes the auto-fill behavior to prevent issues with custom or ambiguous prompt texts, instead providing a user-initiated "Use saved password" option in the UI.

Increases the connection timeout to 120 seconds to provide ample time for users to complete 2FA challenges. A new UI indicator shows when additional 2FA requests are pending.

Also, refines SSH authentication logic to strictly respect explicit password authentication, preventing unintended attempts to use private keys when password authentication is selected.
2026-01-20 17:59:42 +08:00
陈大猫
7347b04461 Merge pull request #98 from binaricat:copilot/add-ioskeleymono-font-support
Add Ioskeley Mono font support
2026-01-20 17:16:29 +08:00
copilot-swe-agent[bot]
d8990dd4b1 Add Ioskeley Mono font support to terminal fonts configuration
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-20 09:04:09 +00:00
copilot-swe-agent[bot]
538dd71084 Initial plan 2026-01-20 08:58:32 +00:00
陈大猫
c43f485bee Merge pull request #96 from AkarinServer/pr/sftp-sudo
Add SFTP sudo mode support and fix sudo handshake
2026-01-20 16:57:31 +08:00
bincxz
839cce58ac Enhances SFTP Sudo usability and diagnostics
Adds client-side warnings to alert users when SFTP Sudo is enabled but a password is not configured, particularly for key-based authentication. This helps prevent connection issues by prompting users to address the missing password proactively.

Improves server-side error messages for SFTP Sudo failures, providing more specific diagnostic information for issues such as platform unavailability, handshake timeouts, and various exit codes (e.g., incorrect password, missing sftp-server, TTY requirement). This makes troubleshooting connection problems more effective.
2026-01-20 16:55:34 +08:00
TachibanaLolo
1324bf95cb Add SFTP sudo mode support and fix handshake 2026-01-20 15:27:44 +08:00
23 changed files with 1356 additions and 660 deletions

View File

@@ -2,7 +2,8 @@
"permissions": {
"allow": [
"Bash(npx tsc:*)",
"Bash(npm run lint:*)"
"Bash(npm run lint:*)",
"Bash(npm run build:*)"
]
}
}

30
App.tsx
View File

@@ -151,8 +151,8 @@ function App({ settings }: { settings: SettingsState }) {
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
// Navigation state for VaultView sections
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
// Keyboard-interactive authentication state (2FA/MFA)
const [keyboardInteractiveRequest, setKeyboardInteractiveRequest] = useState<KeyboardInteractiveRequest | null>(null);
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
const {
theme,
@@ -301,13 +301,15 @@ function App({ settings }: { settings: SettingsState }) {
const unsubscribe = bridge.onKeyboardInteractive((request) => {
console.log('[App] Keyboard-interactive request received:', request);
setKeyboardInteractiveRequest({
// Add to queue instead of replacing - supports multiple concurrent sessions
setKeyboardInteractiveQueue(prev => [...prev, {
requestId: request.requestId,
name: request.name,
instructions: request.instructions,
prompts: request.prompts,
hostname: request.hostname,
});
savedPassword: request.savedPassword,
}]);
});
return () => {
@@ -321,7 +323,8 @@ function App({ settings }: { settings: SettingsState }) {
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, responses, false);
}
setKeyboardInteractiveRequest(null);
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Handle keyboard-interactive cancel
@@ -330,7 +333,8 @@ function App({ settings }: { settings: SettingsState }) {
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, [], true);
}
setKeyboardInteractiveRequest(null);
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Debounce ref for moveFocus to prevent double-triggering when focus switches
@@ -661,7 +665,7 @@ function App({ settings }: { settings: SettingsState }) {
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host) => {
const { username, hostname: localHost } = systemInfoRef.current;
// Handle serial hosts separately
if (host.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
@@ -679,7 +683,7 @@ function App({ settings }: { settings: SettingsState }) {
connectToHost(host);
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
addConnectionLog({
@@ -1032,12 +1036,18 @@ function App({ settings }: { settings: SettingsState }) {
</Suspense>
)}
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) */}
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) - processes queue */}
<KeyboardInteractiveModal
request={keyboardInteractiveRequest}
request={keyboardInteractiveQueue[0] || null}
onSubmit={handleKeyboardInteractiveSubmit}
onCancel={handleKeyboardInteractiveCancel}
/>
{/* Indicator when more 2FA requests are pending */}
{keyboardInteractiveQueue.length > 1 && (
<div className="fixed bottom-4 right-4 z-50 bg-muted/90 backdrop-blur-sm text-sm px-3 py-1.5 rounded-full border shadow-sm">
{keyboardInteractiveQueue.length - 1} more pending
</div>
)}
</div>
);
}

View File

@@ -610,6 +610,10 @@ const en: Messages = {
'hostDetails.section.address': 'Address',
'hostDetails.hostname.placeholder': 'IP or Hostname',
'hostDetails.section.general': 'General',
'hostDetails.section.sftp': 'SFTP Settings',
'hostDetails.sftp.sudo': 'Sudo Mode',
'hostDetails.sftp.sudo.desc': 'Automatically acquire Root privileges using stored password',
'hostDetails.sftp.sudo.passwordWarning': 'Sudo mode requires a password. Configure one above, or ensure the server allows passwordless sudo.',
'hostDetails.label.placeholder': 'Label (e.g., Production Server)',
'hostDetails.group.placeholder': 'Parent Group',
'hostDetails.section.credentials': 'Credentials',
@@ -630,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',
@@ -1139,6 +1146,10 @@ const en: Messages = {
'keyboard.interactive.enterResponse': 'Enter response',
'keyboard.interactive.submit': 'Submit',
'keyboard.interactive.verifying': 'Verifying...',
'keyboard.interactive.fill': 'Fill',
'keyboard.interactive.fillSaved': 'Fill with saved password',
'keyboard.interactive.useSaved': 'Use saved',
'keyboard.interactive.useSavedPassword': 'Use saved password',
};
export default en;

View File

@@ -370,6 +370,10 @@ const zhCN: Messages = {
'hostDetails.section.address': '地址',
'hostDetails.hostname.placeholder': 'IP 或 主机名',
'hostDetails.section.general': '通用',
'hostDetails.section.sftp': 'SFTP 设置',
'hostDetails.sftp.sudo': 'Sudo 提权模式',
'hostDetails.sftp.sudo.desc': '使用保存的密码自动获取 Root 权限',
'hostDetails.sftp.sudo.passwordWarning': 'Sudo 模式需要密码。请在上方配置密码,或确保服务器允许免密 sudo。',
'hostDetails.label.placeholder': '名称例如Production Server',
'hostDetails.group.placeholder': '父级 Group',
'hostDetails.section.credentials': '凭据',
@@ -390,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': '直连',
@@ -1128,6 +1135,10 @@ const zhCN: Messages = {
'keyboard.interactive.enterResponse': '输入响应',
'keyboard.interactive.submit': '提交',
'keyboard.interactive.verifying': '验证中...',
'keyboard.interactive.fill': '填入',
'keyboard.interactive.fillSaved': '填入已保存的密码',
'keyboard.interactive.useSaved': '使用已保存',
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
};
export default zhCN;

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 };
};

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
@@ -669,6 +678,7 @@ export const useSftpState = (
keySource: key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sudo: host.sftpSudo,
};
},
[hosts, identities, keys],
@@ -878,11 +888,15 @@ export const useSftpState = (
if (hasKey) {
try {
// Prefer trying key/cert first when both are present.
sftpId = await openSftp({
const keyFirstCredentials = {
sessionId: `sftp-${connectionId}`,
...credentials,
password: undefined,
});
};
// Preserve password for sudo when enabled (still prefer key auth).
if (!credentials.sudo) {
keyFirstCredentials.password = undefined;
}
sftpId = await openSftp(keyFirstCredentials);
} catch (err) {
if (hasPassword && isAuthError(err)) {
sftpId = await openSftp({
@@ -2893,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,
@@ -2900,6 +2915,10 @@ export const useSftpState = (
currentIndex: 0,
totalFiles,
cancelled: false,
currentFileBytes: 0,
currentFileTotalBytes: 0,
currentFileSpeed: 0,
currentTransferId: "",
});
}
@@ -2928,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,
@@ -2935,6 +2960,10 @@ export const useSftpState = (
currentIndex: fileIndex,
totalFiles,
cancelled: false,
currentFileBytes: 0,
currentFileTotalBytes: fileTotalBytes,
currentFileSpeed: 0,
currentTransferId: transferId,
});
}
@@ -2960,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);
@@ -2983,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);
@@ -3000,6 +3072,8 @@ export const useSftpState = (
}
}
} finally {
// Clear the current transfer ID
currentFolderUploadTransferIdRef.current = "";
// Always reset progress state when done
setFolderUploadProgress({
isUploading: false,
@@ -3007,6 +3081,10 @@ export const useSftpState = (
currentIndex: 0,
totalFiles: 0,
cancelled: cancelFolderUploadRef.current,
currentFileBytes: 0,
currentFileTotalBytes: 0,
currentFileSpeed: 0,
currentTransferId: "",
});
}
@@ -3019,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
@@ -3196,4 +3296,3 @@ export const useSftpState = (
stableMethods,
]);
};

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";
@@ -28,6 +37,7 @@ import {
} from "./ui/aside-panel";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Switch } from "./ui/switch";
import { Card } from "./ui/card";
import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
import { Input } from "./ui/input";
@@ -80,6 +90,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onCreateTag,
}) => {
const { t } = useI18n();
const { checkSshAgent } = useApplicationBackend();
const [form, setForm] = useState<Host>(
() =>
initialData ||
@@ -115,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 || "");
@@ -480,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}
@@ -503,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}
@@ -556,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>
@@ -926,9 +962,40 @@ 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">
<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">
{t("hostDetails.sftp.sudo")}
</div>
<div className="text-xs text-muted-foreground">
{t("hostDetails.sftp.sudo.desc")}
</div>
</div>
<Switch
checked={form.sftpSudo || false}
onCheckedChange={(val) => update("sftpSudo", val)}
/>
</div>
{form.sftpSudo && !form.password && !selectedIdentity?.password && (
<p className="text-xs text-amber-500">
{t("hostDetails.sftp.sudo.passwordWarning")}
</p>
)}
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<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
@@ -1015,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}
@@ -1025,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}
@@ -1033,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) */}

View File

@@ -14,6 +14,7 @@ import {
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Switch } from "./ui/switch";
import {
Select,
SelectContent,
@@ -257,6 +258,29 @@ const HostForm: React.FC<HostFormProps> = ({
</div>
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between space-x-2 border rounded-md p-3">
<div className="space-y-0.5">
<Label htmlFor="sftp-sudo" className="text-base">
{t("hostDetails.sftp.sudo")}
</Label>
<p className="text-xs text-muted-foreground">
{t("hostDetails.sftp.sudo.desc")}
</p>
{formData.sftpSudo && authType === "key" && (
<p className="text-xs text-amber-500 mt-1">
{t("hostDetails.sftp.sudo.passwordWarning")}
</p>
)}
</div>
<Switch
id="sftp-sudo"
checked={formData.sftpSudo || false}
onCheckedChange={(checked) =>
setFormData({ ...formData, sftpSudo: checked })
}
/>
</div>
<Label>{t("hostForm.auth.method")}</Label>
<div className="grid grid-cols-2 gap-4">
<div

View File

@@ -28,6 +28,7 @@ export interface KeyboardInteractiveRequest {
instructions: string;
prompts: KeyboardInteractivePrompt[];
hostname?: string;
savedPassword?: string | null;
}
interface KeyboardInteractiveModalProps {
@@ -137,11 +138,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
value={responses[index] || ""}
onChange={(e) => handleResponseChange(index, e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
isPassword
? t("keyboard.interactive.enterCode")
: t("keyboard.interactive.enterResponse")
}
placeholder=""
className={isPassword ? "pr-10" : undefined}
autoFocus={index === 0}
disabled={isSubmitting}
@@ -149,7 +146,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
{isPassword && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
onClick={() => toggleShowPassword(index)}
disabled={isSubmitting}
>
@@ -157,6 +154,20 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
</button>
)}
</div>
{/* Use saved password button - shown below input, right-aligned */}
{isPassword && request.savedPassword && !responses[index] && (
<div className="flex justify-end">
<button
type="button"
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 disabled:opacity-50"
onClick={() => handleResponseChange(index, request.savedPassword!)}
disabled={isSubmitting}
>
<KeyRound size={12} />
<span>{t("keyboard.interactive.useSavedPassword")}</span>
</button>
</div>
)}
</div>
);
})}

View File

@@ -254,6 +254,7 @@ interface SFTPModalProps {
keySource?: 'generated' | 'imported';
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sftpSudo?: boolean;
};
open: boolean;
onClose: () => void;
@@ -521,6 +522,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
keySource: credentials.keySource,
proxy: credentials.proxy,
jumpHosts: credentials.jumpHosts,
sudo: credentials.sftpSudo,
});
sftpIdRef.current = sftpId;
return sftpId;
@@ -539,6 +541,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
credentials.keySource,
credentials.proxy,
credentials.jumpHosts,
credentials.sftpSudo,
openSftp,
]);
@@ -689,6 +692,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
keySource: credentials.keySource,
proxy: credentials.proxy,
jumpHosts: credentials.jumpHosts,
sudo: credentials.sftpSudo,
});
sftpIdRef.current = sftpId;

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}

View File

@@ -128,7 +128,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onToggleBroadcast,
onBroadcastInput,
}) => {
const CONNECTION_TIMEOUT = 12000;
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
const CONNECTION_TIMEOUT = 120000;
const { t } = useI18n();
const availableFonts = useAvailableFonts();
const containerRef = useRef<HTMLDivElement>(null);
@@ -1079,6 +1080,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
keySource: resolvedAuth.key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sftpSudo: host.sftpSudo,
};
})()}
open={showSFTP && status === "connected"}

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}
/>

View File

@@ -218,12 +218,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
identities: ctx.identities,
override: pendingAuth
? {
authMethod: pendingAuth.authMethod,
username: pendingAuth.username,
password: pendingAuth.password,
keyId: pendingAuth.keyId,
passphrase: pendingAuth.passphrase,
}
authMethod: pendingAuth.authMethod,
username: pendingAuth.username,
password: pendingAuth.password,
keyId: pendingAuth.keyId,
passphrase: pendingAuth.passphrase,
}
: null,
});
@@ -247,12 +247,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const proxyConfig = ctx.host.proxyConfig
? {
type: ctx.host.proxyConfig.type,
host: ctx.host.proxyConfig.host,
port: ctx.host.proxyConfig.port,
username: ctx.host.proxyConfig.username,
password: ctx.host.proxyConfig.password,
}
type: ctx.host.proxyConfig.type,
host: ctx.host.proxyConfig.host,
port: ctx.host.proxyConfig.port,
username: ctx.host.proxyConfig.username,
password: ctx.host.proxyConfig.password,
}
: undefined;
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost) => {
@@ -348,9 +348,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
let id: string;
const hasKeyMaterial = !!key?.privateKey;
// Respect explicit auth method selection - don't use key if password auth was explicitly selected
const authMethod = resolvedAuth.authMethod;
const hasKeyMaterial = !!key?.privateKey && authMethod !== 'password';
const hasPassword = !!effectivePassword;
if (hasKeyMaterial) {
try {
id = await startAttempt({ key });

View File

@@ -90,6 +90,8 @@ export interface Host {
telnetPassword?: string; // Telnet-specific password
// Serial-specific configuration (for protocol='serial' hosts)
serialConfig?: SerialConfig;
// SFTP specific configuration
sftpSudo?: boolean; // Use sudo for SFTP operations (requires password)
}
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';

View File

@@ -57,11 +57,12 @@ export const resolveHostAuth = (args: {
host.username?.trim() ||
"";
const keyId =
override?.keyId ||
identity?.keyId ||
host.identityFileId ||
undefined;
// Don't load key when explicit password auth is requested
// This ensures user's auth method selection is strictly respected
const keyId = override?.authMethod === 'password'
? undefined
: (override?.keyId || identity?.keyId || host.identityFileId || undefined);
const key = keyId ? keys.find((k) => k.id === keyId) : undefined;

View File

@@ -90,60 +90,21 @@ async function startPortForward(event, payload) {
return;
}
// Check if all prompts are password prompts that we can auto-answer
const responses = [];
const promptsNeedingUserInput = [];
for (let i = 0; i < prompts.length; i++) {
const prompt = prompts[i];
const promptText = (prompt.prompt || '').toLowerCase().trim();
// Auto-answer password prompts if we have a configured password
if (password && (
promptText.includes('password') ||
promptText === 'password:' ||
promptText === 'password'
)) {
console.log(`[PortForward] Auto-answering password prompt at index ${i}`);
responses[i] = password;
} else {
// This prompt needs user input (likely 2FA)
promptsNeedingUserInput.push({ index: i, prompt: prompt });
responses[i] = null; // Placeholder
}
}
// If all prompts were auto-answered, finish immediately
if (promptsNeedingUserInput.length === 0) {
console.log(`[PortForward] All prompts auto-answered, finishing keyboard-interactive`);
finish(responses);
return;
}
// If some prompts need user input, show the modal
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
// (Prompt text is admin-customizable and may not contain expected keywords)
const requestId = keyboardInteractiveHandler.generateRequestId('pf');
// Store finish callback with context about which responses are already filled
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
// Merge user responses with auto-filled responses
let userResponseIndex = 0;
for (let i = 0; i < prompts.length; i++) {
if (responses[i] === null) {
responses[i] = userResponses[userResponseIndex] || '';
userResponseIndex++;
}
}
console.log(`[PortForward] Merged responses, finishing keyboard-interactive`);
finish(responses);
console.log(`[PortForward] Received user responses, finishing keyboard-interactive`);
finish(userResponses);
}, sender.id, tunnelId);
// Send only the prompts that need user input
const promptsData = promptsNeedingUserInput.map((item) => ({
prompt: item.prompt.prompt,
echo: item.prompt.echo,
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
console.log(`[PortForward] Showing modal for ${promptsData.length} prompts that need user input`);
console.log(`[PortForward] Showing modal for ${promptsData.length} prompts`);
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
@@ -152,9 +113,11 @@ async function startPortForward(event, payload) {
instructions: instructions || "",
prompts: promptsData,
hostname: hostname,
savedPassword: password || null,
});
});
conn.on('ready', () => {
console.log(`[PortForward] SSH connection ready for tunnel ${tunnelId}`);

View File

@@ -9,6 +9,14 @@ const os = require("node:os");
const net = require("node:net");
const SftpClient = require("ssh2-sftp-client");
const { Client: SSHClient } = require("ssh2");
let SFTPWrapper;
try {
// Try to load SFTPWrapper from ssh2 internals for sudo support
const sftpModule = require("ssh2/lib/protocol/SFTP");
SFTPWrapper = sftpModule.SFTP || sftpModule;
} catch (e) {
console.warn("[SFTP] Failed to load SFTPWrapper from ssh2, sudo mode will not work:", e.message);
}
const { NetcattyAgent } = require("./netcattyAgent.cjs");
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
@@ -21,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
*/
@@ -184,6 +195,232 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
}
}
/**
* Establish an SFTP connection using sudo
* @param {SSHClient} client - Connected SSH client
* @param {string} password - User password for sudo
*/
async function connectSudoSftp(client, password) {
if (!SFTPWrapper) {
throw new Error("SFTP sudo mode is not available on this platform. Please disable sudo mode in host settings.");
}
// Known sftp-server paths to try
const sftpPaths = [
"/usr/lib/openssh/sftp-server",
"/usr/libexec/openssh/sftp-server",
"/usr/lib/ssh/sftp-server",
"/usr/libexec/sftp-server",
"/usr/local/libexec/sftp-server",
"/usr/local/lib/sftp-server"
];
console.log("[SFTP] Probing sftp-server path for sudo mode...");
let serverPath = null;
// Try to find the path
for (const p of sftpPaths) {
try {
await new Promise((resolve, reject) => {
client.exec(`test -x ${p}`, (err, stream) => {
if (err) return reject(err);
stream.on('exit', (code) => {
if (code === 0) resolve();
else reject(new Error('Not found'));
});
});
});
serverPath = p;
break;
} catch (e) {
// Continue probing
}
}
if (!serverPath) {
// Fallback: try to find it in path or assume standard location
console.warn("[SFTP] Could not probe sftp-server, trying default /usr/lib/openssh/sftp-server");
serverPath = "/usr/lib/openssh/sftp-server";
} else {
console.log(`[SFTP] Found sftp-server at ${serverPath}`);
}
return new Promise((resolve, reject) => {
// Use sudo -S to read password from stdin
// Use -p '' to set a specific prompt we can detect
// Use sh -c 'printf SFTPREADY; exec ...' to synchronize the start of sftp-server
// We use printf instead of echo to avoid trailing newline which could confuse SFTPWrapper
const prompt = "SUDOPASSWORD:";
const readyMarker = "SFTPREADY";
const readyMarkerBuffer = Buffer.from(readyMarker);
// Add -e to sftp-server to log to stderr for debugging
const cmd = `sudo -S -p '${prompt}' sh -c 'printf ${readyMarker}; exec ${serverPath} -e'`;
console.log(`[SFTP] Executing sudo command: ${cmd}`);
// Disable pty to ensure clean binary stream for SFTP
client.exec(cmd, { pty: false }, (err, stream) => {
if (err) return reject(err);
// Add stream lifecycle logging
stream.on('close', () => console.log("[SFTP] Stream closed"));
stream.on('end', () => console.log("[SFTP] Stream ended"));
stream.on('error', (e) => console.error("[SFTP] Stream error:", e.message));
let sftpInitialized = false;
let sftp = null;
let settled = false;
let stdoutBuffer = Buffer.alloc(0);
let stderrBuffer = "";
let pendingAfterMarker = null;
let sftpCreated = false;
const timeoutMs = 20000;
const timeoutId = setTimeout(() => {
if (sftpInitialized || settled) return;
settled = true;
stream.stderr?.removeListener('data', onStderr);
stream.removeListener('data', onStdout);
const error = new Error("SFTP sudo handshake timed out. This may happen if: (1) the password is incorrect, (2) sudo requires a TTY, or (3) the user does not have sudo privileges.");
reject(error);
}, timeoutMs);
const finalize = (err, result) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
stream.stderr?.removeListener('data', onStderr);
stream.removeListener('data', onStdout);
if (err) reject(err);
else resolve(result);
};
const createSftp = () => {
if (sftpCreated) return;
sftpCreated = true;
try {
const chanInfo = {
type: 'sftp',
incoming: stream.incoming,
outgoing: stream.outgoing
};
sftp = new SFTPWrapper(client, chanInfo, {
// debug: (str) => console.log(`[SFTP DEBUG] ${str}`)
});
// Route any remaining channel data directly into the SFTP parser
if (client._chanMgr && typeof stream.incoming?.id === "number") {
client._chanMgr.update(stream.incoming.id, sftp);
}
sftp.on('ready', () => {
sftpInitialized = true;
console.log("[SFTP] Protocol ready");
finalize(null, sftp);
});
sftp.on('error', (err) => {
console.error("[SFTP] Protocol error:", err.message);
if (!sftpInitialized) {
finalize(err);
}
});
stream.on('end', () => {
try { sftp.push(null); } catch { }
});
} catch (e) {
console.error("[SFTP] Initialization failed:", e.message);
finalize(e);
}
};
const initSftp = () => {
if (sftpInitialized) return;
console.log("[SFTP] Sudo success, initializing SFTP protocol...");
if (!sftpCreated) createSftp();
try {
// Start the handshake
console.log("[SFTP] Sending INIT packet...");
sftp._init();
if (pendingAfterMarker && pendingAfterMarker.length > 0) {
try {
sftp.push(pendingAfterMarker);
} catch (pushErr) {
console.warn("[SFTP] Failed to push buffered data:", pushErr.message);
}
pendingAfterMarker = null;
}
} catch (e) {
console.error("[SFTP] Initialization failed:", e.message);
finalize(e);
}
};
const onStdout = (data) => {
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
stdoutBuffer = stdoutBuffer.length > 0 ? Buffer.concat([stdoutBuffer, chunk]) : chunk;
const markerIndex = stdoutBuffer.indexOf(readyMarkerBuffer);
if (markerIndex !== -1) {
const afterMarkerIndex = markerIndex + readyMarkerBuffer.length;
if (afterMarkerIndex < stdoutBuffer.length) {
pendingAfterMarker = stdoutBuffer.subarray(afterMarkerIndex);
}
// Found marker, stop listening to stdout here so SFTPWrapper can take over
stream.removeListener('data', onStdout);
stdoutBuffer = Buffer.alloc(0);
console.log("[SFTP] SFTPREADY detected, waiting for stream to stabilize...");
// Delay SFTP initialization to ensure sftp-server is fully started and stream is clean
// Increased timeout to 1000ms to be safe
setTimeout(() => {
initSftp();
}, 1000);
} else if (stdoutBuffer.length > 256) {
stdoutBuffer = stdoutBuffer.subarray(stdoutBuffer.length - 256);
}
};
const onStderr = (data) => {
const chunk = data.toString();
// Only log that we received stderr data, not the content (may contain sensitive prompts)
stderrBuffer += chunk;
if (stderrBuffer.includes(prompt)) {
console.log("[SFTP] Sudo requested password, sending...");
// Send password
if (password) {
stream.write(password + '\n');
} else {
console.warn('[SFTP] sudo requested password but none provided');
stream.write('\n');
}
stderrBuffer = "";
} else if (stderrBuffer.length > 256) {
stderrBuffer = stderrBuffer.slice(-256);
}
};
stream.on('data', onStdout);
stream.stderr.on('data', onStderr);
// Error handling
stream.on('exit', (code) => {
console.log(`[SFTP] Stream exited with code ${code}`);
if (!sftpInitialized && code !== 0) {
let errorMsg = `SFTP sudo failed with exit code ${code}.`;
if (code === 1) {
errorMsg += " The password may be incorrect or sudo privileges are denied.";
} else if (code === 127) {
errorMsg += " sftp-server was not found on the remote system.";
}
const error = new Error(errorMsg);
finalize(error);
}
});
});
});
}
/**
* Open a new SFTP connection
* Supports jump host connections when options.jumpHosts is provided
@@ -264,6 +501,9 @@ async function openSftp(event, options) {
const order = ["agent"];
if (connectOpts.password) order.push("password");
connectOpts.authHandler = order;
} else if (options.privateKey && connectOpts.password) {
// Prefer key auth when both key and password are present (password still needed for sudo)
connectOpts.authHandler = ["publickey", "password"];
}
// Add keyboard-interactive authentication support
@@ -283,60 +523,21 @@ async function openSftp(event, options) {
return;
}
// Check if all prompts are password prompts that we can auto-answer
const responses = [];
const promptsNeedingUserInput = [];
for (let i = 0; i < prompts.length; i++) {
const prompt = prompts[i];
const promptText = (prompt.prompt || '').toLowerCase().trim();
// Auto-answer password prompts if we have a configured password
if (options.password && (
promptText.includes('password') ||
promptText === 'password:' ||
promptText === 'password'
)) {
console.log(`[SFTP] Auto-answering password prompt at index ${i}`);
responses[i] = options.password;
} else {
// This prompt needs user input (likely 2FA)
promptsNeedingUserInput.push({ index: i, prompt: prompt });
responses[i] = null; // Placeholder
}
}
// If all prompts were auto-answered, finish immediately
if (promptsNeedingUserInput.length === 0) {
console.log(`[SFTP] All prompts auto-answered, finishing keyboard-interactive`);
finish(responses);
return;
}
// If some prompts need user input, show the modal
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
// (Prompt text is admin-customizable and may not contain expected keywords)
const requestId = keyboardInteractiveHandler.generateRequestId('sftp');
// Store finish callback with context about which responses are already filled
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
// Merge user responses with auto-filled responses
let userResponseIndex = 0;
for (let i = 0; i < prompts.length; i++) {
if (responses[i] === null) {
responses[i] = userResponses[userResponseIndex] || '';
userResponseIndex++;
}
}
console.log(`[SFTP] Merged responses, finishing keyboard-interactive`);
finish(responses);
console.log(`[SFTP] Received user responses, finishing keyboard-interactive`);
finish(userResponses);
}, event.sender.id, connId);
// Send only the prompts that need user input
const promptsData = promptsNeedingUserInput.map((item) => ({
prompt: item.prompt.prompt,
echo: item.prompt.echo,
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
console.log(`[SFTP] Showing modal for ${promptsData.length} prompts that need user input`);
console.log(`[SFTP] Showing modal for ${promptsData.length} prompts`);
safeSend(event.sender, "netcatty:keyboard-interactive", {
requestId,
@@ -345,9 +546,11 @@ async function openSftp(event, options) {
instructions: instructions || "",
prompts: promptsData,
hostname: options.hostname,
savedPassword: options.password || null,
});
};
// Add keyboard-interactive listener BEFORE connecting
client.on("keyboard-interactive", kiHandler);
@@ -370,8 +573,44 @@ async function openSftp(event, options) {
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
try {
await client.connect(connectOpts);
if (options.sudo) {
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
const sshClient = client.client;
await new Promise((resolve, reject) => {
// Set up error handler for initial connection
const onConnectError = (err) => reject(err);
sshClient.once('error', onConnectError);
sshClient.once('ready', async () => {
sshClient.removeListener('error', onConnectError);
try {
// Use provided password or try empty if using key auth (and hope for nopasswd sudo)
const sudoPass = options.password || "";
const sftpWrapper = await connectSudoSftp(sshClient, sudoPass);
// Inject into sftp-client
client.sftp = sftpWrapper;
// Important: attach cleanup listener expected by sftp-client
client.sftp.on('close', () => client.end());
resolve();
} catch (e) {
sshClient.end();
reject(e);
}
});
try {
sshClient.connect(connectOpts);
} catch (e) {
reject(e);
}
});
} else {
await client.connect(connectOpts);
}
// Increase max listeners AFTER connect, when the internal ssh2 Client exists
// This prevents Node.js MaxListenersExceededWarning when performing many operations
// ssh2-sftp-client adds temporary listeners for each operation, so we need a high limit
@@ -501,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();
@@ -531,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 {
@@ -546,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);
@@ -555,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
@@ -669,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);
@@ -694,6 +997,7 @@ module.exports = {
readSftpBinary,
writeSftp,
writeSftpBinaryWithProgress,
cancelSftpUpload,
closeSftp,
mkdirSftp,
deleteSftp,

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;
@@ -522,61 +641,21 @@ async function startSSHSession(event, options) {
return;
}
// Check if all prompts are password prompts that we can auto-answer
const responses = [];
const promptsNeedingUserInput = [];
for (let i = 0; i < prompts.length; i++) {
const prompt = prompts[i];
const promptText = (prompt.prompt || '').toLowerCase().trim();
// Auto-answer password prompts if we have a configured password
if (options.password && (
promptText.includes('password') ||
promptText === 'password:' ||
promptText === 'password'
)) {
console.log(`${logPrefix} Auto-answering password prompt at index ${i}`);
responses[i] = options.password;
} else {
// This prompt needs user input (likely 2FA)
promptsNeedingUserInput.push({ index: i, prompt: prompt });
responses[i] = null; // Placeholder
}
}
// If all prompts were auto-answered, finish immediately
if (promptsNeedingUserInput.length === 0) {
console.log(`${logPrefix} All prompts auto-answered, finishing keyboard-interactive`);
finish(responses);
return;
}
// If some prompts need user input, show the modal
// But only send the prompts that need user input
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
// (Prompt text is admin-customizable and may not contain expected keywords)
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
// Store finish callback with context about which responses are already filled
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
// Merge user responses with auto-filled responses
let userResponseIndex = 0;
for (let i = 0; i < prompts.length; i++) {
if (responses[i] === null) {
responses[i] = userResponses[userResponseIndex] || '';
userResponseIndex++;
}
}
console.log(`${logPrefix} Merged responses, finishing keyboard-interactive`);
finish(responses);
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
finish(userResponses);
}, sender.id, sessionId);
// Send only the prompts that need user input
const promptsData = promptsNeedingUserInput.map((item) => ({
prompt: item.prompt.prompt,
echo: item.prompt.echo,
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts that need user input`);
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
@@ -585,9 +664,11 @@ async function startSSHSession(event, options) {
instructions: instructions || "",
prompts: promptsData,
hostname: options.hostname,
savedPassword: options.password || null, // Pass saved password for optional fill button
});
});
// Enable keyboard-interactive authentication in authHandler
if (connectOpts.authHandler) {
// Add keyboard-interactive after the existing methods
@@ -869,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);
}
@@ -881,4 +976,6 @@ module.exports = {
execCommand,
getSessionPwd,
generateKeyPair,
checkWindowsSshAgent,
findDefaultPrivateKey,
};

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 });

882
global.d.ts vendored
View File

@@ -2,460 +2,468 @@ import type { RemoteFile } from "./types";
import type { S3Config, SMBConfig, SyncedFile, WebDAVConfig } from "./domain/sync";
declare global {
// Proxy configuration for SSH connections
interface NetcattyProxyConfig {
type: 'http' | 'socks5';
host: string;
port: number;
username?: string;
password?: string;
}
// Jump host configuration for SSH tunneling
interface NetcattyJumpHost {
hostname: string;
port: number;
username: string;
password?: string;
privateKey?: string;
certificate?: string;
passphrase?: string;
publicKey?: string;
keyId?: string;
keySource?: 'generated' | 'imported';
label?: string; // Display label for UI
}
// Host key information for verification
// Reserved for future host key verification UI feature
interface _NetcattyHostKeyInfo {
hostname: string;
port: number;
keyType: string;
fingerprint: string;
publicKey?: string;
}
interface NetcattySSHOptions {
sessionId?: string;
hostname: string;
username: string;
port?: number;
password?: string;
privateKey?: string;
// Optional OpenSSH user certificate
certificate?: string;
publicKey?: string; // OpenSSH public key line
keyId?: string;
keySource?: 'generated' | 'imported';
agentForwarding?: boolean;
cols?: number;
rows?: number;
charset?: string;
extraArgs?: string[];
startupCommand?: string;
passphrase?: string;
// Environment variables to set in the remote shell
env?: Record<string, string>;
// Proxy configuration
proxy?: NetcattyProxyConfig;
// Jump hosts (bastion chain)
jumpHosts?: NetcattyJumpHost[];
// SSH-level keepalive interval in seconds (0 = disabled)
keepaliveInterval?: number;
}
interface SftpStatResult {
name: string;
type: 'file' | 'directory' | 'symlink';
size: number;
lastModified: number; // timestamp
permissions?: string; // e.g., "rwxr-xr-x"
owner?: string;
group?: string;
}
interface SftpTransferProgress {
transferId: string;
bytesTransferred: number;
totalBytes: number;
speed: number; // bytes per second
}
// Port Forwarding Types
interface PortForwardOptions {
tunnelId: string;
type: 'local' | 'remote' | 'dynamic';
localPort: number;
bindAddress?: string;
remoteHost?: string;
remotePort?: number;
// SSH connection details
hostname: string;
port?: number;
username: string;
password?: string;
privateKey?: string;
}
interface PortForwardResult {
tunnelId: string;
success: boolean;
error?: string;
}
interface PortForwardStatusResult {
tunnelId: string;
status: 'inactive' | 'connecting' | 'active' | 'error';
type?: 'local' | 'remote' | 'dynamic';
error?: string;
}
type PortForwardStatusCallback = (status: 'inactive' | 'connecting' | 'active' | 'error', error?: string) => void;
interface NetcattyBridge {
startSSHSession(options: NetcattySSHOptions): Promise<string>;
startTelnetSession?(options: {
sessionId?: string;
hostname: string;
port?: number;
cols?: number;
rows?: number;
charset?: string;
env?: Record<string, string>;
}): Promise<string>;
startMoshSession?(options: {
sessionId?: string;
hostname: string;
// Proxy configuration for SSH connections
interface NetcattyProxyConfig {
type: 'http' | 'socks5';
host: string;
port: number;
username?: string;
port?: number;
moshServerPath?: string;
agentForwarding?: boolean;
cols?: number;
rows?: number;
charset?: string;
env?: Record<string, string>;
}): Promise<string>;
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string> }): Promise<string>;
startSerialSession?(options: {
password?: string;
}
// Jump host configuration for SSH tunneling
interface NetcattyJumpHost {
hostname: string;
port: number;
username: string;
password?: string;
privateKey?: string;
certificate?: string;
passphrase?: string;
publicKey?: string;
keyId?: string;
keySource?: 'generated' | 'imported';
label?: string; // Display label for UI
}
// Host key information for verification
// Reserved for future host key verification UI feature
interface _NetcattyHostKeyInfo {
hostname: string;
port: number;
keyType: string;
fingerprint: string;
publicKey?: string;
}
interface NetcattySSHOptions {
sessionId?: string;
path: string;
baudRate?: number;
dataBits?: 5 | 6 | 7 | 8;
stopBits?: 1 | 1.5 | 2;
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space';
flowControl?: 'none' | 'xon/xoff' | 'rts/cts';
}): Promise<string>;
listSerialPorts?(): Promise<Array<{
path: string;
manufacturer: string;
serialNumber: string;
vendorId: string;
productId: string;
pnpId: string;
}>>;
getDefaultShell?(): Promise<string>;
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean }>;
generateKeyPair?(options: {
type: 'RSA' | 'ECDSA' | 'ED25519';
bits?: number;
comment?: string;
}): Promise<{ success: boolean; privateKey?: string; publicKey?: string; error?: string }>;
execCommand(options: {
hostname: string;
username: string;
port?: number;
password?: string;
privateKey?: string;
command: string;
timeout?: number;
}): Promise<{ stdout: string; stderr: string; code: number | null }>;
/** Get current working directory from an active SSH session */
getSessionPwd?(sessionId: string): Promise<{ success: boolean; cwd?: string; error?: string }>;
writeToSession(sessionId: string, data: string): void;
resizeSession(sessionId: string, cols: number, rows: number): void;
closeSession(sessionId: string): void;
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
onSessionExit(
sessionId: string,
cb: (evt: { exitCode?: number; signal?: number }) => void
): () => void;
onAuthFailed?(
sessionId: string,
cb: (evt: { sessionId: string; error: string; hostname: string }) => void
): () => void;
// Keyboard-interactive authentication (2FA/MFA)
onKeyboardInteractive?(
cb: (request: {
requestId: string;
sessionId: string;
name: string;
instructions: string;
prompts: Array<{ prompt: string; echo: boolean }>;
hostname: string;
}) => void
): () => void;
respondKeyboardInteractive?(
requestId: string,
responses: string[],
cancelled?: boolean
): Promise<{ success: boolean; error?: string }>;
// SFTP operations
openSftp(options: NetcattySSHOptions): Promise<string>;
listSftp(sftpId: string, path: string): Promise<RemoteFile[]>;
readSftp(sftpId: string, path: string): Promise<string>;
readSftpBinary?(sftpId: string, path: string): Promise<ArrayBuffer>;
writeSftp(sftpId: string, path: string, content: string): Promise<void>;
writeSftpBinary?(sftpId: string, path: string, content: ArrayBuffer): Promise<void>;
closeSftp(sftpId: string): Promise<void>;
mkdirSftp(sftpId: string, path: string): Promise<void>;
deleteSftp?(sftpId: string, path: string): Promise<void>;
renameSftp?(sftpId: string, oldPath: string, newPath: string): Promise<void>;
statSftp?(sftpId: string, path: string): Promise<SftpStatResult>;
chmodSftp?(sftpId: string, path: string, mode: string): Promise<void>;
// Write binary with real-time progress callback
writeSftpBinaryWithProgress?(
sftpId: string,
path: string,
content: ArrayBuffer,
transferId: string,
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void
): Promise<{ success: boolean; transferId: string }>;
// Transfer with progress
uploadFile?(sftpId: string, localPath: string, remotePath: string, transferId: string): Promise<void>;
downloadFile?(sftpId: string, remotePath: string, localPath: string, transferId: string): Promise<void>;
cancelTransfer?(transferId: string): Promise<void>;
onTransferProgress?(transferId: string, cb: (progress: SftpTransferProgress) => void): () => void;
// Streaming transfer with real progress and cancellation
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 }>;
// Local filesystem operations
listLocalDir?(path: string): Promise<RemoteFile[]>;
readLocalFile?(path: string): Promise<ArrayBuffer>;
writeLocalFile?(path: string, content: ArrayBuffer): Promise<void>;
deleteLocalFile?(path: string): Promise<void>;
renameLocalFile?(oldPath: string, newPath: string): Promise<void>;
mkdirLocal?(path: string): Promise<void>;
statLocal?(path: string): Promise<SftpStatResult>;
getHomeDir?(): Promise<string>;
getSystemInfo?(): Promise<{ username: string; hostname: string }>;
setTheme?(theme: 'light' | 'dark'): Promise<boolean>;
setBackgroundColor?(color: string): Promise<boolean>;
setLanguage?(language: string): Promise<boolean>;
// Window controls for custom title bar (Windows/Linux)
windowMinimize?(): Promise<void>;
windowMaximize?(): Promise<boolean>;
windowClose?(): Promise<void>;
windowIsMaximized?(): Promise<boolean>;
windowIsFullscreen?(): Promise<boolean>;
onWindowFullScreenChanged?(cb: (isFullscreen: boolean) => void): () => void;
// Settings window
openSettingsWindow?(): Promise<boolean>;
closeSettingsWindow?(): Promise<void>;
// Optional OpenSSH user certificate
certificate?: string;
publicKey?: string; // OpenSSH public key line
keyId?: string;
keySource?: 'generated' | 'imported';
agentForwarding?: boolean;
cols?: number;
rows?: number;
charset?: string;
extraArgs?: string[];
startupCommand?: string;
passphrase?: string;
// Environment variables to set in the remote shell
env?: Record<string, string>;
// Proxy configuration
proxy?: NetcattyProxyConfig;
// Jump hosts (bastion chain)
jumpHosts?: NetcattyJumpHost[];
// SSH-level keepalive interval in seconds (0 = disabled)
keepaliveInterval?: number;
// Use sudo for SFTP server
sudo?: boolean;
}
// Cross-window settings sync
notifySettingsChanged?(payload: { key: string; value: unknown }): void;
onSettingsChanged?(cb: (payload: { key: string; value: unknown }) => void): () => void;
interface SftpStatResult {
name: string;
type: 'file' | 'directory' | 'symlink';
size: number;
lastModified: number; // timestamp
permissions?: string; // e.g., "rwxr-xr-x"
owner?: string;
group?: string;
}
// Cloud sync master password (stored in-memory + persisted via Electron safeStorage)
cloudSyncSetSessionPassword?(password: string): Promise<boolean>;
cloudSyncGetSessionPassword?(): Promise<string | null>;
cloudSyncClearSessionPassword?(): Promise<boolean>;
interface SftpTransferProgress {
transferId: string;
bytesTransferred: number;
totalBytes: number;
speed: number; // bytes per second
}
// Cloud sync network operations (proxied via main process)
cloudSyncWebdavInitialize?(config: WebDAVConfig): Promise<{ resourceId: string | null }>;
cloudSyncWebdavUpload?(
config: WebDAVConfig,
syncedFile: SyncedFile
): Promise<{ resourceId: string }>;
cloudSyncWebdavDownload?(config: WebDAVConfig): Promise<{ syncedFile: SyncedFile | null }>;
cloudSyncWebdavDelete?(config: WebDAVConfig): Promise<{ ok: true }>;
// Port Forwarding Types
interface PortForwardOptions {
tunnelId: string;
type: 'local' | 'remote' | 'dynamic';
localPort: number;
bindAddress?: string;
remoteHost?: string;
remotePort?: number;
// SSH connection details
hostname: string;
port?: number;
username: string;
password?: string;
privateKey?: string;
}
cloudSyncS3Initialize?(config: S3Config): Promise<{ resourceId: string | null }>;
cloudSyncS3Upload?(
config: S3Config,
syncedFile: SyncedFile
): Promise<{ resourceId: string }>;
cloudSyncS3Download?(config: S3Config): Promise<{ syncedFile: SyncedFile | null }>;
cloudSyncS3Delete?(config: S3Config): Promise<{ ok: true }>;
cloudSyncSmbInitialize?(config: SMBConfig): Promise<{ resourceId: string | null }>;
cloudSyncSmbUpload?(
config: SMBConfig,
syncedFile: SyncedFile
): Promise<{ resourceId: string }>;
cloudSyncSmbDownload?(config: SMBConfig): Promise<{ syncedFile: SyncedFile | null }>;
cloudSyncSmbDelete?(config: SMBConfig): Promise<{ ok: true }>;
// Port Forwarding
startPortForward?(options: PortForwardOptions): Promise<PortForwardResult>;
stopPortForward?(tunnelId: string): Promise<PortForwardResult>;
getPortForwardStatus?(tunnelId: string): Promise<PortForwardStatusResult>;
listPortForwards?(): Promise<{ tunnelId: string; type: string; status: string }[]>;
onPortForwardStatus?(tunnelId: string, cb: PortForwardStatusCallback): () => void;
// Known Hosts
readKnownHosts?(): Promise<string | null>;
// Open URL in default browser
openExternal?(url: string): Promise<void>;
// App info (name/version/platform) for About screens
getAppInfo?(): Promise<{ name: string; version: string; platform: string }>;
// Notify main process the renderer has mounted/painted (used to avoid initial blank screen).
rendererReady?(): void;
onLanguageChanged?(cb: (language: string) => void): () => void;
// Chain progress listener for jump host connections
// Callback receives: (currentHop: number, totalHops: number, hostLabel: string, status: string)
onChainProgress?(cb: (hop: number, total: number, label: string, status: string) => void): () => void;
// OAuth callback server for cloud sync
startOAuthCallback?(expectedState?: string): Promise<{ code: string; state?: string }>;
cancelOAuthCallback?(): Promise<void>;
// GitHub Device Flow (cloud sync)
githubStartDeviceFlow?(options?: { clientId?: string; scope?: string }): Promise<{
deviceCode: string;
userCode: string;
verificationUri: string;
expiresAt: number;
interval: number;
}>;
githubPollDeviceFlowToken?(options: { clientId?: string; deviceCode: string }): Promise<{
access_token?: string;
token_type?: string;
scope?: string;
interface PortForwardResult {
tunnelId: string;
success: boolean;
error?: string;
error_description?: string;
}>;
}
// Google OAuth (cloud sync) - proxied via main process to avoid CORS
googleExchangeCodeForTokens?(options: {
clientId: string;
clientSecret?: string;
code: string;
codeVerifier: string;
redirectUri: string;
}): Promise<{
accessToken: string;
refreshToken?: string;
expiresAt?: number;
tokenType: string;
scope?: string;
}>;
googleRefreshAccessToken?(options: {
clientId: string;
clientSecret?: string;
refreshToken: string;
}): Promise<{
accessToken: string;
refreshToken: string;
expiresAt?: number;
tokenType: string;
scope?: string;
}>;
googleGetUserInfo?(options: { accessToken: string }): Promise<{
id: string;
email: string;
name: string;
picture?: string;
}>;
interface PortForwardStatusResult {
tunnelId: string;
status: 'inactive' | 'connecting' | 'active' | 'error';
type?: 'local' | 'remote' | 'dynamic';
error?: string;
}
// Google Drive API (cloud sync) - proxied via main process to avoid CORS/COEP issues
googleDriveFindSyncFile?(options: { accessToken: string; fileName?: string }): Promise<{ fileId: string | null }>;
googleDriveCreateSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string }>;
googleDriveUpdateSyncFile?(options: { accessToken: string; fileId: string; syncedFile: unknown }): Promise<{ ok: true }>;
googleDriveDownloadSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ syncedFile: unknown | null }>;
googleDriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
type PortForwardStatusCallback = (status: 'inactive' | 'connecting' | 'active' | 'error', error?: string) => void;
// OneDrive OAuth + Graph (cloud sync) - proxied via main process to avoid CORS
onedriveExchangeCodeForTokens?(options: {
clientId: string;
code: string;
codeVerifier: string;
redirectUri: string;
scope?: string;
}): Promise<{
accessToken: string;
refreshToken?: string;
expiresAt?: number;
tokenType: string;
scope?: string;
}>;
onedriveRefreshAccessToken?(options: {
clientId: string;
refreshToken: string;
scope?: string;
}): Promise<{
accessToken: string;
refreshToken: string;
expiresAt?: number;
tokenType: string;
scope?: string;
}>;
onedriveGetUserInfo?(options: { accessToken: string }): Promise<{
id: string;
email: string;
name: string;
avatarDataUrl?: string;
}>;
onedriveFindSyncFile?(options: { accessToken: string; fileName?: string }): Promise<{ fileId: string | null }>;
onedriveUploadSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string | null }>;
onedriveDownloadSyncFile?(options: { accessToken: string; fileId?: string; fileName?: string }): Promise<{ syncedFile: unknown | null }>;
onedriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
interface NetcattyBridge {
startSSHSession(options: NetcattySSHOptions): Promise<string>;
startTelnetSession?(options: {
sessionId?: string;
hostname: string;
port?: number;
cols?: number;
rows?: number;
charset?: string;
env?: Record<string, string>;
}): Promise<string>;
startMoshSession?(options: {
sessionId?: string;
hostname: string;
username?: string;
port?: number;
moshServerPath?: string;
agentForwarding?: boolean;
cols?: number;
rows?: number;
charset?: string;
env?: Record<string, string>;
}): Promise<string>;
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string> }): Promise<string>;
startSerialSession?(options: {
sessionId?: string;
path: string;
baudRate?: number;
dataBits?: 5 | 6 | 7 | 8;
stopBits?: 1 | 1.5 | 2;
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space';
flowControl?: 'none' | 'xon/xoff' | 'rts/cts';
}): Promise<string>;
listSerialPorts?(): Promise<Array<{
path: string;
manufacturer: string;
serialNumber: string;
vendorId: string;
productId: string;
pnpId: string;
}>>;
getDefaultShell?(): Promise<string>;
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean }>;
generateKeyPair?(options: {
type: 'RSA' | 'ECDSA' | 'ED25519';
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;
port?: number;
password?: string;
privateKey?: string;
command: string;
timeout?: number;
}): Promise<{ stdout: string; stderr: string; code: number | null }>;
/** Get current working directory from an active SSH session */
getSessionPwd?(sessionId: string): Promise<{ success: boolean; cwd?: string; error?: string }>;
writeToSession(sessionId: string, data: string): void;
resizeSession(sessionId: string, cols: number, rows: number): void;
closeSession(sessionId: string): void;
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
onSessionExit(
sessionId: string,
cb: (evt: { exitCode?: number; signal?: number }) => void
): () => void;
onAuthFailed?(
sessionId: string,
cb: (evt: { sessionId: string; error: string; hostname: string }) => void
): () => void;
// File opener helpers (for "Open With" feature)
selectApplication?(): Promise<{ path: string; name: string } | null>;
openWithApplication?(filePath: string, appPath: string): Promise<boolean>;
downloadSftpToTemp?(sftpId: string, remotePath: string, fileName: string): Promise<string>;
// File watcher for auto-sync feature
startFileWatch?(localPath: string, remotePath: string, sftpId: string): Promise<{ watchId: string }>;
stopFileWatch?(watchId: string, cleanupTempFile?: boolean): Promise<{ success: boolean }>;
listFileWatches?(): Promise<Array<{ watchId: string; localPath: string; remotePath: string; sftpId: string }>>;
registerTempFile?(sftpId: string, localPath: string): Promise<{ success: boolean }>;
onFileWatchSynced?(cb: (payload: { watchId: string; localPath: string; remotePath: string; bytesWritten: number }) => void): () => void;
onFileWatchError?(cb: (payload: { watchId: string; localPath: string; remotePath: string; error: string }) => void): () => void;
// Temp file cleanup
deleteTempFile?(filePath: string): Promise<{ success: boolean }>;
// Temp directory management
getTempDirInfo?(): Promise<{ path: string; fileCount: number; totalSize: number }>;
clearTempDir?(): Promise<{ deletedCount: number; failedCount: number; error?: string }>;
getTempDirPath?(): Promise<string>;
openTempDir?(): Promise<{ success: boolean }>;
}
// Keyboard-interactive authentication (2FA/MFA)
onKeyboardInteractive?(
cb: (request: {
requestId: string;
sessionId: string;
name: string;
instructions: string;
prompts: Array<{ prompt: string; echo: boolean }>;
hostname: string;
savedPassword?: string | null;
}) => void
): () => void;
respondKeyboardInteractive?(
requestId: string,
responses: string[],
cancelled?: boolean
): Promise<{ success: boolean; error?: string }>;
interface Window {
netcatty?: NetcattyBridge;
}
// SFTP operations
openSftp(options: NetcattySSHOptions): Promise<string>;
listSftp(sftpId: string, path: string): Promise<RemoteFile[]>;
readSftp(sftpId: string, path: string): Promise<string>;
readSftpBinary?(sftpId: string, path: string): Promise<ArrayBuffer>;
writeSftp(sftpId: string, path: string, content: string): Promise<void>;
writeSftpBinary?(sftpId: string, path: string, content: ArrayBuffer): Promise<void>;
closeSftp(sftpId: string): Promise<void>;
mkdirSftp(sftpId: string, path: string): Promise<void>;
deleteSftp?(sftpId: string, path: string): Promise<void>;
renameSftp?(sftpId: string, oldPath: string, newPath: string): Promise<void>;
statSftp?(sftpId: string, path: string): Promise<SftpStatResult>;
chmodSftp?(sftpId: string, path: string, mode: string): Promise<void>;
// Write binary with real-time progress callback
writeSftpBinaryWithProgress?(
sftpId: string,
path: string,
content: ArrayBuffer,
transferId: string,
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void
): 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>;
downloadFile?(sftpId: string, remotePath: string, localPath: string, transferId: string): Promise<void>;
cancelTransfer?(transferId: string): Promise<void>;
onTransferProgress?(transferId: string, cb: (progress: SftpTransferProgress) => void): () => void;
// Streaming transfer with real progress and cancellation
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 }>;
// Local filesystem operations
listLocalDir?(path: string): Promise<RemoteFile[]>;
readLocalFile?(path: string): Promise<ArrayBuffer>;
writeLocalFile?(path: string, content: ArrayBuffer): Promise<void>;
deleteLocalFile?(path: string): Promise<void>;
renameLocalFile?(oldPath: string, newPath: string): Promise<void>;
mkdirLocal?(path: string): Promise<void>;
statLocal?(path: string): Promise<SftpStatResult>;
getHomeDir?(): Promise<string>;
getSystemInfo?(): Promise<{ username: string; hostname: string }>;
setTheme?(theme: 'light' | 'dark'): Promise<boolean>;
setBackgroundColor?(color: string): Promise<boolean>;
setLanguage?(language: string): Promise<boolean>;
// Window controls for custom title bar (Windows/Linux)
windowMinimize?(): Promise<void>;
windowMaximize?(): Promise<boolean>;
windowClose?(): Promise<void>;
windowIsMaximized?(): Promise<boolean>;
windowIsFullscreen?(): Promise<boolean>;
onWindowFullScreenChanged?(cb: (isFullscreen: boolean) => void): () => void;
// Settings window
openSettingsWindow?(): Promise<boolean>;
closeSettingsWindow?(): Promise<void>;
// Cross-window settings sync
notifySettingsChanged?(payload: { key: string; value: unknown }): void;
onSettingsChanged?(cb: (payload: { key: string; value: unknown }) => void): () => void;
// Cloud sync master password (stored in-memory + persisted via Electron safeStorage)
cloudSyncSetSessionPassword?(password: string): Promise<boolean>;
cloudSyncGetSessionPassword?(): Promise<string | null>;
cloudSyncClearSessionPassword?(): Promise<boolean>;
// Cloud sync network operations (proxied via main process)
cloudSyncWebdavInitialize?(config: WebDAVConfig): Promise<{ resourceId: string | null }>;
cloudSyncWebdavUpload?(
config: WebDAVConfig,
syncedFile: SyncedFile
): Promise<{ resourceId: string }>;
cloudSyncWebdavDownload?(config: WebDAVConfig): Promise<{ syncedFile: SyncedFile | null }>;
cloudSyncWebdavDelete?(config: WebDAVConfig): Promise<{ ok: true }>;
cloudSyncS3Initialize?(config: S3Config): Promise<{ resourceId: string | null }>;
cloudSyncS3Upload?(
config: S3Config,
syncedFile: SyncedFile
): Promise<{ resourceId: string }>;
cloudSyncS3Download?(config: S3Config): Promise<{ syncedFile: SyncedFile | null }>;
cloudSyncS3Delete?(config: S3Config): Promise<{ ok: true }>;
cloudSyncSmbInitialize?(config: SMBConfig): Promise<{ resourceId: string | null }>;
cloudSyncSmbUpload?(
config: SMBConfig,
syncedFile: SyncedFile
): Promise<{ resourceId: string }>;
cloudSyncSmbDownload?(config: SMBConfig): Promise<{ syncedFile: SyncedFile | null }>;
cloudSyncSmbDelete?(config: SMBConfig): Promise<{ ok: true }>;
// Port Forwarding
startPortForward?(options: PortForwardOptions): Promise<PortForwardResult>;
stopPortForward?(tunnelId: string): Promise<PortForwardResult>;
getPortForwardStatus?(tunnelId: string): Promise<PortForwardStatusResult>;
listPortForwards?(): Promise<{ tunnelId: string; type: string; status: string }[]>;
onPortForwardStatus?(tunnelId: string, cb: PortForwardStatusCallback): () => void;
// Known Hosts
readKnownHosts?(): Promise<string | null>;
// Open URL in default browser
openExternal?(url: string): Promise<void>;
// App info (name/version/platform) for About screens
getAppInfo?(): Promise<{ name: string; version: string; platform: string }>;
// Notify main process the renderer has mounted/painted (used to avoid initial blank screen).
rendererReady?(): void;
onLanguageChanged?(cb: (language: string) => void): () => void;
// Chain progress listener for jump host connections
// Callback receives: (currentHop: number, totalHops: number, hostLabel: string, status: string)
onChainProgress?(cb: (hop: number, total: number, label: string, status: string) => void): () => void;
// OAuth callback server for cloud sync
startOAuthCallback?(expectedState?: string): Promise<{ code: string; state?: string }>;
cancelOAuthCallback?(): Promise<void>;
// GitHub Device Flow (cloud sync)
githubStartDeviceFlow?(options?: { clientId?: string; scope?: string }): Promise<{
deviceCode: string;
userCode: string;
verificationUri: string;
expiresAt: number;
interval: number;
}>;
githubPollDeviceFlowToken?(options: { clientId?: string; deviceCode: string }): Promise<{
access_token?: string;
token_type?: string;
scope?: string;
error?: string;
error_description?: string;
}>;
// Google OAuth (cloud sync) - proxied via main process to avoid CORS
googleExchangeCodeForTokens?(options: {
clientId: string;
clientSecret?: string;
code: string;
codeVerifier: string;
redirectUri: string;
}): Promise<{
accessToken: string;
refreshToken?: string;
expiresAt?: number;
tokenType: string;
scope?: string;
}>;
googleRefreshAccessToken?(options: {
clientId: string;
clientSecret?: string;
refreshToken: string;
}): Promise<{
accessToken: string;
refreshToken: string;
expiresAt?: number;
tokenType: string;
scope?: string;
}>;
googleGetUserInfo?(options: { accessToken: string }): Promise<{
id: string;
email: string;
name: string;
picture?: string;
}>;
// Google Drive API (cloud sync) - proxied via main process to avoid CORS/COEP issues
googleDriveFindSyncFile?(options: { accessToken: string; fileName?: string }): Promise<{ fileId: string | null }>;
googleDriveCreateSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string }>;
googleDriveUpdateSyncFile?(options: { accessToken: string; fileId: string; syncedFile: unknown }): Promise<{ ok: true }>;
googleDriveDownloadSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ syncedFile: unknown | null }>;
googleDriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
// OneDrive OAuth + Graph (cloud sync) - proxied via main process to avoid CORS
onedriveExchangeCodeForTokens?(options: {
clientId: string;
code: string;
codeVerifier: string;
redirectUri: string;
scope?: string;
}): Promise<{
accessToken: string;
refreshToken?: string;
expiresAt?: number;
tokenType: string;
scope?: string;
}>;
onedriveRefreshAccessToken?(options: {
clientId: string;
refreshToken: string;
scope?: string;
}): Promise<{
accessToken: string;
refreshToken: string;
expiresAt?: number;
tokenType: string;
scope?: string;
}>;
onedriveGetUserInfo?(options: { accessToken: string }): Promise<{
id: string;
email: string;
name: string;
avatarDataUrl?: string;
}>;
onedriveFindSyncFile?(options: { accessToken: string; fileName?: string }): Promise<{ fileId: string | null }>;
onedriveUploadSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string | null }>;
onedriveDownloadSyncFile?(options: { accessToken: string; fileId?: string; fileName?: string }): Promise<{ syncedFile: unknown | null }>;
onedriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
// File opener helpers (for "Open With" feature)
selectApplication?(): Promise<{ path: string; name: string } | null>;
openWithApplication?(filePath: string, appPath: string): Promise<boolean>;
downloadSftpToTemp?(sftpId: string, remotePath: string, fileName: string): Promise<string>;
// File watcher for auto-sync feature
startFileWatch?(localPath: string, remotePath: string, sftpId: string): Promise<{ watchId: string }>;
stopFileWatch?(watchId: string, cleanupTempFile?: boolean): Promise<{ success: boolean }>;
listFileWatches?(): Promise<Array<{ watchId: string; localPath: string; remotePath: string; sftpId: string }>>;
registerTempFile?(sftpId: string, localPath: string): Promise<{ success: boolean }>;
onFileWatchSynced?(cb: (payload: { watchId: string; localPath: string; remotePath: string; bytesWritten: number }) => void): () => void;
onFileWatchError?(cb: (payload: { watchId: string; localPath: string; remotePath: string; error: string }) => void): () => void;
// Temp file cleanup
deleteTempFile?(filePath: string): Promise<{ success: boolean }>;
// Temp directory management
getTempDirInfo?(): Promise<{ path: string; fileCount: number; totalSize: number }>;
clearTempDir?(): Promise<{ deletedCount: number; failedCount: number; error?: string }>;
getTempDirPath?(): Promise<string>;
openTempDir?(): Promise<{ success: boolean }>;
}
interface Window {
netcatty?: NetcattyBridge;
}
}

View File

@@ -216,6 +216,13 @@ const BASE_TERMINAL_FONTS: TerminalFont[] = [
description: 'Highly customizable monospace font',
category: 'monospace',
},
{
id: 'ioskeley-mono',
name: 'Ioskeley Mono',
family: '"Ioskeley Mono", monospace',
description: 'Iosevka variant mimicking Berkeley Mono style',
category: 'monospace',
},
{
id: 'mononoki',
name: 'Mononoki',

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);