Compare commits

..

3 Commits

Author SHA1 Message Date
TachibanaLolo
ec04334a21 Merge branch 'binaricat:main' into main
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
2026-01-09 22:03:02 +08:00
TachibanaLolo
57e3641ec5 docs: add Netcatty feature todo list 2026-01-09 22:02:34 +08:00
TachibanaLolo
8258ad6e95 Merge pull request #1 from AkarinServer/feature/linux-build-support
feat: add linux build support (x64/arm64)
2026-01-08 23:22:16 +08:00
70 changed files with 2437 additions and 5290 deletions

View File

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

90
App.tsx
View File

@@ -19,9 +19,8 @@ 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 { ConnectionLog, Host, HostProtocol, TerminalTheme } from './types';
import { LogView as LogViewType } from './application/state/useSessionState';
import type { SftpView as SftpViewComponent } from './components/SftpView';
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
@@ -151,8 +150,6 @@ function App({ settings }: { settings: SettingsState }) {
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
// Navigation state for VaultView sections
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
// Keyboard-interactive authentication state (2FA/MFA)
const [keyboardInteractiveRequest, setKeyboardInteractiveRequest] = useState<KeyboardInteractiveRequest | null>(null);
const {
theme,
@@ -294,45 +291,6 @@ 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);
setKeyboardInteractiveRequest({
requestId: request.requestId,
name: request.name,
instructions: request.instructions,
prompts: request.prompts,
hostname: request.hostname,
});
});
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);
}
setKeyboardInteractiveRequest(null);
}, []);
// Handle keyboard-interactive cancel
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => {
const bridge = netcattyBridge.get();
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, [], true);
}
setKeyboardInteractiveRequest(null);
}, []);
// Debounce ref for moveFocus to prevent double-triggering when focus switches
const lastMoveFocusTimeRef = useRef<number>(0);
const MOVE_FOCUS_DEBOUNCE_MS = 200;
@@ -661,25 +619,6 @@ 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;
addConnectionLog({
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: host.hostname,
username: username,
protocol: 'serial',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
connectToHost(host);
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
addConnectionLog({
@@ -696,24 +635,6 @@ function App({ settings }: { settings: SettingsState }) {
connectToHost(host);
}, [addConnectionLog, connectToHost, identities, keys]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig) => {
const { username, hostname } = systemInfoRef.current;
const portName = config.path.split('/').pop() || config.path;
addConnectionLog({
hostId: '',
hostLabel: `Serial: ${portName}`,
hostname: config.path,
username: username,
protocol: 'serial',
startTime: Date.now(),
localUsername: username,
localHostname: hostname,
saved: false,
});
createSerialSession(config);
}, [addConnectionLog, createSerialSession]);
// Handle terminal data capture when session exits
const handleTerminalDataCapture = useCallback((sessionId: string, data: string) => {
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
@@ -855,7 +776,7 @@ function App({ settings }: { settings: SettingsState }) {
onOpenSettings={handleOpenSettings}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onCreateLocalTerminal={handleCreateLocalTerminal}
onConnectSerial={handleConnectSerial}
onConnectSerial={createSerialSession}
onDeleteHost={handleDeleteHost}
onConnect={handleConnectToHost}
onUpdateHosts={updateHosts}
@@ -1031,13 +952,6 @@ function App({ settings }: { settings: SettingsState }) {
/>
</Suspense>
)}
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) */}
<KeyboardInteractiveModal
request={keyboardInteractiveRequest}
onSubmit={handleKeyboardInteractiveSubmit}
onCancel={handleKeyboardInteractiveCancel}
/>
</div>
);
}

View File

