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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Remote deletions of connections, groups, tags, SSH profiles and table favorites applied with their sync category off.
- **Local only** connections taking edits and deletions made on another device.
- Undo and Redo in a tab with unsaved edits replaying another tab's changes against the wrong rows.
- Client certificate and key dropped by SSH, Cloudflare, SOCKS, Tunnel Command and Cloud SQL tunnels.
- SOCKS proxy dialing the hidden Host instead of the first host-list entry.
- "SSH password rejected" shown for a Password-auth SSH tunnel with no saved password.
- Literal backticks in cloudflared, cloud-sql-proxy, SSH config, remote command and dump tool install messages.
- Tunnel command preview showing port 0 or the wrong host when Port is blank or the connection uses a host list.
- SSH tab host-list warning naming replica set failover for Redis and Kafka, and implying Sentinel works through a tunnel.

## [0.76.1] - 2026-09-29

Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/CloudSQL/CloudSQLProxyError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ enum CloudSQLProxyError: Error, LocalizedError, Equatable {
var errorDescription: String? {
switch self {
case .binaryNotFound:
return String(localized: "cloud-sql-proxy was not found. Install it with `brew install cloud-sql-proxy`, or download it in the Cloud SQL Auth Proxy settings.")
return String(localized: "cloud-sql-proxy was not found. Install it with “brew install cloud-sql-proxy”, or download it in the Cloud SQL Auth Proxy settings.")
case .noAvailablePort:
return String(localized: "No available local port for the Cloud SQL Auth Proxy.")
case .invalidInstanceConnectionName:
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/Cloudflare/CloudflareTunnelError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ enum CloudflareTunnelError: Error, LocalizedError, Equatable {
var errorDescription: String? {
switch self {
case .binaryNotFound:
return String(localized: "cloudflared was not found. Install it with `brew install cloudflared`, or set its path in the connection's Cloudflare Tunnel settings.")
return String(localized: "cloudflared was not found. Install it with “brew install cloudflared”, or set its path in the connection's Cloudflare Tunnel settings.")
case .noAvailablePort:
return String(localized: "No available local port for the Cloudflare tunnel.")
case .startupFailed(let stderrTail):
Expand Down
5 changes: 3 additions & 2 deletions TablePro/Core/Database/DatabaseManager+SOCKSProxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ extension DatabaseManager {
? nil
: connectionStorage.loadSOCKSProxyPassword(for: connection.id)

let endpoint = connection.tunnelForwardEndpoint
let tunnelPort = try await SOCKSProxyManager.shared.createTunnel(
connectionId: connection.id,
config: config,
password: password,
targetHost: connection.host,
targetPort: connection.port
targetHost: endpoint.host,
targetPort: endpoint.port
)

return tunneledConnection(from: connection, localPort: tunnelPort)
Expand Down
4 changes: 1 addition & 3 deletions TablePro/Core/Database/DatabaseManager+Tunnel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import TableProPluginKit
extension DatabaseManager {
/// Rewrite a connection to point at the local tunnel endpoint. A 127.0.0.1
/// certificate can't satisfy hostname verification, so verify modes drop to
/// `.required` while keeping encryption; cert paths are cleared and the pre-tunnel
/// `.required` while keeping encryption and the CA path is cleared. The pre-tunnel
/// endpoint is recorded for the callers that must name the real server rather than
/// the local forward. A tunnel forwards a single local port, so MongoDB's
/// seed list is collapsed to that endpoint and a direct connection is forced,
Expand Down Expand Up @@ -42,8 +42,6 @@ extension DatabaseManager {
tunnelSSL.mode = .required
}
tunnelSSL.caCertificatePath = ""
tunnelSSL.clientCertificatePath = ""
tunnelSSL.clientKeyPath = ""
}

var effectiveFields = connection.additionalFields
Expand Down
8 changes: 4 additions & 4 deletions TablePro/Core/Database/NativeDumpRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ enum NativeDumpRegistry {
NativeDumpDescriptor.CommandLineTool(
backupBinaries: ["pg_dump"],
restoreBinaries: ["pg_restore"],
installHint: String(localized: "Install it with `brew install libpq` and link it."),
installHint: String(localized: "Install it with “brew install libpq” and link it."),
backupDelivery: .toolWritesFile,
restoreDelivery: .toolWritesFile,
restoreExitPolicy: .toleratesUnrecognizedSessionSettings,
Expand Down Expand Up @@ -184,7 +184,7 @@ enum NativeDumpRegistry {
NativeDumpDescriptor.CommandLineTool(
backupBinaries: ["mysqldump", "mariadb-dump"],
restoreBinaries: ["mysql", "mariadb"],
installHint: String(localized: "Install it with `brew install mysql-client` and link it."),
installHint: String(localized: "Install it with “brew install mysql-client” and link it."),
backupDelivery: .standardOutput,
restoreDelivery: .standardOutput,
identifyExecutable: { name, path in
Expand Down Expand Up @@ -266,7 +266,7 @@ enum NativeDumpRegistry {
NativeDumpDescriptor.CommandLineTool(
backupBinaries: ["mongodump"],
restoreBinaries: ["mongorestore"],
installHint: String(localized: "Install it with `brew install mongodb-database-tools`."),
installHint: String(localized: "Install it with “brew install mongodb-database-tools”."),
backupDelivery: .toolWritesFile,
restoreDelivery: .toolWritesFile,
needsCredentialsFile: true,
Expand Down Expand Up @@ -401,7 +401,7 @@ enum NativeDumpRegistry {
NativeDumpDescriptor.CommandLineTool(
backupBinaries: ["sqlite3"],
restoreBinaries: ["sqlite3"],
installHint: String(localized: "Install it with `brew install sqlite` and link it."),
installHint: String(localized: "Install it with “brew install sqlite” and link it."),
backupDelivery: .standardOutput,
restoreDelivery: .standardOutput,
backupArguments: { request, _ in
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Core/Database/PostgreSQLDumpToolCompatibility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,15 @@ enum PostgreSQLDumpToolCompatibility {
}

private static func installHint(newestUsableTool: PostgreSQLServerVersion?) -> String {
guard let newestUsableTool else { return String(localized: "Install it with `brew install libpq`.") }
guard let newestUsableTool else { return String(localized: "Install it with “brew install libpq”.") }
guard newestUsableTool >= PostgreSQLServerVersion(number: 100_000) else {
return String(
format: String(localized: "Install one from PostgreSQL %@ or earlier."),
newestUsableTool.majorReleaseName
)
}
return String(
format: String(localized: "Install one with `brew install postgresql@%@`."),
format: String(localized: "Install one with “brew install postgresql@%@”."),
newestUsableTool.majorReleaseName
)
}
Expand Down
6 changes: 2 additions & 4 deletions TablePro/Core/SSH/LibSSH2TunnelFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,7 @@ internal enum LibSSH2TunnelFactory {
}

if resolvedPrimary.username.isEmpty {
throw SSHTunnelError.tunnelCreationFailed(
"SSH username not set. Add it to the form or set `User` for `\(config.host)` in ~/.ssh/config."
)
throw SSHTunnelError.usernameMissing(host: config.host)
}

let firstHop = resolvedJumps.first ?? resolvedPrimary
Expand Down Expand Up @@ -527,7 +525,7 @@ internal enum LibSSH2TunnelFactory {
// Sequel Ace's behavior.
guard let sshPassword = credentials.sshPassword else {
logger.error("SSH password is nil (Keychain lookup may have failed) for \(resolved.host)")
throw SSHTunnelError.authenticationFailed(reason: .password)
throw SSHTunnelError.authenticationFailed(reason: .passwordMissing)
}
return CompositeAuthenticator(authenticators: [
PasswordAuthenticator(password: sshPassword),
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/SSH/SFTPError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ enum SFTPError: LocalizedError, Equatable {
)
case .remoteCommandFailed(let command, let status, let output):
return String(
format: String(localized: "`%1$@` exited with status %2$d on the server: %3$@"),
format: String(localized: "“%1$@” exited with status %2$d on the server: %3$@"),
command, status, output
)
case .cancelled:
Expand Down
8 changes: 4 additions & 4 deletions TablePro/Core/SSH/SSHConfigTokens.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,24 +69,24 @@ enum SSHTokenExpansionError: Error, Hashable, Sendable {
switch self {
case .unsupportedToken(let keyword, let token):
return String(
format: String(localized: "`%@` in ~/.ssh/config does not accept the token %@."),
format: String(localized: "The %@ line in ~/.ssh/config does not accept the token %@."),
keyword,
token
)
case .danglingPercent(let keyword):
return String(
format: String(localized: "`%@` in ~/.ssh/config ends in a lone %%. Write %%%% for a literal percent sign."),
format: String(localized: "The %@ line in ~/.ssh/config ends in a lone %%. Write %%%% for a literal percent sign."),
keyword
)
case .undefinedEnvironmentVariable(let keyword, let name):
return String(
format: String(localized: "`%@` in ~/.ssh/config uses ${%@}, which is not set in the environment."),
format: String(localized: "The %@ line in ~/.ssh/config uses ${%@}, which is not set in the environment."),
keyword,
name
)
case .malformedEnvironmentReference(let keyword):
return String(
format: String(localized: "`%@` in ~/.ssh/config has a ${ with no closing brace."),
format: String(localized: "The %@ line in ~/.ssh/config has a ${ with no closing brace."),
keyword
)
}
Expand Down
11 changes: 10 additions & 1 deletion TablePro/Core/SSH/SSHTunnelManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import TableProSSHTransport
/// the catch-all "credentials or private key" message.
enum AuthFailureReason: Sendable, Hashable, CaseIterable {
case password
case passwordMissing
case verificationCode
case privateKey
case agentUnavailable(AgentSocketOrigin)
Expand All @@ -32,7 +33,7 @@ enum AuthFailureReason: Sendable, Hashable, CaseIterable {
/// Hand-written because the two agent cases carry the socket source they are about, which
/// stops `CaseIterable` synthesising this.
static var allCases: [AuthFailureReason] {
[.password, .verificationCode, .privateKey]
[.password, .passwordMissing, .verificationCode, .privateKey]
+ AgentSocketOrigin.allCases.map(AuthFailureReason.agentUnavailable)
+ AgentSocketOrigin.allCases.map(AuthFailureReason.agentNoIdentities)
+ AgentSocketOrigin.allCases.map(AuthFailureReason.agentNoMatchingIdentity)
Expand Down Expand Up @@ -82,6 +83,7 @@ enum SSHTunnelError: Error, LocalizedError, Equatable, Sendable {
case configExpansionFailed(String)
case tunnelAlreadyExists(UUID)
case noAvailablePort
case usernameMissing(host: String)
case authenticationFailed(reason: AuthFailureReason)
case connectionTimeout
case hostKeyVerificationFailed
Expand All @@ -100,10 +102,17 @@ enum SSHTunnelError: Error, LocalizedError, Equatable, Sendable {
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 .usernameMissing(let host):
return String(
format: String(localized: "SSH username not set. Add it to the form, or add a User line for %@ in ~/.ssh/config."),
host
)
case .authenticationFailed(let reason):
switch reason {
case .password:
return String(localized: "SSH password rejected. Check the password and try again.")
case .passwordMissing:
return String(localized: "No SSH password was found for this connection. Enter it in the SSH settings and try again.")
case .verificationCode:
return String(localized: "Verification code rejected. Get a new code from your authenticator app and try again.")
case .privateKey:
Expand Down
Loading
Loading