Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Connection names cut off at large text sizes on iPhone and iPad.
- `Cmd+W` closing the whole connection instead of the current tab until something in the window was clicked.
- **Reset Sample Database** leaving the old Chinook copy's journal files beside the fresh one.
- iPhone and iPad edits undoing changes synced while the form was open, and dropping SSH jump hosts set on the Mac.
- Edits on iPhone and iPad closing as if saved after the connection, group or tag changed or was deleted on another device.
- iCloud sync failing on iPhone and iPad after signing in to a different Apple Account.
- SQLite and DuckDB connections on iPhone and iPad losing their file after a restore, or opening an empty database.
- Table page arrows and row arrows hidden under the tab bar on iPhone and iPad with iOS 26 and later.
- App unresponsive while a connection file is encrypted or decrypted with a passphrase.
- Unsaved changes in the connection, group, tag and row forms discarded without asking on iPhone and iPad.
- Open connection on iPhone and iPad jumping back to the table list after a rename, reorder or synced change.
- Deleted connections still showing in Spotlight, Siri, Shortcuts and Handoff on iPhone and iPad.
- No confirmation before deleting a tag on iPhone and iPad.
- Picking an SSH key file on iPhone and iPad replacing another connection's key file of the same name.

### Security

Expand All @@ -115,6 +126,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- A chat tool registered at runtime could take the name of a tool TablePro ships.
- An open connection, a sheet and the app switcher preview left usable or visible behind the iOS app lock.
- **Require Face ID** turned off on iPhone and iPad without authenticating.
- SSH private keys pasted or picked on iPhone and iPad saved in plain text in the connections file.
- Test Connection on iPhone and iPad saving its credentials to the Keychain, synced with Sync Passwords on.

## [0.75.0] - 2026-09-18

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ public enum ConnectionExportCrypto {
data.count > headerLength && data.prefix(4) == magic
}

