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 @@ -44,6 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- AI Chat: Fix Error prompt now reads "MongoDB query" and "Redis command" using the database display name, instead of the raw query language label
- Internal: tab session registry binds automatically when a coordinator falls back to creating its own registry, so unit tests no longer trip the filter-state debug assertion
- Connection-only payloads no longer create an empty `Query 1` tab when there is no query, title, or source file to populate it
- Import from Other App: cancelling a macOS keychain prompt now stops the import loop instead of silently continuing through every remaining password. The loading screen has a Cancel button, and an explainer alert before reading passwords sets expectations about the per-item prompts (#1134)

## [0.39.1] - 2026-05-08

Expand Down
32 changes: 26 additions & 6 deletions TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ protocol ForeignAppImporter {
struct ForeignAppImportResult {
let envelope: ConnectionExportEnvelope
let sourceName: String
let credentialsAborted: Bool

init(envelope: ConnectionExportEnvelope, sourceName: String, credentialsAborted: Bool = false) {
self.envelope = envelope
self.sourceName = sourceName
self.credentialsAborted = credentialsAborted
}
}

// MARK: - Error
Expand Down Expand Up @@ -70,10 +77,16 @@ enum ForeignAppPathHelper {

// MARK: - Keychain Reader

enum KeychainReadResult {
case found(String)
case notFound
case cancelled
}

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

static func readPassword(service: String, account: String) -> String? {
static func readPassword(service: String, account: String) -> KeychainReadResult {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
Expand All @@ -83,12 +96,19 @@ enum ForeignKeychainReader {
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
if status != errSecItemNotFound {
logger.debug("Keychain read failed for \(service): \(status)")

switch status {
case errSecSuccess:
guard let data = result as? Data,
let value = String(data: data, encoding: .utf8) else {
return .notFound
}
return nil
return .found(value)
case errSecItemNotFound:
return .notFound
default:
logger.debug("Keychain read denied or cancelled for \(service): \(status)")
return .cancelled
}
return String(data: data, encoding: .utf8)
}
}
57 changes: 39 additions & 18 deletions TablePro/Core/Services/Export/ForeignApp/SequelAceImporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,16 @@ struct SequelAceImporter: ForeignAppImporter {
var exportableConnections: [ExportableConnection] = []
var groupNames: Set<String> = []
var credentials: [String: ExportableCredentials] = [:]
var credentialsAborted = false

parseChildren(
try parseChildren(
children,
groupName: nil,
connections: &exportableConnections,
groupNames: &groupNames,
credentials: &credentials,
includePasswords: includePasswords
includePasswords: includePasswords,
credentialsAborted: &credentialsAborted
)

guard !exportableConnections.isEmpty else {
Expand All @@ -76,7 +78,11 @@ struct SequelAceImporter: ForeignAppImporter {
credentials: credentials.isEmpty ? nil : credentials
)

return ForeignAppImportResult(envelope: envelope, sourceName: displayName)
return ForeignAppImportResult(
envelope: envelope,
sourceName: displayName,
credentialsAborted: credentialsAborted
)
}

// MARK: - Private
Expand Down Expand Up @@ -106,23 +112,24 @@ struct SequelAceImporter: ForeignAppImporter {
connections: inout [ExportableConnection],
groupNames: inout Set<String>,
credentials: inout [String: ExportableCredentials],
includePasswords: Bool
) {
includePasswords: Bool,
credentialsAborted: inout Bool
) throws {
for child in children {
try Task.checkCancellation()
if let subChildren = child["Children"] as? [[String: Any]] {
// This is a group node
let name = child["Name"] as? String ?? "Untitled Group"
groupNames.insert(name)
parseChildren(
try parseChildren(
subChildren,
groupName: name,
connections: &connections,
groupNames: &groupNames,
credentials: &credentials,
includePasswords: includePasswords
includePasswords: includePasswords,
credentialsAborted: &credentialsAborted
)
} else {
// This is a connection leaf
do {
let conn = try parseConnection(child, groupName: groupName)
let index = connections.count
Expand All @@ -132,8 +139,8 @@ struct SequelAceImporter: ForeignAppImporter {
groupNames.insert(gn)
}

if includePasswords {
let creds = readCredentials(from: child)
if includePasswords, !credentialsAborted {
let creds = readCredentials(from: child, abortFlag: &credentialsAborted)
if creds.password != nil || creds.sshPassword != nil {
credentials[String(index)] = creds
}
Expand Down Expand Up @@ -242,26 +249,40 @@ struct SequelAceImporter: ForeignAppImporter {
)
}

private func readCredentials(from entry: [String: Any]) -> ExportableCredentials {
private func readCredentials(from entry: [String: Any], abortFlag: inout Bool) -> ExportableCredentials {
let name = entry["name"] as? String ?? ""
let connId = entry["id"] ?? 0
let user = entry["user"] as? String ?? ""
let host = entry["host"] as? String ?? ""
let database = entry["database"] as? String ?? ""

let service = "Sequel Ace : \(name) (\(connId))"
let account = "\(user)@\(host)/\(database)"
func read(service: String, account: String) -> String? {
guard !abortFlag else { return nil }
switch ForeignKeychainReader.readPassword(service: service, account: account) {
case .found(let value):
return value
case .notFound:
return nil
case .cancelled:
abortFlag = true
return nil
}
}

let dbPassword = ForeignKeychainReader.readPassword(service: service, account: account)
let dbPassword = read(
service: "Sequel Ace : \(name) (\(connId))",
account: "\(user)@\(host)/\(database)"
)

var sshPassword: String?
let connectionType = entry["type"] as? Int ?? 0
if connectionType == 2 {
let sshUser = entry["sshUser"] as? String ?? ""
let sshHost = entry["sshHost"] as? String ?? ""
let sshService = "Sequel Ace SSHTunnel : \(name) (\(connId))"
let sshAccount = "\(sshUser)@\(sshHost)"
sshPassword = ForeignKeychainReader.readPassword(service: sshService, account: sshAccount)
sshPassword = read(
service: "Sequel Ace SSHTunnel : \(name) (\(connId))",
account: "\(sshUser)@\(sshHost)"
)
}

return ExportableCredentials(
Expand Down
42 changes: 26 additions & 16 deletions TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@ struct TablePlusImporter: ForeignAppImporter {
var exportableConnections: [ExportableConnection] = []
var groupNames: Set<String> = []
var credentials: [String: ExportableCredentials] = [:]
var credentialsAborted = false

for entry in entries {
try Task.checkCancellation()
do {
let conn = try parseConnection(entry, groupMap: groupMap)
let index = exportableConnections.count
Expand All @@ -63,8 +65,8 @@ struct TablePlusImporter: ForeignAppImporter {
groupNames.insert(groupName)
}

if includePasswords, let connId = entry["ID"] as? String {
let creds = readCredentials(for: connId)
if includePasswords, !credentialsAborted, let connId = entry["ID"] as? String {
let creds = readCredentials(for: connId, abortFlag: &credentialsAborted)
if creds.password != nil || creds.sshPassword != nil || creds.keyPassphrase != nil {
credentials[String(index)] = creds
}
Expand Down Expand Up @@ -92,7 +94,11 @@ struct TablePlusImporter: ForeignAppImporter {
credentials: credentials.isEmpty ? nil : credentials
)

return ForeignAppImportResult(envelope: envelope, sourceName: displayName)
return ForeignAppImportResult(
envelope: envelope,
sourceName: displayName,
credentialsAborted: credentialsAborted
)
}

// MARK: - Private
Expand Down Expand Up @@ -220,19 +226,23 @@ struct TablePlusImporter: ForeignAppImporter {
)
}

private func readCredentials(for connectionId: String) -> ExportableCredentials {
let dbPassword = ForeignKeychainReader.readPassword(
service: "com.tableplus.TablePlus",
account: "\(connectionId)_database"
)
let sshPassword = ForeignKeychainReader.readPassword(
service: "com.tableplus.TablePlus",
account: "\(connectionId)_server"
)
let keyPassphrase = ForeignKeychainReader.readPassword(
service: "com.tableplus.TablePlus",
account: "\(connectionId)_server_key"
)
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.tableplus.TablePlus", account: account) {
case .found(let value):
return value
case .notFound:
return nil
case .cancelled:
abortFlag = true
return nil
}
}

let dbPassword = read("\(connectionId)_database")
let sshPassword = read("\(connectionId)_server")
let keyPassphrase = read("\(connectionId)_server_key")
return ExportableCredentials(
password: dbPassword,
sshPassword: sshPassword,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import SwiftUI
struct ImportFromAppPreviewStep: View {
let preview: ConnectionImportPreview
let sourceName: String
let credentialsAborted: Bool
let onBack: () -> Void
var onImported: ((Int) -> Void)?

Expand All @@ -18,6 +19,9 @@ struct ImportFromAppPreviewStep: View {
var body: some View {
VStack(spacing: 0) {
header
if credentialsAborted {
credentialsAbortedBanner
}
Divider()
ConnectionImportPreviewList(
items: preview.items,
Expand All @@ -30,6 +34,19 @@ struct ImportFromAppPreviewStep: View {
.onAppear { selectReadyItems() }
}

private var credentialsAbortedBanner: some View {
Label {
Text(String(localized: "Some passwords were not read. You can enter them in the connection editor after import."))
.font(.caption)
} icon: {
Image(systemName: "key.slash")
.foregroundStyle(Color(nsColor: .systemOrange))
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(nsColor: .systemOrange).opacity(0.12))
}

// MARK: - Header

private var header: some View {
Expand Down
Loading
Loading