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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Importing connections from TablePlus brings over saved passwords again. A recent release looked under the wrong keychain name, so connections imported with no passwords and no warning.
- Importing an SSH connection from TablePlus no longer fills in a fake private key path such as `~/.ssh/Import a private key...` when no key was selected. Empty TLS certificate paths are skipped too.
- Importing from DBeaver no longer shows an unnecessary keychain permission warning. DBeaver stores passwords in its own file, so macOS never prompts.
- Raw SQL filter now suggests columns and keywords at every position in the expression, including after AND and OR, instead of only the first column. (#1346)
- Plugins left incompatible after a TablePro update now update quietly in the background instead of showing a premature "could not be loaded" alert. You are only notified when no compatible version exists yet, and the message tells you what to do. (#1322)
- A plugin you download and install by hand is no longer blocked by macOS Gatekeeper once its signature is verified. (#1322)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ struct BeekeeperStudioImporter: ForeignAppImporter {
let displayName = "Beekeeper Studio"
let symbolName = "ant"
let appBundleIdentifier = "io.beekeeperstudio.desktop"
let readsPasswordsFromKeychain = false

var dataDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/beekeeper-studio")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct DBeaverImporter: ForeignAppImporter {
let displayName = "DBeaver"
let symbolName = "bird"
let appBundleIdentifier = "org.jkiss.dbeaver.core.product"
let readsPasswordsFromKeychain = false

/// All known DBeaver Eclipse product identifiers. Community, Enterprise,
/// Ultimate, and Lite variants each register a different bundle ID, but
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct DataGripImporter: ForeignAppImporter {
let displayName = "DataGrip"
let symbolName = "cylinder.split.1x2"
let appBundleIdentifier = "com.jetbrains.datagrip"
let readsPasswordsFromKeychain = true

/// Root holding versioned IDE config dirs (`DataGrip2024.3`, ...). Injectable for tests.
var jetBrainsRoot: URL = FileManager.default.homeDirectoryForCurrentUser
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ protocol ForeignAppImporter {
/// app ships in multiple editions (e.g. DBeaver Community / Enterprise)
/// should override `installedAppURL()` to look those up as well.
var appBundleIdentifier: String { get }
/// True when importing passwords reads the macOS keychain, which makes the
/// system show a per-item access prompt. Importers that read passwords from
/// a file (DBeaver, Beekeeper Studio) return false so no prompt is promised.
var readsPasswordsFromKeychain: Bool { get }
func installedAppURL() -> URL?
func connectionCount() -> Int
func importConnections(includePasswords: Bool) throws -> ForeignAppImportResult
Expand Down Expand Up @@ -103,6 +107,8 @@ enum KeychainReadResult {
case cancelled
}

typealias ForeignKeychainRead = (_ service: String, _ account: String) -> KeychainReadResult

enum ForeignKeychainReader {
private static let logger = Logger(subsystem: "com.TablePro", category: "ForeignKeychainReader")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct SequelAceImporter: ForeignAppImporter {
let displayName = "Sequel Ace"
let symbolName = "cylinder.split.1x2"
let appBundleIdentifier = "com.sequel-ace.sequel-ace"
let readsPasswordsFromKeychain = true

var favoritesFileURL: URL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(
Expand Down
31 changes: 24 additions & 7 deletions TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ struct TablePlusImporter: ForeignAppImporter {
let displayName = "TablePlus"
let symbolName = "rectangle.stack"
let appBundleIdentifier = "com.tinyapp.TablePlus"
let readsPasswordsFromKeychain = true

static let keychainService = "com.tableplus.TablePlus"

var readKeychain: ForeignKeychainRead = ForeignKeychainReader.readPassword
var keyFileExists: (_ path: String) -> Bool = { FileManager.default.fileExists(atPath: $0) }

var connectionsFileURL: URL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist")
Expand Down Expand Up @@ -182,16 +188,15 @@ struct TablePlusImporter: ForeignAppImporter {
let port = (entry["ServerPort"] as? String).flatMap(Int.init)
let username = entry["ServerUser"] as? String ?? ""
let useKey = entry["isUsePrivateKey"] as? Bool ?? false
let rawKeyPath = entry["ServerPrivateKeyName"] as? String ?? ""
let keyPath = ForeignAppPathHelper.resolveKeyPath(rawKeyPath)
let keyPath = useKey ? importedKeyPath(entry["ServerPrivateKeyName"] as? String ?? "") : ""

return ExportableSSHConfig(
enabled: true,
host: host,
port: port,
username: username,
authMethod: useKey ? "Private Key" : "Password",
privateKeyPath: useKey ? keyPath : "",
privateKeyPath: keyPath,
agentSocketPath: "",
jumpHosts: nil,
totpMode: nil,
Expand All @@ -201,6 +206,14 @@ struct TablePlusImporter: ForeignAppImporter {
)
}

private func importedKeyPath(_ rawName: String) -> String {
let trimmed = rawName.trimmingCharacters(in: .whitespaces)
let resolved = ForeignAppPathHelper.resolveKeyPath(trimmed)
guard !resolved.isEmpty else { return "" }
if trimmed.hasPrefix("/") || trimmed.hasPrefix("~/") { return resolved }
return keyFileExists(PathPortability.expandHome(resolved)) ? resolved : ""
}

private func parseSSLConfig(_ entry: [String: Any]) -> ExportableSSLConfig? {
guard entry.keys.contains("tLSMode") else { return nil }
let tlsMode = entry["tLSMode"] as? Int ?? 0
Expand All @@ -215,18 +228,22 @@ struct TablePlusImporter: ForeignAppImporter {
}

let paths = entry["TlsKeyPaths"] as? [String] ?? []
func certPath(_ index: Int) -> String? {
guard index < paths.count, !paths[index].isEmpty else { return nil }
return paths[index]
}
return ExportableSSLConfig(
mode: mode,
caCertificatePath: !paths.isEmpty ? paths[0] : nil,
clientCertificatePath: paths.count > 1 ? paths[1] : nil,
clientKeyPath: paths.count > 2 ? paths[2] : nil
caCertificatePath: certPath(0),
clientCertificatePath: certPath(1),
clientKeyPath: certPath(2)
)
}

private func readCredentials(for connectionId: String, abortFlag: inout Bool) -> ExportableCredentials {
func read(_ account: String) -> String? {
guard !abortFlag else { return nil }
switch ForeignKeychainReader.readPassword(service: "com.tinyapp.TablePlus", account: account) {
switch readKeychain(Self.keychainService, account) {
case .found(let value):
return value
case .notFound:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,13 @@ struct ImportFromAppSheet: View {

// MARK: - Actions

static func requiresKeychainConfirmation(includePasswords: Bool, importer: any ForeignAppImporter) -> Bool {
includePasswords && importer.readsPasswordsFromKeychain
}

private func beginImport(importer: any ForeignAppImporter, includePasswords: Bool) {
if includePasswords, !confirmKeychainPrompts(for: importer) {
if Self.requiresKeychainConfirmation(includePasswords: includePasswords, importer: importer),
!confirmKeychainPrompts(for: importer) {
return
}
startImport(importer: importer, includePasswords: includePasswords)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,20 @@ struct ForeignAppImporterRegistryTests {
#expect(importer.displayName == "Beekeeper Studio")
#expect(importer.appBundleIdentifier == "io.beekeeperstudio.desktop")
}

@Test("Importers declare whether passwords are read from the keychain")
func testReadsPasswordsFromKeychainFlags() {
#expect(TablePlusImporter().readsPasswordsFromKeychain == true)
#expect(SequelAceImporter().readsPasswordsFromKeychain == true)
#expect(DataGripImporter().readsPasswordsFromKeychain == true)
#expect(DBeaverImporter().readsPasswordsFromKeychain == false)
#expect(BeekeeperStudioImporter().readsPasswordsFromKeychain == false)
}

@Test("Keychain confirmation applies only to keychain-based importers when importing passwords")
func testRequiresKeychainConfirmation() {
#expect(ImportFromAppSheet.requiresKeychainConfirmation(includePasswords: true, importer: TablePlusImporter()))
#expect(!ImportFromAppSheet.requiresKeychainConfirmation(includePasswords: true, importer: DBeaverImporter()))
#expect(!ImportFromAppSheet.requiresKeychainConfirmation(includePasswords: false, importer: TablePlusImporter()))
}
}
152 changes: 145 additions & 7 deletions TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ struct TablePlusImporterTests {
}
}

@Test("importConnections parses SSH config")
@Test("importConnections parses SSH config and keeps an explicit key path even when the file is missing")
func testImportConnections_parsesSSHConfig() throws {
try writeConnections([
makeConnection(
Expand All @@ -179,21 +179,68 @@ struct TablePlusImporterTests {
sshPort: "2222",
sshUser: "deploy",
usePrivateKey: true,
privateKeyPath: "~/.ssh/id_rsa"
privateKeyPath: "/Users/test/.ssh/id_rsa"
)
])

let result = try importer.importConnections(includePasswords: false)
let conn = result.envelope.connections[0]
let ssh = conn.sshConfig
var imp = importer
imp.keyFileExists = { _ in false }

let result = try imp.importConnections(includePasswords: false)
let ssh = result.envelope.connections[0].sshConfig

#expect(ssh != nil)
#expect(ssh?.enabled == true)
#expect(ssh?.host == "bastion.example.com")
#expect(ssh?.port == 2222)
#expect(ssh?.port == 2_222)
#expect(ssh?.username == "deploy")
#expect(ssh?.authMethod == "Private Key")
#expect(ssh?.privateKeyPath == "~/.ssh/id_rsa")
#expect(ssh?.privateKeyPath == "/Users/test/.ssh/id_rsa")
}

@Test("importConnections drops the empty-key placeholder instead of building a fake path")
func testImportConnections_placeholderPrivateKey_producesNoPath() throws {
try writeConnections([
makeConnection(
name: "SSH Placeholder",
id: "ssh-placeholder",
isOverSSH: true,
sshHost: "bastion.example.com",
sshUser: "deploy",
usePrivateKey: true,
privateKeyPath: "Import a private key..."
)
])

var imp = importer
imp.keyFileExists = { _ in false }

let result = try imp.importConnections(includePasswords: false)
let ssh = result.envelope.connections[0].sshConfig

#expect(ssh?.authMethod == "Private Key")
#expect(ssh?.privateKeyPath == "")
}

@Test("importConnections keeps a bare key name when the file exists in ~/.ssh")
func testImportConnections_bareKeyName_keptWhenFileExists() throws {
try writeConnections([
makeConnection(
name: "SSH Bare Key",
id: "ssh-bare",
isOverSSH: true,
sshHost: "bastion.example.com",
sshUser: "deploy",
usePrivateKey: true,
privateKeyPath: "id_rsa"
)
])

var imp = importer
imp.keyFileExists = { _ in true }

let result = try imp.importConnections(includePasswords: false)
#expect(result.envelope.connections[0].sshConfig?.privateKeyPath == "~/.ssh/id_rsa")
}

@Test("importConnections parses SSH config with password auth")
Expand Down Expand Up @@ -296,6 +343,21 @@ struct TablePlusImporterTests {
#expect(result.envelope.connections[0].sslConfig == nil)
}

@Test("importConnections treats empty TablePlus TLS paths as none")
func testImportConnections_emptyTLSPaths_areNil() throws {
var entry = makeConnection(name: "Empty TLS", id: "tls-empty", tlsMode: 1)
entry["TlsKeyPaths"] = ["", "", ""]
try writeConnections([entry])

let result = try importer.importConnections(includePasswords: false)
let ssl = result.envelope.connections[0].sslConfig

#expect(ssl != nil)
#expect(ssl?.caCertificatePath == nil)
#expect(ssl?.clientCertificatePath == nil)
#expect(ssl?.clientKeyPath == nil)
}

@Test("importConnections preserves groups")
func testImportConnections_preservesGroups() throws {
try writeGroups([
Expand Down Expand Up @@ -444,4 +506,80 @@ struct TablePlusImporterTests {
#expect(result.envelope.appVersion == "TablePlus Import")
#expect(result.envelope.tags == nil)
}

// MARK: - Password Import

@Test("importConnections reads the database password from the correct keychain service")
func testImportConnections_readsCorrectKeychainServiceAndAccount() throws {
try writeConnections([makeConnection(name: "DB", id: "conn-1")])
let spy = KeychainSpy()
spy.responses["conn-1_database"] = .found("s3cret")

var imp = importer
imp.readKeychain = spy.read

let result = try imp.importConnections(includePasswords: true)

#expect(spy.calls.contains { $0.service == "com.tableplus.TablePlus" && $0.account == "conn-1_database" })
#expect(result.envelope.credentials?["0"]?.password == "s3cret")
#expect(result.credentialsAborted == false)
}

@Test("importConnections queries database, SSH, and key-passphrase accounts")
func testImportConnections_queriesAllCredentialAccounts() throws {
try writeConnections([makeConnection(name: "DB", id: "conn-1")])
let spy = KeychainSpy()

var imp = importer
imp.readKeychain = spy.read

_ = try imp.importConnections(includePasswords: true)

let accounts = Set(spy.calls.map(\.account))
#expect(accounts == ["conn-1_database", "conn-1_server", "conn-1_server_key"])
#expect(spy.calls.allSatisfy { $0.service == "com.tableplus.TablePlus" })
}

@Test("importConnections leaves credentials empty and does not abort when nothing is stored")
func testImportConnections_noStoredPasswords_emptyCredentialsNoAbort() throws {
try writeConnections([makeConnection(name: "DB", id: "conn-1")])
let spy = KeychainSpy()

var imp = importer
imp.readKeychain = spy.read

let result = try imp.importConnections(includePasswords: true)

#expect(result.envelope.credentials == nil)
#expect(result.credentialsAborted == false)
}

@Test("importConnections aborts and stops reading after a cancelled keychain prompt")
func testImportConnections_cancelledPrompt_abortsAndStops() throws {
try writeConnections([
makeConnection(name: "A", id: "c1"),
makeConnection(name: "B", id: "c2")
])
let spy = KeychainSpy()
spy.responses["c1_database"] = .cancelled

var imp = importer
imp.readKeychain = spy.read

let result = try imp.importConnections(includePasswords: true)

#expect(result.credentialsAborted == true)
#expect(spy.calls.count == 1)
#expect(spy.calls.first?.account == "c1_database")
}
}

private final class KeychainSpy {
var calls: [(service: String, account: String)] = []
var responses: [String: KeychainReadResult] = [:]

func read(_ service: String, _ account: String) -> KeychainReadResult {
calls.append((service: service, account: account))
return responses[account] ?? .notFound
}
}
Loading