Skip to content

Commit 5d587c2

Browse files
committed
fix: resolve SSH profile display on welcome screen (#454)
1 parent d740ca9 commit 5d587c2

File tree

7 files changed

+243
-29
lines changed

7 files changed

+243
-29
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717

1818
### Fixed
1919

20+
- SSH profile connections displaying incorrect host/username on the Welcome window home screen (#454)
2021
- Saved connections disappearing after normal app quit (Cmd+Q) while persisting after force quit (#452)
2122
- Crash when disconnecting an etcd connection while requests are in-flight
2223
- Detail pane showing truncated values for LONGTEXT/MEDIUMTEXT/CLOB columns, preventing correct editing

TablePro/Core/Database/DatabaseManager.swift

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -455,20 +455,10 @@ final class DatabaseManager {
455455
sshPasswordOverride: String? = nil
456456
) async throws -> DatabaseConnection {
457457
// Resolve SSH configuration: profile takes priority over inline
458-
let sshConfig: SSHConfiguration
459-
let isProfile: Bool
460-
let secretOwnerId: UUID
461-
462-
if let profileId = connection.sshProfileId,
463-
let profile = SSHProfileStorage.shared.profile(for: profileId) {
464-
sshConfig = profile.toSSHConfiguration()
465-
secretOwnerId = profileId
466-
isProfile = true
467-
} else {
468-
sshConfig = connection.sshConfig
469-
secretOwnerId = connection.id
470-
isProfile = false
471-
}
458+
let profile = connection.sshProfileId.flatMap { SSHProfileStorage.shared.profile(for: $0) }
459+
let sshConfig = connection.effectiveSSHConfig(profile: profile)
460+
let isProfile = connection.sshProfileId != nil && profile != nil
461+
let secretOwnerId = connection.sshProfileId.flatMap { profile != nil ? $0 : nil } ?? connection.id
472462

473463
guard sshConfig.enabled else {
474464
return connection

TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66
import Foundation
77

88
struct ConnectionURLFormatter {
9-
static func format(_ connection: DatabaseConnection, password: String?, sshPassword: String?) -> String {
9+
static func format(
10+
_ connection: DatabaseConnection,
11+
password: String?,
12+
sshPassword: String?,
13+
sshProfile: SSHProfile? = nil
14+
) -> String {
1015
let scheme = urlScheme(for: connection.type)
1116

1217
if connection.type == .sqlite {
@@ -17,8 +22,9 @@ struct ConnectionURLFormatter {
1722
return formatDuckDB(connection.database)
1823
}
1924

20-
if connection.sshConfig.enabled {
21-
return formatSSH(connection, scheme: scheme, password: password)
25+
let ssh = connection.effectiveSSHConfig(profile: sshProfile)
26+
if ssh.enabled {
27+
return formatSSH(connection, sshConfig: ssh, scheme: scheme, password: password)
2228
}
2329

2430
return formatStandard(connection, scheme: scheme, password: password)
@@ -47,12 +53,12 @@ struct ConnectionURLFormatter {
4753

4854
private static func formatSSH(
4955
_ connection: DatabaseConnection,
56+
sshConfig ssh: SSHConfiguration,
5057
scheme: String,
5158
password: String?
5259
) -> String {
5360
var result = "\(scheme)+ssh://"
5461

55-
let ssh = connection.sshConfig
5662
if !ssh.username.isEmpty {
5763
result += "\(percentEncodeUserinfo(ssh.username))@"
5864
}
@@ -81,7 +87,7 @@ struct ConnectionURLFormatter {
8187
: connection.database
8288
result += "/\(sshPathComponent)"
8389

84-
let query = buildQueryString(connection)
90+
let query = buildQueryString(connection, sshConfig: ssh)
8591
if !query.isEmpty {
8692
result += "?\(query)"
8793
}
@@ -122,7 +128,11 @@ struct ConnectionURLFormatter {
122128
return result
123129
}
124130

125-
private static func buildQueryString(_ connection: DatabaseConnection) -> String {
131+
private static func buildQueryString(
132+
_ connection: DatabaseConnection,
133+
sshConfig: SSHConfiguration? = nil
134+
) -> String {
135+
let ssh = sshConfig ?? connection.sshConfig
126136
var params: [String] = []
127137

128138
if !connection.name.isEmpty {
@@ -135,15 +145,15 @@ struct ConnectionURLFormatter {
135145
params.append("name=\(encoded)")
136146
}
137147

138-
if connection.sshConfig.enabled && connection.sshConfig.authMethod == .privateKey {
148+
if ssh.enabled && ssh.authMethod == .privateKey {
139149
params.append("usePrivateKey=true")
140150
}
141151

142-
if connection.sshConfig.enabled && connection.sshConfig.authMethod == .sshAgent {
152+
if ssh.enabled && ssh.authMethod == .sshAgent {
143153
params.append("useSSHAgent=true")
144-
if !connection.sshConfig.agentSocketPath.isEmpty {
145-
let encoded = connection.sshConfig.agentSocketPath
146-
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? connection.sshConfig.agentSocketPath
154+
if !ssh.agentSocketPath.isEmpty {
155+
let encoded = ssh.agentSocketPath
156+
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ssh.agentSocketPath
147157
params.append("agentSocket=\(encoded)")
148158
}
149159
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//
2+
// DatabaseConnection+SSH.swift
3+
// TablePro
4+
//
5+
6+
extension DatabaseConnection {
7+
/// Resolves the effective SSH configuration for this connection.
8+
/// When an SSH profile is referenced and provided, uses the profile's config.
9+
/// Otherwise falls back to the inline `sshConfig`.
10+
func effectiveSSHConfig(profile: SSHProfile?) -> SSHConfiguration {
11+
if sshProfileId != nil, let profile {
12+
return profile.toSSHConfiguration()
13+
}
14+
return sshConfig
15+
}
16+
}

TablePro/Views/Connection/WelcomeWindowView.swift

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -644,8 +644,21 @@ struct WelcomeWindowView: View {
644644

645645
Button {
646646
let pw = ConnectionStorage.shared.loadPassword(for: connection.id)
647-
let sshPw = ConnectionStorage.shared.loadSSHPassword(for: connection.id)
648-
let url = ConnectionURLFormatter.format(connection, password: pw, sshPassword: sshPw)
647+
let sshPw: String?
648+
let sshProfile: SSHProfile?
649+
if let profileId = connection.sshProfileId {
650+
sshPw = SSHProfileStorage.shared.loadSSHPassword(for: profileId)
651+
sshProfile = SSHProfileStorage.shared.profile(for: profileId)
652+
} else {
653+
sshPw = ConnectionStorage.shared.loadSSHPassword(for: connection.id)
654+
sshProfile = nil
655+
}
656+
let url = ConnectionURLFormatter.format(
657+
connection,
658+
password: pw,
659+
sshPassword: sshPw,
660+
sshProfile: sshProfile
661+
)
649662
ClipboardService.shared.writeText(url)
650663
} label: {
651664
Label(String(localized: "Copy as URL"), systemImage: "link")
@@ -1053,8 +1066,10 @@ private struct ConnectionRow: View {
10531066
}
10541067

10551068
private var connectionSubtitle: String {
1056-
if connection.sshConfig.enabled {
1057-
return "SSH : \(connection.sshConfig.username)@\(connection.sshConfig.host)"
1069+
let profile = connection.sshProfileId.flatMap { SSHProfileStorage.shared.profile(for: $0) }
1070+
let ssh = connection.effectiveSSHConfig(profile: profile)
1071+
if ssh.enabled {
1072+
return "SSH : \(ssh.username)@\(ssh.host)"
10581073
}
10591074
if connection.host.isEmpty {
10601075
return connection.database.isEmpty ? connection.type.rawValue : connection.database
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//
2+
// ConnectionURLFormatterSSHProfileTests.swift
3+
// TableProTests
4+
//
5+
6+
import Foundation
7+
import Testing
8+
@testable import TablePro
9+
10+
@Suite("ConnectionURLFormatter SSH Profile Resolution")
11+
struct ConnectionURLFormatterSSHProfileTests {
12+
@Test("Inline SSH config produces URL with inline SSH user and host")
13+
func inlineSSHConfigInURL() {
14+
var conn = DatabaseConnection(
15+
name: "", host: "db.example.com", port: 3_306, database: "mydb",
16+
username: "dbuser", type: .mysql
17+
)
18+
conn.sshConfig.enabled = true
19+
conn.sshConfig.host = "ssh-inline.example.com"
20+
conn.sshConfig.port = 22
21+
conn.sshConfig.username = "sshuser"
22+
conn.sshProfileId = nil
23+
24+
let url = ConnectionURLFormatter.format(conn, password: nil, sshPassword: nil)
25+
26+
#expect(url.contains("ssh://"))
27+
#expect(url.contains("sshuser@ssh-inline.example.com"))
28+
}
29+
30+
@Test("SSH profile overrides empty inline config in URL")
31+
func profileSSHConfigInURL() {
32+
let profileId = UUID()
33+
var conn = DatabaseConnection(
34+
name: "", host: "db.example.com", port: 3_306, database: "mydb",
35+
username: "dbuser", type: .mysql
36+
)
37+
conn.sshConfig = SSHConfiguration()
38+
conn.sshProfileId = profileId
39+
40+
let profile = SSHProfile(
41+
id: profileId,
42+
name: "My SSH Profile",
43+
host: "ssh-profile.example.com",
44+
port: 2_222,
45+
username: "profileuser"
46+
)
47+
48+
let url = ConnectionURLFormatter.format(conn, password: nil, sshPassword: nil, sshProfile: profile)
49+
50+
#expect(url.contains("ssh://"))
51+
#expect(url.contains("profileuser@ssh-profile.example.com"))
52+
#expect(url.contains(":2222"))
53+
}
54+
55+
@Test("No profile fallback produces URL with inline SSH data")
56+
func noProfileFallbackUsesInlineConfig() {
57+
var conn = DatabaseConnection(
58+
name: "", host: "db.example.com", port: 3_306, database: "mydb",
59+
username: "dbuser", type: .mysql
60+
)
61+
conn.sshConfig.enabled = true
62+
conn.sshConfig.host = "ssh-fallback.example.com"
63+
conn.sshConfig.username = "fallbackuser"
64+
conn.sshProfileId = UUID()
65+
66+
let url = ConnectionURLFormatter.format(conn, password: nil, sshPassword: nil)
67+
68+
#expect(url.contains("ssh://"))
69+
#expect(url.contains("fallbackuser@ssh-fallback.example.com"))
70+
}
71+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
//
2+
// DatabaseConnectionSSHTests.swift
3+
// TableProTests
4+
//
5+
6+
import Foundation
7+
import Testing
8+
@testable import TablePro
9+
10+
@Suite("DatabaseConnection effectiveSSHConfig")
11+
struct DatabaseConnectionSSHTests {
12+
@Test("No profile and no sshProfileId returns inline sshConfig")
13+
func inlineSSHConfigWithoutProfile() {
14+
var conn = TestFixtures.makeConnection()
15+
conn.sshConfig = SSHConfiguration()
16+
conn.sshConfig.enabled = true
17+
conn.sshConfig.host = "inline-host.example.com"
18+
conn.sshConfig.port = 2_222
19+
conn.sshConfig.username = "inline-user"
20+
conn.sshProfileId = nil
21+
22+
let result = conn.effectiveSSHConfig(profile: nil)
23+
24+
#expect(result.host == "inline-host.example.com")
25+
#expect(result.port == 2_222)
26+
#expect(result.username == "inline-user")
27+
#expect(result.enabled == true)
28+
}
29+
30+
@Test("Profile provided and sshProfileId set returns profile config")
31+
func profileOverridesInlineConfig() {
32+
let profileId = UUID()
33+
var conn = TestFixtures.makeConnection()
34+
conn.sshConfig = SSHConfiguration()
35+
conn.sshConfig.enabled = true
36+
conn.sshConfig.host = "inline-host.example.com"
37+
conn.sshConfig.username = "inline-user"
38+
conn.sshProfileId = profileId
39+
40+
let profile = SSHProfile(
41+
id: profileId,
42+
name: "Production SSH",
43+
host: "profile-host.example.com",
44+
port: 2_200,
45+
username: "profile-user",
46+
authMethod: .privateKey,
47+
privateKeyPath: "~/.ssh/id_ed25519"
48+
)
49+
50+
let result = conn.effectiveSSHConfig(profile: profile)
51+
52+
#expect(result.host == "profile-host.example.com")
53+
#expect(result.port == 2_200)
54+
#expect(result.username == "profile-user")
55+
#expect(result.authMethod == .privateKey)
56+
#expect(result.privateKeyPath == "~/.ssh/id_ed25519")
57+
}
58+
59+
@Test("sshProfileId set but profile nil falls back to inline config")
60+
func deletedProfileFallsBackToInline() {
61+
var conn = TestFixtures.makeConnection()
62+
conn.sshConfig = SSHConfiguration()
63+
conn.sshConfig.enabled = true
64+
conn.sshConfig.host = "fallback-host.example.com"
65+
conn.sshConfig.username = "fallback-user"
66+
conn.sshProfileId = UUID()
67+
68+
let result = conn.effectiveSSHConfig(profile: nil)
69+
70+
#expect(result.host == "fallback-host.example.com")
71+
#expect(result.username == "fallback-user")
72+
}
73+
74+
@Test("sshProfileId nil ignores provided profile and returns inline config")
75+
func noProfileIdIgnoresProfile() {
76+
var conn = TestFixtures.makeConnection()
77+
conn.sshConfig = SSHConfiguration()
78+
conn.sshConfig.enabled = true
79+
conn.sshConfig.host = "inline-host.example.com"
80+
conn.sshConfig.username = "inline-user"
81+
conn.sshProfileId = nil
82+
83+
let profile = SSHProfile(
84+
id: UUID(),
85+
name: "Ignored Profile",
86+
host: "profile-host.example.com",
87+
username: "profile-user"
88+
)
89+
90+
let result = conn.effectiveSSHConfig(profile: profile)
91+
92+
#expect(result.host == "inline-host.example.com")
93+
#expect(result.username == "inline-user")
94+
}
95+
96+
@Test("toSSHConfiguration sets enabled to true")
97+
func profileConfigHasEnabledTrue() {
98+
let profile = SSHProfile(
99+
name: "Test Profile",
100+
host: "ssh.example.com",
101+
port: 22,
102+
username: "testuser"
103+
)
104+
105+
let config = profile.toSSHConfiguration()
106+
107+
#expect(config.enabled == true)
108+
#expect(config.host == "ssh.example.com")
109+
#expect(config.username == "testuser")
110+
}
111+
}

0 commit comments

Comments
 (0)