Skip to content
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- File picker dialog appears behind the connection form window
- Fix crash when removing jump hosts from SSH tunnel configuration
- Fix crash when export dialog refreshes database list while tree view is displayed
- Use sheet presentation for password and TOTP prompts instead of blocking modal dialogs
- Fix localized strings with interpolation creating untranslatable dynamic keys
- Fix crash when closing window during SSH tunnel connection (use-after-free in libssh2)

### Added

Expand Down
8 changes: 4 additions & 4 deletions TablePro/AppDelegate+FileOpen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ extension AppDelegate {
let preview = (sql as NSString).length > 300 ? String(sql.prefix(300)) + "…" : sql
let confirmed = await AlertHelper.confirmDestructive(
title: String(localized: "Open Query from Link"),
message: String(localized: "An external link wants to open a query on connection \"\(name)\":\n\n\(preview)"),
message: String(format: String(localized: "An external link wants to open a query on connection \"%@\":\n\n%@"), name, preview),
confirmButton: String(localized: "Open Query"),
cancelButton: String(localized: "Cancel"),
window: NSApp.keyWindow
Expand All @@ -157,7 +157,7 @@ extension AppDelegate {
fileOpenLogger.error("Deep link: no connection named '\(connectionName, privacy: .public)'")
AlertHelper.showErrorSheet(
title: String(localized: "Connection Not Found"),
message: String(localized: "No saved connection named \"\(connectionName)\"."),
message: String(format: String(localized: "No saved connection named \"%@\"."), connectionName),
window: NSApp.keyWindow
)
return
Expand Down Expand Up @@ -191,7 +191,7 @@ extension AppDelegate {
{
let confirmed = await AlertHelper.confirmDestructive(
title: String(localized: "Pre-Connect Script"),
message: String(localized: "Connection \"\(connection.name)\" has a script that will run before connecting:\n\n\(script)"),
message: String(format: String(localized: "Connection \"%@\" has a script that will run before connecting:\n\n%@"), connection.name, script),
confirmButton: String(localized: "Run Script"),
cancelButton: String(localized: "Cancel"),
window: NSApp.keyWindow
Expand Down Expand Up @@ -221,7 +221,7 @@ extension AppDelegate {
let details = "\(type.rawValue)://\(userPart)\(host):\(port)/\(database)"
let confirmed = await AlertHelper.confirmDestructive(
title: String(localized: "Import Connection from Link"),
message: String(localized: "An external link wants to add a database connection:\n\nName: \(name)\n\(details)"),
message: String(format: String(localized: "An external link wants to add a database connection:\n\nName: %@\n%@"), name, details),
confirmButton: String(localized: "Add Connection"),
cancelButton: String(localized: "Cancel"),
window: NSApp.keyWindow
Expand Down
12 changes: 6 additions & 6 deletions TablePro/Core/AI/AIProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,22 @@ enum AIProviderError: Error, LocalizedError {
var errorDescription: String? {
switch self {
case .invalidEndpoint(let endpoint):
return String(localized: "Invalid endpoint: \(endpoint)")
return String(format: String(localized: "Invalid endpoint: %@"), endpoint)
case .authenticationFailed(let detail):
if detail.isEmpty {
return String(localized: "Authentication failed. Check your API key.")
}
return String(localized: "Authentication failed: \(detail)")
return String(format: String(localized: "Authentication failed: %@"), detail)
case .rateLimited:
return String(localized: "Rate limited. Please try again later.")
case .modelNotFound(let model):
return String(localized: "Model not found: \(model)")
return String(format: String(localized: "Model not found: %@"), model)
case .serverError(let code, let message):
return String(localized: "Server error (\(code)): \(message)")
return String(format: String(localized: "Server error (%d): %@"), code, message)
case .networkError(let message):
return String(localized: "Network error: \(message)")
return String(format: String(localized: "Network error: %@"), message)
case .streamingFailed(let message):
return String(localized: "Streaming failed: \(message)")
return String(format: String(localized: "Streaming failed: %@"), message)
}
}

Expand Down
12 changes: 7 additions & 5 deletions TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,10 @@ final class DatabaseManager {
passwordOverride = cached
} else {
let isApiOnly = PluginManager.shared.connectionMode(for: connection.type) == .apiOnly
guard let prompted = PasswordPromptHelper.prompt(
guard let prompted = await PasswordPromptHelper.prompt(
connectionName: connection.name,
isAPIToken: isApiOnly
isAPIToken: isApiOnly,
window: NSApp.keyWindow
) else {
removeSessionEntry(for: connection.id)
currentSessionId = nil
Expand Down Expand Up @@ -754,9 +755,10 @@ final class DatabaseManager {
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(
guard let prompted = await PasswordPromptHelper.prompt(
connectionName: session.connection.name,
isAPIToken: isApiOnly
isAPIToken: isApiOnly,
window: NSApp.keyWindow
) else {
updateSession(sessionId) { $0.status = .disconnected }
return
Expand Down Expand Up @@ -827,7 +829,7 @@ final class DatabaseManager {
Self.logger.error("Manual reconnect failed: \(error.localizedDescription)")
updateSession(sessionId) { session in
session.status = .error(
String(localized: "Reconnect failed: \(error.localizedDescription)"))
String(format: String(localized: "Reconnect failed: %@"), error.localizedDescription))
session.clearCachedData()
}
}
Expand Down
22 changes: 11 additions & 11 deletions TablePro/Core/Plugins/PluginError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,35 +25,35 @@ enum PluginError: LocalizedError {
var errorDescription: String? {
switch self {
case .invalidBundle(let reason):
return String(localized: "Invalid plugin bundle: \(reason)")
return String(format: String(localized: "Invalid plugin bundle: %@"), reason)
case .signatureInvalid(let detail):
return String(localized: "Plugin code signature verification failed: \(detail)")
return String(format: String(localized: "Plugin code signature verification failed: %@"), detail)
case .checksumMismatch:
return String(localized: "Plugin checksum does not match expected value")
case .incompatibleVersion(let required, let current):
return String(localized: "Plugin requires PluginKit version \(required), but app provides version \(current)")
return String(format: String(localized: "Plugin requires PluginKit version %d, but app provides version %d"), required, current)
case .pluginOutdated(let pluginVersion, let requiredVersion):
return String(localized: "Plugin was built with PluginKit version \(pluginVersion), but version \(requiredVersion) is required. Please update the plugin.")
return String(format: String(localized: "Plugin was built with PluginKit version %d, but version %d is required. Please update the plugin."), pluginVersion, requiredVersion)
case .cannotUninstallBuiltIn:
return String(localized: "Built-in plugins cannot be uninstalled")
case .notFound:
return String(localized: "Plugin not found")
case .noCompatibleBinary:
return String(localized: "Plugin does not contain a compatible binary for this architecture")
case .installFailed(let reason):
return String(localized: "Plugin installation failed: \(reason)")
return String(format: String(localized: "Plugin installation failed: %@"), reason)
case .pluginConflict(let existingName):
return String(localized: "A built-in plugin \"\(existingName)\" already provides this bundle ID")
return String(format: String(localized: "A built-in plugin \"%@\" already provides this bundle ID"), existingName)
case .appVersionTooOld(let minimumRequired, let currentApp):
return String(localized: "Plugin requires app version \(minimumRequired) or later, but current version is \(currentApp)")
return String(format: String(localized: "Plugin requires app version %@ or later, but current version is %@"), minimumRequired, currentApp)
case .downloadFailed(let reason):
return String(localized: "Plugin download failed: \(reason)")
return String(format: String(localized: "Plugin download failed: %@"), reason)
case .pluginNotInstalled(let databaseType):
return String(localized: "The \(databaseType) plugin is not installed. You can download it from the plugin marketplace.")
return String(format: String(localized: "The %@ plugin is not installed. You can download it from the plugin marketplace."), databaseType)
case .incompatibleWithCurrentApp(let minimumRequired):
return String(localized: "This plugin requires TablePro \(minimumRequired) or later")
return String(format: String(localized: "This plugin requires TablePro %@ or later"), minimumRequired)
case .invalidDescriptor(let pluginId, let reason):
return String(localized: "Plugin '\(pluginId)' has an invalid descriptor: \(reason)")
return String(format: String(localized: "Plugin '%@' has an invalid descriptor: %@"), pluginId, reason)
}
}
}
5 changes: 1 addition & 4 deletions TablePro/Core/SSH/Auth/PromptTOTPProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,7 @@ internal final class PromptTOTPProvider: TOTPProvider, @unchecked Sendable {
alert.window.initialFirstResponder = textField

let response = alert.runModal()
if response == .alertFirstButtonReturn {
return textField.stringValue
}
return nil
return response == .alertFirstButtonReturn ? textField.stringValue : nil
}

private func handleResult(_ code: String?) throws -> String {
Expand Down
49 changes: 34 additions & 15 deletions TablePro/Core/SSH/LibSSH2Tunnel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {
// MARK: - Forwarding

func startForwarding(remoteHost: String, remotePort: Int) {
libssh2_session_set_blocking(session, 0)
sessionQueue.sync { libssh2_session_set_blocking(session, 0) }

forwardingTask = Task.detached { [weak self] in
guard let self else { return }
Expand Down Expand Up @@ -137,7 +137,7 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {
// MARK: - Keep-Alive

func startKeepAlive() {
libssh2_keepalive_config(session, 1, 30)
sessionQueue.sync { libssh2_keepalive_config(session, 1, 30) }

keepAliveTask = Task.detached { [weak self] in
guard let self else { return }
Expand Down Expand Up @@ -188,24 +188,29 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {
// Close listenFD to stop accepting new connections
Darwin.close(listenFD)

// Defer session teardown to a detached task that waits for relays to exit.
// Defer session teardown to a detached task that waits for all tasks to exit.
let sessionQueue = self.sessionQueue
let session = self.session
let socketFD = self.socketFD
let jumpChain = self.jumpChain
let connectionId = self.connectionId
let forwardingTask = self.forwardingTask
let keepAliveTask = self.keepAliveTask
Task.detached {
// Wait for all relay tasks to finish (they'll exit quickly since
// socketFD is shut down and isRunning is false)
// Wait for all tasks to exit before touching the session.
await forwardingTask?.value
await keepAliveTask?.value
for task in currentRelayTasks {
await task.value
}

// Now safe to close the socket and tear down the session
Darwin.close(socketFD)

libssh2_session_set_blocking(session, 1)
tablepro_libssh2_session_disconnect(session, "Closing tunnel")
libssh2_session_free(session)
// Tear down on sessionQueue to serialize after any pending libssh2 blocks.
sessionQueue.sync {
Darwin.close(socketFD)
libssh2_session_set_blocking(session, 1)
tablepro_libssh2_session_disconnect(session, "Closing tunnel")
libssh2_session_free(session)
}

for hop in jumpChain.reversed() {
hop.relayTask?.cancel()
Expand Down Expand Up @@ -286,7 +291,7 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {
/// Open a direct-tcpip channel, handling EAGAIN with select().
/// Must be called on `sessionQueue`.
private func openDirectTcpipChannel(remoteHost: String, remotePort: Int) -> OpaquePointer? {
while true {
while isRunning {
let channel = libssh2_channel_direct_tcpip_ex(
session,
remoteHost,
Expand All @@ -308,6 +313,7 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {
return nil
}
}
return nil
}

/// Bidirectional relay between a client socket and an SSH channel.
Expand All @@ -332,9 +338,13 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {
}
}

relayTasks.withLock { tasks in
let shouldCancel = relayTasks.withLock { tasks -> Bool in
tasks.removeAll { $0.isCancelled }
tasks.append(task)
return !isAlive.withLock { $0 }
}
if shouldCancel {
task.cancel()
}
}

Expand Down Expand Up @@ -403,8 +413,11 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {
if written > 0 {
totalWritten += written
} else if written == Int(LIBSSH2_ERROR_EAGAIN) {
_ = self.waitForSocket(
session: self.session,
let directions = sessionQueue.sync {
libssh2_session_block_directions(self.session)
}
_ = self.waitForSocketDirections(
directions: directions,
socketFD: self.socketFD,
timeoutMs: 1_000
)
Expand All @@ -417,9 +430,15 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {
}

/// Wait for the SSH socket to become ready, based on libssh2's block directions.
/// Must be called on `sessionQueue` (reads session state via `libssh2_session_block_directions`).
private func waitForSocket(session: OpaquePointer, socketFD: Int32, timeoutMs: Int32) -> Bool {
let directions = libssh2_session_block_directions(session)
return waitForSocketDirections(directions: directions, socketFD: socketFD, timeoutMs: timeoutMs)
}

/// Wait for the SSH socket to become ready with pre-fetched block directions.
/// Safe to call from any queue since it does not access the session.
private func waitForSocketDirections(directions: Int32, socketFD: Int32, timeoutMs: Int32) -> Bool {
var events: Int16 = 0
if directions & LIBSSH2_SESSION_BLOCK_INBOUND != 0 {
events |= Int16(POLLIN)
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Core/SSH/SSHTunnelManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ enum SSHTunnelError: Error, LocalizedError {
var errorDescription: String? {
switch self {
case .tunnelCreationFailed(let message):
return String(localized: "SSH tunnel creation failed: \(message)")
return String(format: String(localized: "SSH tunnel creation failed: %@"), message)
case .tunnelAlreadyExists(let id):
return String(localized: "SSH tunnel already exists for connection: \(id.uuidString)")
return String(format: String(localized: "SSH tunnel already exists for connection: %@"), id.uuidString)
case .noAvailablePort:
return String(localized: "No available local port for SSH tunnel")
case .authenticationFailed:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ struct SchemaStatementGenerator {
throw NSError(
domain: "SchemaStatementGenerator",
code: -1,
userInfo: [NSLocalizedDescriptionKey: String(localized: "Unsupported schema operation: \(change.description)")]
userInfo: [NSLocalizedDescriptionKey: String(format: String(localized: "Unsupported schema operation: %@"), change.description)]
)
}
let sql = stmt.sql.hasSuffix(";") ? stmt.sql : stmt.sql + ";"
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/Services/Export/ConnectionExportCrypto.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ enum ConnectionExportCryptoError: LocalizedError {
case .corruptData:
return String(localized: "The encrypted file is corrupt or incomplete")
case .unsupportedVersion(let v):
return String(localized: "Unsupported encryption version \(v)")
return String(format: String(localized: "Unsupported encryption version %d"), Int(v))
}
}
}
Expand Down
10 changes: 5 additions & 5 deletions TablePro/Core/Services/Export/ConnectionExportService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,19 @@ enum ConnectionExportError: LocalizedError {
case .encodingFailed:
return String(localized: "Failed to encode connection data")
case .fileWriteFailed(let path):
return String(localized: "Failed to write file: \(path)")
return String(format: String(localized: "Failed to write file: %@"), path)
case .fileReadFailed(let path):
return String(localized: "Failed to read file: \(path)")
return String(format: String(localized: "Failed to read file: %@"), path)
case .invalidFormat:
return String(localized: "This file is not a valid TablePro export")
case .unsupportedVersion(let version):
return String(localized: "This file requires a newer version of TablePro (format version \(version))")
return String(format: String(localized: "This file requires a newer version of TablePro (format version %d)"), version)
case .decodingFailed(let detail):
return String(localized: "Failed to parse connection file: \(detail)")
return String(format: String(localized: "Failed to parse connection file: %@"), detail)
case .requiresPassphrase:
return String(localized: "This file is encrypted and requires a passphrase")
case .decryptionFailed(let detail):
return String(localized: "Decryption failed: \(detail)")
return String(format: String(localized: "Decryption failed: %@"), detail)
}
}
}
Expand Down
Loading
Loading