From 34f99352c9735d6d16fe4f803dff4f1b07d905a8 Mon Sep 17 00:00:00 2001 From: sunxiaobin89 <285584806@qq.com> Date: Fri, 31 Jul 2026 13:52:04 +0800 Subject: [PATCH] fix(proxy): persist proxy config so it survives saves and reconnects Proxy settings (type, host, port, username, password) were never persisted: ConnectionData had no proxy fields and every save/update path omitted them. Saving a connection (new or edited) silently dropped the proxy config, so reopening the edit dialog showed empty proxy fields. - Add proxy fields to ConnectionData so the storage model can hold them - Include proxy fields in every save/update path in ConnectionDialog (new connect, edit save, SSH/SFTP/FTP/Desktop) and in duplication - Add toConnectionConfig helper to carry proxy settings when opening the edit dialog, replacing 9 duplicated ConnectionConfig constructions - Fix proxy username input missing value binding so saved data displays - Normalize proxyType to 'none' for connections without proxy settings Test: 522 tests pass, 12 new tests cover proxy persistence --- src/App.tsx | 134 +---------- src/__tests__/connection-config.test.ts | 79 +++++++ .../connection-dialog-proxy.test.tsx | 168 ++++++++++++++ .../connection-storage-proxy.test.ts | 86 +++++++ src/components/connection-dialog.tsx | 210 ++++++++++-------- src/components/connection-manager.tsx | 6 + src/lib/connection-config.ts | 37 +++ src/lib/connection-storage.ts | 6 + 8 files changed, 510 insertions(+), 216 deletions(-) create mode 100644 src/__tests__/connection-config.test.ts create mode 100644 src/__tests__/connection-dialog-proxy.test.tsx create mode 100644 src/__tests__/connection-storage-proxy.test.ts create mode 100644 src/lib/connection-config.ts diff --git a/src/App.tsx b/src/App.tsx index caf54c71..b1ef5a0e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { SettingsModal } from './components/settings-modal'; import { IntegratedFileBrowser } from './components/integrated-file-browser'; import { WelcomeScreen } from './components/welcome-screen'; import { UpdateChecker } from './components/update-checker'; +import { toConnectionConfig } from './lib/connection-config'; import { ActiveConnectionsManager, ConnectionStorageManager } from './lib/connection-storage'; import { isDesktopProtocol } from './lib/protocol-config'; import { registerRestoration, clearAllRestorations } from './lib/restoration-manager'; @@ -506,22 +507,7 @@ function AppContent() { : !!connectionData.privateKeyPath); if (!hasCredentials) { - setEditingConnection({ - id: connection.id, - name: connectionData.name, - protocol: connectionData.protocol as ConnectionConfig['protocol'], - host: connectionData.host, - port: connectionData.port, - username: connectionData.username, - authMethod: connectionData.authMethod || 'password', - password: connectionData.password, - privateKeyPath: connectionData.privateKeyPath, - passphrase: connectionData.passphrase, - ftpsEnabled: connectionData.ftpsEnabled, - domain: connectionData.domain, - rdpResolution: connectionData.rdpResolution as ConnectionConfig['rdpResolution'], - vncColorDepth: connectionData.vncColorDepth as ConnectionConfig['vncColorDepth'], - }); + setEditingConnection(toConnectionConfig(connectionData)); setPendingConnectionId(connection.id); setConnectionDialogOpen(true); return; @@ -631,18 +617,7 @@ function AppContent() { toast.error(t('app.connectionFailed'), { description: result.error || 'Unable to connect to the server. Please check your credentials and try again.', }); - setEditingConnection({ - id: connection.id, - name: connectionData.name, - protocol: connectionData.protocol as ConnectionConfig['protocol'], - host: connectionData.host, - port: connectionData.port, - username: connectionData.username, - authMethod: connectionData.authMethod || 'password', - password: connectionData.password, - privateKeyPath: connectionData.privateKeyPath, - passphrase: connectionData.passphrase, - }); + setEditingConnection(toConnectionConfig(connectionData)); setPendingConnectionId(connection.id); setConnectionDialogOpen(true); } @@ -652,18 +627,7 @@ function AppContent() { toast.error(t('app.connectionError'), { description: error instanceof Error ? error.message : t('app.connectionErrorDesc'), }); - setEditingConnection({ - id: connection.id, - name: connectionData.name, - protocol: connectionData.protocol as ConnectionConfig['protocol'], - host: connectionData.host, - port: connectionData.port, - username: connectionData.username, - authMethod: connectionData.authMethod || 'password', - password: connectionData.password, - privateKeyPath: connectionData.privateKeyPath, - passphrase: connectionData.passphrase, - }); + setEditingConnection(toConnectionConfig(connectionData)); setPendingConnectionId(connection.id); setConnectionDialogOpen(true); } @@ -873,22 +837,7 @@ function AppContent() { toast.error(t('app.cannotReconnect'), { description: t('app.noCredentialsDesc'), }); - setEditingConnection({ - id: originalConnectionId, - name: connectionData.name, - protocol: connectionData.protocol as ConnectionConfig['protocol'], - host: connectionData.host, - port: connectionData.port, - username: connectionData.username, - authMethod: connectionData.authMethod || 'password', - password: connectionData.password, - privateKeyPath: connectionData.privateKeyPath, - passphrase: connectionData.passphrase, - ftpsEnabled: connectionData.ftpsEnabled, - domain: connectionData.domain, - rdpResolution: connectionData.rdpResolution as ConnectionConfig['rdpResolution'], - vncColorDepth: connectionData.vncColorDepth as ConnectionConfig['vncColorDepth'], - }); + setEditingConnection(toConnectionConfig(connectionData)); setPendingConnectionId(originalConnectionId); setConnectionDialogOpen(true); return; @@ -1296,21 +1245,7 @@ function AppContent() { if (connection.type === 'connection') { const connectionData = ConnectionStorageManager.getConnection(connection.id); if (connectionData) { - setEditingConnection({ - id: connectionData.id, - name: connectionData.name, - protocol: connectionData.protocol as ConnectionConfig['protocol'], - host: connectionData.host, - port: connectionData.port, - username: connectionData.username, - authMethod: connectionData.authMethod || 'password', - password: connectionData.password, - privateKeyPath: connectionData.privateKeyPath, - passphrase: connectionData.passphrase, - domain: connectionData.domain, - rdpResolution: connectionData.rdpResolution as ConnectionConfig['rdpResolution'], - vncColorDepth: connectionData.vncColorDepth as ConnectionConfig['vncColorDepth'], - }); + setEditingConnection(toConnectionConfig(connectionData)); setConnectionDialogOpen(true); setPendingConnectionId(null); } else { @@ -1541,22 +1476,7 @@ function AppContent() { : !!connectionData.privateKeyPath); if (!hasCredentials) { - setEditingConnection({ - id: connectionData.id, - name: connectionData.name, - protocol: connectionData.protocol as ConnectionConfig['protocol'], - host: connectionData.host, - port: connectionData.port, - username: connectionData.username, - authMethod: connectionData.authMethod || 'password', - password: connectionData.password, - privateKeyPath: connectionData.privateKeyPath, - passphrase: connectionData.passphrase, - ftpsEnabled: connectionData.ftpsEnabled, - domain: connectionData.domain, - rdpResolution: connectionData.rdpResolution as ConnectionConfig['rdpResolution'], - vncColorDepth: connectionData.vncColorDepth as ConnectionConfig['vncColorDepth'], - }); + setEditingConnection(toConnectionConfig(connectionData)); setPendingConnectionId(connectionData.id); setConnectionDialogOpen(true); return; @@ -1564,19 +1484,7 @@ function AppContent() { if (isFileBrowser) { // Route through handleConnectionDialogConnect which handles SFTP/FTP - const config: ConnectionConfig = { - id: connectionData.id, - name: connectionData.name, - protocol: connectionData.protocol as ConnectionConfig['protocol'], - host: connectionData.host, - port: connectionData.port, - username: connectionData.username, - authMethod: connectionData.authMethod || 'password', - password: connectionData.password, - privateKeyPath: connectionData.privateKeyPath, - passphrase: connectionData.passphrase, - ftpsEnabled: connectionData.ftpsEnabled, - }; + const config: ConnectionConfig = toConnectionConfig(connectionData); await handleConnectionDialogConnect(config); toast.success(t('app.quickConnected'), { description: t('app.quickConnectedDesc', { name: connectionData.name }), @@ -1603,18 +1511,7 @@ function AppContent() { if (result.success) { ConnectionStorageManager.updateLastConnected(connectionData.id); - const config: ConnectionConfig = { - id: connectionData.id, - name: connectionData.name, - protocol: connectionData.protocol as ConnectionConfig['protocol'], - host: connectionData.host, - port: connectionData.port, - username: connectionData.username, - authMethod: connectionData.authMethod || 'password', - password: connectionData.password, - privateKeyPath: connectionData.privateKeyPath, - passphrase: connectionData.passphrase, - }; + const config: ConnectionConfig = toConnectionConfig(connectionData); handleConnectionDialogConnect(config); @@ -1626,18 +1523,7 @@ function AppContent() { toast.error(t('app.connectionFailed'), { description: result.error || 'Unable to connect. Please try again.', }); - setEditingConnection({ - id: connectionData.id, - name: connectionData.name, - protocol: connectionData.protocol as ConnectionConfig['protocol'], - host: connectionData.host, - port: connectionData.port, - username: connectionData.username, - authMethod: connectionData.authMethod || 'password', - password: connectionData.password, - privateKeyPath: connectionData.privateKeyPath, - passphrase: connectionData.passphrase, - }); + setEditingConnection(toConnectionConfig(connectionData)); setPendingConnectionId(connectionData.id); setConnectionDialogOpen(true); } diff --git a/src/__tests__/connection-config.test.ts b/src/__tests__/connection-config.test.ts new file mode 100644 index 00000000..c0096ea7 --- /dev/null +++ b/src/__tests__/connection-config.test.ts @@ -0,0 +1,79 @@ +/** + * Unit tests for toConnectionConfig — the mapping from persisted + * ConnectionData to the dialog form's ConnectionConfig. + * + * Covers the proxy round-trip: saved proxy settings must be carried into + * the edit dialog, and legacy connections without proxy must not show a + * phantom proxy. + */ +import { describe, expect, it } from 'vitest'; +import { toConnectionConfig } from '../lib/connection-config'; + +describe('toConnectionConfig', () => { + const base = { + id: 'conn-1', + name: 'My Server', + host: '192.168.1.1', + port: 22, + username: 'admin', + protocol: 'SSH', + authMethod: 'password' as const, + password: 'secret', + }; + + it('maps all proxy fields from storage', () => { + const config = toConnectionConfig({ + ...base, + proxyType: 'http' as const, + proxyHost: 'proxy.example.com', + proxyPort: 3128, + proxyUsername: 'proxyuser', + proxyPassword: 'proxypass', + }); + + expect(config.proxyType).toBe('http'); + expect(config.proxyHost).toBe('proxy.example.com'); + expect(config.proxyPort).toBe(3128); + expect(config.proxyUsername).toBe('proxyuser'); + expect(config.proxyPassword).toBe('proxypass'); + }); + + it('defaults proxyType to none when storage has no proxy', () => { + const config = toConnectionConfig(base); + + expect(config.proxyType).toBe('none'); + expect(config.proxyHost).toBeUndefined(); + expect(config.proxyUsername).toBeUndefined(); + }); + + it('defaults proxyPort to 8080 when storage omits it', () => { + const config = toConnectionConfig({ + ...base, + proxyType: 'socks5' as const, + proxyHost: 'socks.example.com', + }); + + expect(config.proxyType).toBe('socks5'); + expect(config.proxyPort).toBe(8080); + }); + + it('carries basic fields and auth method default', () => { + const config = toConnectionConfig({ + ...base, + authMethod: 'publickey' as const, + privateKeyPath: '/home/user/.ssh/id_ed25519', + }); + + expect(config.id).toBe('conn-1'); + expect(config.name).toBe('My Server'); + expect(config.host).toBe('192.168.1.1'); + expect(config.authMethod).toBe('publickey'); + expect(config.privateKeyPath).toBe('/home/user/.ssh/id_ed25519'); + }); + + it('falls back to password auth when storage omits authMethod', () => { + const config = toConnectionConfig({ ...base, authMethod: undefined }); + + expect(config.authMethod).toBe('password'); + }); +}); diff --git a/src/__tests__/connection-dialog-proxy.test.tsx b/src/__tests__/connection-dialog-proxy.test.tsx new file mode 100644 index 00000000..423632a2 --- /dev/null +++ b/src/__tests__/connection-dialog-proxy.test.tsx @@ -0,0 +1,168 @@ +/** + * Regression tests: proxy config must survive a failed connection attempt. + * + * Bug: a connection configured with a proxy lost its proxy settings after + * a failed connect — the Edit dialog showed empty proxy fields because the + * proxy was never persisted to connection storage. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { ConnectionDialog } from '../components/connection-dialog'; +import { ConnectionStorageManager } from '../lib/connection-storage'; + +// jsdom lacks Element.prototype.scrollIntoView, which Radix Select calls +// when opening its popover. Polyfill so the proxy type Select can be opened. +beforeAll(() => { + Element.prototype.scrollIntoView = () => {}; +}); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})); + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), info: vi.fn(), success: vi.fn() }, +})); + +vi.mock('../lib/connection-profiles', () => ({ + ConnectionProfileManager: { + getProfiles: vi.fn(() => []), + }, +})); + +import { invoke } from '@tauri-apps/api/core'; + +const mockInvoke = invoke as ReturnType; + +const proxyConfig = { + proxyType: 'http' as const, + proxyHost: 'proxy.example.com', + proxyPort: 3128, + proxyUsername: 'proxyuser', + proxyPassword: 'proxypass', +}; + +const baseConnection = { + id: 'conn-1', + name: 'My Server', + host: '192.168.1.1', + port: 22, + username: 'admin', + protocol: 'SSH' as const, + authMethod: 'password' as const, + password: 'secret', +}; + +function renderDialog(overrides: Partial> = {}) { + return render( + , + ); +} + +/** Radix Tabs triggers activate on mousedown, not click. */ +function activateTab(name: string) { + fireEvent.mouseDown(screen.getByRole('tab', { name })); +} + +beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); +}); + +describe('ConnectionDialog proxy persistence', () => { + it('persists proxy config when the edited connection is saved', () => { + // Seed a connection without proxy (realistic: proxy was never stored before) + ConnectionStorageManager.saveConnectionWithId('conn-1', baseConnection); + + renderDialog({ + editingConnection: { ...baseConnection, ...proxyConfig }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + const stored = ConnectionStorageManager.getConnection('conn-1'); + expect(stored?.proxyType).toBe('http'); + expect(stored?.proxyHost).toBe('proxy.example.com'); + expect(stored?.proxyPort).toBe(3128); + expect(stored?.proxyUsername).toBe('proxyuser'); + expect(stored?.proxyPassword).toBe('proxypass'); + }); + + it('keeps proxy config when a new connection fails to connect', async () => { + mockInvoke.mockResolvedValueOnce({ success: false, error: 'connection refused' }); + + renderDialog(); + + // Connection tab: fill the required SSH fields + fireEvent.change(screen.getByLabelText('Connection Name'), { + target: { value: 'My Server' }, + }); + fireEvent.change(screen.getByLabelText('Host'), { + target: { value: '192.168.1.1' }, + }); + fireEvent.change(screen.getByLabelText('Username'), { + target: { value: 'admin' }, + }); + + // Auth tab: fill password + activateTab('Auth'); + fireEvent.change(screen.getByLabelText('Password'), { + target: { value: 'secret' }, + }); + + // Proxy tab: choose HTTP proxy and fill the fields + activateTab('Proxy'); + // First combobox in the proxy tab is the proxy type select + // (second is the folder select in the footer). + const combos = screen.getAllByRole('combobox'); + combos[0].focus(); + fireEvent.keyDown(combos[0], { key: 'ArrowDown' }); + fireEvent.click(await screen.findByText('HTTP Proxy')); + fireEvent.change(screen.getByLabelText('Proxy Host'), { + target: { value: 'proxy.example.com' }, + }); + fireEvent.change(screen.getByLabelText('Proxy Port'), { + target: { value: '3128' }, + }); + fireEvent.change(screen.getByLabelText('Proxy Username'), { + target: { value: 'proxyuser' }, + }); + fireEvent.change(screen.getByLabelText('Proxy Password'), { + target: { value: 'proxypass' }, + }); + + // Connect — invoke fails with "connection refused" + fireEvent.click(screen.getByRole('button', { name: 'Connect' })); + + await vi.waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('ssh_connect', expect.anything()); + }); + + const connections = ConnectionStorageManager.getConnections(); + expect(connections).toHaveLength(1); + expect(connections[0].proxyType).toBe('http'); + expect(connections[0].proxyHost).toBe('proxy.example.com'); + expect(connections[0].proxyPort).toBe(3128); + expect(connections[0].proxyUsername).toBe('proxyuser'); + expect(connections[0].proxyPassword).toBe('proxypass'); + }); + + it('shows saved proxy values in the proxy tab when editing', () => { + renderDialog({ + editingConnection: { ...baseConnection, ...proxyConfig }, + }); + + activateTab('Proxy'); + + expect((screen.getByLabelText('Proxy Host') as HTMLInputElement).value).toBe('proxy.example.com'); + expect((screen.getByLabelText('Proxy Port') as HTMLInputElement).value).toBe('3128'); + expect((screen.getByLabelText('Proxy Username') as HTMLInputElement).value).toBe('proxyuser'); + expect((screen.getByLabelText('Proxy Password') as HTMLInputElement).value).toBe('proxypass'); + }); +}); diff --git a/src/__tests__/connection-storage-proxy.test.ts b/src/__tests__/connection-storage-proxy.test.ts new file mode 100644 index 00000000..d88988ab --- /dev/null +++ b/src/__tests__/connection-storage-proxy.test.ts @@ -0,0 +1,86 @@ +/** + * Regression tests: proxy settings must round-trip through connection + * storage so a proxy configured on a connection survives connection + * attempts and reappears when the connection is edited. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { ConnectionStorageManager, type ConnectionData } from '../lib/connection-storage'; + +const proxyFields: Partial = { + proxyType: 'http', + proxyHost: 'proxy.example.com', + proxyPort: 3128, + proxyUsername: 'proxyuser', + proxyPassword: 'proxypass', +}; + +const baseConnection: Omit = { + name: 'My Server', + host: '192.168.1.1', + port: 22, + username: 'admin', + protocol: 'SSH', + authMethod: 'password', + password: 'secret', +}; + +beforeEach(() => { + localStorage.clear(); + ConnectionStorageManager.initialize(); +}); + +describe('connection-storage proxy round-trip', () => { + it('saveConnection persists proxy fields', () => { + const conn = ConnectionStorageManager.saveConnection({ + ...baseConnection, + ...proxyFields, + }); + + const loaded = ConnectionStorageManager.getConnection(conn.id); + expect(loaded?.proxyType).toBe('http'); + expect(loaded?.proxyHost).toBe('proxy.example.com'); + expect(loaded?.proxyPort).toBe(3128); + expect(loaded?.proxyUsername).toBe('proxyuser'); + expect(loaded?.proxyPassword).toBe('proxypass'); + }); + + it('saveConnectionWithId persists proxy fields', () => { + const conn = ConnectionStorageManager.saveConnectionWithId('conn-proxy-1', { + ...baseConnection, + ...proxyFields, + }); + + const loaded = ConnectionStorageManager.getConnection('conn-proxy-1'); + expect(loaded?.proxyType).toBe('http'); + expect(loaded?.proxyHost).toBe('proxy.example.com'); + expect(loaded?.proxyPort).toBe(3128); + expect(loaded?.proxyUsername).toBe('proxyuser'); + expect(loaded?.proxyPassword).toBe('proxypass'); + expect(conn.id).toBe('conn-proxy-1'); + }); + + it('updateConnection merges proxy fields without dropping existing ones', () => { + // Seed without proxy, then persist proxy via updateConnection (the flow + // taken by the dialog's save / connect-with-failure paths). + const conn = ConnectionStorageManager.saveConnection(baseConnection); + ConnectionStorageManager.updateConnection(conn.id, proxyFields); + + const loaded = ConnectionStorageManager.getConnection(conn.id); + expect(loaded?.proxyType).toBe('http'); + expect(loaded?.proxyHost).toBe('proxy.example.com'); + expect(loaded?.proxyPort).toBe(3128); + expect(loaded?.proxyUsername).toBe('proxyuser'); + expect(loaded?.proxyPassword).toBe('proxypass'); + // Existing non-proxy fields are preserved by the merge + expect(loaded?.name).toBe('My Server'); + expect(loaded?.password).toBe('secret'); + }); + + it('connections without proxy keep proxyType undefined in storage', () => { + const conn = ConnectionStorageManager.saveConnection(baseConnection); + + const loaded = ConnectionStorageManager.getConnection(conn.id); + expect(loaded?.proxyType).toBeUndefined(); + expect(loaded?.proxyHost).toBeUndefined(); + }); +}); diff --git a/src/components/connection-dialog.tsx b/src/components/connection-dialog.tsx index 8feaa556..051fe041 100644 --- a/src/components/connection-dialog.tsx +++ b/src/components/connection-dialog.tsx @@ -167,7 +167,10 @@ export function ConnectionDialog({ if (editingConnection) { setConfig({ ...defaultConfig, - ...editingConnection + ...editingConnection, + // Normalize proxyType so legacy connections without proxy settings + // don't show the proxy fields as if a proxy were configured. + proxyType: editingConnection.proxyType ?? 'none', }); syncDisplayValues({ port: editingConnection.port ?? 22, @@ -308,6 +311,11 @@ export function ConnectionDialog({ domain: config.domain, rdpResolution: config.rdpResolution, vncColorDepth: config.vncColorDepth, + proxyType: config.proxyType, + proxyHost: config.proxyHost, + proxyPort: config.proxyPort, + proxyUsername: config.proxyUsername, + proxyPassword: config.proxyPassword, lastConnected: new Date().toISOString(), }); } else if (saveAsConnection) { @@ -326,6 +334,11 @@ export function ConnectionDialog({ domain: config.domain, rdpResolution: config.rdpResolution, vncColorDepth: config.vncColorDepth, + proxyType: config.proxyType, + proxyHost: config.proxyHost, + proxyPort: config.proxyPort, + proxyUsername: config.proxyUsername, + proxyPassword: config.proxyPassword, }); } @@ -342,97 +355,104 @@ export function ConnectionDialog({ return; } - // SSH / Telnet / Raw / Serial — connect via ssh_connect - // Save connection config FIRST (consistent with SFTP/FTP/Desktop), - // so the config is preserved even if the remote server is temporarily unreachable. - let connectionSaved = false; - if (editingConnection?.id) { - ConnectionStorageManager.updateConnection(editingConnection.id, { - name: config.name, - host: config.host, - port: config.port || 22, - username: config.username, - protocol: config.protocol, - authMethod: config.authMethod, - password: config.password, - privateKeyPath: config.privateKeyPath, - passphrase: config.passphrase, - lastConnected: new Date().toISOString(), - }); - connectionSaved = true; - } else if (saveAsConnection) { - ConnectionStorageManager.saveConnectionWithId(connectionId, { - name: config.name, - host: config.host, - port: config.port || 22, - username: config.username, - protocol: config.protocol, - folder: connectionFolder, - authMethod: config.authMethod, - password: config.password, - privateKeyPath: config.privateKeyPath, - passphrase: config.passphrase, - }); - connectionSaved = true; - } - - try { - const result = await invoke<{ success: boolean; error?: string }>( - 'ssh_connect', - { - request: { - connection_id: connectionId, - host: config.host, - port: config.port || 22, - username: config.username, - auth_method: config.authMethod || 'password', - password: config.password || '', - key_path: config.privateKeyPath || null, - passphrase: config.passphrase || null, - } - } - ); - - if (result.success) { - onConnect({ - ...config, - id: connectionId - }); - if (!editingConnection) { - setConfig(defaultConfig); - } - } else { - // Connection failed — config was already saved above, user can retry from sidebar - console.error('Connection failed:', result.error); - if (cancelRequestedRef.current && result.error?.toLowerCase().includes('cancelled')) { - toast.info(t('connectionDialog.toast.connectionCancelled')); - } else { - toast.error(t('connectionDialog.toast.connectionFailed'), { - description: result.error || t('connectionDialog.toast.connectionFailedDesc'), - duration: 5000, - }); - } - } - } catch (error) { - console.error('Connection error:', error); - if (cancelRequestedRef.current) { - toast.info(t('connectionDialog.toast.connectionCancelled')); - } else { - toast.error(t('connectionDialog.toast.connectionError'), { - description: error instanceof Error ? error.message : t('connectionDialog.toast.connectionErrorDesc'), - duration: 5000, - }); - } - } finally { - // Close dialog — config is already saved if connectionSaved is true - onOpenChange(false); - if (!editingConnection) { - setConfig(defaultConfig); - } - resetConnectionState(); - } - - } + // SSH / Telnet / Raw / Serial — connect via ssh_connect + // Save connection config FIRST (consistent with SFTP/FTP/Desktop), + // so the config is preserved even if the remote server is temporarily unreachable. + if (editingConnection?.id) { + ConnectionStorageManager.updateConnection(editingConnection.id, { + name: config.name, + host: config.host, + port: config.port || 22, + username: config.username, + protocol: config.protocol, + authMethod: config.authMethod, + password: config.password, + privateKeyPath: config.privateKeyPath, + passphrase: config.passphrase, + proxyType: config.proxyType, + proxyHost: config.proxyHost, + proxyPort: config.proxyPort, + proxyUsername: config.proxyUsername, + proxyPassword: config.proxyPassword, + lastConnected: new Date().toISOString(), + }); + } else if (saveAsConnection) { + ConnectionStorageManager.saveConnectionWithId(connectionId, { + name: config.name, + host: config.host, + port: config.port || 22, + username: config.username, + protocol: config.protocol, + folder: connectionFolder, + authMethod: config.authMethod, + password: config.password, + privateKeyPath: config.privateKeyPath, + passphrase: config.passphrase, + proxyType: config.proxyType, + proxyHost: config.proxyHost, + proxyPort: config.proxyPort, + proxyUsername: config.proxyUsername, + proxyPassword: config.proxyPassword, + }); + } + + try { + const result = await invoke<{ success: boolean; error?: string }>( + 'ssh_connect', + { + request: { + connection_id: connectionId, + host: config.host, + port: config.port || 22, + username: config.username, + auth_method: config.authMethod || 'password', + password: config.password || '', + key_path: config.privateKeyPath || null, + passphrase: config.passphrase || null, + } + } + ); + + if (result.success) { + onConnect({ + ...config, + id: connectionId + }); + if (!editingConnection) { + setConfig(defaultConfig); + } + } else { + // Connection failed — config was already saved above, user can retry from sidebar + console.error('Connection failed:', result.error); + if (cancelRequestedRef.current && result.error?.toLowerCase().includes('cancelled')) { + toast.info(t('connectionDialog.toast.connectionCancelled')); + } else { + toast.error(t('connectionDialog.toast.connectionFailed'), { + description: result.error || t('connectionDialog.toast.connectionFailedDesc'), + duration: 5000, + }); + } + } + } catch (error) { + console.error('Connection error:', error); + if (cancelRequestedRef.current) { + toast.info(t('connectionDialog.toast.connectionCancelled')); + } else { + toast.error(t('connectionDialog.toast.connectionError'), { + description: error instanceof Error ? error.message : t('connectionDialog.toast.connectionErrorDesc'), + duration: 5000, + }); + } + } finally { + // Close dialog — config is always saved above + onOpenChange(false); + if (!editingConnection) { + setConfig(defaultConfig); + } + resetConnectionState(); + } + + } const handleCancelConnectionAttempt = async () => { if (!isConnecting) { @@ -489,6 +509,11 @@ const handleCancelConnectionAttempt = async () => { domain: config.domain, rdpResolution: config.rdpResolution, vncColorDepth: config.vncColorDepth, + proxyType: config.proxyType, + proxyHost: config.proxyHost, + proxyPort: config.proxyPort, + proxyUsername: config.proxyUsername, + proxyPassword: config.proxyPassword, }); // Notify parent to update tab display info (e.g. tab title) @@ -906,6 +931,7 @@ const handleCancelConnectionAttempt = async () => { updateConfig({ proxyUsername: e.target.value })} /> diff --git a/src/components/connection-manager.tsx b/src/components/connection-manager.tsx index 2e000d05..de2c57e5 100644 --- a/src/components/connection-manager.tsx +++ b/src/components/connection-manager.tsx @@ -170,6 +170,12 @@ export function ConnectionManager({ password: connectionData.password, privateKeyPath: connectionData.privateKeyPath, passphrase: connectionData.passphrase, + // Copy proxy settings + proxyType: connectionData.proxyType, + proxyHost: connectionData.proxyHost, + proxyPort: connectionData.proxyPort, + proxyUsername: connectionData.proxyUsername, + proxyPassword: connectionData.proxyPassword, }); setConnections(loadConnections()); toast.success(t('connectionManager.duplicated', { name: duplicated.name })); diff --git a/src/lib/connection-config.ts b/src/lib/connection-config.ts new file mode 100644 index 00000000..a3e2751b --- /dev/null +++ b/src/lib/connection-config.ts @@ -0,0 +1,37 @@ +/** + * Mapping helpers between the persisted connection model (ConnectionData) + * and the dialog form model (ConnectionConfig). + */ +import type { ConnectionData } from './connection-storage'; +import type { ConnectionConfig } from '../components/connection-dialog'; + +/** + * Build a ConnectionConfig from a persisted ConnectionData. + * + * Carries every field the edit dialog can display — including the proxy + * settings — so that saved proxy config survives a round-trip through + * storage and is shown again when the connection is edited. + */ +export function toConnectionConfig(data: ConnectionData): ConnectionConfig { + return { + id: data.id, + name: data.name, + protocol: data.protocol as ConnectionConfig['protocol'], + host: data.host, + port: data.port, + username: data.username, + authMethod: data.authMethod || 'password', + password: data.password, + privateKeyPath: data.privateKeyPath, + passphrase: data.passphrase, + ftpsEnabled: data.ftpsEnabled, + domain: data.domain, + rdpResolution: data.rdpResolution as ConnectionConfig['rdpResolution'], + vncColorDepth: data.vncColorDepth as ConnectionConfig['vncColorDepth'], + proxyType: data.proxyType ?? 'none', + proxyHost: data.proxyHost, + proxyPort: data.proxyPort ?? 8080, + proxyUsername: data.proxyUsername, + proxyPassword: data.proxyPassword, + }; +} diff --git a/src/lib/connection-storage.ts b/src/lib/connection-storage.ts index ca105178..14beb4a1 100644 --- a/src/lib/connection-storage.ts +++ b/src/lib/connection-storage.ts @@ -31,6 +31,12 @@ export interface ConnectionData { // VNC-specific vncColorDepth?: string; vncPassword?: string; + // Proxy settings + proxyType?: 'none' | 'http' | 'socks4' | 'socks5'; + proxyHost?: string; + proxyPort?: number; + proxyUsername?: string; + proxyPassword?: string; // Ordering sortOrder?: number; }