@@ -5,8 +5,7 @@
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>モダンな SSH クライアント、SFTP ブラウザ & ターミナルマネージャー</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
<strong>モダンな SSH クライアント、SFTP ブラウザ & ターミナルマネージャー</strong>
</p>
<p align="center">
@@ -40,7 +39,7 @@
---
[![Netcatty メインインターフェース](screenshots/vault_grid_view.png)](screenshots/vault_grid_view.png)
[![Netcatty メインインターフェース](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
---
@@ -139,15 +138,15 @@ Vault ビューはすべての SSH 接続を管理するコマンドセンター
**ダークモード**
![ホスト管理](screenshots/vault_grid_view.png)
![ダークモード](screenshots/main-window-dark.png)
**ネストされたフォルダと整理**
**ライトモード**
![ネストされたフォルダ](screenshots/nested_folder_structure.png)
![ライトモード](screenshots/main-window-light.png)
**リストビュー**
![リストビュー](screenshots/vault_list_view.png)
![リストビュー](screenshots/main-window-dark-list.png)
<a name="ターミナル"></a>
## ターミナル
@@ -156,28 +155,18 @@ WebGL アクセラレーション対応の xterm.js ベースのターミナル
**分割ウィンドウ**
**ブロードキャストモード**
![分割ウィンドウ](screenshots/split-window.png)
一度入力すれば、どこでも実行できます。複数のサーバーを同時にメンテナンスするのに最適です。
**テーマカスタマイズ**
![ブロードキャストモード](screenshots/broadcast_mode.png)
**パフォーマンス情報とカスタマイズ**
接続の健全性を監視し、ターミナルのあらゆる側面をカスタマイズします。
![ターミナルパフォーマンス](screenshots/terminal_performance.png)
![テーマカスタマイズ](screenshots/terminal-theme-change.png)
<a name="sftp"></a>
## SFTP
デュアルペイン SFTP ブラウザは、ローカルからリモート、リモートからリモートへのファイル転送をサポート。シングルクリックでディレクトリを移動、ペイン間でファイルをドラッグ&ドロップ、転送進捗をリアルタイムで監視。インターフェースにはファイル権限、サイズ、変更日時を表示。複数の転送をキューに入れ、詳細な速度と進捗インジケーターで完了を確認。コンテキストメニューから名前変更、削除、ダウンロード、アップロード操作にすばやくアクセス。
![SFTP デュアルペイン](screenshots/sftp_dual_pane.png)
**転送キュー**
![転送キュー](screenshots/sftp_transfer_queue.png)
![SFTP ビュー](screenshots/sftp.png)
<a name="キーチェーン"></a>
## キーチェーン
@@ -199,10 +188,6 @@ WebGL アクセラレーション対応の xterm.js ベースのターミナル
![キーマネージャー](screenshots/key-manager.png)
**キー生成**
![キー生成](screenshots/key_generator_ui.png)
<a name="ポートフォワーディング"></a>
## ポートフォワーディング
@@ -380,17 +365,6 @@ 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,8 +5,7 @@
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>Modern SSH Client, SFTP Browser & Terminal Manager</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
<strong>Modern SSH Client, SFTP Browser & Terminal Manager</strong>
</p>
<p align="center">
@@ -40,7 +39,7 @@
---
[![Netcatty Main Interface](screenshots/vault_grid_view.png)](screenshots/vault_grid_view.png)
[![Netcatty Main Interface](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
---
@@ -139,15 +138,15 @@ The Vault view is your command center for managing all SSH connections. Create h
**Dark Mode**
![Host Management](screenshots/vault_grid_view.png)
![Dark Mode](screenshots/main-window-dark.png)
**Nested Folders & Organization**
**Light Mode**
![Nested Folders](screenshots/nested_folder_structure.png)
![Light Mode](screenshots/main-window-light.png)
**List View**
![List View](screenshots/vault_list_view.png)
![List View](screenshots/main-window-dark-list.png)
<a name="terminal"></a>
## Terminal
@@ -156,28 +155,18 @@ Powered by xterm.js with WebGL acceleration, the terminal delivers a smooth, res
**Split Windows**
**Broadcast Mode**
![Split Windows](screenshots/split-window.png)
Type once, execute everywhere. Great for maintaining multiple servers simultaneously.
**Theme Customization**
![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)
![Theme Customization](screenshots/terminal-theme-change.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 Dual Pane](screenshots/sftp_dual_pane.png)
**Transfer Queue**
![Transfer Queue](screenshots/sftp_transfer_queue.png)
![SFTP View](screenshots/sftp.png)
<a name="keychain"></a>
## Keychain
@@ -199,10 +188,6 @@ 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
@@ -380,17 +365,6 @@ 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,8 +5,7 @@
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>现代化 SSH 客户端、SFTP 浏览器 & 终端管理器</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
<strong>现代化 SSH 客户端、SFTP 浏览器 & 终端管理器</strong>
</p>
<p align="center">
@@ -40,7 +39,7 @@
---
[![Netcatty 主界面](screenshots/vault_grid_view.png)](screenshots/vault_grid_view.png)
[![Netcatty 主界面](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
---
@@ -139,15 +138,15 @@ Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建
**深色模式**
![主机管理](screenshots/vault_grid_view.png)
![深色模式](screenshots/main-window-dark.png)
**层级文件夹与分组**
**浅色模式**
![层级文件夹](screenshots/nested_folder_structure.png)
![浅色模式](screenshots/main-window-light.png)
**列表视图**
![列表视图](screenshots/vault_list_view.png)
![列表视图](screenshots/main-window-dark-list.png)
<a name="终端"></a>
## 终端
@@ -156,28 +155,18 @@ Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建
**分屏窗口**
**广播模式**
![分屏窗口](screenshots/split-window.png)
一次输入,多处执行。非常适合同时维护这多台服务器。
**主题定制**
![广播模式](screenshots/broadcast_mode.png)
**性能信息与定制**
监控连接健康状况,并自定义终端的方方面面。
![终端性能](screenshots/terminal_performance.png)
![主题定制](screenshots/terminal-theme-change.png)
<a name="sftp"></a>
## SFTP
双窗格 SFTP 浏览器支持本地到远程和远程到远程的文件传输。单击导航目录,在窗格之间拖放文件,实时监控传输进度。界面显示文件权限、大小和修改日期。批量传输队列管理,详细的速度和进度指示器。右键菜单快速访问重命名、删除、下载和上传操作。
![SFTP 双窗格](screenshots/sftp_dual_pane.png)
**传输队列**
![传输队列](screenshots/sftp_transfer_queue.png)
![SFTP 视图](screenshots/sftp.png)
<a name="密钥管理"></a>
## 密钥管理
@@ -199,10 +188,6 @@ Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建
![密钥管理器](screenshots/key-manager.png)
**密钥生成器**
![密钥生成器](screenshots/key_generator_ui.png)
<a name="端口转发"></a>
## 端口转发
@@ -380,17 +365,6 @@ 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,7 +399,6 @@ 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',
@@ -434,8 +433,6 @@ 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',
@@ -448,12 +445,6 @@ 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',
@@ -534,7 +525,7 @@ const en: Messages = {
'settings.sftpFileAssociations.noAssociations': 'No file associations configured',
'settings.sftpFileAssociations.remove': 'Remove',
'settings.sftpFileAssociations.removeConfirm': 'Remove association for .{ext}?',
// Settings > SFTP Behavior
'settings.sftp.doubleClickBehavior': 'Double-click behavior',
'settings.sftp.doubleClickBehavior.desc': 'Choose the action when double-clicking a file in SFTP View',
@@ -542,7 +533,7 @@ const en: Messages = {
'settings.sftp.doubleClickBehavior.transfer': 'Transfer to other pane',
'settings.sftp.doubleClickBehavior.openDesc': 'Open the file in the default application',
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': 'Auto-sync to remote',
'settings.sftp.autoSync.desc': 'Automatically sync file changes back to the remote server when opening files with external applications',
@@ -551,27 +542,6 @@ 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.',
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
'settings.sftp.showHiddenFiles.enableDesc': 'Display Windows hidden files when browsing local filesystem',
// Quick Switcher
'qs.search.placeholder': 'Search hosts or tabs',
'qs.recentConnections': 'Recent connections',
@@ -628,17 +598,16 @@ const en: Messages = {
'hostDetails.keys.empty': 'No keys available',
'hostDetails.certs.search': 'Search certificates...',
'hostDetails.certs.empty': 'No certificates available',
'hostDetails.agentForwarding': 'Forward SSH Agent',
'hostDetails.agentForwarding.desc': 'Allow remote server to use your local SSH keys (e.g., for git operations)',
'hostDetails.jumpHosts': 'Proxy via Hosts',
'hostDetails.agentForwarding': 'Agent Forwarding',
'hostDetails.jumpHosts': 'Jump Hosts',
'hostDetails.jumpHosts.hops': '{count} hop(s)',
'hostDetails.jumpHosts.direct': 'Direct',
'hostDetails.jumpHosts.configure': 'Configure Proxy Hosts',
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5',
'hostDetails.jumpHosts.configure': 'Configure Jump Hosts',
'hostDetails.proxy': 'Proxy',
'hostDetails.proxy.none': 'None',
'hostDetails.proxy.edit': 'Edit Proxy',
'hostDetails.proxy.configure': 'Configure Proxy',
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5',
'hostDetails.proxyPanel.title': 'Proxy',
'hostDetails.proxyPanel.hostPlaceholder': 'Proxy host',
'hostDetails.proxyPanel.credentials': 'Credentials',
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
@@ -679,12 +648,6 @@ const en: Messages = {
'hostDetails.telnet.password': 'Telnet Password',
'hostDetails.charset.placeholder': 'Charset (e.g. UTF-8)',
'hostDetails.telnet.add': 'Add Telnet Protocol',
'hostDetails.tags': 'Tags',
'hostDetails.group': 'Group',
'hostDetails.selectGroup': 'Select Group',
'hostDetails.addTag': 'Add a tag...',
'hostDetails.createTag': 'Create tag',
'hostDetails.createGroup': 'Create group',
// Host form (legacy modal)
'hostForm.title.edit': 'Edit Host',
@@ -1098,12 +1061,11 @@ const en: Messages = {
'serial.field.baudRate': 'Baud Rate',
'serial.field.dataBits': 'Data Bits',
'serial.field.stopBits': 'Stop Bits',
'serial.field.stopBits15Warning': '1.5 stop bits may not be supported on all Windows devices',
'serial.field.parity': 'Parity',
'serial.field.flowControl': 'Flow Control',
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
'serial.field.customPort': 'Custom Port Path',
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001 or COM1',
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001',
'serial.type.hardware': 'Hardware',
'serial.type.pseudo': 'Pseudo Terminal',
'serial.type.custom': 'Custom',
@@ -1120,25 +1082,6 @@ const en: Messages = {
'serial.field.lineMode': 'Line Mode',
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
'serial.connectionError': 'Failed to connect to serial port',
'serial.field.baudRatePlaceholder': 'Select or enter baud rate...',
'serial.field.baudRateEmpty': 'Enter a custom baud rate',
'serial.field.customBaudRate': 'Using custom baud rate',
'serial.field.saveConfig': 'Save Configuration',
'serial.field.saveConfigDesc': 'Save this serial configuration to hosts for quick access',
'serial.field.configLabel': 'Configuration Name',
'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...',
};
export default en;

View File

@@ -264,7 +264,6 @@ const zhCN: Messages = {
// SFTP
'sftp.newFolder': '新建文件夹',
'sftp.newFile': '新建文件',
'sftp.filter': '筛选',
'sftp.filter.placeholder': '按文件名筛选...',
'sftp.columns.name': '名称',
@@ -299,8 +298,6 @@ const zhCN: Messages = {
'sftp.goHome': '返回主目录',
'sftp.folderName': '文件夹名称',
'sftp.folderName.placeholder': '输入文件夹名称',
'sftp.fileName': '文件名称',
'sftp.fileName.placeholder': '输入文件名称',
'sftp.prompt.newFolderName': '新建文件夹名称?',
'sftp.rename.title': '重命名',
'sftp.rename.newName': '新名称',
@@ -313,12 +310,6 @@ const zhCN: Messages = {
'sftp.error.uploadFailed': '上传失败',
'sftp.error.deleteFailed': '删除失败',
'sftp.error.createFolderFailed': '创建文件夹失败',
'sftp.error.createFileFailed': '创建文件失败',
'sftp.error.invalidFileName': '文件名包含非法字符:{chars}',
'sftp.error.reservedName': '此文件名是系统保留名称',
'sftp.overwrite.title': '文件已存在',
'sftp.overwrite.desc': '名为"{name}"的文件已存在。是否要替换它?',
'sftp.overwrite.confirm': '替换',
'sftp.error.renameFailed': '重命名失败',
'sftp.picker.title': '选择主机',
'sftp.picker.desc': '为{side}窗格选择主机',
@@ -388,13 +379,12 @@ const zhCN: Messages = {
'hostDetails.keys.empty': '暂无密钥',
'hostDetails.certs.search': '搜索证书…',
'hostDetails.certs.empty': '暂无证书',
'hostDetails.agentForwarding': '转发 SSH 密钥',
'hostDetails.agentForwarding.desc': '允许远程服务器使用本地 SSH 密钥(例如用于 git 操作)',
'hostDetails.jumpHosts': '通过主机代理',
'hostDetails.agentForwarding': '代理转发',
'hostDetails.jumpHosts': '跳板主机',
'hostDetails.jumpHosts.hops': '{count} 跳',
'hostDetails.jumpHosts.direct': '直连',
'hostDetails.jumpHosts.configure': '配置代理主机',
'hostDetails.proxy': '通过 HTTP/SOCKS5 代理',
'hostDetails.jumpHosts.configure': '配置跳板主机',
'hostDetails.proxy': '代理',
'hostDetails.proxy.none': '无',
'hostDetails.proxy.edit': '编辑代理',
'hostDetails.proxy.configure': '配置代理',
@@ -411,12 +401,6 @@ const zhCN: Messages = {
'hostDetails.telnet.password': 'Telnet 密码',
'hostDetails.charset.placeholder': '字符集(例如 UTF-8',
'hostDetails.telnet.add': '添加 Telnet 协议',
'hostDetails.tags': '标签',
'hostDetails.group': '分组',
'hostDetails.selectGroup': '选择分组',
'hostDetails.addTag': '添加标签...',
'hostDetails.createTag': '创建标签',
'hostDetails.createGroup': '创建分组',
// Host form (legacy modal)
'hostForm.title.edit': '编辑主机',
@@ -773,7 +757,7 @@ const zhCN: Messages = {
'settings.sftpFileAssociations.noAssociations': '未配置文件关联',
'settings.sftpFileAssociations.remove': '移除',
'settings.sftpFileAssociations.removeConfirm': '确定移除 .{ext} 的关联吗?',
// Settings > SFTP Behavior
'settings.sftp.doubleClickBehavior': '双击行为',
'settings.sftp.doubleClickBehavior.desc': '选择在 SFTP 视图中双击文件时的操作',
@@ -781,7 +765,7 @@ const zhCN: Messages = {
'settings.sftp.doubleClickBehavior.transfer': '传输到另一侧',
'settings.sftp.doubleClickBehavior.openDesc': '使用默认应用程序打开文件',
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': '自动同步到远程',
'settings.sftp.autoSync.desc': '使用外部应用程序打开文件时,自动将文件更改同步回远程服务器',
@@ -790,27 +774,6 @@ 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 隐藏属性的文件。',
'settings.sftp.showHiddenFiles.enable': '显示隐藏文件',
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地文件系统时显示 Windows 隐藏文件',
// Settings > Terminal
'settings.terminal.section.theme': '终端主题',
'settings.terminal.themeModal.title': '选择主题',
@@ -1087,12 +1050,11 @@ const zhCN: Messages = {
'serial.field.baudRate': '波特率',
'serial.field.dataBits': '数据位',
'serial.field.stopBits': '停止位',
'serial.field.stopBits15Warning': '1.5 停止位在 Windows 下可能不被所有设备支持',
'serial.field.parity': '校验位',
'serial.field.flowControl': '流控制',
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
'serial.field.customPort': '自定义串口路径',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001',
'serial.type.hardware': '硬件',
'serial.type.pseudo': '虚拟终端',
'serial.type.custom': '自定义',
@@ -1109,25 +1071,6 @@ const zhCN: Messages = {
'serial.field.lineMode': '行模式',
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
'serial.connectionError': '连接串口失败',
'serial.field.baudRatePlaceholder': '选择或输入波特率...',
'serial.field.baudRateEmpty': '输入自定义波特率',
'serial.field.customBaudRate': '使用自定义波特率',
'serial.field.saveConfig': '保存配置',
'serial.field.saveConfigDesc': '将此串口配置保存到主机列表以便快速访问',
'serial.field.configLabel': '配置名称',
'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': '验证中...',
};
export default zhCN;

View File

@@ -72,37 +72,6 @@ export const useSessionState = () => {
}, [setActiveTabId]);
const connectToHost = useCallback((host: Host) => {
// Handle serial hosts specially - use createSerialSession for them
if (host.protocol === 'serial') {
// Use stored serialConfig or construct from host data
const serialConfig: SerialConfig = host.serialConfig || {
path: host.hostname,
baudRate: host.port || 115200,
dataBits: 8,
stopBits: 1,
parity: 'none',
flowControl: 'none',
localEcho: false,
lineMode: false,
};
const sessionId = crypto.randomUUID();
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
const newSession: TerminalSession = {
id: sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: serialConfig.path,
username: '',
status: 'connecting',
protocol: 'serial',
serialConfig: serialConfig,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
return;
}
const newSession: TerminalSession = {
id: crypto.randomUUID(),
hostId: host.id,

View File

@@ -18,7 +18,6 @@ STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
@@ -42,7 +41,6 @@ const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
: 'pc';
const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
const DEFAULT_SFTP_AUTO_SYNC = false;
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
const readStoredString = (key: string): string | null => {
const raw = localStorageAdapter.readString(key);
@@ -169,10 +167,6 @@ export const useSettingsState = () => {
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_SYNC);
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_SYNC;
});
const [sftpShowHiddenFiles, setSftpShowHiddenFiles] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
return stored === 'true' ? true : DEFAULT_SFTP_SHOW_HIDDEN_FILES;
});
// Helper to notify other windows about settings changes via IPC
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
@@ -404,18 +398,11 @@ export const useSettingsState = () => {
setSftpAutoSync(newValue);
}
}
// Sync SFTP show hidden files setting from other windows
if (e.key === STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sftpShowHiddenFiles) {
setSftpShowHiddenFiles(newValue);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles]);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
@@ -478,12 +465,6 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync);
}, [sftpAutoSync, notifySettingsChanged]);
// Persist SFTP show hidden files setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
}, [sftpShowHiddenFiles, notifySettingsChanged]);
// Get merged key bindings (defaults + custom overrides)
const keyBindings = useMemo((): KeyBinding[] => {
return DEFAULT_KEY_BINDINGS.map(binding => {
@@ -594,8 +575,6 @@ export const useSettingsState = () => {
setSftpDoubleClickBehavior,
sftpAutoSync,
setSftpAutoSync,
sftpShowHiddenFiles,
setSftpShowHiddenFiles,
availableFonts,
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,6 @@ import {
Server,
Terminal,
Trash2,
Usb,
User,
} from "lucide-react";
import React, { memo, useCallback, useMemo } from "react";
@@ -64,7 +63,6 @@ interface LogItemProps {
const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) => {
const { t, resolvedLocale } = useI18n();
const isLocal = log.protocol === "local" || log.hostname === "localhost";
const isSerial = log.protocol === "serial";
return (
<div
@@ -94,14 +92,14 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center shrink-0",
isSerial ? "bg-amber-500/10 text-amber-500" : isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
)}>
{isSerial ? <Usb size={14} /> : isLocal ? <Terminal size={14} /> : <Server size={14} />}
{isLocal ? <Terminal size={14} /> : <Server size={14} />}
</div>
<div className="min-w-0">
<div className="text-sm font-medium truncate">{isLocal ? t("logs.localTerminal") : log.hostLabel}</div>
<div className="text-xs text-muted-foreground truncate">
{isLocal ? "local" : isSerial ? `serial, ${log.hostname}` : `${log.protocol}, ${log.username}`}
{isLocal ? "local" : `${log.protocol}, ${log.username}`}
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { Server, Usb } from "lucide-react";
import { Server } from "lucide-react";
import React, { memo } from "react";
import { normalizeDistroId } from "../domain/host";
import { cn } from "../lib/utils";
@@ -69,21 +69,6 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
const containerClass = sizeClasses[size];
const iconSize = iconSizes[size];
// Show USB icon for serial hosts
if (host.protocol === 'serial') {
return (
<div
className={cn(
containerClass,
"flex items-center justify-center bg-amber-500/15 text-amber-500",
className,
)}
>
<Usb className={iconSize} />
</div>
);
}
if (logo && !errored) {
return (
<div

View File

@@ -92,6 +92,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
protocol: "ssh",
tags: [],
os: "linux",
agentForwarding: false,
authMethod: "password",
charset: "UTF-8",
theme: "Flexoki Dark",
@@ -521,16 +522,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<Combobox
options={groupOptions}
value={form.group || ""}
onValueChange={(val) => {
update("group", val);
setGroupInputValue(val);
}}
onValueChange={(val) => update("group", val)}
placeholder={t("hostDetails.group.placeholder")}
allowCreate={true}
onCreateNew={(val) => {
onCreateGroup?.(val);
update("group", val);
setGroupInputValue(val);
}}
createText="Create Group"
triggerClassName="flex-1 h-10"
@@ -1023,92 +1020,75 @@ 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>
{/* 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>
{/* 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>
)}
</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"
{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")}
>
{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>
<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>
<X
size={14}
className="text-muted-foreground hover:text-destructive flex-shrink-0"
className="text-muted-foreground hover:text-destructive flex-shrink-0 ml-auto"
onClick={(e) => {
e.stopPropagation();
clearHostChain();
}}
/>
</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>
</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

@@ -1,189 +0,0 @@
/**
* 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;
}
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={
isPassword
? t("keyboard.interactive.enterCode")
: t("keyboard.interactive.enterResponse")
}
className={isPassword ? "pr-10" : undefined}
autoFocus={index === 0}
disabled={isSubmitting}
/>
{isPassword && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50"
onClick={() => toggleShowPassword(index)}
disabled={isSubmitting}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</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,10 +45,9 @@ 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, extractDropEntries } from "../lib/sftpFileUtils";
import { getFileExtension, isKnownBinaryFile, FileOpenerType, SystemAppInfo } from "../lib/sftpFileUtils";
import { cn } from "../lib/utils";
import { Host, RemoteFile } from "../types";
import { filterHiddenFiles } from "./sftp";
import { DistroAvatar } from "./DistroAvatar";
import FileOpenerDialog from "./FileOpenerDialog";
import TextEditorModal from "./TextEditorModal";
@@ -257,8 +256,6 @@ interface SFTPModalProps {
};
open: boolean;
onClose: () => void;
/** Initial path to open in SFTP. If not accessible, falls back to home directory. */
initialPath?: string;
}
// Sort configuration
@@ -283,7 +280,6 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
credentials,
open,
onClose,
initialPath,
}) => {
const {
openSftp,
@@ -308,7 +304,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
downloadSftpToTempAndOpen,
} = useSftpBackend();
const { t, resolvedLocale } = useI18n();
const { sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
const { sftpAutoSync } = useSettingsState();
const isLocalSession = host.protocol === "local";
const [currentPath, setCurrentPath] = useState("/");
const [files, setFiles] = useState<RemoteFile[]>([]);
@@ -320,17 +316,10 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
const inputRef = useRef<HTMLInputElement>(null);
const sftpIdRef = useRef<string | null>(null);
const initializedRef = useRef(false);
const lastInitialPathRef = useRef<string | undefined>(undefined);
const navigatingRef = useRef(false);
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);
@@ -542,40 +531,6 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
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;
@@ -609,14 +564,6 @@ 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"),
@@ -629,7 +576,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
}
}
},
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, isSessionError, handleSessionError, files.length],
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t],
);
useLayoutEffect(() => {
@@ -663,79 +610,10 @@ 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,
});
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)
const needsReinit = !initializedRef.current || initialPath !== lastInitialPathRef.current;
if (needsReinit) {
if (!initializedRef.current) {
initializedRef.current = true;
lastInitialPathRef.current = initialPath;
if (isLocalSession) {
void (async () => {
let home = localHomeRef.current;
@@ -750,7 +628,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
loadFiles(startPath);
})();
} else {
// For remote sessions, try initialPath first, then fall back to home directory
// For remote sessions, load home directory directly
void (async () => {
const username = credentials.username || 'root';
// Root user's home is /root, other users' home is /home/username
@@ -759,26 +637,6 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
// Set loading state immediately for better UX
setLoading(true);
// If initialPath is provided, try to use it first
if (initialPath) {
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, initialPath);
setCurrentPath(initialPath);
setFiles(list);
setSelectedFiles(new Set());
dirCacheRef.current.set(`${host.id}::${initialPath}`, {
files: list,
timestamp: Date.now(),
});
setLoading(false);
return; // Successfully opened at initialPath
} catch {
// initialPath not accessible, fall back to home directory
logger.warn(`[SFTP] Initial path ${initialPath} not accessible, falling back to home`);
}
}
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, homePath);
@@ -821,7 +679,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
void closeSftpSession();
initializedRef.current = false;
}
}, [open, currentPath, loadFiles, closeSftpSession, getHomeDir, isLocalSession, credentials.username, ensureSftp, listSftp, host.id, t, initialPath]);
}, [open, currentPath, loadFiles, closeSftpSession, getHomeDir, isLocalSession, credentials.username, ensureSftp, listSftp, host.id, t]);
const handleNavigate = useCallback((path: string) => {
// Prevent double navigation (e.g., from double-click race condition)
@@ -871,10 +729,8 @@ 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) =>
@@ -893,7 +749,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
try {
const arrayBuffer = await file.arrayBuffer();
const fullPath = joinPath(currentPath, displayName);
const fullPath = joinPath(currentPath, file.name);
if (isLocalSession) {
await writeLocalFile(fullPath, arrayBuffer);
@@ -1051,95 +907,6 @@ 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 {
@@ -1178,32 +945,6 @@ 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);
@@ -1509,9 +1250,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
e.preventDefault();
e.stopPropagation();
setDragActive(false);
// Use the new drop handler that supports folders
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
handleUploadFromDrop(e.dataTransfer);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleUploadMultiple(e.dataTransfer.files);
}
};
@@ -1523,12 +1263,9 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
// Display files with parent entry (like SftpView)
const displayFiles = useMemo(() => {
// Filter hidden files using utility function
const visibleFiles = filterHiddenFiles(files, sftpShowHiddenFiles);
// Check if we're at root
const atRoot = isRootPath(currentPath);
if (atRoot) return visibleFiles;
if (atRoot) return files;
// Add ".." parent directory entry at the top (only if not at root)
const parentEntry: RemoteFile = {
@@ -1537,8 +1274,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
size: "--",
lastModified: undefined,
};
return [parentEntry, ...visibleFiles.filter((f) => f.name !== "..")];
}, [files, currentPath, isRootPath, sftpShowHiddenFiles]);
return [parentEntry, ...files.filter((f) => f.name !== "..")];
}, [files, currentPath, isRootPath]);
// Sorted files
const sortedFiles = useMemo(() => {
@@ -1934,7 +1671,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
className="h-7 w-7"
onClick={() => loadFiles(currentPath, { force: true })}
>
<RefreshCw size={14} className={cn((loading || reconnecting) && "animate-spin")} />
<RefreshCw size={14} className={cn(loading && "animate-spin")} />
</Button>
{/* Editable Breadcrumbs */}
@@ -2025,14 +1762,6 @@ 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"
@@ -2136,19 +1865,6 @@ 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" />
@@ -2315,9 +2031,6 @@ 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

@@ -2,11 +2,11 @@
* Serial Port Connect Modal
* Allows users to configure and connect to a serial port
*/
import { ChevronDown, ChevronUp, Cpu, RefreshCw, Save, Usb } from 'lucide-react';
import { ChevronDown, ChevronUp, Cpu, RefreshCw, Usb } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useTerminalBackend } from '../application/state/useTerminalBackend';
import type { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
import type { SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { Combobox, type ComboboxOption } from './ui/combobox';
@@ -18,7 +18,6 @@ import {
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
@@ -36,7 +35,6 @@ interface SerialConnectModalProps {
open: boolean;
onClose: () => void;
onConnect: (config: SerialConfig) => void;
onSaveHost?: (host: Host) => void;
}
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
@@ -49,7 +47,6 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
open,
onClose,
onConnect,
onSaveHost,
}) => {
const { t } = useI18n();
const [ports, setPorts] = useState<SerialPort[]>([]);
@@ -66,10 +63,6 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
const [localEcho, setLocalEcho] = useState(false);
const [lineMode, setLineMode] = useState(false);
// Save configuration state
const [saveConfig, setSaveConfig] = useState(false);
const [configLabel, setConfigLabel] = useState('');
const terminalBackend = useTerminalBackend();
const loadPorts = useCallback(async () => {
@@ -94,14 +87,6 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
}
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
// Generate a default label when port is selected
useEffect(() => {
if (selectedPort && !configLabel) {
const portName = selectedPort.split('/').pop() || selectedPort;
setConfigLabel(`Serial: ${portName}`);
}
}, [selectedPort, configLabel]);
const handleConnect = () => {
if (!selectedPort) return;
@@ -116,26 +101,6 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
lineMode,
};
// Save as host if checkbox is checked and onSaveHost is provided
if (saveConfig && onSaveHost) {
const portName = selectedPort.split('/').pop() || selectedPort;
const host: Host = {
id: `serial-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
label: configLabel.trim() || `Serial: ${portName}`,
hostname: selectedPort,
// For serial hosts, port field stores baud rate as a numeric identifier.
// The full configuration is stored in serialConfig for actual connection.
port: baudRate,
username: '',
os: 'linux',
tags: ['serial'],
protocol: 'serial',
createdAt: Date.now(),
serialConfig: config, // Store full serial configuration for connection
};
onSaveHost(host);
}
onConnect(config);
onClose();
};
@@ -149,17 +114,9 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
}));
}, [ports]);
// Validate: port path must start with /dev/ (Unix/macOS) or COM/\\.\COM (Windows)
const trimmedPort = selectedPort.trim();
const isPortValid =
trimmedPort.startsWith('/dev/') ||
/^COM\d+$/i.test(trimmedPort) ||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
// Allow custom baud rates as long as they are positive integers
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
// Check if using 1.5 stop bits (limited Windows support)
const isStopBits15 = stopBits === 1.5;
// Validate: port path must start with /dev/
const isPortValid = selectedPort.trim().startsWith('/dev/');
const isBaudRateValid = BAUD_RATES.includes(baudRate);
const isValid = isPortValid && isBaudRateValid;
return (
@@ -214,28 +171,18 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
{/* Baud Rate */}
<div className="space-y-2">
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
<Combobox
options={BAUD_RATES.map((rate) => ({
value: String(rate),
label: String(rate),
}))}
value={String(baudRate)}
onValueChange={(val) => {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
setBaudRate(parsed);
}
}}
placeholder={t('serial.field.baudRatePlaceholder')}
emptyText={t('serial.field.baudRateEmpty')}
allowCreate
createText={t('common.use')}
/>
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
<p className="text-xs text-muted-foreground">
{t('serial.field.customBaudRate')}
</p>
)}
<select
id="baud-rate"
value={baudRate}
onChange={(e) => setBaudRate(parseInt(e.target.value, 10))}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{BAUD_RATES.map((rate) => (
<option key={rate} value={rate}>
{rate}
</option>
))}
</select>
</div>
{/* Advanced Options */}
@@ -289,11 +236,6 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
</option>
))}
</select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</div>
</div>
@@ -371,40 +313,6 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
</div>
</CollapsibleContent>
</Collapsible>
{/* Save Configuration */}
{onSaveHost && (
<div className="space-y-3 pt-2 border-t border-border/60">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="save-config" className="text-sm font-medium cursor-pointer">
{t('serial.field.saveConfig')}
</Label>
<p className="text-xs text-muted-foreground">
{t('serial.field.saveConfigDesc')}
</p>
</div>
<input
type="checkbox"
id="save-config"
checked={saveConfig}
onChange={(e) => setSaveConfig(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
</div>
{saveConfig && (
<div className="space-y-2">
<Label htmlFor="config-label">{t('serial.field.configLabel')}</Label>
<Input
id="config-label"
value={configLabel}
onChange={(e) => setConfigLabel(e.target.value)}
placeholder={t('serial.field.configLabelPlaceholder')}
/>
</div>
)}
</div>
)}
</div>
<DialogFooter>
@@ -412,12 +320,8 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
{t('common.cancel')}
</Button>
<Button onClick={handleConnect} disabled={!isValid}>
{saveConfig ? (
<Save size={14} className="mr-2" />
) : (
<Cpu size={14} className="mr-2" />
)}
{saveConfig ? t('serial.connectAndSave') : t('common.connect')}
<Cpu size={14} className="mr-2" />
{t('common.connect')}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,415 +0,0 @@
/**
* Serial Host Details Panel
* A dedicated editor for serial port hosts (distinct from SSH HostDetailsPanel)
*/
import { ChevronDown, ChevronUp, Save, Tag, Usb } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useTerminalBackend } from '../application/state/useTerminalBackend';
import type { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
import { Button } from './ui/button';
import { Combobox, ComboboxOption, MultiCombobox } from './ui/combobox';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import {
AsidePanel,
AsidePanelContent,
AsidePanelFooter,
} from './ui/aside-panel';
interface SerialPort {
path: string;
manufacturer: string;
serialNumber: string;
vendorId: string;
productId: string;
pnpId: string;
type?: 'hardware' | 'pseudo' | 'custom';
}
interface SerialHostDetailsPanelProps {
initialData: Host;
allTags?: string[];
groups?: string[];
onSave: (host: Host) => void;
onCancel: () => void;
}
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
const DATA_BITS: Array<5 | 6 | 7 | 8> = [5, 6, 7, 8];
const STOP_BITS: Array<1 | 1.5 | 2> = [1, 1.5, 2];
const PARITY_OPTIONS: SerialParity[] = ['none', 'even', 'odd', 'mark', 'space'];
const FLOW_CONTROL_OPTIONS: SerialFlowControl[] = ['none', 'xon/xoff', 'rts/cts'];
export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
initialData,
allTags = [],
groups = [],
onSave,
onCancel,
}) => {
const { t } = useI18n();
const terminalBackend = useTerminalBackend();
const [ports, setPorts] = useState<SerialPort[]>([]);
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
// Form state
const [label, setLabel] = useState(initialData.label);
const [selectedPort, setSelectedPort] = useState(initialData.hostname || initialData.serialConfig?.path || '');
const [baudRate, setBaudRate] = useState(initialData.serialConfig?.baudRate || initialData.port || 115200);
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(initialData.serialConfig?.dataBits || 8);
const [stopBits, setStopBits] = useState<1 | 1.5 | 2>(initialData.serialConfig?.stopBits || 1);
const [parity, setParity] = useState<SerialParity>(initialData.serialConfig?.parity || 'none');
const [flowControl, setFlowControl] = useState<SerialFlowControl>(initialData.serialConfig?.flowControl || 'none');
const [localEcho, setLocalEcho] = useState(initialData.serialConfig?.localEcho || false);
const [lineMode, setLineMode] = useState(initialData.serialConfig?.lineMode || false);
const [tags, setTags] = useState<string[]>(initialData.tags || []);
const [group, setGroup] = useState(initialData.group || '');
const loadPorts = useCallback(async () => {
setIsLoadingPorts(true);
try {
const result = await terminalBackend.listSerialPorts();
setPorts(result);
} catch (err) {
console.error('[Serial] Failed to list ports:', err);
} finally {
setIsLoadingPorts(false);
}
}, [terminalBackend]);
useEffect(() => {
loadPorts();
}, [loadPorts]);
const handleSave = () => {
if (!selectedPort) return;
const config: SerialConfig = {
path: selectedPort,
baudRate,
dataBits,
stopBits,
parity,
flowControl,
localEcho,
lineMode,
};
const portName = selectedPort.split('/').pop() || selectedPort;
const updatedHost: Host = {
...initialData,
label: label.trim() || `Serial: ${portName}`,
hostname: selectedPort,
port: baudRate,
tags,
group,
serialConfig: config,
};
onSave(updatedHost);
};
// Convert ports to Combobox options
const portOptions: ComboboxOption[] = useMemo(() => {
return ports.map((port) => ({
value: port.path,
label: port.path,
sublabel: port.manufacturer || undefined,
}));
}, [ports]);
// Tag options for MultiCombobox
const tagOptions: ComboboxOption[] = useMemo(() => {
const allUniqueTags = new Set([...allTags, ...tags]);
return Array.from(allUniqueTags).map((tag) => ({
value: tag,
label: tag,
}));
}, [allTags, tags]);
// Group options for Combobox
const groupOptions: ComboboxOption[] = useMemo(() => {
const allGroups = new Set(groups);
if (group && !allGroups.has(group)) {
allGroups.add(group);
}
return Array.from(allGroups).map((g) => ({
value: g,
label: g,
}));
}, [groups, group]);
// Validation
const trimmedPort = selectedPort.trim();
const isPortValid =
trimmedPort.startsWith('/dev/') ||
/^COM\d+$/i.test(trimmedPort) ||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
const isValid = isPortValid && isBaudRateValid;
// Check if using 1.5 stop bits (limited Windows support)
const isStopBits15 = stopBits === 1.5;
return (
<AsidePanel
open={true}
onClose={onCancel}
title={t('serial.edit.title')}
subtitle={initialData.label}
className="z-40"
>
<AsidePanelContent>
{/* Label */}
<div className="space-y-2">
<Label htmlFor="serial-label">{t('serial.field.configLabel')}</Label>
<Input
id="serial-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t('serial.field.configLabelPlaceholder')}
/>
</div>
{/* Serial Port */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="serial-port">{t('serial.field.port')}</Label>
<Button
variant="ghost"
size="sm"
onClick={loadPorts}
disabled={isLoadingPorts}
className="h-6 px-2 text-xs"
>
{t('common.refresh')}
</Button>
</div>
<Combobox
options={portOptions}
value={selectedPort}
onValueChange={setSelectedPort}
placeholder={t('serial.field.selectPort')}
emptyText={t('serial.noPorts')}
allowCreate
createText={t('common.use')}
icon={<Usb size={14} className="text-muted-foreground" />}
/>
{!isPortValid && selectedPort && (
<p className="text-xs text-destructive">
{t('serial.field.customPortPlaceholder')}
</p>
)}
</div>
{/* Baud Rate */}
<div className="space-y-2">
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
<Combobox
options={BAUD_RATES.map((rate) => ({
value: String(rate),
label: String(rate),
}))}
value={String(baudRate)}
onValueChange={(val) => {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
setBaudRate(parsed);
}
}}
placeholder={t('serial.field.baudRatePlaceholder')}
emptyText={t('serial.field.baudRateEmpty')}
allowCreate
createText={t('common.use')}
/>
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
<p className="text-xs text-muted-foreground">
{t('serial.field.customBaudRate')}
</p>
)}
</div>
{/* Tags */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Tag size={14} />
{t('hostDetails.tags')}
</Label>
<MultiCombobox
options={tagOptions}
values={tags}
onValuesChange={setTags}
placeholder={t('hostDetails.addTag')}
allowCreate
createText={t('hostDetails.createTag')}
/>
</div>
{/* Group */}
<div className="space-y-2">
<Label>{t('hostDetails.group')}</Label>
<Combobox
options={groupOptions}
value={group}
onValueChange={setGroup}
placeholder={t('hostDetails.selectGroup')}
allowCreate
createText={t('hostDetails.createGroup')}
/>
</div>
{/* Advanced Options */}
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="w-full justify-between h-9 px-0 hover:bg-transparent"
>
<span className="text-sm font-medium text-muted-foreground">
{t('common.advanced')}
</span>
{showAdvanced ? (
<ChevronUp size={14} className="text-muted-foreground" />
) : (
<ChevronDown size={14} className="text-muted-foreground" />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{/* Data Bits */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<select
id="data-bits"
value={dataBits}
onChange={(e) => setDataBits(parseInt(e.target.value, 10) as 5 | 6 | 7 | 8)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{DATA_BITS.map((bits) => (
<option key={bits} value={bits}>
{bits}
</option>
))}
</select>
</div>
{/* Stop Bits */}
<div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<select
id="stop-bits"
value={stopBits}
onChange={(e) => setStopBits(parseFloat(e.target.value) as 1 | 1.5 | 2)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{STOP_BITS.map((bits) => (
<option key={bits} value={bits}>
{bits}
</option>
))}
</select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</div>
</div>
{/* Parity */}
<div className="space-y-2">
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<select
id="parity"
value={parity}
onChange={(e) => setParity(e.target.value as SerialParity)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{PARITY_OPTIONS.map((option) => (
<option key={option} value={option}>
{t(`serial.parity.${option}`)}
</option>
))}
</select>
</div>
{/* Flow Control */}
<div className="space-y-2">
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<select
id="flow-control"
value={flowControl}
onChange={(e) => setFlowControl(e.target.value as SerialFlowControl)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{FLOW_CONTROL_OPTIONS.map((option) => (
<option key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</option>
))}
</select>
</div>
{/* Terminal Options */}
<div className="space-y-3 pt-2 border-t border-border/60">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="local-echo" className="text-sm font-medium cursor-pointer">
{t('serial.field.localEcho')}
</Label>
<p className="text-xs text-muted-foreground">
{t('serial.field.localEchoDesc')}
</p>
</div>
<input
type="checkbox"
id="local-echo"
checked={localEcho}
onChange={(e) => setLocalEcho(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="line-mode" className="text-sm font-medium cursor-pointer">
{t('serial.field.lineMode')}
</Label>
<p className="text-xs text-muted-foreground">
{t('serial.field.lineModeDesc')}
</p>
</div>
<input
type="checkbox"
id="line-mode"
checked={lineMode}
onChange={(e) => setLineMode(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</AsidePanelContent>
<AsidePanelFooter>
<div className="flex gap-2">
<Button variant="ghost" onClick={onCancel} className="flex-1">
{t('common.cancel')}
</Button>
<Button onClick={handleSave} disabled={!isValid} className="flex-1">
<Save size={14} className="mr-2" />
{t('common.save')}
</Button>
</div>
</AsidePanelFooter>
</AsidePanel>
);
};
export default SerialHostDetailsPanel;

View File

@@ -59,7 +59,6 @@ import { Label } from "./ui/label";
// Import extracted components
import {
ColumnWidths,
filterHiddenFiles,
isNavigableDirectory,
SftpBreadcrumb,
SftpConflictDialog,
@@ -80,7 +79,6 @@ import {
Download,
Edit2,
ExternalLink,
FilePlus,
Folder,
FolderPlus,
HardDrive,
@@ -102,7 +100,6 @@ import {
useSftpPaneCallbacks,
useSftpDrag,
useSftpHosts,
useSftpShowHiddenFiles,
useActiveTabId,
activeTabStore,
type SftpPaneCallbacks,
@@ -165,7 +162,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
const callbacks = useSftpPaneCallbacks(side);
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
const hosts = useSftpHosts();
const showHiddenFiles = useSftpShowHiddenFiles();
// Destructure for easier use
const {
@@ -180,16 +176,14 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
onClearSelection,
onSetFilter,
onCreateDirectory,
onCreateFile,
onDeleteFiles,
onRenameFile,
onCopyToOtherPane,
onReceiveFromOtherPane,
onEditPermissions,
onEditFile,
onOpenFile,
onOpenFileWith,
onDownloadFile,
onUploadExternalFiles,
} = callbacks;
// 渲染追踪 - 只追踪数据 props回调来自 context引用稳定
@@ -210,18 +204,12 @@ 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);
@@ -267,16 +255,11 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
const filteredFiles = useMemo(() => {
const term = pane.filter.trim().toLowerCase();
// Filter hidden files using utility function
let files = filterHiddenFiles(pane.files, showHiddenFiles);
// Apply text filter
if (!term) return files;
return files.filter(
if (!term) return pane.files;
return pane.files.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
);
}, [pane.files, pane.filter, showHiddenFiles]);
}, [pane.files, pane.filter]);
// Path suggestions
const pathSuggestions = useMemo(() => {
@@ -563,53 +546,6 @@ 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;
@@ -625,48 +561,6 @@ 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);
@@ -699,18 +593,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
// Drag handlers
const handlePaneDragOver = (e: React.DragEvent) => {
// Check if this is external file drag (from OS)
const hasFiles = e.dataTransfer.types.includes('Files');
// If it's external files, always allow drop
if (hasFiles) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsDragOverPane(true);
return;
}
// Otherwise, check if it's internal drag from other pane
if (!draggedFiles || draggedFiles[0]?.side === side) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
@@ -725,27 +607,15 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
setDragOverEntry(null);
};
const handlePaneDrop = async (e: React.DragEvent) => {
const handlePaneDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOverPane(false);
setDragOverEntry(null);
// 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, this is an external file/folder drop (from OS)
if (e.dataTransfer.items.length > 0 && onUploadExternalFiles) {
await onUploadExternalFiles(e.dataTransfer);
}
if (!draggedFiles || draggedFiles[0]?.side === side) return;
onReceiveFromOtherPane(
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
);
};
const handleFileDragStart = useCallback(
@@ -944,11 +814,18 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
</>
) : (
<>
<ExternalLink size={14} className="mr-2" />{" "}
{t("sftp.context.open")}
<Download size={14} className="mr-2" />{" "}
{t("sftp.context.download")}
</>
)}
</ContextMenuItem>
{/* File operations - only for files, not directories */}
{!isNavigableDirectory(entry) && onOpenFile && (
<ContextMenuItem onClick={() => onOpenFile(entry)}>
<ExternalLink size={14} className="mr-2" />{" "}
{t("sftp.context.open")}
</ContextMenuItem>
)}
{!isNavigableDirectory(entry) && onOpenFileWith && (
<ContextMenuItem onClick={() => onOpenFileWith(entry)}>
<ExternalLink size={14} className="mr-2" />{" "}
@@ -961,12 +838,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
{t("sftp.context.edit")}
</ContextMenuItem>
)}
{!isNavigableDirectory(entry) && onDownloadFile && (
<ContextMenuItem onClick={() => onDownloadFile(entry)}>
<Download size={14} className="mr-2" />{" "}
{t("sftp.context.download")}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
@@ -1015,9 +886,6 @@ 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>
@@ -1033,10 +901,10 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
handleRowOpen,
handleRowSelect,
onCopyToOtherPane,
onDownloadFile,
onDragEnd,
onEditFile,
onEditPermissions,
onOpenFile,
onOpenFileWith,
onRefresh,
openDeleteConfirm,
@@ -1044,7 +912,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
pane.connection,
pane.selectedFiles,
setShowNewFolderDialog,
setShowNewFileDialog,
t,
],
);
@@ -1232,20 +1099,6 @@ 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"
@@ -1393,73 +1246,53 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
</div>
</div>
{/* File list with empty area context menu */}
<ContextMenu>
<ContextMenuTrigger asChild>
<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 && !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>
</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>
</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>
) : 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>
) : (
<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>
</div>
)}
</div>
{/* 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">
@@ -1476,25 +1309,12 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
</div>
{/* Loading overlay - covers entire pane when navigating directories */}
{pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
{pane.loading && sortedDisplayFiles.length > 0 && (
<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">
@@ -1533,88 +1353,6 @@ 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>
@@ -1742,8 +1480,8 @@ interface SftpViewProps {
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) => {
const { t } = useI18n();
const isActive = useIsSftpActive();
const { sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
const { sftpDoubleClickBehavior, sftpAutoSync } = useSettingsState();
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string }) => {
@@ -1756,7 +1494,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
logger.error("[SFTP] File auto-sync failed", payload);
},
}), [t]);
const sftp = useSftpState(hosts, keys, identities, fileWatchHandlers);
// Store sftp in a ref so callbacks can access the latest instance
@@ -1767,7 +1505,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
// Store behavior setting in ref for stable callbacks
const behaviorRef = useRef(sftpDoubleClickBehavior);
behaviorRef.current = sftpDoubleClickBehavior;
// Store auto-sync setting in ref for stable callbacks
const autoSyncRef = useRef(sftpAutoSync);
autoSyncRef.current = sftpAutoSync;
@@ -1940,14 +1678,6 @@ 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),
[],
@@ -2152,108 +1882,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
[handleOpenFileWithForSide],
);
// 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", dataTransfer: DataTransfer) => {
try {
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 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 items failed
const failedFiles = results.filter(r => !r.success);
failedFiles.forEach(failed => {
const errorMsg = failed.error ? ` - ${failed.error}` : '';
toast.error(
`${t('sftp.error.uploadFailed')}: ${failed.fileName}${errorMsg}`,
"SFTP"
);
});
}
} catch (error) {
logger.error("[SftpView] Failed to upload external files:", error);
toast.error(
error instanceof Error ? error.message : t('sftp.error.uploadFailed'),
"SFTP"
);
}
},
[t],
);
const handleUploadExternalFilesLeft = useCallback(
(dataTransfer: DataTransfer) => handleUploadExternalFilesForSide("left", dataTransfer),
[handleUploadExternalFilesForSide],
);
const handleUploadExternalFilesRight = useCallback(
(dataTransfer: DataTransfer) => handleUploadExternalFilesForSide("right", dataTransfer),
[handleUploadExternalFilesForSide],
);
// Download file to local filesystem (browser download)
const handleDownloadFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
try {
// Read the file as binary
const content = await sftpRef.current.readBinaryFile(side, fullPath);
// Create blob and trigger browser download
const blob = new Blob([content], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(`${t('sftp.context.download')}: ${file.name}`, "SFTP");
} catch (e) {
logger.error("[SftpView] Failed to download file:", e);
toast.error(
e instanceof Error ? e.message : t('sftp.error.downloadFailed'),
"SFTP"
);
}
},
[t],
);
const handleDownloadFileLeft = useCallback(
(file: SftpFileEntry) => handleDownloadFileForSide("left", file),
[handleDownloadFileForSide],
);
const handleDownloadFileRight = useCallback(
(file: SftpFileEntry) => handleDownloadFileForSide("right", file),
[handleDownloadFileForSide],
);
// Custom handleOpenEntry callbacks that check the double-click behavior setting
const handleOpenEntryLeft = useCallback(
(entry: SftpFileEntry) => {
@@ -2323,7 +1951,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
onClearSelection: handleClearSelectionLeft,
onSetFilter: handleSetFilterLeft,
onCreateDirectory: handleCreateDirectoryLeft,
onCreateFile: handleCreateFileLeft,
onDeleteFiles: handleDeleteFilesLeft,
onRenameFile: handleRenameFileLeft,
onCopyToOtherPane: handleCopyToOtherPaneLeft,
@@ -2332,8 +1959,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
onEditFile: handleEditFileLeft,
onOpenFile: handleOpenFileLeft,
onOpenFileWith: handleOpenFileWithLeft,
onDownloadFile: handleDownloadFileLeft,
onUploadExternalFiles: handleUploadExternalFilesLeft,
}),
[],
);
@@ -2351,7 +1976,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
onClearSelection: handleClearSelectionRight,
onSetFilter: handleSetFilterRight,
onCreateDirectory: handleCreateDirectoryRight,
onCreateFile: handleCreateFileRight,
onDeleteFiles: handleDeleteFilesRight,
onRenameFile: handleRenameFileRight,
onCopyToOtherPane: handleCopyToOtherPaneRight,
@@ -2360,8 +1984,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
onEditFile: handleEditFileRight,
onOpenFile: handleOpenFileRight,
onOpenFileWith: handleOpenFileWithRight,
onDownloadFile: handleDownloadFileRight,
onUploadExternalFiles: handleUploadExternalFilesRight,
}),
[],
);
@@ -2502,7 +2124,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
dragCallbacks={dragCallbacks}
leftCallbacks={leftCallbacks}
rightCallbacks={rightCallbacks}
showHiddenFiles={sftpShowHiddenFiles}
>
<div
className={cn(
@@ -2616,15 +2237,14 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
onSelectHost={handleHostSelectRight}
/>
{/* Transfer status area - shows folder uploads and file transfers */}
{(sftp.transfers.length > 0 || sftp.folderUploadProgress.isUploading) && (
{sftp.transfers.length > 0 && (
<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.folderUploadProgress.isUploading) && (
{sftp.activeTransfersCount > 0 && (
<span className="ml-2 text-primary">
({sftp.activeTransfersCount + (sftp.folderUploadProgress.isUploading ? 1 : 0)} active)
({sftp.activeTransfersCount} active)
</span>
)}
</span>
@@ -2642,37 +2262,6 @@ 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

@@ -5,7 +5,6 @@ import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Maximize2, Radio } from "lucide-react";
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { useI18n } from "../application/i18n/I18nProvider";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
@@ -182,7 +181,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [timeLeft, setTimeLeft] = useState(CONNECTION_TIMEOUT / 1000);
const [isCancelling, setIsCancelling] = useState(false);
const [showSFTP, setShowSFTP] = useState(false);
const [sftpInitialPath, setSftpInitialPath] = useState<string | undefined>(undefined);
const [progressValue, setProgressValue] = useState(15);
const [hasSelection, setHasSelection] = useState(false);
@@ -734,34 +732,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current?.writeln("\r\n[No active SSH session]");
};
const handleOpenSFTP = async () => {
// If SFTP is already open, toggle it off
if (showSFTP) {
setShowSFTP(false);
return;
}
// Try to get the current working directory from the terminal session
let initialPath: string | undefined = undefined;
if (sessionRef.current) {
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (result.success && result.cwd) {
initialPath = result.cwd;
}
} catch {
// Silently fail and open SFTP without initial path
}
}
// Use flushSync to ensure initialPath state is committed before opening SFTP modal
// This prevents React's batching from causing the modal to open with stale/undefined initialPath
flushSync(() => {
setSftpInitialPath(initialPath);
});
setShowSFTP(true);
};
const handleCancelConnect = () => {
setIsCancelling(true);
auth.setNeedsAuth(false);
@@ -840,7 +810,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
isScriptsOpen={isScriptsOpen}
setIsScriptsOpen={setIsScriptsOpen}
onOpenSFTP={handleOpenSFTP}
onOpenSFTP={() => setShowSFTP((v) => !v)}
onSnippetClick={handleSnippetClick}
onUpdateHost={onUpdateHost}
showClose={opts?.showClose}
@@ -1083,7 +1053,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
})()}
open={showSFTP && status === "connected"}
onClose={() => setShowSFTP(false)}
initialPath={sftpInitialPath}
/>
</div>
</TerminalContextMenu>

View File

@@ -50,7 +50,6 @@ import PortForwarding from "./PortForwardingNew";
import QuickConnectWizard from "./QuickConnectWizard";
import { isQuickConnectInput, parseQuickConnectInputWithWarnings } from "../domain/quickConnect";
import SerialConnectModal from "./SerialConnectModal";
import SerialHostDetailsPanel from "./SerialHostDetailsPanel";
import SnippetsManager from "./SnippetsManager";
import { ImportVaultDialog } from "./vault/ImportVaultDialog";
import { Button } from "./ui/button";
@@ -1362,7 +1361,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</div>
{/* Host Details Panel - positioned at VaultView root level for correct top alignment */}
{currentSection === "hosts" && isHostPanelOpen && editingHost?.protocol !== 'serial' && (
{currentSection === "hosts" && isHostPanelOpen && (
<HostDetailsPanel
initialData={editingHost}
availableKeys={keys}
@@ -1397,31 +1396,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
/>
)}
{/* Serial Host Details Panel - for editing serial port hosts */}
{currentSection === "hosts" && isHostPanelOpen && editingHost?.protocol === 'serial' && (
<SerialHostDetailsPanel
initialData={editingHost}
allTags={allTags}
groups={Array.from(
new Set([
...customGroups,
...hosts.map((h) => h.group || "General"),
]),
)}
onSave={(host) => {
onUpdateHosts(
hosts.map((h) => (h.id === host.id ? host : h)),
);
setIsHostPanelOpen(false);
setEditingHost(null);
}}
onCancel={() => {
setIsHostPanelOpen(false);
setEditingHost(null);
}}
/>
)}
<Dialog open={isNewFolderOpen} onOpenChange={(open) => {
setIsNewFolderOpen(open);
if (!open) {
@@ -1558,9 +1532,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onConnectSerial(config);
}
}}
onSaveHost={(host) => {
onUpdateHosts([...hosts, host]);
}}
/>
</div>
);

View File

@@ -29,7 +29,7 @@ const getOpenerLabel = (
export default function SettingsFileAssociationsTab() {
const { t } = useI18n();
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles } = useSettingsState();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync } = useSettingsState();
const associations = getAllAssociations();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
@@ -173,46 +173,6 @@ export default function SettingsFileAssociationsTab() {
</button>
</div>
{/* Show hidden files section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.showHiddenFiles')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.showHiddenFiles.desc')}
</p>
<button
onClick={() => setSftpShowHiddenFiles(!sftpShowHiddenFiles)}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpShowHiddenFiles
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpShowHiddenFiles
? "border-primary bg-primary"
: "border-muted-foreground/30"
)}>
{sftpShowHiddenFiles && (
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.showHiddenFiles.enable')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.showHiddenFiles.enableDesc')}
</p>
</div>
</div>
</button>
</div>
{/* File associations section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftpFileAssociations.title')} />

View File

@@ -22,7 +22,6 @@ 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,9 +31,6 @@ export interface SftpPaneCallbacks {
onEditFile?: (entry: SftpFileEntry) => void;
onOpenFile?: (entry: SftpFileEntry) => void;
onOpenFileWith?: (entry: SftpFileEntry) => void; // Always show opener dialog
onDownloadFile?: (entry: SftpFileEntry) => void; // Download to local filesystem
// External file upload (supports folders via DataTransfer)
onUploadExternalFiles?: (dataTransfer: DataTransfer) => Promise<void>;
}
export interface SftpDragCallbacks {
@@ -95,9 +91,6 @@ export interface SftpContextValue {
// Callbacks for each side
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
// Settings
showHiddenFiles: boolean;
}
const SftpContext = createContext<SftpContextValue | null>(null);
@@ -131,19 +124,12 @@ export const useSftpHosts = () => {
return context.hosts;
};
// Hook to get showHiddenFiles setting
export const useSftpShowHiddenFiles = (): boolean => {
const context = useSftpContext();
return context.showHiddenFiles;
};
interface SftpContextProviderProps {
hosts: Host[];
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
dragCallbacks: SftpDragCallbacks;
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
showHiddenFiles: boolean;
children: React.ReactNode;
}
@@ -153,7 +139,6 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
dragCallbacks,
leftCallbacks,
rightCallbacks,
showHiddenFiles,
children,
}) => {
// Memoize the context value to prevent unnecessary re-renders
@@ -165,9 +150,8 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
dragCallbacks,
leftCallbacks,
rightCallbacks,
showHiddenFiles,
}),
[hosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks, showHiddenFiles],
[hosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks],
);
return <SftpContext.Provider value={value}>{children}</SftpContext.Provider>;

View File

@@ -7,7 +7,7 @@
// Utilities
export {
formatBytes,formatDate,
formatSpeed,formatTransferBytes,getFileIcon,isNavigableDirectory,isWindowsHiddenFile,filterHiddenFiles,type ColumnWidths,type SortField,
formatSpeed,formatTransferBytes,getFileIcon,isNavigableDirectory,type ColumnWidths,type SortField,
type SortOrder
} from './utils';
@@ -18,7 +18,6 @@ export {
useSftpPaneCallbacks,
useSftpDrag,
useSftpHosts,
useSftpShowHiddenFiles,
useActiveTabId,
useIsPaneActive,
activeTabStore,

View File

@@ -187,33 +187,3 @@ export interface ColumnWidths {
export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
return entry.type === 'directory' || (entry.type === 'symlink' && entry.linkTarget === 'directory');
};
/**
* Check if a file is hidden on Windows
* Only applies to local Windows filesystem where the hidden attribute is set
* The ".." parent directory entry is never considered hidden
*
* Note: On Unix/Linux, there's no system-level hidden file concept.
* Dotfiles are just a convention, not actual hidden files, so we don't filter them.
*/
export const isWindowsHiddenFile = <T extends { name: string; hidden?: boolean }>(file: T): boolean => {
if (file.name === "..") return false;
return file.hidden === true;
};
/**
* Filter files based on Windows hidden file visibility setting
* Only filters files with the Windows hidden attribute set
* Always preserves ".." parent directory entry
*
* This setting only affects local Windows filesystem browsing.
* On Unix/Linux systems and remote SFTP connections, all files are shown
* because there's no system-level hidden file concept (dotfiles are just a convention).
*/
export const filterHiddenFiles = <T extends { name: string; hidden?: boolean }>(
files: T[],
showHiddenFiles: boolean
): T[] => {
if (showHiddenFiles) return files;
return files.filter((f) => !isWindowsHiddenFile(f));
};

View File

@@ -2,7 +2,6 @@ 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;
@@ -33,7 +32,7 @@ export const useTerminalContextActions = ({
if (!term) return;
try {
const text = await navigator.clipboard.readText();
if (text && sessionRef.current) terminalBackend.writeToSession(sessionRef.current, normalizeLineEndings(text));
if (text && sessionRef.current) terminalBackend.writeToSession(sessionRef.current, text);
} catch (err) {
logger.warn("Failed to paste from clipboard", err);
}

View File

@@ -17,7 +17,6 @@ import {
resolveXTermPerformanceConfig,
} from "../../../infrastructure/config/xtermPerformance";
import { logger } from "../../../lib/logger";
import { normalizeLineEndings } from "../../../lib/utils";
import type {
Host,
KeyBinding,
@@ -107,7 +106,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;
@@ -359,7 +358,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, normalizeLineEndings(text));
if (id) ctx.terminalBackend.writeToSession(id, text);
});
break;
}
@@ -391,7 +390,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, normalizeLineEndings(text));
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, 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: "*",
accept: ".conf,.config,.txt",
},
];

View File

@@ -88,8 +88,6 @@ export interface Host {
telnetEnabled?: boolean; // Is Telnet enabled for this host
telnetUsername?: string; // Telnet-specific username
telnetPassword?: string; // Telnet-specific password
// Serial-specific configuration (for protocol='serial' hosts)
serialConfig?: SerialConfig;
}
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
@@ -475,7 +473,6 @@ export interface RemoteFile {
lastModified: string;
linkTarget?: 'file' | 'directory' | null; // For symlinks: the type of the target, or null if broken
permissions?: string; // rwx format for owner/group/others e.g. "rwxr-xr-x"
hidden?: boolean; // Windows hidden attribute (only set for local Windows filesystem)
}
export type WorkspaceNode =
@@ -515,7 +512,6 @@ export interface SftpFileEntry {
owner?: string;
group?: string;
linkTarget?: 'file' | 'directory' | null; // For symlinks: the type of the target, or null if broken
hidden?: boolean; // Windows hidden attribute (only set for local Windows filesystem)
}
export interface SftpConnection {
@@ -619,7 +615,7 @@ export interface ConnectionLog {
hostLabel: string; // Display label (e.g., 'Local Terminal' or host label)
hostname: string; // Target hostname or 'localhost'
username: string; // SSH username or system username
protocol: 'ssh' | 'telnet' | 'local' | 'mosh' | 'serial';
protocol: 'ssh' | 'telnet' | 'local' | 'mosh';
startTime: number; // Connection start timestamp
endTime?: number; // Connection end timestamp (undefined if still active)
localUsername: string; // System username of the local user

View File

@@ -1,82 +1,6 @@
import { Host, HostChainConfig, HostProtocol } from "./models";
import { Host, 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"
@@ -518,7 +442,6 @@ const importFromSshConfig = (text: string): VaultImportResult => {
hostname?: string;
username?: string;
port?: number;
proxyJump?: string;
};
const blocks: Block[] = [];
@@ -556,23 +479,16 @@ 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;
@@ -589,146 +505,24 @@ const importFromSshConfig = (text: string): VaultImportResult => {
continue;
}
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;
}
parsedHosts.push(
createHost({
label: pat,
hostname,
username: block.username,
port: block.port,
protocol: "ssh",
}),
);
}
}
const { hosts, duplicates } = dedupeHosts(parsedHosts);
return {
hosts: allHosts,
hosts,
groups: [],
issues,
stats: { parsed, imported: allHosts.length, skipped, duplicates },
stats: { parsed, imported: hosts.length, skipped, duplicates },
};
};

View File

@@ -1,110 +0,0 @@
/* global __dirname */
const path = require('path');
/**
* @type {import('electron-builder').Configuration}
*/
module.exports = {
appId: 'com.netcatty.app',
productName: 'Netcatty',
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
icon: 'public/icon.png',
directories: {
buildResources: 'build',
output: 'release'
},
files: [
'dist/**/*',
'electron/**/*',
'!electron/.dev-config.json',
'public/**/*',
'node_modules/**/*'
],
asarUnpack: [
'node_modules/node-pty/**/*',
'node_modules/ssh2/**/*',
'node_modules/cpu-features/**/*'
],
mac: {
target: [
{
target: 'dmg',
arch: ['arm64', 'x64']
},
{
target: 'zip',
arch: ['arm64', 'x64']
}
],
category: 'public.app-category.developer-tools',
hardenedRuntime: false,
gatekeeperAssess: false,
entitlements: 'electron/entitlements.mac.plist',
entitlementsInherit: 'electron/entitlements.mac.plist',
extendInfo: {
NSCameraUsageDescription: 'Netcatty may use the camera for video calls',
NSMicrophoneUsageDescription: 'Netcatty may use the microphone for audio',
NSLocalNetworkUsageDescription: 'Netcatty needs local network access for SSH connections'
}
},
dmg: {
title: '${productName}',
background: 'public/dmg-background.jpg',
iconSize: 100,
iconTextSize: 12,
window: {
width: 672,
height: 500
},
contents: [
{ x: 150, y: 158 },
{ x: 550, y: 158, type: 'link', path: '/Applications' },
{
x: 350,
y: 330,
type: 'file',
// Use absolute path resolved at build time
path: path.resolve(__dirname, 'scripts/FixQuarantine.app'),
name: '已损坏修复.app'
}
]
},
win: {
target: [
{
target: 'nsis',
arch: ['x64']
},
{
target: 'dir',
arch: ['x64']
}
]
},
nsis: {
oneClick: false,
perMachine: false,
allowElevation: true,
allowToChangeInstallationDirectory: true,
createDesktopShortcut: true,
createStartMenuShortcut: true,
shortcutName: 'Netcatty'
},
linux: {
target: [
{
target: 'AppImage',
arch: ['x64', 'arm64']
},
{
target: 'deb',
arch: ['x64', 'arm64']
},
{
target: 'rpm',
arch: ['x64', 'arm64']
}
],
category: 'Development'
}
};

83
electron-builder.json Normal file
View File

@@ -0,0 +1,83 @@
{
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
"appId": "com.netcatty.app",
"productName": "Netcatty",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"icon": "public/icon.png",
"directories": {
"buildResources": "build",
"output": "release"
},
"files": [
"dist/**/*",
"electron/**/*",
"!electron/.dev-config.json",
"public/**/*",
"node_modules/**/*"
],
"asarUnpack": [
"node_modules/node-pty/**/*",
"node_modules/ssh2/**/*",
"node_modules/cpu-features/**/*"
],
"mac": {
"target": [
{
"target": "dmg",
"arch": ["arm64", "x64"]
},
{
"target": "zip",
"arch": ["arm64", "x64"]
}
],
"category": "public.app-category.developer-tools",
"hardenedRuntime": false,
"gatekeeperAssess": false,
"entitlements": "electron/entitlements.mac.plist",
"entitlementsInherit": "electron/entitlements.mac.plist",
"extendInfo": {
"NSCameraUsageDescription": "Netcatty may use the camera for video calls",
"NSMicrophoneUsageDescription": "Netcatty may use the microphone for audio",
"NSLocalNetworkUsageDescription": "Netcatty needs local network access for SSH connections"
}
},
"win": {
"target": [
{
"target": "nsis",
"arch": ["x64"]
},
{
"target": "dir",
"arch": ["x64"]
}
]
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowElevation": true,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "Netcatty"
},
"linux": {
"target": [
{
"target": "AppImage",
"arch": ["x64", "arm64"]
},
{
"target": "deb",
"arch": ["x64", "arm64"]
},
{
"target": "rpm",
"arch": ["x64", "arm64"]
}
],
"category": "Development"
}
}

View File

@@ -1,104 +0,0 @@
/**
* 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

@@ -6,35 +6,14 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { execSync } = require("node:child_process");
/**
* Check if a file is hidden on Windows using the attrib command
* Returns true if the file has the hidden attribute set
*/
function isWindowsHiddenFile(filePath) {
if (process.platform !== "win32") return false;
try {
const output = execSync(`attrib "${filePath}"`, { encoding: "utf8" });
// attrib output format: " H R filename" where H = hidden, R = read-only, etc.
// The attributes appear in the first ~10 characters before the path
const attrPart = output.substring(0, output.indexOf(filePath)).toUpperCase();
return attrPart.includes("H");
} catch (err) {
console.warn(`Could not check hidden attribute for ${filePath}:`, err.message);
return false;
}
}
/**
* List files in a local directory
* Properly handles symlinks by resolving their target type
* On Windows, also detects hidden files using the hidden attribute
*/
async function listLocalDir(event, payload) {
const dirPath = payload.path;
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
const isWindows = process.platform === "win32";
// Stat entries in parallel with a small concurrency limit.
// Serial stats can be very slow on Windows for large dirs.
@@ -66,16 +45,12 @@ async function listLocalDir(event, payload) {
type = "file";
}
// Check for Windows hidden attribute
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
result[i] = {
name: entry.name,
type,
linkTarget,
size: `${stat.size} bytes`,
lastModified: stat.mtime.toISOString(),
hidden,
};
} catch (err) {
// Handle broken symlinks - lstat doesn't follow symlinks
@@ -86,14 +61,12 @@ async function listLocalDir(event, payload) {
const lstat = await fs.promises.lstat(fullPath);
if (lstat.isSymbolicLink()) {
// Broken symlink
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
result[i] = {
name: brokenEntry.name,
type: "symlink",
linkTarget: null, // Broken link - target unknown
size: `${lstat.size} bytes`,
lastModified: lstat.mtime.toISOString(),
hidden,
};
return;
}

View File

@@ -5,31 +5,18 @@
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,
@@ -39,125 +26,34 @@ 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: 120000, // 2 minutes for 2FA input
readyTimeout: 30000,
keepaliveInterval: 10000,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
};
if (privateKey) {
connectOpts.privateKey = privateKey;
}
if (password) {
} else 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;
}
// Check if all prompts are password prompts that we can auto-answer
const responses = [];
const promptsNeedingUserInput = [];
for (let i = 0; i < prompts.length; i++) {
const prompt = prompts[i];
const promptText = (prompt.prompt || '').toLowerCase().trim();
// Auto-answer password prompts if we have a configured password
if (password && (
promptText.includes('password') ||
promptText === 'password:' ||
promptText === 'password'
)) {
console.log(`[PortForward] Auto-answering password prompt at index ${i}`);
responses[i] = password;
} else {
// This prompt needs user input (likely 2FA)
promptsNeedingUserInput.push({ index: i, prompt: prompt });
responses[i] = null; // Placeholder
}
}
// If all prompts were auto-answered, finish immediately
if (promptsNeedingUserInput.length === 0) {
console.log(`[PortForward] All prompts auto-answered, finishing keyboard-interactive`);
finish(responses);
return;
}
// If some prompts need user input, show the modal
const requestId = keyboardInteractiveHandler.generateRequestId('pf');
// Store finish callback with context about which responses are already filled
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
// Merge user responses with auto-filled responses
let userResponseIndex = 0;
for (let i = 0; i < prompts.length; i++) {
if (responses[i] === null) {
responses[i] = userResponses[userResponseIndex] || '';
userResponseIndex++;
}
}
console.log(`[PortForward] Merged responses, finishing keyboard-interactive`);
finish(responses);
}, sender.id, tunnelId);
// Send only the prompts that need user input
const promptsData = promptsNeedingUserInput.map((item) => ({
prompt: item.prompt.prompt,
echo: item.prompt.echo,
}));
console.log(`[PortForward] Showing modal for ${promptsData.length} prompts that need user input`);
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
sessionId: tunnelId,
name: name || "",
instructions: instructions || "",
prompts: promptsData,
hostname: hostname,
});
});
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) => {
@@ -173,13 +69,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);
@@ -187,19 +83,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) => {
@@ -210,24 +106,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();
@@ -237,7 +133,7 @@ async function startPortForward(event, payload) {
socket.end();
});
});
} else if (type === 'dynamic') {
// DYNAMIC FORWARDING (SOCKS5 Proxy)
const server = net.createServer((socket) => {
@@ -247,10 +143,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) {
@@ -258,10 +154,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]}`;
@@ -281,7 +177,7 @@ async function startPortForward(event, payload) {
socket.end();
return;
}
// Forward through SSH tunnel
conn.forwardOut(
bindAddress,
@@ -294,7 +190,7 @@ async function startPortForward(event, payload) {
socket.end();
return;
}
// Success reply
const reply = Buffer.alloc(10);
reply[0] = 0x05;
@@ -303,9 +199,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());
}
@@ -313,7 +209,7 @@ async function startPortForward(event, payload) {
});
});
});
server.on('error', (err) => {
console.error(`[PortForward] SOCKS server error:`, err.message);
sendStatus('error', err.message);
@@ -321,14 +217,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 });
@@ -337,26 +233,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);
});
@@ -368,11 +264,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();
@@ -381,7 +277,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 };
@@ -394,11 +290,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

@@ -1,135 +0,0 @@
/**
* 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

@@ -11,8 +11,6 @@ const SftpClient = require("ssh2-sftp-client");
const { Client: SSHClient } = require("ssh2");
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;
@@ -21,18 +19,6 @@ 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
*/
@@ -41,13 +27,130 @@ 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++) {
@@ -55,14 +158,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,
@@ -71,15 +174,13 @@ 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;
@@ -109,7 +210,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);
@@ -122,7 +223,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', () => {
@@ -139,9 +240,9 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
});
conn.connect(connOpts);
});
connections.push(conn);
// Determine next target
let nextHost, nextPort;
if (isLast) {
@@ -154,7 +255,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) => {
@@ -169,10 +270,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) {
@@ -191,15 +292,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}`);
@@ -220,16 +321,13 @@ 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;
@@ -237,7 +335,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;
@@ -265,122 +363,19 @@ async function openSftp(event, options) {
if (connectOpts.password) order.push("password");
connectOpts.authHandler = order;
}
// 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;
}
// Check if all prompts are password prompts that we can auto-answer
const responses = [];
const promptsNeedingUserInput = [];
for (let i = 0; i < prompts.length; i++) {
const prompt = prompts[i];
const promptText = (prompt.prompt || '').toLowerCase().trim();
// Auto-answer password prompts if we have a configured password
if (options.password && (
promptText.includes('password') ||
promptText === 'password:' ||
promptText === 'password'
)) {
console.log(`[SFTP] Auto-answering password prompt at index ${i}`);
responses[i] = options.password;
} else {
// This prompt needs user input (likely 2FA)
promptsNeedingUserInput.push({ index: i, prompt: prompt });
responses[i] = null; // Placeholder
}
}
// If all prompts were auto-answered, finish immediately
if (promptsNeedingUserInput.length === 0) {
console.log(`[SFTP] All prompts auto-answered, finishing keyboard-interactive`);
finish(responses);
return;
}
// If some prompts need user input, show the modal
const requestId = keyboardInteractiveHandler.generateRequestId('sftp');
// Store finish callback with context about which responses are already filled
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
// Merge user responses with auto-filled responses
let userResponseIndex = 0;
for (let i = 0; i < prompts.length; i++) {
if (responses[i] === null) {
responses[i] = userResponses[userResponseIndex] || '';
userResponseIndex++;
}
}
console.log(`[SFTP] Merged responses, finishing keyboard-interactive`);
finish(responses);
}, event.sender.id, connId);
// Send only the prompts that need user input
const promptsData = promptsNeedingUserInput.map((item) => ({
prompt: item.prompt.prompt,
echo: item.prompt.echo,
}));
console.log(`[SFTP] Showing modal for ${promptsData.length} prompts that need user input`);
safeSend(event.sender, "netcatty:keyboard-interactive", {
requestId,
sessionId: connId,
name: name || "",
instructions: instructions || "",
prompts: promptsData,
hostname: options.hostname,
});
};
// 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);
// 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, {
@@ -388,7 +383,7 @@ async function openSftp(event, options) {
socket: connectionSocket
});
}
console.log(`[SFTP] Connection established: ${connId}`);
return { sftpId: connId };
} catch (err) {
@@ -407,15 +402,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") {
@@ -438,7 +433,7 @@ async function listSftp(event, payload) {
} else {
type = "file";
}
// Extract permissions from longname or rights
let permissions = undefined;
if (item.rights) {
@@ -451,7 +446,7 @@ async function listSftp(event, payload) {
permissions = match[1];
}
}
return {
name: item.name,
type,
@@ -461,7 +456,7 @@ async function listSftp(event, payload) {
permissions,
};
}));
return results;
}
@@ -471,30 +466,18 @@ async function listSftp(event, payload) {
async function readSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const buffer = await client.get(payload.path);
return buffer.toString();
}
/**
* Read file as binary (returns ArrayBuffer for binary files like images)
*/
async function readSftpBinary(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const buffer = await client.get(payload.path);
// Convert Node.js Buffer to ArrayBuffer
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
}
/**
* Write file content
*/
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;
}
@@ -505,14 +488,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() {
@@ -521,7 +504,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;
@@ -530,7 +513,7 @@ async function writeSftpBinaryWithProgress(event, payload) {
lastProgressTime = now;
lastTransferredBytes = transferredBytes;
}
const contents = electronModule.webContents.fromId(event.sender.id);
contents?.send("netcatty:upload:progress", {
transferId,
@@ -538,20 +521,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);
@@ -567,21 +550,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) {
@@ -599,7 +582,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;
}
@@ -610,7 +593,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);
@@ -626,7 +609,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;
}
@@ -637,7 +620,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),
@@ -654,7 +637,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;
}
@@ -666,7 +649,6 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:sftp:open", openSftp);
ipcMain.handle("netcatty:sftp:list", listSftp);
ipcMain.handle("netcatty:sftp:read", readSftp);
ipcMain.handle("netcatty:sftp:readBinary", readSftpBinary);
ipcMain.handle("netcatty:sftp:write", writeSftp);
ipcMain.handle("netcatty:sftp:writeBinaryWithProgress", writeSftpBinaryWithProgress);
ipcMain.handle("netcatty:sftp:close", closeSftp);
@@ -691,7 +673,6 @@ module.exports = {
openSftp,
listSftp,
readSftp,
readSftpBinary,
writeSftp,
writeSftpBinaryWithProgress,
closeSftp,

View File

@@ -8,14 +8,12 @@ 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");
const log = (msg, data) => {
const line = `[${new Date().toISOString()}] ${msg} ${data ? JSON.stringify(data) : ""}\n`;
try { fs.appendFileSync(logFile, line); } catch { }
try { fs.appendFileSync(logFile, line); } catch {}
console.log("[SSH]", msg, data || "");
};
@@ -51,6 +49,122 @@ 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
*/
@@ -58,27 +172,27 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
const sender = event.sender;
const connections = [];
let currentSocket = null;
const sendProgress = (hop, total, label, status) => {
if (!sender.isDestroyed()) {
sender.send("netcatty:chain:progress", { hop, total, label, status });
}
};
try {
const totalHops = jumpHosts.length;
// Connect through each jump host
for (let i = 0; i < jumpHosts.length; i++) {
const jump = jumpHosts[i];
const isFirst = i === 0;
const isLast = i === jumpHosts.length - 1;
const hopLabel = jump.label || `${jump.hostname}:${jump.port || 22}`;
sendProgress(i + 1, totalHops + 1, hopLabel, 'connecting');
const conn = new SSHClient();
// Build connection options
const connOpts = {
host: jump.hostname,
@@ -89,8 +203,6 @@ 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'],
@@ -99,7 +211,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
compress: ['none'],
},
};
// Auth - support agent (certificate), key, and password fallback
const hasCertificate =
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
@@ -129,7 +241,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
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);
@@ -142,7 +254,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
delete connOpts.host;
delete connOpts.port;
}
// Connect this hop
await new Promise((resolve, reject) => {
conn.on('ready', () => {
@@ -162,9 +274,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Connecting to ${hopLabel}...`);
conn.connect(connOpts);
});
connections.push(conn);
// Determine next target
let nextHost, nextPort;
if (isLast) {
@@ -177,7 +289,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
nextHost = nextJump.hostname;
nextPort = nextJump.port || 22;
}
// Create forward stream to next hop
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Forwarding from ${hopLabel} to ${nextHost}:${nextPort}...`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'forwarding');
@@ -193,17 +305,17 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
});
});
}
// Return the final forwarded stream and all connections for cleanup
return {
socket: currentSocket,
return {
socket: currentSocket,
connections,
sendProgress
sendProgress
};
} catch (err) {
// Cleanup on error
for (const conn of connections) {
try { conn.end(); } catch { }
try { conn.end(); } catch {}
}
throw err;
}
@@ -220,7 +332,7 @@ async function startSSHSession(event, options) {
const cols = options.cols || 80;
const rows = options.rows || 24;
const sender = event.sender;
const sendProgress = (hop, total, label, status) => {
if (!sender.isDestroyed()) {
sender.send("netcatty:chain:progress", { hop, total, label, status });
@@ -231,13 +343,13 @@ async function startSSHSession(event, options) {
const conn = new SSHClient();
let chainConnections = [];
let connectionSocket = null;
// Determine if we have jump hosts
const jumpHosts = options.jumpHosts || [];
const hasJumpHosts = jumpHosts.length > 0;
const hasProxy = !!options.proxy;
const totalHops = jumpHosts.length + 1; // +1 for final target
// Build base connection options for final target
const connectOpts = {
host: options.hostname,
@@ -249,8 +361,6 @@ 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'],
@@ -272,7 +382,7 @@ async function startSSHSession(event, options) {
hasPassword: !!options.password,
hasEffectivePassphrase: !!effectivePassphrase,
});
log("Auth configuration", {
hasCertificate,
keySource: options.keySource,
@@ -327,25 +437,25 @@ async function startSSHSession(event, options) {
// Handle chain/proxy connections
if (hasJumpHosts) {
const chainResult = await connectThroughChain(
event,
options,
jumpHosts,
options.hostname,
event,
options,
jumpHosts,
options.hostname,
options.port || 22
);
connectionSocket = chainResult.socket;
chainConnections = chainResult.connections;
connectOpts.sock = connectionSocket;
delete connectOpts.host;
delete connectOpts.port;
sendProgress(totalHops, totalHops, options.hostname, 'connecting');
} else if (hasProxy) {
sendProgress(1, 1, options.hostname, 'connecting');
connectionSocket = await createProxySocket(
options.proxy,
options.hostname,
options.proxy,
options.hostname,
options.port || 22
);
connectOpts.sock = connectionSocket;
@@ -360,7 +470,7 @@ async function startSSHSession(event, options) {
if (hasJumpHosts || hasProxy) {
sendProgress(totalHops, totalHops, options.hostname, 'connected');
}
conn.shell(
{
term: "xterm-256color",
@@ -368,7 +478,7 @@ async function startSSHSession(event, options) {
rows,
},
{
env: {
env: {
LANG: resolveLangFromCharset(options.charset),
COLORTERM: "truecolor",
...(options.env || {}),
@@ -378,7 +488,7 @@ async function startSSHSession(event, options) {
if (err) {
conn.end();
for (const c of chainConnections) {
try { c.end(); } catch { }
try { c.end(); } catch {}
}
reject(err);
return;
@@ -397,7 +507,7 @@ async function startSSHSession(event, options) {
let flushTimeout = null;
const FLUSH_INTERVAL = 8; // ms - flush every 8ms for ~120fps equivalent
const MAX_BUFFER_SIZE = 16384; // 16KB - flush immediately if buffer gets too large
const flushBuffer = () => {
if (dataBuffer.length > 0) {
const contents = event.sender;
@@ -406,7 +516,7 @@ async function startSSHSession(event, options) {
}
flushTimeout = null;
};
const bufferData = (data) => {
dataBuffer += data;
// Immediate flush for large chunks
@@ -441,7 +551,7 @@ async function startSSHSession(event, options) {
sessions.delete(sessionId);
conn.end();
for (const c of chainConnections) {
try { c.end(); } catch { }
try { c.end(); } catch {}
}
});
@@ -459,28 +569,28 @@ async function startSSHSession(event, options) {
conn.on("error", (err) => {
const contents = event.sender;
const isAuthError = err.message?.toLowerCase().includes('authentication') ||
err.message?.toLowerCase().includes('auth') ||
err.message?.toLowerCase().includes('password') ||
err.level === 'client-authentication';
err.message?.toLowerCase().includes('auth') ||
err.message?.toLowerCase().includes('password') ||
err.level === 'client-authentication';
// Use log instead of error for auth failures (normal fallback scenario)
if (isAuthError) {
console.log(`${logPrefix} ${options.hostname} auth failed:`, err.message);
safeSend(contents, "netcatty:auth:failed", {
sessionId,
safeSend(contents, "netcatty:auth:failed", {
sessionId,
error: err.message,
hostname: options.hostname
hostname: options.hostname
});
} else {
console.error(`${logPrefix} ${options.hostname} error:`, err.message);
}
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
sessions.delete(sessionId);
for (const c of chainConnections) {
try { c.end(); } catch { }
try { c.end(); } catch {}
}
reject(err);
});
@@ -492,7 +602,7 @@ async function startSSHSession(event, options) {
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
sessions.delete(sessionId);
for (const c of chainConnections) {
try { c.end(); } catch { }
try { c.end(); } catch {}
}
reject(err);
});
@@ -502,110 +612,10 @@ async function startSSHSession(event, options) {
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
sessions.delete(sessionId);
for (const c of chainConnections) {
try { c.end(); } catch { }
try { c.end(); } catch {}
}
});
// 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;
}
// Check if all prompts are password prompts that we can auto-answer
const responses = [];
const promptsNeedingUserInput = [];
for (let i = 0; i < prompts.length; i++) {
const prompt = prompts[i];
const promptText = (prompt.prompt || '').toLowerCase().trim();
// Auto-answer password prompts if we have a configured password
if (options.password && (
promptText.includes('password') ||
promptText === 'password:' ||
promptText === 'password'
)) {
console.log(`${logPrefix} Auto-answering password prompt at index ${i}`);
responses[i] = options.password;
} else {
// This prompt needs user input (likely 2FA)
promptsNeedingUserInput.push({ index: i, prompt: prompt });
responses[i] = null; // Placeholder
}
}
// If all prompts were auto-answered, finish immediately
if (promptsNeedingUserInput.length === 0) {
console.log(`${logPrefix} All prompts auto-answered, finishing keyboard-interactive`);
finish(responses);
return;
}
// If some prompts need user input, show the modal
// But only send the prompts that need user input
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
// Store finish callback with context about which responses are already filled
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
// Merge user responses with auto-filled responses
let userResponseIndex = 0;
for (let i = 0; i < prompts.length; i++) {
if (responses[i] === null) {
responses[i] = userResponses[userResponseIndex] || '';
userResponseIndex++;
}
}
console.log(`${logPrefix} Merged responses, finishing keyboard-interactive`);
finish(responses);
}, sender.id, sessionId);
// Send only the prompts that need user input
const promptsData = promptsNeedingUserInput.map((item) => ({
prompt: item.prompt.prompt,
echo: item.prompt.echo,
}));
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts that need user input`);
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
sessionId,
name: name || "",
instructions: instructions || "",
prompts: promptsData,
hostname: options.hostname,
});
});
// 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);
});
@@ -721,11 +731,11 @@ async function execCommand(event, payload) {
*/
async function generateKeyPair(event, options) {
const { type, bits, comment } = options;
try {
let keyType;
let keyBits = bits;
switch (type) {
case 'ED25519':
keyType = 'ed25519';
@@ -741,15 +751,15 @@ async function generateKeyPair(event, options) {
keyBits = bits || 4096;
break;
}
const result = sshUtils.generateKeyPairSync(keyType, {
bits: keyBits,
comment: comment || 'netcatty-generated-key',
});
const privateKey = result.private;
const publicKey = result.public;
return {
success: true,
privateKey,
@@ -773,9 +783,9 @@ async function startSSHSessionWrapper(event, options) {
return await startSSHSession(event, options);
} catch (err) {
const isAuthError = err.message?.toLowerCase().includes('authentication') ||
err.message?.toLowerCase().includes('auth') ||
err.level === 'client-authentication';
err.message?.toLowerCase().includes('auth') ||
err.level === 'client-authentication';
if (isAuthError) {
// Re-throw with a clean error to avoid Electron printing full stack trace
// The frontend will handle this as a normal auth failure for fallback
@@ -790,74 +800,50 @@ async function startSSHSessionWrapper(event, options) {
/**
* Get current working directory from an active SSH session
* This sends 'pwd' to the existing shell stream and captures the output
* using unique markers to identify the command output boundaries
* This sends 'pwd' to the shell and captures the output
*/
async function getSessionPwd(event, payload) {
const { sessionId } = payload;
const session = sessions.get(sessionId);
if (!session || !session.stream || !session.conn) {
return { success: false, error: 'Session not found or not connected' };
}
return new Promise((resolve) => {
const stream = session.stream;
const marker = `__PWD_${Date.now()}__`;
const conn = session.conn;
const timeout = setTimeout(() => {
stream.removeListener('data', onData);
resolve({ success: false, error: 'Timeout getting pwd' });
}, 3000);
let buffer = '';
const onData = (data) => {
const str = data.toString();
buffer += str;
// We need to find the ACTUAL output markers, not the command echo
// The command echo looks like: echo '__PWD_xxx__S' && pwd && echo '__PWD_xxx__E'
// The actual output looks like: __PWD_xxx__S\n/path/to/dir\n__PWD_xxx__E
//
// We look for the marker at the START of a line (after newline) to avoid the echo
const startMarkerRegex = new RegExp(`(?:^|[\\r\\n])${marker}S[\\r\\n]+`);
const endMarkerRegex = new RegExp(`[\\r\\n]${marker}E(?:[\\r\\n]|$)`);
const startMatch = buffer.match(startMarkerRegex);
const endMatch = buffer.match(endMarkerRegex);
if (startMatch && endMatch) {
const startIdx = buffer.indexOf(startMatch[0]) + startMatch[0].length;
const endIdx = buffer.indexOf(endMatch[0]);
if (startIdx <= endIdx) {
clearTimeout(timeout);
stream.removeListener('data', onData);
const pwdOutput = buffer.slice(startIdx, endIdx).trim();
console.log('[getSessionPwd] pwdOutput:', JSON.stringify(pwdOutput));
// The pwd output should be a valid absolute path
if (pwdOutput && pwdOutput.startsWith('/')) {
console.log('[getSessionPwd] Success, cwd:', pwdOutput);
resolve({ success: true, cwd: pwdOutput });
} else {
console.log('[getSessionPwd] Failed - invalid path:', pwdOutput);
resolve({ success: false, error: 'Invalid pwd output' });
}
}
// Use exec on the existing connection to run pwd
conn.exec('pwd', (err, stream) => {
if (err) {
clearTimeout(timeout);
resolve({ success: false, error: err.message });
return;
}
};
stream.on('data', onData);
// Send pwd command with short unique markers
// Using 'S' and 'E' as suffixes to make markers shorter
// After the command, send ANSI escape sequences to clear the output lines:
// \x1b[1A = move cursor up 1 line, \x1b[2K = clear entire line
// Clear 4 lines: the command echo, START marker, pwd output, and END marker
const clearLines = '\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K';
stream.write(` echo '${marker}S' && pwd && echo '${marker}E' && printf '${clearLines}'\n`);
let stdout = '';
stream.on('data', (data) => {
stdout += data.toString();
});
stream.on('close', () => {
clearTimeout(timeout);
const cwd = stdout.trim().split(/\r?\n/).pop()?.trim();
if (cwd && cwd.startsWith('/')) {
resolve({ success: true, cwd });
} else {
resolve({ success: false, error: 'Invalid pwd output' });
}
});
stream.on('error', (err) => {
clearTimeout(timeout);
resolve({ success: false, error: err.message });
});
});
});
}
@@ -869,8 +855,6 @@ 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,7 +9,6 @@ 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);
@@ -87,17 +86,6 @@ 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);
@@ -297,18 +285,6 @@ 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;
@@ -319,9 +295,6 @@ const api = {
readSftp: async (sftpId, path) => {
return ipcRenderer.invoke("netcatty:sftp:read", { sftpId, path });
},
readSftpBinary: async (sftpId, path) => {
return ipcRenderer.invoke("netcatty:sftp:readBinary", { sftpId, path });
},
writeSftp: async (sftpId, path, content) => {
return ipcRenderer.invoke("netcatty:sftp:write", { sftpId, path, content });
},

View File

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

17
global.d.ts vendored
View File

@@ -185,23 +185,6 @@ interface NetcattyBridge {
cb: (evt: { sessionId: string; error: string; hostname: string }) => void
): () => void;
// Keyboard-interactive authentication (2FA/MFA)
onKeyboardInteractive?(
cb: (request: {
requestId: string;
sessionId: string;
name: string;
instructions: string;
prompts: Array<{ prompt: string; echo: boolean }>;
hostname: string;
}) => void
): () => void;
respondKeyboardInteractive?(
requestId: string,
responses: string[],
cancelled?: boolean
): Promise<{ success: boolean; error?: string }>;
// SFTP operations
openSftp(options: NetcattySSHOptions): Promise<string>;
listSftp(sftpId: string, path: string): Promise<RemoteFile[]>;

View File

@@ -41,7 +41,6 @@ export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associatio
// SFTP Settings
export const STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR = 'netcatty_sftp_double_click_behavior_v1';
export const STORAGE_KEY_SFTP_AUTO_SYNC = 'netcatty_sftp_auto_sync_v1';
export const STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES = 'netcatty_sftp_show_hidden_files_v1';
// Archived legacy key records that are no longer supported by the app (e.g. biometric/WebAuthn/FIDO2 experiments).
export const STORAGE_KEY_LEGACY_KEYS = 'netcatty_legacy_keys_v1';

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,167 +419,8 @@ 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,15 +1,6 @@
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");
}

2168
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,11 +15,11 @@
"build": "vite build",
"preview": "vite preview",
"start": "node electron/launch.cjs",
"pack": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --publish=never",
"pack:dir": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --dir --publish=never",
"pack:win": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --win --publish=never",
"pack:mac": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --mac --publish=never",
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
"pack": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --publish=never",
"pack:dir": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --dir --publish=never",
"pack:win": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --win --publish=never",
"pack:mac": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --mac --publish=never",
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --linux --publish=never",
"postinstall": "electron-builder install-app-deps",
"rebuild": "electron-builder install-app-deps",
"lint": "eslint .",
@@ -77,4 +77,4 @@
"vite": "^7.2.7",
"wait-on": "^9.0.3"
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 727 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 995 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 871 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 814 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 832 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 897 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 977 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 906 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 817 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 884 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 776 KiB

View File

@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Fix Quarantine</string>
<key>CFBundleExecutable</key>
<string>FixQuarantine</string>
<key>CFBundleIdentifier</key>
<string>com.netcatty.fixquarantine</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Fix Quarantine</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<key>CFBundleIconFile</key>
<string>FixQuarantine.icns</string>
<key>LSMinimumSystemVersion</key>
<string>10.13</string>
</dict>
</plist>

View File

@@ -1,17 +0,0 @@
#!/bin/bash
set -e
APP_PATH="/Applications/Netcatty.app"
if [ ! -d "$APP_PATH" ]; then
/usr/bin/osascript <<'EOF'
display alert "Netcatty.app not found" message "Drag Netcatty.app into /Applications, then run this tool again." as critical buttons {"OK"} default button "OK"
EOF
exit 1
fi
/usr/bin/osascript <<'EOF'
do shell script "xattr -dr com.apple.quarantine /Applications/Netcatty.app" with administrator privileges
EOF
open "$APP_PATH"

View File

@@ -1,10 +0,0 @@
# 1) 准备一张 1024x1024 PNG例如放在 public/dmg-fix-icon.png
# 2) 生成 iconset 并转 icns
ICONSET="scripts/fixquarantine.iconset"
mkdir -p "$ICONSET"
for size in 16 32 128 256 512; do
sips -z "$size" "$size" public/dmg-fix-icon.png --out "$ICONSET/icon_${size}x${size}.png" >/dev/null
sips -z "$((size*2))" "$((size*2))" public/dmg-fix-icon.png --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null
done
iconutil -c icns "$ICONSET" -o scripts/FixQuarantine.app/Contents/Resources/FixQuarantine.icns
rm -rf $ICONSET

162
to-do.md Normal file
View File

@@ -0,0 +1,162 @@
# Netcatty Feature TODO List
项目地址: https://github.com/binaricat/Netcatty
## 功能需求清单
### 1. GB18030编码支持 🔤
**优先级**: 高
**需求描述**:
- 支持操作文件名为GB18030编码的文件
- 实现动态编码切换,无需断开重连即可生效
- 解决目前市面上工具需要重新连接才能应用编码设置的问题
**技术要点**:
- SFTP文件列表的编码转换
- 文件名编码自动检测/手动切换
- 保持连接状态下的编码切换
---
### 2. SFTP的sudo提权支持 🔐
**优先级**: 高
**需求描述**:
- 普通用户通过SFTP操作文件时支持sudo提权
- 两种可选实现方式:
- **方式A (WinSCP式)**: 要求服务器端配置sudo免密码
- **方式B (HexHub式)**: 使用保存的密码自动完成sudo鉴权 ⭐ 推荐
**技术要点**:
- 研究HexHub的实现原理
- 密码安全存储
- sudo命令的SFTP封装
- 权限提升的UI交互设计
---
### 3. trzsz协议支持 📁
**优先级**: 中
**需求描述**:
- 集成trzsz文件传输协议
- 参考项目: https://github.com/trzsz/trzsz
- 解决electerm和tabby现有实现中的稳定性问题
**已知问题**:
- electerm和tabby支持trzsz但偶尔无法正常收发文件
- 具体bug现象待补充
**技术要点**:
- trzsz协议完整实现
- 文件传输的错误处理和重试机制
- 传输进度显示
- 大文件传输稳定性测试
---
### 4. 终端性能优化 ⚡
**优先级**: 高
**需求描述**:
- 解决基于xtermjs的终端在大量滚屏时的性能问题
- 确保高速输出场景下键盘输入的实时响应
**核心问题**:
- 大量刷屏时`Ctrl+C`信号发不出去
- tmux切换窗口命令无响应
- 输入延迟严重
**技术要点**:
- 终端渲染性能优化
- 输入处理与渲染分离
- 虚拟滚动/缓冲区管理
- 输入队列优先级处理
- 压力测试场景设计
---
### 5. X11 Forwarding支持 🖥️
**优先级**: 中
**需求描述**:
- 支持X11图形界面转发
- 能够在SSH连接中运行远程图形应用程序
**技术要点**:
- X11转发的SSH配置
- 本地X Server集成或推荐
- 跨平台兼容性Windows/macOS/Linux
- 连接配置UI
---
### 6. Terminal到SFTP目录定位 🎯
**优先级**: 中
**需求描述**:
- 在Terminal界面时点击右上角按钮
- 自动切换到SFTP视图并定位到当前工作目录
- 实现Terminal和SFTP之间的上下文联动
**已知问题**:
- 之前尝试实现但未成功
**技术要点**:
- 获取当前shell的工作目录`pwd`命令)
- Terminal和SFTP视图的状态同步
- 异步目录切换的UI反馈
- 处理特殊路径(软链接、权限不足等)
**实现思路**:
1. 通过发送`pwd`命令获取当前目录
2. 解析命令输出结果
3. 触发SFTP视图切换
4. 异步加载目标目录内容
---
## 开发注意事项 ⚠️
### 质量要求
- 充分的单元测试和集成测试
- 避免"按下葫芦起了瓢"的问题
- 每个功能都要有完整的测试用例
### 性能考虑
- 避免频繁的AI token消耗
- 代码review和人工测试相结合
- 建立性能基准测试
### 用户体验
- 这些都是"可以没有但有了方便很多"的功能
- 注重细节和边界情况处理
- 提供清晰的错误提示和操作引导
---
## 实现优先级建议
### Phase 1 - 核心功能完善
- [ ] GB18030编码支持
- [ ] 终端性能优化
- [ ] Terminal到SFTP目录定位
### Phase 2 - 高级特性
- [ ] SFTP的sudo提权支持
- [ ] trzsz协议支持
### Phase 3 - 扩展功能
- [ ] X11 Forwarding支持
---
## 参考资料
- trzsz项目: https://github.com/trzsz/trzsz
- 竞品分析: WinSCP, HexHub, electerm, tabby
- 技术栈: xtermjs (需要性能优化方案)
---
**最后更新**: 2026-01-09

View File

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