Compare commits
118 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dbda5bec3 | ||
|
|
2da63c0180 | ||
|
|
2af9cfccb3 | ||
|
|
ffb736eeea | ||
|
|
83cd65ef63 | ||
|
|
e46046081a | ||
|
|
7f75fadb31 | ||
|
|
2997ed6b3c | ||
|
|
2b03db1142 | ||
|
|
513309ba7c | ||
|
|
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 | ||
|
|
2fecbb94fb | ||
|
|
6bd3968e04 | ||
|
|
528cda1f70 | ||
|
|
29bde31989 | ||
|
|
b12a2171e7 | ||
|
|
ffcd94e216 | ||
|
|
9b77fc9e3b | ||
|
|
9e57f2eb90 | ||
|
|
8cf6e9243d | ||
|
|
7a32aa0743 | ||
|
|
a1d0ce02fe | ||
|
|
adb2bc9403 | ||
|
|
7a6ed660fb | ||
|
|
035b22b467 | ||
|
|
1bce2c9808 | ||
|
|
ca2d699e55 | ||
|
|
6907fb54c8 | ||
|
|
4bae2517fe | ||
|
|
da4936ff22 | ||
|
|
2223ec34f0 | ||
|
|
ca1423051d | ||
|
|
ca8b36c7d5 | ||
|
|
b96eaf2aca | ||
|
|
663fe88b2e | ||
|
|
42da477425 | ||
|
|
474a13e4f9 | ||
|
|
3c5e12cc8b | ||
|
|
c2a01d83d7 | ||
|
|
dcc3b6fce7 | ||
|
|
fb43b53f33 | ||
|
|
b90c29f56a | ||
|
|
37092826f3 | ||
|
|
30b809a8f6 | ||
|
|
989a1aa3d7 | ||
|
|
9e5c5f826f | ||
|
|
74e0249797 | ||
|
|
d89d6d3959 | ||
|
|
66680d585f | ||
|
|
57dd2fb48b | ||
|
|
6d973f9bc8 | ||
|
|
425647eeda | ||
|
|
9109aec4ab | ||
|
|
6d5283173a | ||
|
|
ad67099ff3 | ||
|
|
02d44652df | ||
|
|
d227424096 | ||
|
|
1105f7fbb1 | ||
|
|
ef681194e3 | ||
|
|
4971a72620 | ||
|
|
8947d29717 | ||
|
|
dfaeed1ed6 | ||
|
|
443e038dcf | ||
|
|
242d35927a | ||
|
|
708ee1cd09 | ||
|
|
a2c24c2656 | ||
|
|
d91ed8dd23 | ||
|
|
689bb313f7 |
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx tsc:*)"
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm run lint:*)",
|
||||
"Bash(npm run build:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
100
App.tsx
@@ -19,8 +19,9 @@ 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, TerminalTheme } from './types';
|
||||
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
|
||||
import { LogView as LogViewType } from './application/state/useSessionState';
|
||||
import type { SftpView as SftpViewComponent } from './components/SftpView';
|
||||
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
|
||||
@@ -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,6 +665,25 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host) => {
|
||||
const { username, hostname: localHost } = systemInfoRef.current;
|
||||
|
||||
// Handle serial hosts separately
|
||||
if (host.protocol === 'serial') {
|
||||
const portName = host.hostname.split('/').pop() || host.hostname;
|
||||
addConnectionLog({
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: host.hostname,
|
||||
username: username,
|
||||
protocol: 'serial',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
connectToHost(host);
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host, keys, identities });
|
||||
addConnectionLog({
|
||||
@@ -635,6 +700,24 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
connectToHost(host);
|
||||
}, [addConnectionLog, connectToHost, identities, keys]);
|
||||
|
||||
// Wrapper to create serial session with logging
|
||||
const handleConnectSerial = useCallback((config: SerialConfig) => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
const portName = config.path.split('/').pop() || config.path;
|
||||
addConnectionLog({
|
||||
hostId: '',
|
||||
hostLabel: `Serial: ${portName}`,
|
||||
hostname: config.path,
|
||||
username: username,
|
||||
protocol: 'serial',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: hostname,
|
||||
saved: false,
|
||||
});
|
||||
createSerialSession(config);
|
||||
}, [addConnectionLog, createSerialSession]);
|
||||
|
||||
// Handle terminal data capture when session exits
|
||||
const handleTerminalDataCapture = useCallback((sessionId: string, data: string) => {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
|
||||
@@ -776,7 +859,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
onConnectSerial={createSerialSession}
|
||||
onConnectSerial={handleConnectSerial}
|
||||
onDeleteHost={handleDeleteHost}
|
||||
onConnect={handleConnectToHost}
|
||||
onUpdateHosts={updateHosts}
|
||||
@@ -952,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>
|
||||
# 开源协议
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ This project is wired around three layers: domain (pure logic), application stat
|
||||
## Data & Storage
|
||||
- Persisted keys: see `storageKeys.ts`. Use `localStorageAdapter` for all reads/writes.
|
||||
- Seed data: `config/defaultData.ts`; terminal themes: `config/terminalThemes.ts`.
|
||||
- **Temporary files**: All temporary files (e.g., SFTP downloaded files for external editing) must be written to Netcatty's dedicated temp directory via `tempDirBridge.getTempFilePath(fileName)`. Do not write directly to `os.tmpdir()`. This ensures proper cleanup and user visibility in Settings > System.
|
||||
|
||||
## Testing & Safety
|
||||
- Favor unit tests for domain helpers (e.g., `workspace.ts`, `host.ts`) and hook-level tests for application state.
|
||||
|
||||
@@ -61,6 +61,21 @@ const en: Messages = {
|
||||
'settings.tab.terminal': 'Terminal',
|
||||
'settings.tab.shortcuts': 'Shortcuts',
|
||||
'settings.tab.syncCloud': 'Sync & Cloud',
|
||||
'settings.tab.system': 'System',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': 'System',
|
||||
'settings.system.description': 'System information and temporary file management.',
|
||||
'settings.system.tempDirectory': 'Temporary Files',
|
||||
'settings.system.location': 'Location',
|
||||
'settings.system.fileCount': 'Files',
|
||||
'settings.system.totalSize': 'Size',
|
||||
'settings.system.openFolder': 'Open folder',
|
||||
'settings.system.refresh': 'Refresh',
|
||||
'settings.system.clearTempFiles': 'Clear temp files',
|
||||
'settings.system.clearing': 'Clearing...',
|
||||
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
|
||||
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
|
||||
|
||||
// Settings > Application
|
||||
'settings.application.checkUpdates': 'Check for updates',
|
||||
@@ -107,6 +122,10 @@ const en: Messages = {
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': 'Terminal Theme',
|
||||
'settings.terminal.themeModal.title': 'Select Theme',
|
||||
'settings.terminal.themeModal.darkThemes': 'Dark Themes',
|
||||
'settings.terminal.themeModal.lightThemes': 'Light Themes',
|
||||
'settings.terminal.theme.selectButton': 'Select Theme',
|
||||
'settings.terminal.section.font': 'Font',
|
||||
'settings.terminal.section.cursor': 'Cursor',
|
||||
'settings.terminal.section.keyboard': 'Keyboard',
|
||||
@@ -380,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',
|
||||
@@ -414,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',
|
||||
@@ -426,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',
|
||||
@@ -506,7 +534,7 @@ const en: Messages = {
|
||||
'settings.sftpFileAssociations.noAssociations': 'No file associations configured',
|
||||
'settings.sftpFileAssociations.remove': 'Remove',
|
||||
'settings.sftpFileAssociations.removeConfirm': 'Remove association for .{ext}?',
|
||||
|
||||
|
||||
// Settings > SFTP Behavior
|
||||
'settings.sftp.doubleClickBehavior': 'Double-click behavior',
|
||||
'settings.sftp.doubleClickBehavior.desc': 'Choose the action when double-clicking a file in SFTP View',
|
||||
@@ -515,6 +543,35 @@ const en: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.openDesc': 'Open the file in the default application',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': 'Auto-sync to remote',
|
||||
'settings.sftp.autoSync.desc': 'Automatically sync file changes back to the remote server when opening files with external applications',
|
||||
'settings.sftp.autoSync.enable': 'Enable auto-sync',
|
||||
'settings.sftp.autoSync.enableDesc': 'When you save a file in an external application, changes will be automatically uploaded to the remote server',
|
||||
'sftp.autoSync.success': 'File synced to remote: {fileName}',
|
||||
'sftp.autoSync.error': 'Failed to sync file: {error}',
|
||||
|
||||
// SFTP Folder Upload Progress
|
||||
'sftp.upload.progress': 'Uploading {current} of {total} files...',
|
||||
'sftp.upload.currentFile': 'Current: {fileName}',
|
||||
'sftp.upload.cancelled': 'Upload cancelled',
|
||||
'sftp.upload.cancel': 'Cancel',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': 'Reconnecting...',
|
||||
'sftp.reconnecting.desc': 'Connection lost, attempting to reconnect',
|
||||
'sftp.reconnected': 'Connection restored',
|
||||
'sftp.error.reconnectFailed': 'Failed to reconnect. Please try again.',
|
||||
'sftp.error.connectionLostManual': 'Connection lost. Please reconnect manually.',
|
||||
'sftp.error.connectionLostReconnecting': 'Connection lost. Reconnecting...',
|
||||
'sftp.error.sessionLost': 'SFTP session lost. Please reconnect.',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': 'Show hidden files',
|
||||
'settings.sftp.showHiddenFiles.desc': 'Display files with the Windows hidden attribute in the SFTP file browser when browsing local Windows filesystem.',
|
||||
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': 'Display Windows hidden files when browsing local filesystem',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.recentConnections': 'Recent connections',
|
||||
@@ -553,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',
|
||||
@@ -571,16 +632,20 @@ 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.agentForwarding.agentNotRunning': 'SSH Agent is not running',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': 'Enable OpenSSH Authentication Agent service in Windows Services (services.msc) for agent forwarding to work.',
|
||||
'hostDetails.section.agentForwarding': 'SSH Agent',
|
||||
'hostDetails.jumpHosts': 'Proxy via Hosts',
|
||||
'hostDetails.jumpHosts.hops': '{count} hop(s)',
|
||||
'hostDetails.jumpHosts.direct': 'Direct',
|
||||
'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',
|
||||
@@ -621,6 +686,12 @@ const en: Messages = {
|
||||
'hostDetails.telnet.password': 'Telnet Password',
|
||||
'hostDetails.charset.placeholder': 'Charset (e.g. UTF-8)',
|
||||
'hostDetails.telnet.add': 'Add Telnet Protocol',
|
||||
'hostDetails.tags': 'Tags',
|
||||
'hostDetails.group': 'Group',
|
||||
'hostDetails.selectGroup': 'Select Group',
|
||||
'hostDetails.addTag': 'Add a tag...',
|
||||
'hostDetails.createTag': 'Create tag',
|
||||
'hostDetails.createGroup': 'Create group',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': 'Edit Host',
|
||||
@@ -1034,11 +1105,12 @@ const en: Messages = {
|
||||
'serial.field.baudRate': 'Baud Rate',
|
||||
'serial.field.dataBits': 'Data Bits',
|
||||
'serial.field.stopBits': 'Stop Bits',
|
||||
'serial.field.stopBits15Warning': '1.5 stop bits may not be supported on all Windows devices',
|
||||
'serial.field.parity': 'Parity',
|
||||
'serial.field.flowControl': 'Flow Control',
|
||||
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
|
||||
'serial.field.customPort': 'Custom Port Path',
|
||||
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001',
|
||||
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001 or COM1',
|
||||
'serial.type.hardware': 'Hardware',
|
||||
'serial.type.pseudo': 'Pseudo Terminal',
|
||||
'serial.type.custom': 'Custom',
|
||||
@@ -1055,6 +1127,29 @@ const en: Messages = {
|
||||
'serial.field.lineMode': 'Line Mode',
|
||||
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
|
||||
'serial.connectionError': 'Failed to connect to serial port',
|
||||
'serial.field.baudRatePlaceholder': 'Select or enter baud rate...',
|
||||
'serial.field.baudRateEmpty': 'Enter a custom baud rate',
|
||||
'serial.field.customBaudRate': 'Using custom baud rate',
|
||||
'serial.field.saveConfig': 'Save Configuration',
|
||||
'serial.field.saveConfigDesc': 'Save this serial configuration to hosts for quick access',
|
||||
'serial.field.configLabel': 'Configuration Name',
|
||||
'serial.field.configLabelPlaceholder': 'e.g. Arduino Uno',
|
||||
'serial.connectAndSave': 'Connect & Save',
|
||||
'serial.edit.title': 'Serial Port Settings',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': 'Authentication Required',
|
||||
'keyboard.interactive.desc': 'The server requires additional authentication.',
|
||||
'keyboard.interactive.descWithHost': 'The server {hostname} requires additional authentication.',
|
||||
'keyboard.interactive.response': 'Response',
|
||||
'keyboard.interactive.enterCode': 'Enter verification code',
|
||||
'keyboard.interactive.enterResponse': 'Enter response',
|
||||
'keyboard.interactive.submit': 'Submit',
|
||||
'keyboard.interactive.verifying': 'Verifying...',
|
||||
'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;
|
||||
|
||||
@@ -49,6 +49,21 @@ const zhCN: Messages = {
|
||||
'settings.tab.terminal': '终端',
|
||||
'settings.tab.shortcuts': '快捷键',
|
||||
'settings.tab.syncCloud': '同步与云',
|
||||
'settings.tab.system': '系统',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': '系统',
|
||||
'settings.system.description': '系统信息与临时文件管理。',
|
||||
'settings.system.tempDirectory': '临时文件',
|
||||
'settings.system.location': '位置',
|
||||
'settings.system.fileCount': '文件数量',
|
||||
'settings.system.totalSize': '占用空间',
|
||||
'settings.system.openFolder': '打开文件夹',
|
||||
'settings.system.refresh': '刷新',
|
||||
'settings.system.clearTempFiles': '清理临时文件',
|
||||
'settings.system.clearing': '清理中...',
|
||||
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
|
||||
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
|
||||
|
||||
// Settings > Application
|
||||
'settings.application.checkUpdates': '检查更新',
|
||||
@@ -249,6 +264,7 @@ const zhCN: Messages = {
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': '新建文件夹',
|
||||
'sftp.newFile': '新建文件',
|
||||
'sftp.filter': '筛选',
|
||||
'sftp.filter.placeholder': '按文件名筛选...',
|
||||
'sftp.columns.name': '名称',
|
||||
@@ -283,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': '新名称',
|
||||
@@ -295,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}窗格选择主机',
|
||||
@@ -346,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': '凭据',
|
||||
@@ -364,12 +392,16 @@ 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.agentForwarding.agentNotRunning': 'SSH Agent 未运行',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': '请在 Windows 服务管理器 (services.msc) 中启用 OpenSSH Authentication Agent 服务。',
|
||||
'hostDetails.section.agentForwarding': 'SSH 代理',
|
||||
'hostDetails.jumpHosts': '通过主机代理',
|
||||
'hostDetails.jumpHosts.hops': '{count} 跳',
|
||||
'hostDetails.jumpHosts.direct': '直连',
|
||||
'hostDetails.jumpHosts.configure': '配置跳板主机',
|
||||
'hostDetails.proxy': '代理',
|
||||
'hostDetails.jumpHosts.configure': '配置代理主机',
|
||||
'hostDetails.proxy': '通过 HTTP/SOCKS5 代理',
|
||||
'hostDetails.proxy.none': '无',
|
||||
'hostDetails.proxy.edit': '编辑代理',
|
||||
'hostDetails.proxy.configure': '配置代理',
|
||||
@@ -386,6 +418,12 @@ const zhCN: Messages = {
|
||||
'hostDetails.telnet.password': 'Telnet 密码',
|
||||
'hostDetails.charset.placeholder': '字符集(例如 UTF-8)',
|
||||
'hostDetails.telnet.add': '添加 Telnet 协议',
|
||||
'hostDetails.tags': '标签',
|
||||
'hostDetails.group': '分组',
|
||||
'hostDetails.selectGroup': '选择分组',
|
||||
'hostDetails.addTag': '添加标签...',
|
||||
'hostDetails.createTag': '创建标签',
|
||||
'hostDetails.createGroup': '创建分组',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': '编辑主机',
|
||||
@@ -742,7 +780,7 @@ const zhCN: Messages = {
|
||||
'settings.sftpFileAssociations.noAssociations': '未配置文件关联',
|
||||
'settings.sftpFileAssociations.remove': '移除',
|
||||
'settings.sftpFileAssociations.removeConfirm': '确定移除 .{ext} 的关联吗?',
|
||||
|
||||
|
||||
// Settings > SFTP Behavior
|
||||
'settings.sftp.doubleClickBehavior': '双击行为',
|
||||
'settings.sftp.doubleClickBehavior.desc': '选择在 SFTP 视图中双击文件时的操作',
|
||||
@@ -751,8 +789,41 @@ const zhCN: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.openDesc': '使用默认应用程序打开文件',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': '自动同步到远程',
|
||||
'settings.sftp.autoSync.desc': '使用外部应用程序打开文件时,自动将文件更改同步回远程服务器',
|
||||
'settings.sftp.autoSync.enable': '启用自动同步',
|
||||
'settings.sftp.autoSync.enableDesc': '在外部应用程序中保存文件时,更改将自动上传到远程服务器',
|
||||
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
|
||||
'sftp.autoSync.error': '同步文件失败:{error}',
|
||||
|
||||
// SFTP Folder Upload Progress
|
||||
'sftp.upload.progress': '正在上传 {current}/{total} 个文件...',
|
||||
'sftp.upload.currentFile': '当前: {fileName}',
|
||||
'sftp.upload.cancelled': '上传已取消',
|
||||
'sftp.upload.cancel': '取消',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': '正在重连...',
|
||||
'sftp.reconnecting.desc': '连接已断开,正在尝试重新连接',
|
||||
'sftp.reconnected': '连接已恢复',
|
||||
'sftp.error.reconnectFailed': '重连失败,请重试。',
|
||||
'sftp.error.connectionLostManual': '连接已断开,请手动重新连接。',
|
||||
'sftp.error.connectionLostReconnecting': '连接已断开,正在重连...',
|
||||
'sftp.error.sessionLost': 'SFTP 会话已断开,请重新连接。',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': '显示隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.desc': '在浏览本地 Windows 文件系统时,显示具有 Windows 隐藏属性的文件。',
|
||||
'settings.sftp.showHiddenFiles.enable': '显示隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地文件系统时显示 Windows 隐藏文件',
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': '终端主题',
|
||||
'settings.terminal.themeModal.title': '选择主题',
|
||||
'settings.terminal.themeModal.darkThemes': '深色主题',
|
||||
'settings.terminal.themeModal.lightThemes': '浅色主题',
|
||||
'settings.terminal.theme.selectButton': '选择主题',
|
||||
'settings.terminal.section.font': '字体',
|
||||
'settings.terminal.section.cursor': '光标',
|
||||
'settings.terminal.section.keyboard': '键盘',
|
||||
@@ -1023,11 +1094,12 @@ const zhCN: Messages = {
|
||||
'serial.field.baudRate': '波特率',
|
||||
'serial.field.dataBits': '数据位',
|
||||
'serial.field.stopBits': '停止位',
|
||||
'serial.field.stopBits15Warning': '1.5 停止位在 Windows 下可能不被所有设备支持',
|
||||
'serial.field.parity': '校验位',
|
||||
'serial.field.flowControl': '流控制',
|
||||
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
|
||||
'serial.field.customPort': '自定义串口路径',
|
||||
'serial.field.customPortPlaceholder': '例如 /dev/ttys001',
|
||||
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
|
||||
'serial.type.hardware': '硬件',
|
||||
'serial.type.pseudo': '虚拟终端',
|
||||
'serial.type.custom': '自定义',
|
||||
@@ -1044,6 +1116,29 @@ const zhCN: Messages = {
|
||||
'serial.field.lineMode': '行模式',
|
||||
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
|
||||
'serial.connectionError': '连接串口失败',
|
||||
'serial.field.baudRatePlaceholder': '选择或输入波特率...',
|
||||
'serial.field.baudRateEmpty': '输入自定义波特率',
|
||||
'serial.field.customBaudRate': '使用自定义波特率',
|
||||
'serial.field.saveConfig': '保存配置',
|
||||
'serial.field.saveConfigDesc': '将此串口配置保存到主机列表以便快速访问',
|
||||
'serial.field.configLabel': '配置名称',
|
||||
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
|
||||
'serial.connectAndSave': '连接并保存',
|
||||
'serial.edit.title': '串口设置',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': '需要验证',
|
||||
'keyboard.interactive.desc': '服务器需要额外的身份验证。',
|
||||
'keyboard.interactive.descWithHost': '服务器 {hostname} 需要额外的身份验证。',
|
||||
'keyboard.interactive.response': '响应',
|
||||
'keyboard.interactive.enterCode': '输入验证码',
|
||||
'keyboard.interactive.enterResponse': '输入响应',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.verifying': '验证中...',
|
||||
'keyboard.interactive.fill': '填入',
|
||||
'keyboard.interactive.fillSaved': '填入已保存的密码',
|
||||
'keyboard.interactive.useSaved': '使用已保存',
|
||||
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
@@ -7,6 +7,12 @@ export type ApplicationInfo = {
|
||||
platform: string;
|
||||
};
|
||||
|
||||
export type SshAgentStatus = {
|
||||
running: boolean;
|
||||
startupType: string | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export const useApplicationBackend = () => {
|
||||
const openExternal = useCallback(async (url: string) => {
|
||||
try {
|
||||
@@ -27,6 +33,12 @@ export const useApplicationBackend = () => {
|
||||
return info ?? null;
|
||||
}, []);
|
||||
|
||||
return { openExternal, getApplicationInfo };
|
||||
const checkSshAgent = useCallback(async (): Promise<SshAgentStatus | null> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const status = await bridge?.checkSshAgent?.();
|
||||
return status ?? null;
|
||||
}, []);
|
||||
|
||||
return { openExternal, getApplicationInfo, checkSshAgent };
|
||||
};
|
||||
|
||||
|
||||
@@ -72,6 +72,37 @@ export const useSessionState = () => {
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const connectToHost = useCallback((host: Host) => {
|
||||
// Handle serial hosts specially - use createSerialSession for them
|
||||
if (host.protocol === 'serial') {
|
||||
// Use stored serialConfig or construct from host data
|
||||
const serialConfig: SerialConfig = host.serialConfig || {
|
||||
path: host.hostname,
|
||||
baudRate: host.port || 115200,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: 'none',
|
||||
flowControl: 'none',
|
||||
localEcho: false,
|
||||
lineMode: false,
|
||||
};
|
||||
|
||||
const sessionId = crypto.randomUUID();
|
||||
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
|
||||
const newSession: TerminalSession = {
|
||||
id: sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: serialConfig.path,
|
||||
username: '',
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: serialConfig,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const newSession: TerminalSession = {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
|
||||
@@ -17,6 +17,8 @@ STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
@@ -39,6 +41,8 @@ const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
|
||||
? 'mac'
|
||||
: 'pc';
|
||||
const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
|
||||
const DEFAULT_SFTP_AUTO_SYNC = false;
|
||||
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
|
||||
|
||||
const readStoredString = (key: string): string | null => {
|
||||
const raw = localStorageAdapter.readString(key);
|
||||
@@ -161,6 +165,14 @@ export const useSettingsState = () => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
|
||||
return (stored === 'open' || stored === 'transfer') ? stored : DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR;
|
||||
});
|
||||
const [sftpAutoSync, setSftpAutoSync] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_SYNC);
|
||||
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_SYNC;
|
||||
});
|
||||
const [sftpShowHiddenFiles, setSftpShowHiddenFiles] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
|
||||
return stored === 'true' ? true : DEFAULT_SFTP_SHOW_HIDDEN_FILES;
|
||||
});
|
||||
|
||||
// Helper to notify other windows about settings changes via IPC
|
||||
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
|
||||
@@ -385,11 +397,25 @@ export const useSettingsState = () => {
|
||||
setSftpDoubleClickBehavior(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP auto-sync setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_AUTO_SYNC && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sftpAutoSync) {
|
||||
setSftpAutoSync(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP show hidden files setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sftpShowHiddenFiles) {
|
||||
setSftpShowHiddenFiles(newValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior]);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -446,6 +472,18 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
|
||||
}, [sftpDoubleClickBehavior, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP auto-sync setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync);
|
||||
}, [sftpAutoSync, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP show hidden files setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
|
||||
}, [sftpShowHiddenFiles, notifySettingsChanged]);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
const keyBindings = useMemo((): KeyBinding[] => {
|
||||
return DEFAULT_KEY_BINDINGS.map(binding => {
|
||||
@@ -554,6 +592,10 @@ export const useSettingsState = () => {
|
||||
setCustomCSS,
|
||||
sftpDoubleClickBehavior,
|
||||
setSftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
setSftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
setSftpShowHiddenFiles,
|
||||
availableFonts,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -184,16 +184,51 @@ export const useSftpBackend = () => {
|
||||
sftpId: string,
|
||||
remotePath: string,
|
||||
fileName: string,
|
||||
appPath: string
|
||||
) => {
|
||||
appPath: string,
|
||||
options?: { enableWatch?: boolean }
|
||||
): Promise<{ localTempPath: string; watchId?: string }> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.downloadSftpToTemp || !bridge?.openWithApplication) {
|
||||
throw new Error("Download to temp / open with unavailable");
|
||||
}
|
||||
|
||||
// Download the file to temp
|
||||
console.log("[SFTPBackend] Downloading file to temp", { sftpId, remotePath, fileName });
|
||||
const tempPath = await bridge.downloadSftpToTemp(sftpId, remotePath, fileName);
|
||||
console.log("[SFTPBackend] File downloaded to temp", { tempPath });
|
||||
|
||||
// Register temp file for cleanup when SFTP session closes (regardless of auto-sync setting)
|
||||
if (bridge.registerTempFile) {
|
||||
try {
|
||||
await bridge.registerTempFile(sftpId, tempPath);
|
||||
} catch (err) {
|
||||
console.warn("[SFTPBackend] Failed to register temp file for cleanup:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Open with the selected application
|
||||
console.log("[SFTPBackend] Opening with application", { tempPath, appPath });
|
||||
await bridge.openWithApplication(tempPath, appPath);
|
||||
console.log("[SFTPBackend] Application launched");
|
||||
|
||||
// Start file watching if enabled
|
||||
let watchId: string | undefined;
|
||||
console.log("[SFTPBackend] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
|
||||
if (options?.enableWatch && bridge.startFileWatch) {
|
||||
try {
|
||||
console.log("[SFTPBackend] Starting file watch", { tempPath, remotePath, sftpId });
|
||||
const result = await bridge.startFileWatch(tempPath, remotePath, sftpId);
|
||||
watchId = result.watchId;
|
||||
console.log("[SFTPBackend] File watch started successfully", { watchId, tempPath, remotePath });
|
||||
} catch (err) {
|
||||
console.warn("[SFTPBackend] Failed to start file watch:", err);
|
||||
// Don't fail the operation if watching fails
|
||||
}
|
||||
} else {
|
||||
console.log("[SFTPBackend] File watching not enabled or not available");
|
||||
}
|
||||
|
||||
return { localTempPath: tempPath, watchId };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Server,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Usb,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import React, { memo, useCallback, useMemo } from "react";
|
||||
@@ -63,6 +64,7 @@ interface LogItemProps {
|
||||
const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) => {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const isLocal = log.protocol === "local" || log.hostname === "localhost";
|
||||
const isSerial = log.protocol === "serial";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -92,14 +94,14 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className={cn(
|
||||
"h-8 w-8 rounded-lg flex items-center justify-center shrink-0",
|
||||
isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
|
||||
isSerial ? "bg-amber-500/10 text-amber-500" : isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
|
||||
)}>
|
||||
{isLocal ? <Terminal size={14} /> : <Server size={14} />}
|
||||
{isSerial ? <Usb size={14} /> : isLocal ? <Terminal size={14} /> : <Server size={14} />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{isLocal ? t("logs.localTerminal") : log.hostLabel}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{isLocal ? "local" : `${log.protocol}, ${log.username}`}
|
||||
{isLocal ? "local" : isSerial ? `serial, ${log.hostname}` : `${log.protocol}, ${log.username}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Server } from "lucide-react";
|
||||
import { Server, Usb } from "lucide-react";
|
||||
import React, { memo } from "react";
|
||||
import { normalizeDistroId } from "../domain/host";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -69,6 +69,21 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
const containerClass = sizeClasses[size];
|
||||
const iconSize = iconSizes[size];
|
||||
|
||||
// Show USB icon for serial hosts
|
||||
if (host.protocol === 'serial') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
containerClass,
|
||||
"flex items-center justify-center bg-amber-500/15 text-amber-500",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Usb className={iconSize} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (logo && !errored) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
FolderLock,
|
||||
FolderPlus,
|
||||
Forward,
|
||||
Globe,
|
||||
Key,
|
||||
KeyRound,
|
||||
Link2,
|
||||
MapPin,
|
||||
Palette,
|
||||
Plus,
|
||||
Settings2,
|
||||
Shield,
|
||||
Tag,
|
||||
TerminalSquare,
|
||||
User,
|
||||
Variable,
|
||||
Wifi,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useApplicationBackend } from "../application/state/useApplicationBackend";
|
||||
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -28,6 +37,7 @@ import {
|
||||
} from "./ui/aside-panel";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { Card } from "./ui/card";
|
||||
import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
|
||||
import { Input } from "./ui/input";
|
||||
@@ -80,6 +90,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onCreateTag,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const [form, setForm] = useState<Host>(
|
||||
() =>
|
||||
initialData ||
|
||||
@@ -92,7 +103,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
protocol: "ssh",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
agentForwarding: false,
|
||||
authMethod: "password",
|
||||
charset: "UTF-8",
|
||||
theme: "Flexoki Dark",
|
||||
@@ -116,6 +126,22 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [newGroupParent, setNewGroupParent] = useState("");
|
||||
|
||||
// SSH Agent status for Windows (only checked when agentForwarding is enabled)
|
||||
const [sshAgentStatus, setSshAgentStatus] = useState<{
|
||||
running: boolean;
|
||||
startupType: string | null;
|
||||
error: string | null;
|
||||
} | null>(null);
|
||||
|
||||
// Check SSH Agent status when agentForwarding is toggled on (Windows only)
|
||||
useEffect(() => {
|
||||
if (form.agentForwarding) {
|
||||
checkSshAgent().then(setSshAgentStatus);
|
||||
} else {
|
||||
setSshAgentStatus(null);
|
||||
}
|
||||
}, [form.agentForwarding, checkSshAgent]);
|
||||
|
||||
// Group input state for inline creation suggestion
|
||||
const [groupInputValue, setGroupInputValue] = useState(form.group || "");
|
||||
|
||||
@@ -481,9 +507,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
>
|
||||
<AsidePanelContent>
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.address")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.address")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DistroAvatar
|
||||
host={form as Host}
|
||||
@@ -504,9 +533,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.general")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.general")}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t("hostDetails.label.placeholder")}
|
||||
value={form.label}
|
||||
@@ -522,12 +554,16 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<Combobox
|
||||
options={groupOptions}
|
||||
value={form.group || ""}
|
||||
onValueChange={(val) => update("group", val)}
|
||||
onValueChange={(val) => {
|
||||
update("group", val);
|
||||
setGroupInputValue(val);
|
||||
}}
|
||||
placeholder={t("hostDetails.group.placeholder")}
|
||||
allowCreate={true}
|
||||
onCreateNew={(val) => {
|
||||
onCreateGroup?.(val);
|
||||
update("group", val);
|
||||
setGroupInputValue(val);
|
||||
}}
|
||||
createText="Create Group"
|
||||
triggerClassName="flex-1 h-10"
|
||||
@@ -553,9 +589,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.portCredentials")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.portCredentials")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0 h-10 flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-3">
|
||||
<span className="text-xs text-muted-foreground">SSH on</span>
|
||||
@@ -923,9 +962,40 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.appearance")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderLock size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.sftp")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium">
|
||||
{t("hostDetails.sftp.sudo")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.sudo.desc")}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.sftpSudo || false}
|
||||
onCheckedChange={(val) => update("sftpSudo", val)}
|
||||
/>
|
||||
</div>
|
||||
{form.sftpSudo && !form.password && !selectedIdentity?.password && (
|
||||
<p className="text-xs text-amber-500">
|
||||
{t("hostDetails.sftp.sudo.passwordWarning")}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.appearance")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* SSH Theme Selection */}
|
||||
<button
|
||||
@@ -1012,7 +1082,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.mosh")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wifi size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.mosh")}</p>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label="Mosh"
|
||||
enabled={!!form.moshEnabled}
|
||||
@@ -1020,75 +1093,109 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Agent Forwarding */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Forward size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.agentForwarding")}</p>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.agentForwarding")}
|
||||
enabled={!!form.agentForwarding}
|
||||
onToggle={() => update("agentForwarding", !form.agentForwarding)}
|
||||
/>
|
||||
</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 className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.agentForwarding.desc")}
|
||||
</p>
|
||||
{form.agentForwarding && sshAgentStatus && !sshAgentStatus.running && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 font-medium">
|
||||
{t("hostDetails.agentForwarding.agentNotRunning")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.agentForwarding.agentNotRunningHint")}
|
||||
</p>
|
||||
</div>
|
||||
{chainedHosts.length > 0 ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{t("hostDetails.jumpHosts.direct")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{chainedHosts.length > 0 && (
|
||||
<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")}
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.jumpHosts")}
|
||||
</p>
|
||||
</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"
|
||||
>
|
||||
<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;
|
||||
@@ -43,10 +43,12 @@ import React, {
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
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";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import FileOpenerDialog from "./FileOpenerDialog";
|
||||
import TextEditorModal from "./TextEditorModal";
|
||||
@@ -252,9 +254,12 @@ interface SFTPModalProps {
|
||||
keySource?: 'generated' | 'imported';
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sftpSudo?: boolean;
|
||||
};
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Initial path to open in SFTP. If not accessible, falls back to home directory. */
|
||||
initialPath?: string;
|
||||
}
|
||||
|
||||
// Sort configuration
|
||||
@@ -279,6 +284,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
credentials,
|
||||
open,
|
||||
onClose,
|
||||
initialPath,
|
||||
}) => {
|
||||
const {
|
||||
openSftp,
|
||||
@@ -303,6 +309,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
downloadSftpToTempAndOpen,
|
||||
} = useSftpBackend();
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const { sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
|
||||
const isLocalSession = host.protocol === "local";
|
||||
const [currentPath, setCurrentPath] = useState("/");
|
||||
const [files, setFiles] = useState<RemoteFile[]>([]);
|
||||
@@ -314,10 +321,17 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const sftpIdRef = useRef<string | null>(null);
|
||||
const initializedRef = useRef(false);
|
||||
const lastInitialPathRef = useRef<string | undefined>(undefined);
|
||||
const navigatingRef = useRef(false);
|
||||
const lastSelectedIndexRef = useRef<number | null>(null);
|
||||
const localHomeRef = useRef<string | null>(null);
|
||||
|
||||
// Reconnect state
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
const reconnectingRef = useRef(false);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const MAX_RECONNECT_ATTEMPTS = 3;
|
||||
|
||||
// Rename dialog state
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<RemoteFile | null>(null);
|
||||
@@ -508,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;
|
||||
@@ -526,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;
|
||||
@@ -562,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"),
|
||||
@@ -574,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(() => {
|
||||
@@ -608,10 +666,80 @@ 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) {
|
||||
if (!initializedRef.current) {
|
||||
// Check if we need to reinitialize (either first time or initialPath changed)
|
||||
const needsReinit = !initializedRef.current || initialPath !== lastInitialPathRef.current;
|
||||
|
||||
if (needsReinit) {
|
||||
initializedRef.current = true;
|
||||
lastInitialPathRef.current = initialPath;
|
||||
if (isLocalSession) {
|
||||
void (async () => {
|
||||
let home = localHomeRef.current;
|
||||
@@ -626,7 +754,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
loadFiles(startPath);
|
||||
})();
|
||||
} else {
|
||||
// For remote sessions, load home directory directly
|
||||
// For remote sessions, try initialPath first, then fall back to home directory
|
||||
void (async () => {
|
||||
const username = credentials.username || 'root';
|
||||
// Root user's home is /root, other users' home is /home/username
|
||||
@@ -635,6 +763,26 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
// Set loading state immediately for better UX
|
||||
setLoading(true);
|
||||
|
||||
// If initialPath is provided, try to use it first
|
||||
if (initialPath) {
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, initialPath);
|
||||
setCurrentPath(initialPath);
|
||||
setFiles(list);
|
||||
setSelectedFiles(new Set());
|
||||
dirCacheRef.current.set(`${host.id}::${initialPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setLoading(false);
|
||||
return; // Successfully opened at initialPath
|
||||
} catch {
|
||||
// initialPath not accessible, fall back to home directory
|
||||
logger.warn(`[SFTP] Initial path ${initialPath} not accessible, falling back to home`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, homePath);
|
||||
@@ -677,7 +825,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
void closeSftpSession();
|
||||
initializedRef.current = false;
|
||||
}
|
||||
}, [open, currentPath, loadFiles, closeSftpSession, getHomeDir, isLocalSession, credentials.username, ensureSftp, listSftp, host.id, t]);
|
||||
}, [open, currentPath, loadFiles, closeSftpSession, getHomeDir, isLocalSession, credentials.username, ensureSftp, listSftp, host.id, t, initialPath]);
|
||||
|
||||
const handleNavigate = useCallback((path: string) => {
|
||||
// Prevent double navigation (e.g., from double-click race condition)
|
||||
@@ -727,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) =>
|
||||
@@ -747,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);
|
||||
@@ -905,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 {
|
||||
@@ -943,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);
|
||||
@@ -1163,7 +1428,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
}
|
||||
} else {
|
||||
const sftpId = await ensureSftp();
|
||||
await downloadSftpToTempAndOpen(sftpId, fullPath, file.name, savedOpener.systemApp.path);
|
||||
await downloadSftpToTempAndOpen(sftpId, fullPath, file.name, savedOpener.systemApp.path, { enableWatch: sftpAutoSync });
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
@@ -1176,7 +1441,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
// Show opener dialog
|
||||
openFileOpenerDialog(file);
|
||||
}
|
||||
}, [getOpenerForFile, handleEditFile, openFileOpenerDialog, joinPath, currentPath, isLocalSession, ensureSftp, downloadSftpToTempAndOpen, t]);
|
||||
}, [getOpenerForFile, handleEditFile, openFileOpenerDialog, joinPath, currentPath, isLocalSession, ensureSftp, downloadSftpToTempAndOpen, sftpAutoSync, t]);
|
||||
|
||||
const handleFileOpenerSelect = useCallback(async (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => {
|
||||
if (!fileOpenerTarget) return;
|
||||
@@ -1203,7 +1468,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
}
|
||||
} else {
|
||||
const sftpId = await ensureSftp();
|
||||
await downloadSftpToTempAndOpen(sftpId, fullPath, fileOpenerTarget.name, systemApp.path);
|
||||
await downloadSftpToTempAndOpen(sftpId, fullPath, fileOpenerTarget.name, systemApp.path, { enableWatch: sftpAutoSync });
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
@@ -1214,7 +1479,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
}
|
||||
|
||||
setFileOpenerTarget(null);
|
||||
}, [fileOpenerTarget, setOpenerForExtension, handleEditFile, joinPath, currentPath, isLocalSession, ensureSftp, downloadSftpToTempAndOpen, t]);
|
||||
}, [fileOpenerTarget, setOpenerForExtension, handleEditFile, joinPath, currentPath, isLocalSession, ensureSftp, downloadSftpToTempAndOpen, sftpAutoSync, t]);
|
||||
|
||||
// Callback for FileOpenerDialog to select a system application
|
||||
const handleSelectSystemApp = useCallback(async (): Promise<SystemAppInfo | null> => {
|
||||
@@ -1248,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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1261,9 +1527,12 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
|
||||
// Display files with parent entry (like SftpView)
|
||||
const displayFiles = useMemo(() => {
|
||||
// Filter hidden files using utility function
|
||||
const visibleFiles = filterHiddenFiles(files, sftpShowHiddenFiles);
|
||||
|
||||
// Check if we're at root
|
||||
const atRoot = isRootPath(currentPath);
|
||||
if (atRoot) return files;
|
||||
if (atRoot) return visibleFiles;
|
||||
|
||||
// Add ".." parent directory entry at the top (only if not at root)
|
||||
const parentEntry: RemoteFile = {
|
||||
@@ -1272,8 +1541,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
size: "--",
|
||||
lastModified: undefined,
|
||||
};
|
||||
return [parentEntry, ...files.filter((f) => f.name !== "..")];
|
||||
}, [files, currentPath, isRootPath]);
|
||||
return [parentEntry, ...visibleFiles.filter((f) => f.name !== "..")];
|
||||
}, [files, currentPath, isRootPath, sftpShowHiddenFiles]);
|
||||
|
||||
// Sorted files
|
||||
const sortedFiles = useMemo(() => {
|
||||
@@ -1669,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 */}
|
||||
@@ -1760,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"
|
||||
@@ -1863,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" />
|
||||
@@ -2029,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>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* Serial Port Connect Modal
|
||||
* Allows users to configure and connect to a serial port
|
||||
*/
|
||||
import { ChevronDown, ChevronUp, Cpu, RefreshCw, Usb } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, Cpu, RefreshCw, Save, Usb } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTerminalBackend } from '../application/state/useTerminalBackend';
|
||||
import type { SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
|
||||
import type { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox, type ComboboxOption } from './ui/combobox';
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
|
||||
@@ -35,6 +36,7 @@ interface SerialConnectModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConnect: (config: SerialConfig) => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
}
|
||||
|
||||
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
|
||||
@@ -47,6 +49,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onConnect,
|
||||
onSaveHost,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [ports, setPorts] = useState<SerialPort[]>([]);
|
||||
@@ -63,6 +66,10 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
const [localEcho, setLocalEcho] = useState(false);
|
||||
const [lineMode, setLineMode] = useState(false);
|
||||
|
||||
// Save configuration state
|
||||
const [saveConfig, setSaveConfig] = useState(false);
|
||||
const [configLabel, setConfigLabel] = useState('');
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
|
||||
const loadPorts = useCallback(async () => {
|
||||
@@ -87,6 +94,14 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
}
|
||||
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Generate a default label when port is selected
|
||||
useEffect(() => {
|
||||
if (selectedPort && !configLabel) {
|
||||
const portName = selectedPort.split('/').pop() || selectedPort;
|
||||
setConfigLabel(`Serial: ${portName}`);
|
||||
}
|
||||
}, [selectedPort, configLabel]);
|
||||
|
||||
const handleConnect = () => {
|
||||
if (!selectedPort) return;
|
||||
|
||||
@@ -101,6 +116,26 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
lineMode,
|
||||
};
|
||||
|
||||
// Save as host if checkbox is checked and onSaveHost is provided
|
||||
if (saveConfig && onSaveHost) {
|
||||
const portName = selectedPort.split('/').pop() || selectedPort;
|
||||
const host: Host = {
|
||||
id: `serial-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
|
||||
label: configLabel.trim() || `Serial: ${portName}`,
|
||||
hostname: selectedPort,
|
||||
// For serial hosts, port field stores baud rate as a numeric identifier.
|
||||
// The full configuration is stored in serialConfig for actual connection.
|
||||
port: baudRate,
|
||||
username: '',
|
||||
os: 'linux',
|
||||
tags: ['serial'],
|
||||
protocol: 'serial',
|
||||
createdAt: Date.now(),
|
||||
serialConfig: config, // Store full serial configuration for connection
|
||||
};
|
||||
onSaveHost(host);
|
||||
}
|
||||
|
||||
onConnect(config);
|
||||
onClose();
|
||||
};
|
||||
@@ -114,9 +149,17 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
}));
|
||||
}, [ports]);
|
||||
|
||||
// Validate: port path must start with /dev/
|
||||
const isPortValid = selectedPort.trim().startsWith('/dev/');
|
||||
const isBaudRateValid = BAUD_RATES.includes(baudRate);
|
||||
// Validate: port path must start with /dev/ (Unix/macOS) or COM/\\.\COM (Windows)
|
||||
const trimmedPort = selectedPort.trim();
|
||||
const isPortValid =
|
||||
trimmedPort.startsWith('/dev/') ||
|
||||
/^COM\d+$/i.test(trimmedPort) ||
|
||||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
|
||||
// Allow custom baud rates as long as they are positive integers
|
||||
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
|
||||
|
||||
// Check if using 1.5 stop bits (limited Windows support)
|
||||
const isStopBits15 = stopBits === 1.5;
|
||||
const isValid = isPortValid && isBaudRateValid;
|
||||
|
||||
return (
|
||||
@@ -171,18 +214,28 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
{/* Baud Rate */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
|
||||
<select
|
||||
id="baud-rate"
|
||||
value={baudRate}
|
||||
onChange={(e) => setBaudRate(parseInt(e.target.value, 10))}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{BAUD_RATES.map((rate) => (
|
||||
<option key={rate} value={rate}>
|
||||
{rate}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Combobox
|
||||
options={BAUD_RATES.map((rate) => ({
|
||||
value: String(rate),
|
||||
label: String(rate),
|
||||
}))}
|
||||
value={String(baudRate)}
|
||||
onValueChange={(val) => {
|
||||
const parsed = parseInt(val, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
setBaudRate(parsed);
|
||||
}
|
||||
}}
|
||||
placeholder={t('serial.field.baudRatePlaceholder')}
|
||||
emptyText={t('serial.field.baudRateEmpty')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
/>
|
||||
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.customBaudRate')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Options */}
|
||||
@@ -236,6 +289,11 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -313,6 +371,40 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Save Configuration */}
|
||||
{onSaveHost && (
|
||||
<div className="space-y-3 pt-2 border-t border-border/60">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="save-config" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.saveConfig')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.saveConfigDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="save-config"
|
||||
checked={saveConfig}
|
||||
onChange={(e) => setSaveConfig(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
{saveConfig && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="config-label">{t('serial.field.configLabel')}</Label>
|
||||
<Input
|
||||
id="config-label"
|
||||
value={configLabel}
|
||||
onChange={(e) => setConfigLabel(e.target.value)}
|
||||
placeholder={t('serial.field.configLabelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -320,8 +412,12 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConnect} disabled={!isValid}>
|
||||
<Cpu size={14} className="mr-2" />
|
||||
{t('common.connect')}
|
||||
{saveConfig ? (
|
||||
<Save size={14} className="mr-2" />
|
||||
) : (
|
||||
<Cpu size={14} className="mr-2" />
|
||||
)}
|
||||
{saveConfig ? t('serial.connectAndSave') : t('common.connect')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
415
components/SerialHostDetailsPanel.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Serial Host Details Panel
|
||||
* A dedicated editor for serial port hosts (distinct from SSH HostDetailsPanel)
|
||||
*/
|
||||
import { ChevronDown, ChevronUp, Save, Tag, Usb } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTerminalBackend } from '../application/state/useTerminalBackend';
|
||||
import type { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox, ComboboxOption, MultiCombobox } from './ui/combobox';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
} from './ui/aside-panel';
|
||||
|
||||
interface SerialPort {
|
||||
path: string;
|
||||
manufacturer: string;
|
||||
serialNumber: string;
|
||||
vendorId: string;
|
||||
productId: string;
|
||||
pnpId: string;
|
||||
type?: 'hardware' | 'pseudo' | 'custom';
|
||||
}
|
||||
|
||||
interface SerialHostDetailsPanelProps {
|
||||
initialData: Host;
|
||||
allTags?: string[];
|
||||
groups?: string[];
|
||||
onSave: (host: Host) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
|
||||
const DATA_BITS: Array<5 | 6 | 7 | 8> = [5, 6, 7, 8];
|
||||
const STOP_BITS: Array<1 | 1.5 | 2> = [1, 1.5, 2];
|
||||
const PARITY_OPTIONS: SerialParity[] = ['none', 'even', 'odd', 'mark', 'space'];
|
||||
const FLOW_CONTROL_OPTIONS: SerialFlowControl[] = ['none', 'xon/xoff', 'rts/cts'];
|
||||
|
||||
export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
initialData,
|
||||
allTags = [],
|
||||
groups = [],
|
||||
onSave,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const [ports, setPorts] = useState<SerialPort[]>([]);
|
||||
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [label, setLabel] = useState(initialData.label);
|
||||
const [selectedPort, setSelectedPort] = useState(initialData.hostname || initialData.serialConfig?.path || '');
|
||||
const [baudRate, setBaudRate] = useState(initialData.serialConfig?.baudRate || initialData.port || 115200);
|
||||
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(initialData.serialConfig?.dataBits || 8);
|
||||
const [stopBits, setStopBits] = useState<1 | 1.5 | 2>(initialData.serialConfig?.stopBits || 1);
|
||||
const [parity, setParity] = useState<SerialParity>(initialData.serialConfig?.parity || 'none');
|
||||
const [flowControl, setFlowControl] = useState<SerialFlowControl>(initialData.serialConfig?.flowControl || 'none');
|
||||
const [localEcho, setLocalEcho] = useState(initialData.serialConfig?.localEcho || false);
|
||||
const [lineMode, setLineMode] = useState(initialData.serialConfig?.lineMode || false);
|
||||
const [tags, setTags] = useState<string[]>(initialData.tags || []);
|
||||
const [group, setGroup] = useState(initialData.group || '');
|
||||
|
||||
const loadPorts = useCallback(async () => {
|
||||
setIsLoadingPorts(true);
|
||||
try {
|
||||
const result = await terminalBackend.listSerialPorts();
|
||||
setPorts(result);
|
||||
} catch (err) {
|
||||
console.error('[Serial] Failed to list ports:', err);
|
||||
} finally {
|
||||
setIsLoadingPorts(false);
|
||||
}
|
||||
}, [terminalBackend]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPorts();
|
||||
}, [loadPorts]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!selectedPort) return;
|
||||
|
||||
const config: SerialConfig = {
|
||||
path: selectedPort,
|
||||
baudRate,
|
||||
dataBits,
|
||||
stopBits,
|
||||
parity,
|
||||
flowControl,
|
||||
localEcho,
|
||||
lineMode,
|
||||
};
|
||||
|
||||
const portName = selectedPort.split('/').pop() || selectedPort;
|
||||
const updatedHost: Host = {
|
||||
...initialData,
|
||||
label: label.trim() || `Serial: ${portName}`,
|
||||
hostname: selectedPort,
|
||||
port: baudRate,
|
||||
tags,
|
||||
group,
|
||||
serialConfig: config,
|
||||
};
|
||||
|
||||
onSave(updatedHost);
|
||||
};
|
||||
|
||||
// Convert ports to Combobox options
|
||||
const portOptions: ComboboxOption[] = useMemo(() => {
|
||||
return ports.map((port) => ({
|
||||
value: port.path,
|
||||
label: port.path,
|
||||
sublabel: port.manufacturer || undefined,
|
||||
}));
|
||||
}, [ports]);
|
||||
|
||||
// Tag options for MultiCombobox
|
||||
const tagOptions: ComboboxOption[] = useMemo(() => {
|
||||
const allUniqueTags = new Set([...allTags, ...tags]);
|
||||
return Array.from(allUniqueTags).map((tag) => ({
|
||||
value: tag,
|
||||
label: tag,
|
||||
}));
|
||||
}, [allTags, tags]);
|
||||
|
||||
// Group options for Combobox
|
||||
const groupOptions: ComboboxOption[] = useMemo(() => {
|
||||
const allGroups = new Set(groups);
|
||||
if (group && !allGroups.has(group)) {
|
||||
allGroups.add(group);
|
||||
}
|
||||
return Array.from(allGroups).map((g) => ({
|
||||
value: g,
|
||||
label: g,
|
||||
}));
|
||||
}, [groups, group]);
|
||||
|
||||
// Validation
|
||||
const trimmedPort = selectedPort.trim();
|
||||
const isPortValid =
|
||||
trimmedPort.startsWith('/dev/') ||
|
||||
/^COM\d+$/i.test(trimmedPort) ||
|
||||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
|
||||
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
|
||||
const isValid = isPortValid && isBaudRateValid;
|
||||
|
||||
// Check if using 1.5 stop bits (limited Windows support)
|
||||
const isStopBits15 = stopBits === 1.5;
|
||||
|
||||
return (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={onCancel}
|
||||
title={t('serial.edit.title')}
|
||||
subtitle={initialData.label}
|
||||
className="z-40"
|
||||
>
|
||||
<AsidePanelContent>
|
||||
{/* Label */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="serial-label">{t('serial.field.configLabel')}</Label>
|
||||
<Input
|
||||
id="serial-label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t('serial.field.configLabelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Serial Port */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="serial-port">{t('serial.field.port')}</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadPorts}
|
||||
disabled={isLoadingPorts}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
<Combobox
|
||||
options={portOptions}
|
||||
value={selectedPort}
|
||||
onValueChange={setSelectedPort}
|
||||
placeholder={t('serial.field.selectPort')}
|
||||
emptyText={t('serial.noPorts')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
icon={<Usb size={14} className="text-muted-foreground" />}
|
||||
/>
|
||||
{!isPortValid && selectedPort && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('serial.field.customPortPlaceholder')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Baud Rate */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
|
||||
<Combobox
|
||||
options={BAUD_RATES.map((rate) => ({
|
||||
value: String(rate),
|
||||
label: String(rate),
|
||||
}))}
|
||||
value={String(baudRate)}
|
||||
onValueChange={(val) => {
|
||||
const parsed = parseInt(val, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
setBaudRate(parsed);
|
||||
}
|
||||
}}
|
||||
placeholder={t('serial.field.baudRatePlaceholder')}
|
||||
emptyText={t('serial.field.baudRateEmpty')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
/>
|
||||
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.customBaudRate')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Tag size={14} />
|
||||
{t('hostDetails.tags')}
|
||||
</Label>
|
||||
<MultiCombobox
|
||||
options={tagOptions}
|
||||
values={tags}
|
||||
onValuesChange={setTags}
|
||||
placeholder={t('hostDetails.addTag')}
|
||||
allowCreate
|
||||
createText={t('hostDetails.createTag')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Group */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('hostDetails.group')}</Label>
|
||||
<Combobox
|
||||
options={groupOptions}
|
||||
value={group}
|
||||
onValueChange={setGroup}
|
||||
placeholder={t('hostDetails.selectGroup')}
|
||||
allowCreate
|
||||
createText={t('hostDetails.createGroup')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between h-9 px-0 hover:bg-transparent"
|
||||
>
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t('common.advanced')}
|
||||
</span>
|
||||
{showAdvanced ? (
|
||||
<ChevronUp size={14} className="text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown size={14} className="text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{/* Data Bits */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
|
||||
<select
|
||||
id="data-bits"
|
||||
value={dataBits}
|
||||
onChange={(e) => setDataBits(parseInt(e.target.value, 10) as 5 | 6 | 7 | 8)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<option key={bits} value={bits}>
|
||||
{bits}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Stop Bits */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<select
|
||||
id="stop-bits"
|
||||
value={stopBits}
|
||||
onChange={(e) => setStopBits(parseFloat(e.target.value) as 1 | 1.5 | 2)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<option key={bits} value={bits}>
|
||||
{bits}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
|
||||
<select
|
||||
id="parity"
|
||||
value={parity}
|
||||
onChange={(e) => setParity(e.target.value as SerialParity)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Flow Control */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<select
|
||||
id="flow-control"
|
||||
value={flowControl}
|
||||
onChange={(e) => setFlowControl(e.target.value as SerialFlowControl)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Terminal Options */}
|
||||
<div className="space-y-3 pt-2 border-t border-border/60">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="local-echo" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.localEcho')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.localEchoDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="local-echo"
|
||||
checked={localEcho}
|
||||
onChange={(e) => setLocalEcho(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="line-mode" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.lineMode')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.lineModeDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="line-mode"
|
||||
checked={lineMode}
|
||||
onChange={(e) => setLineMode(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</AsidePanelContent>
|
||||
|
||||
<AsidePanelFooter>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onCancel} className="flex-1">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!isValid} className="flex-1">
|
||||
<Save size={14} className="mr-2" />
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</AsidePanelFooter>
|
||||
</AsidePanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default SerialHostDetailsPanel;
|
||||
@@ -2,7 +2,7 @@
|
||||
* Settings Page - Standalone settings window content
|
||||
* This component is rendered in a separate Electron window
|
||||
*/
|
||||
import { AppWindow, Cloud, FileType, Keyboard, Palette, TerminalSquare, X } from "lucide-react";
|
||||
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, TerminalSquare, X } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useVaultState } from "../application/state/useVaultState";
|
||||
@@ -13,6 +13,7 @@ import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
|
||||
import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociationsTab";
|
||||
import SettingsShortcutsTab from "./settings/tabs/SettingsShortcutsTab";
|
||||
import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
|
||||
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
|
||||
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import type { TerminalFont } from "../infrastructure/config/fonts";
|
||||
|
||||
@@ -133,6 +134,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
>
|
||||
<Cloud size={14} /> {t("settings.tab.syncCloud")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="system"
|
||||
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
|
||||
>
|
||||
<HardDrive size={14} /> {t("settings.tab.system")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -193,6 +200,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
<SettingsSyncTabWithVault />
|
||||
</React.Suspense>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("system") && <SettingsSystemTab />}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -59,6 +59,7 @@ import { Label } from "./ui/label";
|
||||
// Import extracted components
|
||||
import {
|
||||
ColumnWidths,
|
||||
filterHiddenFiles,
|
||||
isNavigableDirectory,
|
||||
SftpBreadcrumb,
|
||||
SftpConflictDialog,
|
||||
@@ -79,6 +80,7 @@ import {
|
||||
Download,
|
||||
Edit2,
|
||||
ExternalLink,
|
||||
FilePlus,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
HardDrive,
|
||||
@@ -100,6 +102,7 @@ import {
|
||||
useSftpPaneCallbacks,
|
||||
useSftpDrag,
|
||||
useSftpHosts,
|
||||
useSftpShowHiddenFiles,
|
||||
useActiveTabId,
|
||||
activeTabStore,
|
||||
type SftpPaneCallbacks,
|
||||
@@ -162,6 +165,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
const callbacks = useSftpPaneCallbacks(side);
|
||||
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
|
||||
const hosts = useSftpHosts();
|
||||
const showHiddenFiles = useSftpShowHiddenFiles();
|
||||
|
||||
// Destructure for easier use
|
||||
const {
|
||||
@@ -176,14 +180,16 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
onClearSelection,
|
||||
onSetFilter,
|
||||
onCreateDirectory,
|
||||
onCreateFile,
|
||||
onDeleteFiles,
|
||||
onRenameFile,
|
||||
onCopyToOtherPane,
|
||||
onReceiveFromOtherPane,
|
||||
onEditPermissions,
|
||||
onEditFile,
|
||||
onOpenFile,
|
||||
onOpenFileWith,
|
||||
onDownloadFile,
|
||||
onUploadExternalFiles,
|
||||
} = callbacks;
|
||||
|
||||
// 渲染追踪 - 只追踪数据 props(回调来自 context,引用稳定)
|
||||
@@ -204,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);
|
||||
|
||||
@@ -255,11 +267,16 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
|
||||
const filteredFiles = useMemo(() => {
|
||||
const term = pane.filter.trim().toLowerCase();
|
||||
if (!term) return pane.files;
|
||||
return pane.files.filter(
|
||||
|
||||
// Filter hidden files using utility function
|
||||
let files = filterHiddenFiles(pane.files, showHiddenFiles);
|
||||
|
||||
// Apply text filter
|
||||
if (!term) return files;
|
||||
return files.filter(
|
||||
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
|
||||
);
|
||||
}, [pane.files, pane.filter]);
|
||||
}, [pane.files, pane.filter, showHiddenFiles]);
|
||||
|
||||
// Path suggestions
|
||||
const pathSuggestions = useMemo(() => {
|
||||
@@ -546,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;
|
||||
@@ -561,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);
|
||||
@@ -593,6 +699,18 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
|
||||
// Drag handlers
|
||||
const handlePaneDragOver = (e: React.DragEvent) => {
|
||||
// Check if this is external file drag (from OS)
|
||||
const hasFiles = e.dataTransfer.types.includes('Files');
|
||||
|
||||
// If it's external files, always allow drop
|
||||
if (hasFiles) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setIsDragOverPane(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, check if it's internal drag from other pane
|
||||
if (!draggedFiles || draggedFiles[0]?.side === side) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
@@ -607,15 +725,27 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
setDragOverEntry(null);
|
||||
};
|
||||
|
||||
const handlePaneDrop = (e: React.DragEvent) => {
|
||||
const handlePaneDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOverPane(false);
|
||||
setDragOverEntry(null);
|
||||
if (!draggedFiles || draggedFiles[0]?.side === side) return;
|
||||
onReceiveFromOtherPane(
|
||||
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
|
||||
);
|
||||
|
||||
// Check if this is an internal drag from another pane (draggedFiles is set by onDragStart)
|
||||
if (draggedFiles && draggedFiles.length > 0) {
|
||||
// Handle internal pane-to-pane transfer
|
||||
if (draggedFiles[0]?.side !== side) {
|
||||
onReceiveFromOtherPane(
|
||||
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, this is an external file/folder drop (from OS)
|
||||
if (e.dataTransfer.items.length > 0 && onUploadExternalFiles) {
|
||||
await onUploadExternalFiles(e.dataTransfer);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileDragStart = useCallback(
|
||||
@@ -814,18 +944,11 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.download")}
|
||||
<ExternalLink size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.open")}
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
{/* File operations - only for files, not directories */}
|
||||
{!isNavigableDirectory(entry) && onOpenFile && (
|
||||
<ContextMenuItem onClick={() => onOpenFile(entry)}>
|
||||
<ExternalLink size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.open")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{!isNavigableDirectory(entry) && onOpenFileWith && (
|
||||
<ContextMenuItem onClick={() => onOpenFileWith(entry)}>
|
||||
<ExternalLink size={14} className="mr-2" />{" "}
|
||||
@@ -838,6 +961,12 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
{t("sftp.context.edit")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{!isNavigableDirectory(entry) && onDownloadFile && (
|
||||
<ContextMenuItem onClick={() => onDownloadFile(entry)}>
|
||||
<Download size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.download")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
@@ -886,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>
|
||||
@@ -901,10 +1033,10 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
handleRowOpen,
|
||||
handleRowSelect,
|
||||
onCopyToOtherPane,
|
||||
onDownloadFile,
|
||||
onDragEnd,
|
||||
onEditFile,
|
||||
onEditPermissions,
|
||||
onOpenFile,
|
||||
onOpenFileWith,
|
||||
onRefresh,
|
||||
openDeleteConfirm,
|
||||
@@ -912,6 +1044,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
pane.connection,
|
||||
pane.selectedFiles,
|
||||
setShowNewFolderDialog,
|
||||
setShowNewFileDialog,
|
||||
t,
|
||||
],
|
||||
);
|
||||
@@ -1099,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"
|
||||
@@ -1246,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">
|
||||
@@ -1309,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">
|
||||
@@ -1353,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>
|
||||
@@ -1480,8 +1742,22 @@ interface SftpViewProps {
|
||||
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) => {
|
||||
const { t } = useI18n();
|
||||
const isActive = useIsSftpActive();
|
||||
const sftp = useSftpState(hosts, keys, identities);
|
||||
const { sftpDoubleClickBehavior } = useSettingsState();
|
||||
const { sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
|
||||
|
||||
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
onFileWatchSynced: (payload: { remotePath: string }) => {
|
||||
const fileName = payload.remotePath.split('/').pop() || payload.remotePath;
|
||||
toast.success(t('sftp.autoSync.success', { fileName }));
|
||||
logger.info("[SFTP] File auto-synced to remote", payload);
|
||||
},
|
||||
onFileWatchError: (payload: { error: string }) => {
|
||||
toast.error(t('sftp.autoSync.error', { error: payload.error }));
|
||||
logger.error("[SFTP] File auto-sync failed", payload);
|
||||
},
|
||||
}), [t]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, fileWatchHandlers);
|
||||
|
||||
// Store sftp in a ref so callbacks can access the latest instance
|
||||
// without needing to re-create when sftp changes
|
||||
@@ -1492,6 +1768,10 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
const behaviorRef = useRef(sftpDoubleClickBehavior);
|
||||
behaviorRef.current = sftpDoubleClickBehavior;
|
||||
|
||||
// Store auto-sync setting in ref for stable callbacks
|
||||
const autoSyncRef = useRef(sftpAutoSync);
|
||||
autoSyncRef.current = sftpAutoSync;
|
||||
|
||||
// Sync activeTabId to external store (allows child components to subscribe without parent re-render)
|
||||
// Using useLayoutEffect to sync before paint
|
||||
useLayoutEffect(() => {
|
||||
@@ -1660,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),
|
||||
[],
|
||||
@@ -1743,7 +2031,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
side,
|
||||
fullPath,
|
||||
file.name,
|
||||
savedOpener.systemApp.path
|
||||
savedOpener.systemApp.path,
|
||||
{ enableWatch: autoSyncRef.current }
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
@@ -1785,7 +2074,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
fileOpenerTarget.side,
|
||||
fileOpenerTarget.fullPath,
|
||||
fileOpenerTarget.file.name,
|
||||
systemApp.path
|
||||
systemApp.path,
|
||||
{ enableWatch: autoSyncRef.current }
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
@@ -1862,6 +2152,108 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
[handleOpenFileWithForSide],
|
||||
);
|
||||
|
||||
// Handle external file/folder upload from OS drag-and-drop (shared logic)
|
||||
// Uses sftpRef.current internally, so dependencies are stable.
|
||||
// toast and logger are globally stable, t is the only real dependency.
|
||||
const handleUploadExternalFilesForSide = useCallback(
|
||||
async (side: "left" | "right", dataTransfer: DataTransfer) => {
|
||||
try {
|
||||
const results = await sftpRef.current.uploadExternalFiles(side, dataTransfer);
|
||||
|
||||
// Check if upload was cancelled
|
||||
if (sftpRef.current.folderUploadProgress.cancelled) {
|
||||
toast.info(t('sftp.upload.cancelled'), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const failCount = results.filter(r => !r.success).length;
|
||||
// Count only files, not directories for success message
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
|
||||
if (failCount === 0) {
|
||||
// All items uploaded successfully
|
||||
const message = successCount === 1
|
||||
? `${t('sftp.upload')}: ${results[0].fileName}`
|
||||
: `${t('sftp.uploadFiles')}: ${successCount}`;
|
||||
toast.success(message, "SFTP");
|
||||
} else {
|
||||
// Some or all items failed
|
||||
const failedFiles = results.filter(r => !r.success);
|
||||
failedFiles.forEach(failed => {
|
||||
const errorMsg = failed.error ? ` - ${failed.error}` : '';
|
||||
toast.error(
|
||||
`${t('sftp.error.uploadFailed')}: ${failed.fileName}${errorMsg}`,
|
||||
"SFTP"
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[SftpView] Failed to upload external files:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('sftp.error.uploadFailed'),
|
||||
"SFTP"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleUploadExternalFilesLeft = useCallback(
|
||||
(dataTransfer: DataTransfer) => handleUploadExternalFilesForSide("left", dataTransfer),
|
||||
[handleUploadExternalFilesForSide],
|
||||
);
|
||||
|
||||
const handleUploadExternalFilesRight = useCallback(
|
||||
(dataTransfer: DataTransfer) => handleUploadExternalFilesForSide("right", dataTransfer),
|
||||
[handleUploadExternalFilesForSide],
|
||||
);
|
||||
|
||||
// Download file to local filesystem (browser download)
|
||||
const handleDownloadFileForSide = useCallback(
|
||||
async (side: "left" | "right", file: SftpFileEntry) => {
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
|
||||
try {
|
||||
// Read the file as binary
|
||||
const content = await sftpRef.current.readBinaryFile(side, fullPath);
|
||||
|
||||
// Create blob and trigger browser download
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`${t('sftp.context.download')}: ${file.name}`, "SFTP");
|
||||
} catch (e) {
|
||||
logger.error("[SftpView] Failed to download file:", e);
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t('sftp.error.downloadFailed'),
|
||||
"SFTP"
|
||||
);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleDownloadFileLeft = useCallback(
|
||||
(file: SftpFileEntry) => handleDownloadFileForSide("left", file),
|
||||
[handleDownloadFileForSide],
|
||||
);
|
||||
|
||||
const handleDownloadFileRight = useCallback(
|
||||
(file: SftpFileEntry) => handleDownloadFileForSide("right", file),
|
||||
[handleDownloadFileForSide],
|
||||
);
|
||||
|
||||
// Custom handleOpenEntry callbacks that check the double-click behavior setting
|
||||
const handleOpenEntryLeft = useCallback(
|
||||
(entry: SftpFileEntry) => {
|
||||
@@ -1931,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,
|
||||
@@ -1939,6 +2332,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
onEditFile: handleEditFileLeft,
|
||||
onOpenFile: handleOpenFileLeft,
|
||||
onOpenFileWith: handleOpenFileWithLeft,
|
||||
onDownloadFile: handleDownloadFileLeft,
|
||||
onUploadExternalFiles: handleUploadExternalFilesLeft,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -1956,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,
|
||||
@@ -1964,6 +2360,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
onEditFile: handleEditFileRight,
|
||||
onOpenFile: handleOpenFileRight,
|
||||
onOpenFileWith: handleOpenFileWithRight,
|
||||
onDownloadFile: handleDownloadFileRight,
|
||||
onUploadExternalFiles: handleUploadExternalFilesRight,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -2104,6 +2502,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
dragCallbacks={dragCallbacks}
|
||||
leftCallbacks={leftCallbacks}
|
||||
rightCallbacks={rightCallbacks}
|
||||
showHiddenFiles={sftpShowHiddenFiles}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -2217,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>
|
||||
@@ -2242,6 +2642,55 @@ 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>
|
||||
{sftp.folderUploadProgress.currentFileTotalBytes > 0 && (
|
||||
<span className="text-xs text-muted-foreground ml-2 flex-shrink-0">
|
||||
{sftp.formatFileSize(sftp.folderUploadProgress.currentFileBytes)} / {sftp.formatFileSize(sftp.folderUploadProgress.currentFileTotalBytes)}
|
||||
{sftp.folderUploadProgress.currentFileSpeed > 0 && (
|
||||
<> ({sftp.formatFileSize(sftp.folderUploadProgress.currentFileSpeed)}/s)</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{sftp.folderUploadProgress.currentFileTotalBytes > 0 && (
|
||||
<div className="w-full bg-muted/30 rounded-full h-1.5 mt-1">
|
||||
<div
|
||||
className="bg-primary h-1.5 rounded-full transition-all duration-150 ease-out"
|
||||
style={{
|
||||
width: `${Math.min((sftp.folderUploadProgress.currentFileBytes / Math.max(sftp.folderUploadProgress.currentFileTotalBytes, 1)) * 100, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{sftp.folderUploadProgress.currentFile && (
|
||||
<div className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{sftp.folderUploadProgress.currentFile}
|
||||
</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}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Maximize2, Radio } from "lucide-react";
|
||||
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -127,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);
|
||||
@@ -181,6 +183,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [timeLeft, setTimeLeft] = useState(CONNECTION_TIMEOUT / 1000);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [showSFTP, setShowSFTP] = useState(false);
|
||||
const [sftpInitialPath, setSftpInitialPath] = useState<string | undefined>(undefined);
|
||||
const [progressValue, setProgressValue] = useState(15);
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
|
||||
@@ -732,6 +735,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current?.writeln("\r\n[No active SSH session]");
|
||||
};
|
||||
|
||||
const handleOpenSFTP = async () => {
|
||||
// If SFTP is already open, toggle it off
|
||||
if (showSFTP) {
|
||||
setShowSFTP(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get the current working directory from the terminal session
|
||||
let initialPath: string | undefined = undefined;
|
||||
if (sessionRef.current) {
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (result.success && result.cwd) {
|
||||
initialPath = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail and open SFTP without initial path
|
||||
}
|
||||
}
|
||||
|
||||
// Use flushSync to ensure initialPath state is committed before opening SFTP modal
|
||||
// This prevents React's batching from causing the modal to open with stale/undefined initialPath
|
||||
flushSync(() => {
|
||||
setSftpInitialPath(initialPath);
|
||||
});
|
||||
setShowSFTP(true);
|
||||
};
|
||||
|
||||
const handleCancelConnect = () => {
|
||||
setIsCancelling(true);
|
||||
auth.setNeedsAuth(false);
|
||||
@@ -810,7 +841,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
|
||||
isScriptsOpen={isScriptsOpen}
|
||||
setIsScriptsOpen={setIsScriptsOpen}
|
||||
onOpenSFTP={() => setShowSFTP((v) => !v)}
|
||||
onOpenSFTP={handleOpenSFTP}
|
||||
onSnippetClick={handleSnippetClick}
|
||||
onUpdateHost={onUpdateHost}
|
||||
showClose={opts?.showClose}
|
||||
@@ -1049,10 +1080,12 @@ 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"}
|
||||
onClose={() => setShowSFTP(false)}
|
||||
initialPath={sftpInitialPath}
|
||||
/>
|
||||
</div>
|
||||
</TerminalContextMenu>
|
||||
|
||||
@@ -95,6 +95,9 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
|
||||
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
|
||||
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
|
||||
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
|
||||
// Track theme from document.documentElement class (syncs with app theme)
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(() =>
|
||||
@@ -140,6 +143,11 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
}
|
||||
}, [content, onSave, saving, t]);
|
||||
|
||||
// Keep the ref updated with the latest handleSave function
|
||||
useEffect(() => {
|
||||
handleSaveRef.current = handleSave;
|
||||
}, [handleSave]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (hasChanges) {
|
||||
const confirmed = confirm(t('sftp.editor.unsavedChanges'));
|
||||
@@ -155,9 +163,9 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
|
||||
// Add save shortcut
|
||||
// Add save shortcut - use ref to avoid stale closure
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
handleSave();
|
||||
handleSaveRef.current();
|
||||
});
|
||||
|
||||
// Add find shortcut (Ctrl+F / Cmd+F)
|
||||
@@ -165,7 +173,7 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
// Trigger Monaco's built-in find widget
|
||||
editor.trigger('keyboard', 'actions.find', null);
|
||||
});
|
||||
}, [handleSave]);
|
||||
}, []);
|
||||
|
||||
// Trigger search dialog
|
||||
const handleSearch = useCallback(() => {
|
||||
|
||||
@@ -50,6 +50,7 @@ import PortForwarding from "./PortForwardingNew";
|
||||
import QuickConnectWizard from "./QuickConnectWizard";
|
||||
import { isQuickConnectInput, parseQuickConnectInputWithWarnings } from "../domain/quickConnect";
|
||||
import SerialConnectModal from "./SerialConnectModal";
|
||||
import SerialHostDetailsPanel from "./SerialHostDetailsPanel";
|
||||
import SnippetsManager from "./SnippetsManager";
|
||||
import { ImportVaultDialog } from "./vault/ImportVaultDialog";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -1361,7 +1362,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Host Details Panel - positioned at VaultView root level for correct top alignment */}
|
||||
{currentSection === "hosts" && isHostPanelOpen && (
|
||||
{currentSection === "hosts" && isHostPanelOpen && editingHost?.protocol !== 'serial' && (
|
||||
<HostDetailsPanel
|
||||
initialData={editingHost}
|
||||
availableKeys={keys}
|
||||
@@ -1396,6 +1397,31 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Serial Host Details Panel - for editing serial port hosts */}
|
||||
{currentSection === "hosts" && isHostPanelOpen && editingHost?.protocol === 'serial' && (
|
||||
<SerialHostDetailsPanel
|
||||
initialData={editingHost}
|
||||
allTags={allTags}
|
||||
groups={Array.from(
|
||||
new Set([
|
||||
...customGroups,
|
||||
...hosts.map((h) => h.group || "General"),
|
||||
]),
|
||||
)}
|
||||
onSave={(host) => {
|
||||
onUpdateHosts(
|
||||
hosts.map((h) => (h.id === host.id ? host : h)),
|
||||
);
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={isNewFolderOpen} onOpenChange={(open) => {
|
||||
setIsNewFolderOpen(open);
|
||||
if (!open) {
|
||||
@@ -1532,6 +1558,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onConnectSerial(config);
|
||||
}
|
||||
}}
|
||||
onSaveHost={(host) => {
|
||||
onUpdateHosts([...hosts, host]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -84,7 +84,6 @@ export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pem,.key,.pub,.ppk,*"
|
||||
className="hidden"
|
||||
onChange={handleFileImport}
|
||||
/>
|
||||
|
||||
186
components/settings/ThemeSelectModal.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Theme Select Modal
|
||||
* A modal dialog for selecting terminal themes in settings
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Check, Palette, X } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
|
||||
import { Button } from '../ui/button';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
// Memoized theme item component to prevent unnecessary re-renders
|
||||
const ThemeItem = memo(({
|
||||
theme,
|
||||
isSelected,
|
||||
onSelect
|
||||
}: {
|
||||
theme: TerminalThemeConfig;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) => (
|
||||
<button
|
||||
onClick={() => onSelect(theme.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all',
|
||||
isSelected
|
||||
? 'bg-primary/15 ring-1 ring-primary'
|
||||
: 'hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
{/* Color swatch preview */}
|
||||
<div
|
||||
className="w-12 h-8 rounded-md flex-shrink-0 flex flex-col justify-center items-start pl-1.5 gap-0.5 border border-border/50"
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
>
|
||||
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: theme.colors.green }} />
|
||||
<div className="h-1 w-6 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
|
||||
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn('text-sm font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
|
||||
{theme.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground capitalize">{theme.type}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check size={16} className="text-primary flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
));
|
||||
ThemeItem.displayName = 'ThemeItem';
|
||||
|
||||
interface ThemeSelectModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
selectedThemeId: string;
|
||||
onSelect: (themeId: string) => void;
|
||||
}
|
||||
|
||||
export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
selectedThemeId,
|
||||
onSelect,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
// Group themes by type
|
||||
const { darkThemes, lightThemes } = useMemo(() => {
|
||||
const dark = TERMINAL_THEMES.filter(t => t.type === 'dark');
|
||||
const light = TERMINAL_THEMES.filter(t => t.type === 'light');
|
||||
return { darkThemes: dark, lightThemes: light };
|
||||
}, []);
|
||||
|
||||
// Handle theme selection - select and close
|
||||
const handleThemeSelect = useCallback((themeId: string) => {
|
||||
onSelect(themeId);
|
||||
onClose();
|
||||
}, [onSelect, onClose]);
|
||||
|
||||
// Handle ESC key
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Handle backdrop click
|
||||
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}, [onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const modalTitleId = 'theme-select-modal-title';
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/60"
|
||||
style={{ zIndex: 99999 }}
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={modalTitleId}
|
||||
>
|
||||
<div
|
||||
className="w-[480px] max-h-[600px] bg-background border border-border rounded-2xl shadow-2xl flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 shrink-0 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-primary/10">
|
||||
<Palette size={16} className="text-primary" />
|
||||
</div>
|
||||
<h2 id={modalTitleId} className="text-sm font-semibold text-foreground">{t('settings.terminal.themeModal.title')}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Theme List */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-4">
|
||||
{/* Dark Themes Section */}
|
||||
<div className="mb-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
|
||||
{t('settings.terminal.themeModal.darkThemes')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{darkThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Light Themes Section */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
|
||||
{t('settings.terminal.themeModal.lightThemes')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{lightThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end px-5 py-3 shrink-0 border-t border-border bg-muted/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Use Portal to render at document root
|
||||
return createPortal(modalContent, document.body);
|
||||
};
|
||||
|
||||
export default ThemeSelectModal;
|
||||
@@ -29,7 +29,7 @@ const getOpenerLabel = (
|
||||
export default function SettingsFileAssociationsTab() {
|
||||
const { t } = useI18n();
|
||||
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
|
||||
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior } = useSettingsState();
|
||||
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles } = useSettingsState();
|
||||
const associations = getAllAssociations();
|
||||
const [editingExtension, setEditingExtension] = useState<string | null>(null);
|
||||
|
||||
@@ -133,6 +133,86 @@ export default function SettingsFileAssociationsTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-sync section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.autoSync')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.autoSync.desc')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSftpAutoSync(!sftpAutoSync)}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
sftpAutoSync
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
sftpAutoSync
|
||||
? "border-primary bg-primary"
|
||||
: "border-muted-foreground/30"
|
||||
)}>
|
||||
{sftpAutoSync && (
|
||||
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.autoSync.enable')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.autoSync.enableDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Show hidden files section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.showHiddenFiles')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.showHiddenFiles.desc')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSftpShowHiddenFiles(!sftpShowHiddenFiles)}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
sftpShowHiddenFiles
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
sftpShowHiddenFiles
|
||||
? "border-primary bg-primary"
|
||||
: "border-muted-foreground/30"
|
||||
)}>
|
||||
{sftpShowHiddenFiles && (
|
||||
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.showHiddenFiles.enable')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.showHiddenFiles.enableDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File associations section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftpFileAssociations.title')} />
|
||||
|
||||
180
components/settings/tabs/SettingsSystemTab.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Settings System Tab - System information and temp file management
|
||||
*/
|
||||
import { FolderOpen, HardDrive, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
import { Button } from "../../ui/button";
|
||||
|
||||
interface TempDirInfo {
|
||||
path: string;
|
||||
fileCount: number;
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
const SettingsSystemTab: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const [tempDirInfo, setTempDirInfo] = useState<TempDirInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [clearResult, setClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
|
||||
|
||||
const loadTempDirInfo = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getTempDirInfo) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const info = await bridge.getTempDirInfo();
|
||||
setTempDirInfo(info);
|
||||
} catch (err) {
|
||||
console.error("[SettingsSystemTab] Failed to get temp dir info:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTempDirInfo();
|
||||
}, [loadTempDirInfo]);
|
||||
|
||||
const handleClearTempFiles = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.clearTempDir) return;
|
||||
|
||||
setIsClearing(true);
|
||||
setClearResult(null);
|
||||
try {
|
||||
const result = await bridge.clearTempDir();
|
||||
setClearResult(result);
|
||||
// Refresh info after clearing
|
||||
await loadTempDirInfo();
|
||||
} catch (err) {
|
||||
console.error("[SettingsSystemTab] Failed to clear temp dir:", err);
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
}, [loadTempDirInfo]);
|
||||
|
||||
const handleOpenTempDir = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!tempDirInfo?.path || !bridge?.openTempDir) return;
|
||||
await bridge.openTempDir();
|
||||
}, [tempDirInfo]);
|
||||
|
||||
return (
|
||||
<TabsContent
|
||||
value="system"
|
||||
className="data-[state=inactive]:hidden h-full flex flex-col"
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-8 py-6">
|
||||
<div className="max-w-2xl space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t("settings.system.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("settings.system.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Temp Directory Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t("settings.system.tempDirectory")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
||||
{/* Path */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-muted-foreground">{t("settings.system.location")}</p>
|
||||
<p className="text-sm font-mono mt-1 break-all">
|
||||
{isLoading ? "..." : (tempDirInfo?.path ?? "-")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={handleOpenTempDir}
|
||||
disabled={!tempDirInfo?.path}
|
||||
title={t("settings.system.openFolder")}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("settings.system.fileCount")}:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{isLoading ? "..." : (tempDirInfo?.fileCount ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("settings.system.totalSize")}:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{isLoading ? "..." : formatBytes(tempDirInfo?.totalSize ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadTempDirInfo}
|
||||
disabled={isLoading}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
|
||||
{t("settings.system.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearTempFiles}
|
||||
disabled={isClearing || (tempDirInfo?.fileCount ?? 0) === 0}
|
||||
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{isClearing ? t("settings.system.clearing") : t("settings.system.clearTempFiles")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Clear Result */}
|
||||
{clearResult && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.system.clearResult", {
|
||||
deleted: clearResult.deletedCount,
|
||||
failed: clearResult.failedCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.system.tempDirectoryHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsSystemTab;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { AlertCircle, Check, Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState, useMemo } from "react";
|
||||
import { AlertCircle, ChevronRight, Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import type {
|
||||
CursorShape,
|
||||
LinkModifier,
|
||||
@@ -16,58 +16,56 @@ import { Button } from "../../ui/button";
|
||||
import { Input } from "../../ui/input";
|
||||
import { Label } from "../../ui/label";
|
||||
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
|
||||
import { ThemeSelectModal } from "../ThemeSelectModal";
|
||||
|
||||
// Helper: render terminal preview
|
||||
const renderTerminalPreview = (theme: (typeof TERMINAL_THEMES)[0]) => {
|
||||
// Theme preview button component
|
||||
const ThemePreviewButton: React.FC<{
|
||||
theme: (typeof TERMINAL_THEMES)[0];
|
||||
onClick: () => void;
|
||||
buttonLabel: string;
|
||||
}> = ({ theme, onClick, buttonLabel }) => {
|
||||
const c = theme.colors;
|
||||
const lines = [
|
||||
{ prompt: "~", cmd: "ssh prod-server", color: c.foreground },
|
||||
{ prompt: "prod", cmd: "ls -la", color: c.green },
|
||||
{ prompt: "prod", cmd: "cat config.json", color: c.cyan },
|
||||
];
|
||||
return (
|
||||
<div
|
||||
className="font-mono text-[9px] leading-tight p-1.5 rounded overflow-hidden h-full"
|
||||
style={{ backgroundColor: c.background, color: c.foreground }}
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-4 p-3 rounded-lg border bg-card hover:bg-accent/50 transition-all text-left",
|
||||
)}
|
||||
>
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} className="flex gap-1 truncate">
|
||||
<span style={{ color: c.blue }}>{l.prompt}</span>
|
||||
<span style={{ color: c.magenta }}>$</span>
|
||||
<span style={{ color: l.color }}>{l.cmd}</span>
|
||||
{/* Theme preview swatch */}
|
||||
<div
|
||||
className="w-20 h-14 rounded-lg flex-shrink-0 flex flex-col justify-center items-start pl-2 gap-0.5 border border-border/50"
|
||||
style={{ backgroundColor: c.background }}
|
||||
>
|
||||
<div className="flex gap-1 items-center">
|
||||
<span className="font-mono text-[8px]" style={{ color: c.green }}>$</span>
|
||||
<span className="font-mono text-[8px]" style={{ color: c.blue }}>ls</span>
|
||||
</div>
|
||||
<div className="flex gap-0.5">
|
||||
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: c.cyan }} />
|
||||
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: c.magenta }} />
|
||||
</div>
|
||||
<div className="flex gap-1 items-center">
|
||||
<span className="font-mono text-[8px]" style={{ color: c.green }}>$</span>
|
||||
<span className="inline-block w-1.5 h-2 animate-pulse" style={{ backgroundColor: c.cursor }} />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-1">
|
||||
<span style={{ color: c.blue }}>~</span>
|
||||
<span style={{ color: c.magenta }}>$</span>
|
||||
<span className="inline-block w-1.5 h-2.5 animate-pulse" style={{ backgroundColor: c.cursor }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{theme.name}</div>
|
||||
<div className="text-xs text-muted-foreground capitalize">{theme.type}</div>
|
||||
</div>
|
||||
|
||||
{/* Action button area */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-xs">{buttonLabel}</span>
|
||||
<ChevronRight size={16} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const TerminalThemeCard: React.FC<{
|
||||
theme: (typeof TERMINAL_THEMES)[0];
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}> = ({ theme, active, onClick }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative flex flex-col rounded-lg border-2 transition-all overflow-hidden text-left",
|
||||
active ? "border-primary ring-2 ring-primary/20" : "border-border hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
<div className="h-16">{renderTerminalPreview(theme)}</div>
|
||||
<div className="px-2 py-1.5 text-xs font-medium border-t bg-card">{theme.name}</div>
|
||||
{active && (
|
||||
<div className="absolute top-1 right-1 w-4 h-4 bg-primary rounded-full flex items-center justify-center">
|
||||
<Check size={10} className="text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
export default function SettingsTerminalTab(props: {
|
||||
terminalThemeId: string;
|
||||
setTerminalThemeId: (id: string) => void;
|
||||
@@ -99,6 +97,12 @@ export default function SettingsTerminalTab(props: {
|
||||
const [defaultShell, setDefaultShell] = useState<string>("");
|
||||
const [shellValidation, setShellValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
const [dirValidation, setDirValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
|
||||
// Get current selected theme
|
||||
const currentTheme = useMemo(() => {
|
||||
return TERMINAL_THEMES.find(t => t.id === terminalThemeId) || TERMINAL_THEMES[0];
|
||||
}, [terminalThemeId]);
|
||||
|
||||
// Fetch default shell on mount
|
||||
useEffect(() => {
|
||||
@@ -184,16 +188,18 @@ export default function SettingsTerminalTab(props: {
|
||||
return (
|
||||
<SettingsTabContent value="terminal">
|
||||
<SectionHeader title={t("settings.terminal.section.theme")} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{TERMINAL_THEMES.map((t) => (
|
||||
<TerminalThemeCard
|
||||
key={t.id}
|
||||
theme={t}
|
||||
active={terminalThemeId === t.id}
|
||||
onClick={() => setTerminalThemeId(t.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ThemePreviewButton
|
||||
theme={currentTheme}
|
||||
onClick={() => setThemeModalOpen(true)}
|
||||
buttonLabel={t("settings.terminal.theme.selectButton")}
|
||||
/>
|
||||
|
||||
<ThemeSelectModal
|
||||
open={themeModalOpen}
|
||||
onClose={() => setThemeModalOpen(false)}
|
||||
selectedThemeId={terminalThemeId}
|
||||
onSelect={setTerminalThemeId}
|
||||
/>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.font")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
|
||||
@@ -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;
|
||||
@@ -31,6 +32,9 @@ export interface SftpPaneCallbacks {
|
||||
onEditFile?: (entry: SftpFileEntry) => void;
|
||||
onOpenFile?: (entry: SftpFileEntry) => void;
|
||||
onOpenFileWith?: (entry: SftpFileEntry) => void; // Always show opener dialog
|
||||
onDownloadFile?: (entry: SftpFileEntry) => void; // Download to local filesystem
|
||||
// External file upload (supports folders via DataTransfer)
|
||||
onUploadExternalFiles?: (dataTransfer: DataTransfer) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface SftpDragCallbacks {
|
||||
@@ -91,6 +95,9 @@ export interface SftpContextValue {
|
||||
// Callbacks for each side
|
||||
leftCallbacks: SftpPaneCallbacks;
|
||||
rightCallbacks: SftpPaneCallbacks;
|
||||
|
||||
// Settings
|
||||
showHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
const SftpContext = createContext<SftpContextValue | null>(null);
|
||||
@@ -124,12 +131,19 @@ export const useSftpHosts = () => {
|
||||
return context.hosts;
|
||||
};
|
||||
|
||||
// Hook to get showHiddenFiles setting
|
||||
export const useSftpShowHiddenFiles = (): boolean => {
|
||||
const context = useSftpContext();
|
||||
return context.showHiddenFiles;
|
||||
};
|
||||
|
||||
interface SftpContextProviderProps {
|
||||
hosts: Host[];
|
||||
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
|
||||
dragCallbacks: SftpDragCallbacks;
|
||||
leftCallbacks: SftpPaneCallbacks;
|
||||
rightCallbacks: SftpPaneCallbacks;
|
||||
showHiddenFiles: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -139,6 +153,7 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
|
||||
dragCallbacks,
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
showHiddenFiles,
|
||||
children,
|
||||
}) => {
|
||||
// Memoize the context value to prevent unnecessary re-renders
|
||||
@@ -150,8 +165,9 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
|
||||
dragCallbacks,
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
showHiddenFiles,
|
||||
}),
|
||||
[hosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks],
|
||||
[hosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks, showHiddenFiles],
|
||||
);
|
||||
|
||||
return <SftpContext.Provider value={value}>{children}</SftpContext.Provider>;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Utilities
|
||||
export {
|
||||
formatBytes,formatDate,
|
||||
formatSpeed,formatTransferBytes,getFileIcon,isNavigableDirectory,type ColumnWidths,type SortField,
|
||||
formatSpeed,formatTransferBytes,getFileIcon,isNavigableDirectory,isWindowsHiddenFile,filterHiddenFiles,type ColumnWidths,type SortField,
|
||||
type SortOrder
|
||||
} from './utils';
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
useSftpPaneCallbacks,
|
||||
useSftpDrag,
|
||||
useSftpHosts,
|
||||
useSftpShowHiddenFiles,
|
||||
useActiveTabId,
|
||||
useIsPaneActive,
|
||||
activeTabStore,
|
||||
|
||||
@@ -187,3 +187,33 @@ export interface ColumnWidths {
|
||||
export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
|
||||
return entry.type === 'directory' || (entry.type === 'symlink' && entry.linkTarget === 'directory');
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a file is hidden on Windows
|
||||
* Only applies to local Windows filesystem where the hidden attribute is set
|
||||
* The ".." parent directory entry is never considered hidden
|
||||
*
|
||||
* Note: On Unix/Linux, there's no system-level hidden file concept.
|
||||
* Dotfiles are just a convention, not actual hidden files, so we don't filter them.
|
||||
*/
|
||||
export const isWindowsHiddenFile = <T extends { name: string; hidden?: boolean }>(file: T): boolean => {
|
||||
if (file.name === "..") return false;
|
||||
return file.hidden === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter files based on Windows hidden file visibility setting
|
||||
* Only filters files with the Windows hidden attribute set
|
||||
* Always preserves ".." parent directory entry
|
||||
*
|
||||
* This setting only affects local Windows filesystem browsing.
|
||||
* On Unix/Linux systems and remote SFTP connections, all files are shown
|
||||
* because there's no system-level hidden file concept (dotfiles are just a convention).
|
||||
*/
|
||||
export const filterHiddenFiles = <T extends { name: string; hidden?: boolean }>(
|
||||
files: T[],
|
||||
showHiddenFiles: boolean
|
||||
): T[] => {
|
||||
if (showHiddenFiles) return files;
|
||||
return files.filter((f) => !isWindowsHiddenFile(f));
|
||||
};
|
||||
|
||||
@@ -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: "*",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -88,6 +88,10 @@ export interface Host {
|
||||
telnetEnabled?: boolean; // Is Telnet enabled for this host
|
||||
telnetUsername?: string; // Telnet-specific username
|
||||
telnetPassword?: string; // Telnet-specific password
|
||||
// Serial-specific configuration (for protocol='serial' hosts)
|
||||
serialConfig?: SerialConfig;
|
||||
// SFTP specific configuration
|
||||
sftpSudo?: boolean; // Use sudo for SFTP operations (requires password)
|
||||
}
|
||||
|
||||
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
|
||||
@@ -473,6 +477,7 @@ export interface RemoteFile {
|
||||
lastModified: string;
|
||||
linkTarget?: 'file' | 'directory' | null; // For symlinks: the type of the target, or null if broken
|
||||
permissions?: string; // rwx format for owner/group/others e.g. "rwxr-xr-x"
|
||||
hidden?: boolean; // Windows hidden attribute (only set for local Windows filesystem)
|
||||
}
|
||||
|
||||
export type WorkspaceNode =
|
||||
@@ -512,6 +517,7 @@ export interface SftpFileEntry {
|
||||
owner?: string;
|
||||
group?: string;
|
||||
linkTarget?: 'file' | 'directory' | null; // For symlinks: the type of the target, or null if broken
|
||||
hidden?: boolean; // Windows hidden attribute (only set for local Windows filesystem)
|
||||
}
|
||||
|
||||
export interface SftpConnection {
|
||||
@@ -615,7 +621,7 @@ export interface ConnectionLog {
|
||||
hostLabel: string; // Display label (e.g., 'Local Terminal' or host label)
|
||||
hostname: string; // Target hostname or 'localhost'
|
||||
username: string; // SSH username or system username
|
||||
protocol: 'ssh' | 'telnet' | 'local' | 'mosh';
|
||||
protocol: 'ssh' | 'telnet' | 'local' | 'mosh' | 'serial';
|
||||
startTime: number; // Connection start timestamp
|
||||
endTime?: number; // Connection end timestamp (undefined if still active)
|
||||
localUsername: string; // System username of the local user
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
110
electron-builder.config.cjs
Normal file
@@ -0,0 +1,110 @@
|
||||
/* global __dirname */
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* @type {import('electron-builder').Configuration}
|
||||
*/
|
||||
module.exports = {
|
||||
appId: 'com.netcatty.app',
|
||||
productName: 'Netcatty',
|
||||
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
|
||||
icon: 'public/icon.png',
|
||||
directories: {
|
||||
buildResources: 'build',
|
||||
output: 'release'
|
||||
},
|
||||
files: [
|
||||
'dist/**/*',
|
||||
'electron/**/*',
|
||||
'!electron/.dev-config.json',
|
||||
'public/**/*',
|
||||
'node_modules/**/*'
|
||||
],
|
||||
asarUnpack: [
|
||||
'node_modules/node-pty/**/*',
|
||||
'node_modules/ssh2/**/*',
|
||||
'node_modules/cpu-features/**/*'
|
||||
],
|
||||
mac: {
|
||||
target: [
|
||||
{
|
||||
target: 'dmg',
|
||||
arch: ['arm64', 'x64']
|
||||
},
|
||||
{
|
||||
target: 'zip',
|
||||
arch: ['arm64', 'x64']
|
||||
}
|
||||
],
|
||||
category: 'public.app-category.developer-tools',
|
||||
hardenedRuntime: false,
|
||||
gatekeeperAssess: false,
|
||||
entitlements: 'electron/entitlements.mac.plist',
|
||||
entitlementsInherit: 'electron/entitlements.mac.plist',
|
||||
extendInfo: {
|
||||
NSCameraUsageDescription: 'Netcatty may use the camera for video calls',
|
||||
NSMicrophoneUsageDescription: 'Netcatty may use the microphone for audio',
|
||||
NSLocalNetworkUsageDescription: 'Netcatty needs local network access for SSH connections'
|
||||
}
|
||||
},
|
||||
dmg: {
|
||||
title: '${productName}',
|
||||
background: 'public/dmg-background.jpg',
|
||||
iconSize: 100,
|
||||
iconTextSize: 12,
|
||||
window: {
|
||||
width: 672,
|
||||
height: 500
|
||||
},
|
||||
contents: [
|
||||
{ x: 150, y: 158 },
|
||||
{ x: 550, y: 158, type: 'link', path: '/Applications' },
|
||||
{
|
||||
x: 350,
|
||||
y: 330,
|
||||
type: 'file',
|
||||
// Use absolute path resolved at build time
|
||||
path: path.resolve(__dirname, 'scripts/FixQuarantine.app'),
|
||||
name: '已损坏修复.app'
|
||||
}
|
||||
]
|
||||
},
|
||||
win: {
|
||||
target: [
|
||||
{
|
||||
target: 'nsis',
|
||||
arch: ['x64']
|
||||
},
|
||||
{
|
||||
target: 'dir',
|
||||
arch: ['x64']
|
||||
}
|
||||
]
|
||||
},
|
||||
nsis: {
|
||||
oneClick: false,
|
||||
perMachine: false,
|
||||
allowElevation: true,
|
||||
allowToChangeInstallationDirectory: true,
|
||||
createDesktopShortcut: true,
|
||||
createStartMenuShortcut: true,
|
||||
shortcutName: 'Netcatty'
|
||||
},
|
||||
linux: {
|
||||
target: [
|
||||
{
|
||||
target: 'AppImage',
|
||||
arch: ['x64', 'arm64']
|
||||
},
|
||||
{
|
||||
target: 'deb',
|
||||
arch: ['x64', 'arm64']
|
||||
},
|
||||
{
|
||||
target: 'rpm',
|
||||
arch: ['x64', 'arm64']
|
||||
}
|
||||
],
|
||||
category: 'Development'
|
||||
}
|
||||
};
|
||||
@@ -1,83 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
|
||||
"appId": "com.netcatty.app",
|
||||
"productName": "Netcatty",
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "public/icon.png",
|
||||
"directories": {
|
||||
"buildResources": "build",
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/**/*",
|
||||
"!electron/.dev-config.json",
|
||||
"public/**/*",
|
||||
"node_modules/**/*"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"node_modules/node-pty/**/*",
|
||||
"node_modules/ssh2/**/*",
|
||||
"node_modules/cpu-features/**/*"
|
||||
],
|
||||
"mac": {
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": ["arm64", "x64"]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": ["arm64", "x64"]
|
||||
}
|
||||
],
|
||||
"category": "public.app-category.developer-tools",
|
||||
"hardenedRuntime": false,
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "electron/entitlements.mac.plist",
|
||||
"entitlementsInherit": "electron/entitlements.mac.plist",
|
||||
"extendInfo": {
|
||||
"NSCameraUsageDescription": "Netcatty may use the camera for video calls",
|
||||
"NSMicrophoneUsageDescription": "Netcatty may use the microphone for audio",
|
||||
"NSLocalNetworkUsageDescription": "Netcatty needs local network access for SSH connections"
|
||||
}
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64"]
|
||||
},
|
||||
{
|
||||
"target": "dir",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowElevation": true,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "Netcatty"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": ["x64", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": ["x64", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "rpm",
|
||||
"arch": ["x64", "arm64"]
|
||||
}
|
||||
],
|
||||
"category": "Development"
|
||||
}
|
||||
}
|
||||
379
electron/bridges/fileWatcherBridge.cjs
Normal file
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* File Watcher Bridge - Watches local temp files for changes to sync back to remote
|
||||
*
|
||||
* This bridge enables auto-sync functionality for files opened with external applications.
|
||||
* When a file is downloaded to temp and opened with an external app, we watch for changes
|
||||
* and automatically upload them back to the remote server.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
// Map of watchId -> { watcher, localPath, remotePath, sftpId, lastModified, lastSize }
|
||||
const activeWatchers = new Map();
|
||||
|
||||
// Debounce map to prevent multiple rapid syncs
|
||||
const debounceTimers = new Map();
|
||||
|
||||
// Map of sftpId -> Set<localPath> to track temp files even without watching
|
||||
// This allows cleanup when SFTP session closes, regardless of auto-sync setting
|
||||
const tempFilesMap = new Map();
|
||||
|
||||
let sftpClients = null;
|
||||
let electronModule = null;
|
||||
|
||||
/**
|
||||
* Initialize the file watcher bridge with dependencies
|
||||
*/
|
||||
function init(deps) {
|
||||
sftpClients = deps.sftpClients;
|
||||
electronModule = deps.electronModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a temp file for cleanup when SFTP session closes
|
||||
* Called regardless of whether auto-sync is enabled
|
||||
*/
|
||||
function registerTempFile(sftpId, localPath) {
|
||||
if (!tempFilesMap.has(sftpId)) {
|
||||
tempFilesMap.set(sftpId, new Set());
|
||||
}
|
||||
tempFilesMap.get(sftpId).add(localPath);
|
||||
console.log(`[FileWatcher] Registered temp file for cleanup: ${localPath} (session: ${sftpId})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a system notification for file sync events
|
||||
* Works on macOS, Windows, and Linux
|
||||
*/
|
||||
function showSystemNotification(title, body) {
|
||||
try {
|
||||
if (!electronModule?.Notification) {
|
||||
console.warn("[FileWatcher] Electron Notification API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const { Notification } = electronModule;
|
||||
|
||||
// Check if notifications are supported
|
||||
if (!Notification.isSupported()) {
|
||||
console.warn("[FileWatcher] System notifications not supported on this platform");
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = new Notification({
|
||||
title,
|
||||
body,
|
||||
silent: false, // Allow notification sound
|
||||
});
|
||||
|
||||
notification.show();
|
||||
} catch (err) {
|
||||
console.warn("[FileWatcher] Failed to show system notification:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching a local file for changes
|
||||
* Returns a watchId that can be used to stop watching
|
||||
*/
|
||||
async function startWatching(event, { localPath, remotePath, sftpId }) {
|
||||
const watchId = `watch-${crypto.randomUUID()}`;
|
||||
|
||||
console.log(`[FileWatcher] Starting watch: ${localPath} -> ${remotePath}`);
|
||||
|
||||
// Get initial file stats
|
||||
let lastModified;
|
||||
let lastSize;
|
||||
try {
|
||||
const stat = await fs.promises.stat(localPath);
|
||||
lastModified = stat.mtimeMs;
|
||||
lastSize = stat.size;
|
||||
console.log(`[FileWatcher] Initial file stats: mtime=${lastModified}, size=${lastSize}`);
|
||||
} catch (err) {
|
||||
console.error(`[FileWatcher] Failed to stat file ${localPath}:`, err.message);
|
||||
throw new Error(`Cannot watch file: ${err.message}`);
|
||||
}
|
||||
|
||||
// Store webContents reference for later notifications
|
||||
const webContents = event.sender;
|
||||
|
||||
// Use fs.watchFile (polling) instead of fs.watch for better reliability on Windows
|
||||
// fs.watch can miss events when editors use atomic writes (save to temp, then rename)
|
||||
// fs.watchFile polls the file system at regular intervals
|
||||
const pollInterval = 1000; // Check every 1 second
|
||||
|
||||
fs.watchFile(localPath, { persistent: true, interval: pollInterval }, async (curr, prev) => {
|
||||
console.log(`[FileWatcher] File stat change detected for ${localPath}`);
|
||||
console.log(`[FileWatcher] Previous: mtime=${prev.mtimeMs}, size=${prev.size}`);
|
||||
console.log(`[FileWatcher] Current: mtime=${curr.mtimeMs}, size=${curr.size}`);
|
||||
|
||||
// Check if file was deleted
|
||||
if (curr.nlink === 0) {
|
||||
console.log(`[FileWatcher] File ${localPath} was deleted, stopping watch`);
|
||||
stopWatching(null, { watchId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if file was actually modified
|
||||
if (curr.mtimeMs <= prev.mtimeMs && curr.size === prev.size) {
|
||||
console.log(`[FileWatcher] File unchanged, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce rapid changes (e.g., multiple saves in quick succession)
|
||||
const existingTimer = debounceTimers.get(watchId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
debounceTimers.delete(watchId);
|
||||
await handleFileChange(watchId, webContents);
|
||||
}, 500); // 500ms debounce
|
||||
|
||||
debounceTimers.set(watchId, timer);
|
||||
});
|
||||
|
||||
activeWatchers.set(watchId, {
|
||||
watcher: null, // fs.watchFile doesn't return a watcher object
|
||||
localPath,
|
||||
remotePath,
|
||||
sftpId,
|
||||
lastModified,
|
||||
lastSize,
|
||||
webContents,
|
||||
useWatchFile: true, // Flag to indicate we're using fs.watchFile
|
||||
});
|
||||
|
||||
console.log(`[FileWatcher] Watch started with ID: ${watchId} (using fs.watchFile polling every ${pollInterval}ms)`);
|
||||
return { watchId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file change event - sync to remote
|
||||
*/
|
||||
async function handleFileChange(watchId, webContents) {
|
||||
const watchInfo = activeWatchers.get(watchId);
|
||||
if (!watchInfo) return;
|
||||
|
||||
const { localPath, remotePath, sftpId, lastModified: previousModified, lastSize: previousSize } = watchInfo;
|
||||
|
||||
// Extract file name once for notifications and logging
|
||||
const fileName = path.basename(remotePath);
|
||||
|
||||
console.log(`[FileWatcher] File change detected: ${localPath}`);
|
||||
|
||||
try {
|
||||
// Check if file was actually modified (compare mtime and size)
|
||||
const stat = await fs.promises.stat(localPath);
|
||||
|
||||
// Skip if neither mtime nor size changed (prevents spurious events on some platforms)
|
||||
if (stat.mtimeMs <= previousModified && stat.size === previousSize) {
|
||||
console.log(`[FileWatcher] File unchanged (mtime and size same), skipping sync`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update lastModified and lastSize
|
||||
watchInfo.lastModified = stat.mtimeMs;
|
||||
watchInfo.lastSize = stat.size;
|
||||
|
||||
// Get the SFTP client
|
||||
if (!sftpClients) {
|
||||
throw new Error("SFTP clients not initialized");
|
||||
}
|
||||
|
||||
const client = sftpClients.get(sftpId);
|
||||
if (!client) {
|
||||
throw new Error("SFTP session not found or expired");
|
||||
}
|
||||
|
||||
// Read the local file
|
||||
const content = await fs.promises.readFile(localPath);
|
||||
|
||||
console.log(`[FileWatcher] Syncing ${content.length} bytes to ${remotePath}`);
|
||||
|
||||
// Upload to remote
|
||||
await client.put(content, remotePath);
|
||||
|
||||
console.log(`[FileWatcher] Sync complete: ${remotePath}`);
|
||||
|
||||
// Show system notification for successful sync
|
||||
showSystemNotification(
|
||||
"Netcatty",
|
||||
`File synced to remote: ${fileName}`
|
||||
);
|
||||
|
||||
// Notify the renderer about successful sync
|
||||
if (webContents && !webContents.isDestroyed()) {
|
||||
webContents.send("netcatty:filewatch:synced", {
|
||||
watchId,
|
||||
localPath,
|
||||
remotePath,
|
||||
bytesWritten: content.length,
|
||||
});
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[FileWatcher] Sync failed for ${localPath}:`, err.message);
|
||||
|
||||
// Show system notification for sync failure
|
||||
showSystemNotification(
|
||||
"Netcatty",
|
||||
`Failed to sync ${fileName}: ${err.message}`
|
||||
);
|
||||
|
||||
// Notify the renderer about sync failure
|
||||
if (webContents && !webContents.isDestroyed()) {
|
||||
webContents.send("netcatty:filewatch:error", {
|
||||
watchId,
|
||||
localPath,
|
||||
remotePath,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching a file and optionally clean up the temp file
|
||||
*/
|
||||
function stopWatching(event, { watchId, cleanupTempFile = false }) {
|
||||
const watchInfo = activeWatchers.get(watchId);
|
||||
if (!watchInfo) {
|
||||
console.log(`[FileWatcher] Watch ID not found: ${watchId}`);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
console.log(`[FileWatcher] Stopping watch: ${watchInfo.localPath}`);
|
||||
|
||||
// Clear debounce timer if any
|
||||
const timer = debounceTimers.get(watchId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
debounceTimers.delete(watchId);
|
||||
}
|
||||
|
||||
// Stop the watcher
|
||||
try {
|
||||
if (watchInfo.useWatchFile) {
|
||||
// Using fs.watchFile - need to use fs.unwatchFile
|
||||
fs.unwatchFile(watchInfo.localPath);
|
||||
} else if (watchInfo.watcher) {
|
||||
// Using fs.watch - close the watcher
|
||||
watchInfo.watcher.close();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[FileWatcher] Error stopping watcher:`, err.message);
|
||||
}
|
||||
|
||||
// Clean up temp file if requested
|
||||
if (cleanupTempFile && watchInfo.localPath) {
|
||||
cleanupTempFileAsync(watchInfo.localPath);
|
||||
}
|
||||
|
||||
activeWatchers.delete(watchId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously delete a temp file, logging success and silently handling failures
|
||||
*/
|
||||
async function cleanupTempFileAsync(filePath) {
|
||||
try {
|
||||
await fs.promises.unlink(filePath);
|
||||
console.log(`[FileWatcher] Temp file cleaned up: ${filePath}`);
|
||||
} catch (err) {
|
||||
// Silently ignore deletion failures (file may be in use or already deleted)
|
||||
console.log(`[FileWatcher] Could not delete temp file (may be in use): ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all watchers for a specific SFTP session and clean up temp files
|
||||
* Called when SFTP connection is closed
|
||||
*/
|
||||
function stopWatchersForSession(sftpId, cleanupTempFiles = true) {
|
||||
let watcherCount = 0;
|
||||
|
||||
// Stop active watchers
|
||||
for (const [watchId, watchInfo] of activeWatchers.entries()) {
|
||||
if (watchInfo.sftpId === sftpId) {
|
||||
stopWatching(null, { watchId, cleanupTempFile: cleanupTempFiles });
|
||||
watcherCount++;
|
||||
}
|
||||
}
|
||||
if (watcherCount > 0) {
|
||||
console.log(`[FileWatcher] Stopped ${watcherCount} watcher(s) for SFTP session: ${sftpId}`);
|
||||
}
|
||||
|
||||
// Clean up any registered temp files that weren't being watched
|
||||
if (cleanupTempFiles && tempFilesMap.has(sftpId)) {
|
||||
const tempFiles = tempFilesMap.get(sftpId);
|
||||
let cleanedCount = 0;
|
||||
for (const filePath of tempFiles) {
|
||||
cleanupTempFileAsync(filePath);
|
||||
cleanedCount++;
|
||||
}
|
||||
tempFilesMap.delete(sftpId);
|
||||
if (cleanedCount > 0) {
|
||||
console.log(`[FileWatcher] Queued cleanup for ${cleanedCount} temp file(s) for SFTP session: ${sftpId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of active watchers
|
||||
*/
|
||||
function listWatchers() {
|
||||
const watchers = [];
|
||||
for (const [watchId, info] of activeWatchers.entries()) {
|
||||
watchers.push({
|
||||
watchId,
|
||||
localPath: info.localPath,
|
||||
remotePath: info.remotePath,
|
||||
sftpId: info.sftpId,
|
||||
});
|
||||
}
|
||||
return watchers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for file watching operations
|
||||
*/
|
||||
function registerHandlers(ipcMain) {
|
||||
console.log("[FileWatcher] Registering IPC handlers");
|
||||
ipcMain.handle("netcatty:filewatch:start", (event, args) => {
|
||||
console.log("[FileWatcher] IPC netcatty:filewatch:start received", args);
|
||||
return startWatching(event, args);
|
||||
});
|
||||
ipcMain.handle("netcatty:filewatch:stop", stopWatching);
|
||||
ipcMain.handle("netcatty:filewatch:list", listWatchers);
|
||||
ipcMain.handle("netcatty:filewatch:registerTempFile", (_event, { sftpId, localPath }) => {
|
||||
registerTempFile(sftpId, localPath);
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all watchers on shutdown
|
||||
*/
|
||||
function cleanup() {
|
||||
console.log(`[FileWatcher] Cleaning up ${activeWatchers.size} watcher(s)`);
|
||||
for (const [watchId] of activeWatchers.entries()) {
|
||||
stopWatching(null, { watchId });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
startWatching,
|
||||
stopWatching,
|
||||
stopWatchersForSession,
|
||||
listWatchers,
|
||||
registerTempFile,
|
||||
cleanup,
|
||||
};
|
||||
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,
|
||||
};
|
||||
@@ -6,14 +6,35 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { execSync } = require("node:child_process");
|
||||
|
||||
/**
|
||||
* Check if a file is hidden on Windows using the attrib command
|
||||
* Returns true if the file has the hidden attribute set
|
||||
*/
|
||||
function isWindowsHiddenFile(filePath) {
|
||||
if (process.platform !== "win32") return false;
|
||||
try {
|
||||
const output = execSync(`attrib "${filePath}"`, { encoding: "utf8" });
|
||||
// attrib output format: " H R filename" where H = hidden, R = read-only, etc.
|
||||
// The attributes appear in the first ~10 characters before the path
|
||||
const attrPart = output.substring(0, output.indexOf(filePath)).toUpperCase();
|
||||
return attrPart.includes("H");
|
||||
} catch (err) {
|
||||
console.warn(`Could not check hidden attribute for ${filePath}:`, err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a local directory
|
||||
* Properly handles symlinks by resolving their target type
|
||||
* On Windows, also detects hidden files using the hidden attribute
|
||||
*/
|
||||
async function listLocalDir(event, payload) {
|
||||
const dirPath = payload.path;
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
// Stat entries in parallel with a small concurrency limit.
|
||||
// Serial stats can be very slow on Windows for large dirs.
|
||||
@@ -45,12 +66,16 @@ async function listLocalDir(event, payload) {
|
||||
type = "file";
|
||||
}
|
||||
|
||||
// Check for Windows hidden attribute
|
||||
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
|
||||
|
||||
result[i] = {
|
||||
name: entry.name,
|
||||
type,
|
||||
linkTarget,
|
||||
size: `${stat.size} bytes`,
|
||||
lastModified: stat.mtime.toISOString(),
|
||||
hidden,
|
||||
};
|
||||
} catch (err) {
|
||||
// Handle broken symlinks - lstat doesn't follow symlinks
|
||||
@@ -61,12 +86,14 @@ async function listLocalDir(event, payload) {
|
||||
const lstat = await fs.promises.lstat(fullPath);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
// Broken symlink
|
||||
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
|
||||
result[i] = {
|
||||
name: brokenEntry.name,
|
||||
type: "symlink",
|
||||
linkTarget: null, // Broken link - target unknown
|
||||
size: `${lstat.size} bytes`,
|
||||
lastModified: lstat.mtime.toISOString(),
|
||||
hidden,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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,7 +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;
|
||||
@@ -18,6 +29,21 @@ let electronModule = null;
|
||||
// Storage for jump host connections that need to be cleaned up
|
||||
const jumpConnectionsMap = new Map(); // connId -> { connections: SSHClient[], socket: stream }
|
||||
|
||||
// Storage for active SFTP uploads that can be cancelled
|
||||
const activeSftpUploads = new Map(); // transferId -> { cancelled: boolean, stream: Readable }
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
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
|
||||
*/
|
||||
@@ -26,130 +52,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++) {
|
||||
@@ -157,14 +66,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,
|
||||
@@ -173,13 +82,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;
|
||||
@@ -209,7 +120,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);
|
||||
@@ -222,7 +133,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', () => {
|
||||
@@ -239,9 +150,9 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
});
|
||||
conn.connect(connOpts);
|
||||
});
|
||||
|
||||
|
||||
connections.push(conn);
|
||||
|
||||
|
||||
// Determine next target
|
||||
let nextHost, nextPort;
|
||||
if (isLast) {
|
||||
@@ -254,7 +165,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) => {
|
||||
@@ -269,10 +180,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) {
|
||||
@@ -284,6 +195,232 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish an SFTP connection using sudo
|
||||
* @param {SSHClient} client - Connected SSH client
|
||||
* @param {string} password - User password for sudo
|
||||
*/
|
||||
async function connectSudoSftp(client, password) {
|
||||
if (!SFTPWrapper) {
|
||||
throw new Error("SFTP sudo mode is not available on this platform. Please disable sudo mode in host settings.");
|
||||
}
|
||||
|
||||
// Known sftp-server paths to try
|
||||
const sftpPaths = [
|
||||
"/usr/lib/openssh/sftp-server",
|
||||
"/usr/libexec/openssh/sftp-server",
|
||||
"/usr/lib/ssh/sftp-server",
|
||||
"/usr/libexec/sftp-server",
|
||||
"/usr/local/libexec/sftp-server",
|
||||
"/usr/local/lib/sftp-server"
|
||||
];
|
||||
|
||||
console.log("[SFTP] Probing sftp-server path for sudo mode...");
|
||||
|
||||
let serverPath = null;
|
||||
// Try to find the path
|
||||
for (const p of sftpPaths) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
client.exec(`test -x ${p}`, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
stream.on('exit', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error('Not found'));
|
||||
});
|
||||
});
|
||||
});
|
||||
serverPath = p;
|
||||
break;
|
||||
} catch (e) {
|
||||
// Continue probing
|
||||
}
|
||||
}
|
||||
|
||||
if (!serverPath) {
|
||||
// Fallback: try to find it in path or assume standard location
|
||||
console.warn("[SFTP] Could not probe sftp-server, trying default /usr/lib/openssh/sftp-server");
|
||||
serverPath = "/usr/lib/openssh/sftp-server";
|
||||
} else {
|
||||
console.log(`[SFTP] Found sftp-server at ${serverPath}`);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Use sudo -S to read password from stdin
|
||||
// Use -p '' to set a specific prompt we can detect
|
||||
// Use sh -c 'printf SFTPREADY; exec ...' to synchronize the start of sftp-server
|
||||
// We use printf instead of echo to avoid trailing newline which could confuse SFTPWrapper
|
||||
const prompt = "SUDOPASSWORD:";
|
||||
const readyMarker = "SFTPREADY";
|
||||
const readyMarkerBuffer = Buffer.from(readyMarker);
|
||||
// Add -e to sftp-server to log to stderr for debugging
|
||||
const cmd = `sudo -S -p '${prompt}' sh -c 'printf ${readyMarker}; exec ${serverPath} -e'`;
|
||||
|
||||
console.log(`[SFTP] Executing sudo command: ${cmd}`);
|
||||
|
||||
// Disable pty to ensure clean binary stream for SFTP
|
||||
client.exec(cmd, { pty: false }, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
// Add stream lifecycle logging
|
||||
stream.on('close', () => console.log("[SFTP] Stream closed"));
|
||||
stream.on('end', () => console.log("[SFTP] Stream ended"));
|
||||
stream.on('error', (e) => console.error("[SFTP] Stream error:", e.message));
|
||||
|
||||
let sftpInitialized = false;
|
||||
let sftp = null;
|
||||
let settled = false;
|
||||
let stdoutBuffer = Buffer.alloc(0);
|
||||
let stderrBuffer = "";
|
||||
let pendingAfterMarker = null;
|
||||
let sftpCreated = false;
|
||||
const timeoutMs = 20000;
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (sftpInitialized || settled) return;
|
||||
settled = true;
|
||||
stream.stderr?.removeListener('data', onStderr);
|
||||
stream.removeListener('data', onStdout);
|
||||
const error = new Error("SFTP sudo handshake timed out. This may happen if: (1) the password is incorrect, (2) sudo requires a TTY, or (3) the user does not have sudo privileges.");
|
||||
reject(error);
|
||||
}, timeoutMs);
|
||||
|
||||
const finalize = (err, result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
stream.stderr?.removeListener('data', onStderr);
|
||||
stream.removeListener('data', onStdout);
|
||||
if (err) reject(err);
|
||||
else resolve(result);
|
||||
};
|
||||
|
||||
const createSftp = () => {
|
||||
if (sftpCreated) return;
|
||||
sftpCreated = true;
|
||||
try {
|
||||
const chanInfo = {
|
||||
type: 'sftp',
|
||||
incoming: stream.incoming,
|
||||
outgoing: stream.outgoing
|
||||
};
|
||||
sftp = new SFTPWrapper(client, chanInfo, {
|
||||
// debug: (str) => console.log(`[SFTP DEBUG] ${str}`)
|
||||
});
|
||||
|
||||
// Route any remaining channel data directly into the SFTP parser
|
||||
if (client._chanMgr && typeof stream.incoming?.id === "number") {
|
||||
client._chanMgr.update(stream.incoming.id, sftp);
|
||||
}
|
||||
|
||||
sftp.on('ready', () => {
|
||||
sftpInitialized = true;
|
||||
console.log("[SFTP] Protocol ready");
|
||||
finalize(null, sftp);
|
||||
});
|
||||
|
||||
sftp.on('error', (err) => {
|
||||
console.error("[SFTP] Protocol error:", err.message);
|
||||
if (!sftpInitialized) {
|
||||
finalize(err);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
try { sftp.push(null); } catch { }
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[SFTP] Initialization failed:", e.message);
|
||||
finalize(e);
|
||||
}
|
||||
};
|
||||
|
||||
const initSftp = () => {
|
||||
if (sftpInitialized) return;
|
||||
console.log("[SFTP] Sudo success, initializing SFTP protocol...");
|
||||
if (!sftpCreated) createSftp();
|
||||
try {
|
||||
// Start the handshake
|
||||
console.log("[SFTP] Sending INIT packet...");
|
||||
sftp._init();
|
||||
if (pendingAfterMarker && pendingAfterMarker.length > 0) {
|
||||
try {
|
||||
sftp.push(pendingAfterMarker);
|
||||
} catch (pushErr) {
|
||||
console.warn("[SFTP] Failed to push buffered data:", pushErr.message);
|
||||
}
|
||||
pendingAfterMarker = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[SFTP] Initialization failed:", e.message);
|
||||
finalize(e);
|
||||
}
|
||||
};
|
||||
|
||||
const onStdout = (data) => {
|
||||
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
stdoutBuffer = stdoutBuffer.length > 0 ? Buffer.concat([stdoutBuffer, chunk]) : chunk;
|
||||
const markerIndex = stdoutBuffer.indexOf(readyMarkerBuffer);
|
||||
if (markerIndex !== -1) {
|
||||
const afterMarkerIndex = markerIndex + readyMarkerBuffer.length;
|
||||
if (afterMarkerIndex < stdoutBuffer.length) {
|
||||
pendingAfterMarker = stdoutBuffer.subarray(afterMarkerIndex);
|
||||
}
|
||||
// Found marker, stop listening to stdout here so SFTPWrapper can take over
|
||||
stream.removeListener('data', onStdout);
|
||||
stdoutBuffer = Buffer.alloc(0);
|
||||
|
||||
console.log("[SFTP] SFTPREADY detected, waiting for stream to stabilize...");
|
||||
|
||||
// Delay SFTP initialization to ensure sftp-server is fully started and stream is clean
|
||||
// Increased timeout to 1000ms to be safe
|
||||
setTimeout(() => {
|
||||
initSftp();
|
||||
}, 1000);
|
||||
} else if (stdoutBuffer.length > 256) {
|
||||
stdoutBuffer = stdoutBuffer.subarray(stdoutBuffer.length - 256);
|
||||
}
|
||||
};
|
||||
|
||||
const onStderr = (data) => {
|
||||
const chunk = data.toString();
|
||||
// Only log that we received stderr data, not the content (may contain sensitive prompts)
|
||||
stderrBuffer += chunk;
|
||||
if (stderrBuffer.includes(prompt)) {
|
||||
console.log("[SFTP] Sudo requested password, sending...");
|
||||
// Send password
|
||||
if (password) {
|
||||
stream.write(password + '\n');
|
||||
} else {
|
||||
console.warn('[SFTP] sudo requested password but none provided');
|
||||
stream.write('\n');
|
||||
}
|
||||
stderrBuffer = "";
|
||||
} else if (stderrBuffer.length > 256) {
|
||||
stderrBuffer = stderrBuffer.slice(-256);
|
||||
}
|
||||
};
|
||||
|
||||
stream.on('data', onStdout);
|
||||
stream.stderr.on('data', onStderr);
|
||||
|
||||
// Error handling
|
||||
stream.on('exit', (code) => {
|
||||
console.log(`[SFTP] Stream exited with code ${code}`);
|
||||
if (!sftpInitialized && code !== 0) {
|
||||
let errorMsg = `SFTP sudo failed with exit code ${code}.`;
|
||||
if (code === 1) {
|
||||
errorMsg += " The password may be incorrect or sudo privileges are denied.";
|
||||
} else if (code === 127) {
|
||||
errorMsg += " sftp-server was not found on the remote system.";
|
||||
}
|
||||
const error = new Error(errorMsg);
|
||||
finalize(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a new SFTP connection
|
||||
* Supports jump host connections when options.jumpHosts is provided
|
||||
@@ -291,15 +428,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}`);
|
||||
@@ -320,13 +457,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;
|
||||
@@ -334,7 +474,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;
|
||||
@@ -361,20 +501,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, {
|
||||
@@ -382,7 +627,7 @@ async function openSftp(event, options) {
|
||||
socket: connectionSocket
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
console.log(`[SFTP] Connection established: ${connId}`);
|
||||
return { sftpId: connId };
|
||||
} catch (err) {
|
||||
@@ -401,15 +646,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") {
|
||||
@@ -432,7 +677,7 @@ async function listSftp(event, payload) {
|
||||
} else {
|
||||
type = "file";
|
||||
}
|
||||
|
||||
|
||||
// Extract permissions from longname or rights
|
||||
let permissions = undefined;
|
||||
if (item.rights) {
|
||||
@@ -445,7 +690,7 @@ async function listSftp(event, payload) {
|
||||
permissions = match[1];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
name: item.name,
|
||||
type,
|
||||
@@ -455,7 +700,7 @@ async function listSftp(event, payload) {
|
||||
permissions,
|
||||
};
|
||||
}));
|
||||
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -465,45 +710,77 @@ async function listSftp(event, payload) {
|
||||
async function readSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
|
||||
const buffer = await client.get(payload.path);
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file as binary (returns ArrayBuffer for binary files like images)
|
||||
*/
|
||||
async function readSftpBinary(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
const buffer = await client.get(payload.path);
|
||||
// Convert Node.js Buffer to ArrayBuffer
|
||||
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write file content
|
||||
*/
|
||||
async function writeSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
|
||||
await client.put(Buffer.from(payload.content, "utf-8"), payload.path);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write binary data with progress callback
|
||||
* Supports cancellation via activeSftpUploads map
|
||||
* Optimized for performance with throttled progress updates
|
||||
*/
|
||||
async function writeSftpBinaryWithProgress(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
|
||||
const { sftpId, path: remotePath, content, transferId } = payload;
|
||||
const buffer = Buffer.from(content);
|
||||
|
||||
// Optimize: Use Buffer.isBuffer to avoid unnecessary copy if already a Buffer
|
||||
// For ArrayBuffer from renderer, we still need to convert but use a more efficient method
|
||||
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
||||
const totalBytes = buffer.length;
|
||||
let transferredBytes = 0;
|
||||
let lastProgressTime = Date.now();
|
||||
let lastTransferredBytes = 0;
|
||||
|
||||
let lastProgressSentTime = 0;
|
||||
|
||||
// Throttle settings: send progress at most every 100ms or every 1MB
|
||||
const PROGRESS_THROTTLE_MS = 100;
|
||||
const PROGRESS_THROTTLE_BYTES = 1024 * 1024; // 1MB
|
||||
let lastProgressSentBytes = 0;
|
||||
|
||||
const { Readable } = require("stream");
|
||||
const readableStream = new Readable({
|
||||
read() {
|
||||
const chunkSize = 65536;
|
||||
// Check for cancellation
|
||||
const uploadState = activeSftpUploads.get(transferId);
|
||||
if (uploadState?.cancelled) {
|
||||
this.destroy(new Error("Upload cancelled"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Use larger chunk size for better performance (256KB instead of 64KB)
|
||||
const chunkSize = 262144;
|
||||
if (transferredBytes < totalBytes) {
|
||||
const end = Math.min(transferredBytes + chunkSize, totalBytes);
|
||||
const chunk = buffer.slice(transferredBytes, end);
|
||||
// Use subarray instead of slice to avoid copying
|
||||
const chunk = buffer.subarray(transferredBytes, end);
|
||||
transferredBytes = end;
|
||||
|
||||
|
||||
const now = Date.now();
|
||||
const elapsed = (now - lastProgressTime) / 1000;
|
||||
let speed = 0;
|
||||
@@ -512,51 +789,101 @@ async function writeSftpBinaryWithProgress(event, payload) {
|
||||
lastProgressTime = now;
|
||||
lastTransferredBytes = transferredBytes;
|
||||
}
|
||||
|
||||
const contents = electronModule.webContents.fromId(event.sender.id);
|
||||
contents?.send("netcatty:upload:progress", {
|
||||
transferId,
|
||||
transferred: transferredBytes,
|
||||
totalBytes,
|
||||
speed,
|
||||
});
|
||||
|
||||
|
||||
// Throttle IPC progress events: only send if enough time or bytes have passed
|
||||
const timeSinceLastProgress = now - lastProgressSentTime;
|
||||
const bytesSinceLastProgress = transferredBytes - lastProgressSentBytes;
|
||||
const isComplete = transferredBytes >= totalBytes;
|
||||
|
||||
if (isComplete || timeSinceLastProgress >= PROGRESS_THROTTLE_MS || bytesSinceLastProgress >= PROGRESS_THROTTLE_BYTES) {
|
||||
const contents = electronModule.webContents.fromId(event.sender.id);
|
||||
contents?.send("netcatty:upload:progress", {
|
||||
transferId,
|
||||
transferred: transferredBytes,
|
||||
totalBytes,
|
||||
speed,
|
||||
});
|
||||
lastProgressSentTime = now;
|
||||
lastProgressSentBytes = transferredBytes;
|
||||
}
|
||||
|
||||
this.push(chunk);
|
||||
} else {
|
||||
this.push(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Register this upload for potential cancellation
|
||||
activeSftpUploads.set(transferId, { cancelled: false, stream: readableStream });
|
||||
|
||||
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);
|
||||
contents?.send("netcatty:upload:error", { transferId, error: err.message });
|
||||
throw err;
|
||||
// Only send error if it's not a cancellation
|
||||
if (err.message !== "Upload cancelled") {
|
||||
contents?.send("netcatty:upload:error", { transferId, error: err.message });
|
||||
throw err;
|
||||
}
|
||||
contents?.send("netcatty:upload:cancelled", { transferId });
|
||||
return { success: false, transferId, cancelled: true };
|
||||
} finally {
|
||||
// Cleanup
|
||||
activeSftpUploads.delete(transferId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an in-progress SFTP upload
|
||||
* Note: We only set the cancelled flag and destroy the stream here.
|
||||
* The cleanup (deleting from activeSftpUploads) is handled by writeSftpBinaryWithProgress's finally block
|
||||
* to avoid race conditions.
|
||||
*/
|
||||
async function cancelSftpUpload(event, payload) {
|
||||
const { transferId } = payload;
|
||||
const uploadState = activeSftpUploads.get(transferId);
|
||||
if (uploadState) {
|
||||
uploadState.cancelled = true;
|
||||
try {
|
||||
uploadState.stream?.destroy();
|
||||
} catch (err) {
|
||||
// Log but continue - stream may already be destroyed
|
||||
console.warn("[SFTP] Error destroying upload stream:", err.message);
|
||||
}
|
||||
// Don't delete here - let the finally block in writeSftpBinaryWithProgress handle cleanup
|
||||
// This avoids race conditions where the upload might still be in progress
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Close an SFTP connection
|
||||
* Also cleans up any jump host connections if present
|
||||
* Also cleans up any jump host connections and file watchers if present
|
||||
*/
|
||||
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) {
|
||||
@@ -574,7 +901,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;
|
||||
}
|
||||
@@ -585,7 +912,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);
|
||||
@@ -601,7 +928,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;
|
||||
}
|
||||
@@ -612,7 +939,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),
|
||||
@@ -629,7 +956,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;
|
||||
}
|
||||
@@ -641,8 +968,10 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:sftp:open", openSftp);
|
||||
ipcMain.handle("netcatty:sftp:list", listSftp);
|
||||
ipcMain.handle("netcatty:sftp:read", readSftp);
|
||||
ipcMain.handle("netcatty:sftp:readBinary", readSftpBinary);
|
||||
ipcMain.handle("netcatty:sftp:write", writeSftp);
|
||||
ipcMain.handle("netcatty:sftp:writeBinaryWithProgress", writeSftpBinaryWithProgress);
|
||||
ipcMain.handle("netcatty:sftp:cancelUpload", cancelSftpUpload);
|
||||
ipcMain.handle("netcatty:sftp:close", closeSftp);
|
||||
ipcMain.handle("netcatty:sftp:mkdir", mkdirSftp);
|
||||
ipcMain.handle("netcatty:sftp:delete", deleteSftp);
|
||||
@@ -665,8 +994,10 @@ module.exports = {
|
||||
openSftp,
|
||||
listSftp,
|
||||
readSftp,
|
||||
readSftpBinary,
|
||||
writeSftp,
|
||||
writeSftpBinaryWithProgress,
|
||||
cancelSftpUpload,
|
||||
closeSftp,
|
||||
mkdirSftp,
|
||||
deleteSftp,
|
||||
|
||||
@@ -6,14 +6,124 @@
|
||||
const net = require("node:net");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { exec } = require("node:child_process");
|
||||
const { Client: SSHClient, utils: sshUtils } = require("ssh2");
|
||||
const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
|
||||
/**
|
||||
* Check if an SSH private key is encrypted (requires passphrase)
|
||||
* @param {string} keyContent - The content of the private key file
|
||||
* @returns {boolean} - True if the key is encrypted
|
||||
*/
|
||||
function isKeyEncrypted(keyContent) {
|
||||
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
|
||||
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for legacy PEM format encryption (e.g., RSA PRIVATE KEY with encryption)
|
||||
if (keyContent.includes("Proc-Type:") && keyContent.includes("ENCRYPTED")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for OpenSSH format keys
|
||||
if (keyContent.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
|
||||
try {
|
||||
// Extract the base64 content between the markers
|
||||
const base64Match = keyContent.match(
|
||||
/-----BEGIN OPENSSH PRIVATE KEY-----\s*([\s\S]*?)\s*-----END OPENSSH PRIVATE KEY-----/
|
||||
);
|
||||
if (base64Match) {
|
||||
const base64Content = base64Match[1].replace(/\s/g, "");
|
||||
const keyBuffer = Buffer.from(base64Content, "base64");
|
||||
|
||||
// OpenSSH key format: "openssh-key-v1\0" followed by cipher name
|
||||
// If ciphername is "none", the key is not encrypted
|
||||
const authMagic = "openssh-key-v1\0";
|
||||
if (keyBuffer.toString("ascii", 0, authMagic.length) === authMagic) {
|
||||
// After magic, read ciphername (length-prefixed string)
|
||||
let offset = authMagic.length;
|
||||
const cipherNameLen = keyBuffer.readUInt32BE(offset);
|
||||
offset += 4;
|
||||
const cipherName = keyBuffer.toString("ascii", offset, offset + cipherNameLen);
|
||||
return cipherName !== "none";
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, assume it might be encrypted to be safe
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find default SSH private key from user's ~/.ssh directory
|
||||
* Skips encrypted keys that require a passphrase to allow password/keyboard-interactive auth
|
||||
* @returns {{ privateKey: string, keyPath: string, keyName: string } | null}
|
||||
*/
|
||||
function findDefaultPrivateKey() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
if (fs.existsSync(keyPath)) {
|
||||
try {
|
||||
const privateKey = fs.readFileSync(keyPath, "utf8");
|
||||
// Skip encrypted keys - they require a passphrase and would abort
|
||||
// authentication before password/keyboard-interactive can be tried
|
||||
if (isKeyEncrypted(privateKey)) {
|
||||
log("Skipping encrypted default key", { keyPath, keyName: name });
|
||||
continue;
|
||||
}
|
||||
log("Found default key", { keyPath, keyName: name });
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} catch (e) {
|
||||
log("Failed to read default key", { keyPath, error: e.message });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Windows SSH Agent service is running
|
||||
* @returns {Promise<{ running: boolean, startupType: string | null, error: string | null }>}
|
||||
*/
|
||||
function checkWindowsSshAgent() {
|
||||
return new Promise((resolve) => {
|
||||
if (process.platform !== "win32") {
|
||||
resolve({ running: true, startupType: null, error: null });
|
||||
return;
|
||||
}
|
||||
exec("sc query ssh-agent", (err, stdout) => {
|
||||
if (err) {
|
||||
resolve({ running: false, startupType: null, error: "SSH Agent service not found" });
|
||||
return;
|
||||
}
|
||||
const running = stdout.includes("RUNNING");
|
||||
const stopped = stdout.includes("STOPPED");
|
||||
resolve({
|
||||
running,
|
||||
startupType: stopped ? "stopped" : (running ? "running" : "unknown"),
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Simple file logger for debugging
|
||||
const logFile = path.join(require("os").tmpdir(), "netcatty-ssh.log");
|
||||
const log = (msg, data) => {
|
||||
const line = `[${new Date().toISOString()}] ${msg} ${data ? JSON.stringify(data) : ""}\n`;
|
||||
try { fs.appendFileSync(logFile, line); } catch {}
|
||||
try { fs.appendFileSync(logFile, line); } catch { }
|
||||
console.log("[SSH]", msg, data || "");
|
||||
};
|
||||
|
||||
@@ -49,122 +159,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
|
||||
*/
|
||||
@@ -172,27 +166,27 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
const sender = event.sender;
|
||||
const connections = [];
|
||||
let currentSocket = null;
|
||||
|
||||
|
||||
const sendProgress = (hop, total, label, status) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send("netcatty:chain:progress", { hop, total, label, status });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
try {
|
||||
const totalHops = jumpHosts.length;
|
||||
|
||||
|
||||
// Connect through each jump host
|
||||
for (let i = 0; i < jumpHosts.length; i++) {
|
||||
const jump = jumpHosts[i];
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === jumpHosts.length - 1;
|
||||
const hopLabel = jump.label || `${jump.hostname}:${jump.port || 22}`;
|
||||
|
||||
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'connecting');
|
||||
|
||||
|
||||
const conn = new SSHClient();
|
||||
|
||||
|
||||
// Build connection options
|
||||
const connOpts = {
|
||||
host: jump.hostname,
|
||||
@@ -203,6 +197,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'],
|
||||
@@ -211,7 +207,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
compress: ['none'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Auth - support agent (certificate), key, and password fallback
|
||||
const hasCertificate =
|
||||
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
|
||||
@@ -241,7 +237,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
if (connOpts.password) order.push("password");
|
||||
connOpts.authHandler = order;
|
||||
}
|
||||
|
||||
|
||||
// If first hop and proxy is configured, connect through proxy
|
||||
if (isFirst && options.proxy) {
|
||||
currentSocket = await createProxySocket(options.proxy, jump.hostname, jump.port || 22);
|
||||
@@ -254,7 +250,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
delete connOpts.host;
|
||||
delete connOpts.port;
|
||||
}
|
||||
|
||||
|
||||
// Connect this hop
|
||||
await new Promise((resolve, reject) => {
|
||||
conn.on('ready', () => {
|
||||
@@ -274,9 +270,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Connecting to ${hopLabel}...`);
|
||||
conn.connect(connOpts);
|
||||
});
|
||||
|
||||
|
||||
connections.push(conn);
|
||||
|
||||
|
||||
// Determine next target
|
||||
let nextHost, nextPort;
|
||||
if (isLast) {
|
||||
@@ -289,7 +285,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
nextHost = nextJump.hostname;
|
||||
nextPort = nextJump.port || 22;
|
||||
}
|
||||
|
||||
|
||||
// Create forward stream to next hop
|
||||
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Forwarding from ${hopLabel} to ${nextHost}:${nextPort}...`);
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'forwarding');
|
||||
@@ -305,17 +301,17 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Return the final forwarded stream and all connections for cleanup
|
||||
return {
|
||||
socket: currentSocket,
|
||||
return {
|
||||
socket: currentSocket,
|
||||
connections,
|
||||
sendProgress
|
||||
sendProgress
|
||||
};
|
||||
} catch (err) {
|
||||
// Cleanup on error
|
||||
for (const conn of connections) {
|
||||
try { conn.end(); } catch {}
|
||||
try { conn.end(); } catch { }
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -332,7 +328,7 @@ async function startSSHSession(event, options) {
|
||||
const cols = options.cols || 80;
|
||||
const rows = options.rows || 24;
|
||||
const sender = event.sender;
|
||||
|
||||
|
||||
const sendProgress = (hop, total, label, status) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send("netcatty:chain:progress", { hop, total, label, status });
|
||||
@@ -343,13 +339,13 @@ async function startSSHSession(event, options) {
|
||||
const conn = new SSHClient();
|
||||
let chainConnections = [];
|
||||
let connectionSocket = null;
|
||||
|
||||
|
||||
// Determine if we have jump hosts
|
||||
const jumpHosts = options.jumpHosts || [];
|
||||
const hasJumpHosts = jumpHosts.length > 0;
|
||||
const hasProxy = !!options.proxy;
|
||||
const totalHops = jumpHosts.length + 1; // +1 for final target
|
||||
|
||||
|
||||
// Build base connection options for final target
|
||||
const connectOpts = {
|
||||
host: options.hostname,
|
||||
@@ -361,6 +357,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'],
|
||||
@@ -382,7 +380,7 @@ async function startSSHSession(event, options) {
|
||||
hasPassword: !!options.password,
|
||||
hasEffectivePassphrase: !!effectivePassphrase,
|
||||
});
|
||||
|
||||
|
||||
log("Auth configuration", {
|
||||
hasCertificate,
|
||||
keySource: options.keySource,
|
||||
@@ -414,6 +412,17 @@ async function startSSHSession(event, options) {
|
||||
connectOpts.password = options.password;
|
||||
}
|
||||
|
||||
// Fallback to default SSH key if no authentication method is configured
|
||||
let usedDefaultKey = null;
|
||||
if (!connectOpts.privateKey && !connectOpts.password && !connectOpts.agent) {
|
||||
const defaultKey = findDefaultPrivateKey();
|
||||
if (defaultKey) {
|
||||
log("Using default SSH key as fallback", { keyPath: defaultKey.keyPath });
|
||||
connectOpts.privateKey = defaultKey.privateKey;
|
||||
usedDefaultKey = defaultKey;
|
||||
}
|
||||
}
|
||||
|
||||
// Agent forwarding
|
||||
if (options.agentForwarding) {
|
||||
connectOpts.agentForward = true;
|
||||
@@ -437,25 +446,25 @@ async function startSSHSession(event, options) {
|
||||
// Handle chain/proxy connections
|
||||
if (hasJumpHosts) {
|
||||
const chainResult = await connectThroughChain(
|
||||
event,
|
||||
options,
|
||||
jumpHosts,
|
||||
options.hostname,
|
||||
event,
|
||||
options,
|
||||
jumpHosts,
|
||||
options.hostname,
|
||||
options.port || 22
|
||||
);
|
||||
connectionSocket = chainResult.socket;
|
||||
chainConnections = chainResult.connections;
|
||||
|
||||
|
||||
connectOpts.sock = connectionSocket;
|
||||
delete connectOpts.host;
|
||||
delete connectOpts.port;
|
||||
|
||||
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'connecting');
|
||||
} else if (hasProxy) {
|
||||
sendProgress(1, 1, options.hostname, 'connecting');
|
||||
connectionSocket = await createProxySocket(
|
||||
options.proxy,
|
||||
options.hostname,
|
||||
options.proxy,
|
||||
options.hostname,
|
||||
options.port || 22
|
||||
);
|
||||
connectOpts.sock = connectionSocket;
|
||||
@@ -470,7 +479,7 @@ async function startSSHSession(event, options) {
|
||||
if (hasJumpHosts || hasProxy) {
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'connected');
|
||||
}
|
||||
|
||||
|
||||
conn.shell(
|
||||
{
|
||||
term: "xterm-256color",
|
||||
@@ -478,7 +487,7 @@ async function startSSHSession(event, options) {
|
||||
rows,
|
||||
},
|
||||
{
|
||||
env: {
|
||||
env: {
|
||||
LANG: resolveLangFromCharset(options.charset),
|
||||
COLORTERM: "truecolor",
|
||||
...(options.env || {}),
|
||||
@@ -488,7 +497,7 @@ async function startSSHSession(event, options) {
|
||||
if (err) {
|
||||
conn.end();
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch {}
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
reject(err);
|
||||
return;
|
||||
@@ -507,7 +516,7 @@ async function startSSHSession(event, options) {
|
||||
let flushTimeout = null;
|
||||
const FLUSH_INTERVAL = 8; // ms - flush every 8ms for ~120fps equivalent
|
||||
const MAX_BUFFER_SIZE = 16384; // 16KB - flush immediately if buffer gets too large
|
||||
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (dataBuffer.length > 0) {
|
||||
const contents = event.sender;
|
||||
@@ -516,7 +525,7 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
flushTimeout = null;
|
||||
};
|
||||
|
||||
|
||||
const bufferData = (data) => {
|
||||
dataBuffer += data;
|
||||
// Immediate flush for large chunks
|
||||
@@ -551,7 +560,7 @@ async function startSSHSession(event, options) {
|
||||
sessions.delete(sessionId);
|
||||
conn.end();
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch {}
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -569,28 +578,28 @@ async function startSSHSession(event, options) {
|
||||
|
||||
conn.on("error", (err) => {
|
||||
const contents = event.sender;
|
||||
|
||||
|
||||
const isAuthError = err.message?.toLowerCase().includes('authentication') ||
|
||||
err.message?.toLowerCase().includes('auth') ||
|
||||
err.message?.toLowerCase().includes('password') ||
|
||||
err.level === 'client-authentication';
|
||||
|
||||
err.message?.toLowerCase().includes('auth') ||
|
||||
err.message?.toLowerCase().includes('password') ||
|
||||
err.level === 'client-authentication';
|
||||
|
||||
// Use log instead of error for auth failures (normal fallback scenario)
|
||||
if (isAuthError) {
|
||||
console.log(`${logPrefix} ${options.hostname} auth failed:`, err.message);
|
||||
safeSend(contents, "netcatty:auth:failed", {
|
||||
sessionId,
|
||||
safeSend(contents, "netcatty:auth:failed", {
|
||||
sessionId,
|
||||
error: err.message,
|
||||
hostname: options.hostname
|
||||
hostname: options.hostname
|
||||
});
|
||||
} else {
|
||||
console.error(`${logPrefix} ${options.hostname} error:`, err.message);
|
||||
}
|
||||
|
||||
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
|
||||
sessions.delete(sessionId);
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch {}
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
@@ -602,7 +611,7 @@ async function startSSHSession(event, options) {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
|
||||
sessions.delete(sessionId);
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch {}
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
@@ -612,10 +621,72 @@ async function startSSHSession(event, options) {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
|
||||
sessions.delete(sessionId);
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch {}
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
});
|
||||
|
||||
// Handle keyboard-interactive authentication (2FA/MFA)
|
||||
conn.on("keyboard-interactive", (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`${logPrefix} ${options.hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })),
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`${logPrefix} No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
@@ -731,11 +802,11 @@ async function execCommand(event, payload) {
|
||||
*/
|
||||
async function generateKeyPair(event, options) {
|
||||
const { type, bits, comment } = options;
|
||||
|
||||
|
||||
try {
|
||||
let keyType;
|
||||
let keyBits = bits;
|
||||
|
||||
|
||||
switch (type) {
|
||||
case 'ED25519':
|
||||
keyType = 'ed25519';
|
||||
@@ -751,15 +822,15 @@ async function generateKeyPair(event, options) {
|
||||
keyBits = bits || 4096;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
const result = sshUtils.generateKeyPairSync(keyType, {
|
||||
bits: keyBits,
|
||||
comment: comment || 'netcatty-generated-key',
|
||||
});
|
||||
|
||||
|
||||
const privateKey = result.private;
|
||||
const publicKey = result.public;
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
privateKey,
|
||||
@@ -783,9 +854,9 @@ async function startSSHSessionWrapper(event, options) {
|
||||
return await startSSHSession(event, options);
|
||||
} catch (err) {
|
||||
const isAuthError = err.message?.toLowerCase().includes('authentication') ||
|
||||
err.message?.toLowerCase().includes('auth') ||
|
||||
err.level === 'client-authentication';
|
||||
|
||||
err.message?.toLowerCase().includes('auth') ||
|
||||
err.level === 'client-authentication';
|
||||
|
||||
if (isAuthError) {
|
||||
// Re-throw with a clean error to avoid Electron printing full stack trace
|
||||
// The frontend will handle this as a normal auth failure for fallback
|
||||
@@ -800,50 +871,74 @@ async function startSSHSessionWrapper(event, options) {
|
||||
|
||||
/**
|
||||
* Get current working directory from an active SSH session
|
||||
* This sends 'pwd' to the shell and captures the output
|
||||
* This sends 'pwd' to the existing shell stream and captures the output
|
||||
* using unique markers to identify the command output boundaries
|
||||
*/
|
||||
async function getSessionPwd(event, payload) {
|
||||
const { sessionId } = payload;
|
||||
const session = sessions.get(sessionId);
|
||||
|
||||
|
||||
if (!session || !session.stream || !session.conn) {
|
||||
return { success: false, error: 'Session not found or not connected' };
|
||||
}
|
||||
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const conn = session.conn;
|
||||
const stream = session.stream;
|
||||
const marker = `__PWD_${Date.now()}__`;
|
||||
const timeout = setTimeout(() => {
|
||||
stream.removeListener('data', onData);
|
||||
resolve({ success: false, error: 'Timeout getting pwd' });
|
||||
}, 3000);
|
||||
|
||||
// Use exec on the existing connection to run pwd
|
||||
conn.exec('pwd', (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
resolve({ success: false, error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
stream.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
clearTimeout(timeout);
|
||||
const cwd = stdout.trim().split(/\r?\n/).pop()?.trim();
|
||||
if (cwd && cwd.startsWith('/')) {
|
||||
resolve({ success: true, cwd });
|
||||
} else {
|
||||
resolve({ success: false, error: 'Invalid pwd output' });
|
||||
|
||||
let buffer = '';
|
||||
|
||||
const onData = (data) => {
|
||||
const str = data.toString();
|
||||
buffer += str;
|
||||
|
||||
// We need to find the ACTUAL output markers, not the command echo
|
||||
// The command echo looks like: echo '__PWD_xxx__S' && pwd && echo '__PWD_xxx__E'
|
||||
// The actual output looks like: __PWD_xxx__S\n/path/to/dir\n__PWD_xxx__E
|
||||
//
|
||||
// We look for the marker at the START of a line (after newline) to avoid the echo
|
||||
const startMarkerRegex = new RegExp(`(?:^|[\\r\\n])${marker}S[\\r\\n]+`);
|
||||
const endMarkerRegex = new RegExp(`[\\r\\n]${marker}E(?:[\\r\\n]|$)`);
|
||||
|
||||
const startMatch = buffer.match(startMarkerRegex);
|
||||
const endMatch = buffer.match(endMarkerRegex);
|
||||
|
||||
if (startMatch && endMatch) {
|
||||
const startIdx = buffer.indexOf(startMatch[0]) + startMatch[0].length;
|
||||
const endIdx = buffer.indexOf(endMatch[0]);
|
||||
|
||||
if (startIdx <= endIdx) {
|
||||
clearTimeout(timeout);
|
||||
stream.removeListener('data', onData);
|
||||
|
||||
const pwdOutput = buffer.slice(startIdx, endIdx).trim();
|
||||
console.log('[getSessionPwd] pwdOutput:', JSON.stringify(pwdOutput));
|
||||
|
||||
// The pwd output should be a valid absolute path
|
||||
if (pwdOutput && pwdOutput.startsWith('/')) {
|
||||
console.log('[getSessionPwd] Success, cwd:', pwdOutput);
|
||||
resolve({ success: true, cwd: pwdOutput });
|
||||
} else {
|
||||
console.log('[getSessionPwd] Failed - invalid path:', pwdOutput);
|
||||
resolve({ success: false, error: 'Invalid pwd output' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ success: false, error: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
stream.on('data', onData);
|
||||
|
||||
// Send pwd command with short unique markers
|
||||
// Using 'S' and 'E' as suffixes to make markers shorter
|
||||
// After the command, send ANSI escape sequences to clear the output lines:
|
||||
// \x1b[1A = move cursor up 1 line, \x1b[2K = clear entire line
|
||||
// Clear 4 lines: the command echo, START marker, pwd output, and END marker
|
||||
const clearLines = '\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K';
|
||||
stream.write(` echo '${marker}S' && pwd && echo '${marker}E' && printf '${clearLines}'\n`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -855,6 +950,22 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:ssh:exec", execCommand);
|
||||
ipcMain.handle("netcatty:ssh:pwd", getSessionPwd);
|
||||
ipcMain.handle("netcatty:key:generate", generateKeyPair);
|
||||
ipcMain.handle("netcatty:ssh:check-agent", async () => {
|
||||
return await checkWindowsSshAgent();
|
||||
});
|
||||
ipcMain.handle("netcatty:ssh:get-default-keys", async () => {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
const keys = [];
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
if (fs.existsSync(keyPath)) {
|
||||
keys.push({ name, path: keyPath });
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
});
|
||||
// Register the shared keyboard-interactive response handler
|
||||
keyboardInteractiveHandler.registerHandler(ipcMain);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -865,4 +976,6 @@ module.exports = {
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
generateKeyPair,
|
||||
checkWindowsSshAgent,
|
||||
findDefaultPrivateKey,
|
||||
};
|
||||
|
||||
183
electron/bridges/tempDirBridge.cjs
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Temp Directory Bridge - Manages Netcatty's dedicated temp directory
|
||||
*
|
||||
* All temporary files (SFTP downloads, etc.) are stored in a dedicated
|
||||
* Netcatty folder within the system temp directory for easier cleanup.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
|
||||
// Netcatty temp directory name
|
||||
const NETCATTY_TEMP_DIR_NAME = "Netcatty";
|
||||
|
||||
// Cached temp directory path
|
||||
let cachedTempDir = null;
|
||||
|
||||
/**
|
||||
* Get the Netcatty temp directory path
|
||||
* Creates the directory if it doesn't exist
|
||||
*/
|
||||
function getTempDir() {
|
||||
if (cachedTempDir) {
|
||||
// Verify it still exists
|
||||
try {
|
||||
if (fs.existsSync(cachedTempDir)) {
|
||||
return cachedTempDir;
|
||||
}
|
||||
} catch {
|
||||
// Directory was deleted, recreate it
|
||||
}
|
||||
}
|
||||
|
||||
const systemTempDir = os.tmpdir();
|
||||
const netcattyTempDir = path.join(systemTempDir, NETCATTY_TEMP_DIR_NAME);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(netcattyTempDir)) {
|
||||
fs.mkdirSync(netcattyTempDir, { recursive: true });
|
||||
console.log(`[TempDir] Created Netcatty temp directory: ${netcattyTempDir}`);
|
||||
}
|
||||
cachedTempDir = netcattyTempDir;
|
||||
return netcattyTempDir;
|
||||
} catch (err) {
|
||||
console.error(`[TempDir] Failed to create temp directory:`, err.message);
|
||||
// Fallback to system temp dir
|
||||
return systemTempDir;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the temp directory exists (call on app startup)
|
||||
*/
|
||||
function ensureTempDir() {
|
||||
const tempDir = getTempDir();
|
||||
console.log(`[TempDir] Netcatty temp directory: ${tempDir}`);
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get temp directory info (path, size, file count)
|
||||
*/
|
||||
async function getTempDirInfo() {
|
||||
const tempDir = getTempDir();
|
||||
|
||||
try {
|
||||
const files = await fs.promises.readdir(tempDir);
|
||||
let totalSize = 0;
|
||||
let fileCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(tempDir, file);
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.isFile()) {
|
||||
totalSize += stat.size;
|
||||
fileCount++;
|
||||
}
|
||||
} catch {
|
||||
// Skip files that can't be stat'd
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path: tempDir,
|
||||
totalSize,
|
||||
fileCount,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`[TempDir] Failed to get temp dir info:`, err.message);
|
||||
return {
|
||||
path: tempDir,
|
||||
totalSize: 0,
|
||||
fileCount: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all files in the temp directory
|
||||
* Returns the number of files deleted
|
||||
*/
|
||||
async function clearTempDir() {
|
||||
const tempDir = getTempDir();
|
||||
let deletedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
try {
|
||||
const files = await fs.promises.readdir(tempDir);
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(tempDir, file);
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
|
||||
if (stat.isFile()) {
|
||||
await fs.promises.unlink(filePath);
|
||||
deletedCount++;
|
||||
console.log(`[TempDir] Deleted: ${file}`);
|
||||
} else if (stat.isDirectory()) {
|
||||
// Recursively delete subdirectories
|
||||
await fs.promises.rm(filePath, { recursive: true, force: true });
|
||||
deletedCount++;
|
||||
console.log(`[TempDir] Deleted directory: ${file}`);
|
||||
}
|
||||
} catch (err) {
|
||||
failedCount++;
|
||||
console.log(`[TempDir] Could not delete ${file}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[TempDir] Cleanup complete: ${deletedCount} deleted, ${failedCount} failed`);
|
||||
return { deletedCount, failedCount };
|
||||
} catch (err) {
|
||||
console.error(`[TempDir] Failed to clear temp dir:`, err.message);
|
||||
return { deletedCount: 0, failedCount: 0, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique temp file path for a given filename
|
||||
*/
|
||||
function getTempFilePath(fileName) {
|
||||
const tempDir = getTempDir();
|
||||
const timestamp = Date.now();
|
||||
const safeFileName = fileName.replace(/[<>:"/\\|?*]/g, "_");
|
||||
return path.join(tempDir, `${timestamp}_${safeFileName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers
|
||||
*/
|
||||
function registerHandlers(ipcMain, shell) {
|
||||
ipcMain.handle("netcatty:tempdir:getInfo", async () => {
|
||||
return getTempDirInfo();
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:tempdir:clear", async () => {
|
||||
return clearTempDir();
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:tempdir:getPath", () => {
|
||||
return getTempDir();
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:tempdir:open", async () => {
|
||||
const tempDir = getTempDir();
|
||||
if (shell?.openPath) {
|
||||
await shell.openPath(tempDir);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getTempDir,
|
||||
ensureTempDir,
|
||||
getTempDirInfo,
|
||||
clearTempDir,
|
||||
getTempFilePath,
|
||||
registerHandlers,
|
||||
};
|
||||
@@ -36,7 +36,7 @@ try {
|
||||
electronModule = require("electron");
|
||||
}
|
||||
|
||||
const { app, BrowserWindow, Menu, protocol } = electronModule || {};
|
||||
const { app, BrowserWindow, Menu, protocol, shell } = electronModule || {};
|
||||
if (!app || !BrowserWindow) {
|
||||
throw new Error("Failed to load Electron runtime. Ensure the app is launched with the Electron binary.");
|
||||
}
|
||||
@@ -76,6 +76,8 @@ const githubAuthBridge = require("./bridges/githubAuthBridge.cjs");
|
||||
const googleAuthBridge = require("./bridges/googleAuthBridge.cjs");
|
||||
const onedriveAuthBridge = require("./bridges/onedriveAuthBridge.cjs");
|
||||
const cloudSyncBridge = require("./bridges/cloudSyncBridge.cjs");
|
||||
const fileWatcherBridge = require("./bridges/fileWatcherBridge.cjs");
|
||||
const tempDirBridge = require("./bridges/tempDirBridge.cjs");
|
||||
const windowManager = require("./bridges/windowManager.cjs");
|
||||
|
||||
// GPU settings
|
||||
@@ -359,6 +361,10 @@ const registerBridges = (win) => {
|
||||
sftpBridge.init(deps);
|
||||
transferBridge.init(deps);
|
||||
terminalBridge.init(deps);
|
||||
fileWatcherBridge.init(deps);
|
||||
|
||||
// Initialize temp directory (synchronously)
|
||||
tempDirBridge.ensureTempDir();
|
||||
|
||||
// Register all IPC handlers
|
||||
sshBridge.registerHandlers(ipcMain);
|
||||
@@ -372,6 +378,8 @@ const registerBridges = (win) => {
|
||||
googleAuthBridge.registerHandlers(ipcMain, electronModule);
|
||||
onedriveAuthBridge.registerHandlers(ipcMain, electronModule);
|
||||
cloudSyncBridge.registerHandlers(ipcMain);
|
||||
fileWatcherBridge.registerHandlers(ipcMain);
|
||||
tempDirBridge.registerHandlers(ipcMain, shell);
|
||||
|
||||
// Settings window handler
|
||||
ipcMain.handle("netcatty:settings:open", async () => {
|
||||
@@ -470,33 +478,94 @@ const registerBridges = (win) => {
|
||||
|
||||
// Open a file with a specific application
|
||||
ipcMain.handle("netcatty:openWithApplication", async (_event, { filePath, appPath }) => {
|
||||
const { shell, spawn } = electronModule;
|
||||
const { spawn: cpSpawn } = require("node:child_process");
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
// On macOS, use 'open' command with -a flag for specific app
|
||||
cpSpawn("open", ["-a", appPath, filePath], { detached: true, stdio: "ignore" }).unref();
|
||||
} else if (process.platform === "win32") {
|
||||
// On Windows, just spawn the exe with the file as argument
|
||||
cpSpawn(appPath, [filePath], { detached: true, stdio: "ignore", shell: true }).unref();
|
||||
} else {
|
||||
// On Linux, spawn the app with the file
|
||||
cpSpawn(appPath, [filePath], { detached: true, stdio: "ignore" }).unref();
|
||||
}
|
||||
console.log(`[Main] Opening file with application:`);
|
||||
console.log(`[Main] File: ${filePath}`);
|
||||
console.log(`[Main] App: ${appPath}`);
|
||||
console.log(`[Main] Platform: ${process.platform}`);
|
||||
|
||||
return true;
|
||||
try {
|
||||
let child;
|
||||
if (process.platform === "darwin") {
|
||||
// On macOS, use 'open' command with -a flag for specific app
|
||||
const args = ["-a", appPath, filePath];
|
||||
console.log(`[Main] Command: open ${args.join(' ')}`);
|
||||
child = cpSpawn("open", args, { detached: true, stdio: "pipe" });
|
||||
} else if (process.platform === "win32") {
|
||||
// On Windows, use cmd /c start to properly handle paths with spaces
|
||||
// The empty string "" as window title is required when the first arg has quotes
|
||||
const args = ["/c", "start", "\"\"", `"${appPath}"`, `"${filePath}"`];
|
||||
console.log(`[Main] Command: cmd ${args.join(' ')}`);
|
||||
child = cpSpawn("cmd", args, { detached: true, stdio: "pipe", windowsVerbatimArguments: true });
|
||||
} else {
|
||||
// On Linux, spawn the app with the file
|
||||
console.log(`[Main] Command: ${appPath} ${filePath}`);
|
||||
child = cpSpawn(appPath, [filePath], { detached: true, stdio: "pipe" });
|
||||
}
|
||||
|
||||
// Log any errors from the child process
|
||||
child.on("error", (err) => {
|
||||
console.error(`[Main] Failed to start application:`, err.message);
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
// On Windows, stderr may be encoded in GBK/CP936, try to decode
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
// Try decoding as GBK (code page 936) for Chinese Windows
|
||||
const { TextDecoder } = require("node:util");
|
||||
const decoder = new TextDecoder("gbk");
|
||||
const decoded = decoder.decode(data);
|
||||
console.log(`[Main] Application stderr: ${decoded}`);
|
||||
} catch {
|
||||
// Fallback to hex dump if decoding fails
|
||||
console.log(`[Main] Application stderr (hex): ${data.toString("hex")}`);
|
||||
}
|
||||
} else {
|
||||
console.error(`[Main] Application stderr:`, data.toString());
|
||||
}
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
// On Windows, many apps (like Notepad++) pass the file to an existing instance
|
||||
// and immediately exit with code 1, this is normal behavior
|
||||
if (code !== 0 && code !== null) {
|
||||
if (process.platform === "win32") {
|
||||
console.log(`[Main] Application exited with code: ${code}, signal: ${signal} (this may be normal for single-instance apps)`);
|
||||
} else {
|
||||
console.warn(`[Main] Application exited with code: ${code}, signal: ${signal}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[Main] Application started successfully`);
|
||||
}
|
||||
});
|
||||
|
||||
child.unref();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[Main] Error opening file with application:`, err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// Download SFTP file to temp and return local path
|
||||
ipcMain.handle("netcatty:sftp:downloadToTemp", async (_event, { sftpId, remotePath, fileName }) => {
|
||||
console.log(`[Main] Downloading SFTP file to temp:`);
|
||||
console.log(`[Main] SFTP ID: ${sftpId}`);
|
||||
console.log(`[Main] Remote path: ${remotePath}`);
|
||||
console.log(`[Main] File name: ${fileName}`);
|
||||
|
||||
const client = require("./bridges/sftpBridge.cjs");
|
||||
const tempDir = os.tmpdir();
|
||||
const tempFileName = `netcatty_${Date.now()}_${fileName}`;
|
||||
const localPath = path.join(tempDir, tempFileName);
|
||||
// Use tempDirBridge for dedicated Netcatty temp directory
|
||||
const localPath = await tempDirBridge.getTempFilePath(fileName);
|
||||
|
||||
console.log(`[Main] Local temp path: ${localPath}`);
|
||||
|
||||
// Get the sftp client and download file
|
||||
const sftpClients = client.getSftpClients ? client.getSftpClients() : null;
|
||||
if (!sftpClients) {
|
||||
console.log(`[Main] Using fallback readSftp method`);
|
||||
// Fallback: use readSftp and write to temp file
|
||||
const content = await client.readSftp(null, { sftpId, path: remotePath });
|
||||
if (typeof content === "string") {
|
||||
@@ -504,18 +573,42 @@ const registerBridges = (win) => {
|
||||
} else {
|
||||
await fs.promises.writeFile(localPath, content);
|
||||
}
|
||||
console.log(`[Main] File downloaded successfully (fallback)`);
|
||||
return localPath;
|
||||
}
|
||||
|
||||
const sftpClient = sftpClients.get(sftpId);
|
||||
if (!sftpClient) {
|
||||
console.error(`[Main] SFTP session not found: ${sftpId}`);
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
await sftpClient.fastGet(remotePath, localPath);
|
||||
console.log(`[Main] File downloaded successfully`);
|
||||
return localPath;
|
||||
});
|
||||
|
||||
// Delete a temp file (for cleanup when editors close)
|
||||
ipcMain.handle("netcatty:deleteTempFile", async (_event, { filePath }) => {
|
||||
try {
|
||||
// Only allow deleting files in Netcatty temp directory for security
|
||||
const netcattyTempDir = tempDirBridge.getTempDir();
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
if (!resolvedPath.startsWith(netcattyTempDir)) {
|
||||
console.warn(`[Main] Refused to delete file outside Netcatty temp dir: ${filePath}`);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
await fs.promises.unlink(resolvedPath);
|
||||
console.log(`[Main] Temp file deleted: ${filePath}`);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
// Silently handle failures (file may be in use or already deleted)
|
||||
console.log(`[Main] Could not delete temp file: ${filePath} (${err.message})`);
|
||||
return { success: false };
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[Main] All bridges registered successfully');
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -198,6 +210,30 @@ ipcRenderer.on("netcatty:portforward:status", (_event, payload) => {
|
||||
}
|
||||
});
|
||||
|
||||
// File watcher listeners (for auto-sync feature)
|
||||
const fileWatchSyncedListeners = new Set();
|
||||
const fileWatchErrorListeners = new Set();
|
||||
|
||||
ipcRenderer.on("netcatty:filewatch:synced", (_event, payload) => {
|
||||
fileWatchSyncedListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("File watch synced callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:filewatch:error", (_event, payload) => {
|
||||
fileWatchErrorListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("File watch error callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const api = {
|
||||
startSSHSession: async (options) => {
|
||||
const result = await ipcRenderer.invoke("netcatty:start", options);
|
||||
@@ -240,6 +276,12 @@ const api = {
|
||||
generateKeyPair: async (options) => {
|
||||
return ipcRenderer.invoke("netcatty:key:generate", options);
|
||||
},
|
||||
checkSshAgent: async () => {
|
||||
return ipcRenderer.invoke("netcatty:ssh:check-agent");
|
||||
},
|
||||
getDefaultKeys: async () => {
|
||||
return ipcRenderer.invoke("netcatty:ssh:get-default-keys");
|
||||
},
|
||||
resizeSession: (sessionId, cols, rows) => {
|
||||
ipcRenderer.send("netcatty:resize", { sessionId, cols, rows });
|
||||
},
|
||||
@@ -261,6 +303,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;
|
||||
@@ -271,6 +325,9 @@ const api = {
|
||||
readSftp: async (sftpId, path) => {
|
||||
return ipcRenderer.invoke("netcatty:sftp:read", { sftpId, path });
|
||||
},
|
||||
readSftpBinary: async (sftpId, path) => {
|
||||
return ipcRenderer.invoke("netcatty:sftp:readBinary", { sftpId, path });
|
||||
},
|
||||
writeSftp: async (sftpId, path, content) => {
|
||||
return ipcRenderer.invoke("netcatty:sftp:write", { sftpId, path, content });
|
||||
},
|
||||
@@ -306,6 +363,14 @@ const api = {
|
||||
transferId
|
||||
});
|
||||
},
|
||||
// Cancel an in-progress SFTP upload
|
||||
cancelSftpUpload: async (transferId) => {
|
||||
// Cleanup listeners
|
||||
uploadProgressListeners.delete(transferId);
|
||||
uploadCompleteListeners.delete(transferId);
|
||||
uploadErrorListeners.delete(transferId);
|
||||
return ipcRenderer.invoke("netcatty:sftp:cancelUpload", { transferId });
|
||||
},
|
||||
// Local filesystem operations
|
||||
listLocalDir: async (path) => {
|
||||
return ipcRenderer.invoke("netcatty:local:list", { path });
|
||||
@@ -512,6 +577,38 @@ const api = {
|
||||
ipcRenderer.invoke("netcatty:openWithApplication", { filePath, appPath }),
|
||||
downloadSftpToTemp: (sftpId, remotePath, fileName) =>
|
||||
ipcRenderer.invoke("netcatty:sftp:downloadToTemp", { sftpId, remotePath, fileName }),
|
||||
|
||||
// File watcher for auto-sync feature
|
||||
startFileWatch: (localPath, remotePath, sftpId) =>
|
||||
ipcRenderer.invoke("netcatty:filewatch:start", { localPath, remotePath, sftpId }),
|
||||
stopFileWatch: (watchId, cleanupTempFile = false) =>
|
||||
ipcRenderer.invoke("netcatty:filewatch:stop", { watchId, cleanupTempFile }),
|
||||
listFileWatches: () =>
|
||||
ipcRenderer.invoke("netcatty:filewatch:list"),
|
||||
registerTempFile: (sftpId, localPath) =>
|
||||
ipcRenderer.invoke("netcatty:filewatch:registerTempFile", { sftpId, localPath }),
|
||||
onFileWatchSynced: (cb) => {
|
||||
fileWatchSyncedListeners.add(cb);
|
||||
return () => fileWatchSyncedListeners.delete(cb);
|
||||
},
|
||||
onFileWatchError: (cb) => {
|
||||
fileWatchErrorListeners.add(cb);
|
||||
return () => fileWatchErrorListeners.delete(cb);
|
||||
},
|
||||
|
||||
// Temp file cleanup
|
||||
deleteTempFile: (filePath) =>
|
||||
ipcRenderer.invoke("netcatty:deleteTempFile", { filePath }),
|
||||
|
||||
// Temp directory management
|
||||
getTempDirInfo: () =>
|
||||
ipcRenderer.invoke("netcatty:tempdir:getInfo"),
|
||||
clearTempDir: () =>
|
||||
ipcRenderer.invoke("netcatty:tempdir:clear"),
|
||||
getTempDirPath: () =>
|
||||
ipcRenderer.invoke("netcatty:tempdir:getPath"),
|
||||
openTempDir: () =>
|
||||
ipcRenderer.invoke("netcatty:tempdir:open"),
|
||||
};
|
||||
|
||||
// Merge with existing netcatty (if any) to avoid stale objects on hot reload
|
||||
|
||||
@@ -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}"],
|
||||
|
||||
848
global.d.ts
vendored
@@ -2,426 +2,468 @@ import type { RemoteFile } from "./types";
|
||||
import type { S3Config, SMBConfig, SyncedFile, WebDAVConfig } from "./domain/sync";
|
||||
|
||||
declare global {
|
||||
// Proxy configuration for SSH connections
|
||||
interface NetcattyProxyConfig {
|
||||
type: 'http' | 'socks5';
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
// Jump host configuration for SSH tunneling
|
||||
interface NetcattyJumpHost {
|
||||
hostname: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
passphrase?: string;
|
||||
publicKey?: string;
|
||||
keyId?: string;
|
||||
keySource?: 'generated' | 'imported';
|
||||
label?: string; // Display label for UI
|
||||
}
|
||||
|
||||
// Host key information for verification
|
||||
// Reserved for future host key verification UI feature
|
||||
interface _NetcattyHostKeyInfo {
|
||||
hostname: string;
|
||||
port: number;
|
||||
keyType: string;
|
||||
fingerprint: string;
|
||||
publicKey?: string;
|
||||
}
|
||||
|
||||
interface NetcattySSHOptions {
|
||||
sessionId?: string;
|
||||
hostname: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
// Optional OpenSSH user certificate
|
||||
certificate?: string;
|
||||
publicKey?: string; // OpenSSH public key line
|
||||
keyId?: string;
|
||||
keySource?: 'generated' | 'imported';
|
||||
agentForwarding?: boolean;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
extraArgs?: string[];
|
||||
startupCommand?: string;
|
||||
passphrase?: string;
|
||||
// Environment variables to set in the remote shell
|
||||
env?: Record<string, string>;
|
||||
// Proxy configuration
|
||||
proxy?: NetcattyProxyConfig;
|
||||
// Jump hosts (bastion chain)
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
// SSH-level keepalive interval in seconds (0 = disabled)
|
||||
keepaliveInterval?: number;
|
||||
}
|
||||
|
||||
interface SftpStatResult {
|
||||
name: string;
|
||||
type: 'file' | 'directory' | 'symlink';
|
||||
size: number;
|
||||
lastModified: number; // timestamp
|
||||
permissions?: string; // e.g., "rwxr-xr-x"
|
||||
owner?: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
interface SftpTransferProgress {
|
||||
transferId: string;
|
||||
bytesTransferred: number;
|
||||
totalBytes: number;
|
||||
speed: number; // bytes per second
|
||||
}
|
||||
|
||||
// Port Forwarding Types
|
||||
interface PortForwardOptions {
|
||||
tunnelId: string;
|
||||
type: 'local' | 'remote' | 'dynamic';
|
||||
localPort: number;
|
||||
bindAddress?: string;
|
||||
remoteHost?: string;
|
||||
remotePort?: number;
|
||||
// SSH connection details
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
}
|
||||
|
||||
interface PortForwardResult {
|
||||
tunnelId: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface PortForwardStatusResult {
|
||||
tunnelId: string;
|
||||
status: 'inactive' | 'connecting' | 'active' | 'error';
|
||||
type?: 'local' | 'remote' | 'dynamic';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type PortForwardStatusCallback = (status: 'inactive' | 'connecting' | 'active' | 'error', error?: string) => void;
|
||||
|
||||
interface NetcattyBridge {
|
||||
startSSHSession(options: NetcattySSHOptions): Promise<string>;
|
||||
startTelnetSession?(options: {
|
||||
sessionId?: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<string>;
|
||||
startMoshSession?(options: {
|
||||
sessionId?: string;
|
||||
hostname: string;
|
||||
// Proxy configuration for SSH connections
|
||||
interface NetcattyProxyConfig {
|
||||
type: 'http' | 'socks5';
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
port?: number;
|
||||
moshServerPath?: string;
|
||||
agentForwarding?: boolean;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<string>;
|
||||
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string> }): Promise<string>;
|
||||
startSerialSession?(options: {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
// Jump host configuration for SSH tunneling
|
||||
interface NetcattyJumpHost {
|
||||
hostname: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
passphrase?: string;
|
||||
publicKey?: string;
|
||||
keyId?: string;
|
||||
keySource?: 'generated' | 'imported';
|
||||
label?: string; // Display label for UI
|
||||
}
|
||||
|
||||
// Host key information for verification
|
||||
// Reserved for future host key verification UI feature
|
||||
interface _NetcattyHostKeyInfo {
|
||||
hostname: string;
|
||||
port: number;
|
||||
keyType: string;
|
||||
fingerprint: string;
|
||||
publicKey?: string;
|
||||
}
|
||||
|
||||
interface NetcattySSHOptions {
|
||||
sessionId?: string;
|
||||
path: string;
|
||||
baudRate?: number;
|
||||
dataBits?: 5 | 6 | 7 | 8;
|
||||
stopBits?: 1 | 1.5 | 2;
|
||||
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space';
|
||||
flowControl?: 'none' | 'xon/xoff' | 'rts/cts';
|
||||
}): Promise<string>;
|
||||
listSerialPorts?(): Promise<Array<{
|
||||
path: string;
|
||||
manufacturer: string;
|
||||
serialNumber: string;
|
||||
vendorId: string;
|
||||
productId: string;
|
||||
pnpId: string;
|
||||
}>>;
|
||||
getDefaultShell?(): Promise<string>;
|
||||
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean }>;
|
||||
generateKeyPair?(options: {
|
||||
type: 'RSA' | 'ECDSA' | 'ED25519';
|
||||
bits?: number;
|
||||
comment?: string;
|
||||
}): Promise<{ success: boolean; privateKey?: string; publicKey?: string; error?: string }>;
|
||||
execCommand(options: {
|
||||
hostname: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
command: string;
|
||||
timeout?: number;
|
||||
}): Promise<{ stdout: string; stderr: string; code: number | null }>;
|
||||
/** Get current working directory from an active SSH session */
|
||||
getSessionPwd?(sessionId: string): Promise<{ success: boolean; cwd?: string; error?: string }>;
|
||||
writeToSession(sessionId: string, data: string): void;
|
||||
resizeSession(sessionId: string, cols: number, rows: number): void;
|
||||
closeSession(sessionId: string): void;
|
||||
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
|
||||
onSessionExit(
|
||||
sessionId: string,
|
||||
cb: (evt: { exitCode?: number; signal?: number }) => void
|
||||
): () => void;
|
||||
onAuthFailed?(
|
||||
sessionId: string,
|
||||
cb: (evt: { sessionId: string; error: string; hostname: string }) => void
|
||||
): () => void;
|
||||
|
||||
// SFTP operations
|
||||
openSftp(options: NetcattySSHOptions): Promise<string>;
|
||||
listSftp(sftpId: string, path: string): Promise<RemoteFile[]>;
|
||||
readSftp(sftpId: string, path: string): Promise<string>;
|
||||
readSftpBinary?(sftpId: string, path: string): Promise<ArrayBuffer>;
|
||||
writeSftp(sftpId: string, path: string, content: string): Promise<void>;
|
||||
writeSftpBinary?(sftpId: string, path: string, content: ArrayBuffer): Promise<void>;
|
||||
closeSftp(sftpId: string): Promise<void>;
|
||||
mkdirSftp(sftpId: string, path: string): Promise<void>;
|
||||
deleteSftp?(sftpId: string, path: string): Promise<void>;
|
||||
renameSftp?(sftpId: string, oldPath: string, newPath: string): Promise<void>;
|
||||
statSftp?(sftpId: string, path: string): Promise<SftpStatResult>;
|
||||
chmodSftp?(sftpId: string, path: string, mode: string): Promise<void>;
|
||||
|
||||
// Write binary with real-time progress callback
|
||||
writeSftpBinaryWithProgress?(
|
||||
sftpId: string,
|
||||
path: string,
|
||||
content: ArrayBuffer,
|
||||
transferId: string,
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void
|
||||
): Promise<{ success: boolean; transferId: string }>;
|
||||
|
||||
// Transfer with progress
|
||||
uploadFile?(sftpId: string, localPath: string, remotePath: string, transferId: string): Promise<void>;
|
||||
downloadFile?(sftpId: string, remotePath: string, localPath: string, transferId: string): Promise<void>;
|
||||
cancelTransfer?(transferId: string): Promise<void>;
|
||||
onTransferProgress?(transferId: string, cb: (progress: SftpTransferProgress) => void): () => void;
|
||||
|
||||
// Streaming transfer with real progress and cancellation
|
||||
startStreamTransfer?(
|
||||
options: {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
sourceType: 'local' | 'sftp';
|
||||
targetType: 'local' | 'sftp';
|
||||
sourceSftpId?: string;
|
||||
targetSftpId?: string;
|
||||
totalBytes?: number;
|
||||
},
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void
|
||||
): Promise<{ transferId: string; totalBytes?: number; error?: string }>;
|
||||
|
||||
// Local filesystem operations
|
||||
listLocalDir?(path: string): Promise<RemoteFile[]>;
|
||||
readLocalFile?(path: string): Promise<ArrayBuffer>;
|
||||
writeLocalFile?(path: string, content: ArrayBuffer): Promise<void>;
|
||||
deleteLocalFile?(path: string): Promise<void>;
|
||||
renameLocalFile?(oldPath: string, newPath: string): Promise<void>;
|
||||
mkdirLocal?(path: string): Promise<void>;
|
||||
statLocal?(path: string): Promise<SftpStatResult>;
|
||||
getHomeDir?(): Promise<string>;
|
||||
getSystemInfo?(): Promise<{ username: string; hostname: string }>;
|
||||
|
||||
setTheme?(theme: 'light' | 'dark'): Promise<boolean>;
|
||||
setBackgroundColor?(color: string): Promise<boolean>;
|
||||
setLanguage?(language: string): Promise<boolean>;
|
||||
// Window controls for custom title bar (Windows/Linux)
|
||||
windowMinimize?(): Promise<void>;
|
||||
windowMaximize?(): Promise<boolean>;
|
||||
windowClose?(): Promise<void>;
|
||||
windowIsMaximized?(): Promise<boolean>;
|
||||
windowIsFullscreen?(): Promise<boolean>;
|
||||
onWindowFullScreenChanged?(cb: (isFullscreen: boolean) => void): () => void;
|
||||
|
||||
// Settings window
|
||||
openSettingsWindow?(): Promise<boolean>;
|
||||
closeSettingsWindow?(): Promise<void>;
|
||||
// Optional OpenSSH user certificate
|
||||
certificate?: string;
|
||||
publicKey?: string; // OpenSSH public key line
|
||||
keyId?: string;
|
||||
keySource?: 'generated' | 'imported';
|
||||
agentForwarding?: boolean;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
extraArgs?: string[];
|
||||
startupCommand?: string;
|
||||
passphrase?: string;
|
||||
// Environment variables to set in the remote shell
|
||||
env?: Record<string, string>;
|
||||
// Proxy configuration
|
||||
proxy?: NetcattyProxyConfig;
|
||||
// Jump hosts (bastion chain)
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
// SSH-level keepalive interval in seconds (0 = disabled)
|
||||
keepaliveInterval?: number;
|
||||
// Use sudo for SFTP server
|
||||
sudo?: boolean;
|
||||
}
|
||||
|
||||
// Cross-window settings sync
|
||||
notifySettingsChanged?(payload: { key: string; value: unknown }): void;
|
||||
onSettingsChanged?(cb: (payload: { key: string; value: unknown }) => void): () => void;
|
||||
interface SftpStatResult {
|
||||
name: string;
|
||||
type: 'file' | 'directory' | 'symlink';
|
||||
size: number;
|
||||
lastModified: number; // timestamp
|
||||
permissions?: string; // e.g., "rwxr-xr-x"
|
||||
owner?: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
// Cloud sync master password (stored in-memory + persisted via Electron safeStorage)
|
||||
cloudSyncSetSessionPassword?(password: string): Promise<boolean>;
|
||||
cloudSyncGetSessionPassword?(): Promise<string | null>;
|
||||
cloudSyncClearSessionPassword?(): Promise<boolean>;
|
||||
interface SftpTransferProgress {
|
||||
transferId: string;
|
||||
bytesTransferred: number;
|
||||
totalBytes: number;
|
||||
speed: number; // bytes per second
|
||||
}
|
||||
|
||||
// Cloud sync network operations (proxied via main process)
|
||||
cloudSyncWebdavInitialize?(config: WebDAVConfig): Promise<{ resourceId: string | null }>;
|
||||
cloudSyncWebdavUpload?(
|
||||
config: WebDAVConfig,
|
||||
syncedFile: SyncedFile
|
||||
): Promise<{ resourceId: string }>;
|
||||
cloudSyncWebdavDownload?(config: WebDAVConfig): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncWebdavDelete?(config: WebDAVConfig): Promise<{ ok: true }>;
|
||||
// Port Forwarding Types
|
||||
interface PortForwardOptions {
|
||||
tunnelId: string;
|
||||
type: 'local' | 'remote' | 'dynamic';
|
||||
localPort: number;
|
||||
bindAddress?: string;
|
||||
remoteHost?: string;
|
||||
remotePort?: number;
|
||||
// SSH connection details
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
}
|
||||
|
||||
cloudSyncS3Initialize?(config: S3Config): Promise<{ resourceId: string | null }>;
|
||||
cloudSyncS3Upload?(
|
||||
config: S3Config,
|
||||
syncedFile: SyncedFile
|
||||
): Promise<{ resourceId: string }>;
|
||||
cloudSyncS3Download?(config: S3Config): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncS3Delete?(config: S3Config): Promise<{ ok: true }>;
|
||||
|
||||
cloudSyncSmbInitialize?(config: SMBConfig): Promise<{ resourceId: string | null }>;
|
||||
cloudSyncSmbUpload?(
|
||||
config: SMBConfig,
|
||||
syncedFile: SyncedFile
|
||||
): Promise<{ resourceId: string }>;
|
||||
cloudSyncSmbDownload?(config: SMBConfig): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncSmbDelete?(config: SMBConfig): Promise<{ ok: true }>;
|
||||
|
||||
// Port Forwarding
|
||||
startPortForward?(options: PortForwardOptions): Promise<PortForwardResult>;
|
||||
stopPortForward?(tunnelId: string): Promise<PortForwardResult>;
|
||||
getPortForwardStatus?(tunnelId: string): Promise<PortForwardStatusResult>;
|
||||
listPortForwards?(): Promise<{ tunnelId: string; type: string; status: string }[]>;
|
||||
onPortForwardStatus?(tunnelId: string, cb: PortForwardStatusCallback): () => void;
|
||||
|
||||
// Known Hosts
|
||||
readKnownHosts?(): Promise<string | null>;
|
||||
|
||||
// Open URL in default browser
|
||||
openExternal?(url: string): Promise<void>;
|
||||
|
||||
// App info (name/version/platform) for About screens
|
||||
getAppInfo?(): Promise<{ name: string; version: string; platform: string }>;
|
||||
|
||||
// Notify main process the renderer has mounted/painted (used to avoid initial blank screen).
|
||||
rendererReady?(): void;
|
||||
|
||||
onLanguageChanged?(cb: (language: string) => void): () => void;
|
||||
|
||||
// Chain progress listener for jump host connections
|
||||
// Callback receives: (currentHop: number, totalHops: number, hostLabel: string, status: string)
|
||||
onChainProgress?(cb: (hop: number, total: number, label: string, status: string) => void): () => void;
|
||||
|
||||
// OAuth callback server for cloud sync
|
||||
startOAuthCallback?(expectedState?: string): Promise<{ code: string; state?: string }>;
|
||||
cancelOAuthCallback?(): Promise<void>;
|
||||
|
||||
// GitHub Device Flow (cloud sync)
|
||||
githubStartDeviceFlow?(options?: { clientId?: string; scope?: string }): Promise<{
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
expiresAt: number;
|
||||
interval: number;
|
||||
}>;
|
||||
githubPollDeviceFlowToken?(options: { clientId?: string; deviceCode: string }): Promise<{
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
scope?: string;
|
||||
interface PortForwardResult {
|
||||
tunnelId: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Google OAuth (cloud sync) - proxied via main process to avoid CORS
|
||||
googleExchangeCodeForTokens?(options: {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
googleRefreshAccessToken?(options: {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
refreshToken: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
googleGetUserInfo?(options: { accessToken: string }): Promise<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
picture?: string;
|
||||
}>;
|
||||
interface PortForwardStatusResult {
|
||||
tunnelId: string;
|
||||
status: 'inactive' | 'connecting' | 'active' | 'error';
|
||||
type?: 'local' | 'remote' | 'dynamic';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Google Drive API (cloud sync) - proxied via main process to avoid CORS/COEP issues
|
||||
googleDriveFindSyncFile?(options: { accessToken: string; fileName?: string }): Promise<{ fileId: string | null }>;
|
||||
googleDriveCreateSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string }>;
|
||||
googleDriveUpdateSyncFile?(options: { accessToken: string; fileId: string; syncedFile: unknown }): Promise<{ ok: true }>;
|
||||
googleDriveDownloadSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ syncedFile: unknown | null }>;
|
||||
googleDriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
|
||||
type PortForwardStatusCallback = (status: 'inactive' | 'connecting' | 'active' | 'error', error?: string) => void;
|
||||
|
||||
// OneDrive OAuth + Graph (cloud sync) - proxied via main process to avoid CORS
|
||||
onedriveExchangeCodeForTokens?(options: {
|
||||
clientId: string;
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
scope?: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
onedriveRefreshAccessToken?(options: {
|
||||
clientId: string;
|
||||
refreshToken: string;
|
||||
scope?: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
onedriveGetUserInfo?(options: { accessToken: string }): Promise<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatarDataUrl?: string;
|
||||
}>;
|
||||
onedriveFindSyncFile?(options: { accessToken: string; fileName?: string }): Promise<{ fileId: string | null }>;
|
||||
onedriveUploadSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string | null }>;
|
||||
onedriveDownloadSyncFile?(options: { accessToken: string; fileId?: string; fileName?: string }): Promise<{ syncedFile: unknown | null }>;
|
||||
onedriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
|
||||
interface NetcattyBridge {
|
||||
startSSHSession(options: NetcattySSHOptions): Promise<string>;
|
||||
startTelnetSession?(options: {
|
||||
sessionId?: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<string>;
|
||||
startMoshSession?(options: {
|
||||
sessionId?: string;
|
||||
hostname: string;
|
||||
username?: string;
|
||||
port?: number;
|
||||
moshServerPath?: string;
|
||||
agentForwarding?: boolean;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<string>;
|
||||
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string> }): Promise<string>;
|
||||
startSerialSession?(options: {
|
||||
sessionId?: string;
|
||||
path: string;
|
||||
baudRate?: number;
|
||||
dataBits?: 5 | 6 | 7 | 8;
|
||||
stopBits?: 1 | 1.5 | 2;
|
||||
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space';
|
||||
flowControl?: 'none' | 'xon/xoff' | 'rts/cts';
|
||||
}): Promise<string>;
|
||||
listSerialPorts?(): Promise<Array<{
|
||||
path: string;
|
||||
manufacturer: string;
|
||||
serialNumber: string;
|
||||
vendorId: string;
|
||||
productId: string;
|
||||
pnpId: string;
|
||||
}>>;
|
||||
getDefaultShell?(): Promise<string>;
|
||||
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean }>;
|
||||
generateKeyPair?(options: {
|
||||
type: 'RSA' | 'ECDSA' | 'ED25519';
|
||||
bits?: number;
|
||||
comment?: string;
|
||||
}): Promise<{ success: boolean; privateKey?: string; publicKey?: string; error?: string }>;
|
||||
checkSshAgent?(): Promise<{ running: boolean; startupType: string | null; error: string | null }>;
|
||||
getDefaultKeys?(): Promise<Array<{ name: string; path: string }>>;
|
||||
execCommand(options: {
|
||||
hostname: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
command: string;
|
||||
timeout?: number;
|
||||
}): Promise<{ stdout: string; stderr: string; code: number | null }>;
|
||||
/** Get current working directory from an active SSH session */
|
||||
getSessionPwd?(sessionId: string): Promise<{ success: boolean; cwd?: string; error?: string }>;
|
||||
writeToSession(sessionId: string, data: string): void;
|
||||
resizeSession(sessionId: string, cols: number, rows: number): void;
|
||||
closeSession(sessionId: string): void;
|
||||
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
|
||||
onSessionExit(
|
||||
sessionId: string,
|
||||
cb: (evt: { exitCode?: number; signal?: number }) => void
|
||||
): () => void;
|
||||
onAuthFailed?(
|
||||
sessionId: string,
|
||||
cb: (evt: { sessionId: string; error: string; hostname: string }) => void
|
||||
): () => void;
|
||||
|
||||
// File opener helpers (for "Open With" feature)
|
||||
selectApplication?(): Promise<{ path: string; name: string } | null>;
|
||||
openWithApplication?(filePath: string, appPath: string): Promise<boolean>;
|
||||
downloadSftpToTemp?(sftpId: string, remotePath: string, fileName: string): Promise<string>;
|
||||
}
|
||||
// Keyboard-interactive authentication (2FA/MFA)
|
||||
onKeyboardInteractive?(
|
||||
cb: (request: {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
name: string;
|
||||
instructions: string;
|
||||
prompts: Array<{ prompt: string; echo: boolean }>;
|
||||
hostname: string;
|
||||
savedPassword?: string | null;
|
||||
}) => void
|
||||
): () => void;
|
||||
respondKeyboardInteractive?(
|
||||
requestId: string,
|
||||
responses: string[],
|
||||
cancelled?: boolean
|
||||
): Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
interface Window {
|
||||
netcatty?: NetcattyBridge;
|
||||
}
|
||||
// SFTP operations
|
||||
openSftp(options: NetcattySSHOptions): Promise<string>;
|
||||
listSftp(sftpId: string, path: string): Promise<RemoteFile[]>;
|
||||
readSftp(sftpId: string, path: string): Promise<string>;
|
||||
readSftpBinary?(sftpId: string, path: string): Promise<ArrayBuffer>;
|
||||
writeSftp(sftpId: string, path: string, content: string): Promise<void>;
|
||||
writeSftpBinary?(sftpId: string, path: string, content: ArrayBuffer): Promise<void>;
|
||||
closeSftp(sftpId: string): Promise<void>;
|
||||
mkdirSftp(sftpId: string, path: string): Promise<void>;
|
||||
deleteSftp?(sftpId: string, path: string): Promise<void>;
|
||||
renameSftp?(sftpId: string, oldPath: string, newPath: string): Promise<void>;
|
||||
statSftp?(sftpId: string, path: string): Promise<SftpStatResult>;
|
||||
chmodSftp?(sftpId: string, path: string, mode: string): Promise<void>;
|
||||
|
||||
// Write binary with real-time progress callback
|
||||
writeSftpBinaryWithProgress?(
|
||||
sftpId: string,
|
||||
path: string,
|
||||
content: ArrayBuffer,
|
||||
transferId: string,
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void
|
||||
): Promise<{ success: boolean; transferId: string; cancelled?: boolean }>;
|
||||
|
||||
// Cancel an in-progress SFTP upload
|
||||
cancelSftpUpload?(transferId: string): Promise<{ success: boolean }>;
|
||||
|
||||
// Transfer with progress
|
||||
uploadFile?(sftpId: string, localPath: string, remotePath: string, transferId: string): Promise<void>;
|
||||
downloadFile?(sftpId: string, remotePath: string, localPath: string, transferId: string): Promise<void>;
|
||||
cancelTransfer?(transferId: string): Promise<void>;
|
||||
onTransferProgress?(transferId: string, cb: (progress: SftpTransferProgress) => void): () => void;
|
||||
|
||||
// Streaming transfer with real progress and cancellation
|
||||
startStreamTransfer?(
|
||||
options: {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
sourceType: 'local' | 'sftp';
|
||||
targetType: 'local' | 'sftp';
|
||||
sourceSftpId?: string;
|
||||
targetSftpId?: string;
|
||||
totalBytes?: number;
|
||||
},
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void
|
||||
): Promise<{ transferId: string; totalBytes?: number; error?: string }>;
|
||||
|
||||
// Local filesystem operations
|
||||
listLocalDir?(path: string): Promise<RemoteFile[]>;
|
||||
readLocalFile?(path: string): Promise<ArrayBuffer>;
|
||||
writeLocalFile?(path: string, content: ArrayBuffer): Promise<void>;
|
||||
deleteLocalFile?(path: string): Promise<void>;
|
||||
renameLocalFile?(oldPath: string, newPath: string): Promise<void>;
|
||||
mkdirLocal?(path: string): Promise<void>;
|
||||
statLocal?(path: string): Promise<SftpStatResult>;
|
||||
getHomeDir?(): Promise<string>;
|
||||
getSystemInfo?(): Promise<{ username: string; hostname: string }>;
|
||||
|
||||
setTheme?(theme: 'light' | 'dark'): Promise<boolean>;
|
||||
setBackgroundColor?(color: string): Promise<boolean>;
|
||||
setLanguage?(language: string): Promise<boolean>;
|
||||
// Window controls for custom title bar (Windows/Linux)
|
||||
windowMinimize?(): Promise<void>;
|
||||
windowMaximize?(): Promise<boolean>;
|
||||
windowClose?(): Promise<void>;
|
||||
windowIsMaximized?(): Promise<boolean>;
|
||||
windowIsFullscreen?(): Promise<boolean>;
|
||||
onWindowFullScreenChanged?(cb: (isFullscreen: boolean) => void): () => void;
|
||||
|
||||
// Settings window
|
||||
openSettingsWindow?(): Promise<boolean>;
|
||||
closeSettingsWindow?(): Promise<void>;
|
||||
|
||||
// Cross-window settings sync
|
||||
notifySettingsChanged?(payload: { key: string; value: unknown }): void;
|
||||
onSettingsChanged?(cb: (payload: { key: string; value: unknown }) => void): () => void;
|
||||
|
||||
// Cloud sync master password (stored in-memory + persisted via Electron safeStorage)
|
||||
cloudSyncSetSessionPassword?(password: string): Promise<boolean>;
|
||||
cloudSyncGetSessionPassword?(): Promise<string | null>;
|
||||
cloudSyncClearSessionPassword?(): Promise<boolean>;
|
||||
|
||||
// Cloud sync network operations (proxied via main process)
|
||||
cloudSyncWebdavInitialize?(config: WebDAVConfig): Promise<{ resourceId: string | null }>;
|
||||
cloudSyncWebdavUpload?(
|
||||
config: WebDAVConfig,
|
||||
syncedFile: SyncedFile
|
||||
): Promise<{ resourceId: string }>;
|
||||
cloudSyncWebdavDownload?(config: WebDAVConfig): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncWebdavDelete?(config: WebDAVConfig): Promise<{ ok: true }>;
|
||||
|
||||
cloudSyncS3Initialize?(config: S3Config): Promise<{ resourceId: string | null }>;
|
||||
cloudSyncS3Upload?(
|
||||
config: S3Config,
|
||||
syncedFile: SyncedFile
|
||||
): Promise<{ resourceId: string }>;
|
||||
cloudSyncS3Download?(config: S3Config): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncS3Delete?(config: S3Config): Promise<{ ok: true }>;
|
||||
|
||||
cloudSyncSmbInitialize?(config: SMBConfig): Promise<{ resourceId: string | null }>;
|
||||
cloudSyncSmbUpload?(
|
||||
config: SMBConfig,
|
||||
syncedFile: SyncedFile
|
||||
): Promise<{ resourceId: string }>;
|
||||
cloudSyncSmbDownload?(config: SMBConfig): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncSmbDelete?(config: SMBConfig): Promise<{ ok: true }>;
|
||||
|
||||
// Port Forwarding
|
||||
startPortForward?(options: PortForwardOptions): Promise<PortForwardResult>;
|
||||
stopPortForward?(tunnelId: string): Promise<PortForwardResult>;
|
||||
getPortForwardStatus?(tunnelId: string): Promise<PortForwardStatusResult>;
|
||||
listPortForwards?(): Promise<{ tunnelId: string; type: string; status: string }[]>;
|
||||
onPortForwardStatus?(tunnelId: string, cb: PortForwardStatusCallback): () => void;
|
||||
|
||||
// Known Hosts
|
||||
readKnownHosts?(): Promise<string | null>;
|
||||
|
||||
// Open URL in default browser
|
||||
openExternal?(url: string): Promise<void>;
|
||||
|
||||
// App info (name/version/platform) for About screens
|
||||
getAppInfo?(): Promise<{ name: string; version: string; platform: string }>;
|
||||
|
||||
// Notify main process the renderer has mounted/painted (used to avoid initial blank screen).
|
||||
rendererReady?(): void;
|
||||
|
||||
onLanguageChanged?(cb: (language: string) => void): () => void;
|
||||
|
||||
// Chain progress listener for jump host connections
|
||||
// Callback receives: (currentHop: number, totalHops: number, hostLabel: string, status: string)
|
||||
onChainProgress?(cb: (hop: number, total: number, label: string, status: string) => void): () => void;
|
||||
|
||||
// OAuth callback server for cloud sync
|
||||
startOAuthCallback?(expectedState?: string): Promise<{ code: string; state?: string }>;
|
||||
cancelOAuthCallback?(): Promise<void>;
|
||||
|
||||
// GitHub Device Flow (cloud sync)
|
||||
githubStartDeviceFlow?(options?: { clientId?: string; scope?: string }): Promise<{
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
expiresAt: number;
|
||||
interval: number;
|
||||
}>;
|
||||
githubPollDeviceFlowToken?(options: { clientId?: string; deviceCode: string }): Promise<{
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
scope?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}>;
|
||||
|
||||
// Google OAuth (cloud sync) - proxied via main process to avoid CORS
|
||||
googleExchangeCodeForTokens?(options: {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
googleRefreshAccessToken?(options: {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
refreshToken: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
googleGetUserInfo?(options: { accessToken: string }): Promise<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
picture?: string;
|
||||
}>;
|
||||
|
||||
// Google Drive API (cloud sync) - proxied via main process to avoid CORS/COEP issues
|
||||
googleDriveFindSyncFile?(options: { accessToken: string; fileName?: string }): Promise<{ fileId: string | null }>;
|
||||
googleDriveCreateSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string }>;
|
||||
googleDriveUpdateSyncFile?(options: { accessToken: string; fileId: string; syncedFile: unknown }): Promise<{ ok: true }>;
|
||||
googleDriveDownloadSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ syncedFile: unknown | null }>;
|
||||
googleDriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
|
||||
|
||||
// OneDrive OAuth + Graph (cloud sync) - proxied via main process to avoid CORS
|
||||
onedriveExchangeCodeForTokens?(options: {
|
||||
clientId: string;
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
scope?: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
onedriveRefreshAccessToken?(options: {
|
||||
clientId: string;
|
||||
refreshToken: string;
|
||||
scope?: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
onedriveGetUserInfo?(options: { accessToken: string }): Promise<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatarDataUrl?: string;
|
||||
}>;
|
||||
onedriveFindSyncFile?(options: { accessToken: string; fileName?: string }): Promise<{ fileId: string | null }>;
|
||||
onedriveUploadSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string | null }>;
|
||||
onedriveDownloadSyncFile?(options: { accessToken: string; fileId?: string; fileName?: string }): Promise<{ syncedFile: unknown | null }>;
|
||||
onedriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
|
||||
|
||||
// File opener helpers (for "Open With" feature)
|
||||
selectApplication?(): Promise<{ path: string; name: string } | null>;
|
||||
openWithApplication?(filePath: string, appPath: string): Promise<boolean>;
|
||||
downloadSftpToTemp?(sftpId: string, remotePath: string, fileName: string): Promise<string>;
|
||||
|
||||
// File watcher for auto-sync feature
|
||||
startFileWatch?(localPath: string, remotePath: string, sftpId: string): Promise<{ watchId: string }>;
|
||||
stopFileWatch?(watchId: string, cleanupTempFile?: boolean): Promise<{ success: boolean }>;
|
||||
listFileWatches?(): Promise<Array<{ watchId: string; localPath: string; remotePath: string; sftpId: string }>>;
|
||||
registerTempFile?(sftpId: string, localPath: string): Promise<{ success: boolean }>;
|
||||
onFileWatchSynced?(cb: (payload: { watchId: string; localPath: string; remotePath: string; bytesWritten: number }) => void): () => void;
|
||||
onFileWatchError?(cb: (payload: { watchId: string; localPath: string; remotePath: string; error: string }) => void): () => void;
|
||||
|
||||
// Temp file cleanup
|
||||
deleteTempFile?(filePath: string): Promise<{ success: boolean }>;
|
||||
|
||||
// Temp directory management
|
||||
getTempDirInfo?(): Promise<{ path: string; fileCount: number; totalSize: number }>;
|
||||
clearTempDir?(): Promise<{ deletedCount: number; failedCount: number; error?: string }>;
|
||||
getTempDirPath?(): Promise<string>;
|
||||
openTempDir?(): Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
netcatty?: NetcattyBridge;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -40,6 +40,8 @@ export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associatio
|
||||
|
||||
// SFTP Settings
|
||||
export const STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR = 'netcatty_sftp_double_click_behavior_v1';
|
||||
export const STORAGE_KEY_SFTP_AUTO_SYNC = 'netcatty_sftp_auto_sync_v1';
|
||||
export const STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES = 'netcatty_sftp_show_hidden_files_v1';
|
||||
|
||||
// Archived legacy key records that are no longer supported by the app (e.g. biometric/WebAuthn/FIDO2 experiments).
|
||||
export const STORAGE_KEY_LEGACY_KEYS = 'netcatty_legacy_keys_v1';
|
||||
|
||||
@@ -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,175 @@ 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
|
||||
* Optimized with parallel processing for faster folder traversal
|
||||
* @param entry - The file system entry to process
|
||||
* @param basePath - The base path (folder name) to prepend
|
||||
* @returns Array of DropEntry objects with files and their relative paths
|
||||
*/
|
||||
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);
|
||||
|
||||
// Process entries in parallel batches for better performance
|
||||
// Use a concurrency limit to avoid overwhelming the browser
|
||||
const BATCH_SIZE = 50;
|
||||
|
||||
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
|
||||
const batch = entries.slice(i, i + BATCH_SIZE);
|
||||
// Process batch in parallel
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(childEntry => processEntry(childEntry, currentPath))
|
||||
);
|
||||
// Flatten and add results
|
||||
for (const childResults of batchResults) {
|
||||
results.push(...childResults);
|
||||
}
|
||||
|
||||
// Yield to main thread between batches to keep UI responsive
|
||||
if (i + BATCH_SIZE < entries.length) {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
} 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");
|
||||
}
|
||||
2192
package-lock.json
generated
12
package.json
@@ -15,11 +15,11 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "node electron/launch.cjs",
|
||||
"pack": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --publish=never",
|
||||
"pack:dir": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --dir --publish=never",
|
||||
"pack:win": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --win --publish=never",
|
||||
"pack:mac": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --mac --publish=never",
|
||||
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --linux --publish=never",
|
||||
"pack": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --publish=never",
|
||||
"pack:dir": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --dir --publish=never",
|
||||
"pack:win": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --win --publish=never",
|
||||
"pack:mac": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --mac --publish=never",
|
||||
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"rebuild": "electron-builder install-app-deps",
|
||||
"lint": "eslint .",
|
||||
@@ -77,4 +77,4 @@
|
||||
"vite": "^7.2.7",
|
||||
"wait-on": "^9.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
public/dmg-background.jpg
Normal file
|
After Width: | Height: | Size: 304 KiB |
BIN
public/dmg-fix-icon.png
Normal file
|
After Width: | Height: | Size: 727 KiB |
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 |
28
scripts/FixQuarantine.app/Contents/Info.plist
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Fix Quarantine</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>FixQuarantine</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.netcatty.fixquarantine</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Fix Quarantine</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>FixQuarantine.icns</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13</string>
|
||||
</dict>
|
||||
</plist>
|
||||
17
scripts/FixQuarantine.app/Contents/MacOS/FixQuarantine
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
APP_PATH="/Applications/Netcatty.app"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
/usr/bin/osascript <<'EOF'
|
||||
display alert "Netcatty.app not found" message "Drag Netcatty.app into /Applications, then run this tool again." as critical buttons {"OK"} default button "OK"
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
/usr/bin/osascript <<'EOF'
|
||||
do shell script "xattr -dr com.apple.quarantine /Applications/Netcatty.app" with administrator privileges
|
||||
EOF
|
||||
|
||||
open "$APP_PATH"
|
||||
BIN
scripts/FixQuarantine.app/Contents/Resources/FixQuarantine.icns
Normal file
10
scripts/gen_icns.sh
Normal file
@@ -0,0 +1,10 @@
|
||||
# 1) 准备一张 1024x1024 PNG,例如放在 public/dmg-fix-icon.png
|
||||
# 2) 生成 iconset 并转 icns
|
||||
ICONSET="scripts/fixquarantine.iconset"
|
||||
mkdir -p "$ICONSET"
|
||||
for size in 16 32 128 256 512; do
|
||||
sips -z "$size" "$size" public/dmg-fix-icon.png --out "$ICONSET/icon_${size}x${size}.png" >/dev/null
|
||||
sips -z "$((size*2))" "$((size*2))" public/dmg-fix-icon.png --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null
|
||||
done
|
||||
iconutil -c icns "$ICONSET" -o scripts/FixQuarantine.app/Contents/Resources/FixQuarantine.icns
|
||||
rm -rf $ICONSET
|
||||
@@ -26,5 +26,10 @@
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"global.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
]
|
||||
}
|
||||