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 @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Option to prompt for database password on every connection instead of saving to Keychain
- Autocompletion for filter fields: column names and SQL keywords suggested as you type (Raw SQL and Value fields)
- Multi-line support for Raw SQL filter field (Option+Enter for newline)
- Visual Create Table UI with multi-database support (sidebar → "Create New Table...")
Expand Down
25 changes: 16 additions & 9 deletions TablePro/AppDelegate+ConnectionHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -430,24 +430,31 @@ extension AppDelegate {
// MARK: - Connection Failure

func handleConnectionFailure(_ error: Error) async {
closeOrphanedMainWindows()

// User cancelled password prompt — no error dialog needed
if error is CancellationError { return }

try? await Task.sleep(for: .milliseconds(200))
AlertHelper.showErrorSheet(
title: String(localized: "Connection Failed"),
message: error.localizedDescription,
window: NSApp.keyWindow
)
}

/// Closes main windows that have no active database session, then opens the welcome window if none remain.
private func closeOrphanedMainWindows() {
for window in NSApp.windows where isMainWindow(window) {
let hasActiveSession = DatabaseManager.shared.activeSessions.values.contains {
window.subtitle == $0.connection.name
|| window.subtitle == "\($0.connection.name) — Preview"
}
if !hasActiveSession {
window.close()
}
if !hasActiveSession { window.close() }
}
if !NSApp.windows.contains(where: { isMainWindow($0) && $0.isVisible }) {
openWelcomeWindow()
}
try? await Task.sleep(for: .milliseconds(200))
AlertHelper.showErrorSheet(
title: String(localized: "Connection Failed"),
message: error.localizedDescription,
window: NSApp.keyWindow
)
}

// MARK: - Transient Connection Builder
Expand Down
6 changes: 6 additions & 0 deletions TablePro/AppDelegate+WindowConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,12 @@ extension AppDelegate {
for window in NSApp.windows where self.isWelcomeWindow(window) {
window.close()
}
} catch is CancellationError {
// User cancelled password prompt at startup — return to welcome
for window in NSApp.windows where self.isMainWindow(window) {
window.close()
}
self.openWelcomeWindow()
} catch {
windowLogger.error("Auto-reconnect failed for '\(connection.name)': \(error.localizedDescription)")

Expand Down
13 changes: 10 additions & 3 deletions TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,10 @@ extension DatabaseDriver {
enum DatabaseDriverFactory {
private static let logger = Logger(subsystem: "com.TablePro", category: "DatabaseDriverFactory")

static func createDriver(for connection: DatabaseConnection) throws -> DatabaseDriver {
static func createDriver(
for connection: DatabaseConnection,
passwordOverride: String? = nil
) throws -> DatabaseDriver {
let pluginId = connection.type.pluginTypeId
// If the plugin isn't registered yet and background loading hasn't finished,
// fall back to synchronous loading for this critical code path.
Expand All @@ -338,15 +341,19 @@ enum DatabaseDriverFactory {
host: connection.host,
port: connection.port,
username: connection.username,
password: resolvePassword(for: connection),
password: resolvePassword(for: connection, override: passwordOverride),
database: connection.database,
additionalFields: buildAdditionalFields(for: connection, plugin: plugin)
)
let pluginDriver = plugin.createDriver(config: config)
return PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver)
}

private static func resolvePassword(for connection: DatabaseConnection) -> String {
private static func resolvePassword(
for connection: DatabaseConnection,
override: String? = nil
) -> String {
if let override { return override }
if connection.usePgpass {
let pgpassHost = connection.additionalFields["pgpassOriginalHost"] ?? connection.host
let pgpassPort = connection.additionalFields["pgpassOriginalPort"]
Expand Down
68 changes: 60 additions & 8 deletions TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,32 @@ final class DatabaseManager {
}
}

// Resolve password override for prompt-for-password connections
var passwordOverride: String?
if connection.promptForPassword {
if let cached = activeSessions[connection.id]?.cachedPassword {
passwordOverride = cached
} else {
let isApiOnly = PluginManager.shared.connectionMode(for: connection.type) == .apiOnly
guard let prompted = PasswordPromptHelper.prompt(
connectionName: connection.name,
isAPIToken: isApiOnly
) else {
removeSessionEntry(for: connection.id)
currentSessionId = nil
throw CancellationError()
}
passwordOverride = prompted
}
}

// Create appropriate driver with effective connection
let driver: DatabaseDriver
do {
driver = try DatabaseDriverFactory.createDriver(for: effectiveConnection)
driver = try DatabaseDriverFactory.createDriver(
for: effectiveConnection,
passwordOverride: passwordOverride
)
} catch {
// Close tunnel if SSH was established
if connection.sshConfig.enabled {
Expand Down Expand Up @@ -217,7 +239,9 @@ final class DatabaseManager {
session.driver = driver
session.status = driver.status
session.effectiveConnection = effectiveConnection

if let passwordOverride {
session.cachedPassword = passwordOverride
}
setSession(session, for: connection.id)
}

Expand Down Expand Up @@ -418,9 +442,11 @@ final class DatabaseManager {
}

/// Test a connection without keeping it open
func testConnection(_ connection: DatabaseConnection, sshPassword: String? = nil) async throws
-> Bool
{
func testConnection(
_ connection: DatabaseConnection,
sshPassword: String? = nil,
passwordOverride: String? = nil
) async throws -> Bool {
// Build effective connection (creates SSH tunnel if needed)
let testConnection = try await buildEffectiveConnection(
for: connection,
Expand All @@ -429,7 +455,10 @@ final class DatabaseManager {

let result: Bool
do {
let driver = try DatabaseDriverFactory.createDriver(for: testConnection)
let driver = try DatabaseDriverFactory.createDriver(
for: testConnection,
passwordOverride: passwordOverride
)
result = try await driver.testConnection()
} catch {
if connection.sshConfig.enabled {
Expand Down Expand Up @@ -643,7 +672,10 @@ final class DatabaseManager {

// Use effective connection (tunneled) if available, otherwise original
let connectionForDriver = session.effectiveConnection ?? session.connection
let driver = try DatabaseDriverFactory.createDriver(for: connectionForDriver)
let driver = try DatabaseDriverFactory.createDriver(
for: connectionForDriver,
passwordOverride: session.cachedPassword
)
try await driver.connect()

// Apply timeout
Expand Down Expand Up @@ -712,8 +744,25 @@ final class DatabaseManager {
// Recreate SSH tunnel if needed and build effective connection
let effectiveConnection = try await buildEffectiveConnection(for: session.connection)

// Resolve password for prompt-for-password connections
var passwordOverride = activeSessions[sessionId]?.cachedPassword
if session.connection.promptForPassword && passwordOverride == nil {
let isApiOnly = PluginManager.shared.connectionMode(for: session.connection.type) == .apiOnly
guard let prompted = PasswordPromptHelper.prompt(
connectionName: session.connection.name,
isAPIToken: isApiOnly
) else {
updateSession(sessionId) { $0.status = .disconnected }
return
}
passwordOverride = prompted
}

// Create new driver and connect
let driver = try DatabaseDriverFactory.createDriver(for: effectiveConnection)
let driver = try DatabaseDriverFactory.createDriver(
for: effectiveConnection,
passwordOverride: passwordOverride
)
try await driver.connect()

// Apply timeout
Expand Down Expand Up @@ -750,6 +799,9 @@ final class DatabaseManager {
session.driver = driver
session.status = .connected
session.effectiveConnection = effectiveConnection
if let passwordOverride {
session.cachedPassword = passwordOverride
}
}

// Restart health monitoring if the plugin supports it
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,8 @@ final class ConnectionStorage {
saveConnections(connections)
SyncChangeTracker.shared.markDirty(.connection, id: duplicate.id.uuidString)

// Copy all passwords from source to duplicate
if let password = loadPassword(for: connection.id) {
// Copy all passwords from source to duplicate (skip DB password in prompt mode)
if !connection.promptForPassword, let password = loadPassword(for: connection.id) {
savePassword(password, for: newId)
}
if let sshPassword = loadSSHPassword(for: connection.id) {
Expand Down
36 changes: 36 additions & 0 deletions TablePro/Core/Utilities/UI/PasswordPromptHelper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// PasswordPromptHelper.swift
// TablePro
//
// Prompts the user for a database password via a native modal alert.
//

import AppKit

enum PasswordPromptHelper {
/// Presents a modal alert with a secure text field to collect a password or API token.
/// Returns the entered value (may be empty for passwordless databases), or `nil` if the user cancels.
@MainActor
static func prompt(connectionName: String, isAPIToken: Bool = false) -> String? {
let alert = NSAlert()
alert.messageText = isAPIToken
? String(localized: "API Token Required")
: String(localized: "Password Required")
alert.informativeText = String(
format: String(localized: "Enter the %@ for \"%@\""),
isAPIToken ? String(localized: "API token") : String(localized: "password"),
connectionName
)
alert.addButton(withTitle: String(localized: "Connect"))
alert.addButton(withTitle: String(localized: "Cancel"))

let input = NSSecureTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
input.placeholderString = isAPIToken
? String(localized: "API Token") : String(localized: "Password")
alert.accessoryView = input
alert.window.initialFirstResponder = input

guard alert.runModal() == .alertFirstButtonReturn else { return nil }
return input.stringValue
}
}
4 changes: 4 additions & 0 deletions TablePro/Models/Connection/ConnectionSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ struct ConnectionSession: Identifiable {
var currentSchema: String?
var currentDatabase: String?

/// In-memory password for prompt-for-password connections. Never persisted to disk.
var cachedPassword: String?

var activeDatabase: String {
currentDatabase ?? connection.database
}
Expand Down Expand Up @@ -58,6 +61,7 @@ struct ConnectionSession: Identifiable {
/// Clear cached data that can be re-fetched on reconnect.
/// Called when the connection enters a disconnected or error state
/// to release memory held by stale table metadata.
/// Note: `cachedPassword` is intentionally NOT cleared — auto-reconnect needs it after disconnect.
mutating func clearCachedData() {
tables = []
selectedTables = []
Expand Down
5 changes: 5 additions & 0 deletions TablePro/Models/Connection/DatabaseConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,11 @@ struct DatabaseConnection: Identifiable, Hashable {
set { additionalFields["usePgpass"] = newValue ? "true" : "" }
}

var promptForPassword: Bool {
get { additionalFields["promptForPassword"] == "true" }
set { additionalFields["promptForPassword"] = newValue ? "true" : "" }
}

var preConnectScript: String? {
get { additionalFields["preConnectScript"]?.nilIfEmpty }
set { additionalFields["preConnectScript"] = newValue ?? "" }
Expand Down
7 changes: 7 additions & 0 deletions TablePro/ViewModels/WelcomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ final class WelcomeViewModel {
Task {
do {
try await dbManager.connectToSession(connection)
} catch is CancellationError {
// User cancelled password prompt — return to welcome
NSApplication.shared.closeWindows(withId: "main")
self.openWindow?(id: "welcome")
} catch {
if case PluginError.pluginNotInstalled = error {
Self.logger.info("Plugin not installed for \(connection.type.rawValue), prompting install")
Expand All @@ -237,6 +241,9 @@ final class WelcomeViewModel {
Task {
do {
try await dbManager.connectToSession(connection)
} catch is CancellationError {
NSApplication.shared.closeWindows(withId: "main")
self.openWindow?(id: "welcome")
} catch {
Self.logger.error(
"Failed to connect after plugin install: \(error.localizedDescription, privacy: .public)")
Expand Down
Loading
Loading