Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5918f91132 | ||
|
|
7347b04461 | ||
|
|
d8990dd4b1 | ||
|
|
538dd71084 | ||
|
|
c43f485bee | ||
|
|
839cce58ac | ||
|
|
1324bf95cb | ||
|
|
c668525d17 | ||
|
|
a21970a278 | ||
|
|
c07fd505d3 | ||
|
|
3bb47243ce | ||
|
|
d2483c5863 | ||
|
|
e2f7788c13 | ||
|
|
2e417e1dd5 | ||
|
|
b233e9609f | ||
|
|
f754378bea | ||
|
|
72e79bdc9a | ||
|
|
5d25bda560 | ||
|
|
5baff1ee63 | ||
|
|
1d14f1b0ba | ||
|
|
3f2c3e15d6 | ||
|
|
395361b559 | ||
|
|
918d58862e | ||
|
|
fea1ebf274 | ||
|
|
a56ade35a3 | ||
|
|
1b0cb918d8 | ||
|
|
869d30d4dd | ||
|
|
87388b93d9 | ||
|
|
15a269e5d4 | ||
|
|
cf6b33a3eb | ||
|
|
dfa9b109c2 | ||
|
|
55b55d77c9 | ||
|
|
4dccc11041 | ||
|
|
188e6c860a | ||
|
|
f454c56192 | ||
|
|
4480e5dc8d | ||
|
|
8426da1596 | ||
|
|
c472eaada2 | ||
|
|
71433252a1 | ||
|
|
ca42787808 | ||
|
|
c13c330747 | ||
|
|
a27b99cbf7 | ||
|
|
3d6e981758 | ||
|
|
e6d8c1381c | ||
|
|
bc3d73c683 | ||
|
|
dd5f3ddffd | ||
|
|
3959328e24 | ||
|
|
48928254fa | ||
|
|
30962c992f | ||
|
|
02e0fae051 | ||
|
|
6a94716880 |
63
App.tsx
@@ -19,6 +19,7 @@ import { Input } from './components/ui/input';
|
||||
import { Label } from './components/ui/label';
|
||||
import { ToastProvider, toast } from './components/ui/toast';
|
||||
import { VaultView, VaultSection } from './components/VaultView';
|
||||
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
|
||||
import { cn } from './lib/utils';
|
||||
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
|
||||
import { LogView as LogViewType } from './application/state/useSessionState';
|
||||
@@ -150,6 +151,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
|
||||
// Navigation state for VaultView sections
|
||||
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
|
||||
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
|
||||
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
|
||||
|
||||
const {
|
||||
theme,
|
||||
@@ -291,6 +294,49 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
keys: keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
|
||||
});
|
||||
|
||||
// Keyboard-interactive authentication (2FA/MFA) event listener
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onKeyboardInteractive) return;
|
||||
|
||||
const unsubscribe = bridge.onKeyboardInteractive((request) => {
|
||||
console.log('[App] Keyboard-interactive request received:', request);
|
||||
// Add to queue instead of replacing - supports multiple concurrent sessions
|
||||
setKeyboardInteractiveQueue(prev => [...prev, {
|
||||
requestId: request.requestId,
|
||||
name: request.name,
|
||||
instructions: request.instructions,
|
||||
prompts: request.prompts,
|
||||
hostname: request.hostname,
|
||||
savedPassword: request.savedPassword,
|
||||
}]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle keyboard-interactive submit
|
||||
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondKeyboardInteractive) {
|
||||
void bridge.respondKeyboardInteractive(requestId, responses, false);
|
||||
}
|
||||
// Remove from queue by requestId
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Handle keyboard-interactive cancel
|
||||
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondKeyboardInteractive) {
|
||||
void bridge.respondKeyboardInteractive(requestId, [], true);
|
||||
}
|
||||
// Remove from queue by requestId
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Debounce ref for moveFocus to prevent double-triggering when focus switches
|
||||
const lastMoveFocusTimeRef = useRef<number>(0);
|
||||
const MOVE_FOCUS_DEBOUNCE_MS = 200;
|
||||
@@ -619,7 +665,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host) => {
|
||||
const { username, hostname: localHost } = systemInfoRef.current;
|
||||
|
||||
|
||||
// Handle serial hosts separately
|
||||
if (host.protocol === 'serial') {
|
||||
const portName = host.hostname.split('/').pop() || host.hostname;
|
||||
@@ -637,7 +683,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
connectToHost(host);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host, keys, identities });
|
||||
addConnectionLog({
|
||||
@@ -989,6 +1035,19 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) - processes queue */}
|
||||
<KeyboardInteractiveModal
|
||||
request={keyboardInteractiveQueue[0] || null}
|
||||
onSubmit={handleKeyboardInteractiveSubmit}
|
||||
onCancel={handleKeyboardInteractiveCancel}
|
||||
/>
|
||||
{/* Indicator when more 2FA requests are pending */}
|
||||
{keyboardInteractiveQueue.length > 1 && (
|
||||
<div className="fixed bottom-4 right-4 z-50 bg-muted/90 backdrop-blur-sm text-sm px-3 py-1.5 rounded-full border shadow-sm">
|
||||
{keyboardInteractiveQueue.length - 1} more pending
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
<h1 align="center">Netcatty</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>モダンな SSH クライアント、SFTP ブラウザ & ターミナルマネージャー</strong>
|
||||
<strong>モダンな SSH クライアント、SFTP ブラウザ & ターミナルマネージャー</strong><br/>
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -39,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/main-window-dark.png)
|
||||
[](screenshots/vault_grid_view.png)
|
||||
|
||||
---
|
||||
|
||||
@@ -138,15 +139,15 @@ Vault ビューはすべての SSH 接続を管理するコマンドセンター
|
||||
|
||||
**ダークモード**
|
||||
|
||||

|
||||

|
||||
|
||||
**ライトモード**
|
||||
**ネストされたフォルダと整理**
|
||||
|
||||

|
||||

|
||||
|
||||
**リストビュー**
|
||||
|
||||

|
||||

|
||||
|
||||
<a name="ターミナル"></a>
|
||||
## ターミナル
|
||||
@@ -155,18 +156,28 @@ WebGL アクセラレーション対応の xterm.js ベースのターミナル
|
||||
|
||||
**分割ウィンドウ**
|
||||
|
||||

|
||||
**ブロードキャストモード**
|
||||
|
||||
**テーマカスタマイズ**
|
||||
一度入力すれば、どこでも実行できます。複数のサーバーを同時にメンテナンスするのに最適です。
|
||||
|
||||

|
||||

|
||||
|
||||
**パフォーマンス情報とカスタマイズ**
|
||||
|
||||
接続の健全性を監視し、ターミナルのあらゆる側面をカスタマイズします。
|
||||
|
||||

|
||||
|
||||
<a name="sftp"></a>
|
||||
## SFTP
|
||||
|
||||
デュアルペイン SFTP ブラウザは、ローカルからリモート、リモートからリモートへのファイル転送をサポート。シングルクリックでディレクトリを移動、ペイン間でファイルをドラッグ&ドロップ、転送進捗をリアルタイムで監視。インターフェースにはファイル権限、サイズ、変更日時を表示。複数の転送をキューに入れ、詳細な速度と進捗インジケーターで完了を確認。コンテキストメニューから名前変更、削除、ダウンロード、アップロード操作にすばやくアクセス。
|
||||
|
||||

|
||||

|
||||
|
||||
**転送キュー**
|
||||
|
||||

|
||||
|
||||
<a name="キーチェーン"></a>
|
||||
## キーチェーン
|
||||
@@ -188,6 +199,10 @@ WebGL アクセラレーション対応の xterm.js ベースのターミナル
|
||||
|
||||

|
||||
|
||||
**キー生成**
|
||||
|
||||

|
||||
|
||||
<a name="ポートフォワーディング"></a>
|
||||
## ポートフォワーディング
|
||||
|
||||
@@ -365,6 +380,17 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
---
|
||||
|
||||
<a name="コントリビューター"></a>
|
||||
# コントリビューター
|
||||
|
||||
貢献してくれたすべての人々に感謝します!
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<a name="ライセンス"></a>
|
||||
# ライセンス
|
||||
|
||||
|
||||
46
README.md
@@ -5,7 +5,8 @@
|
||||
<h1 align="center">Netcatty</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Modern SSH Client, SFTP Browser & Terminal Manager</strong>
|
||||
<strong>Modern SSH Client, SFTP Browser & Terminal Manager</strong><br/>
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -39,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/main-window-dark.png)
|
||||
[](screenshots/vault_grid_view.png)
|
||||
|
||||
---
|
||||
|
||||
@@ -138,15 +139,15 @@ The Vault view is your command center for managing all SSH connections. Create h
|
||||
|
||||
**Dark Mode**
|
||||
|
||||

|
||||

|
||||
|
||||
**Light Mode**
|
||||
**Nested Folders & Organization**
|
||||
|
||||

|
||||

|
||||
|
||||
**List View**
|
||||
|
||||

|
||||

|
||||
|
||||
<a name="terminal"></a>
|
||||
## Terminal
|
||||
@@ -155,18 +156,28 @@ Powered by xterm.js with WebGL acceleration, the terminal delivers a smooth, res
|
||||
|
||||
**Split Windows**
|
||||
|
||||

|
||||
**Broadcast Mode**
|
||||
|
||||
**Theme Customization**
|
||||
Type once, execute everywhere. Great for maintaining multiple servers simultaneously.
|
||||
|
||||

|
||||

|
||||
|
||||
**Performance Info & Customization**
|
||||
|
||||
Monitor your connection health and customize every aspect of your terminal.
|
||||
|
||||

|
||||
|
||||
<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.
|
||||
|
||||

|
||||

|
||||
|
||||
**Transfer Queue**
|
||||
|
||||

|
||||
|
||||
<a name="keychain"></a>
|
||||
## Keychain
|
||||
@@ -188,6 +199,10 @@ The Keychain is your secure vault for SSH credentials. Generate new keys, import
|
||||
|
||||

|
||||
|
||||
**Key Generator**
|
||||
|
||||

|
||||
|
||||
<a name="port-forwarding"></a>
|
||||
## Port Forwarding
|
||||
|
||||
@@ -365,6 +380,17 @@ See [agents.md](agents.md) for architecture overview and coding conventions.
|
||||
|
||||
---
|
||||
|
||||
<a name="contributors"></a>
|
||||
# Contributors
|
||||
|
||||
Thanks to all the people who contribute!
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<a name="license"></a>
|
||||
# License
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
<h1 align="center">Netcatty</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>现代化 SSH 客户端、SFTP 浏览器 & 终端管理器</strong>
|
||||
<strong>现代化 SSH 客户端、SFTP 浏览器 & 终端管理器</strong><br/>
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -39,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/main-window-dark.png)
|
||||
[](screenshots/vault_grid_view.png)
|
||||
|
||||
---
|
||||
|
||||
@@ -138,15 +139,15 @@ Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建
|
||||
|
||||
**深色模式**
|
||||
|
||||

|
||||

|
||||
|
||||
**浅色模式**
|
||||
**层级文件夹与分组**
|
||||
|
||||

|
||||

|
||||
|
||||
**列表视图**
|
||||
|
||||

|
||||

|
||||
|
||||
<a name="终端"></a>
|
||||
## 终端
|
||||
@@ -155,18 +156,28 @@ Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建
|
||||
|
||||
**分屏窗口**
|
||||
|
||||

|
||||
**广播模式**
|
||||
|
||||
**主题定制**
|
||||
一次输入,多处执行。非常适合同时维护这多台服务器。
|
||||
|
||||

|
||||

|
||||
|
||||
**性能信息与定制**
|
||||
|
||||
监控连接健康状况,并自定义终端的方方面面。
|
||||
|
||||

|
||||
|
||||
<a name="sftp"></a>
|
||||
## SFTP
|
||||
|
||||
双窗格 SFTP 浏览器支持本地到远程和远程到远程的文件传输。单击导航目录,在窗格之间拖放文件,实时监控传输进度。界面显示文件权限、大小和修改日期。批量传输队列管理,详细的速度和进度指示器。右键菜单快速访问重命名、删除、下载和上传操作。
|
||||
|
||||

|
||||

|
||||
|
||||
**传输队列**
|
||||
|
||||

|
||||
|
||||
<a name="密钥管理"></a>
|
||||
## 密钥管理
|
||||
@@ -188,6 +199,10 @@ Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建
|
||||
|
||||

|
||||
|
||||
**密钥生成器**
|
||||
|
||||

