Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- SSH profile connections displaying incorrect host/username on the Welcome window home screen (#454)
- Saved connections disappearing after normal app quit (Cmd+Q) while persisting after force quit (#452)
- Crash when disconnecting an etcd connection while requests are in-flight
- Detail pane showing truncated values for LONGTEXT/MEDIUMTEXT/CLOB columns, preventing correct editing
Expand Down
18 changes: 4 additions & 14 deletions TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -455,20 +455,10 @@ final class DatabaseManager {
sshPasswordOverride: String? = nil
) async throws -> DatabaseConnection {
// Resolve SSH configuration: profile takes priority over inline
let sshConfig: SSHConfiguration
let isProfile: Bool
let secretOwnerId: UUID

if let profileId = connection.sshProfileId,
let profile = SSHProfileStorage.shared.profile(for: profileId) {
sshConfig = profile.toSSHConfiguration()
secretOwnerId = profileId
isProfile = true
} else {
sshConfig = connection.sshConfig
secretOwnerId = connection.id
isProfile = false
}
let profile = connection.sshProfileId.flatMap { SSHProfileStorage.shared.profile(for: $0) }
let sshConfig = connection.effectiveSSHConfig(profile: profile)
let isProfile = connection.sshProfileId != nil && profile != nil
let secretOwnerId = (isProfile ? connection.sshProfileId : nil) ?? connection.id

guard sshConfig.enabled else {
return connection
Expand Down
32 changes: 21 additions & 11 deletions TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
import Foundation

struct ConnectionURLFormatter {
static func format(_ connection: DatabaseConnection, password: String?, sshPassword: String?) -> String {
static func format(
_ connection: DatabaseConnection,
password: String?,
sshPassword: String?,
sshProfile: SSHProfile? = nil
) -> String {
let scheme = urlScheme(for: connection.type)

if connection.type == .sqlite {
Expand All @@ -17,8 +22,9 @@ struct ConnectionURLFormatter {
return formatDuckDB(connection.database)
}

if connection.sshConfig.enabled {
return formatSSH(connection, scheme: scheme, password: password)
let ssh = connection.effectiveSSHConfig(profile: sshProfile)
if ssh.enabled {
return formatSSH(connection, sshConfig: ssh, scheme: scheme, password: password)
}

return formatStandard(connection, scheme: scheme, password: password)
Expand Down Expand Up @@ -47,12 +53,12 @@ struct ConnectionURLFormatter {

private static func formatSSH(
_ connection: DatabaseConnection,
sshConfig ssh: SSHConfiguration,
scheme: String,
password: String?
) -> String {
var result = "\(scheme)+ssh://"

let ssh = connection.sshConfig
if !ssh.username.isEmpty {
result += "\(percentEncodeUserinfo(ssh.username))@"
}
Expand Down Expand Up @@ -81,7 +87,7 @@ struct ConnectionURLFormatter {
: connection.database
result += "/\(sshPathComponent)"

let query = buildQueryString(connection)
let query = buildQueryString(connection, sshConfig: ssh)
if !query.isEmpty {
result += "?\(query)"
}
Expand Down Expand Up @@ -122,7 +128,11 @@ struct ConnectionURLFormatter {
return result
}

private static func buildQueryString(_ connection: DatabaseConnection) -> String {
private static func buildQueryString(
_ connection: DatabaseConnection,
sshConfig: SSHConfiguration? = nil
) -> String {
let ssh = sshConfig ?? connection.sshConfig
var params: [String] = []

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

if connection.sshConfig.enabled && connection.sshConfig.authMethod == .privateKey {
if ssh.enabled && ssh.authMethod == .privateKey {
params.append("usePrivateKey=true")
}

if connection.sshConfig.enabled && connection.sshConfig.authMethod == .sshAgent {
if ssh.enabled && ssh.authMethod == .sshAgent {
params.append("useSSHAgent=true")
if !connection.sshConfig.agentSocketPath.isEmpty {
let encoded = connection.sshConfig.agentSocketPath
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? connection.sshConfig.agentSocketPath
if !ssh.agentSocketPath.isEmpty {
let encoded = ssh.agentSocketPath
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ssh.agentSocketPath
params.append("agentSocket=\(encoded)")
}
}
Expand Down
16 changes: 16 additions & 0 deletions TablePro/Models/Connection/DatabaseConnection+SSH.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// DatabaseConnection+SSH.swift
// TablePro
//

extension DatabaseConnection {
/// Resolves the effective SSH configuration for this connection.
/// When an SSH profile is referenced and provided, uses the profile's config.
/// Otherwise falls back to the inline `sshConfig`.
func effectiveSSHConfig(profile: SSHProfile?) -> SSHConfiguration {
if sshProfileId != nil, let profile {
return profile.toSSHConfiguration()
}
return sshConfig
}
}
26 changes: 21 additions & 5 deletions TablePro/Views/Connection/WelcomeWindowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,8 @@ struct WelcomeWindowView: View {
}

private func connectionRow(for connection: DatabaseConnection) -> some View {
ConnectionRow(connection: connection, onConnect: { connectToDatabase(connection) })
let sshProfile = connection.sshProfileId.flatMap { SSHProfileStorage.shared.profile(for: $0) }
return ConnectionRow(connection: connection, sshProfile: sshProfile, onConnect: { connectToDatabase(connection) })
.tag(connection.id)
.listRowInsets(ThemeEngine.shared.activeTheme.spacing.listRowInsets.swiftUI)
.listRowSeparator(.hidden)
Expand Down Expand Up @@ -644,8 +645,21 @@ struct WelcomeWindowView: View {

Button {
let pw = ConnectionStorage.shared.loadPassword(for: connection.id)
let sshPw = ConnectionStorage.shared.loadSSHPassword(for: connection.id)
let url = ConnectionURLFormatter.format(connection, password: pw, sshPassword: sshPw)
let sshPw: String?
let sshProfile: SSHProfile?
if let profileId = connection.sshProfileId {
sshPw = SSHProfileStorage.shared.loadSSHPassword(for: profileId)
sshProfile = SSHProfileStorage.shared.profile(for: profileId)
} else {
sshPw = ConnectionStorage.shared.loadSSHPassword(for: connection.id)
sshProfile = nil
}
let url = ConnectionURLFormatter.format(
connection,
password: pw,
sshPassword: sshPw,
sshProfile: sshProfile
)
ClipboardService.shared.writeText(url)
} label: {
Label(String(localized: "Copy as URL"), systemImage: "link")
Expand Down Expand Up @@ -998,6 +1012,7 @@ struct WelcomeWindowView: View {

private struct ConnectionRow: View {
let connection: DatabaseConnection
let sshProfile: SSHProfile?
var onConnect: (() -> Void)?

private var displayTag: ConnectionTag? {
Expand Down Expand Up @@ -1053,8 +1068,9 @@ private struct ConnectionRow: View {
}

private var connectionSubtitle: String {
if connection.sshConfig.enabled {
return "SSH : \(connection.sshConfig.username)@\(connection.sshConfig.host)"
let ssh = connection.effectiveSSHConfig(profile: sshProfile)
if ssh.enabled {
return "SSH : \(ssh.username)@\(ssh.host)"
}
if connection.host.isEmpty {
return connection.database.isEmpty ? connection.type.rawValue : connection.database
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//
// ConnectionURLFormatterSSHProfileTests.swift
// TableProTests
//

import Foundation
import Testing
@testable import TablePro

@Suite("ConnectionURLFormatter SSH Profile Resolution")
struct ConnectionURLFormatterSSHProfileTests {
@Test("Inline SSH config produces URL with inline SSH user and host")
func inlineSSHConfigInURL() {
var conn = DatabaseConnection(
name: "", host: "db.example.com", port: 3_306, database: "mydb",
username: "dbuser", type: .mysql
)
conn.sshConfig.enabled = true
conn.sshConfig.host = "ssh-inline.example.com"
conn.sshConfig.port = 22
conn.sshConfig.username = "sshuser"
conn.sshProfileId = nil

let url = ConnectionURLFormatter.format(conn, password: nil, sshPassword: nil)

#expect(url.contains("ssh://"))
#expect(url.contains("sshuser@ssh-inline.example.com"))
}

@Test("SSH profile overrides empty inline config in URL")
func profileSSHConfigInURL() {
let profileId = UUID()
var conn = DatabaseConnection(
name: "", host: "db.example.com", port: 3_306, database: "mydb",
username: "dbuser", type: .mysql
)
conn.sshConfig = SSHConfiguration()
conn.sshProfileId = profileId

let profile = SSHProfile(
id: profileId,
name: "My SSH Profile",
host: "ssh-profile.example.com",
port: 2_222,
username: "profileuser"
)

let url = ConnectionURLFormatter.format(conn, password: nil, sshPassword: nil, sshProfile: profile)

#expect(url.contains("ssh://"))
#expect(url.contains("profileuser@ssh-profile.example.com"))
#expect(url.contains(":2222"))
}

@Test("No profile fallback produces URL with inline SSH data")
func noProfileFallbackUsesInlineConfig() {
var conn = DatabaseConnection(
name: "", host: "db.example.com", port: 3_306, database: "mydb",
username: "dbuser", type: .mysql
)
conn.sshConfig.enabled = true
conn.sshConfig.host = "ssh-fallback.example.com"
conn.sshConfig.username = "fallbackuser"
conn.sshProfileId = UUID()

let url = ConnectionURLFormatter.format(conn, password: nil, sshPassword: nil)

#expect(url.contains("ssh://"))
#expect(url.contains("fallbackuser@ssh-fallback.example.com"))
}
}
111 changes: 111 additions & 0 deletions TableProTests/Models/DatabaseConnectionSSHTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//
// DatabaseConnectionSSHTests.swift
// TableProTests
//

import Foundation
import Testing
@testable import TablePro

@Suite("DatabaseConnection effectiveSSHConfig")
struct DatabaseConnectionSSHTests {
@Test("No profile and no sshProfileId returns inline sshConfig")
func inlineSSHConfigWithoutProfile() {
var conn = TestFixtures.makeConnection()
conn.sshConfig = SSHConfiguration()
conn.sshConfig.enabled = true
conn.sshConfig.host = "inline-host.example.com"
conn.sshConfig.port = 2_222
conn.sshConfig.username = "inline-user"
conn.sshProfileId = nil

let result = conn.effectiveSSHConfig(profile: nil)

#expect(result.host == "inline-host.example.com")
#expect(result.port == 2_222)
#expect(result.username == "inline-user")
#expect(result.enabled == true)
}

@Test("Profile provided and sshProfileId set returns profile config")
func profileOverridesInlineConfig() {
let profileId = UUID()
var conn = TestFixtures.makeConnection()
conn.sshConfig = SSHConfiguration()
conn.sshConfig.enabled = true
conn.sshConfig.host = "inline-host.example.com"
conn.sshConfig.username = "inline-user"
conn.sshProfileId = profileId

let profile = SSHProfile(
id: profileId,
name: "Production SSH",
host: "profile-host.example.com",
port: 2_200,
username: "profile-user",
authMethod: .privateKey,
privateKeyPath: "~/.ssh/id_ed25519"
)

let result = conn.effectiveSSHConfig(profile: profile)

#expect(result.host == "profile-host.example.com")
#expect(result.port == 2_200)
#expect(result.username == "profile-user")
#expect(result.authMethod == .privateKey)
#expect(result.privateKeyPath == "~/.ssh/id_ed25519")
}

@Test("sshProfileId set but profile nil falls back to inline config")
func deletedProfileFallsBackToInline() {
var conn = TestFixtures.makeConnection()
conn.sshConfig = SSHConfiguration()
conn.sshConfig.enabled = true
conn.sshConfig.host = "fallback-host.example.com"
conn.sshConfig.username = "fallback-user"
conn.sshProfileId = UUID()

let result = conn.effectiveSSHConfig(profile: nil)

#expect(result.host == "fallback-host.example.com")
#expect(result.username == "fallback-user")
}

@Test("sshProfileId nil ignores provided profile and returns inline config")
func noProfileIdIgnoresProfile() {
var conn = TestFixtures.makeConnection()
conn.sshConfig = SSHConfiguration()
conn.sshConfig.enabled = true
conn.sshConfig.host = "inline-host.example.com"
conn.sshConfig.username = "inline-user"
conn.sshProfileId = nil

let profile = SSHProfile(
id: UUID(),
name: "Ignored Profile",
host: "profile-host.example.com",
username: "profile-user"
)

let result = conn.effectiveSSHConfig(profile: profile)

#expect(result.host == "inline-host.example.com")
#expect(result.username == "inline-user")
}

@Test("toSSHConfiguration sets enabled to true")
func profileConfigHasEnabledTrue() {
let profile = SSHProfile(
name: "Test Profile",
host: "ssh.example.com",
port: 22,
username: "testuser"
)

let config = profile.toSSHConfiguration()

#expect(config.enabled == true)
#expect(config.host == "ssh.example.com")
#expect(config.username == "testuser")
}
}
Loading