Compare commits

...

51 Commits

Author SHA1 Message Date
bincxz
5918f91132 Improves 2FA and SSH authentication handling
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Enhances keyboard-interactive (2FA/MFA) authentication by implementing a queue-based system, allowing multiple concurrent requests to be processed sequentially.

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

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

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

Improves server-side error messages for SFTP Sudo failures, providing more specific diagnostic information for issues such as platform unavailability, handshake timeouts, and various exit codes (e.g., incorrect password, missing sftp-server, TTY requirement). This makes troubleshooting connection problems more effective.
2026-01-20 16:55:34 +08:00
TachibanaLolo
1324bf95cb Add SFTP sudo mode support and fix handshake 2026-01-20 15:27:44 +08:00
陈大猫
c668525d17 Merge pull request #95 from binaricat/copilot/fix-empty-lines-when-copying
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Fix extra blank lines when pasting text from other terminals
2026-01-20 13:56:45 +08:00
bincxz
a21970a278 Refactors terminal paste line ending normalization
Extracts the logic for normalizing line endings (CRLF to LF) during clipboard paste operations into a shared utility function.

This improves code reusability and consistency across different paste mechanisms, ensuring that pasted content always uses Unix-style line endings and prevents issues like extra blank lines in the terminal.
2026-01-20 13:56:05 +08:00
copilot-swe-agent[bot]
c07fd505d3 Fix: Normalize CRLF to LF when pasting text to prevent extra blank lines
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-20 04:21:58 +00:00
copilot-swe-agent[bot]
3bb47243ce Initial plan 2026-01-20 04:17:38 +00:00
陈大猫
d2483c5863 Merge pull request #93 from binaricat/copilot/add-new-file-button-sftp
Add "New File" button to SFTP views
2026-01-20 12:14:11 +08:00
bincxz
e2f7788c13 Adds SFTP filename validation and overwrite protection.
Improves file management by introducing client-side validation for new SFTP filenames. This prevents creation of files with invalid characters or system-reserved names, enhancing cross-platform compatibility and preventing potential errors.

Enhances user experience by adding an overwrite confirmation dialog when creating a file with an existing name. This prevents accidental data loss.

Increases robustness of SFTP operations by validating the presence of source and target SFTP sessions for remote transfers, preventing operations on disconnected endpoints.

Adds convenience features such as a smart default filename generator for new files and a context menu on the empty file list area for quick access to file creation and refresh actions.
2026-01-20 12:12:40 +08:00
copilot-swe-agent[bot]
2e417e1dd5 Fix potential null reference in createFile fallback logic
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-20 03:46:04 +00:00
copilot-swe-agent[bot]
b233e9609f Add new file creation feature to SFTP views
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-20 03:41:30 +00:00
copilot-swe-agent[bot]
f754378bea Initial plan 2026-01-20 03:30:53 +00:00
陈大猫
72e79bdc9a Merge pull request #91 from binaricat/copilot/fix-sftp-reconnect-issue
Fix SFTP retry button to trigger reconnection when connection is lost
2026-01-20 11:09:01 +08:00
bincxz
5d25bda560 Migrates SFTP error messages to i18n.
Centralizes SFTP-related error and status messages within the internationalization system.

Introduces new i18n keys for connection loss and session errors. Updates the SFTP state management to store these i18n keys instead of hardcoded strings, and ensures the SFTP view component translates them for display.

This improves localization, consistency, and maintainability of error messages.
2026-01-20 11:07:09 +08:00
bincxz
5baff1ee63 Refines connection error and transfer logic
Adds a specific error message when a connection is lost and no previous host information is available, guiding the user to manually reconnect.

Passes the `targetSide` argument to `processTransfer` calls, providing additional context for file transfer operations.
2026-01-20 11:02:42 +08:00
copilot-swe-agent[bot]
1d14f1b0ba Improve reconnection logic by explicitly checking for no connection
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-20 02:52:22 +00:00
copilot-swe-agent[bot]
3f2c3e15d6 Fix SFTP retry button to trigger reconnection when connection is lost
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-20 02:49:02 +00:00
copilot-swe-agent[bot]
395361b559 Initial plan 2026-01-20 02:42:41 +00:00
陈大猫
918d58862e Merge pull request #90 from binaricat/copilot/add-2fa-login-feature
Add keyboard-interactive (2FA/MFA) authentication support
2026-01-20 10:37:06 +08:00
bincxz
fea1ebf274 Fix: Auto-answer password prompts in keyboard-interactive authentication
When a server uses keyboard-interactive for both password and 2FA:
1. First round prompts for "Password:" - now auto-filled with configured password
2. Second round prompts for 2FA code - shows modal for user input

This fixes the issue where users had to manually enter password in the
2FA modal, leading to authentication failures.

Applied to:
- sshBridge.cjs
- sftpBridge.cjs
- portForwardingBridge.cjs
2026-01-20 10:31:57 +08:00
bincxz
a56ade35a3 Fix: Add tryKeyboard: true to enable keyboard-interactive authentication
The ssh2 library requires tryKeyboard: true in connection options to
trigger the 'keyboard-interactive' event. Without this setting, the
library will not attempt keyboard-interactive authentication even if
it's listed in authHandler.

Added to all connection configurations:
- sshBridge.cjs (main connection + jump hosts)
- sftpBridge.cjs (main connection + jump hosts)
- portForwardingBridge.cjs
2026-01-20 10:23:45 +08:00
bincxz
1b0cb918d8 Refactor: Extract shared proxyUtils and add TTL cleanup for keyboard-interactive requests
- Extract createProxySocket to shared proxyUtils.cjs module
- Add 5-minute TTL cleanup for abandoned keyboard-interactive requests
- Update sshBridge.cjs and sftpBridge.cjs to use shared module
2026-01-20 10:11:45 +08:00
copilot-swe-agent[bot]
869d30d4dd Add keyboard-interactive (2FA/MFA) authentication support for SSH, SFTP, and Port Forwarding
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-19 18:13:21 +00:00
copilot-swe-agent[bot]
87388b93d9 Initial plan 2026-01-19 17:57:59 +00:00
陈大猫
15a269e5d4 Merge pull request #89 from binaricat:copilot/fix-pane-drag-refresh-issue
Fix SFTP pane auto-refresh when dragging files from right to left
2026-01-20 01:54:48 +08:00
copilot-swe-agent[bot]
cf6b33a3eb Fix SFTP pane auto-refresh when dragging files from right to left
Pass targetSide parameter to processTransfer instead of using unreliable
object reference comparison (targetPane === leftPane) which fails due to
memoized computed values having different object references.

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-19 17:50:56 +00:00
copilot-swe-agent[bot]
dfa9b109c2 Initial plan 2026-01-19 17:44:31 +00:00
陈大猫
55b55d77c9 Merge pull request #88 from binaricat/copilot/support-folder-upload-sftp
Add folder upload support to SFTP drag-and-drop
2026-01-20 01:42:38 +08:00
bincxz
4dccc11041 Enhances SFTP UI responsiveness and folder upload UX
Implements periodic yielding to the main thread during large folder uploads and local folder parsing. This prevents the UI from freezing, improving responsiveness during these intensive operations.

Integrates folder upload progress directly into the transfer status panel, replacing the previous full-screen overlay. This provides a less intrusive and more consistent user experience for monitoring folder uploads.

Refines the drag-and-drop handler to correctly prioritize internal pane-to-pane transfers over external file/folder drops from the operating system.
2026-01-20 01:41:53 +08:00
bincxz
188e6c860a Adds SFTP folder upload progress and cancellation
Introduces a new feature to provide real-time feedback during SFTP folder uploads.

- Displays an overlay showing the current file being uploaded and overall progress (e.g., "Uploading X of Y files").
- Allows users to cancel an ongoing folder upload via a dedicated button in the progress overlay.
- Implements state management to track upload status and cancellation requests.
- Adds corresponding internationalization keys for the new UI messages.
2026-01-20 01:29:23 +08:00
copilot-swe-agent[bot]
f454c56192 Address code review feedback - fix directory handling and simplify condition
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-19 17:05:01 +00:00
copilot-swe-agent[bot]
4480e5dc8d Add folder upload support to SFTP via drag-and-drop
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-19 17:02:12 +00:00
copilot-swe-agent[bot]
8426da1596 Initial plan 2026-01-19 16:53:27 +00:00
陈大猫
c472eaada2 Merge pull request #84 from Nightsuki/feature/proxyjump-support
feat: add ProxyJump support for SSH config import
2026-01-20 00:51:50 +08:00
bincxz
71433252a1 Refines SSH config ProxyJump import and cycle detection
Uses a stable hostname and port key for ProxyJump mapping to ensure correct resolution after host deduplication.

Collects dynamically created "inline" jump hosts separately and adds them to the final import result. This prevents issues when modifying the host list during iteration.

Implements robust cycle detection for ProxyJump chains, identifying both direct self-references and indirect circular dependencies across multiple hosts. Chains involved in cycles are removed to prevent infinite loops.

Prevents duplicate host IDs within a single jump chain and avoids creating redundant inline jump hosts by reusing existing ones when possible.
2026-01-20 00:50:22 +08:00
bincxz
ca42787808 Adds SSH Agent Forwarding option
Introduces an option in host details to enable SSH agent forwarding. This allows remote servers to leverage local SSH keys for operations like Git, enhancing convenience for users.
2026-01-20 00:46:39 +08:00
Nightsuki
c13c330747 feat: add ProxyJump support for SSH config import
- Parse ProxyJump directive when importing SSH config files
- Support all standard formats: host, user@host, host:port, user@host:port, ssh://
- Support chained jumps (comma-separated): ProxyJump jump1,jump2,jump3
- Auto-resolve jump hosts to existing hosts or create inline hosts
- Map parsed ProxyJump to hostChain for connection tunneling

UI improvements:
- Rename 'Jump Hosts' to 'Proxy via Hosts' for clarity
- Rename 'Proxy' to 'Proxy via HTTP/SOCKS5' to distinguish from host proxying
- Always show Proxy via Hosts config (not hidden behind toggle)
- Multi-line display for proxy chains with numbered hosts
- Remove confusing Agent Forwarding toggle from UI
2026-01-19 17:13:02 +08:00
陈大猫
a27b99cbf7 Merge pull request #79 from Nightsuki/fix/ssh-config-import-file-filter
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: allow importing ssh_config files without extension
2026-01-18 18:26:52 +08:00
Nightsuki
3d6e981758 fix: allow importing ssh_config files without extension
The file picker filter for ssh_config import was set to only accept
.conf, .config, and .txt files. However, the standard SSH config file
at ~/.ssh/config has no file extension, making it impossible to select
in the import dialog.

Changed the accept attribute to '*' to allow selecting any file,
including extension-less files like 'config'.
2026-01-18 18:20:39 +08:00
bincxz
e6d8c1381c Updates README screenshots
Replaces multiple screenshots with a single, more representative image across all language versions of the README files.
2026-01-17 21:28:33 +08:00
bincxz
bc3d73c683 Refreshes READMEs with latest features and screenshots
Updates the project READMEs across all languages to reflect recent feature additions and improvements.

*   Adds a direct link to the official application website.
*   Refreshes the Vault section with new images showcasing grid view, nested folder organization, and list view.
*   Updates the Terminal section to highlight Broadcast Mode and performance monitoring, replacing older screenshots.
*   Enhances the SFTP section with an updated dual-pane view and a new screenshot for the transfer queue.
*   Introduces a screenshot for the new Key Generator feature in the Keychain section.
*   Adds a "Contributors" section to acknowledge community contributions.
2026-01-17 21:26:36 +08:00
bincxz
dd5f3ddffd Ignores Monaco editor public assets
Prevents ESLint from processing files within the `public/monaco` directory. This helps avoid linting third-party or generated code, reducing unnecessary warnings and improving linting performance.
2026-01-17 17:45:58 +08:00
陈大猫
3959328e24 Merge pull request #77 from binaricat/copilot/add-sftp-reconnect-feature
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Add SFTP reconnect UI overlay with spinner
2026-01-17 04:12:37 +08:00
bincxz
48928254fa Adds auto-reconnect for lost SFTP sessions
Detects common SFTP session errors and automatically attempts to re‑establish the connection (up to three tries).
Provides user feedback with a reconnect overlay, spinner integration, and success/error toast notifications.
Adds corresponding English and Chinese i18n messages for reconnect status and failure.
Minor build config comment added (no functional impact).
2026-01-17 04:08:06 +08:00
copilot-swe-agent[bot]
30962c992f Revert package-lock.json changes
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-16 11:32:35 +00:00
copilot-swe-agent[bot]
02e0fae051 Add reconnecting overlay UI with spinner for SFTP connections
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-16 11:30:01 +00:00
copilot-swe-agent[bot]
6a94716880 Initial plan 2026-01-16 11:24:06 +00:00
52 changed files with 3606 additions and 1412 deletions

63
App.tsx
View File

@@ -19,6 +19,7 @@ import { Input } from './components/ui/input';
import { Label } from './components/ui/label';
import { ToastProvider, toast } from './components/ui/toast';
import { VaultView, VaultSection } from './components/VaultView';
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
import { cn } from './lib/utils';
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
import { LogView as LogViewType } from './application/state/useSessionState';
@@ -150,6 +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[]>([]);
const {
theme,
@@ -291,6 +294,49 @@ function App({ settings }: { settings: SettingsState }) {
keys: keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
});
// Keyboard-interactive authentication (2FA/MFA) event listener
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onKeyboardInteractive) return;
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, {
requestId: request.requestId,
name: request.name,
instructions: request.instructions,
prompts: request.prompts,
hostname: request.hostname,
savedPassword: request.savedPassword,
}]);
});
return () => {
unsubscribe?.();
};
}, []);
// Handle keyboard-interactive submit
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[]) => {
const bridge = netcattyBridge.get();
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, responses, false);
}
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Handle keyboard-interactive cancel
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => {
const bridge = netcattyBridge.get();
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, [], true);
}
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Debounce ref for moveFocus to prevent double-triggering when focus switches
const lastMoveFocusTimeRef = useRef<number>(0);
const MOVE_FOCUS_DEBOUNCE_MS = 200;
@@ -619,7 +665,7 @@ function App({ settings }: { settings: SettingsState }) {
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host) => {
const { username, hostname: localHost } = systemInfoRef.current;
// Handle serial hosts separately
if (host.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
@@ -637,7 +683,7 @@ function App({ settings }: { settings: SettingsState }) {
connectToHost(host);
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
addConnectionLog({
@@ -989,6 +1035,19 @@ function App({ settings }: { settings: SettingsState }) {
/>
</Suspense>
)}
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) - processes queue */}
<KeyboardInteractiveModal
request={keyboardInteractiveQueue[0] || null}
onSubmit={handleKeyboardInteractiveSubmit}
onCancel={handleKeyboardInteractiveCancel}
/>
{/* Indicator when more 2FA requests are pending */}
{keyboardInteractiveQueue.length > 1 && (
<div className="fixed bottom-4 right-4 z-50 bg-muted/90 backdrop-blur-sm text-sm px-3 py-1.5 rounded-full border shadow-sm">
{keyboardInteractiveQueue.length - 1} more pending
</div>
)}
</div>
);
}

View File

