mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-12 01:00:54 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c484cb876c | ||
|
|
51ac667a40 | ||
|
|
1d3745ed5f | ||
|
|
1d59be2576 | ||
|
|
ec04334a21 | ||
|
|
57e3641ec5 | ||
|
|
8258ad6e95 |
No files matched your search
@@ -2,8 +2,7 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm run lint:*)",
|
||||
"Bash(npm run build:*)"
|
||||
"Bash(npm run lint:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
@@ -85,7 +87,7 @@ jobs:
|
||||
name: release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
|
||||
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release) || (github.event_name == 'push' && github.ref == 'refs/heads/main')
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -101,9 +103,21 @@ jobs:
|
||||
- name: List artifacts
|
||||
run: ls -la artifacts/
|
||||
|
||||
- name: Compute release tag
|
||||
id: release_tag
|
||||
if: "!startsWith(github.ref, 'refs/tags/')"
|
||||
shell: bash
|
||||
run: |
|
||||
TS="$(date -u +'%Y%m%d-%H%M')"
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
echo "tag=dev-${TS}-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.release_tag.outputs.tag || github.ref_name }}
|
||||
name: ${{ steps.release_tag.outputs.tag || github.ref_name }}
|
||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
files: |
|
||||
artifacts/*.dmg
|
||||
artifacts/*.zip
|
||||
|
||||
@@ -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 queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
|
||||
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
|
||||
// Keyboard-interactive authentication state (2FA/MFA)
|
||||
const [keyboardInteractiveRequest, setKeyboardInteractiveRequest] = useState<KeyboardInteractiveRequest | null>(null);
|
||||
|
||||
const {
|
||||
theme,
|
||||
@@ -301,15 +301,13 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const unsubscribe = bridge.onKeyboardInteractive((request) => {
|
||||
console.log('[App] Keyboard-interactive request received:', request);
|
||||
// Add to queue instead of replacing - supports multiple concurrent sessions
|
||||
setKeyboardInteractiveQueue(prev => [...prev, {
|
||||
setKeyboardInteractiveRequest({
|
||||
requestId: request.requestId,
|
||||
name: request.name,
|
||||
instructions: request.instructions,
|
||||
prompts: request.prompts,
|
||||
hostname: request.hostname,
|
||||
savedPassword: request.savedPassword,
|
||||
}]);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -323,8 +321,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (bridge?.respondKeyboardInteractive) {
|
||||
void bridge.respondKeyboardInteractive(requestId, responses, false);
|
||||
}
|
||||
// Remove from queue by requestId
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
setKeyboardInteractiveRequest(null);
|
||||
}, []);
|
||||
|
||||
// Handle keyboard-interactive cancel
|
||||
@@ -333,8 +330,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (bridge?.respondKeyboardInteractive) {
|
||||
void bridge.respondKeyboardInteractive(requestId, [], true);
|
||||
}
|
||||
// Remove from queue by requestId
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
setKeyboardInteractiveRequest(null);
|
||||
}, []);
|
||||
|
||||
// Debounce ref for moveFocus to prevent double-triggering when focus switches
|
||||
@@ -665,7 +661,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;
|
||||
@@ -683,7 +679,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
connectToHost(host);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host, keys, identities });
|
||||
addConnectionLog({
|
||||
@@ -1036,18 +1032,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) - processes queue */}
|
||||
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) */}
|
||||
<KeyboardInteractiveModal
|
||||
request={keyboardInteractiveQueue[0] || null}
|
||||
request={keyboardInteractiveRequest}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -613,7 +613,6 @@ const en: Messages = {
|
||||
'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',
|
||||
@@ -634,9 +633,6 @@ 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',
|
||||
@@ -1146,10 +1142,6 @@ 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;
|
||||
@@ -264,7 +264,6 @@ const zhCN: Messages = {
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': '新建文件夹',
|
||||
'sftp.newFile': '新建文件',
|
||||
'sftp.filter': '筛选',
|
||||
'sftp.filter.placeholder': '按文件名筛选...',
|
||||
'sftp.columns.name': '名称',
|
||||
@@ -299,8 +298,6 @@ const zhCN: Messages = {
|
||||
'sftp.goHome': '返回主目录',
|
||||
'sftp.folderName': '文件夹名称',
|
||||
'sftp.folderName.placeholder': '输入文件夹名称',
|
||||
'sftp.fileName': '文件名称',
|
||||
'sftp.fileName.placeholder': '输入文件名称',
|
||||
'sftp.prompt.newFolderName': '新建文件夹名称?',
|
||||
'sftp.rename.title': '重命名',
|
||||
'sftp.rename.newName': '新名称',
|
||||
@@ -313,12 +310,6 @@ const zhCN: Messages = {
|
||||
'sftp.error.uploadFailed': '上传失败',
|
||||
'sftp.error.deleteFailed': '删除失败',
|
||||
'sftp.error.createFolderFailed': '创建文件夹失败',
|
||||
'sftp.error.createFileFailed': '创建文件失败',
|
||||
'sftp.error.invalidFileName': '文件名包含非法字符:{chars}',
|
||||
'sftp.error.reservedName': '此文件名是系统保留名称',
|
||||
'sftp.overwrite.title': '文件已存在',
|
||||
'sftp.overwrite.desc': '名为"{name}"的文件已存在。是否要替换它?',
|
||||
'sftp.overwrite.confirm': '替换',
|
||||
'sftp.error.renameFailed': '重命名失败',
|
||||
'sftp.picker.title': '选择主机',
|
||||
'sftp.picker.desc': '为{side}窗格选择主机',
|
||||
@@ -370,10 +361,6 @@ 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': '凭据',
|
||||
@@ -392,16 +379,12 @@ const zhCN: Messages = {
|
||||
'hostDetails.keys.empty': '暂无密钥',
|
||||
'hostDetails.certs.search': '搜索证书…',
|
||||
'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.agentForwarding': '代理转发',
|
||||
'hostDetails.jumpHosts': '跳板主机',
|
||||
'hostDetails.jumpHosts.hops': '{count} 跳',
|
||||
'hostDetails.jumpHosts.direct': '直连',
|
||||
'hostDetails.jumpHosts.configure': '配置代理主机',
|
||||
'hostDetails.proxy': '通过 HTTP/SOCKS5 代理',
|
||||
'hostDetails.jumpHosts.configure': '配置跳板主机',
|
||||
'hostDetails.proxy': '代理',
|
||||
'hostDetails.proxy.none': '无',
|
||||
'hostDetails.proxy.edit': '编辑代理',
|
||||
'hostDetails.proxy.configure': '配置代理',
|
||||
@@ -797,20 +780,11 @@ const zhCN: Messages = {
|
||||
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
|
||||
'sftp.autoSync.error': '同步文件失败:{error}',
|
||||
|
||||
// SFTP Folder Upload Progress
|
||||
'sftp.upload.progress': '正在上传 {current}/{total} 个文件...',
|
||||
'sftp.upload.currentFile': '当前: {fileName}',
|
||||
'sftp.upload.cancelled': '上传已取消',
|
||||
'sftp.upload.cancel': '取消',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': '正在重连...',
|
||||
'sftp.reconnecting.desc': '连接已断开,正在尝试重新连接',
|
||||
'sftp.reconnected': '连接已恢复',
|
||||
'sftp.error.reconnectFailed': '重连失败,请重试。',
|
||||
'sftp.error.connectionLostManual': '连接已断开,请手动重新连接。',
|
||||
'sftp.error.connectionLostReconnecting': '连接已断开,正在重连...',
|
||||
'sftp.error.sessionLost': 'SFTP 会话已断开,请重新连接。',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': '显示隐藏文件',
|
||||
@@ -1125,20 +1099,9 @@ const zhCN: Messages = {
|
||||
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
|
||||
'serial.connectAndSave': '连接并保存',
|
||||
'serial.edit.title': '串口设置',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': '需要验证',
|
||||
'keyboard.interactive.desc': '服务器需要额外的身份验证。',
|
||||
'keyboard.interactive.descWithHost': '服务器 {hostname} 需要额外的身份验证。',
|
||||
'keyboard.interactive.response': '响应',
|
||||
'keyboard.interactive.enterCode': '输入验证码',
|
||||
'keyboard.interactive.enterResponse': '输入响应',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.verifying': '验证中...',
|
||||
'keyboard.interactive.fill': '填入',
|
||||
'keyboard.interactive.fillSaved': '填入已保存的密码',
|
||||
'keyboard.interactive.useSaved': '使用已保存',
|
||||
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
|
||||
'hostDetails.section.sftp': 'SFTP 设置',
|
||||
'hostDetails.sftp.sudo': 'Sudo 提权模式',
|
||||
'hostDetails.sftp.sudo.desc': '使用保存的密码自动获取 Root 权限',
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
@@ -7,12 +7,6 @@ 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 {
|
||||
@@ -33,12 +27,6 @@ export const useApplicationBackend = () => {
|
||||
return info ?? null;
|
||||
}, []);
|
||||
|
||||
const checkSshAgent = useCallback(async (): Promise<SshAgentStatus | null> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const status = await bridge?.checkSshAgent?.();
|
||||
return status ?? null;
|
||||
}, []);
|
||||
|
||||
return { openExternal, getApplicationInfo, checkSshAgent };
|
||||
return { openExternal, getApplicationInfo };
|
||||
};
|
||||
|
||||
@@ -166,10 +166,6 @@ 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 {
|
||||
@@ -409,13 +405,8 @@ 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
|
||||
@@ -2907,7 +2898,6 @@ export const useSftpState = (
|
||||
|
||||
// Reset cancellation flag and set initial progress state
|
||||
cancelFolderUploadRef.current = false;
|
||||
currentFolderUploadTransferIdRef.current = "";
|
||||
if (totalFiles > 1) {
|
||||
setFolderUploadProgress({
|
||||
isUploading: true,
|
||||
@@ -2915,10 +2905,6 @@ export const useSftpState = (
|
||||
currentIndex: 0,
|
||||
totalFiles,
|
||||
cancelled: false,
|
||||
currentFileBytes: 0,
|
||||
currentFileTotalBytes: 0,
|
||||
currentFileSpeed: 0,
|
||||
currentTransferId: "",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2947,12 +2933,6 @@ 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,
|
||||
@@ -2960,10 +2940,6 @@ export const useSftpState = (
|
||||
currentIndex: fileIndex,
|
||||
totalFiles,
|
||||
cancelled: false,
|
||||
currentFileBytes: 0,
|
||||
currentFileTotalBytes: fileTotalBytes,
|
||||
currentFileSpeed: 0,
|
||||
currentTransferId: transferId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2989,54 +2965,16 @@ 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,
|
||||
transferId,
|
||||
onProgress,
|
||||
crypto.randomUUID(),
|
||||
undefined, // 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);
|
||||
@@ -3050,16 +2988,11 @@ 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);
|
||||
@@ -3072,8 +3005,6 @@ export const useSftpState = (
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Clear the current transfer ID
|
||||
currentFolderUploadTransferIdRef.current = "";
|
||||
// Always reset progress state when done
|
||||
setFolderUploadProgress({
|
||||
isUploading: false,
|
||||
@@ -3081,10 +3012,6 @@ export const useSftpState = (
|
||||
currentIndex: 0,
|
||||
totalFiles: 0,
|
||||
cancelled: cancelFolderUploadRef.current,
|
||||
currentFileBytes: 0,
|
||||
currentFileTotalBytes: 0,
|
||||
currentFileSpeed: 0,
|
||||
currentTransferId: "",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3097,30 +3024,8 @@ export const useSftpState = (
|
||||
);
|
||||
|
||||
// Cancel folder upload in progress
|
||||
const cancelFolderUpload = useCallback(async () => {
|
||||
// Set the flag to stop processing new files
|
||||
const cancelFolderUpload = useCallback(() => {
|
||||
cancelFolderUploadRef.current = true;
|
||||
|
||||
// Cancel the current file transfer if one is in progress
|
||||
const currentTransferId = currentFolderUploadTransferIdRef.current;
|
||||
if (currentTransferId) {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.cancelSftpUpload) {
|
||||
try {
|
||||
await bridge.cancelSftpUpload(currentTransferId);
|
||||
logger.info("[SFTP] Current file upload cancelled:", currentTransferId);
|
||||
} catch (err) {
|
||||
logger.warn("[SFTP] Failed to cancel current file upload:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update progress state to show cancelled
|
||||
setFolderUploadProgress(prev => ({
|
||||
...prev,
|
||||
cancelled: true,
|
||||
isUploading: false,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Select an application from system file picker
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
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";
|
||||
@@ -90,7 +81,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onCreateTag,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const [form, setForm] = useState<Host>(
|
||||
() =>
|
||||
initialData ||
|
||||
@@ -126,22 +116,6 @@ 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 || "");
|
||||
|
||||
@@ -507,12 +481,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
>
|
||||
<AsidePanelContent>
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<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>
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.address")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<DistroAvatar
|
||||
host={form as Host}
|
||||
@@ -533,12 +504,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<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>
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.general")}
|
||||
</p>
|
||||
<Input
|
||||
placeholder={t("hostDetails.label.placeholder")}
|
||||
value={form.label}
|
||||
@@ -589,12 +557,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<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>
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.portCredentials")}
|
||||
</p>
|
||||
<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>
|
||||
@@ -962,12 +927,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<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>
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.sftp")}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium">
|
||||
@@ -982,20 +944,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
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>
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.appearance")}
|
||||
</p>
|
||||
|
||||
{/* SSH Theme Selection */}
|
||||
<button
|
||||
@@ -1082,10 +1036,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<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>
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.mosh")}</p>
|
||||
<ToggleRow
|
||||
label="Mosh"
|
||||
enabled={!!form.moshEnabled}
|
||||
@@ -1095,10 +1046,6 @@ 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}
|
||||
@@ -1107,19 +1054,6 @@ 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) */}
|
||||
|
||||
@@ -266,11 +266,6 @@ const HostForm: React.FC<HostFormProps> = ({
|
||||
<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"
|
||||
|
||||
@@ -28,7 +28,6 @@ export interface KeyboardInteractiveRequest {
|
||||
instructions: string;
|
||||
prompts: KeyboardInteractivePrompt[];
|
||||
hostname?: string;
|
||||
savedPassword?: string | null;
|
||||
}
|
||||
|
||||
interface KeyboardInteractiveModalProps {
|
||||
@@ -138,7 +137,11 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
value={responses[index] || ""}
|
||||
onChange={(e) => handleResponseChange(index, e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder=""
|
||||
placeholder={
|
||||
isPassword
|
||||
? t("keyboard.interactive.enterCode")
|
||||
: t("keyboard.interactive.enterResponse")
|
||||
}
|
||||
className={isPassword ? "pr-10" : undefined}
|
||||
autoFocus={index === 0}
|
||||
disabled={isSubmitting}
|
||||
@@ -146,7 +149,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
{isPassword && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
onClick={() => toggleShowPassword(index)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
@@ -154,20 +157,6 @@ 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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2656,25 +2656,7 @@ 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}
|
||||
|
||||
@@ -128,8 +128,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onToggleBroadcast,
|
||||
onBroadcastInput,
|
||||
}) => {
|
||||
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
|
||||
const CONNECTION_TIMEOUT = 120000;
|
||||
const CONNECTION_TIMEOUT = 12000;
|
||||
const { t } = useI18n();
|
||||
const availableFonts = useAvailableFonts();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -84,6 +84,7 @@ export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pem,.key,.pub,.ppk,*"
|
||||
className="hidden"
|
||||
onChange={handleFileImport}
|
||||
/>
|
||||
|
||||
@@ -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,12 +348,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
};
|
||||
|
||||
let id: string;
|
||||
// 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 hasKeyMaterial = !!key?.privateKey;
|
||||
const hasPassword = !!effectivePassword;
|
||||
|
||||
|
||||
if (hasKeyMaterial) {
|
||||
try {
|
||||
id = await startAttempt({ key });
|
||||
|
||||
+5
-6
@@ -57,12 +57,11 @@ export const resolveHostAuth = (args: {
|
||||
host.username?.trim() ||
|
||||
"";
|
||||
|
||||
// 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 keyId =
|
||||
override?.keyId ||
|
||||
identity?.keyId ||
|
||||
host.identityFileId ||
|
||||
undefined;
|
||||
|
||||
const key = keyId ? keys.find((k) => k.id === keyId) : undefined;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ module.exports = {
|
||||
productName: 'Netcatty',
|
||||
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
|
||||
icon: 'public/icon.png',
|
||||
npmRebuild: false,
|
||||
directories: {
|
||||
buildResources: 'build',
|
||||
output: 'release'
|
||||
|
||||
@@ -90,21 +90,60 @@ async function startPortForward(event, payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
|
||||
// (Prompt text is admin-customizable and may not contain expected keywords)
|
||||
// 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
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('pf');
|
||||
|
||||
// Store finish callback with context about which responses are already filled
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`[PortForward] Received user responses, finishing keyboard-interactive`);
|
||||
finish(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);
|
||||
}, sender.id, tunnelId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
// Send only the prompts that need user input
|
||||
const promptsData = promptsNeedingUserInput.map((item) => ({
|
||||
prompt: item.prompt.prompt,
|
||||
echo: item.prompt.echo,
|
||||
}));
|
||||
|
||||
console.log(`[PortForward] Showing modal for ${promptsData.length} prompts`);
|
||||
console.log(`[PortForward] Showing modal for ${promptsData.length} prompts that need user input`);
|
||||
|
||||
safeSend(sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
@@ -113,11 +152,9 @@ 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}`);
|
||||
|
||||
|
||||
+69
-106
@@ -29,9 +29,6 @@ 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
|
||||
*/
|
||||
@@ -202,7 +199,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
*/
|
||||
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.");
|
||||
throw new Error("SFTPWrapper not available, cannot use sudo mode");
|
||||
}
|
||||
|
||||
// Known sftp-server paths to try
|
||||
@@ -216,7 +213,7 @@ async function connectSudoSftp(client, password) {
|
||||
];
|
||||
|
||||
console.log("[SFTP] Probing sftp-server path for sudo mode...");
|
||||
|
||||
|
||||
let serverPath = null;
|
||||
// Try to find the path
|
||||
for (const p of sftpPaths) {
|
||||
@@ -255,7 +252,7 @@ async function connectSudoSftp(client, password) {
|
||||
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
|
||||
@@ -280,7 +277,7 @@ async function connectSudoSftp(client, password) {
|
||||
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.");
|
||||
const error = new Error("SFTP sudo handshake timed out");
|
||||
reject(error);
|
||||
}, timeoutMs);
|
||||
|
||||
@@ -304,7 +301,7 @@ async function connectSudoSftp(client, password) {
|
||||
outgoing: stream.outgoing
|
||||
};
|
||||
sftp = new SFTPWrapper(client, chanInfo, {
|
||||
// debug: (str) => console.log(`[SFTP DEBUG] ${str}`)
|
||||
// debug: (str) => console.log(`[SFTP DEBUG] ${str}`)
|
||||
});
|
||||
|
||||
// Route any remaining channel data directly into the SFTP parser
|
||||
@@ -326,7 +323,7 @@ async function connectSudoSftp(client, password) {
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
try { sftp.push(null); } catch { }
|
||||
try { sftp.push(null); } catch {}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[SFTP] Initialization failed:", e.message);
|
||||
@@ -368,13 +365,13 @@ async function connectSudoSftp(client, password) {
|
||||
// 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();
|
||||
initSftp();
|
||||
}, 1000);
|
||||
} else if (stdoutBuffer.length > 256) {
|
||||
stdoutBuffer = stdoutBuffer.subarray(stdoutBuffer.length - 256);
|
||||
@@ -383,7 +380,7 @@ async function connectSudoSftp(client, password) {
|
||||
|
||||
const onStderr = (data) => {
|
||||
const chunk = data.toString();
|
||||
// Only log that we received stderr data, not the content (may contain sensitive prompts)
|
||||
console.log("[SFTP] stderr:", chunk);
|
||||
stderrBuffer += chunk;
|
||||
if (stderrBuffer.includes(prompt)) {
|
||||
console.log("[SFTP] Sudo requested password, sending...");
|
||||
@@ -407,13 +404,7 @@ async function connectSudoSftp(client, password) {
|
||||
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);
|
||||
const error = new Error(`SFTP server exited with code ${code}`);
|
||||
finalize(error);
|
||||
}
|
||||
});
|
||||
@@ -523,21 +514,60 @@ async function openSftp(event, options) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
|
||||
// (Prompt text is admin-customizable and may not contain expected keywords)
|
||||
// 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
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('sftp');
|
||||
|
||||
// Store finish callback with context about which responses are already filled
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`[SFTP] Received user responses, finishing keyboard-interactive`);
|
||||
finish(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);
|
||||
}, event.sender.id, connId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
// Send only the prompts that need user input
|
||||
const promptsData = promptsNeedingUserInput.map((item) => ({
|
||||
prompt: item.prompt.prompt,
|
||||
echo: item.prompt.echo,
|
||||
}));
|
||||
|
||||
console.log(`[SFTP] Showing modal for ${promptsData.length} prompts`);
|
||||
console.log(`[SFTP] Showing modal for ${promptsData.length} prompts that need user input`);
|
||||
|
||||
safeSend(event.sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
@@ -546,11 +576,9 @@ 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);
|
||||
|
||||
@@ -740,45 +768,25 @@ 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;
|
||||
|
||||
// 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 buffer = 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() {
|
||||
// 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;
|
||||
const chunkSize = 65536;
|
||||
if (transferredBytes < totalBytes) {
|
||||
const end = Math.min(transferredBytes + chunkSize, totalBytes);
|
||||
// Use subarray instead of slice to avoid copying
|
||||
const chunk = buffer.subarray(transferredBytes, end);
|
||||
const chunk = buffer.slice(transferredBytes, end);
|
||||
transferredBytes = end;
|
||||
|
||||
const now = Date.now();
|
||||
@@ -790,22 +798,13 @@ async function writeSftpBinaryWithProgress(event, payload) {
|
||||
lastTransferredBytes = transferredBytes;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
const contents = electronModule.webContents.fromId(event.sender.id);
|
||||
contents?.send("netcatty:upload:progress", {
|
||||
transferId,
|
||||
transferred: transferredBytes,
|
||||
totalBytes,
|
||||
speed,
|
||||
});
|
||||
|
||||
this.push(chunk);
|
||||
} else {
|
||||
@@ -814,9 +813,6 @@ async function writeSftpBinaryWithProgress(event, payload) {
|
||||
}
|
||||
});
|
||||
|
||||
// Register this upload for potential cancellation
|
||||
activeSftpUploads.set(transferId, { cancelled: false, stream: readableStream });
|
||||
|
||||
try {
|
||||
await client.put(readableStream, remotePath);
|
||||
|
||||
@@ -826,42 +822,11 @@ async function writeSftpBinaryWithProgress(event, payload) {
|
||||
return { success: true, transferId };
|
||||
} catch (err) {
|
||||
const contents = electronModule.webContents.fromId(event.sender.id);
|
||||
// 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);
|
||||
contents?.send("netcatty:upload:error", { transferId, error: err.message });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -971,7 +936,6 @@ 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);
|
||||
@@ -997,7 +961,6 @@ module.exports = {
|
||||
readSftpBinary,
|
||||
writeSftp,
|
||||
writeSftpBinaryWithProgress,
|
||||
cancelSftpUpload,
|
||||
closeSftp,
|
||||
mkdirSftp,
|
||||
deleteSftp,
|
||||
|
||||
+48
-145
@@ -6,119 +6,11 @@
|
||||
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) => {
|
||||
@@ -412,17 +304,6 @@ 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;
|
||||
@@ -641,21 +522,61 @@ async function startSSHSession(event, options) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
|
||||
// (Prompt text is admin-customizable and may not contain expected keywords)
|
||||
// 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
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
|
||||
|
||||
// Store finish callback with context about which responses are already filled
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
|
||||
finish(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);
|
||||
}, sender.id, sessionId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
// Send only the prompts that need user input
|
||||
const promptsData = promptsNeedingUserInput.map((item) => ({
|
||||
prompt: item.prompt.prompt,
|
||||
echo: item.prompt.echo,
|
||||
}));
|
||||
|
||||
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
|
||||
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts that need user input`);
|
||||
|
||||
safeSend(sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
@@ -664,11 +585,9 @@ 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
|
||||
@@ -950,20 +869,6 @@ 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);
|
||||
}
|
||||
@@ -976,6 +881,4 @@ module.exports = {
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
generateKeyPair,
|
||||
checkWindowsSshAgent,
|
||||
findDefaultPrivateKey,
|
||||
};
|
||||
@@ -276,12 +276,6 @@ 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 });
|
||||
},
|
||||
@@ -363,14 +357,6 @@ const api = {
|
||||
transferId
|
||||
});
|
||||
},
|
||||
// Cancel an in-progress SFTP upload
|
||||
cancelSftpUpload: async (transferId) => {
|
||||
// Cleanup listeners
|
||||
uploadProgressListeners.delete(transferId);
|
||||
uploadCompleteListeners.delete(transferId);
|
||||
uploadErrorListeners.delete(transferId);
|
||||
return ipcRenderer.invoke("netcatty:sftp:cancelUpload", { transferId });
|
||||
},
|
||||
// Local filesystem operations
|
||||
listLocalDir: async (path) => {
|
||||
return ipcRenderer.invoke("netcatty:local:list", { path });
|
||||
|
||||
Vendored
+433
-439
@@ -2,468 +2,462 @@ 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;
|
||||
}
|
||||
// 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
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
// 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 {
|
||||
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;
|
||||
// Use sudo for SFTP server
|
||||
sudo?: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
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';
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
}): 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 }>;
|
||||
execCommand(options: {
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username: string;
|
||||
port?: number;
|
||||
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;
|
||||
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;
|
||||
|
||||
// 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 }>;
|
||||
|
||||
// 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;
|
||||
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;
|
||||
picture?: 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>;
|
||||
|
||||
// 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 }>;
|
||||
// Cross-window settings sync
|
||||
notifySettingsChanged?(payload: { key: string; value: unknown }): void;
|
||||
onSettingsChanged?(cb: (payload: { key: string; value: unknown }) => void): () => 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 }>;
|
||||
// Cloud sync master password (stored in-memory + persisted via Electron safeStorage)
|
||||
cloudSyncSetSessionPassword?(password: string): Promise<boolean>;
|
||||
cloudSyncGetSessionPassword?(): Promise<string | null>;
|
||||
cloudSyncClearSessionPassword?(): Promise<boolean>;
|
||||
|
||||
// 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>;
|
||||
// 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 }>;
|
||||
|
||||
// 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;
|
||||
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 }>;
|
||||
|
||||
// Temp file cleanup
|
||||
deleteTempFile?(filePath: string): Promise<{ success: boolean }>;
|
||||
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>;
|
||||
|
||||
// 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 }>;
|
||||
}
|
||||
// App info (name/version/platform) for About screens
|
||||
getAppInfo?(): Promise<{ name: string; version: string; platform: string }>;
|
||||
|
||||
interface Window {
|
||||
netcatty?: NetcattyBridge;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -216,13 +216,6 @@ 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',
|
||||
|
||||
+10
-18
@@ -475,7 +475,6 @@ 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
|
||||
@@ -513,25 +512,18 @@ async function processEntry(
|
||||
const reader = dirEntry.createReader();
|
||||
const entries = await readDirectoryEntries(reader);
|
||||
|
||||
// Process entries in parallel batches for better performance
|
||||
// Use a concurrency limit to avoid overwhelming the browser
|
||||
const BATCH_SIZE = 50;
|
||||
// Helper to yield to main thread - prevents UI freezing during large folder parsing
|
||||
const yieldToMain = () => new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
|
||||
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));
|
||||
// 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();
|
||||
}
|
||||
const childEntry = entries[i];
|
||||
const childResults = await processEntry(childEntry, currentPath);
|
||||
results.push(...childResults);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to read directory: ${entry.name}`, error);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# Netcatty Feature TODO List
|
||||
|
||||
项目地址: https://github.com/binaricat/Netcatty
|
||||
|
||||
## 功能需求清单
|
||||
|
||||
### 1. GB18030编码支持 🔤
|
||||
**优先级**: 高
|
||||
|
||||
**需求描述**:
|
||||
- 支持操作文件名为GB18030编码的文件
|
||||
- 实现动态编码切换,无需断开重连即可生效
|
||||
- 解决目前市面上工具需要重新连接才能应用编码设置的问题
|
||||
|
||||
**技术要点**:
|
||||
- SFTP文件列表的编码转换
|
||||
- 文件名编码自动检测/手动切换
|
||||
- 保持连接状态下的编码切换
|
||||
|
||||
---
|
||||
|
||||
### 2. SFTP的sudo提权支持 🔐 (已完成)
|
||||
- [x] 实现sudo提权功能
|
||||
- [x] 研究HexHub的实现原理
|
||||
- [x] 实现密码的安全存储与自动填写
|
||||
- [x] 完成sudo命令的SFTP封装
|
||||
- [x] 优化权限提升的交互流程
|
||||
- [x] 验证功能稳定性
|
||||
|
||||
---
|
||||
|
||||
### 3. trzsz协议支持 📁
|
||||
**优先级**: 中
|
||||
|
||||
**需求描述**:
|
||||
- 集成trzsz文件传输协议
|
||||
- 参考项目: https://github.com/trzsz/trzsz
|
||||
- 解决electerm和tabby现有实现中的稳定性问题
|
||||
|
||||
**已知问题**:
|
||||
- electerm和tabby支持trzsz但偶尔无法正常收发文件
|
||||
- 具体bug现象待补充
|
||||
|
||||
**技术要点**:
|
||||
- trzsz协议完整实现
|
||||
- 文件传输的错误处理和重试机制
|
||||
- 传输进度显示
|
||||
- 大文件传输稳定性测试
|
||||
|
||||
---
|
||||
|
||||
### 4. 终端性能优化 ⚡
|
||||
**优先级**: 高
|
||||
|
||||
**需求描述**:
|
||||
- 解决基于xtermjs的终端在大量滚屏时的性能问题
|
||||
- 确保高速输出场景下键盘输入的实时响应
|
||||
|
||||
**核心问题**:
|
||||
- 大量刷屏时`Ctrl+C`信号发不出去
|
||||
- tmux切换窗口命令无响应
|
||||
- 输入延迟严重
|
||||
|
||||
**技术要点**:
|
||||
- 终端渲染性能优化
|
||||
- 输入处理与渲染分离
|
||||
- 虚拟滚动/缓冲区管理
|
||||
- 输入队列优先级处理
|
||||
- 压力测试场景设计
|
||||
|
||||
---
|
||||
|
||||
### 5. X11 Forwarding支持 🖥️
|
||||
**优先级**: 中
|
||||
|
||||
**需求描述**:
|
||||
- 支持X11图形界面转发
|
||||
- 能够在SSH连接中运行远程图形应用程序
|
||||
|
||||
**技术要点**:
|
||||
- X11转发的SSH配置
|
||||
- 本地X Server集成或推荐
|
||||
- 跨平台兼容性(Windows/macOS/Linux)
|
||||
- 连接配置UI
|
||||
|
||||
---
|
||||
|
||||
### 6. Terminal到SFTP目录定位 🎯
|
||||
**优先级**: 中
|
||||
|
||||
**需求描述**:
|
||||
- 在Terminal界面时,点击右上角按钮
|
||||
- 自动切换到SFTP视图并定位到当前工作目录
|
||||
- 实现Terminal和SFTP之间的上下文联动
|
||||
|
||||
**已知问题**:
|
||||
- 之前尝试实现但未成功
|
||||
|
||||
**技术要点**:
|
||||
- 获取当前shell的工作目录(`pwd`命令)
|
||||
- Terminal和SFTP视图的状态同步
|
||||
- 异步目录切换的UI反馈
|
||||
- 处理特殊路径(软链接、权限不足等)
|
||||
|
||||
**实现思路**:
|
||||
1. 通过发送`pwd`命令获取当前目录
|
||||
2. 解析命令输出结果
|
||||
3. 触发SFTP视图切换
|
||||
4. 异步加载目标目录内容
|
||||
|
||||
---
|
||||
|
||||
## 开发注意事项 ⚠️
|
||||
|
||||
### 质量要求
|
||||
- 充分的单元测试和集成测试
|
||||
- 避免"按下葫芦起了瓢"的问题
|
||||
- 每个功能都要有完整的测试用例
|
||||
|
||||
### 性能考虑
|
||||
- 避免频繁的AI token消耗
|
||||
- 代码review和人工测试相结合
|
||||
- 建立性能基准测试
|
||||
|
||||
### 用户体验
|
||||
- 这些都是"可以没有但有了方便很多"的功能
|
||||
- 注重细节和边界情况处理
|
||||
- 提供清晰的错误提示和操作引导
|
||||
|
||||
---
|
||||
|
||||
## 实现优先级建议
|
||||
|
||||
### Phase 1 - 核心功能完善
|
||||
- [ ] GB18030编码支持
|
||||
- [ ] 终端性能优化
|
||||
- [ ] Terminal到SFTP目录定位
|
||||
|
||||
### Phase 2 - 高级特性
|
||||
- [ ] SFTP的sudo提权支持
|
||||
- [ ] trzsz协议支持
|
||||
|
||||
### Phase 3 - 扩展功能
|
||||
- [ ] X11 Forwarding支持
|
||||
|
||||
---
|
||||
|
||||
## 参考资料
|
||||
- trzsz项目: https://github.com/trzsz/trzsz
|
||||
- 竞品分析: WinSCP, HexHub, electerm, tabby
|
||||
- 技术栈: xtermjs (需要性能优化方案)
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-01-09
|
||||
Reference in new issue
Block a user