From 9d815ac8c4020881cf05b7dad9f77acbd58d65a1 Mon Sep 17 00:00:00 2001
From: sunxiaobin89 <285584806@qq.com>
Date: Fri, 31 Jul 2026 15:13:17 +0800
Subject: [PATCH 1/2] fix(connections): persist advanced SSH and proxy options
across save/edit
- Add proxy and advanced SSH fields to ConnectionData so they survive
storage round trips
- Include these fields in every save path in ConnectionDialog (edit save,
new connect, SSH/SFTP/FTP/Desktop branches)
- Add toConnectionConfig helper in App.tsx to carry the fields when
opening the edit dialog, replacing 9 duplicated ConnectionConfig builds
- Merge undefined fields back to defaults when editing legacy connections
saved before these fields were persisted, so the Advanced/Proxy tabs
show the same pre-filled values as new connections
Test: 516 tests pass, 6 new tests cover persistence and default fallback
---
src/App.tsx | 168 +++++-------------
.../connection-dialog-advanced-save.test.tsx | 141 +++++++++++++++
...connection-storage-advanced-fields.test.ts | 92 ++++++++++
src/components/connection-dialog.tsx | 72 +++++++-
src/lib/connection-storage.ts | 11 ++
5 files changed, 354 insertions(+), 130 deletions(-)
create mode 100644 src/__tests__/connection-dialog-advanced-save.test.tsx
create mode 100644 src/__tests__/connection-storage-advanced-fields.test.ts
diff --git a/src/App.tsx b/src/App.tsx
index caf54c71..9252b27a 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -13,7 +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 { ActiveConnectionsManager, ConnectionStorageManager } from './lib/connection-storage';
+import { ActiveConnectionsManager, ConnectionStorageManager, type ConnectionData } from './lib/connection-storage';
import { isDesktopProtocol } from './lib/protocol-config';
import { registerRestoration, clearAllRestorations } from './lib/restoration-manager';
import { useLayout, LayoutProvider } from './lib/layout-context';
@@ -37,6 +37,39 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from './componen
import { Tabs, TabsContent, TabsList, TabsTrigger } from './components/ui/tabs';
import { History, ShieldCheck, PlugZap, Activity, Loader2 } from 'lucide-react';
+/**
+ * Build a ConnectionConfig from stored ConnectionData, carrying over all
+ * optional fields (proxy, advanced SSH options, desktop protocol settings).
+ * Keeps editing dialog state in sync with what was persisted.
+ */
+function toConnectionConfig(connectionData: ConnectionData): ConnectionConfig {
+ return {
+ 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,
+ proxyType: connectionData.proxyType,
+ proxyHost: connectionData.proxyHost,
+ proxyPort: connectionData.proxyPort,
+ proxyUsername: connectionData.proxyUsername,
+ proxyPassword: connectionData.proxyPassword,
+ compression: connectionData.compression,
+ keepAlive: connectionData.keepAlive,
+ keepAliveInterval: connectionData.keepAliveInterval,
+ serverAliveCountMax: connectionData.serverAliveCountMax,
+ domain: connectionData.domain,
+ rdpResolution: connectionData.rdpResolution as ConnectionConfig['rdpResolution'],
+ vncColorDepth: connectionData.vncColorDepth as ConnectionConfig['vncColorDepth'],
+ };
+}
+
interface ConnectionNode {
id: string;
name: string;
@@ -506,22 +539,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 +649,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 +659,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 +869,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 +1277,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 +1508,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 +1516,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 +1543,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 +1555,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-dialog-advanced-save.test.tsx b/src/__tests__/connection-dialog-advanced-save.test.tsx
new file mode 100644
index 00000000..2a85a972
--- /dev/null
+++ b/src/__tests__/connection-dialog-advanced-save.test.tsx
@@ -0,0 +1,141 @@
+/**
+ * Regression tests: advanced (SSH) and proxy tab edits must be persisted when
+ * the user clicks Save in the edit-connection dialog. Previously these fields
+ * were updated in local component state but never written to storage.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { ConnectionDialog, type ConnectionConfig } from '../components/connection-dialog';
+import { ConnectionStorageManager } from '../lib/connection-storage';
+
+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-storage', () => ({
+ ConnectionStorageManager: {
+ getValidFolders: vi.fn(() => [{ path: 'All Connections' }]),
+ updateConnection: vi.fn(() => null),
+ },
+}));
+
+vi.mock('../lib/connection-profiles', () => ({
+ ConnectionProfileManager: {
+ getProfiles: vi.fn(() => []),
+ },
+}));
+
+const baseConnection: ConnectionConfig = {
+ id: 'conn-1',
+ name: 'My Server',
+ host: 'example.com',
+ port: 22,
+ username: 'root',
+ protocol: 'SSH',
+ authMethod: 'password',
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('ConnectionDialog advanced tab save', () => {
+ it('persists compression and keepAliveInterval edits on save', async () => {
+ render(
+ ,
+ );
+
+ // Switch to the Advanced tab (Radix Tabs activates on mouseDown)
+ fireEvent.mouseDown(screen.getByRole('tab', { name: 'Advanced' }), { button: 0 });
+
+ // compression switch renders first, defaults to ON → click to turn OFF
+ const switches = await screen.findAllByRole('switch');
+ expect(switches.length).toBeGreaterThanOrEqual(2);
+ fireEvent.click(switches[0]); // compression → false
+
+ // keepAlive interval input (visible because keepAlive defaults to ON)
+ const intervalInput = screen.getByLabelText('Interval (seconds)');
+ fireEvent.change(intervalInput, { target: { value: '30' } });
+
+ // Save
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }));
+
+ expect(ConnectionStorageManager.updateConnection).toHaveBeenCalledWith(
+ 'conn-1',
+ expect.objectContaining({
+ compression: false,
+ keepAlive: true,
+ keepAliveInterval: 30,
+ serverAliveCountMax: 3,
+ }),
+ );
+ });
+
+ it('shows default advanced values for legacy connections missing them', async () => {
+ // A connection saved before advanced/proxy fields were persisted has no
+ // such values — the edit dialog should fall back to the new-connection
+ // defaults instead of showing blank controls.
+ render(
+ ,
+ );
+
+ fireEvent.mouseDown(screen.getByRole('tab', { name: 'Advanced' }), { button: 0 });
+
+ const switches = await screen.findAllByRole('switch');
+ // compression (index 0) and keepAlive (index 1) both default to ON
+ expect(switches[0].getAttribute('data-state')).toBe('checked');
+ expect(switches[1].getAttribute('data-state')).toBe('checked');
+
+ // numeric inputs default to 60 / 3
+ expect((screen.getByLabelText('Interval (seconds)') as HTMLInputElement).value).toBe('60');
+ expect((screen.getByLabelText('Max Count') as HTMLInputElement).value).toBe('3');
+ });
+
+ it('persists existing proxy config unchanged when saving', async () => {
+ const editingWithProxy: ConnectionConfig = {
+ ...baseConnection,
+ id: 'conn-2',
+ proxyType: 'socks5',
+ proxyHost: 'proxy.example.com',
+ proxyPort: 1080,
+ proxyUsername: 'user',
+ proxyPassword: 'pass',
+ };
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }));
+
+ expect(ConnectionStorageManager.updateConnection).toHaveBeenCalledWith(
+ 'conn-2',
+ expect.objectContaining({
+ proxyType: 'socks5',
+ proxyHost: 'proxy.example.com',
+ proxyPort: 1080,
+ proxyUsername: 'user',
+ proxyPassword: 'pass',
+ }),
+ );
+ });
+});
diff --git a/src/__tests__/connection-storage-advanced-fields.test.ts b/src/__tests__/connection-storage-advanced-fields.test.ts
new file mode 100644
index 00000000..6ced8648
--- /dev/null
+++ b/src/__tests__/connection-storage-advanced-fields.test.ts
@@ -0,0 +1,92 @@
+/**
+ * Tests for advanced (SSH) and proxy field persistence in connection storage.
+ * Verifies that fields edited in the dialog's Advanced / Proxy tabs survive a
+ * save → reload round trip (regression: they were previously dropped).
+ */
+import { describe, it, expect, beforeEach } from 'vitest';
+import { ConnectionStorageManager } from '../lib/connection-storage';
+
+beforeEach(() => {
+ localStorage.clear();
+ ConnectionStorageManager.initialize();
+});
+
+describe('connection-storage advanced & proxy field persistence', () => {
+ it('round-trips proxy fields through saveConnectionWithId', () => {
+ ConnectionStorageManager.saveConnectionWithId('conn-1', {
+ name: 'My Server',
+ host: 'example.com',
+ port: 22,
+ username: 'root',
+ protocol: 'SSH',
+ authMethod: 'password',
+ proxyType: 'socks5',
+ proxyHost: 'proxy.example.com',
+ proxyPort: 1080,
+ proxyUsername: 'user',
+ proxyPassword: 'pass',
+ });
+
+ const loaded = ConnectionStorageManager.getConnection('conn-1');
+ expect(loaded?.proxyType).toBe('socks5');
+ expect(loaded?.proxyHost).toBe('proxy.example.com');
+ expect(loaded?.proxyPort).toBe(1080);
+ expect(loaded?.proxyUsername).toBe('user');
+ expect(loaded?.proxyPassword).toBe('pass');
+ });
+
+ it('round-trips advanced SSH fields through saveConnectionWithId', () => {
+ ConnectionStorageManager.saveConnectionWithId('conn-2', {
+ name: 'My Server',
+ host: 'example.com',
+ port: 22,
+ username: 'root',
+ protocol: 'SSH',
+ authMethod: 'password',
+ compression: false,
+ keepAlive: false,
+ keepAliveInterval: 45,
+ serverAliveCountMax: 5,
+ });
+
+ const loaded = ConnectionStorageManager.getConnection('conn-2');
+ expect(loaded?.compression).toBe(false);
+ expect(loaded?.keepAlive).toBe(false);
+ expect(loaded?.keepAliveInterval).toBe(45);
+ expect(loaded?.serverAliveCountMax).toBe(5);
+ });
+
+ it('updateConnection preserves advanced & proxy fields', () => {
+ ConnectionStorageManager.saveConnectionWithId('conn-3', {
+ name: 'My Server',
+ host: 'example.com',
+ port: 22,
+ username: 'root',
+ protocol: 'SSH',
+ authMethod: 'password',
+ });
+
+ ConnectionStorageManager.updateConnection('conn-3', {
+ compression: true,
+ keepAlive: false,
+ keepAliveInterval: 30,
+ serverAliveCountMax: 4,
+ proxyType: 'http',
+ proxyHost: 'proxy.example.com',
+ proxyPort: 3128,
+ proxyUsername: 'user',
+ proxyPassword: 'pass',
+ });
+
+ const loaded = ConnectionStorageManager.getConnection('conn-3');
+ expect(loaded?.compression).toBe(true);
+ expect(loaded?.keepAlive).toBe(false);
+ expect(loaded?.keepAliveInterval).toBe(30);
+ expect(loaded?.serverAliveCountMax).toBe(4);
+ expect(loaded?.proxyType).toBe('http');
+ expect(loaded?.proxyHost).toBe('proxy.example.com');
+ expect(loaded?.proxyPort).toBe(3128);
+ expect(loaded?.proxyUsername).toBe('user');
+ expect(loaded?.proxyPassword).toBe('pass');
+ });
+});
diff --git a/src/components/connection-dialog.tsx b/src/components/connection-dialog.tsx
index 8feaa556..ab3919d0 100644
--- a/src/components/connection-dialog.tsx
+++ b/src/components/connection-dialog.tsx
@@ -70,6 +70,23 @@ export interface ConnectionConfig {
vncColorDepth?: '24' | '16' | '8';
}
+/**
+ * Merge form overrides on top of defaults, falling back to the default when a
+ * field is `undefined`. Historical connections saved before advanced/proxy
+ * fields were persisted have no such values — without this fallback their edit
+ * dialog would show blank controls instead of the defaults used for new ones.
+ */
+function mergeWithDefaults(defaults: ConnectionConfig, overrides: ConnectionConfig): ConnectionConfig {
+ const merged: ConnectionConfig = { ...defaults, ...overrides };
+ const mergedRecord = merged as unknown as Record;
+ for (const key of Object.keys(defaults) as Array) {
+ if (mergedRecord[key] === undefined) {
+ mergedRecord[key] = defaults[key];
+ }
+ }
+ return merged;
+}
+
export function ConnectionDialog({
open,
onOpenChange,
@@ -163,12 +180,12 @@ export function ConnectionDialog({
setConnectionFolder(initialFolder);
}
- // Load editing connection data into config when dialog opens
+ // Load editing connection data into config when dialog opens.
+ // mergeWithDefaults falls back to defaultConfig for fields a historical
+ // connection never stored (advanced/proxy options), matching the
+ // pre-filled values a new connection gets.
if (editingConnection) {
- setConfig({
- ...defaultConfig,
- ...editingConnection
- });
+ setConfig(mergeWithDefaults(defaultConfig, editingConnection));
syncDisplayValues({
port: editingConnection.port ?? 22,
proxyPort: editingConnection.proxyPort ?? 8080,
@@ -305,6 +322,15 @@ export function ConnectionDialog({
privateKeyPath: config.privateKeyPath,
passphrase: config.passphrase,
ftpsEnabled: config.ftpsEnabled,
+ proxyType: config.proxyType,
+ proxyHost: config.proxyHost,
+ proxyPort: config.proxyPort,
+ proxyUsername: config.proxyUsername,
+ proxyPassword: config.proxyPassword,
+ compression: config.compression,
+ keepAlive: config.keepAlive,
+ keepAliveInterval: config.keepAliveInterval,
+ serverAliveCountMax: config.serverAliveCountMax,
domain: config.domain,
rdpResolution: config.rdpResolution,
vncColorDepth: config.vncColorDepth,
@@ -323,6 +349,15 @@ export function ConnectionDialog({
privateKeyPath: config.privateKeyPath,
passphrase: config.passphrase,
ftpsEnabled: config.ftpsEnabled,
+ proxyType: config.proxyType,
+ proxyHost: config.proxyHost,
+ proxyPort: config.proxyPort,
+ proxyUsername: config.proxyUsername,
+ proxyPassword: config.proxyPassword,
+ compression: config.compression,
+ keepAlive: config.keepAlive,
+ keepAliveInterval: config.keepAliveInterval,
+ serverAliveCountMax: config.serverAliveCountMax,
domain: config.domain,
rdpResolution: config.rdpResolution,
vncColorDepth: config.vncColorDepth,
@@ -357,6 +392,15 @@ export function ConnectionDialog({
password: config.password,
privateKeyPath: config.privateKeyPath,
passphrase: config.passphrase,
+ proxyType: config.proxyType,
+ proxyHost: config.proxyHost,
+ proxyPort: config.proxyPort,
+ proxyUsername: config.proxyUsername,
+ proxyPassword: config.proxyPassword,
+ compression: config.compression,
+ keepAlive: config.keepAlive,
+ keepAliveInterval: config.keepAliveInterval,
+ serverAliveCountMax: config.serverAliveCountMax,
lastConnected: new Date().toISOString(),
});
connectionSaved = true;
@@ -372,6 +416,15 @@ export function ConnectionDialog({
password: config.password,
privateKeyPath: config.privateKeyPath,
passphrase: config.passphrase,
+ proxyType: config.proxyType,
+ proxyHost: config.proxyHost,
+ proxyPort: config.proxyPort,
+ proxyUsername: config.proxyUsername,
+ proxyPassword: config.proxyPassword,
+ compression: config.compression,
+ keepAlive: config.keepAlive,
+ keepAliveInterval: config.keepAliveInterval,
+ serverAliveCountMax: config.serverAliveCountMax,
});
connectionSaved = true;
}
@@ -486,6 +539,15 @@ const handleCancelConnectionAttempt = async () => {
privateKeyPath: config.privateKeyPath,
passphrase: config.passphrase,
ftpsEnabled: config.ftpsEnabled,
+ proxyType: config.proxyType,
+ proxyHost: config.proxyHost,
+ proxyPort: config.proxyPort,
+ proxyUsername: config.proxyUsername,
+ proxyPassword: config.proxyPassword,
+ compression: config.compression,
+ keepAlive: config.keepAlive,
+ keepAliveInterval: config.keepAliveInterval,
+ serverAliveCountMax: config.serverAliveCountMax,
domain: config.domain,
rdpResolution: config.rdpResolution,
vncColorDepth: config.vncColorDepth,
diff --git a/src/lib/connection-storage.ts b/src/lib/connection-storage.ts
index ca105178..7bf707fc 100644
--- a/src/lib/connection-storage.ts
+++ b/src/lib/connection-storage.ts
@@ -25,6 +25,17 @@ export interface ConnectionData {
passphrase?: string;
// FTP-specific
ftpsEnabled?: boolean;
+ // Proxy
+ proxyType?: 'none' | 'http' | 'socks4' | 'socks5';
+ proxyHost?: string;
+ proxyPort?: number;
+ proxyUsername?: string;
+ proxyPassword?: string;
+ // SSH-specific advanced
+ compression?: boolean;
+ keepAlive?: boolean;
+ keepAliveInterval?: number;
+ serverAliveCountMax?: number;
// RDP-specific
domain?: string;
rdpResolution?: string;
From c87d361e6630cead078e67ea07a3e714153311dc Mon Sep 17 00:00:00 2001
From: sunxiaobin89 <285584806@qq.com>
Date: Fri, 31 Jul 2026 18:05:16 +0800
Subject: [PATCH 2/2] fix(connections): apply advanced SSH and proxy settings
to connections
The Advanced (compression, keepalive) and Proxy tabs in the connection
dialog were persisted but never reached the backend, so they had no
effect on real SSH connections.
- Add keepalive interval/max, zlib compression negotiation, and
HTTP/SOCKS4/SOCKS5 proxy tunneling to the SSH backend
(new proxy.rs, SshConfig/ConnectRequest fields, SshClient::connect)
- Wire the stored advanced/proxy values through every ssh_connect call
via a shared buildSshConnectRequest helper, replacing 7 duplicated
request payloads
- Fix compression negotiation: advertise zlib before none so russh
actually negotiates zlib instead of always falling back to none
- Add proxy handshake tests (HTTP CONNECT, SOCKS4, SOCKS5 incl. auth and
error paths), compression preference tests, and edit round-trip
regression tests
Test: 526 frontend tests pass (10 new), 122 Rust tests pass (16 new)
---
src-tauri/src/commands.rs | 146 +++-
src-tauri/src/lib.rs | 1 +
src-tauri/src/proxy.rs | 641 ++++++++++++++++++
src-tauri/src/ssh/mod.rs | 73 +-
src-tauri/src/ssh/tests.rs | 58 ++
src/App.tsx | 67 +-
.../connection-dialog-edit-roundtrip.test.tsx | 143 ++++
src/__tests__/ssh-connect-request.test.ts | 118 ++++
src/components/connection-dialog.tsx | 19 +-
src/lib/ssh-connect-request.ts | 85 +++
10 files changed, 1265 insertions(+), 86 deletions(-)
create mode 100644 src-tauri/src/proxy.rs
create mode 100644 src/__tests__/connection-dialog-edit-roundtrip.test.tsx
create mode 100644 src/__tests__/ssh-connect-request.test.ts
create mode 100644 src/lib/ssh-connect-request.ts
diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs
index b6c17fd7..9ae02f2a 100644
--- a/src-tauri/src/commands.rs
+++ b/src-tauri/src/commands.rs
@@ -1,9 +1,9 @@
use crate::connection_manager::ConnectionManager;
use crate::ftp_client::FtpConfig;
use crate::os_detect::{self, OsInfo};
+use crate::proxy::{ProxyConfig, ProxyType};
use crate::sftp_client::{FileEntry, FileEntryType, SftpAuthMethod, SftpConfig};
use crate::ssh::{AuthMethod, SshConfig};
-use base64::Engine as _;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::State;
@@ -18,6 +18,18 @@ pub struct ConnectRequest {
pub password: Option,
pub key_path: Option,
pub passphrase: Option,
+ /// Advanced SSH options — `Option` so legacy callers that omit them keep
+ /// the previous defaults (compression on, keepalive 60 s / 3).
+ pub compression: Option,
+ pub keepalive_enabled: Option,
+ pub keepalive_interval: Option,
+ pub keepalive_max: Option,
+ /// Proxy options — ignored when `proxy_type` is "none" or missing.
+ pub proxy_type: Option,
+ pub proxy_host: Option,
+ pub proxy_port: Option,
+ pub proxy_username: Option,
+ pub proxy_password: Option,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -58,6 +70,21 @@ pub async fn ssh_connect(
request: ConnectRequest,
state: State<'_, Arc>,
) -> Result {
+ let proxy = build_proxy(&request)?;
+
+ // Keepalive defaults match the connection dialog UI: enabled at 60 s / 3.
+ let keepalive_enabled = request.keepalive_enabled.unwrap_or(true);
+ let keepalive_interval = if keepalive_enabled {
+ Some(request.keepalive_interval.unwrap_or(60))
+ } else {
+ None
+ };
+ let keepalive_max = if keepalive_enabled {
+ Some(request.keepalive_max.unwrap_or(3))
+ } else {
+ None
+ };
+
let auth_method = match request.auth_method.as_str() {
"password" => AuthMethod::Password {
password: request.password.ok_or("Password required")?,
@@ -74,6 +101,10 @@ pub async fn ssh_connect(
port: request.port,
username: request.username,
auth_method,
+ compression: request.compression.unwrap_or(true),
+ keepalive_interval,
+ keepalive_max,
+ proxy,
};
match state
@@ -93,6 +124,34 @@ pub async fn ssh_connect(
}
}
+/// Map the proxy request fields into a `ProxyConfig`, or `None` when the
+/// connection should go direct.
+fn build_proxy(request: &ConnectRequest) -> Result