@@ -5,7 +5,8 @@
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>モダンな SSH クライアント、SFTP ブラウザ & ターミナルマネージャー</strong>
<strong>モダンな SSH クライアント、SFTP ブラウザ & ターミナルマネージャー</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
@@ -39,7 +40,7 @@
---
[![Netcatty メインインターフェース](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
[![Netcatty メインインターフェース](screenshots/vault_grid_view.png)](screenshots/vault_grid_view.png)
---
@@ -138,15 +139,15 @@ Vault ビューはすべての SSH 接続を管理するコマンドセンター
**ダークモード**
![ダークモード](screenshots/main-window-dark.png)
![ホスト管理](screenshots/vault_grid_view.png)
**ライトモード**
**ネストされたフォルダと整理**
![ライトモード](screenshots/main-window-light.png)
![ネストされたフォルダ](screenshots/nested_folder_structure.png)
**リストビュー**
![リストビュー](screenshots/main-window-dark-list.png)
![リストビュー](screenshots/vault_list_view.png)
<a name="ターミナル"></a>
## ターミナル
@@ -155,18 +156,28 @@ WebGL アクセラレーション対応の xterm.js ベースのターミナル
**分割ウィンドウ**
![分割ウィンドウ](screenshots/split-window.png)
**ブロードキャストモード**
**テーマカスタマイズ**
一度入力すれば、どこでも実行できます。複数のサーバーを同時にメンテナンスするのに最適です。
![テーマカスタマイズ](screenshots/terminal-theme-change.png)
![ブロードキャストモード](screenshots/broadcast_mode.png)
**パフォーマンス情報とカスタマイズ**
接続の健全性を監視し、ターミナルのあらゆる側面をカスタマイズします。
![ターミナルパフォーマンス](screenshots/terminal_performance.png)
<a name="sftp"></a>
## SFTP
デュアルペイン SFTP ブラウザは、ローカルからリモート、リモートからリモートへのファイル転送をサポート。シングルクリックでディレクトリを移動、ペイン間でファイルをドラッグ&ドロップ、転送進捗をリアルタイムで監視。インターフェースにはファイル権限、サイズ、変更日時を表示。複数の転送をキューに入れ、詳細な速度と進捗インジケーターで完了を確認。コンテキストメニューから名前変更、削除、ダウンロード、アップロード操作にすばやくアクセス。
![SFTP ビュー](screenshots/sftp.png)
![SFTP デュアルペイン](screenshots/sftp_dual_pane.png)
**転送キュー**
![転送キュー](screenshots/sftp_transfer_queue.png)
<a name="キーチェーン"></a>
## キーチェーン
@@ -188,6 +199,10 @@ WebGL アクセラレーション対応の xterm.js ベースのターミナル
![キーマネージャー](screenshots/key-manager.png)
**キー生成**
![キー生成](screenshots/key_generator_ui.png)
<a name="ポートフォワーディング"></a>
## ポートフォワーディング
@@ -365,6 +380,17 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
---
<a name="コントリビューター"></a>
# コントリビューター
貢献してくれたすべての人々に感謝します!
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
</a>
---
<a name="ライセンス"></a>
# ライセンス

View File

@@ -5,7 +5,8 @@
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>Modern SSH Client, SFTP Browser & Terminal Manager</strong>
<strong>Modern SSH Client, SFTP Browser & Terminal Manager</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
@@ -39,7 +40,7 @@
---
[![Netcatty Main Interface](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
[![Netcatty Main Interface](screenshots/vault_grid_view.png)](screenshots/vault_grid_view.png)
---
@@ -138,15 +139,15 @@ The Vault view is your command center for managing all SSH connections. Create h
**Dark Mode**
![Dark Mode](screenshots/main-window-dark.png)
![Host Management](screenshots/vault_grid_view.png)
**Light Mode**
**Nested Folders & Organization**
![Light Mode](screenshots/main-window-light.png)
![Nested Folders](screenshots/nested_folder_structure.png)
**List View**
![List View](screenshots/main-window-dark-list.png)
![List View](screenshots/vault_list_view.png)
<a name="terminal"></a>
## Terminal
@@ -155,18 +156,28 @@ Powered by xterm.js with WebGL acceleration, the terminal delivers a smooth, res
**Split Windows**
![Split Windows](screenshots/split-window.png)
**Broadcast Mode**
**Theme Customization**
Type once, execute everywhere. Great for maintaining multiple servers simultaneously.
![Theme Customization](screenshots/terminal-theme-change.png)
![Broadcast Mode](screenshots/broadcast_mode.png)
**Performance Info & Customization**
Monitor your connection health and customize every aspect of your terminal.
![Terminal Performance](screenshots/terminal_performance.png)
<a name="sftp"></a>
## SFTP
The dual-pane SFTP browser supports local-to-remote and remote-to-remote file transfers. Navigate directories with single-click, drag files between panes, and monitor transfer progress in real-time. The interface shows file permissions, sizes, and modification dates. Queue multiple transfers and watch them complete with detailed speed and progress indicators. Context menus provide quick access to rename, delete, download, and upload operations.
![SFTP View](screenshots/sftp.png)
![SFTP Dual Pane](screenshots/sftp_dual_pane.png)
**Transfer Queue**
![Transfer Queue](screenshots/sftp_transfer_queue.png)
<a name="keychain"></a>
## Keychain
@@ -188,6 +199,10 @@ The Keychain is your secure vault for SSH credentials. Generate new keys, import
![Key Manager](screenshots/key-manager.png)
**Key Generator**
![Key Generator](screenshots/key_generator_ui.png)
<a name="port-forwarding"></a>
## Port Forwarding
@@ -365,6 +380,17 @@ See [agents.md](agents.md) for architecture overview and coding conventions.
---
<a name="contributors"></a>
# Contributors
Thanks to all the people who contribute!
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
</a>
---
<a name="license"></a>
# License

View File

@@ -5,7 +5,8 @@
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>现代化 SSH 客户端、SFTP 浏览器 & 终端管理器</strong>
<strong>现代化 SSH 客户端、SFTP 浏览器 & 终端管理器</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
@@ -39,7 +40,7 @@
---
[![Netcatty 主界面](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
[![Netcatty 主界面](screenshots/vault_grid_view.png)](screenshots/vault_grid_view.png)
---
@@ -138,15 +139,15 @@ Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建
**深色模式**
![深色模式](screenshots/main-window-dark.png)
![主机管理](screenshots/vault_grid_view.png)
**浅色模式**
**层级文件夹与分组**
![浅色模式](screenshots/main-window-light.png)
![层级文件夹](screenshots/nested_folder_structure.png)
**列表视图**
![列表视图](screenshots/main-window-dark-list.png)
![列表视图](screenshots/vault_list_view.png)
<a name="终端"></a>
## 终端
@@ -155,18 +156,28 @@ Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建
**分屏窗口**
![分屏窗口](screenshots/split-window.png)
**广播模式**
**主题定制**
一次输入,多处执行。非常适合同时维护这多台服务器。
![主题定制](screenshots/terminal-theme-change.png)
![广播模式](screenshots/broadcast_mode.png)
**性能信息与定制**
监控连接健康状况,并自定义终端的方方面面。
![终端性能](screenshots/terminal_performance.png)
<a name="sftp"></a>
## SFTP
双窗格 SFTP 浏览器支持本地到远程和远程到远程的文件传输。单击导航目录,在窗格之间拖放文件,实时监控传输进度。界面显示文件权限、大小和修改日期。批量传输队列管理,详细的速度和进度指示器。右键菜单快速访问重命名、删除、下载和上传操作。
![SFTP 视图](screenshots/sftp.png)
![SFTP 双窗格](screenshots/sftp_dual_pane.png)
**传输队列**
![传输队列](screenshots/sftp_transfer_queue.png)
<a name="密钥管理"></a>
## 密钥管理
@@ -188,6 +199,10 @@ Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建
![密钥管理器](screenshots/key-manager.png)
**密钥生成器**
![密钥生成器](screenshots/key_generator_ui.png)
<a name="端口转发"></a>
## 端口转发
@@ -365,6 +380,17 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
---
<a name="贡献者"></a>
# 贡献者
感谢所有参与贡献的人!
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
</a>
---
<a name="开源协议"></a>
# 开源协议

View File

@@ -399,6 +399,7 @@ const en: Messages = {
// SFTP
'sftp.newFolder': 'New Folder',
'sftp.newFile': 'New File',
'sftp.filter': 'Filter',
'sftp.filter.placeholder': 'Filter by filename...',
'sftp.columns.name': 'Name',
@@ -433,6 +434,8 @@ const en: Messages = {
'sftp.goHome': 'Go to home',
'sftp.folderName': 'Folder name',
'sftp.folderName.placeholder': 'Enter folder name',
'sftp.fileName': 'File name',
'sftp.fileName.placeholder': 'Enter file name',
'sftp.prompt.newFolderName': 'New folder name?',
'sftp.rename.title': 'Rename',
'sftp.rename.newName': 'New name',
@@ -445,6 +448,12 @@ const en: Messages = {
'sftp.error.uploadFailed': 'Upload failed',
'sftp.error.deleteFailed': 'Delete failed',
'sftp.error.createFolderFailed': 'Failed to create folder',
'sftp.error.createFileFailed': 'Failed to create file',
'sftp.error.invalidFileName': 'Filename contains invalid characters: {chars}',
'sftp.error.reservedName': 'This filename is reserved by the system',
'sftp.overwrite.title': 'File Already Exists',
'sftp.overwrite.desc': 'A file named "{name}" already exists. Do you want to replace it?',
'sftp.overwrite.confirm': 'Replace',
'sftp.error.renameFailed': 'Failed to rename',
'sftp.picker.title': 'Select Host',
'sftp.picker.desc': 'Pick a host for the {side} pane',
@@ -542,6 +551,21 @@ const en: Messages = {
'sftp.autoSync.success': 'File synced to remote: {fileName}',
'sftp.autoSync.error': 'Failed to sync file: {error}',
// SFTP Folder Upload Progress
'sftp.upload.progress': 'Uploading {current} of {total} files...',
'sftp.upload.currentFile': 'Current: {fileName}',
'sftp.upload.cancelled': 'Upload cancelled',
'sftp.upload.cancel': 'Cancel',
// SFTP Reconnecting
'sftp.reconnecting.title': 'Reconnecting...',
'sftp.reconnecting.desc': 'Connection lost, attempting to reconnect',
'sftp.reconnected': 'Connection restored',
'sftp.error.reconnectFailed': 'Failed to reconnect. Please try again.',
'sftp.error.connectionLostManual': 'Connection lost. Please reconnect manually.',
'sftp.error.connectionLostReconnecting': 'Connection lost. Reconnecting...',
'sftp.error.sessionLost': 'SFTP session lost. Please reconnect.',
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': 'Show hidden files',
'settings.sftp.showHiddenFiles.desc': 'Display files with the Windows hidden attribute in the SFTP file browser when browsing local Windows filesystem.',
@@ -586,6 +610,10 @@ const en: Messages = {
'hostDetails.section.address': 'Address',
'hostDetails.hostname.placeholder': 'IP or Hostname',
'hostDetails.section.general': 'General',
'hostDetails.section.sftp': 'SFTP Settings',
'hostDetails.sftp.sudo': 'Sudo Mode',
'hostDetails.sftp.sudo.desc': 'Automatically acquire Root privileges using stored password',
'hostDetails.sftp.sudo.passwordWarning': 'Sudo mode requires a password. Configure one above, or ensure the server allows passwordless sudo.',
'hostDetails.label.placeholder': 'Label (e.g., Production Server)',
'hostDetails.group.placeholder': 'Parent Group',
'hostDetails.section.credentials': 'Credentials',
@@ -604,16 +632,17 @@ const en: Messages = {
'hostDetails.keys.empty': 'No keys available',
'hostDetails.certs.search': 'Search certificates...',
'hostDetails.certs.empty': 'No certificates available',
'hostDetails.agentForwarding': 'Agent Forwarding',
'hostDetails.jumpHosts': 'Jump Hosts',
'hostDetails.agentForwarding': 'Forward SSH Agent',
'hostDetails.agentForwarding.desc': 'Allow remote server to use your local SSH keys (e.g., for git operations)',
'hostDetails.jumpHosts': 'Proxy via Hosts',
'hostDetails.jumpHosts.hops': '{count} hop(s)',
'hostDetails.jumpHosts.direct': 'Direct',
'hostDetails.jumpHosts.configure': 'Configure Jump Hosts',
'hostDetails.proxy': 'Proxy',
'hostDetails.jumpHosts.configure': 'Configure Proxy Hosts',
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5',
'hostDetails.proxy.none': 'None',
'hostDetails.proxy.edit': 'Edit Proxy',
'hostDetails.proxy.configure': 'Configure Proxy',
'hostDetails.proxyPanel.title': 'Proxy',
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5',
'hostDetails.proxyPanel.hostPlaceholder': 'Proxy host',
'hostDetails.proxyPanel.credentials': 'Credentials',
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
@@ -1104,6 +1133,20 @@ const en: Messages = {
'serial.field.configLabelPlaceholder': 'e.g. Arduino Uno',
'serial.connectAndSave': 'Connect & Save',
'serial.edit.title': 'Serial Port Settings',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': 'Authentication Required',
'keyboard.interactive.desc': 'The server requires additional authentication.',
'keyboard.interactive.descWithHost': 'The server {hostname} requires additional authentication.',
'keyboard.interactive.response': 'Response',
'keyboard.interactive.enterCode': 'Enter verification code',
'keyboard.interactive.enterResponse': 'Enter response',
'keyboard.interactive.submit': 'Submit',
'keyboard.interactive.verifying': 'Verifying...',
'keyboard.interactive.fill': 'Fill',
'keyboard.interactive.fillSaved': 'Fill with saved password',
'keyboard.interactive.useSaved': 'Use saved',
'keyboard.interactive.useSavedPassword': 'Use saved password',
};
export default en;

View File

@@ -264,6 +264,7 @@ const zhCN: Messages = {
// SFTP
'sftp.newFolder': '新建文件夹',
'sftp.newFile': '新建文件',
'sftp.filter': '筛选',
'sftp.filter.placeholder': '按文件名筛选...',
'sftp.columns.name': '名称',
@@ -298,6 +299,8 @@ const zhCN: Messages = {
'sftp.goHome': '返回主目录',
'sftp.folderName': '文件夹名称',
'sftp.folderName.placeholder': '输入文件夹名称',
'sftp.fileName': '文件名称',
'sftp.fileName.placeholder': '输入文件名称',
'sftp.prompt.newFolderName': '新建文件夹名称?',
'sftp.rename.title': '重命名',
'sftp.rename.newName': '新名称',
@@ -310,6 +313,12 @@ 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}窗格选择主机',
@@ -361,6 +370,10 @@ const zhCN: Messages = {
'hostDetails.section.address': '地址',
'hostDetails.hostname.placeholder': 'IP 或 主机名',
'hostDetails.section.general': '通用',
'hostDetails.section.sftp': 'SFTP 设置',
'hostDetails.sftp.sudo': 'Sudo 提权模式',
'hostDetails.sftp.sudo.desc': '使用保存的密码自动获取 Root 权限',
'hostDetails.sftp.sudo.passwordWarning': 'Sudo 模式需要密码。请在上方配置密码,或确保服务器允许免密 sudo。',
'hostDetails.label.placeholder': '名称例如Production Server',
'hostDetails.group.placeholder': '父级 Group',
'hostDetails.section.credentials': '凭据',
@@ -379,12 +392,13 @@ const zhCN: Messages = {
'hostDetails.keys.empty': '暂无密钥',
'hostDetails.certs.search': '搜索证书…',
'hostDetails.certs.empty': '暂无证书',
'hostDetails.agentForwarding': '代理转发',
'hostDetails.jumpHosts': '跳板主机',
'hostDetails.agentForwarding': '转发 SSH 密钥',
'hostDetails.agentForwarding.desc': '允许远程服务器使用本地 SSH 密钥(例如用于 git 操作)',
'hostDetails.jumpHosts': '通过主机代理',
'hostDetails.jumpHosts.hops': '{count} 跳',
'hostDetails.jumpHosts.direct': '直连',
'hostDetails.jumpHosts.configure': '配置跳板主机',
'hostDetails.proxy': '代理',
'hostDetails.jumpHosts.configure': '配置代理主机',
'hostDetails.proxy': '通过 HTTP/SOCKS5 代理',
'hostDetails.proxy.none': '无',
'hostDetails.proxy.edit': '编辑代理',
'hostDetails.proxy.configure': '配置代理',
@@ -780,6 +794,21 @@ 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': '显示隐藏文件',
'settings.sftp.showHiddenFiles.desc': '在浏览本地 Windows 文件系统时,显示具有 Windows 隐藏属性的文件。',
@@ -1093,6 +1122,20 @@ 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': '使用已保存的密码',
};
export default zhCN;

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,7 @@ import {
} from "./ui/aside-panel";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Switch } from "./ui/switch";
import { Card } from "./ui/card";
import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
import { Input } from "./ui/input";
@@ -92,7 +93,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
protocol: "ssh",
tags: [],
os: "linux",
agentForwarding: false,
authMethod: "password",
charset: "UTF-8",
theme: "Flexoki Dark",
@@ -926,6 +926,31 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<p className="text-xs font-semibold">
{t("hostDetails.section.sftp")}
</p>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="text-sm font-medium">
{t("hostDetails.sftp.sudo")}
</div>
<div className="text-xs text-muted-foreground">
{t("hostDetails.sftp.sudo.desc")}
</div>
</div>
<Switch
checked={form.sftpSudo || false}
onCheckedChange={(val) => update("sftpSudo", val)}
/>
</div>
{form.sftpSudo && !form.password && !selectedIdentity?.password && (
<p className="text-xs text-amber-500">
{t("hostDetails.sftp.sudo.passwordWarning")}
</p>
)}
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<p className="text-xs font-semibold">
{t("hostDetails.section.appearance")}
@@ -1024,75 +1049,92 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
/>
</Card>
{/* Agent Forwarding */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<ToggleRow
label={t("hostDetails.agentForwarding")}
enabled={!!form.agentForwarding}
onToggle={() => update("agentForwarding", !form.agentForwarding)}
/>
<p className="text-xs text-muted-foreground">
{t("hostDetails.agentForwarding.desc")}
</p>
</Card>
{/* Host Chain Configuration - Only show when Agent Forwarding is enabled */}
{form.agentForwarding && (
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link2 size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.jumpHosts")}
</p>
</div>
{chainedHosts.length > 0 ? (
<Badge variant="secondary" className="text-xs">
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
</Badge>
) : (
<Badge
variant="outline"
className="text-xs text-muted-foreground"
>
{t("hostDetails.jumpHosts.direct")}
</Badge>
)}
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link2 size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.jumpHosts")}
</p>
</div>
{chainedHosts.length > 0 && (
<button
className="w-full flex items-center gap-1 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
onClick={() => setActiveSubPanel("chain")}
{chainedHosts.length > 0 ? (
<Badge variant="secondary" className="text-xs">
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
</Badge>
) : (
<Badge
variant="outline"
className="text-xs text-muted-foreground"
>
<Link2
size={14}
className="text-muted-foreground flex-shrink-0"
/>
<span className="text-sm truncate">
{chainedHosts
.slice(0, 3)
.map((h) => h.hostname || h.label)
.join(" -> ")}
{chainedHosts.length > 3 && "..."}
</span>
{t("hostDetails.jumpHosts.direct")}
</Badge>
)}
</div>
{chainedHosts.length > 0 && (
<button
className="w-full flex flex-col items-start gap-1 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
onClick={() => setActiveSubPanel("chain")}
>
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-1 min-w-0 flex-1">
<Link2
size={14}
className="text-muted-foreground flex-shrink-0"
/>
<span className="text-xs text-muted-foreground">
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
</span>
</div>
<X
size={14}
className="text-muted-foreground hover:text-destructive flex-shrink-0 ml-auto"
className="text-muted-foreground hover:text-destructive flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
clearHostChain();
}}
/>
</button>
)}
{chainedHosts.length === 0 && (
<Button
variant="ghost"
className="w-full h-9 justify-start gap-2 text-sm"
onClick={() => setActiveSubPanel("chain")}
>
<Plus size={14} />
{t("hostDetails.jumpHosts.configure")}
</Button>
)}
</Card>
)}
</div>
<div className="w-full space-y-1 pl-5">
{chainedHosts.slice(0, 5).map((h, idx) => (
<div key={h.id} className="flex items-center gap-1 text-sm">
<span className="text-muted-foreground">{idx + 1}.</span>
<span className="truncate">
{h.label !== h.hostname ? `${h.hostname} (${h.label})` : h.hostname}
</span>
</div>
))}
{chainedHosts.length > 5 && (
<div className="text-xs text-muted-foreground">
+{chainedHosts.length - 5} more...
</div>
)}
</div>
</button>
)}
{chainedHosts.length === 0 && (
<Button
variant="ghost"
className="w-full h-9 justify-start gap-2 text-sm"
onClick={() => setActiveSubPanel("chain")}
>
<Plus size={14} />
{t("hostDetails.jumpHosts.configure")}
</Button>
)}
</Card>
{/* Proxy Configuration */}
<Card className="p-3 space-y-2 bg-card border-border/80">

View File

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

View File

@@ -0,0 +1,200 @@
/**
* Keyboard Interactive Authentication Modal
* Global modal for handling SSH keyboard-interactive authentication (2FA/MFA)
* This modal displays prompts from the SSH server and collects user responses.
*/
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
export interface KeyboardInteractivePrompt {
prompt: string;
echo: boolean;
}
export interface KeyboardInteractiveRequest {
requestId: string;
name: string;
instructions: string;
prompts: KeyboardInteractivePrompt[];
hostname?: string;
savedPassword?: string | null;
}
interface KeyboardInteractiveModalProps {
request: KeyboardInteractiveRequest | null;
onSubmit: (requestId: string, responses: string[]) => void;
onCancel: (requestId: string) => void;
}
export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> = ({
request,
onSubmit,
onCancel,
}) => {
const { t } = useI18n();
const [responses, setResponses] = useState<string[]>([]);
const [showPasswords, setShowPasswords] = useState<boolean[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset state when request changes
useEffect(() => {
if (request) {
setResponses(request.prompts.map(() => ""));
setShowPasswords(request.prompts.map(() => false));
setIsSubmitting(false);
}
}, [request]);
const handleResponseChange = useCallback((index: number, value: string) => {
setResponses((prev) => {
const updated = [...prev];
updated[index] = value;
return updated;
});
}, []);
const toggleShowPassword = useCallback((index: number) => {
setShowPasswords((prev) => {
const updated = [...prev];
updated[index] = !updated[index];
return updated;
});
}, []);
const handleSubmit = useCallback(() => {
if (!request || isSubmitting) return;
setIsSubmitting(true);
onSubmit(request.requestId, responses);
}, [request, responses, onSubmit, isSubmitting]);
const handleCancel = useCallback(() => {
if (!request) return;
onCancel(request.requestId);
}, [request, onCancel]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isSubmitting) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit, isSubmitting]
);
if (!request) return null;
const title = request.name?.trim() || t("keyboard.interactive.title");
const description =
request.instructions?.trim() ||
(request.hostname
? t("keyboard.interactive.descWithHost", { hostname: request.hostname })
: t("keyboard.interactive.desc"));
return (
<Dialog open={!!request} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="sm:max-w-[425px]" hideCloseButton>
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<KeyRound className="h-5 w-5 text-primary" />
</div>
<div>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="mt-1">
{description}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-4 py-2">
{request.prompts.map((prompt, index) => {
const isPassword = !prompt.echo;
const showPassword = showPasswords[index];
// Clean up prompt text (remove trailing colon and whitespace)
const promptLabel = prompt.prompt.replace(/:\s*$/, "").trim();
return (
<div key={index} className="space-y-2">
<Label htmlFor={`ki-prompt-${index}`}>
{promptLabel || t("keyboard.interactive.response")}
</Label>
<div className="relative">
<Input
id={`ki-prompt-${index}`}
type={isPassword && !showPassword ? "password" : "text"}
value={responses[index] || ""}
onChange={(e) => handleResponseChange(index, e.target.value)}
onKeyDown={handleKeyDown}
placeholder=""
className={isPassword ? "pr-10" : undefined}
autoFocus={index === 0}
disabled={isSubmitting}
/>
{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"
onClick={() => toggleShowPassword(index)}
disabled={isSubmitting}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</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>
);
})}
</div>
<div className="flex items-center justify-between pt-2">
<Button
variant="secondary"
onClick={handleCancel}
disabled={isSubmitting}
>
{t("common.cancel")}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("keyboard.interactive.verifying")}
</>
) : (
t("keyboard.interactive.submit")
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default KeyboardInteractiveModal;

View File

@@ -45,7 +45,7 @@ import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { useSettingsState } from "../application/state/useSettingsState";
import { logger } from "../lib/logger";
import { getFileExtension, isKnownBinaryFile, FileOpenerType, SystemAppInfo } from "../lib/sftpFileUtils";
import { getFileExtension, isKnownBinaryFile, FileOpenerType, SystemAppInfo, extractDropEntries } from "../lib/sftpFileUtils";
import { cn } from "../lib/utils";
import { Host, RemoteFile } from "../types";
import { filterHiddenFiles } from "./sftp";
@@ -254,6 +254,7 @@ interface SFTPModalProps {
keySource?: 'generated' | 'imported';
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sftpSudo?: boolean;
};
open: boolean;
onClose: () => void;
@@ -325,6 +326,12 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
const lastSelectedIndexRef = useRef<number | null>(null);
const localHomeRef = useRef<string | null>(null);
// Reconnect state
const [reconnecting, setReconnecting] = useState(false);
const reconnectingRef = useRef(false);
const reconnectAttemptsRef = useRef(0);
const MAX_RECONNECT_ATTEMPTS = 3;
// Rename dialog state
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [renameTarget, setRenameTarget] = useState<RemoteFile | null>(null);
@@ -515,6 +522,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
keySource: credentials.keySource,
proxy: credentials.proxy,
jumpHosts: credentials.jumpHosts,
sudo: credentials.sftpSudo,
});
sftpIdRef.current = sftpId;
return sftpId;
@@ -533,9 +541,44 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
credentials.keySource,
credentials.proxy,
credentials.jumpHosts,
credentials.sftpSudo,
openSftp,
]);
// Check if an error indicates a stale/lost SFTP session
const isSessionError = useCallback((err: unknown): boolean => {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("session not found") ||
msg.includes("sftp session") ||
msg.includes("not found") ||
msg.includes("closed") ||
msg.includes("connection reset") ||
msg.includes("eof")
);
}, []);
// Handle session error - triggers auto-reconnect
const handleSessionError = useCallback(() => {
if (reconnectingRef.current) return; // Prevent duplicate reconnect attempts
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
toast.error(t("sftp.error.reconnectFailed"), "SFTP");
setReconnecting(false);
reconnectingRef.current = false;
return;
}
// Clear stale session reference
sftpIdRef.current = null;
// Set reconnecting state
reconnectingRef.current = true;
reconnectAttemptsRef.current++;
setReconnecting(true);
}, [t]);
const loadFiles = useCallback(
async (path: string, options?: { force?: boolean }) => {
const requestId = ++loadSeqRef.current;
@@ -569,6 +612,14 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
setSelectedFiles(new Set());
} catch (e) {
if (loadSeqRef.current !== requestId) return;
// Check if this is a session error that can trigger auto-reconnect
if (!isLocalSession && isSessionError(e) && files.length > 0) {
logger.info("[SFTP] Session lost, attempting to reconnect...");
handleSessionError();
return;
}
logger.error("Failed to load files", e);
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
@@ -581,7 +632,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
}
}
},
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t],
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, isSessionError, handleSessionError, files.length],
);
useLayoutEffect(() => {
@@ -615,6 +666,72 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
};
}, [closeSftpSession]);
// Auto-reconnect effect
useEffect(() => {
if (!reconnecting || !reconnectingRef.current || isLocalSession) return;
const attemptReconnect = async () => {
// Small delay before reconnecting
await new Promise((resolve) => setTimeout(resolve, 1000));
if (!reconnectingRef.current) return; // May have been cancelled
try {
// Re-establish SFTP connection
const sftpId = await openSftp({
sessionId: `sftp-modal-${host.id}`,
hostname: credentials.hostname,
username: credentials.username || "root",
port: credentials.port || 22,
password: credentials.password,
privateKey: credentials.privateKey,
certificate: credentials.certificate,
passphrase: credentials.passphrase,
publicKey: credentials.publicKey,
keyId: credentials.keyId,
keySource: credentials.keySource,
proxy: credentials.proxy,
jumpHosts: credentials.jumpHosts,
sudo: credentials.sftpSudo,
});
sftpIdRef.current = sftpId;
// Refresh current directory
const list = await listSftp(sftpId, currentPath);
dirCacheRef.current.set(`${host.id}::${currentPath}`, {
files: list,
timestamp: Date.now(),
});
setFiles(list);
setSelectedFiles(new Set());
// Reconnect successful
reconnectingRef.current = false;
reconnectAttemptsRef.current = 0;
setReconnecting(false);
toast.success(t("sftp.reconnected"), "SFTP");
} catch (e) {
logger.error("[SFTP] Reconnect failed", e);
// Check if we can retry
if (reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
// Trigger another attempt
sftpIdRef.current = null;
reconnectingRef.current = false; // Reset to allow handleSessionError to work
handleSessionError();
} else {
// Max retries reached
reconnectingRef.current = false;
setReconnecting(false);
toast.error(t("sftp.error.reconnectFailed"), "SFTP");
}
} finally {
setLoading(false);
}
};
attemptReconnect();
}, [reconnecting, isLocalSession, host.id, credentials, openSftp, listSftp, currentPath, t, handleSessionError]);
useEffect(() => {
if (open) {
// Check if we need to reinitialize (either first time or initialPath changed)
@@ -758,8 +875,10 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
const handleUploadFile = async (
file: File,
taskId: string,
relativePath?: string,
): Promise<boolean> => {
const startTime = Date.now();
const displayName = relativePath || file.name;
// Update task to uploading with start time
setUploadTasks((prev) =>
@@ -778,7 +897,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
try {
const arrayBuffer = await file.arrayBuffer();
const fullPath = joinPath(currentPath, file.name);
const fullPath = joinPath(currentPath, displayName);
if (isLocalSession) {
await writeLocalFile(fullPath, arrayBuffer);
@@ -936,6 +1055,95 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
}, 3000);
};
// Upload files/folders from drag-and-drop (supports folders via DataTransfer API)
const handleUploadFromDrop = async (dataTransfer: DataTransfer) => {
// Extract all entries (files and folders) using webkitGetAsEntry
const entries = await extractDropEntries(dataTransfer);
if (entries.length === 0) return;
// Track created directories to avoid duplicates
const createdDirs = new Set<string>();
// Helper to ensure directory exists
const ensureDirectory = async (dirPath: string) => {
if (createdDirs.has(dirPath)) return;
try {
if (isLocalSession) {
await mkdirLocal(dirPath);
} else {
const sftpId = await ensureSftp();
await mkdirSftp(sftpId, dirPath);
}
createdDirs.add(dirPath);
} catch {
// Directory may already exist
createdDirs.add(dirPath);
}
};
// Sort entries: directories first, then by path depth
const sortedEntries = [...entries].sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
const aDepth = a.relativePath.split('/').length;
const bDepth = b.relativePath.split('/').length;
return aDepth - bDepth;
});
// Separate files and directories
const fileEntries = sortedEntries.filter(e => !e.isDirectory);
// Create tasks for files only (directories are created silently)
const newTasks: UploadTask[] = fileEntries.map((entry) => ({
id: crypto.randomUUID(),
fileName: entry.relativePath,
status: "pending" as const,
progress: 0,
totalBytes: entry.file.size,
transferredBytes: 0,
speed: 0,
startTime: 0,
}));
if (newTasks.length > 0) {
setUploadTasks((prev) => [...prev, ...newTasks]);
}
setUploading(true);
// Process all entries
let taskIndex = 0;
for (const entry of sortedEntries) {
const targetPath = joinPath(currentPath, entry.relativePath);
if (entry.isDirectory) {
// Create directory
await ensureDirectory(targetPath);
} else if (entry.file) {
// Ensure parent directories exist
const pathParts = entry.relativePath.split('/');
if (pathParts.length > 1) {
let parentPath = currentPath;
for (let i = 0; i < pathParts.length - 1; i++) {
parentPath = joinPath(parentPath, pathParts[i]);
await ensureDirectory(parentPath);
}
}
// Upload file
await handleUploadFile(entry.file, newTasks[taskIndex].id, entry.relativePath);
taskIndex++;
}
}
setUploading(false);
await loadFiles(currentPath, { force: true });
// Auto-clear completed tasks after 3 seconds
setTimeout(() => {
setUploadTasks((prev) => prev.filter((t) => t.status !== "completed"));
}, 3000);
};
const handleDelete = async (file: RemoteFile) => {
if (!confirm(t("sftp.confirm.deleteOne", { name: file.name }))) return;
try {
@@ -974,6 +1182,32 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
}
};
const handleCreateFile = async () => {
const fileName = prompt(t("sftp.fileName.placeholder"));
if (!fileName) return;
try {
const fullPath = joinPath(currentPath, fileName);
if (isLocalSession) {
// Write an empty file
await writeLocalFile(fullPath, new ArrayBuffer(0));
} else {
// Write empty content to create the file using binary write for consistency
try {
await writeSftpBinary(await ensureSftp(), fullPath, new ArrayBuffer(0));
} catch {
// Fallback to text write if binary write is not available
await writeSftp(await ensureSftp(), fullPath, "");
}
}
await loadFiles(currentPath, { force: true });
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.createFileFailed"),
"SFTP",
);
}
};
// Open rename dialog
const openRenameDialog = useCallback((file: RemoteFile) => {
setRenameTarget(file);
@@ -1279,8 +1513,9 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleUploadMultiple(e.dataTransfer.files);
// Use the new drop handler that supports folders
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
handleUploadFromDrop(e.dataTransfer);
}
};
@@ -1703,7 +1938,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
className="h-7 w-7"
onClick={() => loadFiles(currentPath, { force: true })}
>
<RefreshCw size={14} className={cn(loading && "animate-spin")} />
<RefreshCw size={14} className={cn((loading || reconnecting) && "animate-spin")} />
</Button>
{/* Editable Breadcrumbs */}
@@ -1794,6 +2029,14 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
>
<Plus size={14} className="mr-1.5" /> {t("sftp.newFolder")}
</Button>
<Button
variant="outline"
size="sm"
className="h-7"
onClick={handleCreateFile}
>
<Plus size={14} className="mr-1.5" /> {t("sftp.newFile")}
</Button>
<input
type="file"
className="hidden"
@@ -1897,6 +2140,19 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
</div>
)}
{/* Reconnecting overlay */}
{reconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="text-center">
<div className="text-sm font-medium">{t("sftp.reconnecting.title")}</div>
<div className="text-xs text-muted-foreground mt-1">{t("sftp.reconnecting.desc")}</div>
</div>
</div>
</div>
)}
{files.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Folder size={48} className="mb-3 opacity-50" />
@@ -2063,6 +2319,9 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
<ContextMenuItem onClick={handleCreateFolder}>
<Plus className="h-4 w-4 mr-2" /> {t("sftp.newFolder")}
</ContextMenuItem>
<ContextMenuItem onClick={handleCreateFile}>
<Plus className="h-4 w-4 mr-2" /> {t("sftp.newFile")}
</ContextMenuItem>
<ContextMenuItem onClick={() => inputRef.current?.click()}>
<Upload className="h-4 w-4 mr-2" /> {t("sftp.uploadFiles")}
</ContextMenuItem>

View File

@@ -80,6 +80,7 @@ import {
Download,
Edit2,
ExternalLink,
FilePlus,
Folder,
FolderPlus,
HardDrive,
@@ -179,6 +180,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
onClearSelection,
onSetFilter,
onCreateDirectory,
onCreateFile,
onDeleteFiles,
onRenameFile,
onCopyToOtherPane,
@@ -208,12 +210,18 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
const [hostSearch, setHostSearch] = useState("");
const [showNewFolderDialog, setShowNewFolderDialog] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [showNewFileDialog, setShowNewFileDialog] = useState(false);
const [newFileName, setNewFileName] = useState("");
const [fileNameError, setFileNameError] = useState<string | null>(null);
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false);
const [overwriteTarget, setOverwriteTarget] = useState<string | null>(null);
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [renameTarget, setRenameTarget] = useState<string | null>(null);
const [renameName, setRenameName] = useState("");
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteTargets, setDeleteTargets] = useState<string[]>([]);
const [isCreating, setIsCreating] = useState(false);
const [isCreatingFile, setIsCreatingFile] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
@@ -555,6 +563,53 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
}, 150);
};
// Filename validation - constants defined inline to satisfy eslint
const validateFileName = useCallback((name: string): string | null => {
const INVALID_FILENAME_CHARS = /[/\\:*?"<>|]/;
const RESERVED_NAMES = new Set([
'CON', 'PRN', 'AUX', 'NUL',
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
]);
const trimmed = name.trim();
if (!trimmed) return null;
// Check for invalid characters
const invalidMatch = trimmed.match(INVALID_FILENAME_CHARS);
if (invalidMatch) {
return t('sftp.error.invalidFileName', { chars: invalidMatch[0] });
}
// Check for reserved names (Windows)
const baseName = trimmed.split('.')[0].toUpperCase();
if (RESERVED_NAMES.has(baseName)) {
return t('sftp.error.reservedName');
}
return null;
}, [t]);
// Smart default filename generator
const getNextUntitledName = useCallback((existingFiles: string[]): string => {
const existingSet = new Set(existingFiles.map(f => f.toLowerCase()));
if (!existingSet.has('untitled.txt')) {
return 'untitled.txt';
}
let counter = 1;
while (counter < 1000) {
const name = `untitled (${counter}).txt`;
if (!existingSet.has(name.toLowerCase())) {
return name;
}
counter++;
}
return `untitled_${Date.now()}.txt`;
}, []);
// File operations
const handleCreateFolder = async () => {
if (!newFolderName.trim() || isCreating) return;
@@ -570,6 +625,48 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
}
};
const handleCreateFile = async (forceOverwrite = false) => {
const trimmedName = newFileName.trim();
if (!trimmedName || isCreatingFile) return;
// Validate filename
const error = validateFileName(trimmedName);
if (error) {
setFileNameError(error);
return;
}
// Check if file exists (unless we're forcing overwrite)
if (!forceOverwrite) {
const existingFile = pane.files.find(
f => f.name.toLowerCase() === trimmedName.toLowerCase() && f.type === 'file'
);
if (existingFile) {
setOverwriteTarget(trimmedName);
setShowOverwriteConfirm(true);
return;
}
}
setIsCreatingFile(true);
try {
await onCreateFile(trimmedName);
setShowNewFileDialog(false);
setShowOverwriteConfirm(false);
setOverwriteTarget(null);
setNewFileName("");
setFileNameError(null);
} catch {
/* Error handling */
} finally {
setIsCreatingFile(false);
}
};
const handleConfirmOverwrite = async () => {
await handleCreateFile(true);
};
const handleRename = async () => {
if (!renameTarget || !renameName.trim() || isRenaming) return;
setIsRenaming(true);
@@ -634,21 +731,21 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
setIsDragOverPane(false);
setDragOverEntry(null);
// Check if this is external file drop (from OS)
const droppedFiles = e.dataTransfer.files;
if (droppedFiles && droppedFiles.length > 0) {
// Handle external file upload using the callback
if (onUploadExternalFiles) {
await onUploadExternalFiles(droppedFiles);
// Check if this is an internal drag from another pane (draggedFiles is set by onDragStart)
if (draggedFiles && draggedFiles.length > 0) {
// Handle internal pane-to-pane transfer
if (draggedFiles[0]?.side !== side) {
onReceiveFromOtherPane(
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
);
}
return;
}
// Otherwise, handle internal drag from other pane
if (!draggedFiles || draggedFiles[0]?.side === side) return;
onReceiveFromOtherPane(
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
);
// Otherwise, this is an external file/folder drop (from OS)
if (e.dataTransfer.items.length > 0 && onUploadExternalFiles) {
await onUploadExternalFiles(e.dataTransfer);
}
};
const handleFileDragStart = useCallback(
@@ -918,6 +1015,9 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
<ContextMenuItem onClick={() => setShowNewFolderDialog(true)}>
<FolderPlus size={14} className="mr-2" /> {t("sftp.newFolder")}
</ContextMenuItem>
<ContextMenuItem onClick={() => setShowNewFileDialog(true)}>
<FilePlus size={14} className="mr-2" /> {t("sftp.newFile")}
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
@@ -944,6 +1044,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
pane.connection,
pane.selectedFiles,
setShowNewFolderDialog,
setShowNewFileDialog,
t,
],
);
@@ -1131,6 +1232,20 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
>
<FolderPlus size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => {
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
setNewFileName(defaultName);
setFileNameError(null);
setShowNewFileDialog(true);
}}
title={t("sftp.newFile")}
>
<FilePlus size={14} />
</Button>
<Button
variant={showFilterBar || pane.filter ? "secondary" : "ghost"}
size="icon"
@@ -1278,53 +1393,73 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
</div>
</div>
{/* File list */}
<div
ref={fileListRef}
className={cn(
"flex-1 min-h-0 overflow-y-auto relative",
isDragOverPane && "ring-2 ring-primary/30 ring-inset",
)}
onScroll={handleFileListScroll}
>
{pane.loading && sortedDisplayFiles.length === 0 ? (
<div className="flex items-center justify-center h-full">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
) : pane.error ? (
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
<AlertCircle size={24} />
<span className="text-sm">{pane.error}</span>
<Button variant="outline" size="sm" onClick={onRefresh}>
{t("sftp.retry")}
</Button>
</div>
) : sortedDisplayFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Folder size={32} className="mb-2 opacity-50" />
<span className="text-sm">{t("sftp.emptyDirectory")}</span>
</div>
) : (
{/* File list with empty area context menu */}
<ContextMenu>
<ContextMenuTrigger asChild>
<div
ref={fileListRef}
className={cn(
shouldVirtualize ? "relative" : "divide-y divide-border/30",
"flex-1 min-h-0 overflow-y-auto relative",
isDragOverPane && "ring-2 ring-primary/30 ring-inset",
)}
style={shouldVirtualize ? { height: totalHeight } : undefined}
onScroll={handleFileListScroll}
>
{fileRows}
</div>
)}
{pane.loading && sortedDisplayFiles.length === 0 ? (
<div className="flex items-center justify-center h-full">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
) : pane.error && !pane.reconnecting ? (
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
<AlertCircle size={24} />
<span className="text-sm">{t(pane.error)}</span>
<Button variant="outline" size="sm" onClick={onRefresh}>
{t("sftp.retry")}
</Button>
</div>
) : sortedDisplayFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Folder size={32} className="mb-2 opacity-50" />
<span className="text-sm">{t("sftp.emptyDirectory")}</span>
</div>
) : (
<div
className={cn(
shouldVirtualize ? "relative" : "divide-y divide-border/30",
)}
style={shouldVirtualize ? { height: totalHeight } : undefined}
>
{fileRows}
</div>
)}
{/* Drop overlay */}
{isDragOverPane && draggedFiles && draggedFiles[0]?.side !== side && (
<div className="absolute inset-0 flex items-center justify-center bg-primary/5 pointer-events-none">
<div className="flex flex-col items-center gap-2 text-primary">
<ArrowDown size={32} />
<span className="text-sm font-medium">{t("sftp.dropFilesHere")}</span>
</div>
{/* Drop overlay */}
{isDragOverPane && draggedFiles && draggedFiles[0]?.side !== side && (
<div className="absolute inset-0 flex items-center justify-center bg-primary/5 pointer-events-none">
<div className="flex flex-col items-center gap-2 text-primary">
<ArrowDown size={32} />
<span className="text-sm font-medium">{t("sftp.dropFilesHere")}</span>
</div>
</div>
)}
</div>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onRefresh}>
<RefreshCw size={14} className="mr-2" />{t("sftp.context.refresh")}
</ContextMenuItem>
<ContextMenuItem onClick={() => setShowNewFolderDialog(true)}>
<FolderPlus size={14} className="mr-2" />{t("sftp.newFolder")}
</ContextMenuItem>
<ContextMenuItem onClick={() => {
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
setNewFileName(defaultName);
setFileNameError(null);
setShowNewFileDialog(true);
}}>
<FilePlus size={14} className="mr-2" />{t("sftp.newFile")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{/* Footer */}
<div className="h-9 shrink-0 px-4 flex items-center justify-between text-[11px] text-muted-foreground border-t border-border/40 bg-secondary/30">
@@ -1341,12 +1476,25 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
</div>
{/* Loading overlay - covers entire pane when navigating directories */}
{pane.loading && sortedDisplayFiles.length > 0 && (
{pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] pointer-events-none z-10">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
)}
{/* Reconnecting overlay - shows when SFTP connection is lost and reconnecting */}
{pane.reconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
<Loader2 size={32} className="animate-spin text-primary" />
<div className="text-center">
<div className="text-sm font-medium">{t("sftp.reconnecting.title")}</div>
<div className="text-xs text-muted-foreground mt-1">{t("sftp.reconnecting.desc")}</div>
</div>
</div>
</div>
)}
{/* Dialogs */}
<Dialog open={showNewFolderDialog} onOpenChange={setShowNewFolderDialog}>
<DialogContent className="max-w-sm">
@@ -1385,6 +1533,88 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
</DialogContent>
</Dialog>
<Dialog open={showNewFileDialog} onOpenChange={(open) => {
setShowNewFileDialog(open);
if (!open) {
setFileNameError(null);
}
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("sftp.newFile")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("sftp.fileName")}</Label>
<Input
value={newFileName}
onChange={(e) => {
setNewFileName(e.target.value);
setFileNameError(null);
}}
placeholder={t("sftp.fileName.placeholder")}
onKeyDown={(e) => e.key === "Enter" && handleCreateFile()}
autoFocus
className={fileNameError ? "border-destructive" : ""}
/>
{fileNameError && (
<p className="text-xs text-destructive">{fileNameError}</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowNewFileDialog(false)}
>
{t("common.cancel")}
</Button>
<Button
onClick={() => handleCreateFile()}
disabled={!newFileName.trim() || isCreatingFile}
>
{isCreatingFile && (
<Loader2 size={14} className="mr-2 animate-spin" />
)}
{t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Overwrite Confirmation Dialog */}
<Dialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("sftp.overwrite.title")}</DialogTitle>
<DialogDescription>
{t("sftp.overwrite.desc", { name: overwriteTarget || "" })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowOverwriteConfirm(false);
setOverwriteTarget(null);
}}
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
onClick={handleConfirmOverwrite}
disabled={isCreatingFile}
>
{isCreatingFile && (
<Loader2 size={14} className="mr-2 animate-spin" />
)}
{t("sftp.overwrite.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
<DialogContent className="max-w-sm">
<DialogHeader>
@@ -1710,6 +1940,14 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
(name: string) => sftpRef.current.createDirectory("right", name),
[],
);
const handleCreateFileLeft = useCallback(
(name: string) => sftpRef.current.createFile("left", name),
[],
);
const handleCreateFileRight = useCallback(
(name: string) => sftpRef.current.createFile("right", name),
[],
);
const handleDeleteFilesLeft = useCallback(
(names: string[]) => sftpRef.current.deleteFiles("left", names),
[],
@@ -1914,24 +2152,32 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
[handleOpenFileWithForSide],
);
// Handle external file upload from OS drag-and-drop (shared logic)
// Handle external file/folder upload from OS drag-and-drop (shared logic)
// Uses sftpRef.current internally, so dependencies are stable.
// toast and logger are globally stable, t is the only real dependency.
const handleUploadExternalFilesForSide = useCallback(
async (side: "left" | "right", files: FileList) => {
async (side: "left" | "right", dataTransfer: DataTransfer) => {
try {
const results = await sftpRef.current.uploadExternalFiles(side, files);
const results = await sftpRef.current.uploadExternalFiles(side, dataTransfer);
// Check if upload was cancelled
if (sftpRef.current.folderUploadProgress.cancelled) {
toast.info(t('sftp.upload.cancelled'), "SFTP");
return;
}
const failCount = results.filter(r => !r.success).length;
// Count only files, not directories for success message
const successCount = results.filter(r => r.success).length;
if (failCount === 0) {
// All files uploaded successfully
const successCount = results.length;
// All items uploaded successfully
const message = successCount === 1
? `${t('sftp.upload')}: ${results[0].fileName}`
: `${t('sftp.uploadFiles')}: ${successCount}`;
toast.success(message, "SFTP");
} else {
// Some or all files failed
// Some or all items failed
const failedFiles = results.filter(r => !r.success);
failedFiles.forEach(failed => {
const errorMsg = failed.error ? ` - ${failed.error}` : '';
@@ -1954,12 +2200,12 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
);
const handleUploadExternalFilesLeft = useCallback(
(files: FileList) => handleUploadExternalFilesForSide("left", files),
(dataTransfer: DataTransfer) => handleUploadExternalFilesForSide("left", dataTransfer),
[handleUploadExternalFilesForSide],
);
const handleUploadExternalFilesRight = useCallback(
(files: FileList) => handleUploadExternalFilesForSide("right", files),
(dataTransfer: DataTransfer) => handleUploadExternalFilesForSide("right", dataTransfer),
[handleUploadExternalFilesForSide],
);
@@ -2077,6 +2323,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
onClearSelection: handleClearSelectionLeft,
onSetFilter: handleSetFilterLeft,
onCreateDirectory: handleCreateDirectoryLeft,
onCreateFile: handleCreateFileLeft,
onDeleteFiles: handleDeleteFilesLeft,
onRenameFile: handleRenameFileLeft,
onCopyToOtherPane: handleCopyToOtherPaneLeft,
@@ -2104,6 +2351,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
onClearSelection: handleClearSelectionRight,
onSetFilter: handleSetFilterRight,
onCreateDirectory: handleCreateDirectoryRight,
onCreateFile: handleCreateFileRight,
onDeleteFiles: handleDeleteFilesRight,
onRenameFile: handleRenameFileRight,
onCopyToOtherPane: handleCopyToOtherPaneRight,
@@ -2368,14 +2616,15 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
onSelectHost={handleHostSelectRight}
/>
{sftp.transfers.length > 0 && (
{/* Transfer status area - shows folder uploads and file transfers */}
{(sftp.transfers.length > 0 || sftp.folderUploadProgress.isUploading) && (
<div className="border-t border-border/70 bg-secondary/80 backdrop-blur-sm shrink-0">
<div className="flex items-center justify-between px-4 py-2 text-xs text-muted-foreground border-b border-border/40">
<span className="font-medium">
Transfers
{sftp.activeTransfersCount > 0 && (
{(sftp.activeTransfersCount > 0 || sftp.folderUploadProgress.isUploading) && (
<span className="ml-2 text-primary">
({sftp.activeTransfersCount} active)
({sftp.activeTransfersCount + (sftp.folderUploadProgress.isUploading ? 1 : 0)} active)
</span>
)}
</span>
@@ -2393,6 +2642,37 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
)}
</div>
<div className="max-h-40 overflow-auto">
{/* Folder upload progress - shown at top when active */}
{sftp.folderUploadProgress.isUploading && (
<div className="flex items-center gap-3 px-4 py-2 border-b border-border/30 bg-primary/5">
<div className="flex-shrink-0">
<Loader2 size={16} className="animate-spin text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between text-sm">
<span className="font-medium truncate">
{t("sftp.upload.progress", {
current: sftp.folderUploadProgress.currentIndex,
total: sftp.folderUploadProgress.totalFiles,
})}
</span>
</div>
{sftp.folderUploadProgress.currentFile && (
<div className="text-xs text-muted-foreground truncate mt-0.5">
{sftp.folderUploadProgress.currentFile}
</div>
)}
</div>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs flex-shrink-0"
onClick={() => sftp.cancelFolderUpload()}
>
{t("sftp.upload.cancel")}
</Button>
</div>
)}
{visibleTransfers.map((task) => (
<SftpTransferItem
key={task.id}

View File

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

View File

@@ -22,6 +22,7 @@ export interface SftpPaneCallbacks {
onClearSelection: () => void;
onSetFilter: (filter: string) => void;
onCreateDirectory: (name: string) => Promise<void>;
onCreateFile: (name: string) => Promise<void>;
onDeleteFiles: (fileNames: string[]) => Promise<void>;
onRenameFile: (oldName: string, newName: string) => Promise<void>;
onCopyToOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
@@ -32,8 +33,8 @@ export interface SftpPaneCallbacks {
onOpenFile?: (entry: SftpFileEntry) => void;
onOpenFileWith?: (entry: SftpFileEntry) => void; // Always show opener dialog
onDownloadFile?: (entry: SftpFileEntry) => void; // Download to local filesystem
// External file upload
onUploadExternalFiles?: (files: FileList) => Promise<void>;
// External file upload (supports folders via DataTransfer)
onUploadExternalFiles?: (dataTransfer: DataTransfer) => Promise<void>;
}
export interface SftpDragCallbacks {

View File

@@ -2,6 +2,7 @@ import type { Terminal as XTerm } from "@xterm/xterm";
import { useCallback } from "react";
import type { RefObject } from "react";
import { logger } from "../../../lib/logger";
import { normalizeLineEndings } from "../../../lib/utils";
type TerminalBackendWriteApi = {
writeToSession: (sessionId: string, data: string) => void;
@@ -32,7 +33,7 @@ export const useTerminalContextActions = ({
if (!term) return;
try {
const text = await navigator.clipboard.readText();
if (text && sessionRef.current) terminalBackend.writeToSession(sessionRef.current, text);
if (text && sessionRef.current) terminalBackend.writeToSession(sessionRef.current, normalizeLineEndings(text));
} catch (err) {
logger.warn("Failed to paste from clipboard", err);
}

View File

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

View File

@@ -17,6 +17,7 @@ import {
resolveXTermPerformanceConfig,
} from "../../../infrastructure/config/xtermPerformance";
import { logger } from "../../../lib/logger";
import { normalizeLineEndings } from "../../../lib/utils";
import type {
Host,
KeyBinding,
@@ -106,7 +107,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const platform = detectPlatform();
const deviceMemoryGb =
typeof navigator !== "undefined" &&
typeof (navigator as { deviceMemory?: number }).deviceMemory === "number"
typeof (navigator as { deviceMemory?: number }).deviceMemory === "number"
? (navigator as { deviceMemory?: number }).deviceMemory
: undefined;
@@ -358,7 +359,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
case "paste": {
navigator.clipboard.readText().then((text) => {
const id = ctx.sessionRef.current;
if (id) ctx.terminalBackend.writeToSession(id, text);
if (id) ctx.terminalBackend.writeToSession(id, normalizeLineEndings(text));
});
break;
}
@@ -390,7 +391,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
try {
const text = await navigator.clipboard.readText();
if (text && ctx.sessionRef.current) {
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, text);
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, normalizeLineEndings(text));
}
} catch (err) {
logger.warn("[Terminal] Failed to paste from clipboard:", err);

View File

@@ -47,7 +47,7 @@ const OPTIONS: ImportOption[] = [
format: "ssh_config",
label: "ssh_config",
iconSrc: "/import/file.png",
accept: ".conf,.config,.txt",
accept: "*",
},
];

View File

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

View File

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

View File

@@ -1,6 +1,82 @@
import { Host, HostProtocol } from "./models";
import { Host, HostChainConfig, HostProtocol } from "./models";
import { parseQuickConnectInput } from "./quickConnect";
interface ParsedJumpHost {
hostname: string;
username?: string;
port?: number;
}
const parseJumpHostSpec = (spec: string): ParsedJumpHost | null => {
const trimmed = spec.trim();
if (!trimmed || trimmed.toLowerCase() === "none") return null;
if (trimmed.startsWith("ssh://")) {
try {
const url = new URL(trimmed);
return {
hostname: url.hostname,
username: url.username || undefined,
port: url.port ? parseInt(url.port, 10) : undefined,
};
} catch {
return null;
}
}
let username: string | undefined;
let hostname: string;
let port: number | undefined;
let rest = trimmed;
const atIndex = rest.indexOf("@");
if (atIndex !== -1) {
username = rest.slice(0, atIndex);
rest = rest.slice(atIndex + 1);
}
if (rest.startsWith("[")) {
const bracketEnd = rest.indexOf("]");
if (bracketEnd !== -1) {
hostname = rest.slice(1, bracketEnd);
const portPart = rest.slice(bracketEnd + 1);
if (portPart.startsWith(":")) {
const p = parseInt(portPart.slice(1), 10);
if (Number.isFinite(p) && p >= 1 && p <= 65535) port = p;
}
} else {
hostname = rest;
}
} else {
const colonIndex = rest.lastIndexOf(":");
if (colonIndex !== -1) {
const portStr = rest.slice(colonIndex + 1);
const p = parseInt(portStr, 10);
if (Number.isFinite(p) && p >= 1 && p <= 65535) {
port = p;
hostname = rest.slice(0, colonIndex);
} else {
hostname = rest;
}
} else {
hostname = rest;
}
}
if (!hostname) return null;
return { hostname, username, port };
};
const parseProxyJump = (value: string): ParsedJumpHost[] => {
if (!value || value.toLowerCase() === "none") return [];
return value
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map(parseJumpHostSpec)
.filter((h): h is ParsedJumpHost => h !== null);
};
export type VaultImportFormat =
| "putty"
| "mobaxterm"
@@ -442,6 +518,7 @@ const importFromSshConfig = (text: string): VaultImportResult => {
hostname?: string;
username?: string;
port?: number;
proxyJump?: string;
};
const blocks: Block[] = [];
@@ -479,16 +556,23 @@ const importFromSshConfig = (text: string): VaultImportResult => {
if (keyword === "hostname") current.hostname = value;
else if (keyword === "user") current.username = value;
else if (keyword === "port") current.port = parsePort(value);
else if (keyword === "proxyjump") current.proxyJump = value;
}
flush();
const parsedHosts: Host[] = [];
// Use hostname+port as key instead of host.id to survive deduplication
const hostProxyJumpMap = new Map<string, string>();
let parsed = 0;
let skipped = 0;
const isWildcardPattern = (p: string) => /[*?]/.test(p) || p === "!" || p.startsWith("!");
// Helper to create a stable key for ProxyJump mapping
const makeHostKey = (hostname: string, port?: number) =>
`${hostname.toLowerCase()}:${port ?? 22}`;
for (const block of blocks) {
const patterns = block.patterns.filter((p) => p && !isWildcardPattern(p));
if (patterns.length === 0) continue;
@@ -505,24 +589,146 @@ const importFromSshConfig = (text: string): VaultImportResult => {
continue;
}
parsedHosts.push(
createHost({
label: pat,
hostname,
username: block.username,
port: block.port,
protocol: "ssh",
}),
);
const host = createHost({
label: pat,
hostname,
username: block.username,
port: block.port,
protocol: "ssh",
});
parsedHosts.push(host);
// Store ProxyJump using hostname key (survives deduplication)
if (block.proxyJump && block.proxyJump.toLowerCase() !== "none") {
const hostKey = makeHostKey(hostname, block.port);
hostProxyJumpMap.set(hostKey, block.proxyJump);
}
}
}
const { hosts: dedupedHosts, duplicates } = dedupeHosts(parsedHosts);
const hostnameToId = new Map<string, string>();
const labelToId = new Map<string, string>();
for (const host of dedupedHosts) {
hostnameToId.set(host.hostname.toLowerCase(), host.id);
labelToId.set(host.label.toLowerCase(), host.id);
}
const resolveJumpHostToId = (jumpHost: ParsedJumpHost): string | null => {
const hostnameKey = jumpHost.hostname.toLowerCase();
if (labelToId.has(hostnameKey)) return labelToId.get(hostnameKey)!;
if (hostnameToId.has(hostnameKey)) return hostnameToId.get(hostnameKey)!;
return null;
};
// Collect inline hosts separately to avoid modifying array during iteration
const inlineHosts: Host[] = [];
// Process ProxyJump for each host (iterate over a copy to avoid issues)
const hostsToProcess = [...dedupedHosts];
for (const host of hostsToProcess) {
const hostKey = makeHostKey(host.hostname, host.port);
const proxyJumpValue = hostProxyJumpMap.get(hostKey);
if (!proxyJumpValue) continue;
const jumpHosts = parseProxyJump(proxyJumpValue);
if (jumpHosts.length === 0) continue;
const resolvedIds: string[] = [];
const unresolvedJumps: string[] = [];
for (const jumpHost of jumpHosts) {
const existingId = resolveJumpHostToId(jumpHost);
if (existingId) {
// Avoid duplicate IDs in the chain
if (!resolvedIds.includes(existingId)) {
resolvedIds.push(existingId);
}
} else {
// Check if we already created an inline host for this
const inlineKey = jumpHost.hostname.toLowerCase();
let inlineId = hostnameToId.get(inlineKey);
if (!inlineId) {
const inlineHost = createHost({
label: jumpHost.hostname,
hostname: jumpHost.hostname,
username: jumpHost.username,
port: jumpHost.port,
protocol: "ssh",
});
inlineHosts.push(inlineHost);
hostnameToId.set(inlineHost.hostname.toLowerCase(), inlineHost.id);
labelToId.set(inlineHost.label.toLowerCase(), inlineHost.id);
inlineId = inlineHost.id;
unresolvedJumps.push(jumpHost.hostname);
}
if (!resolvedIds.includes(inlineId)) {
resolvedIds.push(inlineId);
}
}
}
if (resolvedIds.length > 0) {
// Cycle detection: check if this host appears in its own chain
if (resolvedIds.includes(host.id)) {
issues.push({
level: "warning",
message: `ssh_config: detected circular reference in ProxyJump for "${host.label}", skipping chain.`,
});
continue;
}
const hostChain: HostChainConfig = { hostIds: resolvedIds };
host.hostChain = hostChain;
}
if (unresolvedJumps.length > 0) {
issues.push({
level: "warning",
message: `ssh_config: created inline jump host(s) for "${host.label}": ${unresolvedJumps.join(", ")}`,
});
}
}
// Add inline hosts to the final result
const allHosts = [...dedupedHosts, ...inlineHosts];
// Deep cycle detection: check for indirect cycles (A -> B -> C -> A)
const detectCycle = (hostId: string, visited: Set<string>): boolean => {
if (visited.has(hostId)) return true;
visited.add(hostId);
const host = allHosts.find(h => h.id === hostId);
if (host?.hostChain?.hostIds) {
for (const chainId of host.hostChain.hostIds) {
if (detectCycle(chainId, visited)) return true;
}
}
visited.delete(hostId);
return false;
};
// Remove chains that form cycles
for (const host of allHosts) {
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
if (detectCycle(host.id, new Set())) {
issues.push({
level: "warning",
message: `ssh_config: detected circular dependency in jump chain for "${host.label}", removing chain.`,
});
delete host.hostChain;
}
}
}
const { hosts, duplicates } = dedupeHosts(parsedHosts);
return {
hosts,
hosts: allHosts,
groups: [],
issues,
stats: { parsed, imported: hosts.length, skipped, duplicates },
stats: { parsed, imported: allHosts.length, skipped, duplicates },
};
};

View File

@@ -1,3 +1,4 @@
/* global __dirname */
const path = require('path');
/**

View File

@@ -0,0 +1,104 @@
/**
* Keyboard Interactive Handler - Shared state for keyboard-interactive authentication
* This module provides a centralized storage for keyboard-interactive auth requests
* used by SSH, SFTP, and Port Forwarding bridges.
*/
// Keyboard-interactive authentication pending requests
// Map of requestId -> { finishCallback, webContentsId, sessionId, createdAt, timeoutId }
const keyboardInteractiveRequests = new Map();
// TTL for abandoned requests (5 minutes)
const REQUEST_TTL_MS = 5 * 60 * 1000;
/**
* Generate a unique request ID for keyboard-interactive requests
*/
function generateRequestId(prefix = 'ki') {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
/**
* Store a keyboard-interactive request with TTL cleanup
*/
function storeRequest(requestId, finishCallback, webContentsId, sessionId) {
// Set up TTL timeout to clean up abandoned requests
const timeoutId = setTimeout(() => {
const pending = keyboardInteractiveRequests.get(requestId);
if (pending) {
console.warn(`[KeyboardInteractive] Request ${requestId} timed out after ${REQUEST_TTL_MS / 1000}s, cleaning up`);
keyboardInteractiveRequests.delete(requestId);
// Call finish with empty responses to abort the authentication
try {
pending.finishCallback([]);
} catch (err) {
console.warn(`[KeyboardInteractive] Failed to call finishCallback for timed out request:`, err.message);
}
}
}, REQUEST_TTL_MS);
keyboardInteractiveRequests.set(requestId, {
finishCallback,
webContentsId,
sessionId,
createdAt: Date.now(),
timeoutId,
});
}
/**
* Handle keyboard-interactive authentication response from renderer
*/
function handleResponse(_event, payload) {
console.log(`[KeyboardInteractive] handleResponse called with payload:`, JSON.stringify(payload));
const { requestId, responses, cancelled } = payload;
const pending = keyboardInteractiveRequests.get(requestId);
console.log(`[KeyboardInteractive] Looking for request ${requestId}, found:`, !!pending);
console.log(`[KeyboardInteractive] Current pending requests:`, Array.from(keyboardInteractiveRequests.keys()));
if (!pending) {
console.warn(`[KeyboardInteractive] No pending request for ${requestId}`);
return { success: false, error: 'Request not found' };
}
// Clear the TTL timeout since we received a response
if (pending.timeoutId) {
clearTimeout(pending.timeoutId);
}
keyboardInteractiveRequests.delete(requestId);
if (cancelled) {
console.log(`[KeyboardInteractive] Auth cancelled for ${requestId}`);
pending.finishCallback([]); // Empty responses to cancel
} else {
console.log(`[KeyboardInteractive] Auth response received for ${requestId}, responses count:`, responses?.length);
pending.finishCallback(responses);
}
return { success: true };
}
/**
* Get the requests map (for debugging/testing)
*/
function getRequests() {
return keyboardInteractiveRequests;
}
/**
* Register IPC handler for keyboard-interactive responses
*/
function registerHandler(ipcMain) {
ipcMain.handle("netcatty:keyboard-interactive:respond", handleResponse);
}
module.exports = {
generateRequestId,
storeRequest,
handleResponse,
getRequests,
registerHandler,
};

View File

@@ -5,18 +5,31 @@
const net = require("node:net");
const { Client: SSHClient } = require("ssh2");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
// Active port forwarding tunnels
const portForwardingTunnels = new Map();
/**
* Send message to renderer safely
*/
function safeSend(sender, channel, payload) {
try {
if (!sender || sender.isDestroyed()) return;
sender.send(channel, payload);
} catch {
// Ignore destroyed webContents during shutdown.
}
}
/**
* Start a port forwarding tunnel
*/
async function startPortForward(event, payload) {
const {
tunnelId,
const {
tunnelId,
type, // 'local' | 'remote' | 'dynamic'
localPort,
localPort,
bindAddress = '127.0.0.1',
remoteHost,
remotePort,
@@ -26,34 +39,88 @@ async function startPortForward(event, payload) {
password,
privateKey,
} = payload;
return new Promise((resolve, reject) => {
const conn = new SSHClient();
const sender = event.sender;
const sendStatus = (status, error = null) => {
if (!sender.isDestroyed()) {
sender.send("netcatty:portforward:status", { tunnelId, status, error });
}
};
const connectOpts = {
host: hostname,
port: port,
username: username || 'root',
readyTimeout: 30000,
readyTimeout: 120000, // 2 minutes for 2FA input
keepaliveInterval: 10000,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
};
if (privateKey) {
connectOpts.privateKey = privateKey;
} else if (password) {
}
if (password) {
connectOpts.password = password;
}
// Build auth handler with keyboard-interactive support
const authMethods = [];
if (privateKey) authMethods.push("publickey");
if (password) authMethods.push("password");
authMethods.push("keyboard-interactive");
connectOpts.authHandler = authMethods;
// Handle keyboard-interactive authentication (2FA/MFA)
conn.on("keyboard-interactive", (name, instructions, instructionsLang, prompts, finish) => {
console.log(`[PortForward] ${hostname} keyboard-interactive auth requested`, {
name,
instructions,
promptCount: prompts?.length || 0,
prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })),
});
// If there are no prompts, just call finish with empty array
if (!prompts || prompts.length === 0) {
console.log(`[PortForward] No prompts, finishing keyboard-interactive`);
finish([]);
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)
const requestId = keyboardInteractiveHandler.generateRequestId('pf');
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
console.log(`[PortForward] Received user responses, finishing keyboard-interactive`);
finish(userResponses);
}, sender.id, tunnelId);
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
console.log(`[PortForward] Showing modal for ${promptsData.length} prompts`);
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
sessionId: tunnelId,
name: name || "",
instructions: instructions || "",
prompts: promptsData,
hostname: hostname,
savedPassword: password || null,
});
});
conn.on('ready', () => {
console.log(`[PortForward] SSH connection ready for tunnel ${tunnelId}`);
if (type === 'local') {
// LOCAL FORWARDING: Listen on local port, forward to remote
const server = net.createServer((socket) => {
@@ -69,13 +136,13 @@ async function startPortForward(event, payload) {
return;
}
socket.pipe(stream).pipe(socket);
socket.on('error', (e) => console.warn('[PortForward] Socket error:', e.message));
stream.on('error', (e) => console.warn('[PortForward] Stream error:', e.message));
}
);
});
server.on('error', (err) => {
console.error(`[PortForward] Server error:`, err.message);
sendStatus('error', err.message);
@@ -83,19 +150,19 @@ async function startPortForward(event, payload) {
portForwardingTunnels.delete(tunnelId);
reject(err);
});
server.listen(localPort, bindAddress, () => {
console.log(`[PortForward] Local forwarding active: ${bindAddress}:${localPort} -> ${remoteHost}:${remotePort}`);
portForwardingTunnels.set(tunnelId, {
type: 'local',
conn,
portForwardingTunnels.set(tunnelId, {
type: 'local',
conn,
server,
webContentsId: sender.id
webContentsId: sender.id
});
sendStatus('active');
resolve({ tunnelId, success: true });
});
} else if (type === 'remote') {
// REMOTE FORWARDING: Listen on remote port, forward to local
conn.forwardIn(bindAddress, localPort, (err) => {
@@ -106,24 +173,24 @@ async function startPortForward(event, payload) {
reject(err);
return;
}
console.log(`[PortForward] Remote forwarding active: remote ${bindAddress}:${localPort} -> local ${remoteHost}:${remotePort}`);
portForwardingTunnels.set(tunnelId, {
type: 'remote',
portForwardingTunnels.set(tunnelId, {
type: 'remote',
conn,
webContentsId: sender.id
webContentsId: sender.id
});
sendStatus('active');
resolve({ tunnelId, success: true });
});
// Handle incoming connections from remote
conn.on('tcp connection', (info, accept, rejectConn) => {
const stream = accept();
const socket = net.connect(remotePort, remoteHost || '127.0.0.1', () => {
stream.pipe(socket).pipe(stream);
});
socket.on('error', (e) => {
console.warn('[PortForward] Local socket error:', e.message);
stream.end();
@@ -133,7 +200,7 @@ async function startPortForward(event, payload) {
socket.end();
});
});
} else if (type === 'dynamic') {
// DYNAMIC FORWARDING (SOCKS5 Proxy)
const server = net.createServer((socket) => {
@@ -143,10 +210,10 @@ async function startPortForward(event, payload) {
socket.end();
return;
}
// Reply: version, no auth required
socket.write(Buffer.from([0x05, 0x00]));
// Wait for connection request
socket.once('data', (request) => {
if (request[0] !== 0x05 || request[1] !== 0x01) {
@@ -154,10 +221,10 @@ async function startPortForward(event, payload) {
socket.end();
return;
}
let targetHost, targetPort;
const addressType = request[3];
if (addressType === 0x01) {
// IPv4
targetHost = `${request[4]}.${request[5]}.${request[6]}.${request[7]}`;
@@ -177,7 +244,7 @@ async function startPortForward(event, payload) {
socket.end();
return;
}
// Forward through SSH tunnel
conn.forwardOut(
bindAddress,
@@ -190,7 +257,7 @@ async function startPortForward(event, payload) {
socket.end();
return;
}
// Success reply
const reply = Buffer.alloc(10);
reply[0] = 0x05;
@@ -199,9 +266,9 @@ async function startPortForward(event, payload) {
reply[3] = 0x01;
reply.writeUInt16BE(0, 8);
socket.write(reply);
socket.pipe(stream).pipe(socket);
socket.on('error', () => stream.end());
stream.on('error', () => socket.end());
}
@@ -209,7 +276,7 @@ async function startPortForward(event, payload) {
});
});
});
server.on('error', (err) => {
console.error(`[PortForward] SOCKS server error:`, err.message);
sendStatus('error', err.message);
@@ -217,14 +284,14 @@ async function startPortForward(event, payload) {
portForwardingTunnels.delete(tunnelId);
reject(err);
});
server.listen(localPort, bindAddress, () => {
console.log(`[PortForward] Dynamic SOCKS5 proxy active on ${bindAddress}:${localPort}`);
portForwardingTunnels.set(tunnelId, {
type: 'dynamic',
conn,
portForwardingTunnels.set(tunnelId, {
type: 'dynamic',
conn,
server,
webContentsId: sender.id
webContentsId: sender.id
});
sendStatus('active');
resolve({ tunnelId, success: true });
@@ -233,26 +300,26 @@ async function startPortForward(event, payload) {
reject(new Error(`Unknown forwarding type: ${type}`));
}
});
conn.on('error', (err) => {
console.error(`[PortForward] SSH error:`, err.message);
sendStatus('error', err.message);
portForwardingTunnels.delete(tunnelId);
reject(err);
});
conn.on('close', () => {
console.log(`[PortForward] SSH connection closed for tunnel ${tunnelId}`);
const tunnel = portForwardingTunnels.get(tunnelId);
if (tunnel) {
if (tunnel.server) {
try { tunnel.server.close(); } catch {}
try { tunnel.server.close(); } catch { }
}
sendStatus('inactive');
portForwardingTunnels.delete(tunnelId);
}
});
sendStatus('connecting');
conn.connect(connectOpts);
});
@@ -264,11 +331,11 @@ async function startPortForward(event, payload) {
async function stopPortForward(event, payload) {
const { tunnelId } = payload;
const tunnel = portForwardingTunnels.get(tunnelId);
if (!tunnel) {
return { tunnelId, success: false, error: 'Tunnel not found' };
}
try {
if (tunnel.server) {
tunnel.server.close();
@@ -277,7 +344,7 @@ async function stopPortForward(event, payload) {
tunnel.conn.end();
}
portForwardingTunnels.delete(tunnelId);
return { tunnelId, success: true };
} catch (err) {
return { tunnelId, success: false, error: err.message };
@@ -290,11 +357,11 @@ async function stopPortForward(event, payload) {
async function getPortForwardStatus(event, payload) {
const { tunnelId } = payload;
const tunnel = portForwardingTunnels.get(tunnelId);
if (!tunnel) {
return { tunnelId, status: 'inactive' };
}
return { tunnelId, status: 'active', type: tunnel.type };
}

View File

@@ -0,0 +1,135 @@
/**
* Proxy Utilities - Shared proxy socket creation for SSH connections
* Extracted from sshBridge.cjs and sftpBridge.cjs to eliminate code duplication
*/
const net = require("node:net");
/**
* Create a socket through a proxy (HTTP CONNECT or SOCKS5)
* @param {Object} proxy - Proxy configuration
* @param {string} proxy.type - 'http' or 'socks5'
* @param {string} proxy.host - Proxy host
* @param {number} proxy.port - Proxy port
* @param {string} [proxy.username] - Optional username for auth
* @param {string} [proxy.password] - Optional password for auth
* @param {string} targetHost - Target host to connect through proxy
* @param {number} targetPort - Target port to connect through proxy
* @returns {Promise<net.Socket>} Connected socket through proxy
*/
function createProxySocket(proxy, targetHost, targetPort) {
return new Promise((resolve, reject) => {
if (proxy.type === 'http') {
// HTTP CONNECT proxy
const socket = net.connect(proxy.port, proxy.host, () => {
let authHeader = '';
if (proxy.username && proxy.password) {
const auth = Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64');
authHeader = `Proxy-Authorization: Basic ${auth}\r\n`;
}
const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${authHeader}\r\n`;
socket.write(connectRequest);
let response = '';
const onData = (data) => {
response += data.toString();
if (response.includes('\r\n\r\n')) {
socket.removeListener('data', onData);
if (response.startsWith('HTTP/1.1 200') || response.startsWith('HTTP/1.0 200')) {
resolve(socket);
} else {
socket.destroy();
reject(new Error(`HTTP proxy error: ${response.split('\r\n')[0]}`));
}
}
};
socket.on('data', onData);
});
socket.on('error', reject);
} else if (proxy.type === 'socks5') {
// SOCKS5 proxy
const socket = net.connect(proxy.port, proxy.host, () => {
// SOCKS5 greeting
const authMethods = proxy.username && proxy.password ? [0x00, 0x02] : [0x00];
socket.write(Buffer.from([0x05, authMethods.length, ...authMethods]));
let step = 'greeting';
const onData = (data) => {
if (step === 'greeting') {
if (data[0] !== 0x05) {
socket.destroy();
reject(new Error('Invalid SOCKS5 response'));
return;
}
const method = data[1];
if (method === 0x02 && proxy.username && proxy.password) {
// Username/password auth
step = 'auth';
const userBuf = Buffer.from(proxy.username);
const passBuf = Buffer.from(proxy.password);
socket.write(Buffer.concat([
Buffer.from([0x01, userBuf.length]),
userBuf,
Buffer.from([passBuf.length]),
passBuf
]));
} else if (method === 0x00) {
// No auth, proceed to connect
step = 'connect';
sendConnectRequest();
} else {
socket.destroy();
reject(new Error('SOCKS5 authentication method not supported'));
}
} else if (step === 'auth') {
if (data[1] !== 0x00) {
socket.destroy();
reject(new Error('SOCKS5 authentication failed'));
return;
}
step = 'connect';
sendConnectRequest();
} else if (step === 'connect') {
socket.removeListener('data', onData);
if (data[1] === 0x00) {
resolve(socket);
} else {
const errors = {
0x01: 'General failure',
0x02: 'Connection not allowed',
0x03: 'Network unreachable',
0x04: 'Host unreachable',
0x05: 'Connection refused',
0x06: 'TTL expired',
0x07: 'Command not supported',
0x08: 'Address type not supported',
};
socket.destroy();
reject(new Error(`SOCKS5 error: ${errors[data[1]] || 'Unknown'}`));
}
}
};
const sendConnectRequest = () => {
// SOCKS5 connect request
const hostBuf = Buffer.from(targetHost);
const request = Buffer.concat([
Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]),
hostBuf,
Buffer.from([(targetPort >> 8) & 0xff, targetPort & 0xff])
]);
socket.write(request);
};
socket.on('data', onData);
});
socket.on('error', reject);
} else {
reject(new Error(`Unknown proxy type: ${proxy.type}`));
}
});
}
module.exports = {
createProxySocket,
};

View File

@@ -9,8 +9,18 @@ const os = require("node:os");
const net = require("node:net");
const SftpClient = require("ssh2-sftp-client");
const { Client: SSHClient } = require("ssh2");
let SFTPWrapper;
try {
// Try to load SFTPWrapper from ssh2 internals for sudo support
const sftpModule = require("ssh2/lib/protocol/SFTP");
SFTPWrapper = sftpModule.SFTP || sftpModule;
} catch (e) {
console.warn("[SFTP] Failed to load SFTPWrapper from ssh2, sudo mode will not work:", e.message);
}
const { NetcattyAgent } = require("./netcattyAgent.cjs");
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
// SFTP clients storage - shared reference passed from main
let sftpClients = null;
@@ -19,6 +29,18 @@ let electronModule = null;
// Storage for jump host connections that need to be cleaned up
const jumpConnectionsMap = new Map(); // connId -> { connections: SSHClient[], socket: stream }
/**
* Send message to renderer safely
*/
function safeSend(sender, channel, payload) {
try {
if (!sender || sender.isDestroyed()) return;
sender.send(channel, payload);
} catch {
// Ignore destroyed webContents during shutdown.
}
}
/**
* Initialize the SFTP bridge with dependencies
*/
@@ -27,130 +49,13 @@ function init(deps) {
electronModule = deps.electronModule;
}
/**
* Create a socket through a proxy (HTTP CONNECT or SOCKS5)
* Reused from sshBridge.cjs
*/
function createProxySocket(proxy, targetHost, targetPort) {
return new Promise((resolve, reject) => {
if (proxy.type === 'http') {
// HTTP CONNECT proxy
const socket = net.connect(proxy.port, proxy.host, () => {
let authHeader = '';
if (proxy.username && proxy.password) {
const auth = Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64');
authHeader = `Proxy-Authorization: Basic ${auth}\r\n`;
}
const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${authHeader}\r\n`;
socket.write(connectRequest);
let response = '';
const onData = (data) => {
response += data.toString();
if (response.includes('\r\n\r\n')) {
socket.removeListener('data', onData);
if (response.startsWith('HTTP/1.1 200') || response.startsWith('HTTP/1.0 200')) {
resolve(socket);
} else {
socket.destroy();
reject(new Error(`HTTP proxy error: ${response.split('\r\n')[0]}`));
}
}
};
socket.on('data', onData);
});
socket.on('error', reject);
} else if (proxy.type === 'socks5') {
// SOCKS5 proxy
const socket = net.connect(proxy.port, proxy.host, () => {
// SOCKS5 greeting
const authMethods = proxy.username && proxy.password ? [0x00, 0x02] : [0x00];
socket.write(Buffer.from([0x05, authMethods.length, ...authMethods]));
let step = 'greeting';
const onData = (data) => {
if (step === 'greeting') {
if (data[0] !== 0x05) {
socket.destroy();
reject(new Error('Invalid SOCKS5 response'));
return;
}
const method = data[1];
if (method === 0x02 && proxy.username && proxy.password) {
// Username/password auth
step = 'auth';
const userBuf = Buffer.from(proxy.username);
const passBuf = Buffer.from(proxy.password);
socket.write(Buffer.concat([
Buffer.from([0x01, userBuf.length]),
userBuf,
Buffer.from([passBuf.length]),
passBuf
]));
} else if (method === 0x00) {
// No auth, proceed to connect
step = 'connect';
sendConnectRequest();
} else {
socket.destroy();
reject(new Error('SOCKS5 authentication method not supported'));
}
} else if (step === 'auth') {
if (data[1] !== 0x00) {
socket.destroy();
reject(new Error('SOCKS5 authentication failed'));
return;
}
step = 'connect';
sendConnectRequest();
} else if (step === 'connect') {
socket.removeListener('data', onData);
if (data[1] === 0x00) {
resolve(socket);
} else {
const errors = {
0x01: 'General failure',
0x02: 'Connection not allowed',
0x03: 'Network unreachable',
0x04: 'Host unreachable',
0x05: 'Connection refused',
0x06: 'TTL expired',
0x07: 'Command not supported',
0x08: 'Address type not supported',
};
socket.destroy();
reject(new Error(`SOCKS5 error: ${errors[data[1]] || 'Unknown'}`));
}
}
};
const sendConnectRequest = () => {
// SOCKS5 connect request
const hostBuf = Buffer.from(targetHost);
const request = Buffer.concat([
Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]),
hostBuf,
Buffer.from([(targetPort >> 8) & 0xff, targetPort & 0xff])
]);
socket.write(request);
};
socket.on('data', onData);
});
socket.on('error', reject);
} else {
reject(new Error(`Unknown proxy type: ${proxy.type}`));
}
});
}
/**
* Connect through a chain of jump hosts for SFTP
*/
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort) {
const connections = [];
let currentSocket = null;
try {
// Connect through each jump host
for (let i = 0; i < jumpHosts.length; i++) {
@@ -158,14 +63,14 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
const isFirst = i === 0;
const isLast = i === jumpHosts.length - 1;
const hopLabel = jump.label || `${jump.hostname}:${jump.port || 22}`;
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Connecting to ${hopLabel}...`);
const conn = new SSHClient();
// Increase max listeners to prevent Node.js warning
// Set to 0 (unlimited) since complex operations add many temp listeners
conn.setMaxListeners(0);
// Build connection options
const connOpts = {
host: jump.hostname,
@@ -174,13 +79,15 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
readyTimeout: 20000,
keepaliveInterval: 10000,
keepaliveCountMax: 3,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: {
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
kex: ['curve25519-sha256', 'curve25519-sha256@libssh.org', 'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'diffie-hellman-group14-sha256'],
compress: ['none'],
},
};
// Auth - support agent (certificate), key, and password fallback
const hasCertificate =
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
@@ -210,7 +117,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
if (connOpts.password) order.push("password");
connOpts.authHandler = order;
}
// If first hop and proxy is configured, connect through proxy
if (isFirst && options.proxy) {
currentSocket = await createProxySocket(options.proxy, jump.hostname, jump.port || 22);
@@ -223,7 +130,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
delete connOpts.host;
delete connOpts.port;
}
// Connect this hop
await new Promise((resolve, reject) => {
conn.on('ready', () => {
@@ -240,9 +147,9 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
});
conn.connect(connOpts);
});
connections.push(conn);
// Determine next target
let nextHost, nextPort;
if (isLast) {
@@ -255,7 +162,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
nextHost = nextJump.hostname;
nextPort = nextJump.port || 22;
}
// Create forward stream to next hop
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Forwarding to ${nextHost}:${nextPort}...`);
currentSocket = await new Promise((resolve, reject) => {
@@ -270,10 +177,10 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
});
});
}
// Return the final forwarded stream and all connections for cleanup
return {
socket: currentSocket,
return {
socket: currentSocket,
connections
};
} catch (err) {
@@ -285,6 +192,232 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
}
}
/**
* Establish an SFTP connection using sudo
* @param {SSHClient} client - Connected SSH client
* @param {string} password - User password for sudo
*/
async function connectSudoSftp(client, password) {
if (!SFTPWrapper) {
throw new Error("SFTP sudo mode is not available on this platform. Please disable sudo mode in host settings.");
}
// Known sftp-server paths to try
const sftpPaths = [
"/usr/lib/openssh/sftp-server",
"/usr/libexec/openssh/sftp-server",
"/usr/lib/ssh/sftp-server",
"/usr/libexec/sftp-server",
"/usr/local/libexec/sftp-server",
"/usr/local/lib/sftp-server"
];
console.log("[SFTP] Probing sftp-server path for sudo mode...");
let serverPath = null;
// Try to find the path
for (const p of sftpPaths) {
try {
await new Promise((resolve, reject) => {
client.exec(`test -x ${p}`, (err, stream) => {
if (err) return reject(err);
stream.on('exit', (code) => {
if (code === 0) resolve();
else reject(new Error('Not found'));
});
});
});
serverPath = p;
break;
} catch (e) {
// Continue probing
}
}
if (!serverPath) {
// Fallback: try to find it in path or assume standard location
console.warn("[SFTP] Could not probe sftp-server, trying default /usr/lib/openssh/sftp-server");
serverPath = "/usr/lib/openssh/sftp-server";
} else {
console.log(`[SFTP] Found sftp-server at ${serverPath}`);
}
return new Promise((resolve, reject) => {
// Use sudo -S to read password from stdin
// Use -p '' to set a specific prompt we can detect
// Use sh -c 'printf SFTPREADY; exec ...' to synchronize the start of sftp-server
// We use printf instead of echo to avoid trailing newline which could confuse SFTPWrapper
const prompt = "SUDOPASSWORD:";
const readyMarker = "SFTPREADY";
const readyMarkerBuffer = Buffer.from(readyMarker);
// Add -e to sftp-server to log to stderr for debugging
const cmd = `sudo -S -p '${prompt}' sh -c 'printf ${readyMarker}; exec ${serverPath} -e'`;
console.log(`[SFTP] Executing sudo command: ${cmd}`);
// Disable pty to ensure clean binary stream for SFTP
client.exec(cmd, { pty: false }, (err, stream) => {
if (err) return reject(err);
// Add stream lifecycle logging
stream.on('close', () => console.log("[SFTP] Stream closed"));
stream.on('end', () => console.log("[SFTP] Stream ended"));
stream.on('error', (e) => console.error("[SFTP] Stream error:", e.message));
let sftpInitialized = false;
let sftp = null;
let settled = false;
let stdoutBuffer = Buffer.alloc(0);
let stderrBuffer = "";
let pendingAfterMarker = null;
let sftpCreated = false;
const timeoutMs = 20000;
const timeoutId = setTimeout(() => {
if (sftpInitialized || settled) return;
settled = true;
stream.stderr?.removeListener('data', onStderr);
stream.removeListener('data', onStdout);
const error = new Error("SFTP sudo handshake timed out. This may happen if: (1) the password is incorrect, (2) sudo requires a TTY, or (3) the user does not have sudo privileges.");
reject(error);
}, timeoutMs);
const finalize = (err, result) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
stream.stderr?.removeListener('data', onStderr);
stream.removeListener('data', onStdout);
if (err) reject(err);
else resolve(result);
};
const createSftp = () => {
if (sftpCreated) return;
sftpCreated = true;
try {
const chanInfo = {
type: 'sftp',
incoming: stream.incoming,
outgoing: stream.outgoing
};
sftp = new SFTPWrapper(client, chanInfo, {
// debug: (str) => console.log(`[SFTP DEBUG] ${str}`)
});
// Route any remaining channel data directly into the SFTP parser
if (client._chanMgr && typeof stream.incoming?.id === "number") {
client._chanMgr.update(stream.incoming.id, sftp);
}
sftp.on('ready', () => {
sftpInitialized = true;
console.log("[SFTP] Protocol ready");
finalize(null, sftp);
});
sftp.on('error', (err) => {
console.error("[SFTP] Protocol error:", err.message);
if (!sftpInitialized) {
finalize(err);
}
});
stream.on('end', () => {
try { sftp.push(null); } catch { }
});
} catch (e) {
console.error("[SFTP] Initialization failed:", e.message);
finalize(e);
}
};
const initSftp = () => {
if (sftpInitialized) return;
console.log("[SFTP] Sudo success, initializing SFTP protocol...");
if (!sftpCreated) createSftp();
try {
// Start the handshake
console.log("[SFTP] Sending INIT packet...");
sftp._init();
if (pendingAfterMarker && pendingAfterMarker.length > 0) {
try {
sftp.push(pendingAfterMarker);
} catch (pushErr) {
console.warn("[SFTP] Failed to push buffered data:", pushErr.message);
}
pendingAfterMarker = null;
}
} catch (e) {
console.error("[SFTP] Initialization failed:", e.message);
finalize(e);
}
};
const onStdout = (data) => {
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
stdoutBuffer = stdoutBuffer.length > 0 ? Buffer.concat([stdoutBuffer, chunk]) : chunk;
const markerIndex = stdoutBuffer.indexOf(readyMarkerBuffer);
if (markerIndex !== -1) {
const afterMarkerIndex = markerIndex + readyMarkerBuffer.length;
if (afterMarkerIndex < stdoutBuffer.length) {
pendingAfterMarker = stdoutBuffer.subarray(afterMarkerIndex);
}
// Found marker, stop listening to stdout here so SFTPWrapper can take over
stream.removeListener('data', onStdout);
stdoutBuffer = Buffer.alloc(0);
console.log("[SFTP] SFTPREADY detected, waiting for stream to stabilize...");
// Delay SFTP initialization to ensure sftp-server is fully started and stream is clean
// Increased timeout to 1000ms to be safe
setTimeout(() => {
initSftp();
}, 1000);
} else if (stdoutBuffer.length > 256) {
stdoutBuffer = stdoutBuffer.subarray(stdoutBuffer.length - 256);
}
};
const onStderr = (data) => {
const chunk = data.toString();
// Only log that we received stderr data, not the content (may contain sensitive prompts)
stderrBuffer += chunk;
if (stderrBuffer.includes(prompt)) {
console.log("[SFTP] Sudo requested password, sending...");
// Send password
if (password) {
stream.write(password + '\n');
} else {
console.warn('[SFTP] sudo requested password but none provided');
stream.write('\n');
}
stderrBuffer = "";
} else if (stderrBuffer.length > 256) {
stderrBuffer = stderrBuffer.slice(-256);
}
};
stream.on('data', onStdout);
stream.stderr.on('data', onStderr);
// Error handling
stream.on('exit', (code) => {
console.log(`[SFTP] Stream exited with code ${code}`);
if (!sftpInitialized && code !== 0) {
let errorMsg = `SFTP sudo failed with exit code ${code}.`;
if (code === 1) {
errorMsg += " The password may be incorrect or sudo privileges are denied.";
} else if (code === 127) {
errorMsg += " sftp-server was not found on the remote system.";
}
const error = new Error(errorMsg);
finalize(error);
}
});
});
});
}
/**
* Open a new SFTP connection
* Supports jump host connections when options.jumpHosts is provided
@@ -292,15 +425,15 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
async function openSftp(event, options) {
const client = new SftpClient();
const connId = options.sessionId || `${Date.now()}-sftp-${Math.random().toString(16).slice(2)}`;
// Check if we need to connect through jump hosts
const jumpHosts = options.jumpHosts || [];
const hasJumpHosts = jumpHosts.length > 0;
const hasProxy = !!options.proxy;
let chainConnections = [];
let connectionSocket = null;
// Handle chain/proxy connections
if (hasJumpHosts) {
console.log(`[SFTP] Opening connection through ${jumpHosts.length} jump host(s) to ${options.hostname}:${options.port || 22}`);
@@ -321,13 +454,16 @@ async function openSftp(event, options) {
options.port || 22
);
}
const connectOpts = {
host: options.hostname,
port: options.port || 22,
username: options.username || "root",
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
readyTimeout: 120000, // 2 minutes for 2FA input
};
// Use the tunneled socket if we have one
if (connectionSocket) {
connectOpts.sock = connectionSocket;
@@ -335,7 +471,7 @@ async function openSftp(event, options) {
delete connectOpts.host;
delete connectOpts.port;
}
const hasCertificate = typeof options.certificate === "string" && options.certificate.trim().length > 0;
let authAgent = null;
@@ -362,20 +498,125 @@ async function openSftp(event, options) {
const order = ["agent"];
if (connectOpts.password) order.push("password");
connectOpts.authHandler = order;
} else if (options.privateKey && connectOpts.password) {
// Prefer key auth when both key and password are present (password still needed for sudo)
connectOpts.authHandler = ["publickey", "password"];
}
// Add keyboard-interactive authentication support
// ssh2-sftp-client exposes the underlying ssh2 Client through its `on` method
const kiHandler = (name, instructions, instructionsLang, prompts, finish) => {
console.log(`[SFTP] ${options.hostname} keyboard-interactive auth requested`, {
name,
instructions,
promptCount: prompts?.length || 0,
prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })),
});
// If there are no prompts, just call finish with empty array
if (!prompts || prompts.length === 0) {
console.log(`[SFTP] No prompts, finishing keyboard-interactive`);
finish([]);
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)
const requestId = keyboardInteractiveHandler.generateRequestId('sftp');
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
console.log(`[SFTP] Received user responses, finishing keyboard-interactive`);
finish(userResponses);
}, event.sender.id, connId);
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
console.log(`[SFTP] Showing modal for ${promptsData.length} prompts`);
safeSend(event.sender, "netcatty:keyboard-interactive", {
requestId,
sessionId: connId,
name: name || "",
instructions: instructions || "",
prompts: promptsData,
hostname: options.hostname,
savedPassword: options.password || null,
});
};
// Add keyboard-interactive listener BEFORE connecting
client.on("keyboard-interactive", kiHandler);
// Enable keyboard-interactive authentication in authHandler
if (connectOpts.authHandler) {
// Add keyboard-interactive after the existing methods
if (!connectOpts.authHandler.includes("keyboard-interactive")) {
connectOpts.authHandler.push("keyboard-interactive");
}
} else {
// Create authHandler with keyboard-interactive support
const authMethods = [];
if (connectOpts.privateKey) authMethods.push("publickey");
if (connectOpts.password) authMethods.push("password");
authMethods.push("keyboard-interactive");
connectOpts.authHandler = authMethods;
}
// Increase timeout to allow for keyboard-interactive auth
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
try {
await client.connect(connectOpts);
if (options.sudo) {
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
const sshClient = client.client;
await new Promise((resolve, reject) => {
// Set up error handler for initial connection
const onConnectError = (err) => reject(err);
sshClient.once('error', onConnectError);
sshClient.once('ready', async () => {
sshClient.removeListener('error', onConnectError);
try {
// Use provided password or try empty if using key auth (and hope for nopasswd sudo)
const sudoPass = options.password || "";
const sftpWrapper = await connectSudoSftp(sshClient, sudoPass);
// Inject into sftp-client
client.sftp = sftpWrapper;
// Important: attach cleanup listener expected by sftp-client
client.sftp.on('close', () => client.end());
resolve();
} catch (e) {
sshClient.end();
reject(e);
}
});
try {
sshClient.connect(connectOpts);
} catch (e) {
reject(e);
}
});
} else {
await client.connect(connectOpts);
}
// Increase max listeners AFTER connect, when the internal ssh2 Client exists
// This prevents Node.js MaxListenersExceededWarning when performing many operations
// ssh2-sftp-client adds temporary listeners for each operation, so we need a high limit
if (client.client && typeof client.client.setMaxListeners === 'function') {
client.client.setMaxListeners(0); // 0 means unlimited
}
sftpClients.set(connId, client);
// Store jump connections for cleanup when SFTP is closed
if (chainConnections.length > 0) {
jumpConnectionsMap.set(connId, {
@@ -383,7 +624,7 @@ async function openSftp(event, options) {
socket: connectionSocket
});
}
console.log(`[SFTP] Connection established: ${connId}`);
return { sftpId: connId };
} catch (err) {
@@ -402,15 +643,15 @@ async function openSftp(event, options) {
async function listSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const list = await client.list(payload.path || ".");
const basePath = payload.path || ".";
// Process items and resolve symlinks
const results = await Promise.all(list.map(async (item) => {
let type;
let linkTarget = null;
if (item.type === "d") {
type = "directory";
} else if (item.type === "l") {
@@ -433,7 +674,7 @@ async function listSftp(event, payload) {
} else {
type = "file";
}
// Extract permissions from longname or rights
let permissions = undefined;
if (item.rights) {
@@ -446,7 +687,7 @@ async function listSftp(event, payload) {
permissions = match[1];
}
}
return {
name: item.name,
type,
@@ -456,7 +697,7 @@ async function listSftp(event, payload) {
permissions,
};
}));
return results;
}
@@ -489,7 +730,7 @@ async function readSftpBinary(event, payload) {
async function writeSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await client.put(Buffer.from(payload.content, "utf-8"), payload.path);
return true;
}
@@ -500,14 +741,14 @@ async function writeSftp(event, payload) {
async function writeSftpBinaryWithProgress(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const { sftpId, path: remotePath, content, transferId } = payload;
const buffer = Buffer.from(content);
const totalBytes = buffer.length;
let transferredBytes = 0;
let lastProgressTime = Date.now();
let lastTransferredBytes = 0;
const { Readable } = require("stream");
const readableStream = new Readable({
read() {
@@ -516,7 +757,7 @@ async function writeSftpBinaryWithProgress(event, payload) {
const end = Math.min(transferredBytes + chunkSize, totalBytes);
const chunk = buffer.slice(transferredBytes, end);
transferredBytes = end;
const now = Date.now();
const elapsed = (now - lastProgressTime) / 1000;
let speed = 0;
@@ -525,7 +766,7 @@ async function writeSftpBinaryWithProgress(event, payload) {
lastProgressTime = now;
lastTransferredBytes = transferredBytes;
}
const contents = electronModule.webContents.fromId(event.sender.id);
contents?.send("netcatty:upload:progress", {
transferId,
@@ -533,20 +774,20 @@ async function writeSftpBinaryWithProgress(event, payload) {
totalBytes,
speed,
});
this.push(chunk);
} else {
this.push(null);
}
}
});
try {
await client.put(readableStream, remotePath);
const contents = electronModule.webContents.fromId(event.sender.id);
contents?.send("netcatty:upload:complete", { transferId });
return { success: true, transferId };
} catch (err) {
const contents = electronModule.webContents.fromId(event.sender.id);
@@ -562,21 +803,21 @@ async function writeSftpBinaryWithProgress(event, payload) {
async function closeSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) return;
// Stop file watchers and clean up temp files for this SFTP session
try {
fileWatcherBridge.stopWatchersForSession(payload.sftpId, true);
} catch (err) {
console.warn("[SFTP] Error stopping file watchers:", err.message);
}
try {
await client.end();
} catch (err) {
console.warn("SFTP close failed", err);
}
sftpClients.delete(payload.sftpId);
// Clean up jump connections if any
const jumpData = jumpConnectionsMap.get(payload.sftpId);
if (jumpData) {
@@ -594,7 +835,7 @@ async function closeSftp(event, payload) {
async function mkdirSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await client.mkdir(payload.path, true);
return true;
}
@@ -605,7 +846,7 @@ async function mkdirSftp(event, payload) {
async function deleteSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const stat = await client.stat(payload.path);
if (stat.isDirectory) {
await client.rmdir(payload.path, true);
@@ -621,7 +862,7 @@ async function deleteSftp(event, payload) {
async function renameSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await client.rename(payload.oldPath, payload.newPath);
return true;
}
@@ -632,7 +873,7 @@ async function renameSftp(event, payload) {
async function statSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const stat = await client.stat(payload.path);
return {
name: path.basename(payload.path),
@@ -649,7 +890,7 @@ async function statSftp(event, payload) {
async function chmodSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await client.chmod(payload.path, parseInt(payload.mode, 8));
return true;
}

View File

@@ -8,6 +8,8 @@ const fs = require("node:fs");
const path = require("node:path");
const { Client: SSHClient, utils: sshUtils } = require("ssh2");
const { NetcattyAgent } = require("./netcattyAgent.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
// Simple file logger for debugging
const logFile = path.join(require("os").tmpdir(), "netcatty-ssh.log");
@@ -49,122 +51,6 @@ function init(deps) {
electronModule = deps.electronModule;
}
/**
* Create a socket through a proxy (HTTP CONNECT or SOCKS5)
*/
function createProxySocket(proxy, targetHost, targetPort) {
return new Promise((resolve, reject) => {
if (proxy.type === 'http') {
// HTTP CONNECT proxy
const socket = net.connect(proxy.port, proxy.host, () => {
let authHeader = '';
if (proxy.username && proxy.password) {
const auth = Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64');
authHeader = `Proxy-Authorization: Basic ${auth}\r\n`;
}
const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${authHeader}\r\n`;
socket.write(connectRequest);
let response = '';
const onData = (data) => {
response += data.toString();
if (response.includes('\r\n\r\n')) {
socket.removeListener('data', onData);
if (response.startsWith('HTTP/1.1 200') || response.startsWith('HTTP/1.0 200')) {
resolve(socket);
} else {
socket.destroy();
reject(new Error(`HTTP proxy error: ${response.split('\r\n')[0]}`));
}
}
};
socket.on('data', onData);
});
socket.on('error', reject);
} else if (proxy.type === 'socks5') {
// SOCKS5 proxy
const socket = net.connect(proxy.port, proxy.host, () => {
// SOCKS5 greeting
const authMethods = proxy.username && proxy.password ? [0x00, 0x02] : [0x00];
socket.write(Buffer.from([0x05, authMethods.length, ...authMethods]));
let step = 'greeting';
const onData = (data) => {
if (step === 'greeting') {
if (data[0] !== 0x05) {
socket.destroy();
reject(new Error('Invalid SOCKS5 response'));
return;
}
const method = data[1];
if (method === 0x02 && proxy.username && proxy.password) {
// Username/password auth
step = 'auth';
const userBuf = Buffer.from(proxy.username);
const passBuf = Buffer.from(proxy.password);
socket.write(Buffer.concat([
Buffer.from([0x01, userBuf.length]),
userBuf,
Buffer.from([passBuf.length]),
passBuf
]));
} else if (method === 0x00) {
// No auth, proceed to connect
step = 'connect';
sendConnectRequest();
} else {
socket.destroy();
reject(new Error('SOCKS5 authentication method not supported'));
}
} else if (step === 'auth') {
if (data[1] !== 0x00) {
socket.destroy();
reject(new Error('SOCKS5 authentication failed'));
return;
}
step = 'connect';
sendConnectRequest();
} else if (step === 'connect') {
socket.removeListener('data', onData);
if (data[1] === 0x00) {
resolve(socket);
} else {
const errors = {
0x01: 'General failure',
0x02: 'Connection not allowed',
0x03: 'Network unreachable',
0x04: 'Host unreachable',
0x05: 'Connection refused',
0x06: 'TTL expired',
0x07: 'Command not supported',
0x08: 'Address type not supported',
};
socket.destroy();
reject(new Error(`SOCKS5 error: ${errors[data[1]] || 'Unknown'}`));
}
}
};
const sendConnectRequest = () => {
// SOCKS5 connect request
const hostBuf = Buffer.from(targetHost);
const request = Buffer.concat([
Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]),
hostBuf,
Buffer.from([(targetPort >> 8) & 0xff, targetPort & 0xff])
]);
socket.write(request);
};
socket.on('data', onData);
});
socket.on('error', reject);
} else {
reject(new Error(`Unknown proxy type: ${proxy.type}`));
}
});
}
/**
* Connect through a chain of jump hosts
*/
@@ -203,6 +89,8 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
// If 0 or not provided, use 10000ms as default
keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
keepaliveCountMax: 3,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: {
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
@@ -361,6 +249,8 @@ async function startSSHSession(event, options) {
// If 0 or not provided, use 10000ms as default
keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
keepaliveCountMax: 3,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: {
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
@@ -616,6 +506,68 @@ async function startSSHSession(event, options) {
}
});
// Handle keyboard-interactive authentication (2FA/MFA)
conn.on("keyboard-interactive", (name, instructions, instructionsLang, prompts, finish) => {
console.log(`${logPrefix} ${options.hostname} keyboard-interactive auth requested`, {
name,
instructions,
promptCount: prompts?.length || 0,
prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })),
});
// If there are no prompts, just call finish with empty array
if (!prompts || prompts.length === 0) {
console.log(`${logPrefix} No prompts, finishing keyboard-interactive`);
finish([]);
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)
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
finish(userResponses);
}, sender.id, sessionId);
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
sessionId,
name: name || "",
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
if (!connectOpts.authHandler.includes("keyboard-interactive")) {
connectOpts.authHandler.push("keyboard-interactive");
}
} else {
// Create authHandler with keyboard-interactive support
const authMethods = [];
if (connectOpts.privateKey) authMethods.push("publickey");
if (connectOpts.password) authMethods.push("password");
authMethods.push("keyboard-interactive");
connectOpts.authHandler = authMethods;
}
// Increase timeout to allow for keyboard-interactive auth
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
console.log(`${logPrefix} Connecting to ${options.hostname}...`);
conn.connect(connectOpts);
});
@@ -879,6 +831,8 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:ssh:exec", execCommand);
ipcMain.handle("netcatty:ssh:pwd", getSessionPwd);
ipcMain.handle("netcatty:key:generate", generateKeyPair);
// Register the shared keyboard-interactive response handler
keyboardInteractiveHandler.registerHandler(ipcMain);
}
module.exports = {

View File

@@ -9,6 +9,7 @@ const chainProgressListeners = new Map();
const authFailedListeners = new Map();
const languageChangeListeners = new Set();
const fullscreenChangeListeners = new Set();
const keyboardInteractiveListeners = new Set();
ipcRenderer.on("netcatty:data", (_event, payload) => {
const set = dataListeners.get(payload.sessionId);
@@ -86,6 +87,17 @@ ipcRenderer.on("netcatty:auth:failed", (_event, payload) => {
}
});
// Keyboard-interactive authentication events (2FA/MFA)
ipcRenderer.on("netcatty:keyboard-interactive", (_event, payload) => {
keyboardInteractiveListeners.forEach((cb) => {
try {
cb(payload);
} catch (err) {
console.error("Keyboard-interactive callback failed", err);
}
});
});
// Transfer progress events
ipcRenderer.on("netcatty:transfer:progress", (_event, payload) => {
const cb = transferProgressListeners.get(payload.transferId);
@@ -285,6 +297,18 @@ const api = {
authFailedListeners.get(sessionId).add(cb);
return () => authFailedListeners.get(sessionId)?.delete(cb);
},
// Keyboard-interactive authentication (2FA/MFA)
onKeyboardInteractive: (cb) => {
keyboardInteractiveListeners.add(cb);
return () => keyboardInteractiveListeners.delete(cb);
},
respondKeyboardInteractive: async (requestId, responses, cancelled = false) => {
return ipcRenderer.invoke("netcatty:keyboard-interactive:respond", {
requestId,
responses,
cancelled,
});
},
openSftp: async (options) => {
const result = await ipcRenderer.invoke("netcatty:sftp:open", options);
return result.sftpId;

View File

@@ -7,7 +7,7 @@ import reactHooks from "eslint-plugin-react-hooks";
export default [
js.configs.recommended,
{
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**"],
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**"],
},
{
files: ["**/*.{ts,tsx}"],

860
global.d.ts vendored
View File

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

View File

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

View File

@@ -192,21 +192,21 @@ export function getFileExtension(fileName: string): string {
*/
export function isTextFile(fileName: string): boolean {
const ext = getFileExtension(fileName);
// Check known text extensions
if (TEXT_EXTENSIONS.has(ext)) {
return true;
}
// Check common filenames that are text but have no extension
const baseName = fileName.toLowerCase().split('/').pop() || '';
const nameWithoutExt = baseName.replace(/\.[^.]+$/, '');
// Check exact filename matches
if (TEXT_FILENAMES.has(baseName) || TEXT_FILENAMES.has(nameWithoutExt)) {
return true;
}
// Check dot-files that are typically text config files
if (baseName.startsWith('.')) {
const dotConfigPatterns = [
@@ -218,7 +218,7 @@ export function isTextFile(fileName: string): boolean {
return true;
}
}
return false;
}
@@ -233,42 +233,42 @@ export function isTextFile(fileName: string): boolean {
export function isTextData(data: ArrayBuffer | Uint8Array, maxBytes: number = 512): boolean {
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
const checkLength = Math.min(bytes.length, maxBytes);
if (checkLength === 0) return true; // Empty file is considered text
let controlChars = 0;
let nullBytes = 0;
let highBytes = 0;
let totalBytes = 0;
for (let i = 0; i < checkLength; i++) {
const byte = bytes[i];
totalBytes++;
// Null bytes are strong indicators of binary files
if (byte === 0) {
nullBytes++;
if (nullBytes > 0) return false; // Even one null byte suggests binary
}
// Control characters (except common ones like \t, \n, \r)
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
controlChars++;
}
// High-bit characters (non-ASCII) - some are OK for UTF-8
if (byte > 127) {
highBytes++;
}
}
// If more than 30% are control chars or more than 95% are high-bit chars, likely binary
const controlRatio = controlChars / totalBytes;
const highRatio = highBytes / totalBytes;
if (controlRatio > 0.3) return false;
if (highRatio > 0.95) return false;
return true;
}
@@ -279,12 +279,12 @@ export function isTextData(data: ArrayBuffer | Uint8Array, maxBytes: number = 51
export function isTextFileEnhanced(fileName: string, data?: ArrayBuffer | Uint8Array): boolean {
// First check by extension
const extCheck = isTextFile(fileName);
// If we have data, verify it's actually text
if (data && data.byteLength > 0) {
return extCheck && isTextData(data);
}
// Fall back to extension-only check
return extCheck;
}
@@ -419,8 +419,167 @@ export interface FileAssociation {
export function getSupportedLanguages(): { id: string; name: string }[] {
const languageIds = new Set(Object.values(EXTENSION_TO_LANGUAGE));
languageIds.add('plaintext');
return Array.from(languageIds)
.map(id => ({ id, name: getLanguageName(id) }))
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Represents a file or directory entry from drag-and-drop
* This includes the relative path for nested files in folders
*/
export interface DropEntry {
file: File | null; // null for directory entries
relativePath: string; // Path relative to the root of the drop (e.g., "folder/subfolder/file.txt")
isDirectory: boolean;
}
/**
* Read entries from a FileSystemDirectoryEntry recursively
* Uses the webkitGetAsEntry API to access folder contents
*/
function readDirectoryEntries(
directoryReader: FileSystemDirectoryReader
): Promise<FileSystemEntry[]> {
return new Promise((resolve, reject) => {
const allEntries: FileSystemEntry[] = [];
const readBatch = () => {
directoryReader.readEntries(
(entries) => {
if (entries.length === 0) {
resolve(allEntries);
} else {
allEntries.push(...entries);
// Continue reading (readEntries may not return all entries at once)
readBatch();
}
},
(error) => reject(error)
);
};
readBatch();
});
}
/**
* Convert a FileSystemEntry to a File
*/
function entryToFile(entry: FileSystemFileEntry): Promise<File> {
return new Promise((resolve, reject) => {
entry.file(resolve, reject);
});
}
/**
* Recursively process a FileSystemEntry and collect all files
* @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
*/
async function processEntry(
entry: FileSystemEntry,
basePath: string = ""
): Promise<DropEntry[]> {
const results: DropEntry[] = [];
if (entry.isFile) {
const fileEntry = entry as FileSystemFileEntry;
try {
const file = await entryToFile(fileEntry);
results.push({
file,
relativePath: basePath ? `${basePath}/${entry.name}` : entry.name,
isDirectory: false,
});
} catch (error) {
console.warn(`Failed to read file entry: ${entry.name}`, error);
}
} else if (entry.isDirectory) {
const dirEntry = entry as FileSystemDirectoryEntry;
const currentPath = basePath ? `${basePath}/${entry.name}` : entry.name;
// Add a placeholder for the directory itself (to ensure it gets created)
results.push({
file: null, // Directories don't have file content
relativePath: currentPath,
isDirectory: true,
});
try {
const reader = dirEntry.createReader();
const entries = await readDirectoryEntries(reader);
// Helper to yield to main thread - prevents UI freezing during large folder parsing
const yieldToMain = () => new Promise<void>(resolve => setTimeout(resolve, 0));
// Process 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);
}
}
return results;
}
/**
* Extract all files and directories from a DataTransfer object
* Supports both regular files and folders dropped from the OS
*
* Uses the webkitGetAsEntry() API for folder access, with fallback
* to regular FileList for browsers that don't support it.
*
* @param dataTransfer - The DataTransfer object from a drop event
* @returns Array of DropEntry objects with files and relative paths
*/
export async function extractDropEntries(
dataTransfer: DataTransfer
): Promise<DropEntry[]> {
const items = dataTransfer.items;
const results: DropEntry[] = [];
// Check if webkitGetAsEntry is supported (for folder access)
if (items && items.length > 0 && typeof items[0].webkitGetAsEntry === 'function') {
// Collect all entries first (getAsEntry must be called synchronously)
const entries: FileSystemEntry[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const entry = item.webkitGetAsEntry();
if (entry) {
entries.push(entry);
}
}
}
// Now process entries asynchronously
for (const entry of entries) {
const entryResults = await processEntry(entry);
results.push(...entryResults);
}
} else {
// Fallback: use regular FileList (no folder support)
const files = dataTransfer.files;
for (let i = 0; i < files.length; i++) {
const file = files[i];
results.push({
file,
relativePath: file.name,
isDirectory: false,
});
}
}
return results;
}

View File

@@ -1,6 +1,15 @@
import { type ClassValue,clsx } from "clsx"
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* Normalize line endings to LF (Unix style).
* Converts CRLF (Windows) and standalone CR (old Mac) to LF.
* Used for clipboard paste operations in terminal to avoid extra blank lines.
*/
export function normalizeLineEndings(text: string): string {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}

31
package-lock.json generated
View File

@@ -1004,7 +1004,6 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -1651,6 +1650,7 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -1672,6 +1672,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -1688,6 +1689,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -1702,6 +1704,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -5584,7 +5587,6 @@
"integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.53.0",
@@ -5614,7 +5616,6 @@
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.53.0",
"@typescript-eslint/types": "8.53.0",
@@ -5893,8 +5894,7 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/7zip-bin": {
"version": "5.2.0",
@@ -5919,7 +5919,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5952,7 +5951,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -6347,7 +6345,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -6971,7 +6968,8 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true
"optional": true,
"peer": true
},
"node_modules/cross-env": {
"version": "10.1.0",
@@ -7537,6 +7535,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -7557,6 +7556,7 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -7781,7 +7781,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -9930,7 +9929,6 @@
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
@@ -10456,7 +10454,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -10515,6 +10512,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -10532,6 +10530,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -10632,7 +10631,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -10642,7 +10640,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -11543,6 +11540,7 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -11606,6 +11604,7 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -11620,6 +11619,7 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@@ -11768,7 +11768,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11971,7 +11970,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -12310,7 +12308,6 @@
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 995 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 871 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 897 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 977 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 776 KiB

View File

@@ -26,5 +26,10 @@
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}
},
"include": [
"global.d.ts",
"**/*.ts",
"**/*.tsx"
]
}