public static func encrypt(data: Data, passphrase: String) throws -> Data {
@concurrent
public static func encrypt(data: Data, passphrase: String) async throws -> Data {
var salt = Data(count: saltLength)
let saltStatus = salt.withUnsafeMutableBytes { buffer -> OSStatus in
guard let baseAddress = buffer.baseAddress else { return errSecParam }
Expand All @@ -57,7 +58,8 @@ public enum ConnectionExportCrypto {
return result
}

public static func decrypt(data: Data, passphrase: String) throws -> Data {
@concurrent
public static func decrypt(data: Data, passphrase: String) async throws -> Data {
guard data.count > headerLength else {
throw ConnectionExportCryptoError.corruptData
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +251,11 @@ public enum ConnectionImportDecoder {
)
}

public static func decodeEncryptedData(_ data: Data, passphrase: String) throws -> ConnectionExportEnvelope {
@concurrent
public static func decodeEncryptedData(_ data: Data, passphrase: String) async throws -> ConnectionExportEnvelope {
let decryptedData: Data
do {
decryptedData = try ConnectionExportCrypto.decrypt(data: data, passphrase: passphrase)
decryptedData = try await ConnectionExportCrypto.decrypt(data: data, passphrase: passphrase)
} catch {
throw ConnectionExportError.decryptionFailed(error.localizedDescription)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ public struct SSHConfiguration: Codable, Hashable, Sendable {
public var username: String
public var authMethod: SSHAuthMethod
public var privateKeyPath: String?
public var privateKeyData: String?
public var jumpHosts: [SSHJumpHost]

/// Fields the macOS app stores inside `sshConfigJson` that this model does not use, kept only so
Expand Down Expand Up @@ -56,21 +55,19 @@ public struct SSHConfiguration: Codable, Hashable, Sendable {
username: String = "",
authMethod: SSHAuthMethod = .password,
privateKeyPath: String? = nil,
privateKeyData: String? = nil,
jumpHosts: [SSHJumpHost] = []
) {
self.host = host
self.port = port
self.username = username
self.authMethod = authMethod
self.privateKeyPath = privateKeyPath
self.privateKeyData = privateKeyData
self.jumpHosts = jumpHosts
}

// Custom Codable to handle macOS extra fields gracefully
private enum CodingKeys: String, CodingKey {
case host, port, username, authMethod, privateKeyPath, privateKeyData, jumpHosts
case host, port, username, authMethod, privateKeyPath, jumpHosts
// macOS fields this model does not use but must preserve through a sync round trip.
case enabled, useSSHConfig, agentSocketPath, remoteFilePath, remoteFileAccess
case totpMode, totpAlgorithm, totpDigits, totpPeriod
Expand All @@ -83,7 +80,6 @@ public struct SSHConfiguration: Codable, Hashable, Sendable {
username = (try? container.decode(String.self, forKey: .username)) ?? ""
authMethod = (try? container.decode(SSHAuthMethod.self, forKey: .authMethod)) ?? .password
privateKeyPath = try? container.decode(String.self, forKey: .privateKeyPath)
privateKeyData = try? container.decode(String.self, forKey: .privateKeyData)
jumpHosts = (try? container.decode([SSHJumpHost].self, forKey: .jumpHosts)) ?? []
macEnabled = try container.decodeIfPresent(Bool.self, forKey: .enabled)
macUseSSHConfig = try container.decodeIfPresent(Bool.self, forKey: .useSSHConfig)
Expand All @@ -103,7 +99,6 @@ public struct SSHConfiguration: Codable, Hashable, Sendable {
try container.encode(username, forKey: .username)
try container.encode(authMethod, forKey: .authMethod)
try container.encodeIfPresent(privateKeyPath, forKey: .privateKeyPath)
try container.encodeIfPresent(privateKeyData, forKey: .privateKeyData)
try container.encode(jumpHosts, forKey: .jumpHosts)
try container.encodeIfPresent(macEnabled, forKey: .enabled)
try container.encodeIfPresent(macUseSSHConfig, forKey: .useSSHConfig)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,7 @@ public enum SyncRecordMapper {

if let sshConfig = connection.sshConfiguration {
do {
var syncSafe = sshConfig
syncSafe.privateKeyData = nil
let data = try encoder.encode(syncSafe)
let data = try encoder.encode(sshConfig)
fields[.sshConfigJson] = data as CKRecordValue
} catch {
logger.warning("Failed to encode SSH config for sync: \(error.localizedDescription)")
Expand Down Expand Up @@ -230,9 +228,7 @@ public enum SyncRecordMapper {
fields[.queryTimeoutSeconds] = connection.queryTimeoutSeconds.map { Int64($0) } as CKRecordValue?

if let sshConfig = connection.sshConfiguration {
var syncSafe = sshConfig
syncSafe.privateKeyData = nil
if let data = try? encoder.encode(syncSafe) {
if let data = try? encoder.encode(sshConfig) {
fields[.sshConfigJson] = data as CKRecordValue
}
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public actor CloudKitSyncEngine {
return try await container.accountStatus()
}

public func currentAccountId() async throws -> String? {
public func currentAccountId() async throws -> String {
guard let container else { throw SyncError.accountUnavailable }
return try await container.userRecordID().recordName
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ public struct PushOutcome: Sendable {
failures[recordID] = failure
}

public mutating func acceptMissingDeletions(of deletions: [CKRecord.ID]) {
for recordID in deletions where failures[recordID]?.code == .unknownItem {
failures[recordID] = nil
deletedRecordIDs.insert(recordID)
}
}

public mutating func merge(_ other: PushOutcome) {
savedRecords.merge(other.savedRecords) { _, new in new }
deletedRecordIDs.formUnion(other.deletedRecordIDs)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ public struct Tombstone: Codable, Sendable {
}
}

public enum SyncAccountChange: Equatable, Sendable {
case firstSeen
case unchanged
case switched
case previousAccountUnknown
}

public final class SyncMetadataStorage: @unchecked Sendable {
private static let logger = Logger(subsystem: "com.TablePro", category: "SyncMetadataStorage")

Expand Down Expand Up @@ -143,6 +150,32 @@ public final class SyncMetadataStorage: @unchecked Sendable {
set { userDefaults.set(newValue, forKey: key("lastAccountId")) }
}

@discardableResult
public func adoptAccount(_ accountId: String) -> SyncAccountChange {
guard let recorded = lastAccountId else {
lastAccountId = accountId
guard hasStoredToken else { return .firstSeen }
forgetServerPosition()
return .previousAccountUnknown
}
guard recorded != accountId else { return .unchanged }
forgetServerPosition()
for type in SyncRecordType.allCases {
clearTombstones(type: type)
}
lastAccountId = accountId
return .switched
}

private var hasStoredToken: Bool {
userDefaults.object(forKey: key("serverChangeToken")) != nil
}

private func forgetServerPosition() {
saveToken(nil)
userDefaults.removeObject(forKey: key("lastSyncDate"))
}

// MARK: - Reset

public func clearAll() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@ public final class SyncRecordCache {
}
}

public func removeAll() {
migration.withLock { $0 = true }
legacyDefaults?.removeObject(forKey: legacyStorageKey)
guard FileManager.default.fileExists(atPath: directory.path) else { return }
do {
try FileManager.default.removeItem(at: directory)
} catch {
Self.logger.error("Failed to clear the sync record cache: \(error.localizedDescription)")
}
}

// MARK: - Migration

/// Moves a cache written by an older build out of `UserDefaults` on first use, then clears the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
@testable import TableProImport
import XCTest

@MainActor
final class ConnectionExportCryptoIsolationTests: XCTestCase {
private let passphrase = "correct horse battery"

func testEncryptingLeavesTheMainActorFree() async throws {
let payload = Data("payload".utf8)
let passphrase = passphrase
try await assertLeavesMainActorFree {
_ = try await ConnectionExportCrypto.encrypt(data: payload, passphrase: passphrase)
}
}

func testDecryptingLeavesTheMainActorFree() async throws {
let passphrase = passphrase
let sealed = try await ConnectionExportCrypto.encrypt(data: Data("payload".utf8), passphrase: passphrase)
try await assertLeavesMainActorFree {
_ = try await ConnectionExportCrypto.decrypt(data: sealed, passphrase: passphrase)
}
}

func testDecodingAnEncryptedFileLeavesTheMainActorFree() async throws {
let passphrase = passphrase
let json = try ConnectionImportDecoder.encode(makeEnvelope(connections: [makeConnection()]))
let sealed = try await ConnectionExportCrypto.encrypt(data: json, passphrase: passphrase)
try await assertLeavesMainActorFree {
_ = try await ConnectionImportDecoder.decodeEncryptedData(sealed, passphrase: passphrase)
}
}

private func assertLeavesMainActorFree(
_ work: @escaping @MainActor () async throws -> Void,
file: StaticString = #filePath,
line: UInt = #line
) async throws {
let probe = DerivationProbe()
let task = Task { @MainActor in
probe.hasStarted = true
try await work()
probe.hasFinished = true
}
while !probe.hasStarted {
await Task.yield()
}
XCTAssertFalse(probe.hasFinished, "The derivation held the main actor until it finished", file: file, line: line)
try await task.value
XCTAssertTrue(probe.hasFinished, file: file, line: line)
}
}

@MainActor
private final class DerivationProbe {
var hasStarted = false
var hasFinished = false
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,47 @@
import XCTest

final class ConnectionExportCryptoTests: XCTestCase {
func testEncryptDecryptRoundTripRecoversOriginal() throws {
func testEncryptDecryptRoundTripRecoversOriginal() async throws {
let original = Data("the quick brown fox".utf8)
let encrypted = try ConnectionExportCrypto.encrypt(data: original, passphrase: "correct horse battery")
let decrypted = try ConnectionExportCrypto.decrypt(data: encrypted, passphrase: "correct horse battery")
let encrypted = try await ConnectionExportCrypto.encrypt(data: original, passphrase: "correct horse battery")
let decrypted = try await ConnectionExportCrypto.decrypt(data: encrypted, passphrase: "correct horse battery")
XCTAssertEqual(decrypted, original)
}

func testEncryptedBlobIsDetectedAndPlainJSONIsNot() throws {
let encrypted = try ConnectionExportCrypto.encrypt(data: Data("x".utf8), passphrase: "pw")
func testEncryptedBlobIsDetectedAndPlainJSONIsNot() async throws {
let encrypted = try await ConnectionExportCrypto.encrypt(data: Data("x".utf8), passphrase: "pw")
XCTAssertTrue(ConnectionExportCrypto.isEncrypted(encrypted))
XCTAssertFalse(ConnectionExportCrypto.isEncrypted(Data("{\"a\":1}".utf8)))
}

func testWrongPassphraseThrowsInvalidPassphrase() throws {
let encrypted = try ConnectionExportCrypto.encrypt(data: Data("secret".utf8), passphrase: "right")
XCTAssertThrowsError(try ConnectionExportCrypto.decrypt(data: encrypted, passphrase: "wrong")) { error in
XCTAssertEqual(error as? ConnectionExportCryptoError, .invalidPassphrase)
}
func testWrongPassphraseThrowsInvalidPassphrase() async throws {
let encrypted = try await ConnectionExportCrypto.encrypt(data: Data("secret".utf8), passphrase: "right")
await assertDecryptFails(encrypted, passphrase: "wrong", with: .invalidPassphrase)
}

func testTruncatedHeaderThrowsCorruptData() {
func testTruncatedHeaderThrowsCorruptData() async {
let tooShort = Data([0x54, 0x50, 0x52, 0x4F, 0x01])
XCTAssertThrowsError(try ConnectionExportCrypto.decrypt(data: tooShort, passphrase: "pw")) { error in
XCTAssertEqual(error as? ConnectionExportCryptoError, .corruptData)
}
await assertDecryptFails(tooShort, passphrase: "pw", with: .corruptData)
}

func testNonMagicPrefixThrowsCorruptData() throws {
var blob = try ConnectionExportCrypto.encrypt(data: Data("hello world data".utf8), passphrase: "pw")
func testNonMagicPrefixThrowsCorruptData() async throws {
var blob = try await ConnectionExportCrypto.encrypt(data: Data("hello world data".utf8), passphrase: "pw")
blob[0] = 0x00
XCTAssertThrowsError(try ConnectionExportCrypto.decrypt(data: blob, passphrase: "pw")) { error in
XCTAssertEqual(error as? ConnectionExportCryptoError, .corruptData)
await assertDecryptFails(blob, passphrase: "pw", with: .corruptData)
}

private func assertDecryptFails(
_ data: Data,
passphrase: String,
with expected: ConnectionExportCryptoError,
file: StaticString = #filePath,
line: UInt = #line
) async {
do {
_ = try await ConnectionExportCrypto.decrypt(data: data, passphrase: passphrase)
XCTFail("Decrypting was expected to throw \(expected)", file: file, line: line)
} catch {
XCTAssertEqual(error as? ConnectionExportCryptoError, expected, file: file, line: line)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,29 @@ final class ConnectionImportDecoderTests: XCTestCase {
XCTAssertThrowsError(try ConnectionImportDecoder.decodeData(data))
}

func testEncryptedRoundTripThroughDecoder() throws {
func testEncryptedRoundTripThroughDecoder() async throws {
let envelope = makeEnvelope(connections: [makeConnection()])
let json = try ConnectionImportDecoder.encode(envelope)
let encrypted = try ConnectionExportCrypto.encrypt(data: json, passphrase: "hunter2")
let encrypted = try await ConnectionExportCrypto.encrypt(data: json, passphrase: "hunter2")

let decoded = try ConnectionImportDecoder.decodeEncryptedData(encrypted, passphrase: "hunter2")
let decoded = try await ConnectionImportDecoder.decodeEncryptedData(encrypted, passphrase: "hunter2")
XCTAssertEqual(decoded.connections.count, 1)
}

func testWrongPassphraseThroughDecoderThrowsDecryptionFailed() async throws {
let json = try ConnectionImportDecoder.encode(makeEnvelope(connections: [makeConnection()]))
let encrypted = try await ConnectionExportCrypto.encrypt(data: json, passphrase: "hunter2")

do {
_ = try await ConnectionImportDecoder.decodeEncryptedData(encrypted, passphrase: "hunter3")
XCTFail("A wrong passphrase was expected to throw")
} catch ConnectionExportError.decryptionFailed(let detail) {
XCTAssertEqual(detail, ConnectionExportCryptoError.invalidPassphrase.localizedDescription)
} catch {
XCTFail("Expected decryptionFailed, got \(error)")
}
}

func testPathPortabilityRoundTrips() {
let original = NSHomeDirectory() + "/.ssh/id_rsa"
let contracted = PathPortability.contractHome(original)
Expand Down
Loading
Loading