|
||||
|
||||
<a name="端口转发"></a>
|
||||
## 端口转发
|
||||
|
||||
@@ -365,6 +380,17 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
---
|
||||
|
||||
<a name="贡献者"></a>
|
||||
# 贡献者
|
||||
|
||||
感谢所有参与贡献的人!
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<a name="开源协议"></a>
|
||||
# 开源协议
|
||||
|
||||
|
||||
@@ -399,6 +399,7 @@ const en: Messages = {
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': 'New Folder',
|
||||
'sftp.newFile': 'New File',
|
||||
'sftp.filter': 'Filter',
|
||||
'sftp.filter.placeholder': 'Filter by filename...',
|
||||
'sftp.columns.name': 'Name',
|
||||
@@ -433,6 +434,8 @@ const en: Messages = {
|
||||
'sftp.goHome': 'Go to home',
|
||||
'sftp.folderName': 'Folder name',
|
||||
'sftp.folderName.placeholder': 'Enter folder name',
|
||||
'sftp.fileName': 'File name',
|
||||
'sftp.fileName.placeholder': 'Enter file name',
|
||||
'sftp.prompt.newFolderName': 'New folder name?',
|
||||
'sftp.rename.title': 'Rename',
|
||||
'sftp.rename.newName': 'New name',
|
||||
@@ -445,6 +448,12 @@ const en: Messages = {
|
||||
'sftp.error.uploadFailed': 'Upload failed',
|
||||
'sftp.error.deleteFailed': 'Delete failed',
|
||||
'sftp.error.createFolderFailed': 'Failed to create folder',
|
||||
'sftp.error.createFileFailed': 'Failed to create file',
|
||||
'sftp.error.invalidFileName': 'Filename contains invalid characters: {chars}',
|
||||
'sftp.error.reservedName': 'This filename is reserved by the system',
|
||||
'sftp.overwrite.title': 'File Already Exists',
|
||||
'sftp.overwrite.desc': 'A file named "{name}" already exists. Do you want to replace it?',
|
||||
'sftp.overwrite.confirm': 'Replace',
|
||||
'sftp.error.renameFailed': 'Failed to rename',
|
||||
'sftp.picker.title': 'Select Host',
|
||||
'sftp.picker.desc': 'Pick a host for the {side} pane',
|
||||
@@ -542,6 +551,21 @@ const en: Messages = {
|
||||
'sftp.autoSync.success': 'File synced to remote: {fileName}',
|
||||
'sftp.autoSync.error': 'Failed to sync file: {error}',
|
||||
|
||||
// SFTP Folder Upload Progress
|
||||
'sftp.upload.progress': 'Uploading {current} of {total} files...',
|
||||
'sftp.upload.currentFile': 'Current: {fileName}',
|
||||
'sftp.upload.cancelled': 'Upload cancelled',
|
||||
'sftp.upload.cancel': 'Cancel',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': 'Reconnecting...',
|
||||
'sftp.reconnecting.desc': 'Connection lost, attempting to reconnect',
|
||||
'sftp.reconnected': 'Connection restored',
|
||||
'sftp.error.reconnectFailed': 'Failed to reconnect. Please try again.',
|
||||
'sftp.error.connectionLostManual': 'Connection lost. Please reconnect manually.',
|
||||
'sftp.error.connectionLostReconnecting': 'Connection lost. Reconnecting...',
|
||||
'sftp.error.sessionLost': 'SFTP session lost. Please reconnect.',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': 'Show hidden files',
|
||||
'settings.sftp.showHiddenFiles.desc': 'Display files with the Windows hidden attribute in the SFTP file browser when browsing local Windows filesystem.',
|
||||
@@ -586,6 +610,10 @@ const en: Messages = {
|
||||
'hostDetails.section.address': 'Address',
|
||||
'hostDetails.hostname.placeholder': 'IP or Hostname',
|
||||
'hostDetails.section.general': 'General',
|
||||
'hostDetails.section.sftp': 'SFTP Settings',
|
||||
'hostDetails.sftp.sudo': 'Sudo Mode',
|
||||
'hostDetails.sftp.sudo.desc': 'Automatically acquire Root privileges using stored password',
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'Sudo mode requires a password. Configure one above, or ensure the server allows passwordless sudo.',
|
||||
'hostDetails.label.placeholder': 'Label (e.g., Production Server)',
|
||||
'hostDetails.group.placeholder': 'Parent Group',
|
||||
'hostDetails.section.credentials': 'Credentials',
|
||||
@@ -604,16 +632,17 @@ const en: Messages = {
|
||||
'hostDetails.keys.empty': 'No keys available',
|
||||
'hostDetails.certs.search': 'Search certificates...',
|
||||
'hostDetails.certs.empty': 'No certificates available',
|
||||
'hostDetails.agentForwarding': 'Agent Forwarding',
|
||||
'hostDetails.jumpHosts': 'Jump Hosts',
|
||||
'hostDetails.agentForwarding': 'Forward SSH Agent',
|
||||
'hostDetails.agentForwarding.desc': 'Allow remote server to use your local SSH keys (e.g., for git operations)',
|
||||
'hostDetails.jumpHosts': 'Proxy via Hosts',
|
||||
'hostDetails.jumpHosts.hops': '{count} hop(s)',
|
||||
'hostDetails.jumpHosts.direct': 'Direct',
|
||||
'hostDetails.jumpHosts.configure': 'Configure Jump Hosts',
|
||||
'hostDetails.proxy': 'Proxy',
|
||||
'hostDetails.jumpHosts.configure': 'Configure Proxy Hosts',
|
||||
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5',
|
||||
'hostDetails.proxy.none': 'None',
|
||||
'hostDetails.proxy.edit': 'Edit Proxy',
|
||||
'hostDetails.proxy.configure': 'Configure Proxy',
|
||||
'hostDetails.proxyPanel.title': 'Proxy',
|
||||
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': 'Proxy host',
|
||||
'hostDetails.proxyPanel.credentials': 'Credentials',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
|
||||
@@ -1104,6 +1133,20 @@ const en: Messages = {
|
||||
'serial.field.configLabelPlaceholder': 'e.g. Arduino Uno',
|
||||
'serial.connectAndSave': 'Connect & Save',
|
||||
'serial.edit.title': 'Serial Port Settings',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': 'Authentication Required',
|
||||
'keyboard.interactive.desc': 'The server requires additional authentication.',
|
||||
'keyboard.interactive.descWithHost': 'The server {hostname} requires additional authentication.',
|
||||
'keyboard.interactive.response': 'Response',
|
||||
'keyboard.interactive.enterCode': 'Enter verification code',
|
||||
'keyboard.interactive.enterResponse': 'Enter response',
|
||||
'keyboard.interactive.submit': 'Submit',
|
||||
'keyboard.interactive.verifying': 'Verifying...',
|
||||
'keyboard.interactive.fill': 'Fill',
|
||||
'keyboard.interactive.fillSaved': 'Fill with saved password',
|
||||
'keyboard.interactive.useSaved': 'Use saved',
|
||||
'keyboard.interactive.useSavedPassword': 'Use saved password',
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -264,6 +264,7 @@ const zhCN: Messages = {
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': '新建文件夹',
|
||||
'sftp.newFile': '新建文件',
|
||||
'sftp.filter': '筛选',
|
||||
'sftp.filter.placeholder': '按文件名筛选...',
|
||||
'sftp.columns.name': '名称',
|
||||
@@ -298,6 +299,8 @@ const zhCN: Messages = {
|
||||
'sftp.goHome': '返回主目录',
|
||||
'sftp.folderName': '文件夹名称',
|
||||
'sftp.folderName.placeholder': '输入文件夹名称',
|
||||
'sftp.fileName': '文件名称',
|
||||
'sftp.fileName.placeholder': '输入文件名称',
|
||||
'sftp.prompt.newFolderName': '新建文件夹名称?',
|
||||
'sftp.rename.title': '重命名',
|
||||
'sftp.rename.newName': '新名称',
|
||||
@@ -310,6 +313,12 @@ const zhCN: Messages = {
|
||||
'sftp.error.uploadFailed': '上传失败',
|
||||
'sftp.error.deleteFailed': '删除失败',
|
||||
'sftp.error.createFolderFailed': '创建文件夹失败',
|
||||
'sftp.error.createFileFailed': '创建文件失败',
|
||||
'sftp.error.invalidFileName': '文件名包含非法字符:{chars}',
|
||||
'sftp.error.reservedName': '此文件名是系统保留名称',
|
||||
'sftp.overwrite.title': '文件已存在',
|
||||
'sftp.overwrite.desc': '名为"{name}"的文件已存在。是否要替换它?',
|
||||
'sftp.overwrite.confirm': '替换',
|
||||
'sftp.error.renameFailed': '重命名失败',
|
||||
'sftp.picker.title': '选择主机',
|
||||
'sftp.picker.desc': '为{side}窗格选择主机',
|
||||
@@ -361,6 +370,10 @@ const zhCN: Messages = {
|
||||
'hostDetails.section.address': '地址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主机名',
|
||||
'hostDetails.section.general': '通用',
|
||||
'hostDetails.section.sftp': 'SFTP 设置',
|
||||
'hostDetails.sftp.sudo': 'Sudo 提权模式',
|
||||
'hostDetails.sftp.sudo.desc': '使用保存的密码自动获取 Root 权限',
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'Sudo 模式需要密码。请在上方配置密码,或确保服务器允许免密 sudo。',
|
||||
'hostDetails.label.placeholder': '名称(例如:Production Server)',
|
||||
'hostDetails.group.placeholder': '父级 Group',
|
||||
'hostDetails.section.credentials': '凭据',
|
||||
@@ -379,12 +392,13 @@ const zhCN: Messages = {
|
||||
'hostDetails.keys.empty': '暂无密钥',
|
||||
'hostDetails.certs.search': '搜索证书…',
|
||||
'hostDetails.certs.empty': '暂无证书',
|
||||
'hostDetails.agentForwarding': '代理转发',
|
||||
'hostDetails.jumpHosts': '跳板主机',
|
||||
'hostDetails.agentForwarding': '转发 SSH 密钥',
|
||||
'hostDetails.agentForwarding.desc': '允许远程服务器使用本地 SSH 密钥(例如用于 git 操作)',
|
||||
'hostDetails.jumpHosts': '通过主机代理',
|
||||
'hostDetails.jumpHosts.hops': '{count} 跳',
|
||||
'hostDetails.jumpHosts.direct': '直连',
|
||||
'hostDetails.jumpHosts.configure': '配置跳板主机',
|
||||
'hostDetails.proxy': '代理',
|
||||
'hostDetails.jumpHosts.configure': '配置代理主机',
|
||||
'hostDetails.proxy': '通过 HTTP/SOCKS5 代理',
|
||||
'hostDetails.proxy.none': '无',
|
||||
'hostDetails.proxy.edit': '编辑代理',
|
||||
'hostDetails.proxy.configure': '配置代理',
|
||||
@@ -780,6 +794,21 @@ const zhCN: Messages = {
|
||||
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
|
||||
'sftp.autoSync.error': '同步文件失败:{error}',
|
||||
|
||||
// SFTP Folder Upload Progress
|
||||
'sftp.upload.progress': '正在上传 {current}/{total} 个文件...',
|
||||
'sftp.upload.currentFile': '当前: {fileName}',
|
||||
'sftp.upload.cancelled': '上传已取消',
|
||||
'sftp.upload.cancel': '取消',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': '正在重连...',
|
||||
'sftp.reconnecting.desc': '连接已断开,正在尝试重新连接',
|
||||
'sftp.reconnected': '连接已恢复',
|
||||
'sftp.error.reconnectFailed': '重连失败,请重试。',
|
||||
'sftp.error.connectionLostManual': '连接已断开,请手动重新连接。',
|
||||
'sftp.error.connectionLostReconnecting': '连接已断开,正在重连...',
|
||||
'sftp.error.sessionLost': 'SFTP 会话已断开,请重新连接。',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': '显示隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.desc': '在浏览本地 Windows 文件系统时,显示具有 Windows 隐藏属性的文件。',
|
||||
@@ -1093,6 +1122,20 @@ const zhCN: Messages = {
|
||||
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
|
||||
'serial.connectAndSave': '连接并保存',
|
||||
'serial.edit.title': '串口设置',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': '需要验证',
|
||||
'keyboard.interactive.desc': '服务器需要额外的身份验证。',
|
||||
'keyboard.interactive.descWithHost': '服务器 {hostname} 需要额外的身份验证。',
|
||||
'keyboard.interactive.response': '响应',
|
||||
'keyboard.interactive.enterCode': '输入验证码',
|
||||
'keyboard.interactive.enterResponse': '输入响应',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.verifying': '验证中...',
|
||||
'keyboard.interactive.fill': '填入',
|
||||
'keyboard.interactive.fillSaved': '填入已保存的密码',
|
||||
'keyboard.interactive.useSaved': '使用已保存',
|
||||
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "./ui/aside-panel";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { Card } from "./ui/card";
|
||||
import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
|
||||
import { Input } from "./ui/input";
|
||||
@@ -92,7 +93,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
protocol: "ssh",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
agentForwarding: false,
|
||||
authMethod: "password",
|
||||
charset: "UTF-8",
|
||||
theme: "Flexoki Dark",
|
||||
@@ -926,6 +926,31 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.sftp")}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium">
|
||||
{t("hostDetails.sftp.sudo")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.sudo.desc")}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.sftpSudo || false}
|
||||
onCheckedChange={(val) => update("sftpSudo", val)}
|
||||
/>
|
||||
</div>
|
||||
{form.sftpSudo && !form.password && !selectedIdentity?.password && (
|
||||
<p className="text-xs text-amber-500">
|
||||
{t("hostDetails.sftp.sudo.passwordWarning")}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.appearance")}
|
||||
@@ -1024,75 +1049,92 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Agent Forwarding */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<ToggleRow
|
||||
label={t("hostDetails.agentForwarding")}
|
||||
enabled={!!form.agentForwarding}
|
||||
onToggle={() => update("agentForwarding", !form.agentForwarding)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.agentForwarding.desc")}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* Host Chain Configuration - Only show when Agent Forwarding is enabled */}
|
||||
{form.agentForwarding && (
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.jumpHosts")}
|
||||
</p>
|
||||
</div>
|
||||
{chainedHosts.length > 0 ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{t("hostDetails.jumpHosts.direct")}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.jumpHosts")}
|
||||
</p>
|
||||
</div>
|
||||
{chainedHosts.length > 0 && (
|
||||
<button
|
||||
className="w-full flex items-center gap-1 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
{chainedHosts.length > 0 ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<Link2
|
||||
size={14}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<span className="text-sm truncate">
|
||||
{chainedHosts
|
||||
.slice(0, 3)
|
||||
.map((h) => h.hostname || h.label)
|
||||
.join(" -> ")}
|
||||
{chainedHosts.length > 3 && "..."}
|
||||
</span>
|
||||
{t("hostDetails.jumpHosts.direct")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{chainedHosts.length > 0 && (
|
||||
<button
|
||||
className="w-full flex flex-col items-start gap-1 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
>
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1">
|
||||
<Link2
|
||||
size={14}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
size={14}
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0 ml-auto"
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearHostChain();
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{chainedHosts.length === 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 justify-start gap-2 text-sm"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("hostDetails.jumpHosts.configure")}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full space-y-1 pl-5">
|
||||
{chainedHosts.slice(0, 5).map((h, idx) => (
|
||||
<div key={h.id} className="flex items-center gap-1 text-sm">
|
||||
<span className="text-muted-foreground">{idx + 1}.</span>
|
||||
<span className="truncate">
|
||||
{h.label !== h.hostname ? `${h.hostname} (${h.label})` : h.hostname}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{chainedHosts.length > 5 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
+{chainedHosts.length - 5} more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{chainedHosts.length === 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 justify-start gap-2 text-sm"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("hostDetails.jumpHosts.configure")}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Proxy Configuration */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Switch } from "./ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -257,6 +258,29 @@ const HostForm: React.FC<HostFormProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between space-x-2 border rounded-md p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="sftp-sudo" className="text-base">
|
||||
{t("hostDetails.sftp.sudo")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.sudo.desc")}
|
||||
</p>
|
||||
{formData.sftpSudo && authType === "key" && (
|
||||
<p className="text-xs text-amber-500 mt-1">
|
||||
{t("hostDetails.sftp.sudo.passwordWarning")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
id="sftp-sudo"
|
||||
checked={formData.sftpSudo || false}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, sftpSudo: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Label>{t("hostForm.auth.method")}</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
|
||||
200
components/KeyboardInteractiveModal.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Keyboard Interactive Authentication Modal
|
||||
* Global modal for handling SSH keyboard-interactive authentication (2FA/MFA)
|
||||
* This modal displays prompts from the SSH server and collects user responses.
|
||||
*/
|
||||
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
export interface KeyboardInteractivePrompt {
|
||||
prompt: string;
|
||||
echo: boolean;
|
||||
}
|
||||
|
||||
export interface KeyboardInteractiveRequest {
|
||||
requestId: string;
|
||||
name: string;
|
||||
instructions: string;
|
||||
prompts: KeyboardInteractivePrompt[];
|
||||
hostname?: string;
|
||||
savedPassword?: string | null;
|
||||
}
|
||||
|
||||
interface KeyboardInteractiveModalProps {
|
||||
request: KeyboardInteractiveRequest | null;
|
||||
onSubmit: (requestId: string, responses: string[]) => void;
|
||||
onCancel: (requestId: string) => void;
|
||||
}
|
||||
|
||||
export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> = ({
|
||||
request,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [responses, setResponses] = useState<string[]>([]);
|
||||
const [showPasswords, setShowPasswords] = useState<boolean[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Reset state when request changes
|
||||
useEffect(() => {
|
||||
if (request) {
|
||||
setResponses(request.prompts.map(() => ""));
|
||||
setShowPasswords(request.prompts.map(() => false));
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
const handleResponseChange = useCallback((index: number, value: string) => {
|
||||
setResponses((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index] = value;
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleShowPassword = useCallback((index: number) => {
|
||||
setShowPasswords((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index] = !updated[index];
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!request || isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
onSubmit(request.requestId, responses);
|
||||
}, [request, responses, onSubmit, isSubmitting]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (!request) return;
|
||||
onCancel(request.requestId);
|
||||
}, [request, onCancel]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !isSubmitting) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit, isSubmitting]
|
||||
);
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const title = request.name?.trim() || t("keyboard.interactive.title");
|
||||
const description =
|
||||
request.instructions?.trim() ||
|
||||
(request.hostname
|
||||
? t("keyboard.interactive.descWithHost", { hostname: request.hostname })
|
||||
: t("keyboard.interactive.desc"));
|
||||
|
||||
return (
|
||||
<Dialog open={!!request} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="sm:max-w-[425px]" hideCloseButton>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<KeyRound className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{request.prompts.map((prompt, index) => {
|
||||
const isPassword = !prompt.echo;
|
||||
const showPassword = showPasswords[index];
|
||||
// Clean up prompt text (remove trailing colon and whitespace)
|
||||
const promptLabel = prompt.prompt.replace(/:\s*$/, "").trim();
|
||||
|
||||
return (
|
||||
<div key={index} className="space-y-2">
|
||||
<Label htmlFor={`ki-prompt-${index}`}>
|
||||
{promptLabel || t("keyboard.interactive.response")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`ki-prompt-${index}`}
|
||||
type={isPassword && !showPassword ? "password" : "text"}
|
||||
value={responses[index] || ""}
|
||||
onChange={(e) => handleResponseChange(index, e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder=""
|
||||
className={isPassword ? "pr-10" : undefined}
|
||||
autoFocus={index === 0}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{isPassword && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
|
||||
onClick={() => toggleShowPassword(index)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Use saved password button - shown below input, right-aligned */}
|
||||
{isPassword && request.savedPassword && !responses[index] && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 disabled:opacity-50"
|
||||
onClick={() => handleResponseChange(index, request.savedPassword!)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<KeyRound size={12} />
|
||||
<span>{t("keyboard.interactive.useSavedPassword")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("keyboard.interactive.verifying")}
|
||||
</>
|
||||
) : (
|
||||
t("keyboard.interactive.submit")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyboardInteractiveModal;
|
||||
@@ -45,7 +45,7 @@ import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { logger } from "../lib/logger";
|
||||
import { getFileExtension, isKnownBinaryFile, FileOpenerType, SystemAppInfo } from "../lib/sftpFileUtils";
|
||||
import { getFileExtension, isKnownBinaryFile, FileOpenerType, SystemAppInfo, extractDropEntries } from "../lib/sftpFileUtils";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, RemoteFile } from "../types";
|
||||
import { filterHiddenFiles } from "./sftp";
|
||||
@@ -254,6 +254,7 @@ interface SFTPModalProps {
|
||||
keySource?: 'generated' | 'imported';
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sftpSudo?: boolean;
|
||||
};
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -325,6 +326,12 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
const lastSelectedIndexRef = useRef<number | null>(null);
|
||||
const localHomeRef = useRef<string | null>(null);
|
||||
|
||||
// Reconnect state
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
const reconnectingRef = useRef(false);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const MAX_RECONNECT_ATTEMPTS = 3;
|
||||
|
||||
// Rename dialog state
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<RemoteFile | null>(null);
|
||||
@@ -515,6 +522,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
keySource: credentials.keySource,
|
||||
proxy: credentials.proxy,
|
||||
jumpHosts: credentials.jumpHosts,
|
||||
sudo: credentials.sftpSudo,
|
||||
});
|
||||
sftpIdRef.current = sftpId;
|
||||
return sftpId;
|
||||
@@ -533,9 +541,44 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
credentials.keySource,
|
||||
credentials.proxy,
|
||||
credentials.jumpHosts,
|
||||
credentials.sftpSudo,
|
||||
openSftp,
|
||||
]);
|
||||
|
||||
// Check if an error indicates a stale/lost SFTP session
|
||||
const isSessionError = useCallback((err: unknown): boolean => {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("session not found") ||
|
||||
msg.includes("sftp session") ||
|
||||
msg.includes("not found") ||
|
||||
msg.includes("closed") ||
|
||||
msg.includes("connection reset") ||
|
||||
msg.includes("eof")
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Handle session error - triggers auto-reconnect
|
||||
const handleSessionError = useCallback(() => {
|
||||
if (reconnectingRef.current) return; // Prevent duplicate reconnect attempts
|
||||
|
||||
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
|
||||
toast.error(t("sftp.error.reconnectFailed"), "SFTP");
|
||||
setReconnecting(false);
|
||||
reconnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear stale session reference
|
||||
sftpIdRef.current = null;
|
||||
|
||||
// Set reconnecting state
|
||||
reconnectingRef.current = true;
|
||||
reconnectAttemptsRef.current++;
|
||||
setReconnecting(true);
|
||||
}, [t]);
|
||||
|
||||
const loadFiles = useCallback(
|
||||
async (path: string, options?: { force?: boolean }) => {
|
||||
const requestId = ++loadSeqRef.current;
|
||||
@@ -569,6 +612,14 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
setSelectedFiles(new Set());
|
||||
} catch (e) {
|
||||
if (loadSeqRef.current !== requestId) return;
|
||||
|
||||
// Check if this is a session error that can trigger auto-reconnect
|
||||
if (!isLocalSession && isSessionError(e) && files.length > 0) {
|
||||
logger.info("[SFTP] Session lost, attempting to reconnect...");
|
||||
handleSessionError();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error("Failed to load files", e);
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
|
||||
@@ -581,7 +632,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t],
|
||||
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, isSessionError, handleSessionError, files.length],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -615,6 +666,72 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
};
|
||||
}, [closeSftpSession]);
|
||||
|
||||
// Auto-reconnect effect
|
||||
useEffect(() => {
|
||||
if (!reconnecting || !reconnectingRef.current || isLocalSession) return;
|
||||
|
||||
const attemptReconnect = async () => {
|
||||
// Small delay before reconnecting
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
if (!reconnectingRef.current) return; // May have been cancelled
|
||||
|
||||
try {
|
||||
// Re-establish SFTP connection
|
||||
const sftpId = await openSftp({
|
||||
sessionId: `sftp-modal-${host.id}`,
|
||||
hostname: credentials.hostname,
|
||||
username: credentials.username || "root",
|
||||
port: credentials.port || 22,
|
||||
password: credentials.password,
|
||||
privateKey: credentials.privateKey,
|
||||
certificate: credentials.certificate,
|
||||
passphrase: credentials.passphrase,
|
||||
publicKey: credentials.publicKey,
|
||||
keyId: credentials.keyId,
|
||||
keySource: credentials.keySource,
|
||||
proxy: credentials.proxy,
|
||||
jumpHosts: credentials.jumpHosts,
|
||||
sudo: credentials.sftpSudo,
|
||||
});
|
||||
sftpIdRef.current = sftpId;
|
||||
|
||||
// Refresh current directory
|
||||
const list = await listSftp(sftpId, currentPath);
|
||||
dirCacheRef.current.set(`${host.id}::${currentPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setFiles(list);
|
||||
setSelectedFiles(new Set());
|
||||
|
||||
// Reconnect successful
|
||||
reconnectingRef.current = false;
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setReconnecting(false);
|
||||
toast.success(t("sftp.reconnected"), "SFTP");
|
||||
} catch (e) {
|
||||
logger.error("[SFTP] Reconnect failed", e);
|
||||
// Check if we can retry
|
||||
if (reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
|
||||
// Trigger another attempt
|
||||
sftpIdRef.current = null;
|
||||
reconnectingRef.current = false; // Reset to allow handleSessionError to work
|
||||
handleSessionError();
|
||||
} else {
|
||||
// Max retries reached
|
||||
reconnectingRef.current = false;
|
||||
setReconnecting(false);
|
||||
toast.error(t("sftp.error.reconnectFailed"), "SFTP");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
attemptReconnect();
|
||||
}, [reconnecting, isLocalSession, host.id, credentials, openSftp, listSftp, currentPath, t, handleSessionError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Check if we need to reinitialize (either first time or initialPath changed)
|
||||
@@ -758,8 +875,10 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
const handleUploadFile = async (
|
||||
file: File,
|
||||
taskId: string,
|
||||
relativePath?: string,
|
||||
): Promise<boolean> => {
|
||||
const startTime = Date.now();
|
||||
const displayName = relativePath || file.name;
|
||||
|
||||
// Update task to uploading with start time
|
||||
setUploadTasks((prev) =>
|
||||
@@ -778,7 +897,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
const fullPath = joinPath(currentPath, displayName);
|
||||
|
||||
if (isLocalSession) {
|
||||
await writeLocalFile(fullPath, arrayBuffer);
|
||||
@@ -936,6 +1055,95 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// Upload files/folders from drag-and-drop (supports folders via DataTransfer API)
|
||||
const handleUploadFromDrop = async (dataTransfer: DataTransfer) => {
|
||||
// Extract all entries (files and folders) using webkitGetAsEntry
|
||||
const entries = await extractDropEntries(dataTransfer);
|
||||
if (entries.length === 0) return;
|
||||
|
||||
// Track created directories to avoid duplicates
|
||||
const createdDirs = new Set<string>();
|
||||
|
||||
// Helper to ensure directory exists
|
||||
const ensureDirectory = async (dirPath: string) => {
|
||||
if (createdDirs.has(dirPath)) return;
|
||||
try {
|
||||
if (isLocalSession) {
|
||||
await mkdirLocal(dirPath);
|
||||
} else {
|
||||
const sftpId = await ensureSftp();
|
||||
await mkdirSftp(sftpId, dirPath);
|
||||
}
|
||||
createdDirs.add(dirPath);
|
||||
} catch {
|
||||
// Directory may already exist
|
||||
createdDirs.add(dirPath);
|
||||
}
|
||||
};
|
||||
|
||||
// Sort entries: directories first, then by path depth
|
||||
const sortedEntries = [...entries].sort((a, b) => {
|
||||
if (a.isDirectory && !b.isDirectory) return -1;
|
||||
if (!a.isDirectory && b.isDirectory) return 1;
|
||||
const aDepth = a.relativePath.split('/').length;
|
||||
const bDepth = b.relativePath.split('/').length;
|
||||
return aDepth - bDepth;
|
||||
});
|
||||
|
||||
// Separate files and directories
|
||||
const fileEntries = sortedEntries.filter(e => !e.isDirectory);
|
||||
|
||||
// Create tasks for files only (directories are created silently)
|
||||
const newTasks: UploadTask[] = fileEntries.map((entry) => ({
|
||||
id: crypto.randomUUID(),
|
||||
fileName: entry.relativePath,
|
||||
status: "pending" as const,
|
||||
progress: 0,
|
||||
totalBytes: entry.file.size,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: 0,
|
||||
}));
|
||||
|
||||
if (newTasks.length > 0) {
|
||||
setUploadTasks((prev) => [...prev, ...newTasks]);
|
||||
}
|
||||
setUploading(true);
|
||||
|
||||
// Process all entries
|
||||
let taskIndex = 0;
|
||||
for (const entry of sortedEntries) {
|
||||
const targetPath = joinPath(currentPath, entry.relativePath);
|
||||
|
||||
if (entry.isDirectory) {
|
||||
// Create directory
|
||||
await ensureDirectory(targetPath);
|
||||
} else if (entry.file) {
|
||||
// Ensure parent directories exist
|
||||
const pathParts = entry.relativePath.split('/');
|
||||
if (pathParts.length > 1) {
|
||||
let parentPath = currentPath;
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
parentPath = joinPath(parentPath, pathParts[i]);
|
||||
await ensureDirectory(parentPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload file
|
||||
await handleUploadFile(entry.file, newTasks[taskIndex].id, entry.relativePath);
|
||||
taskIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
setUploading(false);
|
||||
await loadFiles(currentPath, { force: true });
|
||||
|
||||
// Auto-clear completed tasks after 3 seconds
|
||||
setTimeout(() => {
|
||||
setUploadTasks((prev) => prev.filter((t) => t.status !== "completed"));
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleDelete = async (file: RemoteFile) => {
|
||||
if (!confirm(t("sftp.confirm.deleteOne", { name: file.name }))) return;
|
||||
try {
|
||||
@@ -974,6 +1182,32 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateFile = async () => {
|
||||
const fileName = prompt(t("sftp.fileName.placeholder"));
|
||||
if (!fileName) return;
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, fileName);
|
||||
if (isLocalSession) {
|
||||
// Write an empty file
|
||||
await writeLocalFile(fullPath, new ArrayBuffer(0));
|
||||
} else {
|
||||
// Write empty content to create the file using binary write for consistency
|
||||
try {
|
||||
await writeSftpBinary(await ensureSftp(), fullPath, new ArrayBuffer(0));
|
||||
} catch {
|
||||
// Fallback to text write if binary write is not available
|
||||
await writeSftp(await ensureSftp(), fullPath, "");
|
||||
}
|
||||
}
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.createFileFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Open rename dialog
|
||||
const openRenameDialog = useCallback((file: RemoteFile) => {
|
||||
setRenameTarget(file);
|
||||
@@ -1279,8 +1513,9 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
handleUploadMultiple(e.dataTransfer.files);
|
||||
// Use the new drop handler that supports folders
|
||||
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||
handleUploadFromDrop(e.dataTransfer);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1703,7 +1938,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
className="h-7 w-7"
|
||||
onClick={() => loadFiles(currentPath, { force: true })}
|
||||
>
|
||||
<RefreshCw size={14} className={cn(loading && "animate-spin")} />
|
||||
<RefreshCw size={14} className={cn((loading || reconnecting) && "animate-spin")} />
|
||||
</Button>
|
||||
|
||||
{/* Editable Breadcrumbs */}
|
||||
@@ -1794,6 +2029,14 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
>
|
||||
<Plus size={14} className="mr-1.5" /> {t("sftp.newFolder")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleCreateFile}
|
||||
>
|
||||
<Plus size={14} className="mr-1.5" /> {t("sftp.newFile")}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
@@ -1897,6 +2140,19 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reconnecting overlay */}
|
||||
{reconnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
|
||||
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium">{t("sftp.reconnecting.title")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{t("sftp.reconnecting.desc")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Folder size={48} className="mb-3 opacity-50" />
|
||||
@@ -2063,6 +2319,9 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
<ContextMenuItem onClick={handleCreateFolder}>
|
||||
<Plus className="h-4 w-4 mr-2" /> {t("sftp.newFolder")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleCreateFile}>
|
||||
<Plus className="h-4 w-4 mr-2" /> {t("sftp.newFile")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => inputRef.current?.click()}>
|
||||
<Upload className="h-4 w-4 mr-2" /> {t("sftp.uploadFiles")}
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
Download,
|
||||
Edit2,
|
||||
ExternalLink,
|
||||
FilePlus,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
HardDrive,
|
||||
@@ -179,6 +180,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
onClearSelection,
|
||||
onSetFilter,
|
||||
onCreateDirectory,
|
||||
onCreateFile,
|
||||
onDeleteFiles,
|
||||
onRenameFile,
|
||||
onCopyToOtherPane,
|
||||
@@ -208,12 +210,18 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
const [hostSearch, setHostSearch] = useState("");
|
||||
const [showNewFolderDialog, setShowNewFolderDialog] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [showNewFileDialog, setShowNewFileDialog] = useState(false);
|
||||
const [newFileName, setNewFileName] = useState("");
|
||||
const [fileNameError, setFileNameError] = useState<string | null>(null);
|
||||
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false);
|
||||
const [overwriteTarget, setOverwriteTarget] = useState<string | null>(null);
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<string | null>(null);
|
||||
const [renameName, setRenameName] = useState("");
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deleteTargets, setDeleteTargets] = useState<string[]>([]);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isCreatingFile, setIsCreatingFile] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
@@ -555,6 +563,53 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
}, 150);
|
||||
};
|
||||
|
||||
// Filename validation - constants defined inline to satisfy eslint
|
||||
const validateFileName = useCallback((name: string): string | null => {
|
||||
const INVALID_FILENAME_CHARS = /[/\\:*?"<>|]/;
|
||||
const RESERVED_NAMES = new Set([
|
||||
'CON', 'PRN', 'AUX', 'NUL',
|
||||
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
||||
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
|
||||
]);
|
||||
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// Check for invalid characters
|
||||
const invalidMatch = trimmed.match(INVALID_FILENAME_CHARS);
|
||||
if (invalidMatch) {
|
||||
return t('sftp.error.invalidFileName', { chars: invalidMatch[0] });
|
||||
}
|
||||
|
||||
// Check for reserved names (Windows)
|
||||
const baseName = trimmed.split('.')[0].toUpperCase();
|
||||
if (RESERVED_NAMES.has(baseName)) {
|
||||
return t('sftp.error.reservedName');
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [t]);
|
||||
|
||||
// Smart default filename generator
|
||||
const getNextUntitledName = useCallback((existingFiles: string[]): string => {
|
||||
const existingSet = new Set(existingFiles.map(f => f.toLowerCase()));
|
||||
|
||||
if (!existingSet.has('untitled.txt')) {
|
||||
return 'untitled.txt';
|
||||
}
|
||||
|
||||
let counter = 1;
|
||||
while (counter < 1000) {
|
||||
const name = `untitled (${counter}).txt`;
|
||||
if (!existingSet.has(name.toLowerCase())) {
|
||||
return name;
|
||||
}
|
||||
counter++;
|
||||
}
|
||||
|
||||
return `untitled_${Date.now()}.txt`;
|
||||
}, []);
|
||||
|
||||
// File operations
|
||||
const handleCreateFolder = async () => {
|
||||
if (!newFolderName.trim() || isCreating) return;
|
||||
@@ -570,6 +625,48 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateFile = async (forceOverwrite = false) => {
|
||||
const trimmedName = newFileName.trim();
|
||||
if (!trimmedName || isCreatingFile) return;
|
||||
|
||||
// Validate filename
|
||||
const error = validateFileName(trimmedName);
|
||||
if (error) {
|
||||
setFileNameError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if file exists (unless we're forcing overwrite)
|
||||
if (!forceOverwrite) {
|
||||
const existingFile = pane.files.find(
|
||||
f => f.name.toLowerCase() === trimmedName.toLowerCase() && f.type === 'file'
|
||||
);
|
||||
if (existingFile) {
|
||||
setOverwriteTarget(trimmedName);
|
||||
setShowOverwriteConfirm(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsCreatingFile(true);
|
||||
try {
|
||||
await onCreateFile(trimmedName);
|
||||
setShowNewFileDialog(false);
|
||||
setShowOverwriteConfirm(false);
|
||||
setOverwriteTarget(null);
|
||||
setNewFileName("");
|
||||
setFileNameError(null);
|
||||
} catch {
|
||||
/* Error handling */
|
||||
} finally {
|
||||
setIsCreatingFile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmOverwrite = async () => {
|
||||
await handleCreateFile(true);
|
||||
};
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!renameTarget || !renameName.trim() || isRenaming) return;
|
||||
setIsRenaming(true);
|
||||
@@ -634,21 +731,21 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
setIsDragOverPane(false);
|
||||
setDragOverEntry(null);
|
||||
|
||||
// Check if this is external file drop (from OS)
|
||||
const droppedFiles = e.dataTransfer.files;
|
||||
if (droppedFiles && droppedFiles.length > 0) {
|
||||
// Handle external file upload using the callback
|
||||
if (onUploadExternalFiles) {
|
||||
await onUploadExternalFiles(droppedFiles);
|
||||
// Check if this is an internal drag from another pane (draggedFiles is set by onDragStart)
|
||||
if (draggedFiles && draggedFiles.length > 0) {
|
||||
// Handle internal pane-to-pane transfer
|
||||
if (draggedFiles[0]?.side !== side) {
|
||||
onReceiveFromOtherPane(
|
||||
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, handle internal drag from other pane
|
||||
if (!draggedFiles || draggedFiles[0]?.side === side) return;
|
||||
onReceiveFromOtherPane(
|
||||
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
|
||||
);
|
||||
// Otherwise, this is an external file/folder drop (from OS)
|
||||
if (e.dataTransfer.items.length > 0 && onUploadExternalFiles) {
|
||||
await onUploadExternalFiles(e.dataTransfer);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileDragStart = useCallback(
|
||||
@@ -918,6 +1015,9 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
<ContextMenuItem onClick={() => setShowNewFolderDialog(true)}>
|
||||
<FolderPlus size={14} className="mr-2" /> {t("sftp.newFolder")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => setShowNewFileDialog(true)}>
|
||||
<FilePlus size={14} className="mr-2" /> {t("sftp.newFile")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
)}
|
||||
</ContextMenu>
|
||||
@@ -944,6 +1044,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
pane.connection,
|
||||
pane.selectedFiles,
|
||||
setShowNewFolderDialog,
|
||||
setShowNewFileDialog,
|
||||
t,
|
||||
],
|
||||
);
|
||||
@@ -1131,6 +1232,20 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => {
|
||||
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
|
||||
setNewFileName(defaultName);
|
||||
setFileNameError(null);
|
||||
setShowNewFileDialog(true);
|
||||
}}
|
||||
title={t("sftp.newFile")}
|
||||
>
|
||||
<FilePlus size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant={showFilterBar || pane.filter ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
@@ -1278,53 +1393,73 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
<div
|
||||
ref={fileListRef}
|
||||
className={cn(
|
||||
"flex-1 min-h-0 overflow-y-auto relative",
|
||||
isDragOverPane && "ring-2 ring-primary/30 ring-inset",
|
||||
)}
|
||||
onScroll={handleFileListScroll}
|
||||
>
|
||||
{pane.loading && sortedDisplayFiles.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : pane.error ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
|
||||
<AlertCircle size={24} />
|
||||
<span className="text-sm">{pane.error}</span>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh}>
|
||||
{t("sftp.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : sortedDisplayFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Folder size={32} className="mb-2 opacity-50" />
|
||||
<span className="text-sm">{t("sftp.emptyDirectory")}</span>
|
||||
</div>
|
||||
) : (
|
||||
{/* File list with empty area context menu */}
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
ref={fileListRef}
|
||||
className={cn(
|
||||
shouldVirtualize ? "relative" : "divide-y divide-border/30",
|
||||
"flex-1 min-h-0 overflow-y-auto relative",
|
||||
isDragOverPane && "ring-2 ring-primary/30 ring-inset",
|
||||
)}
|
||||
style={shouldVirtualize ? { height: totalHeight } : undefined}
|
||||
onScroll={handleFileListScroll}
|
||||
>
|
||||
{fileRows}
|
||||
</div>
|
||||
)}
|
||||
{pane.loading && sortedDisplayFiles.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : pane.error && !pane.reconnecting ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
|
||||
<AlertCircle size={24} />
|
||||
<span className="text-sm">{t(pane.error)}</span>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh}>
|
||||
{t("sftp.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : sortedDisplayFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Folder size={32} className="mb-2 opacity-50" />
|
||||
<span className="text-sm">{t("sftp.emptyDirectory")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
shouldVirtualize ? "relative" : "divide-y divide-border/30",
|
||||
)}
|
||||
style={shouldVirtualize ? { height: totalHeight } : undefined}
|
||||
>
|
||||
{fileRows}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop overlay */}
|
||||
{isDragOverPane && draggedFiles && draggedFiles[0]?.side !== side && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-primary/5 pointer-events-none">
|
||||
<div className="flex flex-col items-center gap-2 text-primary">
|
||||
<ArrowDown size={32} />
|
||||
<span className="text-sm font-medium">{t("sftp.dropFilesHere")}</span>
|
||||
</div>
|
||||
{/* Drop overlay */}
|
||||
{isDragOverPane && draggedFiles && draggedFiles[0]?.side !== side && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-primary/5 pointer-events-none">
|
||||
<div className="flex flex-col items-center gap-2 text-primary">
|
||||
<ArrowDown size={32} />
|
||||
<span className="text-sm font-medium">{t("sftp.dropFilesHere")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={onRefresh}>
|
||||
<RefreshCw size={14} className="mr-2" />{t("sftp.context.refresh")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => setShowNewFolderDialog(true)}>
|
||||
<FolderPlus size={14} className="mr-2" />{t("sftp.newFolder")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => {
|
||||
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
|
||||
setNewFileName(defaultName);
|
||||
setFileNameError(null);
|
||||
setShowNewFileDialog(true);
|
||||
}}>
|
||||
<FilePlus size={14} className="mr-2" />{t("sftp.newFile")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="h-9 shrink-0 px-4 flex items-center justify-between text-[11px] text-muted-foreground border-t border-border/40 bg-secondary/30">
|
||||
@@ -1341,12 +1476,25 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Loading overlay - covers entire pane when navigating directories */}
|
||||
{pane.loading && sortedDisplayFiles.length > 0 && (
|
||||
{pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] pointer-events-none z-10">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reconnecting overlay - shows when SFTP connection is lost and reconnecting */}
|
||||
{pane.reconnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
|
||||
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
|
||||
<Loader2 size={32} className="animate-spin text-primary" />
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium">{t("sftp.reconnecting.title")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{t("sftp.reconnecting.desc")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<Dialog open={showNewFolderDialog} onOpenChange={setShowNewFolderDialog}>
|
||||
<DialogContent className="max-w-sm">
|
||||
@@ -1385,6 +1533,88 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showNewFileDialog} onOpenChange={(open) => {
|
||||
setShowNewFileDialog(open);
|
||||
if (!open) {
|
||||
setFileNameError(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sftp.newFile")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("sftp.fileName")}</Label>
|
||||
<Input
|
||||
value={newFileName}
|
||||
onChange={(e) => {
|
||||
setNewFileName(e.target.value);
|
||||
setFileNameError(null);
|
||||
}}
|
||||
placeholder={t("sftp.fileName.placeholder")}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreateFile()}
|
||||
autoFocus
|
||||
className={fileNameError ? "border-destructive" : ""}
|
||||
/>
|
||||
{fileNameError && (
|
||||
<p className="text-xs text-destructive">{fileNameError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowNewFileDialog(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleCreateFile()}
|
||||
disabled={!newFileName.trim() || isCreatingFile}
|
||||
>
|
||||
{isCreatingFile && (
|
||||
<Loader2 size={14} className="mr-2 animate-spin" />
|
||||
)}
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Overwrite Confirmation Dialog */}
|
||||
<Dialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sftp.overwrite.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sftp.overwrite.desc", { name: overwriteTarget || "" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowOverwriteConfirm(false);
|
||||
setOverwriteTarget(null);
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmOverwrite}
|
||||
disabled={isCreatingFile}
|
||||
>
|
||||
{isCreatingFile && (
|
||||
<Loader2 size={14} className="mr-2 animate-spin" />
|
||||
)}
|
||||
{t("sftp.overwrite.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
@@ -1710,6 +1940,14 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
(name: string) => sftpRef.current.createDirectory("right", name),
|
||||
[],
|
||||
);
|
||||
const handleCreateFileLeft = useCallback(
|
||||
(name: string) => sftpRef.current.createFile("left", name),
|
||||
[],
|
||||
);
|
||||
const handleCreateFileRight = useCallback(
|
||||
(name: string) => sftpRef.current.createFile("right", name),
|
||||
[],
|
||||
);
|
||||
const handleDeleteFilesLeft = useCallback(
|
||||
(names: string[]) => sftpRef.current.deleteFiles("left", names),
|
||||
[],
|
||||
@@ -1914,24 +2152,32 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
[handleOpenFileWithForSide],
|
||||
);
|
||||
|
||||
// Handle external file upload from OS drag-and-drop (shared logic)
|
||||
// Handle external file/folder upload from OS drag-and-drop (shared logic)
|
||||
// Uses sftpRef.current internally, so dependencies are stable.
|
||||
// toast and logger are globally stable, t is the only real dependency.
|
||||
const handleUploadExternalFilesForSide = useCallback(
|
||||
async (side: "left" | "right", files: FileList) => {
|
||||
async (side: "left" | "right", dataTransfer: DataTransfer) => {
|
||||
try {
|
||||
const results = await sftpRef.current.uploadExternalFiles(side, files);
|
||||
const results = await sftpRef.current.uploadExternalFiles(side, dataTransfer);
|
||||
|
||||
// Check if upload was cancelled
|
||||
if (sftpRef.current.folderUploadProgress.cancelled) {
|
||||
toast.info(t('sftp.upload.cancelled'), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const failCount = results.filter(r => !r.success).length;
|
||||
// Count only files, not directories for success message
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
|
||||
if (failCount === 0) {
|
||||
// All files uploaded successfully
|
||||
const successCount = results.length;
|
||||
// All items uploaded successfully
|
||||
const message = successCount === 1
|
||||
? `${t('sftp.upload')}: ${results[0].fileName}`
|
||||
: `${t('sftp.uploadFiles')}: ${successCount}`;
|
||||
toast.success(message, "SFTP");
|
||||
} else {
|
||||
// Some or all files failed
|
||||
// Some or all items failed
|
||||
const failedFiles = results.filter(r => !r.success);
|
||||
failedFiles.forEach(failed => {
|
||||
const errorMsg = failed.error ? ` - ${failed.error}` : '';
|
||||
@@ -1954,12 +2200,12 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
);
|
||||
|
||||
const handleUploadExternalFilesLeft = useCallback(
|
||||
(files: FileList) => handleUploadExternalFilesForSide("left", files),
|
||||
(dataTransfer: DataTransfer) => handleUploadExternalFilesForSide("left", dataTransfer),
|
||||
[handleUploadExternalFilesForSide],
|
||||
);
|
||||
|
||||
const handleUploadExternalFilesRight = useCallback(
|
||||
(files: FileList) => handleUploadExternalFilesForSide("right", files),
|
||||
(dataTransfer: DataTransfer) => handleUploadExternalFilesForSide("right", dataTransfer),
|
||||
[handleUploadExternalFilesForSide],
|
||||
);
|
||||
|
||||
@@ -2077,6 +2323,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
onClearSelection: handleClearSelectionLeft,
|
||||
onSetFilter: handleSetFilterLeft,
|
||||
onCreateDirectory: handleCreateDirectoryLeft,
|
||||
onCreateFile: handleCreateFileLeft,
|
||||
onDeleteFiles: handleDeleteFilesLeft,
|
||||
onRenameFile: handleRenameFileLeft,
|
||||
onCopyToOtherPane: handleCopyToOtherPaneLeft,
|
||||
@@ -2104,6 +2351,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
onClearSelection: handleClearSelectionRight,
|
||||
onSetFilter: handleSetFilterRight,
|
||||
onCreateDirectory: handleCreateDirectoryRight,
|
||||
onCreateFile: handleCreateFileRight,
|
||||
onDeleteFiles: handleDeleteFilesRight,
|
||||
onRenameFile: handleRenameFileRight,
|
||||
onCopyToOtherPane: handleCopyToOtherPaneRight,
|
||||
@@ -2368,14 +2616,15 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
onSelectHost={handleHostSelectRight}
|
||||
/>
|
||||
|
||||
{sftp.transfers.length > 0 && (
|
||||
{/* Transfer status area - shows folder uploads and file transfers */}
|
||||
{(sftp.transfers.length > 0 || sftp.folderUploadProgress.isUploading) && (
|
||||
<div className="border-t border-border/70 bg-secondary/80 backdrop-blur-sm shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-2 text-xs text-muted-foreground border-b border-border/40">
|
||||
<span className="font-medium">
|
||||
Transfers
|
||||
{sftp.activeTransfersCount > 0 && (
|
||||
{(sftp.activeTransfersCount > 0 || sftp.folderUploadProgress.isUploading) && (
|
||||
<span className="ml-2 text-primary">
|
||||
({sftp.activeTransfersCount} active)
|
||||
({sftp.activeTransfersCount + (sftp.folderUploadProgress.isUploading ? 1 : 0)} active)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -2393,6 +2642,37 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-40 overflow-auto">
|
||||
{/* Folder upload progress - shown at top when active */}
|
||||
{sftp.folderUploadProgress.isUploading && (
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-border/30 bg-primary/5">
|
||||
<div className="flex-shrink-0">
|
||||
<Loader2 size={16} className="animate-spin text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium truncate">
|
||||
{t("sftp.upload.progress", {
|
||||
current: sftp.folderUploadProgress.currentIndex,
|
||||
total: sftp.folderUploadProgress.totalFiles,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{sftp.folderUploadProgress.currentFile && (
|
||||
<div className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{sftp.folderUploadProgress.currentFile}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs flex-shrink-0"
|
||||
onClick={() => sftp.cancelFolderUpload()}
|
||||
>
|
||||
{t("sftp.upload.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{visibleTransfers.map((task) => (
|
||||
<SftpTransferItem
|
||||
key={task.id}
|
||||
|
||||
@@ -128,7 +128,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onToggleBroadcast,
|
||||
onBroadcastInput,
|
||||
}) => {
|
||||
const CONNECTION_TIMEOUT = 12000;
|
||||
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
|
||||
const CONNECTION_TIMEOUT = 120000;
|
||||
const { t } = useI18n();
|
||||
const availableFonts = useAvailableFonts();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -1079,6 +1080,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
keySource: resolvedAuth.key?.source,
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sftpSudo: host.sftpSudo,
|
||||
};
|
||||
})()}
|
||||
open={showSFTP && status === "connected"}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface SftpPaneCallbacks {
|
||||
onClearSelection: () => void;
|
||||
onSetFilter: (filter: string) => void;
|
||||
onCreateDirectory: (name: string) => Promise<void>;
|
||||
onCreateFile: (name: string) => Promise<void>;
|
||||
onDeleteFiles: (fileNames: string[]) => Promise<void>;
|
||||
onRenameFile: (oldName: string, newName: string) => Promise<void>;
|
||||
onCopyToOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
|
||||
@@ -32,8 +33,8 @@ export interface SftpPaneCallbacks {
|
||||
onOpenFile?: (entry: SftpFileEntry) => void;
|
||||
onOpenFileWith?: (entry: SftpFileEntry) => void; // Always show opener dialog
|
||||
onDownloadFile?: (entry: SftpFileEntry) => void; // Download to local filesystem
|
||||
// External file upload
|
||||
onUploadExternalFiles?: (files: FileList) => Promise<void>;
|
||||
// External file upload (supports folders via DataTransfer)
|
||||
onUploadExternalFiles?: (dataTransfer: DataTransfer) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface SftpDragCallbacks {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { useCallback } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { normalizeLineEndings } from "../../../lib/utils";
|
||||
|
||||
type TerminalBackendWriteApi = {
|
||||
writeToSession: (sessionId: string, data: string) => void;
|
||||
@@ -32,7 +33,7 @@ export const useTerminalContextActions = ({
|
||||
if (!term) return;
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text && sessionRef.current) terminalBackend.writeToSession(sessionRef.current, text);
|
||||
if (text && sessionRef.current) terminalBackend.writeToSession(sessionRef.current, normalizeLineEndings(text));
|
||||
} catch (err) {
|
||||
logger.warn("Failed to paste from clipboard", err);
|
||||
}
|
||||
|
||||
@@ -218,12 +218,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
identities: ctx.identities,
|
||||
override: pendingAuth
|
||||
? {
|
||||
authMethod: pendingAuth.authMethod,
|
||||
username: pendingAuth.username,
|
||||
password: pendingAuth.password,
|
||||
keyId: pendingAuth.keyId,
|
||||
passphrase: pendingAuth.passphrase,
|
||||
}
|
||||
authMethod: pendingAuth.authMethod,
|
||||
username: pendingAuth.username,
|
||||
password: pendingAuth.password,
|
||||
keyId: pendingAuth.keyId,
|
||||
passphrase: pendingAuth.passphrase,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
@@ -247,12 +247,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
|
||||
const proxyConfig = ctx.host.proxyConfig
|
||||
? {
|
||||
type: ctx.host.proxyConfig.type,
|
||||
host: ctx.host.proxyConfig.host,
|
||||
port: ctx.host.proxyConfig.port,
|
||||
username: ctx.host.proxyConfig.username,
|
||||
password: ctx.host.proxyConfig.password,
|
||||
}
|
||||
type: ctx.host.proxyConfig.type,
|
||||
host: ctx.host.proxyConfig.host,
|
||||
port: ctx.host.proxyConfig.port,
|
||||
username: ctx.host.proxyConfig.username,
|
||||
password: ctx.host.proxyConfig.password,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost) => {
|
||||
@@ -348,9 +348,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
};
|
||||
|
||||
let id: string;
|
||||
const hasKeyMaterial = !!key?.privateKey;
|
||||
// Respect explicit auth method selection - don't use key if password auth was explicitly selected
|
||||
const authMethod = resolvedAuth.authMethod;
|
||||
const hasKeyMaterial = !!key?.privateKey && authMethod !== 'password';
|
||||
const hasPassword = !!effectivePassword;
|
||||
|
||||
|
||||
if (hasKeyMaterial) {
|
||||
try {
|
||||
id = await startAttempt({ key });
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
resolveXTermPerformanceConfig,
|
||||
} from "../../../infrastructure/config/xtermPerformance";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { normalizeLineEndings } from "../../../lib/utils";
|
||||
import type {
|
||||
Host,
|
||||
KeyBinding,
|
||||
@@ -106,7 +107,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const platform = detectPlatform();
|
||||
const deviceMemoryGb =
|
||||
typeof navigator !== "undefined" &&
|
||||
typeof (navigator as { deviceMemory?: number }).deviceMemory === "number"
|
||||
typeof (navigator as { deviceMemory?: number }).deviceMemory === "number"
|
||||
? (navigator as { deviceMemory?: number }).deviceMemory
|
||||
: undefined;
|
||||
|
||||
@@ -358,7 +359,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
case "paste": {
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) ctx.terminalBackend.writeToSession(id, text);
|
||||
if (id) ctx.terminalBackend.writeToSession(id, normalizeLineEndings(text));
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -390,7 +391,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text && ctx.sessionRef.current) {
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, text);
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, normalizeLineEndings(text));
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("[Terminal] Failed to paste from clipboard:", err);
|
||||
|
||||
@@ -47,7 +47,7 @@ const OPTIONS: ImportOption[] = [
|
||||
format: "ssh_config",
|
||||
label: "ssh_config",
|
||||
iconSrc: "/import/file.png",
|
||||
accept: ".conf,.config,.txt",
|
||||
accept: "*",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -90,6 +90,8 @@ export interface Host {
|
||||
telnetPassword?: string; // Telnet-specific password
|
||||
// Serial-specific configuration (for protocol='serial' hosts)
|
||||
serialConfig?: SerialConfig;
|
||||
// SFTP specific configuration
|
||||
sftpSudo?: boolean; // Use sudo for SFTP operations (requires password)
|
||||
}
|
||||
|
||||
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
|
||||
|
||||
@@ -57,11 +57,12 @@ export const resolveHostAuth = (args: {
|
||||
host.username?.trim() ||
|
||||
"";
|
||||
|
||||
const keyId =
|
||||
override?.keyId ||
|
||||
identity?.keyId ||
|
||||
host.identityFileId ||
|
||||
undefined;
|
||||
// Don't load key when explicit password auth is requested
|
||||
// This ensures user's auth method selection is strictly respected
|
||||
const keyId = override?.authMethod === 'password'
|
||||
? undefined
|
||||
: (override?.keyId || identity?.keyId || host.identityFileId || undefined);
|
||||
|
||||
|
||||
const key = keyId ? keys.find((k) => k.id === keyId) : undefined;
|
||||
|
||||
|
||||
@@ -1,6 +1,82 @@
|
||||
import { Host, HostProtocol } from "./models";
|
||||
import { Host, HostChainConfig, HostProtocol } from "./models";
|
||||
import { parseQuickConnectInput } from "./quickConnect";
|
||||
|
||||
interface ParsedJumpHost {
|
||||
hostname: string;
|
||||
username?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
const parseJumpHostSpec = (spec: string): ParsedJumpHost | null => {
|
||||
const trimmed = spec.trim();
|
||||
if (!trimmed || trimmed.toLowerCase() === "none") return null;
|
||||
|
||||
if (trimmed.startsWith("ssh://")) {
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
return {
|
||||
hostname: url.hostname,
|
||||
username: url.username || undefined,
|
||||
port: url.port ? parseInt(url.port, 10) : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let username: string | undefined;
|
||||
let hostname: string;
|
||||
let port: number | undefined;
|
||||
let rest = trimmed;
|
||||
|
||||
const atIndex = rest.indexOf("@");
|
||||
if (atIndex !== -1) {
|
||||
username = rest.slice(0, atIndex);
|
||||
rest = rest.slice(atIndex + 1);
|
||||
}
|
||||
|
||||
if (rest.startsWith("[")) {
|
||||
const bracketEnd = rest.indexOf("]");
|
||||
if (bracketEnd !== -1) {
|
||||
hostname = rest.slice(1, bracketEnd);
|
||||
const portPart = rest.slice(bracketEnd + 1);
|
||||
if (portPart.startsWith(":")) {
|
||||
const p = parseInt(portPart.slice(1), 10);
|
||||
if (Number.isFinite(p) && p >= 1 && p <= 65535) port = p;
|
||||
}
|
||||
} else {
|
||||
hostname = rest;
|
||||
}
|
||||
} else {
|
||||
const colonIndex = rest.lastIndexOf(":");
|
||||
if (colonIndex !== -1) {
|
||||
const portStr = rest.slice(colonIndex + 1);
|
||||
const p = parseInt(portStr, 10);
|
||||
if (Number.isFinite(p) && p >= 1 && p <= 65535) {
|
||||
port = p;
|
||||
hostname = rest.slice(0, colonIndex);
|
||||
} else {
|
||||
hostname = rest;
|
||||
}
|
||||
} else {
|
||||
hostname = rest;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hostname) return null;
|
||||
return { hostname, username, port };
|
||||
};
|
||||
|
||||
const parseProxyJump = (value: string): ParsedJumpHost[] => {
|
||||
if (!value || value.toLowerCase() === "none") return [];
|
||||
return value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map(parseJumpHostSpec)
|
||||
.filter((h): h is ParsedJumpHost => h !== null);
|
||||
};
|
||||
|
||||
export type VaultImportFormat =
|
||||
| "putty"
|
||||
| "mobaxterm"
|
||||
@@ -442,6 +518,7 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
hostname?: string;
|
||||
username?: string;
|
||||
port?: number;
|
||||
proxyJump?: string;
|
||||
};
|
||||
|
||||
const blocks: Block[] = [];
|
||||
@@ -479,16 +556,23 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
if (keyword === "hostname") current.hostname = value;
|
||||
else if (keyword === "user") current.username = value;
|
||||
else if (keyword === "port") current.port = parsePort(value);
|
||||
else if (keyword === "proxyjump") current.proxyJump = value;
|
||||
}
|
||||
|
||||
flush();
|
||||
|
||||
const parsedHosts: Host[] = [];
|
||||
// Use hostname+port as key instead of host.id to survive deduplication
|
||||
const hostProxyJumpMap = new Map<string, string>();
|
||||
let parsed = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const isWildcardPattern = (p: string) => /[*?]/.test(p) || p === "!" || p.startsWith("!");
|
||||
|
||||
// Helper to create a stable key for ProxyJump mapping
|
||||
const makeHostKey = (hostname: string, port?: number) =>
|
||||
`${hostname.toLowerCase()}:${port ?? 22}`;
|
||||
|
||||
for (const block of blocks) {
|
||||
const patterns = block.patterns.filter((p) => p && !isWildcardPattern(p));
|
||||
if (patterns.length === 0) continue;
|
||||
@@ -505,24 +589,146 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
continue;
|
||||
}
|
||||
|
||||
parsedHosts.push(
|
||||
createHost({
|
||||
label: pat,
|
||||
hostname,
|
||||
username: block.username,
|
||||
port: block.port,
|
||||
protocol: "ssh",
|
||||
}),
|
||||
);
|
||||
const host = createHost({
|
||||
label: pat,
|
||||
hostname,
|
||||
username: block.username,
|
||||
port: block.port,
|
||||
protocol: "ssh",
|
||||
});
|
||||
|
||||
parsedHosts.push(host);
|
||||
|
||||
// Store ProxyJump using hostname key (survives deduplication)
|
||||
if (block.proxyJump && block.proxyJump.toLowerCase() !== "none") {
|
||||
const hostKey = makeHostKey(hostname, block.port);
|
||||
hostProxyJumpMap.set(hostKey, block.proxyJump);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { hosts: dedupedHosts, duplicates } = dedupeHosts(parsedHosts);
|
||||
|
||||
const hostnameToId = new Map<string, string>();
|
||||
const labelToId = new Map<string, string>();
|
||||
for (const host of dedupedHosts) {
|
||||
hostnameToId.set(host.hostname.toLowerCase(), host.id);
|
||||
labelToId.set(host.label.toLowerCase(), host.id);
|
||||
}
|
||||
|
||||
const resolveJumpHostToId = (jumpHost: ParsedJumpHost): string | null => {
|
||||
const hostnameKey = jumpHost.hostname.toLowerCase();
|
||||
if (labelToId.has(hostnameKey)) return labelToId.get(hostnameKey)!;
|
||||
if (hostnameToId.has(hostnameKey)) return hostnameToId.get(hostnameKey)!;
|
||||
return null;
|
||||
};
|
||||
|
||||
// Collect inline hosts separately to avoid modifying array during iteration
|
||||
const inlineHosts: Host[] = [];
|
||||
|
||||
// Process ProxyJump for each host (iterate over a copy to avoid issues)
|
||||
const hostsToProcess = [...dedupedHosts];
|
||||
for (const host of hostsToProcess) {
|
||||
const hostKey = makeHostKey(host.hostname, host.port);
|
||||
const proxyJumpValue = hostProxyJumpMap.get(hostKey);
|
||||
if (!proxyJumpValue) continue;
|
||||
|
||||
const jumpHosts = parseProxyJump(proxyJumpValue);
|
||||
if (jumpHosts.length === 0) continue;
|
||||
|
||||
const resolvedIds: string[] = [];
|
||||
const unresolvedJumps: string[] = [];
|
||||
|
||||
for (const jumpHost of jumpHosts) {
|
||||
const existingId = resolveJumpHostToId(jumpHost);
|
||||
if (existingId) {
|
||||
// Avoid duplicate IDs in the chain
|
||||
if (!resolvedIds.includes(existingId)) {
|
||||
resolvedIds.push(existingId);
|
||||
}
|
||||
} else {
|
||||
// Check if we already created an inline host for this
|
||||
const inlineKey = jumpHost.hostname.toLowerCase();
|
||||
let inlineId = hostnameToId.get(inlineKey);
|
||||
|
||||
if (!inlineId) {
|
||||
const inlineHost = createHost({
|
||||
label: jumpHost.hostname,
|
||||
hostname: jumpHost.hostname,
|
||||
username: jumpHost.username,
|
||||
port: jumpHost.port,
|
||||
protocol: "ssh",
|
||||
});
|
||||
inlineHosts.push(inlineHost);
|
||||
hostnameToId.set(inlineHost.hostname.toLowerCase(), inlineHost.id);
|
||||
labelToId.set(inlineHost.label.toLowerCase(), inlineHost.id);
|
||||
inlineId = inlineHost.id;
|
||||
unresolvedJumps.push(jumpHost.hostname);
|
||||
}
|
||||
|
||||
if (!resolvedIds.includes(inlineId)) {
|
||||
resolvedIds.push(inlineId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedIds.length > 0) {
|
||||
// Cycle detection: check if this host appears in its own chain
|
||||
if (resolvedIds.includes(host.id)) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `ssh_config: detected circular reference in ProxyJump for "${host.label}", skipping chain.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const hostChain: HostChainConfig = { hostIds: resolvedIds };
|
||||
host.hostChain = hostChain;
|
||||
}
|
||||
|
||||
if (unresolvedJumps.length > 0) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `ssh_config: created inline jump host(s) for "${host.label}": ${unresolvedJumps.join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add inline hosts to the final result
|
||||
const allHosts = [...dedupedHosts, ...inlineHosts];
|
||||
|
||||
// Deep cycle detection: check for indirect cycles (A -> B -> C -> A)
|
||||
const detectCycle = (hostId: string, visited: Set<string>): boolean => {
|
||||
if (visited.has(hostId)) return true;
|
||||
visited.add(hostId);
|
||||
const host = allHosts.find(h => h.id === hostId);
|
||||
if (host?.hostChain?.hostIds) {
|
||||
for (const chainId of host.hostChain.hostIds) {
|
||||
if (detectCycle(chainId, visited)) return true;
|
||||
}
|
||||
}
|
||||
visited.delete(hostId);
|
||||
return false;
|
||||
};
|
||||
|
||||
// Remove chains that form cycles
|
||||
for (const host of allHosts) {
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
if (detectCycle(host.id, new Set())) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `ssh_config: detected circular dependency in jump chain for "${host.label}", removing chain.`,
|
||||
});
|
||||
delete host.hostChain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { hosts, duplicates } = dedupeHosts(parsedHosts);
|
||||
return {
|
||||
hosts,
|
||||
hosts: allHosts,
|
||||
groups: [],
|
||||
issues,
|
||||
stats: { parsed, imported: hosts.length, skipped, duplicates },
|
||||
stats: { parsed, imported: allHosts.length, skipped, duplicates },
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* global __dirname */
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
|
||||
104
electron/bridges/keyboardInteractiveHandler.cjs
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Keyboard Interactive Handler - Shared state for keyboard-interactive authentication
|
||||
* This module provides a centralized storage for keyboard-interactive auth requests
|
||||
* used by SSH, SFTP, and Port Forwarding bridges.
|
||||
*/
|
||||
|
||||
// Keyboard-interactive authentication pending requests
|
||||
// Map of requestId -> { finishCallback, webContentsId, sessionId, createdAt, timeoutId }
|
||||
const keyboardInteractiveRequests = new Map();
|
||||
|
||||
// TTL for abandoned requests (5 minutes)
|
||||
const REQUEST_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Generate a unique request ID for keyboard-interactive requests
|
||||
*/
|
||||
function generateRequestId(prefix = 'ki') {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a keyboard-interactive request with TTL cleanup
|
||||
*/
|
||||
function storeRequest(requestId, finishCallback, webContentsId, sessionId) {
|
||||
// Set up TTL timeout to clean up abandoned requests
|
||||
const timeoutId = setTimeout(() => {
|
||||
const pending = keyboardInteractiveRequests.get(requestId);
|
||||
if (pending) {
|
||||
console.warn(`[KeyboardInteractive] Request ${requestId} timed out after ${REQUEST_TTL_MS / 1000}s, cleaning up`);
|
||||
keyboardInteractiveRequests.delete(requestId);
|
||||
// Call finish with empty responses to abort the authentication
|
||||
try {
|
||||
pending.finishCallback([]);
|
||||
} catch (err) {
|
||||
console.warn(`[KeyboardInteractive] Failed to call finishCallback for timed out request:`, err.message);
|
||||
}
|
||||
}
|
||||
}, REQUEST_TTL_MS);
|
||||
|
||||
keyboardInteractiveRequests.set(requestId, {
|
||||
finishCallback,
|
||||
webContentsId,
|
||||
sessionId,
|
||||
createdAt: Date.now(),
|
||||
timeoutId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard-interactive authentication response from renderer
|
||||
*/
|
||||
function handleResponse(_event, payload) {
|
||||
console.log(`[KeyboardInteractive] handleResponse called with payload:`, JSON.stringify(payload));
|
||||
|
||||
const { requestId, responses, cancelled } = payload;
|
||||
const pending = keyboardInteractiveRequests.get(requestId);
|
||||
|
||||
console.log(`[KeyboardInteractive] Looking for request ${requestId}, found:`, !!pending);
|
||||
console.log(`[KeyboardInteractive] Current pending requests:`, Array.from(keyboardInteractiveRequests.keys()));
|
||||
|
||||
if (!pending) {
|
||||
console.warn(`[KeyboardInteractive] No pending request for ${requestId}`);
|
||||
return { success: false, error: 'Request not found' };
|
||||
}
|
||||
|
||||
// Clear the TTL timeout since we received a response
|
||||
if (pending.timeoutId) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
}
|
||||
|
||||
keyboardInteractiveRequests.delete(requestId);
|
||||
|
||||
if (cancelled) {
|
||||
console.log(`[KeyboardInteractive] Auth cancelled for ${requestId}`);
|
||||
pending.finishCallback([]); // Empty responses to cancel
|
||||
} else {
|
||||
console.log(`[KeyboardInteractive] Auth response received for ${requestId}, responses count:`, responses?.length);
|
||||
pending.finishCallback(responses);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the requests map (for debugging/testing)
|
||||
*/
|
||||
function getRequests() {
|
||||
return keyboardInteractiveRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handler for keyboard-interactive responses
|
||||
*/
|
||||
function registerHandler(ipcMain) {
|
||||
ipcMain.handle("netcatty:keyboard-interactive:respond", handleResponse);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateRequestId,
|
||||
storeRequest,
|
||||
handleResponse,
|
||||
getRequests,
|
||||
registerHandler,
|
||||
};
|
||||
@@ -5,18 +5,31 @@
|
||||
|
||||
const net = require("node:net");
|
||||
const { Client: SSHClient } = require("ssh2");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
|
||||
// Active port forwarding tunnels
|
||||
const portForwardingTunnels = new Map();
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a port forwarding tunnel
|
||||
*/
|
||||
async function startPortForward(event, payload) {
|
||||
const {
|
||||
tunnelId,
|
||||
const {
|
||||
tunnelId,
|
||||
type, // 'local' | 'remote' | 'dynamic'
|
||||
localPort,
|
||||
localPort,
|
||||
bindAddress = '127.0.0.1',
|
||||
remoteHost,
|
||||
remotePort,
|
||||
@@ -26,34 +39,88 @@ async function startPortForward(event, payload) {
|
||||
password,
|
||||
privateKey,
|
||||
} = payload;
|
||||
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new SSHClient();
|
||||
const sender = event.sender;
|
||||
|
||||
|
||||
const sendStatus = (status, error = null) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send("netcatty:portforward:status", { tunnelId, status, error });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const connectOpts = {
|
||||
host: hostname,
|
||||
port: port,
|
||||
username: username || 'root',
|
||||
readyTimeout: 30000,
|
||||
readyTimeout: 120000, // 2 minutes for 2FA input
|
||||
keepaliveInterval: 10000,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
};
|
||||
|
||||
|
||||
if (privateKey) {
|
||||
connectOpts.privateKey = privateKey;
|
||||
} else if (password) {
|
||||
}
|
||||
if (password) {
|
||||
connectOpts.password = password;
|
||||
}
|
||||
|
||||
|
||||
// Build auth handler with keyboard-interactive support
|
||||
const authMethods = [];
|
||||
if (privateKey) authMethods.push("publickey");
|
||||
if (password) authMethods.push("password");
|
||||
authMethods.push("keyboard-interactive");
|
||||
connectOpts.authHandler = authMethods;
|
||||
|
||||
// Handle keyboard-interactive authentication (2FA/MFA)
|
||||
conn.on("keyboard-interactive", (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`[PortForward] ${hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })),
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`[PortForward] No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
|
||||
// (Prompt text is admin-customizable and may not contain expected keywords)
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('pf');
|
||||
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`[PortForward] Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, sender.id, tunnelId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`[PortForward] Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId: tunnelId,
|
||||
name: name || "",
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
hostname: hostname,
|
||||
savedPassword: password || null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
conn.on('ready', () => {
|
||||
console.log(`[PortForward] SSH connection ready for tunnel ${tunnelId}`);
|
||||
|
||||
|
||||
if (type === 'local') {
|
||||
// LOCAL FORWARDING: Listen on local port, forward to remote
|
||||
const server = net.createServer((socket) => {
|
||||
@@ -69,13 +136,13 @@ async function startPortForward(event, payload) {
|
||||
return;
|
||||
}
|
||||
socket.pipe(stream).pipe(socket);
|
||||
|
||||
|
||||
socket.on('error', (e) => console.warn('[PortForward] Socket error:', e.message));
|
||||
stream.on('error', (e) => console.warn('[PortForward] Stream error:', e.message));
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error(`[PortForward] Server error:`, err.message);
|
||||
sendStatus('error', err.message);
|
||||
@@ -83,19 +150,19 @@ async function startPortForward(event, payload) {
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
|
||||
server.listen(localPort, bindAddress, () => {
|
||||
console.log(`[PortForward] Local forwarding active: ${bindAddress}:${localPort} -> ${remoteHost}:${remotePort}`);
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'local',
|
||||
conn,
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'local',
|
||||
conn,
|
||||
server,
|
||||
webContentsId: sender.id
|
||||
webContentsId: sender.id
|
||||
});
|
||||
sendStatus('active');
|
||||
resolve({ tunnelId, success: true });
|
||||
});
|
||||
|
||||
|
||||
} else if (type === 'remote') {
|
||||
// REMOTE FORWARDING: Listen on remote port, forward to local
|
||||
conn.forwardIn(bindAddress, localPort, (err) => {
|
||||
@@ -106,24 +173,24 @@ async function startPortForward(event, payload) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
console.log(`[PortForward] Remote forwarding active: remote ${bindAddress}:${localPort} -> local ${remoteHost}:${remotePort}`);
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'remote',
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'remote',
|
||||
conn,
|
||||
webContentsId: sender.id
|
||||
webContentsId: sender.id
|
||||
});
|
||||
sendStatus('active');
|
||||
resolve({ tunnelId, success: true });
|
||||
});
|
||||
|
||||
|
||||
// Handle incoming connections from remote
|
||||
conn.on('tcp connection', (info, accept, rejectConn) => {
|
||||
const stream = accept();
|
||||
const socket = net.connect(remotePort, remoteHost || '127.0.0.1', () => {
|
||||
stream.pipe(socket).pipe(stream);
|
||||
});
|
||||
|
||||
|
||||
socket.on('error', (e) => {
|
||||
console.warn('[PortForward] Local socket error:', e.message);
|
||||
stream.end();
|
||||
@@ -133,7 +200,7 @@ async function startPortForward(event, payload) {
|
||||
socket.end();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
} else if (type === 'dynamic') {
|
||||
// DYNAMIC FORWARDING (SOCKS5 Proxy)
|
||||
const server = net.createServer((socket) => {
|
||||
@@ -143,10 +210,10 @@ async function startPortForward(event, payload) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Reply: version, no auth required
|
||||
socket.write(Buffer.from([0x05, 0x00]));
|
||||
|
||||
|
||||
// Wait for connection request
|
||||
socket.once('data', (request) => {
|
||||
if (request[0] !== 0x05 || request[1] !== 0x01) {
|
||||
@@ -154,10 +221,10 @@ async function startPortForward(event, payload) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
let targetHost, targetPort;
|
||||
const addressType = request[3];
|
||||
|
||||
|
||||
if (addressType === 0x01) {
|
||||
// IPv4
|
||||
targetHost = `${request[4]}.${request[5]}.${request[6]}.${request[7]}`;
|
||||
@@ -177,7 +244,7 @@ async function startPortForward(event, payload) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Forward through SSH tunnel
|
||||
conn.forwardOut(
|
||||
bindAddress,
|
||||
@@ -190,7 +257,7 @@ async function startPortForward(event, payload) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Success reply
|
||||
const reply = Buffer.alloc(10);
|
||||
reply[0] = 0x05;
|
||||
@@ -199,9 +266,9 @@ async function startPortForward(event, payload) {
|
||||
reply[3] = 0x01;
|
||||
reply.writeUInt16BE(0, 8);
|
||||
socket.write(reply);
|
||||
|
||||
|
||||
socket.pipe(stream).pipe(socket);
|
||||
|
||||
|
||||
socket.on('error', () => stream.end());
|
||||
stream.on('error', () => socket.end());
|
||||
}
|
||||
@@ -209,7 +276,7 @@ async function startPortForward(event, payload) {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error(`[PortForward] SOCKS server error:`, err.message);
|
||||
sendStatus('error', err.message);
|
||||
@@ -217,14 +284,14 @@ async function startPortForward(event, payload) {
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
|
||||
server.listen(localPort, bindAddress, () => {
|
||||
console.log(`[PortForward] Dynamic SOCKS5 proxy active on ${bindAddress}:${localPort}`);
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'dynamic',
|
||||
conn,
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'dynamic',
|
||||
conn,
|
||||
server,
|
||||
webContentsId: sender.id
|
||||
webContentsId: sender.id
|
||||
});
|
||||
sendStatus('active');
|
||||
resolve({ tunnelId, success: true });
|
||||
@@ -233,26 +300,26 @@ async function startPortForward(event, payload) {
|
||||
reject(new Error(`Unknown forwarding type: ${type}`));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
conn.on('error', (err) => {
|
||||
console.error(`[PortForward] SSH error:`, err.message);
|
||||
sendStatus('error', err.message);
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
|
||||
conn.on('close', () => {
|
||||
console.log(`[PortForward] SSH connection closed for tunnel ${tunnelId}`);
|
||||
const tunnel = portForwardingTunnels.get(tunnelId);
|
||||
if (tunnel) {
|
||||
if (tunnel.server) {
|
||||
try { tunnel.server.close(); } catch {}
|
||||
try { tunnel.server.close(); } catch { }
|
||||
}
|
||||
sendStatus('inactive');
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
sendStatus('connecting');
|
||||
conn.connect(connectOpts);
|
||||
});
|
||||
@@ -264,11 +331,11 @@ async function startPortForward(event, payload) {
|
||||
async function stopPortForward(event, payload) {
|
||||
const { tunnelId } = payload;
|
||||
const tunnel = portForwardingTunnels.get(tunnelId);
|
||||
|
||||
|
||||
if (!tunnel) {
|
||||
return { tunnelId, success: false, error: 'Tunnel not found' };
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
if (tunnel.server) {
|
||||
tunnel.server.close();
|
||||
@@ -277,7 +344,7 @@ async function stopPortForward(event, payload) {
|
||||
tunnel.conn.end();
|
||||
}
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
|
||||
|
||||
return { tunnelId, success: true };
|
||||
} catch (err) {
|
||||
return { tunnelId, success: false, error: err.message };
|
||||
@@ -290,11 +357,11 @@ async function stopPortForward(event, payload) {
|
||||
async function getPortForwardStatus(event, payload) {
|
||||
const { tunnelId } = payload;
|
||||
const tunnel = portForwardingTunnels.get(tunnelId);
|
||||
|
||||
|
||||
if (!tunnel) {
|
||||
return { tunnelId, status: 'inactive' };
|
||||
}
|
||||
|
||||
|
||||
return { tunnelId, status: 'active', type: tunnel.type };
|
||||
}
|
||||
|
||||
|
||||
135
electron/bridges/proxyUtils.cjs
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Proxy Utilities - Shared proxy socket creation for SSH connections
|
||||
* Extracted from sshBridge.cjs and sftpBridge.cjs to eliminate code duplication
|
||||
*/
|
||||
|
||||
const net = require("node:net");
|
||||
|
||||
/**
|
||||
* Create a socket through a proxy (HTTP CONNECT or SOCKS5)
|
||||
* @param {Object} proxy - Proxy configuration
|
||||
* @param {string} proxy.type - 'http' or 'socks5'
|
||||
* @param {string} proxy.host - Proxy host
|
||||
* @param {number} proxy.port - Proxy port
|
||||
* @param {string} [proxy.username] - Optional username for auth
|
||||
* @param {string} [proxy.password] - Optional password for auth
|
||||
* @param {string} targetHost - Target host to connect through proxy
|
||||
* @param {number} targetPort - Target port to connect through proxy
|
||||
* @returns {Promise<net.Socket>} Connected socket through proxy
|
||||
*/
|
||||
function createProxySocket(proxy, targetHost, targetPort) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (proxy.type === 'http') {
|
||||
// HTTP CONNECT proxy
|
||||
const socket = net.connect(proxy.port, proxy.host, () => {
|
||||
let authHeader = '';
|
||||
if (proxy.username && proxy.password) {
|
||||
const auth = Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64');
|
||||
authHeader = `Proxy-Authorization: Basic ${auth}\r\n`;
|
||||
}
|
||||
const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${authHeader}\r\n`;
|
||||
socket.write(connectRequest);
|
||||
|
||||
let response = '';
|
||||
const onData = (data) => {
|
||||
response += data.toString();
|
||||
if (response.includes('\r\n\r\n')) {
|
||||
socket.removeListener('data', onData);
|
||||
if (response.startsWith('HTTP/1.1 200') || response.startsWith('HTTP/1.0 200')) {
|
||||
resolve(socket);
|
||||
} else {
|
||||
socket.destroy();
|
||||
reject(new Error(`HTTP proxy error: ${response.split('\r\n')[0]}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
socket.on('data', onData);
|
||||
});
|
||||
socket.on('error', reject);
|
||||
} else if (proxy.type === 'socks5') {
|
||||
// SOCKS5 proxy
|
||||
const socket = net.connect(proxy.port, proxy.host, () => {
|
||||
// SOCKS5 greeting
|
||||
const authMethods = proxy.username && proxy.password ? [0x00, 0x02] : [0x00];
|
||||
socket.write(Buffer.from([0x05, authMethods.length, ...authMethods]));
|
||||
|
||||
let step = 'greeting';
|
||||
const onData = (data) => {
|
||||
if (step === 'greeting') {
|
||||
if (data[0] !== 0x05) {
|
||||
socket.destroy();
|
||||
reject(new Error('Invalid SOCKS5 response'));
|
||||
return;
|
||||
}
|
||||
const method = data[1];
|
||||
if (method === 0x02 && proxy.username && proxy.password) {
|
||||
// Username/password auth
|
||||
step = 'auth';
|
||||
const userBuf = Buffer.from(proxy.username);
|
||||
const passBuf = Buffer.from(proxy.password);
|
||||
socket.write(Buffer.concat([
|
||||
Buffer.from([0x01, userBuf.length]),
|
||||
userBuf,
|
||||
Buffer.from([passBuf.length]),
|
||||
passBuf
|
||||
]));
|
||||
} else if (method === 0x00) {
|
||||
// No auth, proceed to connect
|
||||
step = 'connect';
|
||||
sendConnectRequest();
|
||||
} else {
|
||||
socket.destroy();
|
||||
reject(new Error('SOCKS5 authentication method not supported'));
|
||||
}
|
||||
} else if (step === 'auth') {
|
||||
if (data[1] !== 0x00) {
|
||||
socket.destroy();
|
||||
reject(new Error('SOCKS5 authentication failed'));
|
||||
return;
|
||||
}
|
||||
step = 'connect';
|
||||
sendConnectRequest();
|
||||
} else if (step === 'connect') {
|
||||
socket.removeListener('data', onData);
|
||||
if (data[1] === 0x00) {
|
||||
resolve(socket);
|
||||
} else {
|
||||
const errors = {
|
||||
0x01: 'General failure',
|
||||
0x02: 'Connection not allowed',
|
||||
0x03: 'Network unreachable',
|
||||
0x04: 'Host unreachable',
|
||||
0x05: 'Connection refused',
|
||||
0x06: 'TTL expired',
|
||||
0x07: 'Command not supported',
|
||||
0x08: 'Address type not supported',
|
||||
};
|
||||
socket.destroy();
|
||||
reject(new Error(`SOCKS5 error: ${errors[data[1]] || 'Unknown'}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sendConnectRequest = () => {
|
||||
// SOCKS5 connect request
|
||||
const hostBuf = Buffer.from(targetHost);
|
||||
const request = Buffer.concat([
|
||||
Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]),
|
||||
hostBuf,
|
||||
Buffer.from([(targetPort >> 8) & 0xff, targetPort & 0xff])
|
||||
]);
|
||||
socket.write(request);
|
||||
};
|
||||
|
||||
socket.on('data', onData);
|
||||
});
|
||||
socket.on('error', reject);
|
||||
} else {
|
||||
reject(new Error(`Unknown proxy type: ${proxy.type}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createProxySocket,
|
||||
};
|
||||
@@ -9,8 +9,18 @@ const os = require("node:os");
|
||||
const net = require("node:net");
|
||||
const SftpClient = require("ssh2-sftp-client");
|
||||
const { Client: SSHClient } = require("ssh2");
|
||||
let SFTPWrapper;
|
||||
try {
|
||||
// Try to load SFTPWrapper from ssh2 internals for sudo support
|
||||
const sftpModule = require("ssh2/lib/protocol/SFTP");
|
||||
SFTPWrapper = sftpModule.SFTP || sftpModule;
|
||||
} catch (e) {
|
||||
console.warn("[SFTP] Failed to load SFTPWrapper from ssh2, sudo mode will not work:", e.message);
|
||||
}
|
||||
const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
|
||||
// SFTP clients storage - shared reference passed from main
|
||||
let sftpClients = null;
|
||||
@@ -19,6 +29,18 @@ let electronModule = null;
|
||||
// Storage for jump host connections that need to be cleaned up
|
||||
const jumpConnectionsMap = new Map(); // connId -> { connections: SSHClient[], socket: stream }
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the SFTP bridge with dependencies
|
||||
*/
|
||||
@@ -27,130 +49,13 @@ function init(deps) {
|
||||
electronModule = deps.electronModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a socket through a proxy (HTTP CONNECT or SOCKS5)
|
||||
* Reused from sshBridge.cjs
|
||||
*/
|
||||
function createProxySocket(proxy, targetHost, targetPort) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (proxy.type === 'http') {
|
||||
// HTTP CONNECT proxy
|
||||
const socket = net.connect(proxy.port, proxy.host, () => {
|
||||
let authHeader = '';
|
||||
if (proxy.username && proxy.password) {
|
||||
const auth = Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64');
|
||||
authHeader = `Proxy-Authorization: Basic ${auth}\r\n`;
|
||||
}
|
||||
const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${authHeader}\r\n`;
|
||||
socket.write(connectRequest);
|
||||
|
||||
let response = '';
|
||||
const onData = (data) => {
|
||||
response += data.toString();
|
||||
if (response.includes('\r\n\r\n')) {
|
||||
socket.removeListener('data', onData);
|
||||
if (response.startsWith('HTTP/1.1 200') || response.startsWith('HTTP/1.0 200')) {
|
||||
resolve(socket);
|
||||
} else {
|
||||
socket.destroy();
|
||||
reject(new Error(`HTTP proxy error: ${response.split('\r\n')[0]}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
socket.on('data', onData);
|
||||
});
|
||||
socket.on('error', reject);
|
||||
} else if (proxy.type === 'socks5') {
|
||||
// SOCKS5 proxy
|
||||
const socket = net.connect(proxy.port, proxy.host, () => {
|
||||
// SOCKS5 greeting
|
||||
const authMethods = proxy.username && proxy.password ? [0x00, 0x02] : [0x00];
|
||||
socket.write(Buffer.from([0x05, authMethods.length, ...authMethods]));
|
||||
|
||||
let step = 'greeting';
|
||||
const onData = (data) => {
|
||||
if (step === 'greeting') {
|
||||
if (data[0] !== 0x05) {
|
||||
socket.destroy();
|
||||
reject(new Error('Invalid SOCKS5 response'));
|
||||
return;
|
||||
}
|
||||
const method = data[1];
|
||||
if (method === 0x02 && proxy.username && proxy.password) {
|
||||
// Username/password auth
|
||||
step = 'auth';
|
||||
const userBuf = Buffer.from(proxy.username);
|
||||
const passBuf = Buffer.from(proxy.password);
|
||||
socket.write(Buffer.concat([
|
||||
Buffer.from([0x01, userBuf.length]),
|
||||
userBuf,
|
||||
Buffer.from([passBuf.length]),
|
||||
passBuf
|
||||
]));
|
||||
} else if (method === 0x00) {
|
||||
// No auth, proceed to connect
|
||||
step = 'connect';
|
||||
sendConnectRequest();
|
||||
} else {
|
||||
socket.destroy();
|
||||
reject(new Error('SOCKS5 authentication method not supported'));
|
||||
}
|
||||
} else if (step === 'auth') {
|
||||
if (data[1] !== 0x00) {
|
||||
socket.destroy();
|
||||
reject(new Error('SOCKS5 authentication failed'));
|
||||
return;
|
||||
}
|
||||
step = 'connect';
|
||||
sendConnectRequest();
|
||||
} else if (step === 'connect') {
|
||||
socket.removeListener('data', onData);
|
||||
if (data[1] === 0x00) {
|
||||
resolve(socket);
|
||||
} else {
|
||||
const errors = {
|
||||
0x01: 'General failure',
|
||||
0x02: 'Connection not allowed',
|
||||
0x03: 'Network unreachable',
|
||||
0x04: 'Host unreachable',
|
||||
0x05: 'Connection refused',
|
||||
0x06: 'TTL expired',
|
||||
0x07: 'Command not supported',
|
||||
0x08: 'Address type not supported',
|
||||
};
|
||||
socket.destroy();
|
||||
reject(new Error(`SOCKS5 error: ${errors[data[1]] || 'Unknown'}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sendConnectRequest = () => {
|
||||
// SOCKS5 connect request
|
||||
const hostBuf = Buffer.from(targetHost);
|
||||
const request = Buffer.concat([
|
||||
Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]),
|
||||
hostBuf,
|
||||
Buffer.from([(targetPort >> 8) & 0xff, targetPort & 0xff])
|
||||
]);
|
||||
socket.write(request);
|
||||
};
|
||||
|
||||
socket.on('data', onData);
|
||||
});
|
||||
socket.on('error', reject);
|
||||
} else {
|
||||
reject(new Error(`Unknown proxy type: ${proxy.type}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect through a chain of jump hosts for SFTP
|
||||
*/
|
||||
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort) {
|
||||
const connections = [];
|
||||
let currentSocket = null;
|
||||
|
||||
|
||||
try {
|
||||
// Connect through each jump host
|
||||
for (let i = 0; i < jumpHosts.length; i++) {
|
||||
@@ -158,14 +63,14 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === jumpHosts.length - 1;
|
||||
const hopLabel = jump.label || `${jump.hostname}:${jump.port || 22}`;
|
||||
|
||||
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Connecting to ${hopLabel}...`);
|
||||
|
||||
|
||||
const conn = new SSHClient();
|
||||
// Increase max listeners to prevent Node.js warning
|
||||
// Set to 0 (unlimited) since complex operations add many temp listeners
|
||||
conn.setMaxListeners(0);
|
||||
|
||||
|
||||
// Build connection options
|
||||
const connOpts = {
|
||||
host: jump.hostname,
|
||||
@@ -174,13 +79,15 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
readyTimeout: 20000,
|
||||
keepaliveInterval: 10000,
|
||||
keepaliveCountMax: 3,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
|
||||
kex: ['curve25519-sha256', 'curve25519-sha256@libssh.org', 'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'diffie-hellman-group14-sha256'],
|
||||
compress: ['none'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Auth - support agent (certificate), key, and password fallback
|
||||
const hasCertificate =
|
||||
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
|
||||
@@ -210,7 +117,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
if (connOpts.password) order.push("password");
|
||||
connOpts.authHandler = order;
|
||||
}
|
||||
|
||||
|
||||
// If first hop and proxy is configured, connect through proxy
|
||||
if (isFirst && options.proxy) {
|
||||
currentSocket = await createProxySocket(options.proxy, jump.hostname, jump.port || 22);
|
||||
@@ -223,7 +130,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
delete connOpts.host;
|
||||
delete connOpts.port;
|
||||
}
|
||||
|
||||
|
||||
// Connect this hop
|
||||
await new Promise((resolve, reject) => {
|
||||
conn.on('ready', () => {
|
||||
@@ -240,9 +147,9 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
});
|
||||
conn.connect(connOpts);
|
||||
});
|
||||
|
||||
|
||||
connections.push(conn);
|
||||
|
||||
|
||||
// Determine next target
|
||||
let nextHost, nextPort;
|
||||
if (isLast) {
|
||||
@@ -255,7 +162,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
nextHost = nextJump.hostname;
|
||||
nextPort = nextJump.port || 22;
|
||||
}
|
||||
|
||||
|
||||
// Create forward stream to next hop
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Forwarding to ${nextHost}:${nextPort}...`);
|
||||
currentSocket = await new Promise((resolve, reject) => {
|
||||
@@ -270,10 +177,10 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Return the final forwarded stream and all connections for cleanup
|
||||
return {
|
||||
socket: currentSocket,
|
||||
return {
|
||||
socket: currentSocket,
|
||||
connections
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -285,6 +192,232 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish an SFTP connection using sudo
|
||||
* @param {SSHClient} client - Connected SSH client
|
||||
* @param {string} password - User password for sudo
|
||||
*/
|
||||
async function connectSudoSftp(client, password) {
|
||||
if (!SFTPWrapper) {
|
||||
throw new Error("SFTP sudo mode is not available on this platform. Please disable sudo mode in host settings.");
|
||||
}
|
||||
|
||||
// Known sftp-server paths to try
|
||||
const sftpPaths = [
|
||||
"/usr/lib/openssh/sftp-server",
|
||||
"/usr/libexec/openssh/sftp-server",
|
||||
"/usr/lib/ssh/sftp-server",
|
||||
"/usr/libexec/sftp-server",
|
||||
"/usr/local/libexec/sftp-server",
|
||||
"/usr/local/lib/sftp-server"
|
||||
];
|
||||
|
||||
console.log("[SFTP] Probing sftp-server path for sudo mode...");
|
||||
|
||||
let serverPath = null;
|
||||
// Try to find the path
|
||||
for (const p of sftpPaths) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
client.exec(`test -x ${p}`, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
stream.on('exit', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error('Not found'));
|
||||
});
|
||||
});
|
||||
});
|
||||
serverPath = p;
|
||||
break;
|
||||
} catch (e) {
|
||||
// Continue probing
|
||||
}
|
||||
}
|
||||
|
||||
if (!serverPath) {
|
||||
// Fallback: try to find it in path or assume standard location
|
||||
console.warn("[SFTP] Could not probe sftp-server, trying default /usr/lib/openssh/sftp-server");
|
||||
serverPath = "/usr/lib/openssh/sftp-server";
|
||||
} else {
|
||||
console.log(`[SFTP] Found sftp-server at ${serverPath}`);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Use sudo -S to read password from stdin
|
||||
// Use -p '' to set a specific prompt we can detect
|
||||
// Use sh -c 'printf SFTPREADY; exec ...' to synchronize the start of sftp-server
|
||||
// We use printf instead of echo to avoid trailing newline which could confuse SFTPWrapper
|
||||
const prompt = "SUDOPASSWORD:";
|
||||
const readyMarker = "SFTPREADY";
|
||||
const readyMarkerBuffer = Buffer.from(readyMarker);
|
||||
// Add -e to sftp-server to log to stderr for debugging
|
||||
const cmd = `sudo -S -p '${prompt}' sh -c 'printf ${readyMarker}; exec ${serverPath} -e'`;
|
||||
|
||||
console.log(`[SFTP] Executing sudo command: ${cmd}`);
|
||||
|
||||
// Disable pty to ensure clean binary stream for SFTP
|
||||
client.exec(cmd, { pty: false }, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
// Add stream lifecycle logging
|
||||
stream.on('close', () => console.log("[SFTP] Stream closed"));
|
||||
stream.on('end', () => console.log("[SFTP] Stream ended"));
|
||||
stream.on('error', (e) => console.error("[SFTP] Stream error:", e.message));
|
||||
|
||||
let sftpInitialized = false;
|
||||
let sftp = null;
|
||||
let settled = false;
|
||||
let stdoutBuffer = Buffer.alloc(0);
|
||||
let stderrBuffer = "";
|
||||
let pendingAfterMarker = null;
|
||||
let sftpCreated = false;
|
||||
const timeoutMs = 20000;
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (sftpInitialized || settled) return;
|
||||
settled = true;
|
||||
stream.stderr?.removeListener('data', onStderr);
|
||||
stream.removeListener('data', onStdout);
|
||||
const error = new Error("SFTP sudo handshake timed out. This may happen if: (1) the password is incorrect, (2) sudo requires a TTY, or (3) the user does not have sudo privileges.");
|
||||
reject(error);
|
||||
}, timeoutMs);
|
||||
|
||||
const finalize = (err, result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
stream.stderr?.removeListener('data', onStderr);
|
||||
stream.removeListener('data', onStdout);
|
||||
if (err) reject(err);
|
||||
else resolve(result);
|
||||
};
|
||||
|
||||
const createSftp = () => {
|
||||
if (sftpCreated) return;
|
||||
sftpCreated = true;
|
||||
try {
|
||||
const chanInfo = {
|
||||
type: 'sftp',
|
||||
incoming: stream.incoming,
|
||||
outgoing: stream.outgoing
|
||||
};
|
||||
sftp = new SFTPWrapper(client, chanInfo, {
|
||||
// debug: (str) => console.log(`[SFTP DEBUG] ${str}`)
|
||||
});
|
||||
|
||||
// Route any remaining channel data directly into the SFTP parser
|
||||
if (client._chanMgr && typeof stream.incoming?.id === "number") {
|
||||
client._chanMgr.update(stream.incoming.id, sftp);
|
||||
}
|
||||
|
||||
sftp.on('ready', () => {
|
||||
sftpInitialized = true;
|
||||
console.log("[SFTP] Protocol ready");
|
||||
finalize(null, sftp);
|
||||
});
|
||||
|
||||
sftp.on('error', (err) => {
|
||||
console.error("[SFTP] Protocol error:", err.message);
|
||||
if (!sftpInitialized) {
|
||||
finalize(err);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
try { sftp.push(null); } catch { }
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[SFTP] Initialization failed:", e.message);
|
||||
finalize(e);
|
||||
}
|
||||
};
|
||||
|
||||
const initSftp = () => {
|
||||
if (sftpInitialized) return;
|
||||
console.log("[SFTP] Sudo success, initializing SFTP protocol...");
|
||||
if (!sftpCreated) createSftp();
|
||||
try {
|
||||
// Start the handshake
|
||||
console.log("[SFTP] Sending INIT packet...");
|
||||
sftp._init();
|
||||
if (pendingAfterMarker && pendingAfterMarker.length > 0) {
|
||||
try {
|
||||
sftp.push(pendingAfterMarker);
|
||||
} catch (pushErr) {
|
||||
console.warn("[SFTP] Failed to push buffered data:", pushErr.message);
|
||||
}
|
||||
pendingAfterMarker = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[SFTP] Initialization failed:", e.message);
|
||||
finalize(e);
|
||||
}
|
||||
};
|
||||
|
||||
const onStdout = (data) => {
|
||||
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
stdoutBuffer = stdoutBuffer.length > 0 ? Buffer.concat([stdoutBuffer, chunk]) : chunk;
|
||||
const markerIndex = stdoutBuffer.indexOf(readyMarkerBuffer);
|
||||
if (markerIndex !== -1) {
|
||||
const afterMarkerIndex = markerIndex + readyMarkerBuffer.length;
|
||||
if (afterMarkerIndex < stdoutBuffer.length) {
|
||||
pendingAfterMarker = stdoutBuffer.subarray(afterMarkerIndex);
|
||||
}
|
||||
// Found marker, stop listening to stdout here so SFTPWrapper can take over
|
||||
stream.removeListener('data', onStdout);
|
||||
stdoutBuffer = Buffer.alloc(0);
|
||||
|
||||
console.log("[SFTP] SFTPREADY detected, waiting for stream to stabilize...");
|
||||
|
||||
// Delay SFTP initialization to ensure sftp-server is fully started and stream is clean
|
||||
// Increased timeout to 1000ms to be safe
|
||||
setTimeout(() => {
|
||||
initSftp();
|
||||
}, 1000);
|
||||
} else if (stdoutBuffer.length > 256) {
|
||||
stdoutBuffer = stdoutBuffer.subarray(stdoutBuffer.length - 256);
|
||||
}
|
||||
};
|
||||
|
||||
const onStderr = (data) => {
|
||||
const chunk = data.toString();
|
||||
// Only log that we received stderr data, not the content (may contain sensitive prompts)
|
||||
stderrBuffer += chunk;
|
||||
if (stderrBuffer.includes(prompt)) {
|
||||
console.log("[SFTP] Sudo requested password, sending...");
|
||||
// Send password
|
||||
if (password) {
|
||||
stream.write(password + '\n');
|
||||
} else {
|
||||
console.warn('[SFTP] sudo requested password but none provided');
|
||||
stream.write('\n');
|
||||
}
|
||||
stderrBuffer = "";
|
||||
} else if (stderrBuffer.length > 256) {
|
||||
stderrBuffer = stderrBuffer.slice(-256);
|
||||
}
|
||||
};
|
||||
|
||||
stream.on('data', onStdout);
|
||||
stream.stderr.on('data', onStderr);
|
||||
|
||||
// Error handling
|
||||
stream.on('exit', (code) => {
|
||||
console.log(`[SFTP] Stream exited with code ${code}`);
|
||||
if (!sftpInitialized && code !== 0) {
|
||||
let errorMsg = `SFTP sudo failed with exit code ${code}.`;
|
||||
if (code === 1) {
|
||||
errorMsg += " The password may be incorrect or sudo privileges are denied.";
|
||||
} else if (code === 127) {
|
||||
errorMsg += " sftp-server was not found on the remote system.";
|
||||
}
|
||||
const error = new Error(errorMsg);
|
||||
finalize(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a new SFTP connection
|
||||
* Supports jump host connections when options.jumpHosts is provided
|
||||
@@ -292,15 +425,15 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
async function openSftp(event, options) {
|
||||
const client = new SftpClient();
|
||||
const connId = options.sessionId || `${Date.now()}-sftp-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
|
||||
// Check if we need to connect through jump hosts
|
||||
const jumpHosts = options.jumpHosts || [];
|
||||
const hasJumpHosts = jumpHosts.length > 0;
|
||||
const hasProxy = !!options.proxy;
|
||||
|
||||
|
||||
let chainConnections = [];
|
||||
let connectionSocket = null;
|
||||
|
||||
|
||||
// Handle chain/proxy connections
|
||||
if (hasJumpHosts) {
|
||||
console.log(`[SFTP] Opening connection through ${jumpHosts.length} jump host(s) to ${options.hostname}:${options.port || 22}`);
|
||||
@@ -321,13 +454,16 @@ async function openSftp(event, options) {
|
||||
options.port || 22
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const connectOpts = {
|
||||
host: options.hostname,
|
||||
port: options.port || 22,
|
||||
username: options.username || "root",
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 120000, // 2 minutes for 2FA input
|
||||
};
|
||||
|
||||
|
||||
// Use the tunneled socket if we have one
|
||||
if (connectionSocket) {
|
||||
connectOpts.sock = connectionSocket;
|
||||
@@ -335,7 +471,7 @@ async function openSftp(event, options) {
|
||||
delete connectOpts.host;
|
||||
delete connectOpts.port;
|
||||
}
|
||||
|
||||
|
||||
const hasCertificate = typeof options.certificate === "string" && options.certificate.trim().length > 0;
|
||||
|
||||
let authAgent = null;
|
||||
@@ -362,20 +498,125 @@ async function openSftp(event, options) {
|
||||
const order = ["agent"];
|
||||
if (connectOpts.password) order.push("password");
|
||||
connectOpts.authHandler = order;
|
||||
} else if (options.privateKey && connectOpts.password) {
|
||||
// Prefer key auth when both key and password are present (password still needed for sudo)
|
||||
connectOpts.authHandler = ["publickey", "password"];
|
||||
}
|
||||
|
||||
|
||||
// Add keyboard-interactive authentication support
|
||||
// ssh2-sftp-client exposes the underlying ssh2 Client through its `on` method
|
||||
const kiHandler = (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`[SFTP] ${options.hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })),
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`[SFTP] No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
|
||||
// (Prompt text is admin-customizable and may not contain expected keywords)
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('sftp');
|
||||
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`[SFTP] Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, event.sender.id, connId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`[SFTP] Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(event.sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId: connId,
|
||||
name: name || "",
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
hostname: options.hostname,
|
||||
savedPassword: options.password || null,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// Add keyboard-interactive listener BEFORE connecting
|
||||
client.on("keyboard-interactive", kiHandler);
|
||||
|
||||
// Enable keyboard-interactive authentication in authHandler
|
||||
if (connectOpts.authHandler) {
|
||||
// Add keyboard-interactive after the existing methods
|
||||
if (!connectOpts.authHandler.includes("keyboard-interactive")) {
|
||||
connectOpts.authHandler.push("keyboard-interactive");
|
||||
}
|
||||
} else {
|
||||
// Create authHandler with keyboard-interactive support
|
||||
const authMethods = [];
|
||||
if (connectOpts.privateKey) authMethods.push("publickey");
|
||||
if (connectOpts.password) authMethods.push("password");
|
||||
authMethods.push("keyboard-interactive");
|
||||
connectOpts.authHandler = authMethods;
|
||||
}
|
||||
|
||||
// Increase timeout to allow for keyboard-interactive auth
|
||||
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
|
||||
|
||||
try {
|
||||
await client.connect(connectOpts);
|
||||
|
||||
if (options.sudo) {
|
||||
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
|
||||
const sshClient = client.client;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
// Set up error handler for initial connection
|
||||
const onConnectError = (err) => reject(err);
|
||||
sshClient.once('error', onConnectError);
|
||||
|
||||
sshClient.once('ready', async () => {
|
||||
sshClient.removeListener('error', onConnectError);
|
||||
try {
|
||||
// Use provided password or try empty if using key auth (and hope for nopasswd sudo)
|
||||
const sudoPass = options.password || "";
|
||||
const sftpWrapper = await connectSudoSftp(sshClient, sudoPass);
|
||||
|
||||
// Inject into sftp-client
|
||||
client.sftp = sftpWrapper;
|
||||
|
||||
// Important: attach cleanup listener expected by sftp-client
|
||||
client.sftp.on('close', () => client.end());
|
||||
|
||||
resolve();
|
||||
} catch (e) {
|
||||
sshClient.end();
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
sshClient.connect(connectOpts);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await client.connect(connectOpts);
|
||||
}
|
||||
// Increase max listeners AFTER connect, when the internal ssh2 Client exists
|
||||
// This prevents Node.js MaxListenersExceededWarning when performing many operations
|
||||
// ssh2-sftp-client adds temporary listeners for each operation, so we need a high limit
|
||||
if (client.client && typeof client.client.setMaxListeners === 'function') {
|
||||
client.client.setMaxListeners(0); // 0 means unlimited
|
||||
}
|
||||
|
||||
|
||||
sftpClients.set(connId, client);
|
||||
|
||||
|
||||
// Store jump connections for cleanup when SFTP is closed
|
||||
if (chainConnections.length > 0) {
|
||||
jumpConnectionsMap.set(connId, {
|
||||
@@ -383,7 +624,7 @@ async function openSftp(event, options) {
|
||||
socket: connectionSocket
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
console.log(`[SFTP] Connection established: ${connId}`);
|
||||
return { sftpId: connId };
|
||||
} catch (err) {
|
||||
@@ -402,15 +643,15 @@ async function openSftp(event, options) {
|
||||
async function listSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
|
||||
const list = await client.list(payload.path || ".");
|
||||
const basePath = payload.path || ".";
|
||||
|
||||
|
||||
// Process items and resolve symlinks
|
||||
const results = await Promise.all(list.map(async (item) => {
|
||||
let type;
|
||||
let linkTarget = null;
|
||||
|
||||
|
||||
if (item.type === "d") {
|
||||
type = "directory";
|
||||
} else if (item.type === "l") {
|
||||
@@ -433,7 +674,7 @@ async function listSftp(event, payload) {
|
||||
} else {
|
||||
type = "file";
|
||||
}
|
||||
|
||||
|
||||
// Extract permissions from longname or rights
|
||||
let permissions = undefined;
|
||||
if (item.rights) {
|
||||
@@ -446,7 +687,7 @@ async function listSftp(event, payload) {
|
||||
permissions = match[1];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
name: item.name,
|
||||
type,
|
||||
@@ -456,7 +697,7 @@ async function listSftp(event, payload) {
|
||||
permissions,
|
||||
};
|
||||
}));
|
||||
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -489,7 +730,7 @@ async function readSftpBinary(event, payload) {
|
||||
async function writeSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
|
||||
await client.put(Buffer.from(payload.content, "utf-8"), payload.path);
|
||||
return true;
|
||||
}
|
||||
@@ -500,14 +741,14 @@ async function writeSftp(event, payload) {
|
||||
async function writeSftpBinaryWithProgress(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
|
||||
const { sftpId, path: remotePath, content, transferId } = payload;
|
||||
const buffer = Buffer.from(content);
|
||||
const totalBytes = buffer.length;
|
||||
let transferredBytes = 0;
|
||||
let lastProgressTime = Date.now();
|
||||
let lastTransferredBytes = 0;
|
||||
|
||||
|
||||
const { Readable } = require("stream");
|
||||
const readableStream = new Readable({
|
||||
read() {
|
||||
@@ -516,7 +757,7 @@ async function writeSftpBinaryWithProgress(event, payload) {
|
||||
const end = Math.min(transferredBytes + chunkSize, totalBytes);
|
||||
const chunk = buffer.slice(transferredBytes, end);
|
||||
transferredBytes = end;
|
||||
|
||||
|
||||
const now = Date.now();
|
||||
const elapsed = (now - lastProgressTime) / 1000;
|
||||
let speed = 0;
|
||||
@@ -525,7 +766,7 @@ async function writeSftpBinaryWithProgress(event, payload) {
|
||||
lastProgressTime = now;
|
||||
lastTransferredBytes = transferredBytes;
|
||||
}
|
||||
|
||||
|
||||
const contents = electronModule.webContents.fromId(event.sender.id);
|
||||
contents?.send("netcatty:upload:progress", {
|
||||
transferId,
|
||||
@@ -533,20 +774,20 @@ async function writeSftpBinaryWithProgress(event, payload) {
|
||||
totalBytes,
|
||||
speed,
|
||||
});
|
||||
|
||||
|
||||
this.push(chunk);
|
||||
} else {
|
||||
this.push(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
try {
|
||||
await client.put(readableStream, remotePath);
|
||||
|
||||
|
||||
const contents = electronModule.webContents.fromId(event.sender.id);
|
||||
contents?.send("netcatty:upload:complete", { transferId });
|
||||
|
||||
|
||||
return { success: true, transferId };
|
||||
} catch (err) {
|
||||
const contents = electronModule.webContents.fromId(event.sender.id);
|
||||
@@ -562,21 +803,21 @@ async function writeSftpBinaryWithProgress(event, payload) {
|
||||
async function closeSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) return;
|
||||
|
||||
|
||||
// Stop file watchers and clean up temp files for this SFTP session
|
||||
try {
|
||||
fileWatcherBridge.stopWatchersForSession(payload.sftpId, true);
|
||||
} catch (err) {
|
||||
console.warn("[SFTP] Error stopping file watchers:", err.message);
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
await client.end();
|
||||
} catch (err) {
|
||||
console.warn("SFTP close failed", err);
|
||||
}
|
||||
sftpClients.delete(payload.sftpId);
|
||||
|
||||
|
||||
// Clean up jump connections if any
|
||||
const jumpData = jumpConnectionsMap.get(payload.sftpId);
|
||||
if (jumpData) {
|
||||
@@ -594,7 +835,7 @@ async function closeSftp(event, payload) {
|
||||
async function mkdirSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
|
||||
await client.mkdir(payload.path, true);
|
||||
return true;
|
||||
}
|
||||
@@ -605,7 +846,7 @@ async function mkdirSftp(event, payload) {
|
||||
async function deleteSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
|
||||
const stat = await client.stat(payload.path);
|
||||
if (stat.isDirectory) {
|
||||
await client.rmdir(payload.path, true);
|
||||
@@ -621,7 +862,7 @@ async function deleteSftp(event, payload) {
|
||||
async function renameSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
|
||||
await client.rename(payload.oldPath, payload.newPath);
|
||||
return true;
|
||||
}
|
||||
@@ -632,7 +873,7 @@ async function renameSftp(event, payload) {
|
||||
async function statSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
|
||||
const stat = await client.stat(payload.path);
|
||||
return {
|
||||
name: path.basename(payload.path),
|
||||
@@ -649,7 +890,7 @@ async function statSftp(event, payload) {
|
||||
async function chmodSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
|
||||
await client.chmod(payload.path, parseInt(payload.mode, 8));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { Client: SSHClient, utils: sshUtils } = require("ssh2");
|
||||
const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
|
||||
// Simple file logger for debugging
|
||||
const logFile = path.join(require("os").tmpdir(), "netcatty-ssh.log");
|
||||
@@ -49,122 +51,6 @@ function init(deps) {
|
||||
electronModule = deps.electronModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a socket through a proxy (HTTP CONNECT or SOCKS5)
|
||||
*/
|
||||
function createProxySocket(proxy, targetHost, targetPort) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (proxy.type === 'http') {
|
||||
// HTTP CONNECT proxy
|
||||
const socket = net.connect(proxy.port, proxy.host, () => {
|
||||
let authHeader = '';
|
||||
if (proxy.username && proxy.password) {
|
||||
const auth = Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64');
|
||||
authHeader = `Proxy-Authorization: Basic ${auth}\r\n`;
|
||||
}
|
||||
const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${authHeader}\r\n`;
|
||||
socket.write(connectRequest);
|
||||
|
||||
let response = '';
|
||||
const onData = (data) => {
|
||||
response += data.toString();
|
||||
if (response.includes('\r\n\r\n')) {
|
||||
socket.removeListener('data', onData);
|
||||
if (response.startsWith('HTTP/1.1 200') || response.startsWith('HTTP/1.0 200')) {
|
||||
resolve(socket);
|
||||
} else {
|
||||
socket.destroy();
|
||||
reject(new Error(`HTTP proxy error: ${response.split('\r\n')[0]}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
socket.on('data', onData);
|
||||
});
|
||||
socket.on('error', reject);
|
||||
} else if (proxy.type === 'socks5') {
|
||||
// SOCKS5 proxy
|
||||
const socket = net.connect(proxy.port, proxy.host, () => {
|
||||
// SOCKS5 greeting
|
||||
const authMethods = proxy.username && proxy.password ? [0x00, 0x02] : [0x00];
|
||||
socket.write(Buffer.from([0x05, authMethods.length, ...authMethods]));
|
||||
|
||||
let step = 'greeting';
|
||||
const onData = (data) => {
|
||||
if (step === 'greeting') {
|
||||
if (data[0] !== 0x05) {
|
||||
socket.destroy();
|
||||
reject(new Error('Invalid SOCKS5 response'));
|
||||
return;
|
||||
}
|
||||
const method = data[1];
|
||||
if (method === 0x02 && proxy.username && proxy.password) {
|
||||
// Username/password auth
|
||||
step = 'auth';
|
||||
const userBuf = Buffer.from(proxy.username);
|
||||
const passBuf = Buffer.from(proxy.password);
|
||||
socket.write(Buffer.concat([
|
||||
Buffer.from([0x01, userBuf.length]),
|
||||
userBuf,
|
||||
Buffer.from([passBuf.length]),
|
||||
passBuf
|
||||
]));
|
||||
} else if (method === 0x00) {
|
||||
// No auth, proceed to connect
|
||||
step = 'connect';
|
||||
sendConnectRequest();
|
||||
} else {
|
||||
socket.destroy();
|
||||
reject(new Error('SOCKS5 authentication method not supported'));
|
||||
}
|
||||
} else if (step === 'auth') {
|
||||
if (data[1] !== 0x00) {
|
||||
socket.destroy();
|
||||
reject(new Error('SOCKS5 authentication failed'));
|
||||
return;
|
||||
}
|
||||
step = 'connect';
|
||||
sendConnectRequest();
|
||||
} else if (step === 'connect') {
|
||||
socket.removeListener('data', onData);
|
||||
if (data[1] === 0x00) {
|
||||
resolve(socket);
|
||||
} else {
|
||||
const errors = {
|
||||
0x01: 'General failure',
|
||||
0x02: 'Connection not allowed',
|
||||
0x03: 'Network unreachable',
|
||||
0x04: 'Host unreachable',
|
||||
0x05: 'Connection refused',
|
||||
0x06: 'TTL expired',
|
||||
0x07: 'Command not supported',
|
||||
0x08: 'Address type not supported',
|
||||
};
|
||||
socket.destroy();
|
||||
reject(new Error(`SOCKS5 error: ${errors[data[1]] || 'Unknown'}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sendConnectRequest = () => {
|
||||
// SOCKS5 connect request
|
||||
const hostBuf = Buffer.from(targetHost);
|
||||
const request = Buffer.concat([
|
||||
Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]),
|
||||
hostBuf,
|
||||
Buffer.from([(targetPort >> 8) & 0xff, targetPort & 0xff])
|
||||
]);
|
||||
socket.write(request);
|
||||
};
|
||||
|
||||
socket.on('data', onData);
|
||||
});
|
||||
socket.on('error', reject);
|
||||
} else {
|
||||
reject(new Error(`Unknown proxy type: ${proxy.type}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect through a chain of jump hosts
|
||||
*/
|
||||
@@ -203,6 +89,8 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
// If 0 or not provided, use 10000ms as default
|
||||
keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
|
||||
keepaliveCountMax: 3,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
|
||||
@@ -361,6 +249,8 @@ async function startSSHSession(event, options) {
|
||||
// If 0 or not provided, use 10000ms as default
|
||||
keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
|
||||
keepaliveCountMax: 3,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
|
||||
@@ -616,6 +506,68 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
});
|
||||
|
||||
// Handle keyboard-interactive authentication (2FA/MFA)
|
||||
conn.on("keyboard-interactive", (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`${logPrefix} ${options.hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })),
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`${logPrefix} No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
|
||||
// (Prompt text is admin-customizable and may not contain expected keywords)
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
|
||||
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, sender.id, sessionId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId,
|
||||
name: name || "",
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
hostname: options.hostname,
|
||||
savedPassword: options.password || null, // Pass saved password for optional fill button
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Enable keyboard-interactive authentication in authHandler
|
||||
if (connectOpts.authHandler) {
|
||||
// Add keyboard-interactive after the existing methods
|
||||
if (!connectOpts.authHandler.includes("keyboard-interactive")) {
|
||||
connectOpts.authHandler.push("keyboard-interactive");
|
||||
}
|
||||
} else {
|
||||
// Create authHandler with keyboard-interactive support
|
||||
const authMethods = [];
|
||||
if (connectOpts.privateKey) authMethods.push("publickey");
|
||||
if (connectOpts.password) authMethods.push("password");
|
||||
authMethods.push("keyboard-interactive");
|
||||
connectOpts.authHandler = authMethods;
|
||||
}
|
||||
|
||||
// Increase timeout to allow for keyboard-interactive auth
|
||||
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
|
||||
|
||||
console.log(`${logPrefix} Connecting to ${options.hostname}...`);
|
||||
conn.connect(connectOpts);
|
||||
});
|
||||
@@ -879,6 +831,8 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:ssh:exec", execCommand);
|
||||
ipcMain.handle("netcatty:ssh:pwd", getSessionPwd);
|
||||
ipcMain.handle("netcatty:key:generate", generateKeyPair);
|
||||
// Register the shared keyboard-interactive response handler
|
||||
keyboardInteractiveHandler.registerHandler(ipcMain);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -9,6 +9,7 @@ const chainProgressListeners = new Map();
|
||||
const authFailedListeners = new Map();
|
||||
const languageChangeListeners = new Set();
|
||||
const fullscreenChangeListeners = new Set();
|
||||
const keyboardInteractiveListeners = new Set();
|
||||
|
||||
ipcRenderer.on("netcatty:data", (_event, payload) => {
|
||||
const set = dataListeners.get(payload.sessionId);
|
||||
@@ -86,6 +87,17 @@ ipcRenderer.on("netcatty:auth:failed", (_event, payload) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard-interactive authentication events (2FA/MFA)
|
||||
ipcRenderer.on("netcatty:keyboard-interactive", (_event, payload) => {
|
||||
keyboardInteractiveListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Keyboard-interactive callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Transfer progress events
|
||||
ipcRenderer.on("netcatty:transfer:progress", (_event, payload) => {
|
||||
const cb = transferProgressListeners.get(payload.transferId);
|
||||
@@ -285,6 +297,18 @@ const api = {
|
||||
authFailedListeners.get(sessionId).add(cb);
|
||||
return () => authFailedListeners.get(sessionId)?.delete(cb);
|
||||
},
|
||||
// Keyboard-interactive authentication (2FA/MFA)
|
||||
onKeyboardInteractive: (cb) => {
|
||||
keyboardInteractiveListeners.add(cb);
|
||||
return () => keyboardInteractiveListeners.delete(cb);
|
||||
},
|
||||
respondKeyboardInteractive: async (requestId, responses, cancelled = false) => {
|
||||
return ipcRenderer.invoke("netcatty:keyboard-interactive:respond", {
|
||||
requestId,
|
||||
responses,
|
||||
cancelled,
|
||||
});
|
||||
},
|
||||
openSftp: async (options) => {
|
||||
const result = await ipcRenderer.invoke("netcatty:sftp:open", options);
|
||||
return result.sftpId;
|
||||
|
||||
@@ -7,7 +7,7 @@ import reactHooks from "eslint-plugin-react-hooks";
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**"],
|
||||
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
|
||||
860
global.d.ts
vendored
@@ -2,443 +2,463 @@ import type { RemoteFile } from "./types";
|
||||
import type { S3Config, SMBConfig, SyncedFile, WebDAVConfig } from "./domain/sync";
|
||||
|
||||
declare global {
|
||||
// Proxy configuration for SSH connections
|
||||
interface NetcattyProxyConfig {
|
||||
type: 'http' | 'socks5';
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
// Jump host configuration for SSH tunneling
|
||||
interface NetcattyJumpHost {
|
||||
hostname: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
passphrase?: string;
|
||||
publicKey?: string;
|
||||
keyId?: string;
|
||||
keySource?: 'generated' | 'imported';
|
||||
label?: string; // Display label for UI
|
||||
}
|
||||
|
||||
// Host key information for verification
|
||||
// Reserved for future host key verification UI feature
|
||||
interface _NetcattyHostKeyInfo {
|
||||
hostname: string;
|
||||
port: number;
|
||||
keyType: string;
|
||||
fingerprint: string;
|
||||
publicKey?: string;
|
||||
}
|
||||
|
||||
interface NetcattySSHOptions {
|
||||
sessionId?: string;
|
||||
hostname: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
// Optional OpenSSH user certificate
|
||||
certificate?: string;
|
||||
publicKey?: string; // OpenSSH public key line
|
||||
keyId?: string;
|
||||
keySource?: 'generated' | 'imported';
|
||||
agentForwarding?: boolean;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
extraArgs?: string[];
|
||||
startupCommand?: string;
|
||||
passphrase?: string;
|
||||
// Environment variables to set in the remote shell
|
||||
env?: Record<string, string>;
|
||||
// Proxy configuration
|
||||
proxy?: NetcattyProxyConfig;
|
||||
// Jump hosts (bastion chain)
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
// SSH-level keepalive interval in seconds (0 = disabled)
|
||||
keepaliveInterval?: number;
|
||||
}
|
||||
|
||||
interface SftpStatResult {
|
||||
name: string;
|
||||
type: 'file' | 'directory' | 'symlink';
|
||||
size: number;
|
||||
lastModified: number; // timestamp
|
||||
permissions?: string; // e.g., "rwxr-xr-x"
|
||||
owner?: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
interface SftpTransferProgress {
|
||||
transferId: string;
|
||||
bytesTransferred: number;
|
||||
totalBytes: number;
|
||||
speed: number; // bytes per second
|
||||
}
|
||||
|
||||
// Port Forwarding Types
|
||||
interface PortForwardOptions {
|
||||
tunnelId: string;
|
||||
type: 'local' | 'remote' | 'dynamic';
|
||||
localPort: number;
|
||||
bindAddress?: string;
|
||||
remoteHost?: string;
|
||||
remotePort?: number;
|
||||
// SSH connection details
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
}
|
||||
|
||||
interface PortForwardResult {
|
||||
tunnelId: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface PortForwardStatusResult {
|
||||
tunnelId: string;
|
||||
status: 'inactive' | 'connecting' | 'active' | 'error';
|
||||
type?: 'local' | 'remote' | 'dynamic';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type PortForwardStatusCallback = (status: 'inactive' | 'connecting' | 'active' | 'error', error?: string) => void;
|
||||
|
||||
interface NetcattyBridge {
|
||||
startSSHSession(options: NetcattySSHOptions): Promise<string>;
|
||||
startTelnetSession?(options: {
|
||||
sessionId?: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<string>;
|
||||
startMoshSession?(options: {
|
||||
sessionId?: string;
|
||||
hostname: string;
|
||||
// Proxy configuration for SSH connections
|
||||
interface NetcattyProxyConfig {
|
||||
type: 'http' | 'socks5';
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
port?: number;
|
||||
moshServerPath?: string;
|
||||
agentForwarding?: boolean;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<string>;
|
||||
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string> }): Promise<string>;
|
||||
startSerialSession?(options: {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
// Jump host configuration for SSH tunneling
|
||||
interface NetcattyJumpHost {
|
||||
hostname: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
passphrase?: string;
|
||||
publicKey?: string;
|
||||
keyId?: string;
|
||||
keySource?: 'generated' | 'imported';
|
||||
label?: string; // Display label for UI
|
||||
}
|
||||
|
||||
// Host key information for verification
|
||||
// Reserved for future host key verification UI feature
|
||||
interface _NetcattyHostKeyInfo {
|
||||
hostname: string;
|
||||
port: number;
|
||||
keyType: string;
|
||||
fingerprint: string;
|
||||
publicKey?: string;
|
||||
}
|
||||
|
||||
interface NetcattySSHOptions {
|
||||
sessionId?: string;
|
||||
path: string;
|
||||
baudRate?: number;
|
||||
dataBits?: 5 | 6 | 7 | 8;
|
||||
stopBits?: 1 | 1.5 | 2;
|
||||
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space';
|
||||
flowControl?: 'none' | 'xon/xoff' | 'rts/cts';
|
||||
}): Promise<string>;
|
||||
listSerialPorts?(): Promise<Array<{
|
||||
path: string;
|
||||
manufacturer: string;
|
||||
serialNumber: string;
|
||||
vendorId: string;
|
||||
productId: string;
|
||||
pnpId: string;
|
||||
}>>;
|
||||
getDefaultShell?(): Promise<string>;
|
||||
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean }>;
|
||||
generateKeyPair?(options: {
|
||||
type: 'RSA' | 'ECDSA' | 'ED25519';
|
||||
bits?: number;
|
||||
comment?: string;
|
||||
}): Promise<{ success: boolean; privateKey?: string; publicKey?: string; error?: string }>;
|
||||
execCommand(options: {
|
||||
hostname: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
command: string;
|
||||
timeout?: number;
|
||||
}): Promise<{ stdout: string; stderr: string; code: number | null }>;
|
||||
/** Get current working directory from an active SSH session */
|
||||
getSessionPwd?(sessionId: string): Promise<{ success: boolean; cwd?: string; error?: string }>;
|
||||
writeToSession(sessionId: string, data: string): void;
|
||||
resizeSession(sessionId: string, cols: number, rows: number): void;
|
||||
closeSession(sessionId: string): void;
|
||||
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
|
||||
onSessionExit(
|
||||
sessionId: string,
|
||||
cb: (evt: { exitCode?: number; signal?: number }) => void
|
||||
): () => void;
|
||||
onAuthFailed?(
|
||||
sessionId: string,
|
||||
cb: (evt: { sessionId: string; error: string; hostname: string }) => void
|
||||
): () => void;
|
||||
|
||||
// SFTP operations
|
||||
openSftp(options: NetcattySSHOptions): Promise<string>;
|
||||
listSftp(sftpId: string, path: string): Promise<RemoteFile[]>;
|
||||
readSftp(sftpId: string, path: string): Promise<string>;
|
||||
readSftpBinary?(sftpId: string, path: string): Promise<ArrayBuffer>;
|
||||
writeSftp(sftpId: string, path: string, content: string): Promise<void>;
|
||||
writeSftpBinary?(sftpId: string, path: string, content: ArrayBuffer): Promise<void>;
|
||||
closeSftp(sftpId: string): Promise<void>;
|
||||
mkdirSftp(sftpId: string, path: string): Promise<void>;
|
||||
deleteSftp?(sftpId: string, path: string): Promise<void>;
|
||||
renameSftp?(sftpId: string, oldPath: string, newPath: string): Promise<void>;
|
||||
statSftp?(sftpId: string, path: string): Promise<SftpStatResult>;
|
||||
chmodSftp?(sftpId: string, path: string, mode: string): Promise<void>;
|
||||
|
||||
// Write binary with real-time progress callback
|
||||
writeSftpBinaryWithProgress?(
|
||||
sftpId: string,
|
||||
path: string,
|
||||
content: ArrayBuffer,
|
||||
transferId: string,
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void
|
||||
): Promise<{ success: boolean; transferId: string }>;
|
||||
|
||||
// Transfer with progress
|
||||
uploadFile?(sftpId: string, localPath: string, remotePath: string, transferId: string): Promise<void>;
|
||||
downloadFile?(sftpId: string, remotePath: string, localPath: string, transferId: string): Promise<void>;
|
||||
cancelTransfer?(transferId: string): Promise<void>;
|
||||
onTransferProgress?(transferId: string, cb: (progress: SftpTransferProgress) => void): () => void;
|
||||
|
||||
// Streaming transfer with real progress and cancellation
|
||||
startStreamTransfer?(
|
||||
options: {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
sourceType: 'local' | 'sftp';
|
||||
targetType: 'local' | 'sftp';
|
||||
sourceSftpId?: string;
|
||||
targetSftpId?: string;
|
||||
totalBytes?: number;
|
||||
},
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void
|
||||
): Promise<{ transferId: string; totalBytes?: number; error?: string }>;
|
||||
|
||||
// Local filesystem operations
|
||||
listLocalDir?(path: string): Promise<RemoteFile[]>;
|
||||
readLocalFile?(path: string): Promise<ArrayBuffer>;
|
||||
writeLocalFile?(path: string, content: ArrayBuffer): Promise<void>;
|
||||
deleteLocalFile?(path: string): Promise<void>;
|
||||
renameLocalFile?(oldPath: string, newPath: string): Promise<void>;
|
||||
mkdirLocal?(path: string): Promise<void>;
|
||||
statLocal?(path: string): Promise<SftpStatResult>;
|
||||
getHomeDir?(): Promise<string>;
|
||||
getSystemInfo?(): Promise<{ username: string; hostname: string }>;
|
||||
|
||||
setTheme?(theme: 'light' | 'dark'): Promise<boolean>;
|
||||
setBackgroundColor?(color: string): Promise<boolean>;
|
||||
setLanguage?(language: string): Promise<boolean>;
|
||||
// Window controls for custom title bar (Windows/Linux)
|
||||
windowMinimize?(): Promise<void>;
|
||||
windowMaximize?(): Promise<boolean>;
|
||||
windowClose?(): Promise<void>;
|
||||
windowIsMaximized?(): Promise<boolean>;
|
||||
windowIsFullscreen?(): Promise<boolean>;
|
||||
onWindowFullScreenChanged?(cb: (isFullscreen: boolean) => void): () => void;
|
||||
|
||||
// Settings window
|
||||
openSettingsWindow?(): Promise<boolean>;
|
||||
closeSettingsWindow?(): Promise<void>;
|
||||
// Optional OpenSSH user certificate
|
||||
certificate?: string;
|
||||
publicKey?: string; // OpenSSH public key line
|
||||
keyId?: string;
|
||||
keySource?: 'generated' | 'imported';
|
||||
agentForwarding?: boolean;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
extraArgs?: string[];
|
||||
startupCommand?: string;
|
||||
passphrase?: string;
|
||||
// Environment variables to set in the remote shell
|
||||
env?: Record<string, string>;
|
||||
// Proxy configuration
|
||||
proxy?: NetcattyProxyConfig;
|
||||
// Jump hosts (bastion chain)
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
// SSH-level keepalive interval in seconds (0 = disabled)
|
||||
keepaliveInterval?: number;
|
||||
// Use sudo for SFTP server
|
||||
sudo?: boolean;
|
||||
}
|
||||
|
||||
// Cross-window settings sync
|
||||
notifySettingsChanged?(payload: { key: string; value: unknown }): void;
|
||||
onSettingsChanged?(cb: (payload: { key: string; value: unknown }) => void): () => void;
|
||||
interface SftpStatResult {
|
||||
name: string;
|
||||
type: 'file' | 'directory' | 'symlink';
|
||||
size: number;
|
||||
lastModified: number; // timestamp
|
||||
permissions?: string; // e.g., "rwxr-xr-x"
|
||||
owner?: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
// Cloud sync master password (stored in-memory + persisted via Electron safeStorage)
|
||||
cloudSyncSetSessionPassword?(password: string): Promise<boolean>;
|
||||
cloudSyncGetSessionPassword?(): Promise<string | null>;
|
||||
cloudSyncClearSessionPassword?(): Promise<boolean>;
|
||||
interface SftpTransferProgress {
|
||||
transferId: string;
|
||||
bytesTransferred: number;
|
||||
totalBytes: number;
|
||||
speed: number; // bytes per second
|
||||
}
|
||||
|
||||
// Cloud sync network operations (proxied via main process)
|
||||
cloudSyncWebdavInitialize?(config: WebDAVConfig): Promise<{ resourceId: string | null }>;
|
||||
cloudSyncWebdavUpload?(
|
||||
config: WebDAVConfig,
|
||||
syncedFile: SyncedFile
|
||||
): Promise<{ resourceId: string }>;
|
||||
cloudSyncWebdavDownload?(config: WebDAVConfig): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncWebdavDelete?(config: WebDAVConfig): Promise<{ ok: true }>;
|
||||
// Port Forwarding Types
|
||||
interface PortForwardOptions {
|
||||
tunnelId: string;
|
||||
type: 'local' | 'remote' | 'dynamic';
|
||||
localPort: number;
|
||||
bindAddress?: string;
|
||||
remoteHost?: string;
|
||||
remotePort?: number;
|
||||
// SSH connection details
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
}
|
||||
|
||||
cloudSyncS3Initialize?(config: S3Config): Promise<{ resourceId: string | null }>;
|
||||
cloudSyncS3Upload?(
|
||||
config: S3Config,
|
||||
syncedFile: SyncedFile
|
||||
): Promise<{ resourceId: string }>;
|
||||
cloudSyncS3Download?(config: S3Config): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncS3Delete?(config: S3Config): Promise<{ ok: true }>;
|
||||
|
||||
cloudSyncSmbInitialize?(config: SMBConfig): Promise<{ resourceId: string | null }>;
|
||||
cloudSyncSmbUpload?(
|
||||
config: SMBConfig,
|
||||
syncedFile: SyncedFile
|
||||
): Promise<{ resourceId: string }>;
|
||||
cloudSyncSmbDownload?(config: SMBConfig): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncSmbDelete?(config: SMBConfig): Promise<{ ok: true }>;
|
||||
|
||||
// Port Forwarding
|
||||
startPortForward?(options: PortForwardOptions): Promise<PortForwardResult>;
|
||||
stopPortForward?(tunnelId: string): Promise<PortForwardResult>;
|
||||
getPortForwardStatus?(tunnelId: string): Promise<PortForwardStatusResult>;
|
||||
listPortForwards?(): Promise<{ tunnelId: string; type: string; status: string }[]>;
|
||||
onPortForwardStatus?(tunnelId: string, cb: PortForwardStatusCallback): () => void;
|
||||
|
||||
// Known Hosts
|
||||
readKnownHosts?(): Promise<string | null>;
|
||||
|
||||
// Open URL in default browser
|
||||
openExternal?(url: string): Promise<void>;
|
||||
|
||||
// App info (name/version/platform) for About screens
|
||||
getAppInfo?(): Promise<{ name: string; version: string; platform: string }>;
|
||||
|
||||
// Notify main process the renderer has mounted/painted (used to avoid initial blank screen).
|
||||
rendererReady?(): void;
|
||||
|
||||
onLanguageChanged?(cb: (language: string) => void): () => void;
|
||||
|
||||
// Chain progress listener for jump host connections
|
||||
// Callback receives: (currentHop: number, totalHops: number, hostLabel: string, status: string)
|
||||
onChainProgress?(cb: (hop: number, total: number, label: string, status: string) => void): () => void;
|
||||
|
||||
// OAuth callback server for cloud sync
|
||||
startOAuthCallback?(expectedState?: string): Promise<{ code: string; state?: string }>;
|
||||
cancelOAuthCallback?(): Promise<void>;
|
||||
|
||||
// GitHub Device Flow (cloud sync)
|
||||
githubStartDeviceFlow?(options?: { clientId?: string; scope?: string }): Promise<{
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
expiresAt: number;
|
||||
interval: number;
|
||||
}>;
|
||||
githubPollDeviceFlowToken?(options: { clientId?: string; deviceCode: string }): Promise<{
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
scope?: string;
|
||||
interface PortForwardResult {
|
||||
tunnelId: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Google OAuth (cloud sync) - proxied via main process to avoid CORS
|
||||
googleExchangeCodeForTokens?(options: {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
googleRefreshAccessToken?(options: {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
refreshToken: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
googleGetUserInfo?(options: { accessToken: string }): Promise<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
picture?: string;
|
||||
}>;
|
||||
interface PortForwardStatusResult {
|
||||
tunnelId: string;
|
||||
status: 'inactive' | 'connecting' | 'active' | 'error';
|
||||
type?: 'local' | 'remote' | 'dynamic';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Google Drive API (cloud sync) - proxied via main process to avoid CORS/COEP issues
|
||||
googleDriveFindSyncFile?(options: { accessToken: string; fileName?: string }): Promise<{ fileId: string | null }>;
|
||||
googleDriveCreateSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string }>;
|
||||
googleDriveUpdateSyncFile?(options: { accessToken: string; fileId: string; syncedFile: unknown }): Promise<{ ok: true }>;
|
||||
googleDriveDownloadSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ syncedFile: unknown | null }>;
|
||||
googleDriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
|
||||
type PortForwardStatusCallback = (status: 'inactive' | 'connecting' | 'active' | 'error', error?: string) => void;
|
||||
|
||||
// OneDrive OAuth + Graph (cloud sync) - proxied via main process to avoid CORS
|
||||
onedriveExchangeCodeForTokens?(options: {
|
||||
clientId: string;
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
scope?: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
onedriveRefreshAccessToken?(options: {
|
||||
clientId: string;
|
||||
refreshToken: string;
|
||||
scope?: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
onedriveGetUserInfo?(options: { accessToken: string }): Promise<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatarDataUrl?: string;
|
||||
}>;
|
||||
onedriveFindSyncFile?(options: { accessToken: string; fileName?: string }): Promise<{ fileId: string | null }>;
|
||||
onedriveUploadSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string | null }>;
|
||||
onedriveDownloadSyncFile?(options: { accessToken: string; fileId?: string; fileName?: string }): Promise<{ syncedFile: unknown | null }>;
|
||||
onedriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
|
||||
interface NetcattyBridge {
|
||||
startSSHSession(options: NetcattySSHOptions): Promise<string>;
|
||||
startTelnetSession?(options: {
|
||||
sessionId?: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<string>;
|
||||
startMoshSession?(options: {
|
||||
sessionId?: string;
|
||||
hostname: string;
|
||||
username?: string;
|
||||
port?: number;
|
||||
moshServerPath?: string;
|
||||
agentForwarding?: boolean;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<string>;
|
||||
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string> }): Promise<string>;
|
||||
startSerialSession?(options: {
|
||||
sessionId?: string;
|
||||
path: string;
|
||||
baudRate?: number;
|
||||
dataBits?: 5 | 6 | 7 | 8;
|
||||
stopBits?: 1 | 1.5 | 2;
|
||||
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space';
|
||||
flowControl?: 'none' | 'xon/xoff' | 'rts/cts';
|
||||
}): Promise<string>;
|
||||
listSerialPorts?(): Promise<Array<{
|
||||
path: string;
|
||||
manufacturer: string;
|
||||
serialNumber: string;
|
||||
vendorId: string;
|
||||
productId: string;
|
||||
pnpId: string;
|
||||
}>>;
|
||||
getDefaultShell?(): Promise<string>;
|
||||
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean }>;
|
||||
generateKeyPair?(options: {
|
||||
type: 'RSA' | 'ECDSA' | 'ED25519';
|
||||
bits?: number;
|
||||
comment?: string;
|
||||
}): Promise<{ success: boolean; privateKey?: string; publicKey?: string; error?: string }>;
|
||||
execCommand(options: {
|
||||
hostname: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
command: string;
|
||||
timeout?: number;
|
||||
}): Promise<{ stdout: string; stderr: string; code: number | null }>;
|
||||
/** Get current working directory from an active SSH session */
|
||||
getSessionPwd?(sessionId: string): Promise<{ success: boolean; cwd?: string; error?: string }>;
|
||||
writeToSession(sessionId: string, data: string): void;
|
||||
resizeSession(sessionId: string, cols: number, rows: number): void;
|
||||
closeSession(sessionId: string): void;
|
||||
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
|
||||
onSessionExit(
|
||||
sessionId: string,
|
||||
cb: (evt: { exitCode?: number; signal?: number }) => void
|
||||
): () => void;
|
||||
onAuthFailed?(
|
||||
sessionId: string,
|
||||
cb: (evt: { sessionId: string; error: string; hostname: string }) => void
|
||||
): () => void;
|
||||
|
||||
// File opener helpers (for "Open With" feature)
|
||||
selectApplication?(): Promise<{ path: string; name: string } | null>;
|
||||
openWithApplication?(filePath: string, appPath: string): Promise<boolean>;
|
||||
downloadSftpToTemp?(sftpId: string, remotePath: string, fileName: string): Promise<string>;
|
||||
|
||||
// File watcher for auto-sync feature
|
||||
startFileWatch?(localPath: string, remotePath: string, sftpId: string): Promise<{ watchId: string }>;
|
||||
stopFileWatch?(watchId: string, cleanupTempFile?: boolean): Promise<{ success: boolean }>;
|
||||
listFileWatches?(): Promise<Array<{ watchId: string; localPath: string; remotePath: string; sftpId: string }>>;
|
||||
registerTempFile?(sftpId: string, localPath: string): Promise<{ success: boolean }>;
|
||||
onFileWatchSynced?(cb: (payload: { watchId: string; localPath: string; remotePath: string; bytesWritten: number }) => void): () => void;
|
||||
onFileWatchError?(cb: (payload: { watchId: string; localPath: string; remotePath: string; error: string }) => void): () => void;
|
||||
|
||||
// Temp file cleanup
|
||||
deleteTempFile?(filePath: string): Promise<{ success: boolean }>;
|
||||
|
||||
// Temp directory management
|
||||
getTempDirInfo?(): Promise<{ path: string; fileCount: number; totalSize: number }>;
|
||||
clearTempDir?(): Promise<{ deletedCount: number; failedCount: number; error?: string }>;
|
||||
getTempDirPath?(): Promise<string>;
|
||||
openTempDir?(): Promise<{ success: boolean }>;
|
||||
}
|
||||
// Keyboard-interactive authentication (2FA/MFA)
|
||||
onKeyboardInteractive?(
|
||||
cb: (request: {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
name: string;
|
||||
instructions: string;
|
||||
prompts: Array<{ prompt: string; echo: boolean }>;
|
||||
hostname: string;
|
||||
savedPassword?: string | null;
|
||||
}) => void
|
||||
): () => void;
|
||||
respondKeyboardInteractive?(
|
||||
requestId: string,
|
||||
responses: string[],
|
||||
cancelled?: boolean
|
||||
): Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
interface Window {
|
||||
netcatty?: NetcattyBridge;
|
||||
}
|
||||
// SFTP operations
|
||||
openSftp(options: NetcattySSHOptions): Promise<string>;
|
||||
listSftp(sftpId: string, path: string): Promise<RemoteFile[]>;
|
||||
readSftp(sftpId: string, path: string): Promise<string>;
|
||||
readSftpBinary?(sftpId: string, path: string): Promise<ArrayBuffer>;
|
||||
writeSftp(sftpId: string, path: string, content: string): Promise<void>;
|
||||
writeSftpBinary?(sftpId: string, path: string, content: ArrayBuffer): Promise<void>;
|
||||
closeSftp(sftpId: string): Promise<void>;
|
||||
mkdirSftp(sftpId: string, path: string): Promise<void>;
|
||||
deleteSftp?(sftpId: string, path: string): Promise<void>;
|
||||
renameSftp?(sftpId: string, oldPath: string, newPath: string): Promise<void>;
|
||||
statSftp?(sftpId: string, path: string): Promise<SftpStatResult>;
|
||||
chmodSftp?(sftpId: string, path: string, mode: string): Promise<void>;
|
||||
|
||||
// Write binary with real-time progress callback
|
||||
writeSftpBinaryWithProgress?(
|
||||
sftpId: string,
|
||||
path: string,
|
||||
content: ArrayBuffer,
|
||||
transferId: string,
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void
|
||||
): Promise<{ success: boolean; transferId: string }>;
|
||||
|
||||
// Transfer with progress
|
||||
uploadFile?(sftpId: string, localPath: string, remotePath: string, transferId: string): Promise<void>;
|
||||
downloadFile?(sftpId: string, remotePath: string, localPath: string, transferId: string): Promise<void>;
|
||||
cancelTransfer?(transferId: string): Promise<void>;
|
||||
onTransferProgress?(transferId: string, cb: (progress: SftpTransferProgress) => void): () => void;
|
||||
|
||||
// Streaming transfer with real progress and cancellation
|
||||
startStreamTransfer?(
|
||||
options: {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
sourceType: 'local' | 'sftp';
|
||||
targetType: 'local' | 'sftp';
|
||||
sourceSftpId?: string;
|
||||
targetSftpId?: string;
|
||||
totalBytes?: number;
|
||||
},
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void
|
||||
): Promise<{ transferId: string; totalBytes?: number; error?: string }>;
|
||||
|
||||
// Local filesystem operations
|
||||
listLocalDir?(path: string): Promise<RemoteFile[]>;
|
||||
readLocalFile?(path: string): Promise<ArrayBuffer>;
|
||||
writeLocalFile?(path: string, content: ArrayBuffer): Promise<void>;
|
||||
deleteLocalFile?(path: string): Promise<void>;
|
||||
renameLocalFile?(oldPath: string, newPath: string): Promise<void>;
|
||||
mkdirLocal?(path: string): Promise<void>;
|
||||
statLocal?(path: string): Promise<SftpStatResult>;
|
||||
getHomeDir?(): Promise<string>;
|
||||
getSystemInfo?(): Promise<{ username: string; hostname: string }>;
|
||||
|
||||
setTheme?(theme: 'light' | 'dark'): Promise<boolean>;
|
||||
setBackgroundColor?(color: string): Promise<boolean>;
|
||||
setLanguage?(language: string): Promise<boolean>;
|
||||
// Window controls for custom title bar (Windows/Linux)
|
||||
windowMinimize?(): Promise<void>;
|
||||
windowMaximize?(): Promise<boolean>;
|
||||
windowClose?(): Promise<void>;
|
||||
windowIsMaximized?(): Promise<boolean>;
|
||||
windowIsFullscreen?(): Promise<boolean>;
|
||||
onWindowFullScreenChanged?(cb: (isFullscreen: boolean) => void): () => void;
|
||||
|
||||
// Settings window
|
||||
openSettingsWindow?(): Promise<boolean>;
|
||||
closeSettingsWindow?(): Promise<void>;
|
||||
|
||||
// Cross-window settings sync
|
||||
notifySettingsChanged?(payload: { key: string; value: unknown }): void;
|
||||
onSettingsChanged?(cb: (payload: { key: string; value: unknown }) => void): () => void;
|
||||
|
||||
// Cloud sync master password (stored in-memory + persisted via Electron safeStorage)
|
||||
cloudSyncSetSessionPassword?(password: string): Promise<boolean>;
|
||||
cloudSyncGetSessionPassword?(): Promise<string | null>;
|
||||
cloudSyncClearSessionPassword?(): Promise<boolean>;
|
||||
|
||||
// Cloud sync network operations (proxied via main process)
|
||||
cloudSyncWebdavInitialize?(config: WebDAVConfig): Promise<{ resourceId: string | null }>;
|
||||
cloudSyncWebdavUpload?(
|
||||
config: WebDAVConfig,
|
||||
syncedFile: SyncedFile
|
||||
): Promise<{ resourceId: string }>;
|
||||
cloudSyncWebdavDownload?(config: WebDAVConfig): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncWebdavDelete?(config: WebDAVConfig): Promise<{ ok: true }>;
|
||||
|
||||
cloudSyncS3Initialize?(config: S3Config): Promise<{ resourceId: string | null }>;
|
||||
cloudSyncS3Upload?(
|
||||
config: S3Config,
|
||||
syncedFile: SyncedFile
|
||||
): Promise<{ resourceId: string }>;
|
||||
cloudSyncS3Download?(config: S3Config): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncS3Delete?(config: S3Config): Promise<{ ok: true }>;
|
||||
|
||||
cloudSyncSmbInitialize?(config: SMBConfig): Promise<{ resourceId: string | null }>;
|
||||
cloudSyncSmbUpload?(
|
||||
config: SMBConfig,
|
||||
syncedFile: SyncedFile
|
||||
): Promise<{ resourceId: string }>;
|
||||
cloudSyncSmbDownload?(config: SMBConfig): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncSmbDelete?(config: SMBConfig): Promise<{ ok: true }>;
|
||||
|
||||
// Port Forwarding
|
||||
startPortForward?(options: PortForwardOptions): Promise<PortForwardResult>;
|
||||
stopPortForward?(tunnelId: string): Promise<PortForwardResult>;
|
||||
getPortForwardStatus?(tunnelId: string): Promise<PortForwardStatusResult>;
|
||||
listPortForwards?(): Promise<{ tunnelId: string; type: string; status: string }[]>;
|
||||
onPortForwardStatus?(tunnelId: string, cb: PortForwardStatusCallback): () => void;
|
||||
|
||||
// Known Hosts
|
||||
readKnownHosts?(): Promise<string | null>;
|
||||
|
||||
// Open URL in default browser
|
||||
openExternal?(url: string): Promise<void>;
|
||||
|
||||
// App info (name/version/platform) for About screens
|
||||
getAppInfo?(): Promise<{ name: string; version: string; platform: string }>;
|
||||
|
||||
// Notify main process the renderer has mounted/painted (used to avoid initial blank screen).
|
||||
rendererReady?(): void;
|
||||
|
||||
onLanguageChanged?(cb: (language: string) => void): () => void;
|
||||
|
||||
// Chain progress listener for jump host connections
|
||||
// Callback receives: (currentHop: number, totalHops: number, hostLabel: string, status: string)
|
||||
onChainProgress?(cb: (hop: number, total: number, label: string, status: string) => void): () => void;
|
||||
|
||||
// OAuth callback server for cloud sync
|
||||
startOAuthCallback?(expectedState?: string): Promise<{ code: string; state?: string }>;
|
||||
cancelOAuthCallback?(): Promise<void>;
|
||||
|
||||
// GitHub Device Flow (cloud sync)
|
||||
githubStartDeviceFlow?(options?: { clientId?: string; scope?: string }): Promise<{
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
expiresAt: number;
|
||||
interval: number;
|
||||
}>;
|
||||
githubPollDeviceFlowToken?(options: { clientId?: string; deviceCode: string }): Promise<{
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
scope?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}>;
|
||||
|
||||
// Google OAuth (cloud sync) - proxied via main process to avoid CORS
|
||||
googleExchangeCodeForTokens?(options: {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
googleRefreshAccessToken?(options: {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
refreshToken: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
googleGetUserInfo?(options: { accessToken: string }): Promise<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
picture?: string;
|
||||
}>;
|
||||
|
||||
// Google Drive API (cloud sync) - proxied via main process to avoid CORS/COEP issues
|
||||
googleDriveFindSyncFile?(options: { accessToken: string; fileName?: string }): Promise<{ fileId: string | null }>;
|
||||
googleDriveCreateSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string }>;
|
||||
googleDriveUpdateSyncFile?(options: { accessToken: string; fileId: string; syncedFile: unknown }): Promise<{ ok: true }>;
|
||||
googleDriveDownloadSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ syncedFile: unknown | null }>;
|
||||
googleDriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
|
||||
|
||||
// OneDrive OAuth + Graph (cloud sync) - proxied via main process to avoid CORS
|
||||
onedriveExchangeCodeForTokens?(options: {
|
||||
clientId: string;
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
scope?: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
onedriveRefreshAccessToken?(options: {
|
||||
clientId: string;
|
||||
refreshToken: string;
|
||||
scope?: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
onedriveGetUserInfo?(options: { accessToken: string }): Promise<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatarDataUrl?: string;
|
||||
}>;
|
||||
onedriveFindSyncFile?(options: { accessToken: string; fileName?: string }): Promise<{ fileId: string | null }>;
|
||||
onedriveUploadSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string | null }>;
|
||||
onedriveDownloadSyncFile?(options: { accessToken: string; fileId?: string; fileName?: string }): Promise<{ syncedFile: unknown | null }>;
|
||||
onedriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
|
||||
|
||||
// File opener helpers (for "Open With" feature)
|
||||
selectApplication?(): Promise<{ path: string; name: string } | null>;
|
||||
openWithApplication?(filePath: string, appPath: string): Promise<boolean>;
|
||||
downloadSftpToTemp?(sftpId: string, remotePath: string, fileName: string): Promise<string>;
|
||||
|
||||
// File watcher for auto-sync feature
|
||||
startFileWatch?(localPath: string, remotePath: string, sftpId: string): Promise<{ watchId: string }>;
|
||||
stopFileWatch?(watchId: string, cleanupTempFile?: boolean): Promise<{ success: boolean }>;
|
||||
listFileWatches?(): Promise<Array<{ watchId: string; localPath: string; remotePath: string; sftpId: string }>>;
|
||||
registerTempFile?(sftpId: string, localPath: string): Promise<{ success: boolean }>;
|
||||
onFileWatchSynced?(cb: (payload: { watchId: string; localPath: string; remotePath: string; bytesWritten: number }) => void): () => void;
|
||||
onFileWatchError?(cb: (payload: { watchId: string; localPath: string; remotePath: string; error: string }) => void): () => void;
|
||||
|
||||
// Temp file cleanup
|
||||
deleteTempFile?(filePath: string): Promise<{ success: boolean }>;
|
||||
|
||||
// Temp directory management
|
||||
getTempDirInfo?(): Promise<{ path: string; fileCount: number; totalSize: number }>;
|
||||
clearTempDir?(): Promise<{ deletedCount: number; failedCount: number; error?: string }>;
|
||||
getTempDirPath?(): Promise<string>;
|
||||
openTempDir?(): Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
netcatty?: NetcattyBridge;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -216,6 +216,13 @@ const BASE_TERMINAL_FONTS: TerminalFont[] = [
|
||||
description: 'Highly customizable monospace font',
|
||||
category: 'monospace',
|
||||
},
|
||||
{
|
||||
id: 'ioskeley-mono',
|
||||
name: 'Ioskeley Mono',
|
||||
family: '"Ioskeley Mono", monospace',
|
||||
description: 'Iosevka variant mimicking Berkeley Mono style',
|
||||
category: 'monospace',
|
||||
},
|
||||
{
|
||||
id: 'mononoki',
|
||||
name: 'Mononoki',
|
||||
|
||||
@@ -192,21 +192,21 @@ export function getFileExtension(fileName: string): string {
|
||||
*/
|
||||
export function isTextFile(fileName: string): boolean {
|
||||
const ext = getFileExtension(fileName);
|
||||
|
||||
|
||||
// Check known text extensions
|
||||
if (TEXT_EXTENSIONS.has(ext)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Check common filenames that are text but have no extension
|
||||
const baseName = fileName.toLowerCase().split('/').pop() || '';
|
||||
const nameWithoutExt = baseName.replace(/\.[^.]+$/, '');
|
||||
|
||||
|
||||
// Check exact filename matches
|
||||
if (TEXT_FILENAMES.has(baseName) || TEXT_FILENAMES.has(nameWithoutExt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Check dot-files that are typically text config files
|
||||
if (baseName.startsWith('.')) {
|
||||
const dotConfigPatterns = [
|
||||
@@ -218,7 +218,7 @@ export function isTextFile(fileName: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -233,42 +233,42 @@ export function isTextFile(fileName: string): boolean {
|
||||
export function isTextData(data: ArrayBuffer | Uint8Array, maxBytes: number = 512): boolean {
|
||||
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
||||
const checkLength = Math.min(bytes.length, maxBytes);
|
||||
|
||||
|
||||
if (checkLength === 0) return true; // Empty file is considered text
|
||||
|
||||
|
||||
let controlChars = 0;
|
||||
let nullBytes = 0;
|
||||
let highBytes = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
|
||||
for (let i = 0; i < checkLength; i++) {
|
||||
const byte = bytes[i];
|
||||
totalBytes++;
|
||||
|
||||
|
||||
// Null bytes are strong indicators of binary files
|
||||
if (byte === 0) {
|
||||
nullBytes++;
|
||||
if (nullBytes > 0) return false; // Even one null byte suggests binary
|
||||
}
|
||||
|
||||
|
||||
// Control characters (except common ones like \t, \n, \r)
|
||||
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
|
||||
controlChars++;
|
||||
}
|
||||
|
||||
|
||||
// High-bit characters (non-ASCII) - some are OK for UTF-8
|
||||
if (byte > 127) {
|
||||
highBytes++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If more than 30% are control chars or more than 95% are high-bit chars, likely binary
|
||||
const controlRatio = controlChars / totalBytes;
|
||||
const highRatio = highBytes / totalBytes;
|
||||
|
||||
|
||||
if (controlRatio > 0.3) return false;
|
||||
if (highRatio > 0.95) return false;
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -279,12 +279,12 @@ export function isTextData(data: ArrayBuffer | Uint8Array, maxBytes: number = 51
|
||||
export function isTextFileEnhanced(fileName: string, data?: ArrayBuffer | Uint8Array): boolean {
|
||||
// First check by extension
|
||||
const extCheck = isTextFile(fileName);
|
||||
|
||||
|
||||
// If we have data, verify it's actually text
|
||||
if (data && data.byteLength > 0) {
|
||||
return extCheck && isTextData(data);
|
||||
}
|
||||
|
||||
|
||||
// Fall back to extension-only check
|
||||
return extCheck;
|
||||
}
|
||||
@@ -419,8 +419,167 @@ export interface FileAssociation {
|
||||
export function getSupportedLanguages(): { id: string; name: string }[] {
|
||||
const languageIds = new Set(Object.values(EXTENSION_TO_LANGUAGE));
|
||||
languageIds.add('plaintext');
|
||||
|
||||
|
||||
return Array.from(languageIds)
|
||||
.map(id => ({ id, name: getLanguageName(id) }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a file or directory entry from drag-and-drop
|
||||
* This includes the relative path for nested files in folders
|
||||
*/
|
||||
export interface DropEntry {
|
||||
file: File | null; // null for directory entries
|
||||
relativePath: string; // Path relative to the root of the drop (e.g., "folder/subfolder/file.txt")
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read entries from a FileSystemDirectoryEntry recursively
|
||||
* Uses the webkitGetAsEntry API to access folder contents
|
||||
*/
|
||||
function readDirectoryEntries(
|
||||
directoryReader: FileSystemDirectoryReader
|
||||
): Promise<FileSystemEntry[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const allEntries: FileSystemEntry[] = [];
|
||||
|
||||
const readBatch = () => {
|
||||
directoryReader.readEntries(
|
||||
(entries) => {
|
||||
if (entries.length === 0) {
|
||||
resolve(allEntries);
|
||||
} else {
|
||||
allEntries.push(...entries);
|
||||
// Continue reading (readEntries may not return all entries at once)
|
||||
readBatch();
|
||||
}
|
||||
},
|
||||
(error) => reject(error)
|
||||
);
|
||||
};
|
||||
|
||||
readBatch();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a FileSystemEntry to a File
|
||||
*/
|
||||
function entryToFile(entry: FileSystemFileEntry): Promise<File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
entry.file(resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively process a FileSystemEntry and collect all files
|
||||
* @param entry - The file system entry to process
|
||||
* @param basePath - The base path (folder name) to prepend
|
||||
* @returns Array of DropEntry objects with files and their relative paths
|
||||
*/
|
||||
async function processEntry(
|
||||
entry: FileSystemEntry,
|
||||
basePath: string = ""
|
||||
): Promise<DropEntry[]> {
|
||||
const results: DropEntry[] = [];
|
||||
|
||||
if (entry.isFile) {
|
||||
const fileEntry = entry as FileSystemFileEntry;
|
||||
try {
|
||||
const file = await entryToFile(fileEntry);
|
||||
results.push({
|
||||
file,
|
||||
relativePath: basePath ? `${basePath}/${entry.name}` : entry.name,
|
||||
isDirectory: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`Failed to read file entry: ${entry.name}`, error);
|
||||
}
|
||||
} else if (entry.isDirectory) {
|
||||
const dirEntry = entry as FileSystemDirectoryEntry;
|
||||
const currentPath = basePath ? `${basePath}/${entry.name}` : entry.name;
|
||||
|
||||
// Add a placeholder for the directory itself (to ensure it gets created)
|
||||
results.push({
|
||||
file: null, // Directories don't have file content
|
||||
relativePath: currentPath,
|
||||
isDirectory: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const reader = dirEntry.createReader();
|
||||
const entries = await readDirectoryEntries(reader);
|
||||
|
||||
// Helper to yield to main thread - prevents UI freezing during large folder parsing
|
||||
const yieldToMain = () => new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
|
||||
// Process all entries in the directory with periodic yielding
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
// Yield every 10 entries to keep UI responsive
|
||||
if (i > 0 && i % 10 === 0) {
|
||||
await yieldToMain();
|
||||
}
|
||||
const childEntry = entries[i];
|
||||
const childResults = await processEntry(childEntry, currentPath);
|
||||
results.push(...childResults);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to read directory: ${entry.name}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all files and directories from a DataTransfer object
|
||||
* Supports both regular files and folders dropped from the OS
|
||||
*
|
||||
* Uses the webkitGetAsEntry() API for folder access, with fallback
|
||||
* to regular FileList for browsers that don't support it.
|
||||
*
|
||||
* @param dataTransfer - The DataTransfer object from a drop event
|
||||
* @returns Array of DropEntry objects with files and relative paths
|
||||
*/
|
||||
export async function extractDropEntries(
|
||||
dataTransfer: DataTransfer
|
||||
): Promise<DropEntry[]> {
|
||||
const items = dataTransfer.items;
|
||||
const results: DropEntry[] = [];
|
||||
|
||||
// Check if webkitGetAsEntry is supported (for folder access)
|
||||
if (items && items.length > 0 && typeof items[0].webkitGetAsEntry === 'function') {
|
||||
// Collect all entries first (getAsEntry must be called synchronously)
|
||||
const entries: FileSystemEntry[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'file') {
|
||||
const entry = item.webkitGetAsEntry();
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now process entries asynchronously
|
||||
for (const entry of entries) {
|
||||
const entryResults = await processEntry(entry);
|
||||
results.push(...entryResults);
|
||||
}
|
||||
} else {
|
||||
// Fallback: use regular FileList (no folder support)
|
||||
const files = dataTransfer.files;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
results.push({
|
||||
file,
|
||||
relativePath: file.name,
|
||||
isDirectory: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
11
lib/utils.ts
@@ -1,6 +1,15 @@
|
||||
import { type ClassValue,clsx } from "clsx"
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize line endings to LF (Unix style).
|
||||
* Converts CRLF (Windows) and standalone CR (old Mac) to LF.
|
||||
* Used for clipboard paste operations in terminal to avoid extra blank lines.
|
||||
*/
|
||||
export function normalizeLineEndings(text: string): string {
|
||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
}
|
||||
31
package-lock.json
generated
@@ -1004,7 +1004,6 @@
|
||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
@@ -1651,6 +1650,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1672,6 +1672,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1688,6 +1689,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1702,6 +1704,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -5584,7 +5587,6 @@
|
||||
"integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.53.0",
|
||||
@@ -5614,7 +5616,6 @@
|
||||
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.53.0",
|
||||
"@typescript-eslint/types": "8.53.0",
|
||||
@@ -5893,8 +5894,7 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/7zip-bin": {
|
||||
"version": "5.2.0",
|
||||
@@ -5919,7 +5919,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5952,7 +5951,6 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -6347,7 +6345,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -6971,7 +6968,8 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
@@ -7537,6 +7535,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -7557,6 +7556,7 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -7781,7 +7781,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -9930,7 +9929,6 @@
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
@@ -10456,7 +10454,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -10515,6 +10512,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -10532,6 +10530,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -10632,7 +10631,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -10642,7 +10640,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -11543,6 +11540,7 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -11606,6 +11604,7 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -11620,6 +11619,7 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -11768,7 +11768,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -11971,7 +11970,6 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -12310,7 +12308,6 @@
|
||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
BIN
screenshots/broadcast_mode.png
Normal file
|
After Width: | Height: | Size: 995 KiB |
BIN
screenshots/host_config_advanced.png
Normal file
|
After Width: | Height: | Size: 849 KiB |
BIN
screenshots/host_config_general.png
Normal file
|
After Width: | Height: | Size: 849 KiB |
BIN
screenshots/hybrid_taxonomy.png
Normal file
|
After Width: | Height: | Size: 871 KiB |
BIN
screenshots/key_generator_ui.png
Normal file
|
After Width: | Height: | Size: 657 KiB |
BIN
screenshots/keychain_overview.png
Normal file
|
After Width: | Height: | Size: 671 KiB |
BIN
screenshots/macos_gatekeeper_warning.png
Normal file
|
After Width: | Height: | Size: 814 KiB |
BIN
screenshots/monaco_editor.png
Normal file
|
After Width: | Height: | Size: 832 KiB |
BIN
screenshots/nested_folder_structure.png
Normal file
|
After Width: | Height: | Size: 897 KiB |
BIN
screenshots/serial_port_config.png
Normal file
|
After Width: | Height: | Size: 977 KiB |
BIN
screenshots/sftp_dual_pane.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
screenshots/sftp_transfer_queue.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
screenshots/ssh_import_config.png
Normal file
|
After Width: | Height: | Size: 906 KiB |
BIN
screenshots/terminal_performance.png
Normal file
|
After Width: | Height: | Size: 817 KiB |
BIN
screenshots/theme_color_picker.png
Normal file
|
After Width: | Height: | Size: 494 KiB |
BIN
screenshots/vault_grid_view.png
Normal file
|
After Width: | Height: | Size: 884 KiB |
BIN
screenshots/vault_list_view.png
Normal file
|
After Width: | Height: | Size: 776 KiB |
@@ -26,5 +26,10 @@
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"global.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
]
|
||||
}
|
||||