diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b227ec1a7..e27885c5a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/Packages/TableProCore/Sources/TableProImport/ConnectionExportCrypto.swift b/Packages/TableProCore/Sources/TableProImport/ConnectionExportCrypto.swift index a1a6f9c1ed..18aca709db 100644 --- a/Packages/TableProCore/Sources/TableProImport/ConnectionExportCrypto.swift +++ b/Packages/TableProCore/Sources/TableProImport/ConnectionExportCrypto.swift @@ -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 } @@ -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 } diff --git a/Packages/TableProCore/Sources/TableProImport/ConnectionImportTypes.swift b/Packages/TableProCore/Sources/TableProImport/ConnectionImportTypes.swift index f843abf209..10e6ea4a24 100644 --- a/Packages/TableProCore/Sources/TableProImport/ConnectionImportTypes.swift +++ b/Packages/TableProCore/Sources/TableProImport/ConnectionImportTypes.swift @@ -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) } diff --git a/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift b/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift index 477b2326a2..7dcccab02f 100644 --- a/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift +++ b/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift @@ -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 @@ -56,7 +55,6 @@ public struct SSHConfiguration: Codable, Hashable, Sendable { username: String = "", authMethod: SSHAuthMethod = .password, privateKeyPath: String? = nil, - privateKeyData: String? = nil, jumpHosts: [SSHJumpHost] = [] ) { self.host = host @@ -64,13 +62,12 @@ public struct SSHConfiguration: Codable, Hashable, Sendable { 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 @@ -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) @@ -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) diff --git a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift index fa866f1902..4fac5ffbdc 100644 --- a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift +++ b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift @@ -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)") @@ -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 { diff --git a/Packages/TableProCore/Sources/TableProSyncTransport/CloudKitSyncEngine.swift b/Packages/TableProCore/Sources/TableProSyncTransport/CloudKitSyncEngine.swift index dca1c5e0b6..a7971a439c 100644 --- a/Packages/TableProCore/Sources/TableProSyncTransport/CloudKitSyncEngine.swift +++ b/Packages/TableProCore/Sources/TableProSyncTransport/CloudKitSyncEngine.swift @@ -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 } diff --git a/Packages/TableProCore/Sources/TableProSyncTransport/PushOutcome.swift b/Packages/TableProCore/Sources/TableProSyncTransport/PushOutcome.swift index 90ef9b156d..256d4fcc46 100644 --- a/Packages/TableProCore/Sources/TableProSyncTransport/PushOutcome.swift +++ b/Packages/TableProCore/Sources/TableProSyncTransport/PushOutcome.swift @@ -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) diff --git a/Packages/TableProCore/Sources/TableProSyncTransport/SyncMetadataStorage.swift b/Packages/TableProCore/Sources/TableProSyncTransport/SyncMetadataStorage.swift index 3826635afa..d615f5f17a 100644 --- a/Packages/TableProCore/Sources/TableProSyncTransport/SyncMetadataStorage.swift +++ b/Packages/TableProCore/Sources/TableProSyncTransport/SyncMetadataStorage.swift @@ -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") @@ -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() { diff --git a/Packages/TableProCore/Sources/TableProSyncTransport/SyncRecordCache.swift b/Packages/TableProCore/Sources/TableProSyncTransport/SyncRecordCache.swift index a2d0364661..89f5f2df8e 100644 --- a/Packages/TableProCore/Sources/TableProSyncTransport/SyncRecordCache.swift +++ b/Packages/TableProCore/Sources/TableProSyncTransport/SyncRecordCache.swift @@ -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 diff --git a/Packages/TableProCore/Tests/TableProImportTests/ConnectionExportCryptoIsolationTests.swift b/Packages/TableProCore/Tests/TableProImportTests/ConnectionExportCryptoIsolationTests.swift new file mode 100644 index 0000000000..cad7dc29d2 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProImportTests/ConnectionExportCryptoIsolationTests.swift @@ -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 +} diff --git a/Packages/TableProCore/Tests/TableProImportTests/ConnectionExportCryptoTests.swift b/Packages/TableProCore/Tests/TableProImportTests/ConnectionExportCryptoTests.swift index 2c20a47f0b..e7b3be20c1 100644 --- a/Packages/TableProCore/Tests/TableProImportTests/ConnectionExportCryptoTests.swift +++ b/Packages/TableProCore/Tests/TableProImportTests/ConnectionExportCryptoTests.swift @@ -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) } } } diff --git a/Packages/TableProCore/Tests/TableProImportTests/ConnectionImportDecoderTests.swift b/Packages/TableProCore/Tests/TableProImportTests/ConnectionImportDecoderTests.swift index ce968715f3..a20c61809b 100644 --- a/Packages/TableProCore/Tests/TableProImportTests/ConnectionImportDecoderTests.swift +++ b/Packages/TableProCore/Tests/TableProImportTests/ConnectionImportDecoderTests.swift @@ -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) diff --git a/Packages/TableProCore/Tests/TableProModelsTests/SSHConfigurationTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/SSHConfigurationTests.swift index 88d23300f7..21e8959121 100644 --- a/Packages/TableProCore/Tests/TableProModelsTests/SSHConfigurationTests.swift +++ b/Packages/TableProCore/Tests/TableProModelsTests/SSHConfigurationTests.swift @@ -58,6 +58,39 @@ struct SSHConfigurationTests { #expect(fields["totpMode"] as? String == "totp") } + @Test("A configuration never encodes a private key field") + func neverEncodesPrivateKey() throws { + let config = SSHConfiguration( + host: "prod-1", + username: "deploy", + authMethod: .privateKey, + privateKeyPath: "/keys/id_ed25519" + ) + let fields = try #require( + JSONSerialization.jsonObject(with: JSONEncoder().encode(config)) as? [String: Any] + ) + #expect(fields["privateKeyData"] == nil) + #expect(fields["privateKeyPath"] as? String == "/keys/id_ed25519") + } + + @Test("JSON an older build wrote with a pasted key still decodes, and re-encodes without the key") + func legacyPastedKeyIsDropped() throws { + let keyText = "-----BEGIN OPENSSH PRIVATE KEY-----\\nb3BlbnNzaC1rZXktdjE\\n-----END OPENSSH PRIVATE KEY-----" + let legacyJSON = """ + {"host":"prod-1","port":2222,"username":"deploy","authMethod":"privateKey", + "privateKeyData":"\(keyText)","jumpHosts":[]} + """ + let decoded = try JSONDecoder().decode(SSHConfiguration.self, from: Data(legacyJSON.utf8)) + #expect(decoded.host == "prod-1") + #expect(decoded.port == 2_222) + #expect(decoded.authMethod == .privateKey) + + let reencoded = try JSONEncoder().encode(decoded) + let text = try #require(String(data: reencoded, encoding: .utf8)) + #expect(!text.contains("privateKeyData")) + #expect(!text.contains("b3BlbnNzaC1rZXktdjE")) + } + @Test("A configuration this model creates omits the macOS-only keys, so the host inference is unchanged") func iosCreatedConfigOmitsMacKeys() throws { let config = SSHConfiguration(host: "prod-1", username: "deploy") diff --git a/Packages/TableProCore/Tests/TableProSyncTests/FieldLevelMergeTests.swift b/Packages/TableProCore/Tests/TableProSyncTests/FieldLevelMergeTests.swift index f7d3ff6819..057539b08d 100644 --- a/Packages/TableProCore/Tests/TableProSyncTests/FieldLevelMergeTests.swift +++ b/Packages/TableProCore/Tests/TableProSyncTests/FieldLevelMergeTests.swift @@ -173,6 +173,39 @@ struct SyncRecordCacheTests { #expect(defaults.object(forKey: "recordCache") == nil, "The oversized key must be released") } + @Test("Removing everything forgets every record, and storing works again afterwards") + func removeAllForgetsEveryRecord() throws { + let cache = try makeCache() + let first = makeRecord("Connection_A") + let second = makeRecord("Connection_B") + cache.store([first, second]) + + cache.removeAll() + + #expect(cache.record(for: first.recordID) == nil) + #expect(cache.record(for: second.recordID) == nil) + cache.store([first]) + #expect(cache.record(for: first.recordID)?["name"] as? String == "Production") + } + + @Test("Removing everything drops a legacy UserDefaults cache, and a later read never brings it back") + func removeAllDropsLegacyCache() throws { + let suite = "com.TablePro.tests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let record = makeRecord("Connection_Legacy") + let archived = try NSKeyedArchiver.archivedData(withRootObject: record, requiringSecureCoding: true) + defaults.set(["Connection_Legacy": archived], forKey: "recordCache") + let directory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("SyncRecordCacheTests/\(UUID().uuidString)", isDirectory: true) + let cache = SyncRecordCache(directory: directory, defaults: defaults, storageKey: "recordCache") + + cache.removeAll() + + #expect(defaults.object(forKey: "recordCache") == nil) + #expect(cache.record(for: record.recordID) == nil) + } + @Test("An unknown record is absent") func unknownRecordIsAbsent() throws { let cache = try makeCache() diff --git a/Packages/TableProCore/Tests/TableProSyncTests/PushOutcomeTests.swift b/Packages/TableProCore/Tests/TableProSyncTests/PushOutcomeTests.swift index a529e1dca2..f96d927407 100644 --- a/Packages/TableProCore/Tests/TableProSyncTests/PushOutcomeTests.swift +++ b/Packages/TableProCore/Tests/TableProSyncTests/PushOutcomeTests.swift @@ -121,4 +121,27 @@ struct PushOutcomeTests { #expect(first.savedRecords.count == 2) #expect(first.failures.count == 1) } + + @Test("Deleting a record the server never had counts as deleted, and only for deletions") + func missingDeletionCountsAsDeleted() { + var outcome = PushOutcome() + let missing = recordID("Connection_Missing") + let missingSave = recordID("Connection_MissingSave") + let rejected = recordID("Connection_Rejected") + let notFound = SyncItemFailure(code: .unknownItem, serverRecord: nil, clientRecord: nil, message: "not found") + outcome.recordFailure(notFound, for: missing) + outcome.recordFailure(notFound, for: missingSave) + outcome.recordFailure( + SyncItemFailure(code: .permissionFailure, serverRecord: nil, clientRecord: nil, message: "denied"), + for: rejected + ) + + outcome.acceptMissingDeletions(of: [missing, rejected]) + + #expect(outcome.didDelete(missing)) + #expect(outcome.failures[missing] == nil) + #expect(outcome.failures[missingSave] != nil) + #expect(outcome.failures[rejected] != nil) + #expect(!outcome.didDelete(rejected)) + } } diff --git a/Packages/TableProCore/Tests/TableProSyncTests/SyncMetadataStorageTests.swift b/Packages/TableProCore/Tests/TableProSyncTests/SyncMetadataStorageTests.swift index 38f374b98b..eaf9a0d9cd 100644 --- a/Packages/TableProCore/Tests/TableProSyncTests/SyncMetadataStorageTests.swift +++ b/Packages/TableProCore/Tests/TableProSyncTests/SyncMetadataStorageTests.swift @@ -90,6 +90,79 @@ struct SyncMetadataStorageTests { #expect(storage.lastAccountId == "account") } + @Test("The first account seen on a device that never synced is recorded and nothing queued is dropped") + func firstAccountIsRecorded() { + let defaults = UserDefaults(suiteName: "com.TablePro.tests.\(UUID().uuidString)") ?? .standard + let storage = SyncMetadataStorage(userDefaults: defaults) + storage.markDirty("a", type: .connection) + storage.addTombstone("b", type: .connection) + + #expect(storage.adoptAccount("account-a") == .firstSeen) + + #expect(storage.lastAccountId == "account-a") + #expect(storage.dirtyIds(for: .connection) == ["a"]) + #expect(storage.tombstones(for: .connection).map(\.id) == ["b"]) + } + + @Test("An account recorded for the first time over an earlier sync starts sync over once and keeps what is queued") + func unrecordedEarlierSyncStartsOverOnce() { + let defaults = UserDefaults(suiteName: "com.TablePro.tests.\(UUID().uuidString)") ?? .standard + let storage = SyncMetadataStorage(userDefaults: defaults) + storage.markDirty("a", type: .connection) + storage.addTombstone("b", type: .connection) + storage.lastSyncDate = Date() + defaults.set(Data([1, 2, 3]), forKey: "com.TablePro.sync.serverChangeToken") + + #expect(storage.adoptAccount("account-a") == .previousAccountUnknown) + + #expect(storage.lastAccountId == "account-a") + #expect(defaults.data(forKey: "com.TablePro.sync.serverChangeToken") == nil) + #expect(storage.lastSyncDate == nil) + #expect(storage.dirtyIds(for: .connection) == ["a"]) + #expect(storage.tombstones(for: .connection).map(\.id) == ["b"]) + + defaults.set(Data([4, 5, 6]), forKey: "com.TablePro.sync.serverChangeToken") + #expect(storage.adoptAccount("account-a") == .unchanged) + #expect(defaults.data(forKey: "com.TablePro.sync.serverChangeToken") == Data([4, 5, 6])) + } + + @Test("The same account changes nothing") + func sameAccountChangesNothing() { + let defaults = UserDefaults(suiteName: "com.TablePro.tests.\(UUID().uuidString)") ?? .standard + let storage = SyncMetadataStorage(userDefaults: defaults) + storage.lastAccountId = "account-a" + storage.markDirty("a", type: .connection) + storage.lastSyncDate = Date() + defaults.set(Data([1, 2, 3]), forKey: "com.TablePro.sync.serverChangeToken") + + #expect(storage.adoptAccount("account-a") == .unchanged) + + #expect(storage.dirtyIds(for: .connection) == ["a"]) + #expect(storage.lastSyncDate != nil) + #expect(defaults.data(forKey: "com.TablePro.sync.serverChangeToken") == Data([1, 2, 3])) + } + + @Test("A different account clears the old account's token and deletions, and keeps edits waiting to go up") + func differentAccountStartsOver() { + let defaults = UserDefaults(suiteName: "com.TablePro.tests.\(UUID().uuidString)") ?? .standard + let storage = SyncMetadataStorage(userDefaults: defaults) + storage.lastAccountId = "account-a" + storage.markDirty("a", type: .connection) + storage.markDirty("c", type: .tag) + storage.addTombstone("b", type: .group) + storage.lastSyncDate = Date() + defaults.set(Data([1, 2, 3]), forKey: "com.TablePro.sync.serverChangeToken") + + #expect(storage.adoptAccount("account-b") == .switched) + + #expect(storage.lastAccountId == "account-b") + #expect(storage.dirtyIds(for: .connection) == ["a"]) + #expect(storage.dirtyIds(for: .tag) == ["c"]) + #expect(storage.tombstones(for: .group).isEmpty) + #expect(storage.lastSyncDate == nil) + #expect(defaults.data(forKey: "com.TablePro.sync.serverChangeToken") == nil) + } + @Test("An absent token reads as nil") func absentTokenReadsAsNil() { #expect(makeStorage().loadToken() == nil) diff --git a/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperSSHConfigTests.swift b/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperSSHConfigTests.swift new file mode 100644 index 0000000000..14a836dfbd --- /dev/null +++ b/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperSSHConfigTests.swift @@ -0,0 +1,73 @@ +import CloudKit +import Foundation +import Testing + +@testable import TableProModels +@testable import TableProSync +@testable import TableProSyncTransport + +@Suite("SyncRecordMapper SSH configuration") +struct SyncRecordMapperSSHConfigTests { + private let zoneID = CKRecordZone.ID(zoneName: "TestZone", ownerName: CKCurrentUserDefaultName) + private let keyMarker = "b3BlbnNzaC1rZXktdjE" + + private func makeConnection(host: String = "bastion.example.com") -> DatabaseConnection { + DatabaseConnection( + name: "Tunnelled", + type: .postgresql, + host: "10.0.0.5", + port: 5_432, + username: "app", + database: "prod", + sshEnabled: true, + sshConfiguration: SSHConfiguration( + host: host, + port: 22, + username: "deploy", + authMethod: .privateKey, + privateKeyPath: "/keys/id_ed25519" + ) + ) + } + + private func sshJSONObject(in record: CKRecord) throws -> [String: Any] { + let data = try #require(record.fields(ConnectionSyncField.self)[.sshConfigJson] as? Data) + return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + @Test("toRecord writes SSH settings without a private key field") + func toRecordOmitsPrivateKey() throws { + let record = SyncRecordMapper.toRecord(makeConnection(), zoneID: zoneID) + let json = try sshJSONObject(in: record) + #expect(json["privateKeyData"] == nil) + #expect(json["host"] as? String == "bastion.example.com") + #expect(json["privateKeyPath"] as? String == "/keys/id_ed25519") + } + + @Test("updateRecord writes SSH settings without a private key field") + func updateRecordOmitsPrivateKey() throws { + let record = SyncRecordMapper.toRecord(makeConnection(), zoneID: zoneID) + SyncRecordMapper.updateRecord(record, with: makeConnection(host: "jump.example.com")) + let json = try sshJSONObject(in: record) + #expect(json["privateKeyData"] == nil) + #expect(json["host"] as? String == "jump.example.com") + } + + @Test("A record carrying a private key maps to a connection that never encodes it") + func incomingPrivateKeyIsDropped() throws { + let record = SyncRecordMapper.toRecord(makeConnection(), zoneID: zoneID) + let legacySSH = """ + {"host":"bastion.example.com","port":22,"username":"deploy","authMethod":"privateKey", + "privateKeyData":"-----BEGIN OPENSSH PRIVATE KEY-----\\n\(keyMarker)\\n-----END OPENSSH PRIVATE KEY-----", + "jumpHosts":[]} + """ + record.fields(ConnectionSyncField.self)[.sshConfigJson] = Data(legacySSH.utf8) as CKRecordValue + + let connection = try #require(SyncRecordMapper.toConnection(record)) + #expect(connection.sshConfiguration?.host == "bastion.example.com") + + let encoded = try #require(String(data: JSONEncoder().encode(connection), encoding: .utf8)) + #expect(!encoded.contains("privateKeyData")) + #expect(!encoded.contains(keyMarker)) + } +} diff --git a/TablePro/Core/Services/Export/ConnectionExportService.swift b/TablePro/Core/Services/Export/ConnectionExportService.swift index 0b16e47620..fd3db75e27 100644 --- a/TablePro/Core/Services/Export/ConnectionExportService.swift +++ b/TablePro/Core/Services/Export/ConnectionExportService.swift @@ -316,24 +316,9 @@ enum ConnectionExportService { ) } - static func exportEncryptedData(_ connections: [DatabaseConnection], passphrase: String) throws -> Data { + static func exportEncryptedData(_ connections: [DatabaseConnection], passphrase: String) async throws -> Data { let jsonData = try encode(buildEnvelopeWithCredentials(for: connections)) - return try ConnectionExportCrypto.encrypt(data: jsonData, passphrase: passphrase) - } - - static func exportConnectionsEncrypted( - _ connections: [DatabaseConnection], - to url: URL, - passphrase: String - ) throws { - let encryptedData = try exportEncryptedData(connections, passphrase: passphrase) - - do { - try encryptedData.write(to: url, options: .atomic) - logger.info("Exported \(connections.count) encrypted connections to \(url.path)") - } catch { - throw ConnectionExportError.fileWriteFailed(url.path) - } + return try await ConnectionExportCrypto.encrypt(data: jsonData, passphrase: passphrase) } // MARK: - Import diff --git a/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift b/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift index 0c86733d73..fba36d99f7 100644 --- a/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift +++ b/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift @@ -17,6 +17,7 @@ struct ConnectionExportOptionsSheet: View { @State private var confirmPassphrase = "" @State private var exportDocument: ConnectionExportDocument? @State private var isExporting = false + @State private var isPreparingExport = false @State private var exportError: String? private var isProAvailable: Bool { @@ -44,6 +45,7 @@ struct ConnectionExportOptionsSheet: View { options .padding(20) + .disabled(isPreparingExport) Spacer(minLength: 0) @@ -53,6 +55,11 @@ struct ConnectionExportOptionsSheet: View { .padding(16) } .frame(width: 440, height: 300) + .task(id: isPreparingExport) { + guard isPreparingExport else { return } + await performExport() + isPreparingExport = false + } .fileExporter( isPresented: $isExporting, document: exportDocument, @@ -159,26 +166,39 @@ struct ConnectionExportOptionsSheet: View { private var footer: some View { DialogFooter { + if isPreparingExport { + ProgressView() + .controlSize(.small) + } + } actions: { Button("Cancel") { dismiss() } .keyboardShortcut(.cancelAction) - Button("Export…") { performExport() } + Button("Export…") { isPreparingExport = true } .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) - .disabled(!canExport) + .disabled(!canExport || isPreparingExport) } } - private func performExport() { + private func performExport() async { do { - let data = includeCredentials && isProAvailable - ? try ConnectionExportService.exportEncryptedData(connections, passphrase: passphrase) - : try ConnectionExportService.exportData(connections) + let data = try await exportPayload() + try Task.checkCancellation() passphrase = "" confirmPassphrase = "" exportDocument = ConnectionExportDocument(data: data) isExporting = true + } catch is CancellationError { + return } catch { exportError = error.localizedDescription } } + + private func exportPayload() async throws -> Data { + guard includeCredentials, isProAvailable else { + return try ConnectionExportService.exportData(connections) + } + return try await ConnectionExportService.exportEncryptedData(connections, passphrase: passphrase) + } } diff --git a/TablePro/Views/Connection/ConnectionImportSheet.swift b/TablePro/Views/Connection/ConnectionImportSheet.swift index 20fdce0a8d..6ff185e886 100644 --- a/TablePro/Views/Connection/ConnectionImportSheet.swift +++ b/TablePro/Views/Connection/ConnectionImportSheet.swift @@ -226,7 +226,7 @@ struct ConnectionImportSheet: View { Task.detached(priority: .userInitiated) { do { - let envelope = try ConnectionImportDecoder.decodeEncryptedData(data, passphrase: currentPassphrase) + let envelope = try await ConnectionImportDecoder.decodeEncryptedData(data, passphrase: currentPassphrase) let result = await ConnectionExportService.analyzeImport(envelope) await MainActor.run { passphraseError = nil diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index 9b146d0c29..f977ce5425 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -1,11 +1,9 @@ -import CoreSpotlight import Foundation import Observation import os import TableProConnectionLibrary import TableProDatabase import TableProModels -import WidgetKit @MainActor @Observable final class AppState { @@ -37,11 +35,14 @@ final class AppState { let syncCoordinator: IOSSyncCoordinator @ObservationIgnored private var automaticPresentationOwner: UUID? + @ObservationIgnored private var pastedSSHKeysAwaitingKeychain: [UUID: String] = [:] let libraryPreferences: ConnectionLibraryPreferences let sshProvider: IOSSSHProvider - let secureStore: KeychainSecureStore + let secureStore: any SecureStore + let localDatabaseFiles: LocalDatabaseFileLocator private let sampleInstaller: SampleDatabaseInstaller + private let libraryPublisher: ConnectionLibraryPublisher private let storage: ConnectionPersistence private let groupStorage: GroupPersistence private let tagStorage: TagPersistence @@ -49,20 +50,26 @@ final class AppState { init( libraryDirectory: URL = LibraryStorage.defaultDirectory, defaults: UserDefaults = .standard, + secureStore: any SecureStore = KeychainSecureStore(), syncCoordinator injectedSyncCoordinator: IOSSyncCoordinator? = nil, - sampleInstaller: SampleDatabaseInstaller = .live + sampleInstaller: SampleDatabaseInstaller = .live, + localDatabaseFiles: LocalDatabaseFileLocator = .live, + bookmarkStore: FileBookmarkStore = FileBookmarkStore(), + libraryPublisher: ConnectionLibraryPublisher? = nil ) { self.sampleInstaller = sampleInstaller + self.libraryPublisher = libraryPublisher ?? .live() + self.localDatabaseFiles = localDatabaseFiles + localDatabaseFiles.container.recordCurrentContainer() onboarding = OnboardingPreferences(defaults: defaults) libraryPreferences = ConnectionLibraryPreferences(defaults: defaults) syncCoordinator = injectedSyncCoordinator ?? IOSSyncCoordinator() storage = ConnectionPersistence(directory: libraryDirectory) groupStorage = GroupPersistence(directory: libraryDirectory) tagStorage = TagPersistence(directory: libraryDirectory) - let driverFactory = IOSDriverFactory() - let secureStore = KeychainSecureStore() + let driverFactory = IOSDriverFactory(bookmarkStore: bookmarkStore, localFiles: localDatabaseFiles) self.secureStore = secureStore - let sshProvider = IOSSSHProvider(secureStore: secureStore) + let sshProvider = IOSSSHProvider(secureStore: secureStore, container: localDatabaseFiles.container) self.sshProvider = sshProvider let connectionManager = ConnectionManager( driverFactory: driverFactory, @@ -76,18 +83,14 @@ final class AppState { guard !TestRuntime.isActive else { return } if loadStatus == .ready { - secureStore.cleanOrphanedCredentials(validConnectionIds: Set(connections.map(\.id))) + KeychainSecureStore.cleanOrphanedCredentials(validConnectionIds: Set(connections.map(\.id))) Task { publishLibrary() } } syncCoordinator.onConnectionsChanged = { [weak self] merged in - guard let self else { return } - guard merged != self.connections else { return } - self.persist(connections: merged) - self.updateWidgetData() - self.updateSpotlightIndex() + self?.applySyncedConnections(merged) } syncCoordinator.onGroupsChanged = { [weak self] merged in @@ -132,8 +135,14 @@ final class AppState { private func publishLibrary() { guard loadStatus == .ready else { return } - updateWidgetData() - updateSpotlightIndex() + libraryPublisher.publish(connections) + } + + func applySyncedConnections(_ merged: [DatabaseConnection]) { + guard !refuseWriteIfNotReady() else { return } + guard merged != connections else { return } + persist(connections: merged) + publishLibrary() } private func syncsConnection(_ id: UUID) -> Bool { @@ -142,7 +151,9 @@ final class AppState { private func loadPersistedData() { do { - connectionsState = .loaded(try storage.load()) + let stored = try storage.load() + connectionsState = .loaded(stored.connections) + movePastedSSHKeysToKeychain(from: stored) } catch { connectionsState = .failed(error) Self.logger.error("Connections load failed: \(error.localizedDescription, privacy: .public)") @@ -163,12 +174,33 @@ final class AppState { } } + private func movePastedSSHKeysToKeychain(from stored: StoredConnections) { + guard !stored.pastedSSHKeys.isEmpty else { return } + let unstored = ConnectionSecrets(secureStore: secureStore).storeMissingPrivateKeys(stored.pastedSSHKeys) + pastedSSHKeysAwaitingKeychain = unstored + let movedCount = stored.pastedSSHKeys.count - unstored.count + guard movedCount > 0 else { + Self.logger.error("No pasted SSH key reached the Keychain; the connections file keeps them until the next launch") + return + } + Self.logger.info("Moved \(movedCount) pasted SSH keys out of the connections file, \(unstored.count) left for the next launch") + do { + try storage.save(stored.connections, keepingPastedSSHKeys: unstored) + } catch { + Self.logger.error("Rewriting connections without SSH keys failed: \(error.localizedDescription, privacy: .public)") + } + } + // MARK: - Persistence Bridges private func persist(connections: [DatabaseConnection]) { connectionsState = .loaded(connections) + pastedSSHKeysAwaitingKeychain = PastedSSHKeyMigration.keysStillHeld( + pastedSSHKeysAwaitingKeychain, + by: connections + ) do { - try storage.save(connections) + try storage.save(connections, keepingPastedSSHKeys: pastedSSHKeysAwaitingKeychain) } catch { Self.logger.error("Failed to save connections: \(error.localizedDescription, privacy: .public)") } @@ -209,19 +241,29 @@ final class AppState { // MARK: - Connections + func isConnectionRemoved(_ id: UUID) -> Bool { + loadStatus == .ready && !connections.contains { $0.id == id } + } + + func offersHandoff(for connection: DatabaseConnection) -> Bool { + !connection.isSample && !isConnectionRemoved(connection.id) + } + @discardableResult func addConnection(_ connection: DatabaseConnection) -> Bool { apply(ConnectionLibraryEditing.adding(connection, to: connections, validGroupIds: validGroupIds)) } @discardableResult - func updateConnection(_ connection: DatabaseConnection) -> Bool { - guard let change = ConnectionLibraryEditing.updating( - connection, + func mutateConnection(_ id: UUID, _ mutate: (inout DatabaseConnection) -> Void) -> LibraryWriteOutcome { + guard !refuseWriteIfNotReady() else { return .refused } + guard let change = ConnectionLibraryEditing.mutatingConnection( + id, in: connections, - validGroupIds: validGroupIds - ) else { return false } - return apply(change) + validGroupIds: validGroupIds, + mutate + ) else { return .missing } + return apply(change) ? .applied : .unchanged } func reorderConnections(_ orderedIds: [UUID]) { @@ -312,23 +354,27 @@ final class AppState { // MARK: - Groups @discardableResult - func addGroup(_ group: ConnectionGroup) -> Bool { - guard !refuseWriteIfNotReady() else { return false } - guard let updated = ConnectionLibraryEditing.addingGroup(group, to: groups) else { return false } + func addGroup(_ group: ConnectionGroup) -> LibraryWriteOutcome { + guard !refuseWriteIfNotReady() else { return .refused } + guard let updated = ConnectionLibraryEditing.addingGroup(group, to: groups) else { return .invalidPlacement } persist(groups: updated) syncCoordinator.markDirtyGroup(group.id) syncCoordinator.scheduleSyncAfterChange() - return true + return .applied } @discardableResult - func updateGroup(_ group: ConnectionGroup) -> Bool { - guard !refuseWriteIfNotReady() else { return false } - guard let updated = ConnectionLibraryEditing.updatingGroup(group, in: groups) else { return false } - persist(groups: updated) - syncCoordinator.markDirtyGroup(group.id) + func mutateGroup(_ id: UUID, _ mutate: (inout ConnectionGroup) -> Void) -> LibraryWriteOutcome { + guard !refuseWriteIfNotReady() else { return .refused } + guard groups.contains(where: { $0.id == id }) else { return .missing } + guard let result = ConnectionLibraryEditing.mutatingGroup(id, in: groups, mutate) else { + return .invalidPlacement + } + guard result.changed else { return .unchanged } + persist(groups: result.groups) + syncCoordinator.markDirtyGroup(id) syncCoordinator.scheduleSyncAfterChange() - return true + return .applied } func reorderGroups(_ orderedIds: [UUID]) { @@ -361,45 +407,44 @@ final class AppState { // MARK: - Tags - func addTag(_ tag: ConnectionTag) { - guard !refuseWriteIfNotReady() else { return } + @discardableResult + func addTag(_ tag: ConnectionTag) -> LibraryWriteOutcome { + guard !refuseWriteIfNotReady() else { return .refused } var updated = tags updated.append(tag) persist(tags: updated) syncCoordinator.markDirtyTag(tag.id) syncCoordinator.scheduleSyncAfterChange() + return .applied } - func updateTag(_ tag: ConnectionTag) { - guard !refuseWriteIfNotReady() else { return } - var updated = tags - guard let index = updated.firstIndex(where: { $0.id == tag.id }) else { return } - updated[index] = tag - persist(tags: updated) - syncCoordinator.markDirtyTag(tag.id) + @discardableResult + func mutateTag(_ id: UUID, _ mutate: (inout ConnectionTag) -> Void) -> LibraryWriteOutcome { + guard !refuseWriteIfNotReady() else { return .refused } + guard let result = ConnectionLibraryEditing.mutatingTag(id, in: tags, mutate) else { return .missing } + guard result.changed else { return .unchanged } + persist(tags: result.tags) + syncCoordinator.markDirtyTag(id) syncCoordinator.scheduleSyncAfterChange() + return .applied } - func deleteTag(_ tagId: UUID) { - guard !refuseWriteIfNotReady() else { return } - guard let tag = tags.first(where: { $0.id == tagId }), !tag.isPreset else { return } - - var updatedTags = tags - updatedTags.removeAll { $0.id == tagId } - persist(tags: updatedTags) - - var updatedConnections = connections - for index in updatedConnections.indices where updatedConnections[index].tagIds.contains(tagId) { - updatedConnections[index].tagIds.removeAll { $0 == tagId } - if updatedConnections[index].participatesInSync { - syncCoordinator.markDirty(updatedConnections[index].id) - } + @discardableResult + func deleteTag(_ tagId: UUID) -> Bool { + guard !refuseWriteIfNotReady() else { return false } + guard let change = ConnectionLibraryEditing.deletingTag(tagId, tags: tags, connections: connections) else { + return false } - persist(connections: updatedConnections) + persist(connections: change.connections) + persist(tags: change.tags) publishLibrary() - syncCoordinator.markDeletedTag(tagId) + for id in change.changedConnectionIds where syncsConnection(id) { + syncCoordinator.markDirty(id) + } + syncCoordinator.markDeletedTag(change.removedTagId) syncCoordinator.scheduleSyncAfterChange() + return true } // MARK: - First Run @@ -481,44 +526,6 @@ final class AppState { sampleResetRevision += 1 } - // MARK: - Spotlight - - private func updateSpotlightIndex() { - let items = connections.map { conn in - let attributes = CSSearchableItemAttributeSet(contentType: .item) - attributes.title = conn.name.isEmpty ? conn.host : conn.name - attributes.contentDescription = [conn.type.mobileDisplayName, ConnectionDetailFormatter.detail(for: conn)] - .joined(separator: ", ") - return CSSearchableItem( - uniqueIdentifier: conn.id.uuidString, - domainIdentifier: "com.TablePro.connections", - attributeSet: attributes - ) - } - if items.isEmpty { - CSSearchableIndex.default().deleteAllSearchableItems() - } else { - CSSearchableIndex.default().indexSearchableItems(items) - } - } - - // MARK: - Widget - - private func updateWidgetData() { - let items = connections - .sorted { ($0.sortOrder, $0.name) < ($1.sortOrder, $1.name) } - .map { conn in - WidgetConnectionItem( - id: conn.id, - name: conn.name.isEmpty ? conn.host : conn.name, - type: conn.type.rawValue, - sortOrder: conn.sortOrder - ) - } - SharedConnectionStore.write(items) - WidgetCenter.shared.reloadAllTimelines() - } - // MARK: - Helpers func group(for id: UUID?) -> ConnectionGroup? { @@ -542,6 +549,11 @@ nonisolated enum LibraryStorage { } } +private struct StoredConnections { + let connections: [DatabaseConnection] + let pastedSSHKeys: [UUID: String] +} + private struct ConnectionPersistence { let directory: URL @@ -550,18 +562,20 @@ private struct ConnectionPersistence { return directory.appendingPathComponent("connections.json") } - func save(_ connections: [DatabaseConnection]) throws { + func save(_ connections: [DatabaseConnection], keepingPastedSSHKeys pastedSSHKeys: [UUID: String]) throws { guard let fileURL else { return } - let data = try JSONEncoder().encode(connections) + let data = try PastedSSHKeyMigration.libraryFile(JSONEncoder().encode(connections), keeping: pastedSSHKeys) try data.write(to: fileURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) } - func load() throws -> [DatabaseConnection] { - guard let fileURL else { return [] } - if !FileManager.default.fileExists(atPath: fileURL.path) { - return [] + func load() throws -> StoredConnections { + guard let fileURL, FileManager.default.fileExists(atPath: fileURL.path) else { + return StoredConnections(connections: [], pastedSSHKeys: [:]) } let data = try Data(contentsOf: fileURL) - return try JSONDecoder().decode([DatabaseConnection].self, from: data) + return StoredConnections( + connections: try JSONDecoder().decode([DatabaseConnection].self, from: data), + pastedSSHKeys: PastedSSHKeyMigration.pendingKeys(inLibraryFile: data) + ) } } diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index c65754040d..97ba62d3b6 100644 --- a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -7,7 +7,7 @@ import TableProModels @MainActor @Observable final class ConnectionCoordinator { - let connection: DatabaseConnection + private(set) var connection: DatabaseConnection private(set) var session: ConnectionSession? private(set) var phase: ConnectionPhase = .connecting @@ -30,7 +30,6 @@ final class ConnectionCoordinator { var pendingQuery: String? var pendingTableName: String? var tablesPath = NavigationPath() - var showingEditSheet = false private(set) var queryHistory: [QueryHistoryItem] = [] private let historyStorage = QueryHistoryStorage() @@ -106,14 +105,23 @@ final class ConnectionCoordinator { if connectTask == task { connectTask = nil } } - /// Never waits on the driver: `Task.cancel()` is cooperative and these drivers ignore it. - func cancelConnect() { - guard connectTask != nil else { return } + func adopt(_ record: DatabaseConnection) { + guard record.id == connection.id, record != connection else { return } + connection = record + } + + func retire() { attemptToken = UUID() connectTask?.cancel() connectTask = nil appState.connectionManager.invalidateAttempt(for: connection.id) session = nil + } + + /// Never waits on the driver: `Task.cancel()` is cooperative and these drivers ignore it. + func cancelConnect() { + guard connectTask != nil else { return } + retire() phase = .error(Self.cancelledError) } diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinatorStore.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinatorStore.swift index aa4b160245..1120e1fd8f 100644 --- a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinatorStore.swift +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinatorStore.swift @@ -3,22 +3,25 @@ import Observation import TableProDatabase import TableProModels -/// Owns the live coordinator per connection, so a presented screen never writes back into the -/// state of the screen presenting it. @MainActor @Observable final class ConnectionCoordinatorStore { - /// Bumped whenever an entry is retired, so a screen already showing a coordinator rebuilds it - /// instead of going on talking to a driver that has been disconnected underneath it. - private(set) var revision = 0 + private(set) var generations: [UUID: Int] = [:] private var coordinators: [UUID: ConnectionCoordinator] = [:] + private var removedRecords: [UUID: DatabaseConnection] = [:] + private var isHoldingRebuilds = false + private var heldRebuilds: [UUID: Bool] = [:] private let connectionManager: ConnectionManager init(connectionManager: ConnectionManager) { self.connectionManager = connectionManager } + func generation(for id: UUID) -> Int { + generations[id, default: 0] + } + func coordinator(for connection: DatabaseConnection, appState: AppState) -> ConnectionCoordinator { if let existing = coordinators[connection.id] { return existing } let created = ConnectionCoordinator(connection: connection, appState: appState) @@ -27,24 +30,81 @@ final class ConnectionCoordinatorStore { return created } + func presentedRecord(for id: UUID, in connections: [DatabaseConnection]) -> DatabaseConnection? { + connections.first { $0.id == id } ?? coordinators[id]?.connection ?? removedRecords[id] + } + + func discardRemovedRecords() { + removedRecords.removeAll() + } + func invalidate(_ id: UUID, droppingSession: Bool = true) { - let removed = coordinators.removeValue(forKey: id) - removed?.cancelConnect() - revision += 1 + guard !isHoldingRebuilds else { + heldRebuilds[id] = heldRebuilds[id, default: false] || droppingSession + return + } + rebuild(id, droppingSession: droppingSession) + } + + func holdRebuilds(_ isHolding: Bool) { + isHoldingRebuilds = isHolding + guard !isHolding else { return } + let released = heldRebuilds + heldRebuilds.removeAll() + for (id, droppingSession) in released { + rebuild(id, droppingSession: droppingSession) + } + } + + func reconcile(from old: [DatabaseConnection], to new: [DatabaseConnection]) { + for change in ConnectionRecordChange.changes(from: old, to: new) { + switch change { + case .edited(let record): + coordinators[record.id]?.adopt(record) + case .redialed(let record): + coordinators[record.id]?.adopt(record) + invalidate(record.id) + case .removed(let id): + remove(id) + } + } + } + + private func rebuild(_ id: UUID, droppingSession: Bool) { + coordinators.removeValue(forKey: id)?.retire() + generations[id, default: 0] += 1 guard droppingSession else { return } + disconnect(id) + } + + private func remove(_ id: UUID) { + heldRebuilds[id] = nil + if let retired = coordinators.removeValue(forKey: id) { + removedRecords[id] = retired.connection + retired.retire() + } + disconnect(id) + } + + private func disconnect(_ id: UUID) { let manager = connectionManager Task { await manager.disconnect(id) } } +} - /// Only a change to how the app dials drops the live session. Sorting, grouping, tagging and - /// renaming rewrite every connection, and a dragged row must not close a working one. - func reconcile(from old: [DatabaseConnection], to new: [DatabaseConnection]) { - let updated = Dictionary(uniqueKeysWithValues: new.map { ($0.id, $0) }) - for previous in old { - let current = updated[previous.id] - guard current != previous else { continue } - let redials = current.map { !$0.dialsTheSameWay(as: previous) } ?? true - invalidate(previous.id, droppingSession: redials) +nonisolated enum ConnectionRecordChange: Equatable, Sendable { + case edited(DatabaseConnection) + case redialed(DatabaseConnection) + case removed(UUID) + + static func changes(from old: [DatabaseConnection], to new: [DatabaseConnection]) -> [ConnectionRecordChange] { + let current = Dictionary(new.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + var seen: Set = [] + return old.compactMap { previous in + guard seen.insert(previous.id).inserted else { return nil } + guard let record = current[previous.id] else { return .removed(previous.id) } + guard record != previous else { return nil } + return record.dialsTheSameWay(as: previous) ? .edited(record) : .redialed(record) } } } @@ -61,5 +121,6 @@ nonisolated extension DatabaseConnection { && sslEnabled == other.sslEnabled && sslConfiguration == other.sslConfiguration && additionalFields == other.additionalFields + && isSample == other.isSample } } diff --git a/TableProMobile/TableProMobile/Coordinators/SceneEditorHold.swift b/TableProMobile/TableProMobile/Coordinators/SceneEditorHold.swift new file mode 100644 index 0000000000..96e73115b4 --- /dev/null +++ b/TableProMobile/TableProMobile/Coordinators/SceneEditorHold.swift @@ -0,0 +1,41 @@ +import Foundation +import SwiftUI + +@MainActor +final class SceneEditorHold { + private weak var presenter: ScenePresenter? + private let token = UUID() + + func update(isHolding: Bool, in presenter: ScenePresenter) { + self.presenter = presenter + presenter.setEditorHold(token, isHolding: isHolding) + } + + deinit { + guard let presenter else { return } + let token = token + Task { @MainActor in + presenter.setEditorHold(token, isHolding: false) + } + } +} + +extension View { + func holdsScene(withUnsavedChanges isHolding: Bool) -> some View { + modifier(SceneEditorHoldModifier(isHolding: isHolding)) + } +} + +private struct SceneEditorHoldModifier: ViewModifier { + let isHolding: Bool + + @Environment(ScenePresenter.self) private var presenter: ScenePresenter? + @State private var hold = SceneEditorHold() + + func body(content: Content) -> some View { + content.onChange(of: isHolding, initial: true) { _, holding in + guard let presenter else { return } + hold.update(isHolding: holding, in: presenter) + } + } +} diff --git a/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift b/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift index 17ac0a1c21..3c663fab7f 100644 --- a/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift +++ b/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift @@ -49,9 +49,13 @@ final class ScenePresenter { let sceneId = UUID() var sheet: SceneSheet? + private(set) var editingConnectionId: UUID? private(set) var pendingIntent: SceneIntent? private(set) var pendingTable: PendingTableRequest? private(set) var holdsConnectionRestore = false + private(set) var editorHolds: Set = [] + + var isHeldByEditor: Bool { !editorHolds.isEmpty } @ObservationIgnored private var hasBegunLaunch = false @ObservationIgnored private var presentedLaunchSheet = false @@ -92,12 +96,33 @@ final class ScenePresenter { sheet = newSheet } + func presentConnectionEditor(for connectionId: UUID) { + editingConnectionId = connectionId + } + + func dismissConnectionEditor() { + editingConnectionId = nil + } + + func isEditingConnection(_ connectionId: UUID) -> Bool { + editingConnectionId == connectionId + } + func receive(_ intent: SceneIntent) { pendingIntent = intent } + func setEditorHold(_ token: UUID, isHolding: Bool) { + guard editorHolds.contains(token) != isHolding else { return } + if isHolding { + editorHolds.insert(token) + } else { + editorHolds.remove(token) + } + } + func takeDeliverableIntent(isLocked: Bool, isLibraryWritable: Bool) -> SceneIntent? { - guard let pendingIntent, sheet == nil, !isLocked, !holdsConnectionRestore else { return nil } + guard let pendingIntent, sheet == nil, !isLocked, !holdsConnectionRestore, !isHeldByEditor else { return nil } if case .importConnections = pendingIntent, !isLibraryWritable { return nil } diff --git a/TableProMobile/TableProMobile/Drivers/DuckDBDriver.swift b/TableProMobile/TableProMobile/Drivers/DuckDBDriver.swift index b7dda78304..caa5b89cae 100644 --- a/TableProMobile/TableProMobile/Drivers/DuckDBDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/DuckDBDriver.swift @@ -5,20 +5,18 @@ import TableProDatabase import TableProModels nonisolated final class DuckDBDriver: DatabaseDriver, @unchecked Sendable { - static let inMemoryPath = ":memory:" - let actor = DuckDBActor() - private let dbPath: String - private let bookmark: Data? + private let source: LocalDatabaseFileSource + private let openMode: LocalDatabaseOpenMode + private let fileAccess = LocalDatabaseFileAccess() private let stateLock = NSLock() private var currentSchemaName = "main" - private var securedURL: URL? nonisolated(unsafe) private var interruptHandle: duckdb_connection? var supportsSchemas: Bool { true } var supportsTransactions: Bool { true } var serverVersion: String? { String(cString: duckdb_library_version()) } - var holdsSuspensionBlockingResource: Bool { dbPath != Self.inMemoryPath } + var holdsSuspensionBlockingResource: Bool { source != .inMemory } var currentSchema: String? { stateLock.lock() @@ -26,16 +24,21 @@ nonisolated final class DuckDBDriver: DatabaseDriver, @unchecked Sendable { return currentSchemaName } - init(path: String, bookmark: Data?) { - self.dbPath = path - self.bookmark = bookmark + init(source: LocalDatabaseFileSource, openMode: LocalDatabaseOpenMode = .existingOnly) { + self.source = source + self.openMode = openMode } // MARK: - Connection func connect() async throws { - let resolvedPath = try resolvePath() - try await actor.open(path: resolvedPath) + let path = try fileAccess.begin(source, openMode: openMode) + do { + try await actor.open(path: path) + } catch { + fileAccess.end() + throw error + } try? await actor.query("SET autoinstall_known_extensions=false") try? await actor.query("SET autoload_known_extensions=false") setInterruptHandle(await actor.connectionHandle.connection) @@ -44,9 +47,7 @@ nonisolated final class DuckDBDriver: DatabaseDriver, @unchecked Sendable { func disconnect() async throws { setInterruptHandle(nil) await actor.close() - if let url = takeSecuredURL() { - url.stopAccessingSecurityScopedResource() - } + fileAccess.end() } func ping() async throws -> Bool { @@ -54,36 +55,6 @@ nonisolated final class DuckDBDriver: DatabaseDriver, @unchecked Sendable { return true } - private func resolvePath() throws -> String { - if dbPath == Self.inMemoryPath { - return Self.inMemoryPath - } - - if let bookmark { - var isStale = false - let url = try URL( - resolvingBookmarkData: bookmark, - options: [], - relativeTo: nil, - bookmarkDataIsStale: &isStale - ) - guard url.startAccessingSecurityScopedResource() else { - throw DuckDBDriverError.connectionFailed("Cannot access the DuckDB file. Open it again to grant access.") - } - setSecuredURL(url) - return url.path - } - - let expanded = (dbPath as NSString).expandingTildeInPath - if !FileManager.default.fileExists(atPath: expanded) { - let directory = (expanded as NSString).deletingLastPathComponent - if !directory.isEmpty { - try? FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) - } - } - return expanded - } - // MARK: - Query Execution func execute(query: String) async throws -> QueryResult { @@ -144,20 +115,6 @@ nonisolated final class DuckDBDriver: DatabaseDriver, @unchecked Sendable { interruptHandle = handle stateLock.unlock() } - - private func setSecuredURL(_ url: URL) { - stateLock.lock() - securedURL = url - stateLock.unlock() - } - - private func takeSecuredURL() -> URL? { - stateLock.lock() - defer { stateLock.unlock() } - let url = securedURL - securedURL = nil - return url - } } // MARK: - DuckDB Actor (thread-safe C API access) diff --git a/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift b/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift index cb3ea2b05b..0d49e096e4 100644 --- a/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift @@ -4,7 +4,9 @@ import TableProDatabase import TableProModels nonisolated final class SQLiteDriver: DatabaseDriver, @unchecked Sendable { - private let dbPath: String + private let source: LocalDatabaseFileSource + private let openMode: LocalDatabaseOpenMode + private let fileAccess = LocalDatabaseFileAccess() private let actor = SQLiteActor() var supportsSchemas: Bool { false } @@ -12,25 +14,31 @@ nonisolated final class SQLiteDriver: DatabaseDriver, @unchecked Sendable { var supportsTransactions: Bool { true } var serverVersion: String? { String(cString: sqlite3_libversion()) } - init(path: String) { - self.dbPath = path + init(source: LocalDatabaseFileSource, openMode: LocalDatabaseOpenMode = .existingOnly) { + self.source = source + self.openMode = openMode } // MARK: - Connection func connect() async throws { - let expanded = (dbPath as NSString).expandingTildeInPath - - if !FileManager.default.fileExists(atPath: expanded) { - let dir = (expanded as NSString).deletingLastPathComponent - try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + let path = try fileAccess.begin(source, openMode: openMode) + do { + try await actor.open(path: path, flags: openFlags) + } catch { + fileAccess.end() + throw error } - - try await actor.open(path: expanded) } func disconnect() async throws { await actor.close() + fileAccess.end() + } + + private var openFlags: Int32 { + guard openMode == .createNew || source == .inMemory else { return SQLITE_OPEN_READWRITE } + return SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE } func ping() async throws -> Bool { @@ -126,7 +134,7 @@ nonisolated final class SQLiteDriver: DatabaseDriver, @unchecked Sendable { """) return raw.rows.compactMap { row in - guard row.count > 0, let name = row[0] else { return nil } + guard !row.isEmpty, let name = row[0] else { return nil } let kind: TableInfo.TableKind = (row.count > 1 ? row[1] : nil)?.lowercased() == "view" ? .view : .table return TableInfo(name: name, type: kind, rowCount: nil, dataSize: nil, comment: nil) } @@ -260,14 +268,14 @@ nonisolated final class SQLiteDriver: DatabaseDriver, @unchecked Sendable { private actor SQLiteActor { private var db: OpaquePointer? - func open(path: String) throws { - if sqlite3_open(path, &db) != SQLITE_OK { + func open(path: String, flags: Int32) throws { + if sqlite3_open_v2(path, &db, flags, nil) != SQLITE_OK { let msg = db.map { String(cString: sqlite3_errmsg($0)) } ?? "Unknown error" if let db { sqlite3_close(db) } self.db = nil throw SQLiteError.connectionFailed(msg) } - sqlite3_busy_timeout(db, 5000) + sqlite3_busy_timeout(db, 5_000) } func close() { diff --git a/TableProMobile/TableProMobile/Helpers/AppError.swift b/TableProMobile/TableProMobile/Helpers/AppError.swift index 8dbd17162c..b8630b3e83 100644 --- a/TableProMobile/TableProMobile/Helpers/AppError.swift +++ b/TableProMobile/TableProMobile/Helpers/AppError.swift @@ -97,6 +97,16 @@ nonisolated enum ErrorClassifier { logger.error("[\(context.operation)] \(error.localizedDescription, privacy: .public)") + if let fileError = error as? LocalDatabaseFileError { + return AppError( + category: .config, + title: String(localized: "Database File Unavailable"), + message: fileError.localizedDescription, + recovery: fileError.recoverySuggestion, + underlying: error + ) + } + if error is LocalNetworkPermissionError { return AppError( category: .network, diff --git a/TableProMobile/TableProMobile/Helpers/ConfirmedWriteGate.swift b/TableProMobile/TableProMobile/Helpers/ConfirmedWriteGate.swift new file mode 100644 index 0000000000..cff56557f2 --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/ConfirmedWriteGate.swift @@ -0,0 +1,35 @@ +import Foundation +import TableProModels + +nonisolated struct ConfirmedWriteGate: Equatable, Sendable { + nonisolated enum Decision: Equatable, Sendable { + case run(String) + case awaitConfirmation + case blocked + } + + private(set) var pendingStatement: String? + + mutating func submit(_ statement: String, under level: SafeModeLevel) -> Decision { + pendingStatement = nil + switch level.writePermission { + case .blocked: + return .blocked + case .requiresConfirmation: + pendingStatement = statement + return .awaitConfirmation + case .proceed: + return .run(statement) + } + } + + mutating func confirm(under level: SafeModeLevel) -> String? { + defer { pendingStatement = nil } + guard !level.blocksWrites else { return nil } + return pendingStatement + } + + mutating func cancel() { + pendingStatement = nil + } +} diff --git a/TableProMobile/TableProMobile/Helpers/ConnectionDetailFormatter.swift b/TableProMobile/TableProMobile/Helpers/ConnectionDetailFormatter.swift index 94f3aa8649..3eac5dd75d 100644 --- a/TableProMobile/TableProMobile/Helpers/ConnectionDetailFormatter.swift +++ b/TableProMobile/TableProMobile/Helpers/ConnectionDetailFormatter.swift @@ -2,8 +2,6 @@ import Foundation import TableProModels nonisolated enum ConnectionDetailFormatter { - static let inMemoryDatabasePath = ":memory:" - static func detail(for connection: DatabaseConnection) -> String { switch connection.type { case .sqlite, .duckdb: @@ -14,7 +12,7 @@ nonisolated enum ConnectionDetailFormatter { } private static func fileDetail(_ path: String) -> String { - guard path != inMemoryDatabasePath else { return String(localized: "In Memory") } + guard path != LocalDatabaseLocation.inMemoryPath else { return String(localized: "In Memory") } let name = (path as NSString).lastPathComponent return name.isEmpty ? path : name } diff --git a/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift b/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift new file mode 100644 index 0000000000..8c2e50f6a5 --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift @@ -0,0 +1,127 @@ +import Foundation +import TableProModels +import TableProOracleCore + +nonisolated struct ConnectionFormEdits: Equatable, Sendable { + nonisolated struct SSHTunnel: Equatable, Sendable { + var host: String + var port: Int + var username: String + var authMethod: SSHConfiguration.SSHAuthMethod + var privateKeyPath: String? + } + + nonisolated struct OracleOptions: Equatable, Sendable { + var identifierMode: OracleConnectionOptions.IdentifierMode + var serviceName: String + var sid: String + var role: OracleConnectionOptions.Role + var networkEncryption: OracleConnectionOptions.NetworkEncryption + } + + var name: String + var type: DatabaseType + var host: String + var port: Int + var username: String + var database: String + var groupId: UUID? + var tagId: UUID? + var safeModeLevel: SafeModeLevel + var sslMode: SSLConfiguration.SSLMode? + var sshTunnel: SSHTunnel? + var oracle: OracleOptions? + + static func tagIds(selecting tagId: UUID?, over existing: [UUID]) -> [UUID] { + let others = existing.dropFirst().filter { $0 != tagId } + guard let tagId else { return Array(others) } + return [tagId] + others + } + + func applied(to base: DatabaseConnection, changedSince opening: ConnectionFormEdits?) -> DatabaseConnection { + func changed(_ field: KeyPath) -> Bool { + Self.differs(field, from: opening, to: self) + } + + var connection = base + if changed(\.name) { connection.name = name } + if changed(\.type) { connection.type = type } + if changed(\.host) { connection.host = host } + if changed(\.port) { connection.port = port } + if changed(\.username) { connection.username = username } + if changed(\.database) { connection.database = database } + if changed(\.groupId) { connection.groupId = groupId } + if changed(\.tagId) { connection.tagIds = Self.tagIds(selecting: tagId, over: connection.tagIds) } + if changed(\.safeModeLevel) { + connection.safeModeLevel = safeModeLevel + connection.isReadOnly = safeModeLevel.blocksWrites + } + if changed(\.sslMode) { applySSLMode(to: &connection) } + if changed(\.sshTunnel) { applySSHTunnel(to: &connection, changedSince: opening?.sshTunnel) } + if changed(\.oracle) { applyOracleOptions(to: &connection, changedSince: opening?.oracle) } + return connection + } + + private static func differs( + _ field: KeyPath, + from opening: Root?, + to current: Root + ) -> Bool { + guard let opening else { return true } + return opening[keyPath: field] != current[keyPath: field] + } + + private func applySSLMode(to connection: inout DatabaseConnection) { + guard let sslMode else { + connection.sslEnabled = false + return + } + var configuration = connection.sslConfiguration ?? SSLConfiguration() + configuration.mode = sslMode + connection.sslConfiguration = configuration + connection.sslEnabled = sslMode != .disable + } + + private func applySSHTunnel(to connection: inout DatabaseConnection, changedSince opening: SSHTunnel?) { + guard let sshTunnel else { + connection.sshEnabled = false + connection.sshConfiguration = nil + return + } + func changed(_ field: KeyPath) -> Bool { + Self.differs(field, from: opening, to: sshTunnel) + } + + var configuration = connection.sshConfiguration ?? SSHConfiguration() + if changed(\.host) { configuration.host = sshTunnel.host } + if changed(\.port) { configuration.port = sshTunnel.port } + if changed(\.username) { configuration.username = sshTunnel.username } + if changed(\.authMethod) { configuration.authMethod = sshTunnel.authMethod } + if changed(\.privateKeyPath) { configuration.privateKeyPath = sshTunnel.privateKeyPath } + if opening == nil { + connection.sshEnabled = true + if configuration.macEnabled != nil { + configuration.macEnabled = true + } + } + connection.sshConfiguration = configuration + } + + private func applyOracleOptions(to connection: inout DatabaseConnection, changedSince opening: OracleOptions?) { + guard let oracle else { return } + func changed(_ field: KeyPath) -> Bool { + Self.differs(field, from: opening, to: oracle) + } + + typealias Key = OracleConnectionOptions.AdditionalFieldKey + if changed(\.identifierMode) { + connection.additionalFields[Key.connectionType] = oracle.identifierMode.rawValue + } + if changed(\.serviceName) { connection.additionalFields[Key.serviceName] = oracle.serviceName } + if changed(\.sid) { connection.additionalFields[Key.sid] = oracle.sid } + if changed(\.role) { connection.additionalFields[Key.role] = oracle.role.rawValue } + if changed(\.networkEncryption) { + connection.additionalFields[Key.networkEncryption] = oracle.networkEncryption.rawValue + } + } +} diff --git a/TableProMobile/TableProMobile/Helpers/ConnectionLibraryEditing.swift b/TableProMobile/TableProMobile/Helpers/ConnectionLibraryEditing.swift index b4fce66700..12a9ce5857 100644 --- a/TableProMobile/TableProMobile/Helpers/ConnectionLibraryEditing.swift +++ b/TableProMobile/TableProMobile/Helpers/ConnectionLibraryEditing.swift @@ -15,6 +15,13 @@ nonisolated struct GroupLibraryChange: Equatable, Sendable { let changedConnectionIds: [UUID] } +nonisolated struct TagLibraryChange: Equatable, Sendable { + let tags: [ConnectionTag] + let connections: [DatabaseConnection] + let removedTagId: UUID + let changedConnectionIds: [UUID] +} + nonisolated enum ConnectionLibraryEditing { static func effectiveGroupId(of connection: DatabaseConnection, validGroupIds: Set) -> UUID? { connection.groupId.flatMap { validGroupIds.contains($0) ? $0 : nil } @@ -46,24 +53,31 @@ nonisolated enum ConnectionLibraryEditing { return ConnectionLibraryChange(connections: connections + [placed], changedConnectionIds: [placed.id]) } - static func updating( - _ connection: DatabaseConnection, + static func mutatingConnection( + _ id: UUID, in connections: [DatabaseConnection], - validGroupIds: Set + validGroupIds: Set, + _ mutate: (inout DatabaseConnection) -> Void ) -> ConnectionLibraryChange? { - guard let index = connections.firstIndex(where: { $0.id == connection.id }) else { return nil } - var updated = connection - let targetGroup = effectiveGroupId(of: connection, validGroupIds: validGroupIds) - if effectiveGroupId(of: connections[index], validGroupIds: validGroupIds) != targetGroup { + guard let index = connections.firstIndex(where: { $0.id == id }) else { return nil } + let stored = connections[index] + var updated = stored + mutate(&updated) + updated.id = id + let targetGroup = effectiveGroupId(of: updated, validGroupIds: validGroupIds) + if effectiveGroupId(of: stored, validGroupIds: validGroupIds) != targetGroup { updated.sortOrder = nextSortOrder( - in: connections.filter { $0.id != connection.id }, + in: connections.filter { $0.id != id }, groupId: targetGroup, validGroupIds: validGroupIds ) } + guard updated != stored else { + return ConnectionLibraryChange(connections: connections, changedConnectionIds: []) + } var result = connections result[index] = updated - return ConnectionLibraryChange(connections: result, changedConnectionIds: [updated.id]) + return ConnectionLibraryChange(connections: result, changedConnectionIds: [id]) } static func moving( @@ -185,19 +199,42 @@ nonisolated enum ConnectionLibraryEditing { return groups + [placed] } - static func updatingGroup(_ group: ConnectionGroup, in groups: [ConnectionGroup]) -> [ConnectionGroup]? { - guard let index = groups.firstIndex(where: { $0.id == group.id }) else { return nil } - var updated = group - if group.parentId != groups[index].parentId { - let graph = LibraryGroupGraph(groups: groups) - guard graph.canPlace(group.id, under: group.parentId) else { return nil } + static func mutatingGroup( + _ id: UUID, + in groups: [ConnectionGroup], + _ mutate: (inout ConnectionGroup) -> Void + ) -> (groups: [ConnectionGroup], changed: Bool)? { + guard let index = groups.firstIndex(where: { $0.id == id }) else { return nil } + let stored = groups[index] + var updated = stored + mutate(&updated) + updated.id = id + if updated.parentId != stored.parentId { + guard LibraryGroupGraph(groups: groups).canPlace(id, under: updated.parentId) else { return nil } updated.sortOrder = LibraryOrdering.nextSortOrder( - after: groups.filter { $0.parentId == group.parentId && $0.id != group.id }.map(\.sortOrder) + after: groups.filter { $0.parentId == updated.parentId && $0.id != id }.map(\.sortOrder) ) } + guard updated != stored else { return (groups, false) } var result = groups result[index] = updated - return result + return (result, true) + } + + static func mutatingTag( + _ id: UUID, + in tags: [ConnectionTag], + _ mutate: (inout ConnectionTag) -> Void + ) -> (tags: [ConnectionTag], changed: Bool)? { + guard let index = tags.firstIndex(where: { $0.id == id }) else { return nil } + let stored = tags[index] + var updated = stored + mutate(&updated) + updated.id = id + guard updated != stored else { return (tags, false) } + var result = tags + result[index] = updated + return (result, true) } static func reorderingGroups(_ orderedIds: [UUID], in groups: [ConnectionGroup]) -> (groups: [ConnectionGroup], changed: [UUID]) { @@ -240,6 +277,33 @@ nonisolated enum ConnectionLibraryEditing { ) } + static func deletingTag( + _ tagId: UUID, + tags: [ConnectionTag], + connections: [DatabaseConnection] + ) -> TagLibraryChange? { + guard let tag = tags.first(where: { $0.id == tagId }), !tag.isPreset else { return nil } + let stripped = applying(to: connections) { connection in + connection.tagIds.removeAll { $0 == tagId } + } + return TagLibraryChange( + tags: tags.filter { $0.id != tagId }, + connections: stripped.connections, + removedTagId: tagId, + changedConnectionIds: stripped.changedConnectionIds + ) + } + + static func tagDeletionRequest( + _ tagId: UUID, + tags: [ConnectionTag], + connections: [DatabaseConnection] + ) -> TagDeletionRequest? { + guard let change = deletingTag(tagId, tags: tags, connections: connections), + let tag = tags.first(where: { $0.id == tagId }) else { return nil } + return TagDeletionRequest(tag: tag, connectionCount: change.changedConnectionIds.count) + } + static func tagUsageCounts(in connections: [DatabaseConnection]) -> [UUID: Int] { var counts: [UUID: Int] = [:] for connection in connections { diff --git a/TableProMobile/TableProMobile/Helpers/LibraryFormEdits.swift b/TableProMobile/TableProMobile/Helpers/LibraryFormEdits.swift new file mode 100644 index 0000000000..98119bd88c --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/LibraryFormEdits.swift @@ -0,0 +1,95 @@ +import Foundation +import TableProModels + +nonisolated struct GroupFormEdits: Equatable, Sendable { + let name: String + let color: ConnectionColor + let parentId: UUID? + + init(name: String, color: ConnectionColor, parentId: UUID?) { + self.name = name.trimmingCharacters(in: .whitespaces) + self.color = color + self.parentId = parentId + } + + init(group: ConnectionGroup) { + self.init(name: group.name, color: group.color, parentId: group.parentId) + } + + init(opening group: ConnectionGroup?, parentId: UUID?) { + guard let group else { + self.init(name: "", color: .none, parentId: parentId) + return + } + self.init(group: group) + } + + func applied(to base: ConnectionGroup, changedSince opening: GroupFormEdits?) -> ConnectionGroup { + func changed(_ field: KeyPath) -> Bool { + guard let opening else { return true } + return opening[keyPath: field] != self[keyPath: field] + } + + var group = base + if changed(\.name) { group.name = name } + if changed(\.color) { group.color = color } + if changed(\.parentId) { group.parentId = parentId } + return group + } +} + +nonisolated struct TagFormEdits: Equatable, Sendable { + let name: String + let color: ConnectionColor + + init(name: String, color: ConnectionColor) { + self.name = name + self.color = color + } + + init(tag: ConnectionTag) { + self.init(name: tag.name, color: tag.color) + } + + init(opening tag: ConnectionTag?) { + guard let tag else { + self.init(name: "", color: .gray) + return + } + self.init(tag: tag) + } + + func applied(to base: ConnectionTag, changedSince opening: TagFormEdits?) -> ConnectionTag { + func changed(_ field: KeyPath) -> Bool { + guard let opening else { return true } + return opening[keyPath: field] != self[keyPath: field] + } + + var tag = base + if changed(\.name) { tag.name = name } + if changed(\.color) { tag.color = color } + return tag + } +} + +extension GroupFormEdits { + @MainActor + func save(editing existing: ConnectionGroup?, in appState: AppState) -> LibraryWriteOutcome { + guard let existing else { + return appState.addGroup(applied(to: ConnectionGroup(), changedSince: nil)) + } + let opening = GroupFormEdits(group: existing) + return appState.mutateGroup(existing.id) { $0 = applied(to: $0, changedSince: opening) } + } +} + +extension TagFormEdits { + @MainActor + func save(editing existing: ConnectionTag?, in appState: AppState) -> LibraryWriteOutcome { + guard let existing else { + return appState.addTag(applied(to: ConnectionTag(), changedSince: nil)) + } + let opening = TagFormEdits(tag: existing) + return appState.mutateTag(existing.id) { $0 = applied(to: $0, changedSince: opening) } + } +} diff --git a/TableProMobile/TableProMobile/Helpers/LibraryWriteOutcome.swift b/TableProMobile/TableProMobile/Helpers/LibraryWriteOutcome.swift new file mode 100644 index 0000000000..69b70fd48b --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/LibraryWriteOutcome.swift @@ -0,0 +1,75 @@ +import Foundation + +nonisolated enum LibraryWriteOutcome: Equatable, Sendable { + case applied + case unchanged + case missing + case refused + case invalidPlacement + + var isSaved: Bool { + self == .applied || self == .unchanged + } +} + +nonisolated enum LibraryItemKind: Equatable, Sendable { + case connection + case group + case tag +} + +nonisolated enum LibraryWriteFailure: Equatable, Sendable { + case removed(LibraryItemKind) + case libraryUnavailable(LibraryItemKind) + case invalidPlacement + + init?(_ outcome: LibraryWriteOutcome, kind: LibraryItemKind) { + switch outcome { + case .applied, .unchanged: + return nil + case .missing: + self = .removed(kind) + case .refused: + self = .libraryUnavailable(kind) + case .invalidPlacement: + self = .invalidPlacement + } + } + + var closesForm: Bool { + guard case .removed = self else { return false } + return true + } + + var title: String { + switch self { + case .removed(.connection): + String(localized: "Connection Deleted") + case .removed(.group): + String(localized: "Group Deleted") + case .removed(.tag): + String(localized: "Tag Deleted") + case .libraryUnavailable(.connection): + String(localized: "Connection Not Saved") + case .libraryUnavailable(.group), .invalidPlacement: + String(localized: "Group Not Saved") + case .libraryUnavailable(.tag): + String(localized: "Tag Not Saved") + } + } + + var message: String { + switch self { + case .removed(.connection): + String(localized: "This connection no longer exists. It may have been removed from another device.") + case .removed(.group): + String(localized: "This group no longer exists. It may have been removed from another device.") + case .removed(.tag): + String(localized: "This tag no longer exists. It may have been removed from another device.") + case .libraryUnavailable: + String(localized: "Your connections could not be loaded, so this change was not saved.") + case .invalidPlacement: + String(localized: "This group can't go inside the parent you chose. It may have changed on another device.") + } + } +} diff --git a/TableProMobile/TableProMobile/Helpers/TagDeletionRequest.swift b/TableProMobile/TableProMobile/Helpers/TagDeletionRequest.swift new file mode 100644 index 0000000000..6bfefa17bc --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/TagDeletionRequest.swift @@ -0,0 +1,18 @@ +import Foundation +import TableProModels + +nonisolated struct TagDeletionRequest: Equatable, Sendable { + let tag: ConnectionTag + let connectionCount: Int + + var message: String { + switch connectionCount { + case 0: + String(format: String(localized: "“%@” is not on any connection."), tag.name) + case 1: + String(format: String(localized: "“%@” will be removed from 1 connection."), tag.name) + default: + String(format: String(localized: "“%@” will be removed from %d connections."), tag.name, connectionCount) + } + } +} diff --git a/TableProMobile/TableProMobile/Localizable.xcstrings b/TableProMobile/TableProMobile/Localizable.xcstrings index 3dc97dee61..c0fa82ace2 100644 --- a/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/TableProMobile/TableProMobile/Localizable.xcstrings @@ -856,6 +856,34 @@ } } }, + "A database named “%@” already exists." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%@”(이)라는 이름의 데이터베이스가 이미 있습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã có cơ sở dữ liệu tên “%@”." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "名为“%@”的数据库已存在。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "名為「%@」的資料庫已存在。" + } + } + } + }, "A fast, lightweight database client for your iPhone and iPad." : { "extractionState" : "stale", "localizations" : { @@ -3652,6 +3680,34 @@ } } }, + "Connection Not Saved" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결이 저장되지 않음" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa lưu kết nối" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连接未保存" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "連線未儲存" + } + } + } + }, "Connection failed while waiting on the socket." : { "localizations" : { "ko" : { @@ -4270,6 +4326,62 @@ } } }, + "Could not copy “%1$@” into TablePro: %2$@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%1$@”을(를) TablePro로 복사할 수 없습니다: %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể sao chép “%1$@” vào TablePro: %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法将“%1$@”复制到 TablePro:%2$@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法將「%1$@」複製到 TablePro:%2$@" + } + } + } + }, + "Could not create “%1$@”: %2$@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%1$@”을(를) 만들 수 없습니다: %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể tạo “%1$@”: %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法创建“%1$@”:%2$@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法建立「%1$@」:%2$@" + } + } + } + }, "Could not install the sample database: %@" : { "localizations" : { "ko" : { @@ -4608,6 +4720,34 @@ } } }, + "Database File Unavailable" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "데이터베이스 파일을 사용할 수 없음" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tệp cơ sở dữ liệu không khả dụng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "数据库文件不可用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "資料庫檔案無法使用" + } + } + } + }, "Database Name" : { "localizations" : { "ko" : { @@ -4692,6 +4832,34 @@ } } }, + "Database names can't be blank, contain a slash, or start with a period." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "데이터베이스 이름은 비워 둘 수 없으며, 슬래시를 포함하거나 마침표로 시작할 수 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tên cơ sở dữ liệu không được để trống, chứa dấu gạch chéo hoặc bắt đầu bằng dấu chấm." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "数据库名称不能为空,不能包含斜杠,也不能以句点开头。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "資料庫名稱不能空白、不能包含斜線,也不能以句點開頭。" + } + } + } + }, "Database or Schema" : { "localizations" : { "ko" : { @@ -5085,6 +5253,34 @@ } } }, + "Delete Tag" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "태그 삭제" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa nhãn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除标签" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "刪除標籤" + } + } + } + }, "Delete group" : { "extractionState" : "stale", "localizations" : { @@ -5142,86 +5338,114 @@ } } }, - "Delete tag" : { + "Descending" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "태그 삭제" + "value" : "내림차순" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Xóa thẻ" + "value" : "Giảm dần" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "删除标签" + "value" : "降序" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "刪除標籤" + "value" : "遞減" } } } }, - "Descending" : { + "Disabled" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "내림차순" + "value" : "비활성화됨" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Giảm dần" + "value" : "Đã tắt" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "降序" + "value" : "已禁用" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "遞減" + "value" : "已停用" } } } }, - "Disabled" : { + "Discard Changes" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "비활성화됨" + "value" : "변경 사항 폐기" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đã tắt" + "value" : "Bỏ các thay đổi" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已禁用" + "value" : "放弃更改" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "已停用" + "value" : "放棄變更" + } + } + } + }, + "Discard Changes?" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "변경 사항을 폐기하시겠습니까?" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bỏ các thay đổi?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "放弃更改?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "放棄變更?" } } } @@ -5506,6 +5730,34 @@ } } }, + "Edit the connection and choose the database file again." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결을 편집하여 데이터베이스 파일을 다시 선택하십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hãy sửa kết nối và chọn lại tệp cơ sở dữ liệu." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请编辑此连接并重新选择数据库文件。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "請編輯此連線並重新選擇資料庫檔案。" + } + } + } + }, "Empty String" : { "localizations" : { "ko" : { @@ -6944,92 +7196,148 @@ } } }, - "Group by Folder" : { - "extractionState" : "stale", + "Group Deleted" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "폴더별 그룹화" + "value" : "그룹 삭제됨" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Nhóm theo thư mục" + "value" : "Nhóm đã bị xóa" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "按文件夹分组" + "value" : "分组已删除" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "依資料夾分組" + "value" : "群組已刪除" } } } }, - "Groups" : { + "Group Not Saved" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "그룹" + "value" : "그룹이 저장되지 않음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Nhóm" + "value" : "Chưa lưu nhóm" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "分组" + "value" : "分组未保存" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "群組" + "value" : "群組未儲存" } } } }, - "Help decide what to improve next by sending one small report a day." : { + "Group by Folder" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "하루에 한 번 간단한 보고서를 보내 다음에 개선할 부분을 정하는 데 도움을 주십시오." + "value" : "폴더별 그룹화" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Giúp chọn điều cần cải thiện tiếp theo bằng cách gửi một báo cáo nhỏ mỗi ngày." + "value" : "Nhóm theo thư mục" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "每天发送一份简短报告,帮助决定接下来改进什么。" + "value" : "按文件夹分组" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "每天傳送一份簡短報告,協助決定接下來要改進哪些地方。" + "value" : "依資料夾分組" } } } }, - "Help improve TablePro by sharing anonymous usage statistics (no personal data or queries)." : { + "Groups" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "그룹" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhóm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分组" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "群組" + } + } + } + }, + "Help decide what to improve next by sending one small report a day." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "하루에 한 번 간단한 보고서를 보내 다음에 개선할 부분을 정하는 데 도움을 주십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giúp chọn điều cần cải thiện tiếp theo bằng cách gửi một báo cáo nhỏ mỗi ngày." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每天发送一份简短报告,帮助决定接下来改进什么。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "每天傳送一份簡短報告,協助決定接下來要改進哪些地方。" + } + } + } + }, + "Help improve TablePro by sharing anonymous usage statistics (no personal data or queries)." : { "extractionState" : "stale", "localizations" : { "ko" : { @@ -7904,6 +8212,34 @@ } } }, + "Keep Editing" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "계속 편집" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiếp tục sửa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "继续编辑" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "繼續編輯" + } + } + } + }, "Keep your connections, groups, and tags the same on your iPhone, iPad, and Mac." : { "localizations" : { "ko" : { @@ -9171,6 +9507,62 @@ } } }, + "Next Page" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다음 페이지" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trang sau" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一页" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一頁" + } + } + } + }, + "Next Row" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다음 행" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dòng sau" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一行" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一列" + } + } + } + }, "No Connections" : { "localizations" : { "ko" : { @@ -11025,6 +11417,62 @@ } } }, + "Previous Page" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이전 페이지" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trang trước" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上一页" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "上一頁" + } + } + } + }, + "Previous Row" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이전 행" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dòng trước" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上一行" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "上一列" + } + } + } + }, "Primary" : { "extractionState" : "stale", "localizations" : { @@ -13557,6 +14005,34 @@ } } }, + "Set a passphrase to include passwords." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "암호를 포함하려면 암호 구문을 설정하십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Để đưa mật khẩu vào, hãy đặt một cụm mật khẩu." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "若要包含密码,请设置一个密码短语。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "若要包含密碼,請設定一組通關密語。" + } + } + } + }, "Set up a new database connection" : { "extractionState" : "stale", "localizations" : { @@ -14745,6 +15221,34 @@ } } }, + "TablePro can't open “%@”." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro에서 “%@”을(를) 열 수 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro không mở được “%@”." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 无法打开“%@”。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 無法開啟「%@」。" + } + } + } + }, "TablePro cannot receive the output of COPY TO STDOUT, so it was discarded. Run a SELECT instead." : { "localizations" : { "ko" : { @@ -15062,6 +15566,62 @@ } } }, + "Tag Deleted" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "태그 삭제됨" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhãn đã bị xóa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标签已删除" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "標籤已刪除" + } + } + } + }, + "Tag Not Saved" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "태그가 저장되지 않음" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa lưu nhãn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标签未保存" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "標籤未儲存" + } + } + } + }, "Tags" : { "localizations" : { "ko" : { @@ -17046,6 +17606,62 @@ } } }, + "This group can't go inside the parent you chose. It may have changed on another device." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 그룹은 선택한 상위 그룹 안에 넣을 수 없습니다. 다른 기기에서 변경되었을 수 있습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể đặt nhóm này vào nhóm cha bạn đã chọn. Có thể nhóm đó đã thay đổi trên thiết bị khác." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法将此分组放入你选择的上级分组。它可能已在其他设备上更改。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法將此群組放入你選擇的上層群組,它可能已在其他裝置上變更。" + } + } + } + }, + "This group no longer exists. It may have been removed from another device." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 그룹은 더 이상 존재하지 않습니다. 다른 기기에서 제거되었을 수 있습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhóm này không còn tồn tại. Có thể đã bị xóa từ thiết bị khác." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此分组已不存在。它可能已在其他设备上被移除。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此群組已不存在,可能已從其他裝置移除。" + } + } + } + }, "This query will modify data. Are you sure you want to continue?" : { "localizations" : { "ko" : { @@ -17270,6 +17886,34 @@ } } }, + "This tag no longer exists. It may have been removed from another device." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 태그는 더 이상 존재하지 않습니다. 다른 기기에서 제거되었을 수 있습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhãn này không còn tồn tại. Có thể đã bị xóa từ thiết bị khác." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此标签已不存在。它可能已在其他设备上被移除。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此標籤已不存在,可能已從其他裝置移除。" + } + } + } + }, "This will insert a row into %@. Continue?" : { "localizations" : { "ko" : { @@ -18846,6 +19490,34 @@ } } }, + "Your connections could not be loaded, so this change was not saved." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결을 불러올 수 없어 이 변경 사항을 저장하지 못했습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không tải được các kết nối của bạn nên thay đổi này chưa được lưu." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法加载你的连接,因此此更改未保存。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法載入你的連線,因此此變更未儲存。" + } + } + } + }, "auto-increment" : { "localizations" : { "ko" : { @@ -19808,6 +20480,124 @@ } } }, + "“%@” is not on any connection." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%@” 태그가 지정된 연결이 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có kết nối nào gắn nhãn “%@”." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有连接使用标签“%@”。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "沒有連線使用標籤「%@」。" + } + } + } + }, + "“%@” isn't available on this device." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 기기에서 “%@”을(를) 사용할 수 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%@” không có trên thiết bị này." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%@”在此设备上不可用。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "「%@」在此裝置上無法使用。" + } + } + } + }, + "“%@” will be removed from %d connections." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "“%1$@” will be removed from %2$d connections." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$d개 연결에서 “%1$@” 태그가 제거됩니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhãn “%1$@” sẽ bị gỡ khỏi %2$d kết nối." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将从 %2$d 个连接中移除标签“%1$@”。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "將從 %2$d 個連線中移除標籤「%1$@」。" + } + } + } + }, + "“%@” will be removed from 1 connection." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "1개 연결에서 “%@” 태그가 제거됩니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhãn “%@” sẽ bị gỡ khỏi 1 kết nối." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将从 1 个连接中移除标签“%@”。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "將從 1 個連線中移除標籤「%@」。" + } + } + } + }, "≤" : { "localizations" : { "ko" : { diff --git a/TableProMobile/TableProMobile/Onboarding/OnboardingPageLayout.swift b/TableProMobile/TableProMobile/Onboarding/OnboardingPageLayout.swift index 9c413a3bf1..25ddc6ecd6 100644 --- a/TableProMobile/TableProMobile/Onboarding/OnboardingPageLayout.swift +++ b/TableProMobile/TableProMobile/Onboarding/OnboardingPageLayout.swift @@ -9,13 +9,7 @@ struct OnboardingPageLayout: View { @ViewBuilder let actions: Actions var body: some View { - if #available(iOS 26.0, *) { - scrollContent.safeAreaBar(edge: .bottom) { actionBar } - } else { - scrollContent.safeAreaInset(edge: .bottom) { - actionBar.background(.bar) - } - } + scrollContent.bottomSafeAreaBar(spacing: nil) { actionBar } } private var scrollContent: some View { diff --git a/TableProMobile/TableProMobile/Platform/AppContainerHistory.swift b/TableProMobile/TableProMobile/Platform/AppContainerHistory.swift new file mode 100644 index 0000000000..41c4cd1d80 --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/AppContainerHistory.swift @@ -0,0 +1,27 @@ +import Foundation + +nonisolated struct AppContainerHistory: Sendable { + static let live = AppContainerHistory() + + private static let key = "com.TablePro.appContainerIds" + + private let suiteName: String? + + init(suiteName: String? = nil) { + self.suiteName = suiteName + } + + private var defaults: UserDefaults { + suiteName.flatMap(UserDefaults.init(suiteName:)) ?? .standard + } + + var containerIds: Set { + Set(defaults.stringArray(forKey: Self.key) ?? []) + } + + func record(_ containerId: String) { + var ids = containerIds + guard ids.insert(containerId).inserted else { return } + defaults.set(ids.sorted(), forKey: Self.key) + } +} diff --git a/TableProMobile/TableProMobile/Platform/AppContainerPaths.swift b/TableProMobile/TableProMobile/Platform/AppContainerPaths.swift new file mode 100644 index 0000000000..844e53248d --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/AppContainerPaths.swift @@ -0,0 +1,86 @@ +import Foundation + +nonisolated struct AppContainerPaths: Sendable { + nonisolated enum Resolution: Equatable, Sendable { + case inThisInstall(URL) + case outsideAppContainers(URL) + case notOnThisDevice + } + + private struct ContainerReference { + let containerId: String + let pathInContainer: ArraySlice + } + + static let live = AppContainerPaths(documentsDirectory: .documentsDirectory, history: .live) + + let documentsDirectory: URL + private let history: AppContainerHistory + private let documentsComponents: [String] + + init(documentsDirectory: URL, history: AppContainerHistory) { + self.documentsDirectory = documentsDirectory + self.history = history + self.documentsComponents = Self.normalizedComponents(of: documentsDirectory.path) + } + + var currentContainerId: String? { + guard documentsComponents.count >= 3 else { return nil } + return documentsComponents[documentsComponents.count - 2] + } + + func recordCurrentContainer() { + guard let currentContainerId else { return } + history.record(currentContainerId) + } + + func resolve(_ storedPath: String) -> Resolution { + guard storedPath.hasPrefix("/") else { return .notOnThisDevice } + let components = Self.normalizedComponents(of: storedPath) + guard !components.isEmpty, components.allSatisfy(Self.isPlainComponent) else { return .notOnThisDevice } + guard let reference = containerReference(in: components) else { + return .outsideAppContainers(URL(fileURLWithPath: storedPath)) + } + if reference.containerId == currentContainerId { + return .inThisInstall(URL(fileURLWithPath: storedPath)) + } + guard history.containerIds.contains(reference.containerId), + reference.pathInContainer.count > 1, + reference.pathInContainer.first == documentsComponents.last + else { return .notOnThisDevice } + let pathInDocuments = reference.pathInContainer.dropFirst().joined(separator: "/") + return .inThisInstall(documentsDirectory.appendingPathComponent(pathInDocuments)) + } + + func localPath(forStoredPath storedPath: String) -> String { + guard case .inThisInstall(let url) = resolve(storedPath) else { return storedPath } + return url.path + } + + func isInDocuments(_ url: URL) -> Bool { + let components = Self.normalizedComponents(of: url.path) + return components.count > documentsComponents.count && components.starts(with: documentsComponents) + } + + private func containerReference(in components: [String]) -> ContainerReference? { + guard documentsComponents.count >= 3 else { return nil } + let family = documentsComponents.dropLast(2) + guard components.count > family.count, components.starts(with: family) else { return nil } + return ContainerReference( + containerId: components[family.count], + pathInContainer: components[(family.count + 1)...] + ) + } + + private static func isPlainComponent(_ component: String) -> Bool { + !component.isEmpty && component != "." && component != ".." + } + + private static func normalizedComponents(of path: String) -> [String] { + let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + guard components.count > 1, components[0] == "private", ["var", "tmp"].contains(components[1]) else { + return components + } + return Array(components.dropFirst()) + } +} diff --git a/TableProMobile/TableProMobile/Platform/ConnectionLibraryPublisher.swift b/TableProMobile/TableProMobile/Platform/ConnectionLibraryPublisher.swift new file mode 100644 index 0000000000..fe659e06eb --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/ConnectionLibraryPublisher.swift @@ -0,0 +1,92 @@ +import AppIntents +import Foundation +import os +import TableProModels +import WidgetKit + +@MainActor +final class ConnectionLibraryPublisher { + private static let logger = Logger(subsystem: "com.TablePro", category: "LibraryPublisher") + + private let searchIndex: any ConnectionSearchIndexing + private let writeWidgetItems: ([WidgetConnectionItem]) -> Void + private let refreshShortcutParameters: () -> Void + + private var shortcutProjection: [SearchableConnection]? + private var indexed: [SearchableConnection]? + private var queued: [SearchableConnection]? + private var replacing: Task? + + init( + searchIndex: any ConnectionSearchIndexing, + writeWidgetItems: @escaping ([WidgetConnectionItem]) -> Void, + refreshShortcutParameters: @escaping () -> Void + ) { + self.searchIndex = searchIndex + self.writeWidgetItems = writeWidgetItems + self.refreshShortcutParameters = refreshShortcutParameters + } + + static func live() -> ConnectionLibraryPublisher { + ConnectionLibraryPublisher( + searchIndex: SpotlightConnectionIndex(), + writeWidgetItems: { items in + SharedConnectionStore.write(items) + WidgetCenter.shared.reloadAllTimelines() + }, + refreshShortcutParameters: { + TableProShortcuts.updateAppShortcutParameters() + } + ) + } + + nonisolated static func widgetItems(for connections: [DatabaseConnection]) -> [WidgetConnectionItem] { + connections + .sorted { ($0.sortOrder, $0.name) < ($1.sortOrder, $1.name) } + .map { connection in + WidgetConnectionItem( + id: connection.id, + name: connection.name.isEmpty ? connection.host : connection.name, + type: connection.type.rawValue, + sortOrder: connection.sortOrder + ) + } + } + + nonisolated static func searchableConnections(for connections: [DatabaseConnection]) -> [SearchableConnection] { + connections + .map(SearchableConnection.init(connection:)) + .sorted { $0.id.uuidString < $1.id.uuidString } + } + + func publish(_ connections: [DatabaseConnection]) { + writeWidgetItems(Self.widgetItems(for: connections)) + let searchable = Self.searchableConnections(for: connections) + if searchable != shortcutProjection { + shortcutProjection = searchable + refreshShortcutParameters() + } + queued = searchable + guard replacing == nil else { return } + replacing = Task { await drainReplacements() } + } + + func settle() async { + await replacing?.value + } + + private func drainReplacements() async { + while let next = queued { + queued = nil + guard next != indexed else { continue } + do { + try await searchIndex.replaceConnections(with: next) + indexed = next + } catch { + indexed = nil + Self.logger.error("Spotlight replace failed: \(error.localizedDescription, privacy: .public)") + } + } + replacing = nil + } +} diff --git a/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift b/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift new file mode 100644 index 0000000000..f40ad25abc --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift @@ -0,0 +1,42 @@ +import Foundation + +nonisolated enum ConnectionSecretKind: CaseIterable, Sendable { + case password + case sshPassword + case keyPassphrase + case sshPrivateKey + + var prefix: String { + switch self { + case .password: "com.TablePro.password." + case .sshPassword: "com.TablePro.sshpassword." + case .keyPassphrase: "com.TablePro.keypassphrase." + case .sshPrivateKey: "com.TablePro.sshkeydata." + } + } + + var isSweptWhenOrphaned: Bool { + switch self { + case .password, .sshPassword, .keyPassphrase: true + case .sshPrivateKey: false + } + } + + func account(for connectionId: UUID) -> String { + prefix + connectionId.uuidString + } + + static func sweptConnectionId(inAccount account: String) -> UUID? { + let swept = allCases.filter(\.isSweptWhenOrphaned) + guard let kind = swept.first(where: { account.hasPrefix($0.prefix) }) else { return nil } + return UUID(uuidString: String(account.dropFirst(kind.prefix.count))) + } + + static func orphanedAccounts(_ accounts: [String], keeping validConnectionIds: Set) -> [String] { + guard !validConnectionIds.isEmpty else { return [] } + return accounts.filter { account in + guard let connectionId = sweptConnectionId(inAccount: account) else { return false } + return !validConnectionIds.contains(connectionId) + } + } +} diff --git a/TableProMobile/TableProMobile/Platform/ConnectionSecretsCopier.swift b/TableProMobile/TableProMobile/Platform/ConnectionSecretsCopier.swift index 6932673f68..dcd3203ba3 100644 --- a/TableProMobile/TableProMobile/Platform/ConnectionSecretsCopier.swift +++ b/TableProMobile/TableProMobile/Platform/ConnectionSecretsCopier.swift @@ -3,12 +3,7 @@ import os import TableProDatabase nonisolated struct ConnectionSecrets: Sendable { - static let secureStoreKeyPrefixes = [ - "com.TablePro.password.", - "com.TablePro.sshpassword.", - "com.TablePro.keypassphrase.", - "com.TablePro.sshkeydata.", - ] + static let secureStoreKeyPrefixes = ConnectionSecretKind.allCases.map(\.prefix) private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionSecrets") @@ -54,6 +49,25 @@ nonisolated struct ConnectionSecrets: Sendable { return copiedEverything } + func storeMissingPrivateKeys(_ keys: [UUID: String]) -> [UUID: String] { + var unstored: [UUID: String] = [:] + for (connectionId, key) in keys.sorted(by: { $0.key.uuidString < $1.key.uuidString }) { + let account = ConnectionSecretKind.sshPrivateKey.account(for: connectionId) + do { + if let existing = try secureStore.retrieve(forKey: account), !existing.isEmpty { + continue + } + try secureStore.store(key, forKey: account) + } catch { + Self.logger.error( + "Storing the pasted SSH key of \(connectionId.uuidString, privacy: .public) failed: \(error.localizedDescription, privacy: .public)" + ) + unstored[connectionId] = key + } + } + return unstored + } + func delete(for connectionId: UUID) { for prefix in Self.secureStoreKeyPrefixes { try? secureStore.delete(forKey: prefix + connectionId.uuidString) diff --git a/TableProMobile/TableProMobile/Platform/EphemeralSecureStore.swift b/TableProMobile/TableProMobile/Platform/EphemeralSecureStore.swift new file mode 100644 index 0000000000..8c11563ed0 --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/EphemeralSecureStore.swift @@ -0,0 +1,23 @@ +import Foundation +import os +import TableProDatabase + +nonisolated final class EphemeralSecureStore: SecureStore { + private let values: OSAllocatedUnfairLock<[String: String]> + + init(_ values: [String: String] = [:]) { + self.values = OSAllocatedUnfairLock(initialState: values) + } + + func store(_ value: String, forKey key: String) throws { + values.withLock { $0[key] = value } + } + + func retrieve(forKey key: String) throws -> String? { + values.withLock { $0[key] } + } + + func delete(forKey key: String) throws { + values.withLock { $0[key] = nil } + } +} diff --git a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift index 9577744b43..bcded68946 100644 --- a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift +++ b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift @@ -1,17 +1,70 @@ import Foundation +import os import TableProDatabase import TableProModels nonisolated final class IOSDriverFactory: DriverFactory { + private static let logger = Logger(subsystem: "com.TablePro", category: "IOSDriverFactory") + private let bookmarkStore: FileBookmarkStore private let materializer: CertificateMaterializer + private let localFiles: LocalDatabaseFileLocator init( bookmarkStore: FileBookmarkStore = FileBookmarkStore(), - materializer: CertificateMaterializer = CertificateMaterializer() + materializer: CertificateMaterializer = CertificateMaterializer(), + localFiles: LocalDatabaseFileLocator = .live ) { self.bookmarkStore = bookmarkStore self.materializer = materializer + self.localFiles = localFiles + } + + private func sqliteSource(for connection: DatabaseConnection) throws -> LocalDatabaseFileSource { + try localFiles.existingSource(for: localFiles.location(forStoredPath: connection.database)) + } + + private func duckDBSource(for connection: DatabaseConnection) throws -> LocalDatabaseFileSource { + let location = localFiles.location(forStoredPath: connection.database) + switch location { + case .inMemory, .appFile: + return try localFiles.existingSource(for: location) + case .externalFile, .notOnThisDevice: + guard let bookmark = bookmarkStore.bookmark(for: connection.id) else { + return try localFiles.existingSource(for: location) + } + return .securityScoped(try resolve(bookmark, storedPath: connection.database, for: connection.id)) + } + } + + private func resolve(_ bookmark: Data, storedPath: String, for connectionId: UUID) throws -> URL { + var isStale = false + let url: URL + do { + url = try URL(resolvingBookmarkData: bookmark, options: [], relativeTo: nil, bookmarkDataIsStale: &isStale) + } catch { + Self.logger.error("A DuckDB file bookmark no longer resolves: \(error.localizedDescription, privacy: .private)") + throw LocalDatabaseFileError.unavailable( + fileName: (storedPath as NSString).lastPathComponent, + reason: .accessLost + ) + } + if isStale { + refreshBookmark(of: url, for: connectionId) + } + return url + } + + private func refreshBookmark(of url: URL, for connectionId: UUID) { + let didStart = url.startAccessingSecurityScopedResource() + defer { + if didStart { url.stopAccessingSecurityScopedResource() } + } + do { + bookmarkStore.save(try url.bookmarkData(), for: connectionId) + } catch { + Self.logger.error("Refreshing a stale DuckDB file bookmark failed: \(error.localizedDescription, privacy: .private)") + } } private func ssl(for connection: DatabaseConnection) throws -> DriverSSLConfiguration { @@ -27,14 +80,11 @@ nonisolated final class IOSDriverFactory: DriverFactory { func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver { switch connection.type { case .sqlite where connection.isSample: - return SQLiteDriver(path: try SampleDatabaseInstaller.live.installIfNeeded().path) + return SQLiteDriver(source: .file(try SampleDatabaseInstaller.live.installIfNeeded())) case .sqlite: - return SQLiteDriver(path: connection.database) + return SQLiteDriver(source: try sqliteSource(for: connection)) case .duckdb: - let bookmark = connection.database == DuckDBDriver.inMemoryPath - ? nil - : bookmarkStore.bookmark(for: connection.id) - return DuckDBDriver(path: connection.database, bookmark: bookmark) + return DuckDBDriver(source: try duckDBSource(for: connection)) case .mysql, .mariadb, .tidb, .oceanbase: return MySQLDriver( host: connection.host, diff --git a/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift b/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift index ee9105ec98..23f3620aaa 100644 --- a/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift +++ b/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift @@ -6,7 +6,7 @@ import TableProDatabase nonisolated final class KeychainSecureStore: SecureStore { private static let logger = Logger(subsystem: "com.TablePro", category: "KeychainSecureStore") - private let serviceName = "com.TablePro" + private static let serviceName = "com.TablePro" private let accessGroup: String? private static let cachedAccessGroup = OSAllocatedUnfairLock(initialState: nil) @@ -17,7 +17,10 @@ nonisolated final class KeychainSecureStore: SecureStore { guard let prefix = Bundle.main.infoDictionary?["AppIdentifierPrefix"] as? String, !prefix.isEmpty, !prefix.hasPrefix("$(") else { - logger.warning("AppIdentifierPrefix unavailable; using the app-local keychain without a shared access group (expected for unsigned or test builds; in a signed build, widget keychain sharing is off).") + logger.warning(""" + AppIdentifierPrefix unavailable; using the app-local keychain without a shared access group \ + (expected for unsigned or test builds; in a signed build, widget keychain sharing is off). + """) return nil } @@ -31,6 +34,10 @@ nonisolated final class KeychainSecureStore: SecureStore { } private func applyingAccessGroup(_ query: [String: Any]) -> [String: Any] { + Self.applying(accessGroup: accessGroup, to: query) + } + + private static func applying(accessGroup: String?, to query: [String: Any]) -> [String: Any] { guard let accessGroup else { return query } var query = query query[kSecAttrAccessGroup as String] = accessGroup @@ -50,7 +57,7 @@ nonisolated final class KeychainSecureStore: SecureStore { let deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, + kSecAttrService as String: Self.serviceName, kSecAttrAccount as String: key, kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, kSecUseDataProtectionKeychain as String: true, @@ -59,7 +66,7 @@ nonisolated final class KeychainSecureStore: SecureStore { var addQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, + kSecAttrService as String: Self.serviceName, kSecAttrAccount as String: key, kSecValueData as String: data, kSecAttrAccessible as String: Self.accessibility(forSync: synchronizable), @@ -77,7 +84,7 @@ nonisolated final class KeychainSecureStore: SecureStore { func retrieve(forKey key: String) throws -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, + kSecAttrService as String: Self.serviceName, kSecAttrAccount as String: key, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, @@ -100,7 +107,7 @@ nonisolated final class KeychainSecureStore: SecureStore { func delete(forKey key: String) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, + kSecAttrService as String: Self.serviceName, kSecAttrAccount as String: key, kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, kSecUseDataProtectionKeychain as String: true, @@ -111,18 +118,19 @@ nonisolated final class KeychainSecureStore: SecureStore { } } - /// Remove orphaned test connection credentials that may remain after a SIGKILL. - /// Test credentials use temp UUIDs not associated with any saved connection. + /// Remove passwords left under ids no saved connection uses, such as a test connection an older + /// build stored and was killed before deleting. /// - /// Two limits, because this deletes by prefix and cannot tell a throwaway id from an id it has + /// Three limits, because this deletes by prefix and cannot tell a throwaway id from an id it has /// simply not heard of yet. An empty valid set means the connections have not loaded, which is - /// every launch before the first sync merge, and sweeping then would delete all of them. And - /// only device-local items are considered: a synchronizable item belongs to iCloud Keychain, so - /// deleting one here removes it from the Mac that wrote it too. - func cleanOrphanedCredentials(validConnectionIds: Set) { + /// every launch before the first sync merge, and sweeping then would delete all of them. Only + /// device-local items are considered: a synchronizable item belongs to iCloud Keychain, so + /// deleting one here removes it from the Mac that wrote it too. And a pasted private key is + /// never swept, because a connection sync has not delivered yet may hold its only copy. + static func cleanOrphanedCredentials(validConnectionIds: Set) { guard !validConnectionIds.isEmpty else { return } - let prefixes = ["com.TablePro.password.", "com.TablePro.sshpassword.", "com.TablePro.keypassphrase."] + let accessGroup = resolveAccessGroup() let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, @@ -132,25 +140,19 @@ nonisolated final class KeychainSecureStore: SecureStore { kSecUseDataProtectionKeychain as String: true, ] var result: AnyObject? - guard SecItemCopyMatching(applyingAccessGroup(query) as CFDictionary, &result) == errSecSuccess, + guard SecItemCopyMatching(applying(accessGroup: accessGroup, to: query) as CFDictionary, &result) == errSecSuccess, let items = result as? [[String: Any]] else { return } - for item in items { - guard let account = item[kSecAttrAccount as String] as? String else { continue } - for prefix in prefixes { - guard account.hasPrefix(prefix) else { continue } - let uuidString = String(account.dropFirst(prefix.count)) - guard let uuid = UUID(uuidString: uuidString), - !validConnectionIds.contains(uuid) else { continue } - deleteDeviceLocal(forKey: account) - } + let accounts = items.compactMap { $0[kSecAttrAccount as String] as? String } + for account in ConnectionSecretKind.orphanedAccounts(accounts, keeping: validConnectionIds) { + deleteDeviceLocal(forKey: account, accessGroup: accessGroup) } } /// The sweep's own delete. `delete(forKey:)` matches `kSecAttrSynchronizableAny` because an /// ordinary delete has to reach the item whichever it is; here that would let a device-local /// match take an iCloud-shared item of the same name with it. - private func deleteDeviceLocal(forKey key: String) { + private static func deleteDeviceLocal(forKey key: String, accessGroup: String?) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, @@ -158,7 +160,7 @@ nonisolated final class KeychainSecureStore: SecureStore { kSecAttrSynchronizable as String: false, kSecUseDataProtectionKeychain as String: true, ] - SecItemDelete(applyingAccessGroup(query) as CFDictionary) + SecItemDelete(applying(accessGroup: accessGroup, to: query) as CFDictionary) } } diff --git a/TableProMobile/TableProMobile/Platform/LocalDatabaseFileAccess.swift b/TableProMobile/TableProMobile/Platform/LocalDatabaseFileAccess.swift new file mode 100644 index 0000000000..21411e0f7d --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/LocalDatabaseFileAccess.swift @@ -0,0 +1,45 @@ +import Foundation + +nonisolated final class LocalDatabaseFileAccess: @unchecked Sendable { + private let lock = NSLock() + private var scopedURL: URL? + + func begin(_ source: LocalDatabaseFileSource, openMode: LocalDatabaseOpenMode) throws -> String { + switch source { + case .inMemory: + return LocalDatabaseLocation.inMemoryPath + case .file(let url): + try Self.requireFile(at: url, openMode: openMode, reason: .missing) + return url.path + case .securityScoped(let url): + if url.startAccessingSecurityScopedResource() { + lock.withLock { scopedURL = url } + } + do { + try Self.requireFile(at: url, openMode: openMode, reason: .accessLost) + } catch { + end() + throw error + } + return url.path + } + } + + func end() { + let url = lock.withLock { () -> URL? in + let url = scopedURL + scopedURL = nil + return url + } + url?.stopAccessingSecurityScopedResource() + } + + private static func requireFile( + at url: URL, + openMode: LocalDatabaseOpenMode, + reason: LocalDatabaseFileError.UnavailableReason + ) throws { + guard openMode == .existingOnly, !FileManager.default.fileExists(atPath: url.path) else { return } + throw LocalDatabaseFileError.unavailable(fileName: url.lastPathComponent, reason: reason) + } +} diff --git a/TableProMobile/TableProMobile/Platform/LocalDatabaseFileCreator.swift b/TableProMobile/TableProMobile/Platform/LocalDatabaseFileCreator.swift new file mode 100644 index 0000000000..cd938da3d6 --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/LocalDatabaseFileCreator.swift @@ -0,0 +1,52 @@ +import Foundation +import os +import TableProDatabase +import TableProModels + +nonisolated protocol LocalDatabaseFileCreating: Sendable { + func createDatabase(at url: URL, type: DatabaseType) async throws + func removeDatabase(at url: URL) +} + +nonisolated struct DriverDatabaseFileCreator: LocalDatabaseFileCreating { + private static let logger = Logger(subsystem: "com.TablePro", category: "LocalDatabaseFileCreator") + private static let sidecarSuffixes = ["-journal", "-wal", "-shm", ".wal"] + + func createDatabase(at url: URL, type: DatabaseType) async throws { + let fileName = url.lastPathComponent + guard !FileManager.default.fileExists(atPath: url.path) else { + throw LocalDatabaseFileError.alreadyExists(fileName: fileName) + } + do { + let driver = try Self.makeDriver(at: url, type: type) + try await driver.connect() + try await driver.disconnect() + } catch { + removeDatabase(at: url) + Self.logger.error("Creating a database file failed: \(error.localizedDescription, privacy: .private)") + throw LocalDatabaseFileError.creationFailed(fileName: fileName, message: error.localizedDescription) + } + } + + func removeDatabase(at url: URL) { + for path in [url.path] + Self.sidecarSuffixes.map({ url.path + $0 }) { + guard FileManager.default.fileExists(atPath: path) else { continue } + do { + try FileManager.default.removeItem(atPath: path) + } catch { + Self.logger.error("Removing a database file failed: \(error.localizedDescription, privacy: .private)") + } + } + } + + private static func makeDriver(at url: URL, type: DatabaseType) throws -> any DatabaseDriver { + switch type { + case .sqlite: + return SQLiteDriver(source: .file(url), openMode: .createNew) + case .duckdb: + return DuckDBDriver(source: .file(url), openMode: .createNew) + default: + throw ConnectionError.driverNotFound(type.rawValue) + } + } +} diff --git a/TableProMobile/TableProMobile/Platform/LocalDatabaseFileLocator.swift b/TableProMobile/TableProMobile/Platform/LocalDatabaseFileLocator.swift new file mode 100644 index 0000000000..e5ced8edee --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/LocalDatabaseFileLocator.swift @@ -0,0 +1,109 @@ +import Foundation +import TableProModels + +nonisolated struct LocalDatabaseFileLocator: Sendable { + static let live = LocalDatabaseFileLocator(container: .live) + + let container: AppContainerPaths + private let fileExists: @Sendable (String) -> Bool + + var documentsDirectory: URL { container.documentsDirectory } + + init( + container: AppContainerPaths, + fileExists: @escaping @Sendable (String) -> Bool = { FileManager.default.fileExists(atPath: $0) } + ) { + self.container = container + self.fileExists = fileExists + } + + // MARK: - Reading a stored path + + func location(forStoredPath storedPath: String) -> LocalDatabaseLocation { + guard storedPath != LocalDatabaseLocation.inMemoryPath else { return .inMemory } + switch container.resolve(storedPath) { + case .inThisInstall(let url): + return .appFile(url) + case .outsideAppContainers(let url): + return .externalFile(url) + case .notOnThisDevice: + return .notOnThisDevice(storedPath: storedPath) + } + } + + func existingSource(for location: LocalDatabaseLocation) throws -> LocalDatabaseFileSource { + switch location { + case .inMemory: + return .inMemory + case .appFile(let url): + guard fileExists(url.path) else { + throw LocalDatabaseFileError.unavailable(fileName: url.lastPathComponent, reason: .missing) + } + return .file(url) + case .externalFile(let url): + guard fileExists(url.path) else { + throw LocalDatabaseFileError.unavailable(fileName: url.lastPathComponent, reason: .notOnThisDevice) + } + return .file(url) + case .notOnThisDevice(let storedPath): + throw LocalDatabaseFileError.unavailable( + fileName: Self.displayName(of: storedPath), + reason: .notOnThisDevice + ) + } + } + + func isInDocuments(_ url: URL) -> Bool { + container.isInDocuments(url) + } + + // MARK: - Adding files to Documents + + func newDatabaseFile(named requestedName: String, type: DatabaseType) throws -> URL { + let trimmed = requestedName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmed.contains("/"), !trimmed.hasPrefix(".") else { + throw LocalDatabaseFileError.invalidName + } + let suffix = "." + Self.fileExtension(for: type) + let fileName = trimmed.hasSuffix(suffix) ? trimmed : trimmed + suffix + let url = documentsDirectory.appendingPathComponent(fileName) + guard !fileExists(url.path) else { + throw LocalDatabaseFileError.alreadyExists(fileName: fileName) + } + return url + } + + func importCopy(of source: URL) throws -> URL { + let fileName = source.lastPathComponent + var destination = documentsDirectory.appendingPathComponent(fileName) + if fileExists(destination.path) { + destination = documentsDirectory.appendingPathComponent(Self.uniqueName(for: source)) + } + do { + try FileManager.default.createDirectory(at: documentsDirectory, withIntermediateDirectories: true) + try FileManager.default.copyItem(at: source, to: destination) + } catch { + throw LocalDatabaseFileError.copyFailed(fileName: fileName, message: error.localizedDescription) + } + return destination + } + + // MARK: - Names + + private static func displayName(of storedPath: String) -> String { + let name = (storedPath as NSString).lastPathComponent + return name.isEmpty ? storedPath : name + } + + private static func fileExtension(for type: DatabaseType) -> String { + type == .duckdb ? "duckdb" : "db" + } + + private static func uniqueName(for source: URL) -> String { + let baseName = source.deletingPathExtension().lastPathComponent + let suffix = UUID().uuidString.prefix(8) + let pathExtension = source.pathExtension + guard !pathExtension.isEmpty else { return "\(baseName)_\(suffix)" } + return "\(baseName)_\(suffix).\(pathExtension)" + } +} diff --git a/TableProMobile/TableProMobile/Platform/LocalDatabaseLocation.swift b/TableProMobile/TableProMobile/Platform/LocalDatabaseLocation.swift new file mode 100644 index 0000000000..5592da9056 --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/LocalDatabaseLocation.swift @@ -0,0 +1,65 @@ +import Foundation + +nonisolated enum LocalDatabaseLocation: Equatable, Sendable { + case inMemory + case appFile(URL) + case externalFile(URL) + case notOnThisDevice(storedPath: String) + + static let inMemoryPath = ":memory:" + + var fileURL: URL? { + switch self { + case .appFile(let url), .externalFile(let url): url + case .inMemory, .notOnThisDevice: nil + } + } +} + +nonisolated enum LocalDatabaseFileSource: Equatable, Sendable { + case inMemory + case file(URL) + case securityScoped(URL) +} + +nonisolated enum LocalDatabaseOpenMode: Equatable, Sendable { + case existingOnly + case createNew +} + +nonisolated enum LocalDatabaseFileError: LocalizedError, Equatable, Sendable { + nonisolated enum UnavailableReason: Equatable, Sendable { + case missing + case notOnThisDevice + case accessLost + } + + case unavailable(fileName: String, reason: UnavailableReason) + case accessDenied(fileName: String) + case copyFailed(fileName: String, message: String) + case creationFailed(fileName: String, message: String) + case alreadyExists(fileName: String) + case invalidName + + var errorDescription: String? { + switch self { + case .unavailable(let fileName, _): + return String(format: String(localized: "“%@” isn't available on this device."), fileName) + case .accessDenied(let fileName): + return String(format: String(localized: "TablePro can't open “%@”."), fileName) + case .copyFailed(let fileName, let message): + return String(format: String(localized: "Could not copy “%1$@” into TablePro: %2$@"), fileName, message) + case .creationFailed(let fileName, let message): + return String(format: String(localized: "Could not create “%1$@”: %2$@"), fileName, message) + case .alreadyExists(let fileName): + return String(format: String(localized: "A database named “%@” already exists."), fileName) + case .invalidName: + return String(localized: "Database names can't be blank, contain a slash, or start with a period.") + } + } + + var recoverySuggestion: String? { + guard case .unavailable = self else { return nil } + return String(localized: "Edit the connection and choose the database file again.") + } +} diff --git a/TableProMobile/TableProMobile/Platform/PastedSSHKeyMigration.swift b/TableProMobile/TableProMobile/Platform/PastedSSHKeyMigration.swift new file mode 100644 index 0000000000..d882621be0 --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/PastedSSHKeyMigration.swift @@ -0,0 +1,69 @@ +import Foundation +import TableProModels + +nonisolated enum PastedSSHKeyMigration { + private static let idKey = "id" + private static let sshConfigurationKey = "sshConfiguration" + private static let privateKeyDataKey = "privateKeyData" + + static func pendingKeys(inLibraryFile data: Data) -> [UUID: String] { + guard let entries = try? JSONDecoder().decode([LegacyConnection].self, from: data) else { return [:] } + var keys: [UUID: String] = [:] + for entry in entries { + guard let key = entry.privateKeyData, !key.isEmpty, keys[entry.id] == nil else { continue } + keys[entry.id] = key + } + return keys + } + + static func keysStillHeld(_ keys: [UUID: String], by connections: [DatabaseConnection]) -> [UUID: String] { + guard !keys.isEmpty else { return [:] } + let holders = Set(connections.filter { $0.sshConfiguration != nil }.map(\.id)) + return keys.filter { holders.contains($0.key) } + } + + static func libraryFile(_ encoded: Data, keeping keys: [UUID: String]) throws -> Data { + guard !keys.isEmpty, + var entries = try JSONSerialization.jsonObject(with: encoded) as? [[String: Any]] else { + return encoded + } + for index in entries.indices { + guard let idString = entries[index][idKey] as? String, + let id = UUID(uuidString: idString), + let key = keys[id], + var ssh = entries[index][sshConfigurationKey] as? [String: Any] else { continue } + ssh[privateKeyDataKey] = key + entries[index][sshConfigurationKey] = ssh + } + return try JSONSerialization.data(withJSONObject: entries) + } + + nonisolated private struct LegacyConnection: Decodable { + let id: UUID + let privateKeyData: String? + + private enum CodingKeys: String, CodingKey { + case id, sshConfiguration + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + let ssh = try? container.decodeIfPresent(LegacySSHConfiguration.self, forKey: .sshConfiguration) + privateKeyData = ssh?.privateKeyData + } + } + + nonisolated private struct LegacySSHConfiguration: Decodable { + let privateKeyData: String? + + private enum CodingKeys: String, CodingKey { + case privateKeyData + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + privateKeyData = try? container.decodeIfPresent(String.self, forKey: .privateKeyData) + } + } +} diff --git a/TableProMobile/TableProMobile/Platform/SpotlightConnectionIndex.swift b/TableProMobile/TableProMobile/Platform/SpotlightConnectionIndex.swift new file mode 100644 index 0000000000..0197142618 --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/SpotlightConnectionIndex.swift @@ -0,0 +1,49 @@ +import CoreSpotlight +import Foundation +import TableProModels + +nonisolated struct SearchableConnection: Equatable, Sendable { + let id: UUID + let title: String + let summary: String + + init(id: UUID, title: String, summary: String) { + self.id = id + self.title = title + self.summary = summary + } + + init(connection: DatabaseConnection) { + id = connection.id + title = connection.name.isEmpty ? connection.host : connection.name + summary = [connection.type.mobileDisplayName, ConnectionDetailFormatter.detail(for: connection)] + .joined(separator: ", ") + } +} + +nonisolated protocol ConnectionSearchIndexing: Sendable { + func replaceConnections(with connections: [SearchableConnection]) async throws +} + +nonisolated struct SpotlightConnectionIndex: ConnectionSearchIndexing { + static let domainIdentifier = "com.TablePro.connections" + + func replaceConnections(with connections: [SearchableConnection]) async throws { + guard CSSearchableIndex.isIndexingAvailable() else { return } + let index = CSSearchableIndex.default() + try await index.deleteSearchableItems(withDomainIdentifiers: [Self.domainIdentifier]) + guard !connections.isEmpty else { return } + try await index.indexSearchableItems(connections.map(Self.searchableItem(for:))) + } + + static func searchableItem(for connection: SearchableConnection) -> CSSearchableItem { + let attributes = CSSearchableItemAttributeSet(contentType: .item) + attributes.title = connection.title + attributes.contentDescription = connection.summary + return CSSearchableItem( + uniqueIdentifier: connection.id.uuidString, + domainIdentifier: domainIdentifier, + attributeSet: attributes + ) + } +} diff --git a/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift b/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift index dd80b5ccec..a7fa4a176a 100644 --- a/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift +++ b/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift @@ -5,9 +5,11 @@ import TableProModels final class IOSSSHProvider: SSHProvider, @unchecked Sendable { private let tunnelStore = TunnelStore() private let secureStore: SecureStore + private let container: AppContainerPaths - init(secureStore: SecureStore) { + init(secureStore: SecureStore, container: AppContainerPaths = .live) { self.secureStore = secureStore + self.container = container } func createTunnel( @@ -17,23 +19,13 @@ final class IOSSSHProvider: SSHProvider, @unchecked Sendable { remotePort: Int ) async throws -> TableProDatabase.SSHTunnel { var resolvedConfig = config - - let sshPassword = try? secureStore.retrieve( - forKey: "com.TablePro.sshpassword.\(connectionId.uuidString)") - let keyPassphrase = try? secureStore.retrieve( - forKey: "com.TablePro.keypassphrase.\(connectionId.uuidString)") - - if resolvedConfig.privateKeyData == nil || resolvedConfig.privateKeyData?.isEmpty == true { - resolvedConfig.privateKeyData = try? secureStore.retrieve( - forKey: "com.TablePro.sshkeydata.\(connectionId.uuidString)") - } + resolvedConfig.privateKeyPath = config.privateKeyPath.map(container.localPath(forStoredPath:)) let tunnel = try await SSHTunnelFactory.create( config: resolvedConfig, remoteHost: remoteHost, remotePort: remotePort, - sshPassword: sshPassword, - keyPassphrase: keyPassphrase + credentials: SSHTunnelCredentials(connectionId: connectionId, secureStore: secureStore) ) let tunnelId = UUID() diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnelCredentials.swift b/TableProMobile/TableProMobile/SSH/SSHTunnelCredentials.swift new file mode 100644 index 0000000000..244ab8cafd --- /dev/null +++ b/TableProMobile/TableProMobile/SSH/SSHTunnelCredentials.swift @@ -0,0 +1,49 @@ +import Foundation +import TableProDatabase + +nonisolated struct SSHTunnelCredentials: Sendable, Equatable { + nonisolated enum PrivateKeySource: Sendable, Equatable { + case inMemory(String) + case file(path: String) + case missing + } + + let password: String? + let keyPassphrase: String? + let privateKey: String? + + init(password: String? = nil, keyPassphrase: String? = nil, privateKey: String? = nil) { + self.password = Self.nonEmpty(password) + self.keyPassphrase = Self.nonEmpty(keyPassphrase) + self.privateKey = Self.nonEmpty(privateKey) + } + + init(connectionId: UUID, secureStore: any SecureStore) { + self.init( + password: Self.secret(.sshPassword, for: connectionId, in: secureStore), + keyPassphrase: Self.secret(.keyPassphrase, for: connectionId, in: secureStore), + privateKey: Self.secret(.sshPrivateKey, for: connectionId, in: secureStore) + ) + } + + func privateKeySource(keyPath: String?) -> PrivateKeySource { + if let privateKey { + return .inMemory(privateKey) + } + guard let keyPath, !keyPath.isEmpty else { return .missing } + return .file(path: keyPath) + } + + private static func secret( + _ kind: ConnectionSecretKind, + for connectionId: UUID, + in secureStore: any SecureStore + ) -> String? { + try? secureStore.retrieve(forKey: kind.account(for: connectionId)) + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + return value + } +} diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift b/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift index 314f2e04c0..cd5f7d8a8e 100644 --- a/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift +++ b/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift @@ -12,8 +12,7 @@ enum SSHTunnelFactory { config: SSHConfiguration, remoteHost: String, remotePort: Int, - sshPassword: String?, - keyPassphrase: String? + credentials: SSHTunnelCredentials ) async throws -> SSHTunnel { _ = initialized @@ -39,25 +38,26 @@ enum SSHTunnelFactory { switch config.authMethod { case .password: - guard let password = sshPassword else { + guard let password = credentials.password else { throw SSHTunnelError.authenticationFailed("No SSH password provided") } try await tunnel.authenticatePassword(username: config.username, password: password) case .privateKey: - if let keyContent = config.privateKeyData, !keyContent.isEmpty { + switch credentials.privateKeySource(keyPath: config.privateKeyPath) { + case .inMemory(let keyContent): try await tunnel.authenticatePublicKeyFromMemory( username: config.username, keyContent: keyContent, - passphrase: keyPassphrase + passphrase: credentials.keyPassphrase ) - } else if let keyPath = config.privateKeyPath, !keyPath.isEmpty { + case .file(let keyPath): try await tunnel.authenticatePublicKey( username: config.username, keyPath: keyPath, - passphrase: keyPassphrase + passphrase: credentials.keyPassphrase ) - } else { + case .missing: throw SSHTunnelError.authenticationFailed("No private key provided") } diff --git a/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift b/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift index 1cffb72182..4ca4bdf7c5 100644 --- a/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift +++ b/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift @@ -6,6 +6,17 @@ import TableProModels @MainActor enum IOSConnectionExportService { + nonisolated enum ExportError: LocalizedError, Equatable { + case credentialsNeedPassphrase + + var errorDescription: String? { + switch self { + case .credentialsNeedPassphrase: + String(localized: "Set a passphrase to include passwords.") + } + } + } + private static let logger = Logger(subsystem: "com.TablePro", category: "IOSConnectionExport") private static let currentFormatVersion = 1 @@ -14,16 +25,23 @@ enum IOSConnectionExportService { appState: AppState, includeCredentials: Bool, passphrase: String? - ) throws -> Data { + ) async throws -> Data { let envelope = includeCredentials ? buildEnvelopeWithCredentials(connections, appState: appState) : buildEnvelope(connections, appState: appState) - let json = try ConnectionImportDecoder.encode(envelope) + return try await fileData(for: envelope, passphrase: includeCredentials ? passphrase : nil) + } - guard includeCredentials, let passphrase, !passphrase.isEmpty else { + static func fileData(for envelope: ConnectionExportEnvelope, passphrase: String?) async throws -> Data { + let json = try ConnectionImportDecoder.encode(envelope) + guard let passphrase, !passphrase.isEmpty else { + guard envelope.credentials == nil else { + logger.error("Refusing to write saved passwords to a connection file without a passphrase") + throw ExportError.credentialsNeedPassphrase + } return json } - return try ConnectionExportCrypto.encrypt(data: json, passphrase: passphrase) + return try await ConnectionExportCrypto.encrypt(data: json, passphrase: passphrase) } static func suggestedFilename(for connections: [DatabaseConnection]) -> String { @@ -101,10 +119,9 @@ enum IOSConnectionExportService { var credentialsMap: [String: ExportableCredentials] = [:] for (index, connection) in connections.enumerated() { - let suffix = connection.id.uuidString - let password = secret(from: store, key: "com.TablePro.password.\(suffix)") - let sshPassword = secret(from: store, key: "com.TablePro.sshpassword.\(suffix)") - let keyPassphrase = secret(from: store, key: "com.TablePro.keypassphrase.\(suffix)") + let password = secret(.password, of: connection.id, from: store) + let sshPassword = secret(.sshPassword, of: connection.id, from: store) + let keyPassphrase = secret(.keyPassphrase, of: connection.id, from: store) guard password != nil || sshPassword != nil || keyPassphrase != nil else { continue } credentialsMap[String(index)] = ExportableCredentials( @@ -130,8 +147,12 @@ enum IOSConnectionExportService { // MARK: - Helpers - private static func secret(from store: any SecureStore, key: String) -> String? { - (try? store.retrieve(forKey: key)) ?? nil + private static func secret( + _ kind: ConnectionSecretKind, + of connectionId: UUID, + from store: any SecureStore + ) -> String? { + (try? store.retrieve(forKey: kind.account(for: connectionId))) ?? nil } private static func exportableSSH(_ connection: DatabaseConnection) -> ExportableSSHConfig? { diff --git a/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift b/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift index 4d3e217c96..4c54664866 100644 --- a/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift +++ b/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift @@ -93,7 +93,10 @@ enum IOSConnectionImportService { id: existingId, from: item.connection, name: item.connection.name, sortOrder: existingSortOrder, tagIdsByName: tagIdsByName, groupIdsByName: groupIdsByName ) - appState.updateConnection(connection) + guard appState.mutateConnection(existingId, { $0 = connection }).isSaved else { + logger.error("Import could not replace a connection that is no longer in the library") + continue + } connectionIdMap[index] = existingId importedCount += 1 } @@ -115,19 +118,30 @@ enum IOSConnectionImportService { guard let credentials = envelope.credentials else { return } for (indexString, creds) in credentials { guard let index = Int(indexString), let id = connectionIdMap[index] else { continue } - let suffix = id.uuidString - if let password = creds.password { - try? secureStore.store(password, forKey: "com.TablePro.password.\(suffix)") - } - if let sshPassword = creds.sshPassword { - try? secureStore.store(sshPassword, forKey: "com.TablePro.sshpassword.\(suffix)") - } - if let keyPassphrase = creds.keyPassphrase { - try? secureStore.store(keyPassphrase, forKey: "com.TablePro.keypassphrase.\(suffix)") + let secrets: [(ConnectionSecretKind, String?)] = [ + (.password, creds.password), + (.sshPassword, creds.sshPassword), + (.keyPassphrase, creds.keyPassphrase) + ] + for case let (kind, value?) in secrets { + storeSecret(value, as: kind, of: id, in: secureStore) } } } + private static func storeSecret( + _ value: String, + as kind: ConnectionSecretKind, + of connectionId: UUID, + in secureStore: any SecureStore + ) { + do { + try secureStore.store(value, forKey: kind.account(for: connectionId)) + } catch { + logger.error("Restoring an imported secret failed: \(error.localizedDescription, privacy: .public)") + } + } + // MARK: - Building private static func buildConnection( diff --git a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift index 6b31722ba6..cce603bdb4 100644 --- a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift +++ b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift @@ -1,4 +1,5 @@ import CloudKit +import Combine import Foundation import Observation import os @@ -30,6 +31,7 @@ final class IOSSyncCoordinator { @ObservationIgnored private var needsResync = false @ObservationIgnored private var statusGeneration = 0 @ObservationIgnored private var editGenerations: [EditKey: Int] = [:] + @ObservationIgnored private var accountChangeObservation: AnyCancellable? @ObservationIgnored var onConnectionsChanged: (([DatabaseConnection]) -> Void)? @ObservationIgnored var onGroupsChanged: (([ConnectionGroup]) -> Void)? @@ -49,7 +51,8 @@ final class IOSSyncCoordinator { defaults: .standard ), makeTransport: @escaping () -> any IOSSyncTransport = { CloudKitSyncEngine() }, - isEnabled: @escaping () -> Bool = { AppPreferences.isCloudSyncEnabled } + isEnabled: @escaping () -> Bool = { AppPreferences.isCloudSyncEnabled }, + notificationCenter: NotificationCenter = .default ) { self.metadata = metadata self.recordCache = recordCache @@ -57,6 +60,12 @@ final class IOSSyncCoordinator { self.isEnabled = isEnabled self.status = isEnabled() ? .idle : .disabled(.userDisabled) self.lastSyncDate = metadata.lastSyncDate + accountChangeObservation = notificationCenter.publisher(for: .CKAccountChanged) + .sink { @Sendable [weak self] _ in + Task { @MainActor in + self?.scheduleSyncAfterChange() + } + } } private func currentTransport() -> any IOSSyncTransport { @@ -135,6 +144,10 @@ final class IOSSyncCoordinator { return } + let accountId = try await transport.currentAccountId() + guard generation == statusGeneration else { return } + adoptAccount(accountId) + try await transport.ensureZoneExists() let remoteChanges = try await pull(using: transport) guard generation == statusGeneration else { return } @@ -170,6 +183,19 @@ final class IOSSyncCoordinator { } } + private func adoptAccount(_ accountId: String) { + switch metadata.adoptAccount(accountId) { + case .firstSeen, .unchanged: + return + case .switched: + Self.logger.notice("The iCloud account changed, so sync starts over and pending edits go to the new account") + case .previousAccountUnknown: + Self.logger.notice("An earlier build synced without recording its iCloud account, so sync starts over once") + } + recordCache.removeAll() + lastSyncDate = nil + } + @discardableResult private func decide(_ outcome: SyncStatus) -> Int { statusGeneration += 1 @@ -313,7 +339,8 @@ final class IOSSyncCoordinator { guard !allRecords.isEmpty || !allDeletions.isEmpty else { return } - let outcome = try await transport.push(records: allRecords, deletions: allDeletions) + var outcome = try await transport.push(records: allRecords, deletions: allDeletions) + outcome.acceptMissingDeletions(of: allDeletions) recordCache.store(Array(outcome.savedRecords.values)) recordCache.remove(Array(outcome.deletedRecordIDs)) diff --git a/TableProMobile/TableProMobile/Sync/IOSSyncTransport.swift b/TableProMobile/TableProMobile/Sync/IOSSyncTransport.swift index 204aa997d9..d3fc8aab4b 100644 --- a/TableProMobile/TableProMobile/Sync/IOSSyncTransport.swift +++ b/TableProMobile/TableProMobile/Sync/IOSSyncTransport.swift @@ -5,6 +5,7 @@ import TableProSyncTransport nonisolated protocol IOSSyncTransport: Sendable { var currentZoneID: CKRecordZone.ID { get async } func accountStatus() async throws -> CKAccountStatus + func currentAccountId() async throws -> String func ensureZoneExists() async throws func pull(since token: CKServerChangeToken?) async throws -> PullResult func push(records: [CKRecord], deletions: [CKRecord.ID]) async throws -> PushOutcome diff --git a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Certificates.swift b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Certificates.swift index 1f6d15505e..ee2dbbd26e 100644 --- a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Certificates.swift +++ b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Certificates.swift @@ -1,7 +1,10 @@ import Foundation +import os import TableProModels extension ConnectionFormViewModel { + private static let certificateLogger = Logger(subsystem: "com.TablePro", category: "ConnectionFormViewModel") + var usesCertificateSection: Bool { !isFileBased && type != .oracle && type != .mssql } @@ -14,6 +17,7 @@ extension ConnectionFormViewModel { guard let connectionId = existingConnection?.id else { return } for role in CertificateRole.allCases { guard let pem = certificateStore.pem(role: role, for: connectionId) else { continue } + storedCertificateRoles.insert(role) certificateSummaries[role] = summary(for: role, pem: pem) } } @@ -74,15 +78,26 @@ extension ConnectionFormViewModel { removedCertificates.insert(role) } - func persistCertificates(for connectionId: UUID) { + func persistCertificates(for connectionId: UUID) -> Bool { for role in removedCertificates where pendingCertificates[role] == nil { certificateStore.delete(role: role, for: connectionId) + storedCertificateRoles.remove(role) } + removedCertificates.removeAll() + var storedEvery = true for (role, pem) in pendingCertificates { - try? certificateStore.store(pem, role: role, for: connectionId) + do { + try certificateStore.store(pem, role: role, for: connectionId) + storedCertificateRoles.insert(role) + pendingCertificates[role] = nil + } catch { + Self.certificateLogger.error( + "Failed to store the \(role.rawValue, privacy: .public) certificate: \(error.localizedDescription, privacy: .public)" + ) + storedEvery = false + } } - removedCertificates.removeAll() - pendingCertificates.removeAll() + return storedEvery } func cancelPKCS12() { diff --git a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Changes.swift b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Changes.swift new file mode 100644 index 0000000000..c32d9a35c1 --- /dev/null +++ b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Changes.swift @@ -0,0 +1,78 @@ +import Foundation +import TableProModels + +nonisolated struct ConnectionFormSecrets: Equatable, Sendable { + var password = "" + var sshPassword = "" + var sshKeyPassphrase = "" + var privateKey: String? +} + +nonisolated struct ConnectionFormSecretWrites: Equatable, Sendable { + var password: String? + var sshPassword: String? + var sshKeyPassphrase: String? +} + +nonisolated struct ConnectionFormSnapshot: Equatable, Sendable { + var edits: ConnectionFormEdits + var secrets: ConnectionFormSecrets + var stagedCertificates: [CertificateRole: String] + var removedStoredCertificates: Set +} + +extension ConnectionFormViewModel { + var snapshot: ConnectionFormSnapshot { + ConnectionFormSnapshot( + edits: edits, + secrets: secretsAfterSave, + stagedCertificates: pendingCertificates, + removedStoredCertificates: removedCertificates.intersection(storedCertificateRoles) + ) + } + + var openingEdits: ConnectionFormEdits? { + guard isEditing else { return nil } + return baseline?.edits + } + + var hasChanges: Bool { + guard let baseline else { return false } + return snapshot != baseline + } + + var changesSecrets: Bool { + guard let baseline else { return false } + let current = snapshot + return current.secrets != baseline.secrets + || current.stagedCertificates != baseline.stagedCertificates + || current.removedStoredCertificates != baseline.removedStoredCertificates + } + + var reconnectsAfterSave: Bool { + isEditing && changesSecrets + } + + var secretWrites: ConnectionFormSecretWrites { + ConnectionFormSecretWrites( + password: Self.changedSecret(password, loaded: storedSecrets.password), + sshPassword: sshEnabled ? Self.changedSecret(sshPassword, loaded: storedSecrets.sshPassword) : nil, + sshKeyPassphrase: sshEnabled + ? Self.changedSecret(sshKeyPassphrase, loaded: storedSecrets.sshKeyPassphrase) + : nil + ) + } + + private var secretsAfterSave: ConnectionFormSecrets { + ConnectionFormSecrets( + password: password.isEmpty ? storedSecrets.password : password, + sshPassword: sshEnabled && !sshPassword.isEmpty ? sshPassword : storedSecrets.sshPassword, + sshKeyPassphrase: sshEnabled && !sshKeyPassphrase.isEmpty ? sshKeyPassphrase : storedSecrets.sshKeyPassphrase, + privateKey: pastedPrivateKey + ) + } + + private static func changedSecret(_ value: String, loaded: String) -> String? { + value.isEmpty || value == loaded ? nil : value + } +} diff --git a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift index d9ed175959..e461ccb116 100644 --- a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift @@ -26,6 +26,12 @@ final class ConnectionFormViewModel { var suggestedOracleMode: OracleConnectionOptions.IdentifierMode? } + nonisolated enum PendingDatabaseFile: Equatable, Sendable { + case documentsFile + case newDocumentsFile(URL) + case bookmarked(Data) + } + private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionFormViewModel") // Form fields @@ -48,10 +54,11 @@ final class ConnectionFormViewModel { var certificateError: String? var pastedCertificate = "" var pkcs12Password = "" - @ObservationIgnored var pendingCertificates: [CertificateRole: String] = [:] - @ObservationIgnored var removedCertificates: Set = [] + var pendingCertificates: [CertificateRole: String] = [:] + var removedCertificates: Set = [] + var storedCertificateRoles: Set = [] @ObservationIgnored var pendingPKCS12: Data? - @ObservationIgnored let certificateStore: any CertificateMaterialStoring = CertificateMaterialStore() + @ObservationIgnored let certificateStore: any CertificateMaterialStoring var oracleConnectionType: OracleConnectionOptions.IdentifierMode = .service var oracleServiceName = "" var oracleSID = "" @@ -81,18 +88,41 @@ final class ConnectionFormViewModel { var duckDBInMemory = false { didSet { onDuckDBInMemoryChange() } } - private var pendingBookmark: Data? - private let bookmarkStore = FileBookmarkStore() + private(set) var pendingFile: PendingDatabaseFile? + private(set) var fileError: String? + private(set) var sshKeyFileError: String? // Async state private(set) var isTesting = false + private(set) var isSaving = false private(set) var testResult: TestResult? private(set) var credentialError: String? + private(set) var saveFailure: LibraryWriteFailure? @ObservationIgnored let existingConnection: DatabaseConnection? - - init(editing: DatabaseConnection? = nil) { + @ObservationIgnored let connectionId: UUID + @ObservationIgnored private var createdFileURL: URL? + @ObservationIgnored private var addedNewConnection = false + private(set) var baseline: ConnectionFormSnapshot? + private(set) var storedSecrets = ConnectionFormSecrets() + private let localFiles: LocalDatabaseFileLocator + private let fileCreator: any LocalDatabaseFileCreating + private let bookmarkStore: FileBookmarkStore + + init( + editing: DatabaseConnection? = nil, + localFiles: LocalDatabaseFileLocator = .live, + fileCreator: any LocalDatabaseFileCreating = DriverDatabaseFileCreator(), + bookmarkStore: FileBookmarkStore = FileBookmarkStore(), + certificateStore: any CertificateMaterialStoring = CertificateMaterialStore() + ) { self.existingConnection = editing + self.connectionId = editing?.id ?? UUID() + self.localFiles = localFiles + self.fileCreator = fileCreator + self.bookmarkStore = bookmarkStore + self.certificateStore = certificateStore + defer { baseline = snapshot } guard let conn = editing else { safeModeLevel = AppPreferences.defaultSafeMode return @@ -125,21 +155,24 @@ final class ConnectionFormViewModel { sshUsername = ssh.username sshAuthMethod = ssh.authMethod sshKeyPath = ssh.privateKeyPath ?? "" - sshKeyContent = ssh.privateKeyData ?? "" - if let keyData = ssh.privateKeyData, !keyData.isEmpty { + if ssh.authMethod == .privateKey, sshKeyPath.isEmpty { sshKeyInputMode = .paste } } - if conn.type == .sqlite { - selectedFileURL = URL(fileURLWithPath: conn.database) - } - if conn.type == .duckdb { - if conn.database == DuckDBDriver.inMemoryPath { + hydrateDatabaseFile(from: conn) + } + + private func hydrateDatabaseFile(from connection: DatabaseConnection) { + guard connection.type == .sqlite || connection.type == .duckdb else { return } + let location = localFiles.location(forStoredPath: connection.database) + guard location != .inMemory else { + if connection.type == .duckdb { duckDBInMemory = true - } else if !conn.database.isEmpty { - selectedFileURL = URL(fileURLWithPath: conn.database) } + return } + guard !connection.database.isEmpty else { return } + selectedFileURL = location.fileURL ?? URL(fileURLWithPath: connection.database) } // MARK: - Computed @@ -160,20 +193,92 @@ final class ConnectionFormViewModel { var isEditing: Bool { existingConnection != nil } + var pastedPrivateKey: String? { + guard sshEnabled, sshAuthMethod == .privateKey, sshKeyInputMode == .paste, + !sshKeyContent.isEmpty else { return nil } + return sshKeyContent + } + + var edits: ConnectionFormEdits { + ConnectionFormEdits( + name: name.isEmpty ? (selectedFileURL?.lastPathComponent ?? host) : name, + type: type, + host: host, + port: Int(port) ?? 3_306, + username: username, + database: database, + groupId: groupId, + tagId: tagId, + safeModeLevel: safeModeLevel, + sslMode: effectiveSSLMode, + sshTunnel: sshTunnel, + oracle: oracleOptions + ) + } + + private var effectiveSSLMode: SSLConfiguration.SSLMode? { + switch type { + case .sqlite, .duckdb: nil + case .mssql: mssqlSSLMode + case .oracle: oracleSSLMode + default: sslMode + } + } + + private var sshTunnel: ConnectionFormEdits.SSHTunnel? { + guard sshEnabled else { return nil } + return ConnectionFormEdits.SSHTunnel( + host: sshHost, + port: Int(sshPort) ?? 22, + username: sshUsername, + authMethod: sshAuthMethod, + privateKeyPath: sshKeyPath.isEmpty ? nil : sshKeyPath + ) + } + + private var oracleOptions: ConnectionFormEdits.OracleOptions? { + guard type == .oracle else { return nil } + return ConnectionFormEdits.OracleOptions( + identifierMode: oracleConnectionType, + serviceName: oracleServiceName, + sid: oracleSID, + role: oracleRole, + networkEncryption: oracleNetworkEncryption + ) + } + // MARK: - Credential Hydration func loadStoredCredentials(secureStore: any SecureStore) async { guard let conn = existingConnection else { return } - let connKey = "com.TablePro.password.\(conn.id.uuidString)" - if let stored = try? secureStore.retrieve(forKey: connKey), !stored.isEmpty { + if let stored = Self.storedSecret(.password, for: conn.id, in: secureStore) { password = stored + storedSecrets.password = stored } - if let sshPwd = try? secureStore.retrieve(forKey: "com.TablePro.sshpassword.\(conn.id.uuidString)"), !sshPwd.isEmpty { + if let sshPwd = Self.storedSecret(.sshPassword, for: conn.id, in: secureStore) { sshPassword = sshPwd + storedSecrets.sshPassword = sshPwd } - if let passphrase = try? secureStore.retrieve(forKey: "com.TablePro.keypassphrase.\(conn.id.uuidString)"), !passphrase.isEmpty { + if let passphrase = Self.storedSecret(.keyPassphrase, for: conn.id, in: secureStore) { sshKeyPassphrase = passphrase + storedSecrets.sshKeyPassphrase = passphrase + } + if let privateKey = Self.storedSecret(.sshPrivateKey, for: conn.id, in: secureStore) { + sshKeyContent = privateKey + storedSecrets.privateKey = privateKey + sshKeyInputMode = .paste } + baseline?.secrets = snapshot.secrets + } + + private static func storedSecret( + _ kind: ConnectionSecretKind, + for connectionId: UUID, + in secureStore: any SecureStore + ) -> String? { + guard let value = try? secureStore.retrieve(forKey: kind.account(for: connectionId)), + !value.isEmpty else { return nil } + return value } // MARK: - Type Change @@ -183,16 +288,16 @@ final class ConnectionFormViewModel { updateDefaultPort() selectedFileURL = nil database = "" - pendingBookmark = nil + pendingFile = nil duckDBInMemory = false } private func onDuckDBInMemoryChange() { if duckDBInMemory { selectedFileURL = nil - pendingBookmark = nil - database = DuckDBDriver.inMemoryPath - } else if database == DuckDBDriver.inMemoryPath { + pendingFile = nil + database = LocalDatabaseLocation.inMemoryPath + } else if database == LocalDatabaseLocation.inMemoryPath { database = "" } } @@ -204,93 +309,136 @@ final class ConnectionFormViewModel { // MARK: - File Picker func handleSQLiteFilePicker(_ result: Result<[URL], Error>) { - guard case .success(let urls) = result, let url = urls.first else { return } - guard url.startAccessingSecurityScopedResource() else { return } - defer { url.stopAccessingSecurityScopedResource() } - - let destURL = copyToDocuments(url) - selectedFileURL = destURL - database = destURL.path - if name.isEmpty { - name = destURL.deletingPathExtension().lastPathComponent + guard let url = pickedDatabaseURL(from: result) else { return } + guard !localFiles.isInDocuments(url) else { + adopt(url, pending: .documentsFile) + return + } + let didStart = url.startAccessingSecurityScopedResource() + defer { + if didStart { url.stopAccessingSecurityScopedResource() } + } + do { + adopt(try localFiles.importCopy(of: url), pending: .documentsFile) + } catch { + fileError = error.localizedDescription } } func handleDuckDBFilePicker(_ result: Result<[URL], Error>) { - guard case .success(let urls) = result, let url = urls.first else { return } - guard url.startAccessingSecurityScopedResource() else { return } - defer { url.stopAccessingSecurityScopedResource() } + guard let url = pickedDatabaseURL(from: result) else { return } + guard !localFiles.isInDocuments(url) else { + adopt(url, pending: .documentsFile) + return + } + let didStart = url.startAccessingSecurityScopedResource() + defer { + if didStart { url.stopAccessingSecurityScopedResource() } + } + do { + adopt(url, pending: .bookmarked(try url.bookmarkData())) + } catch { + Self.logger.error("Bookmarking a DuckDB file failed: \(error.localizedDescription, privacy: .private)") + fileError = LocalDatabaseFileError.accessDenied(fileName: url.lastPathComponent).localizedDescription + } + } + + private func pickedDatabaseURL(from result: Result<[URL], Error>) -> URL? { + switch result { + case .success(let urls): + return urls.first + case .failure(let error): + fileError = error.localizedDescription + return nil + } + } - guard let data = try? url.bookmarkData() else { return } - pendingBookmark = data + private func adopt(_ url: URL, pending: PendingDatabaseFile) { selectedFileURL = url database = url.path + pendingFile = pending if name.isEmpty { name = url.deletingPathExtension().lastPathComponent } } func handleSSHKeyFilePicker(_ result: Result<[URL], Error>) { - guard case .success(let urls) = result, let url = urls.first else { return } - guard url.startAccessingSecurityScopedResource() else { return } - defer { url.stopAccessingSecurityScopedResource() } + let url: URL + switch result { + case .success(let urls): + guard let first = urls.first else { return } + url = first + case .failure(let error): + sshKeyFileError = error.localizedDescription + return + } + let didStart = url.startAccessingSecurityScopedResource() + defer { + if didStart { url.stopAccessingSecurityScopedResource() } + } if let content = try? String(contentsOf: url, encoding: .utf8) { sshKeyContent = content sshKeyInputMode = .paste - } else { - guard let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } - let dest = docsDir.appendingPathComponent("ssh_" + url.lastPathComponent) - try? FileManager.default.removeItem(at: dest) - try? FileManager.default.copyItem(at: url, to: dest) - sshKeyPath = dest.path + return } - } - - private func copyToDocuments(_ sourceURL: URL) -> URL { - guard let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { - return sourceURL + guard !localFiles.isInDocuments(url) else { + sshKeyPath = url.path + return } - var destURL = documentsDir.appendingPathComponent(sourceURL.lastPathComponent) - - if FileManager.default.fileExists(atPath: destURL.path) { - let baseName = sourceURL.deletingPathExtension().lastPathComponent - let ext = sourceURL.pathExtension - let suffix = UUID().uuidString.prefix(8) - destURL = documentsDir.appendingPathComponent("\(baseName)_\(suffix).\(ext)") + do { + sshKeyPath = try localFiles.importCopy(of: url).path + } catch { + sshKeyFileError = error.localizedDescription } + } - try? FileManager.default.copyItem(at: sourceURL, to: destURL) - return destURL + func dismissSSHKeyFileError() { + sshKeyFileError = nil } func clearSelectedFile() { selectedFileURL = nil database = "" - pendingBookmark = nil + pendingFile = nil } func createNewDatabase() { - guard !newDatabaseName.isEmpty else { return } - - let fileExtension = type == .duckdb ? "duckdb" : "db" - let suffix = ".\(fileExtension)" - let safeName = newDatabaseName.hasSuffix(suffix) ? newDatabaseName : "\(newDatabaseName)\(suffix)" - guard let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } - let fileURL = documentsDir.appendingPathComponent(safeName) - - selectedFileURL = fileURL - database = fileURL.path - pendingBookmark = nil - if name.isEmpty { - name = newDatabaseName - } + let requestedName = newDatabaseName newDatabaseName = "" + do { + let url = try localFiles.newDatabaseFile(named: requestedName, type: type) + adopt(url, pending: .newDocumentsFile(url)) + } catch { + fileError = error.localizedDescription + } + } + + func dismissFileError() { + fileError = nil } // MARK: - Test Connection - func testConnection(appState: AppState, secureStore: any SecureStore) async { + func testSecrets(for connectionId: UUID) -> [String: String] { + var secrets: [String: String] = [:] + if !password.isEmpty { + secrets[ConnectionSecretKind.password.account(for: connectionId)] = password + } + guard sshEnabled else { return secrets } + if !sshPassword.isEmpty { + secrets[ConnectionSecretKind.sshPassword.account(for: connectionId)] = sshPassword + } + if !sshKeyPassphrase.isEmpty { + secrets[ConnectionSecretKind.keyPassphrase.account(for: connectionId)] = sshKeyPassphrase + } + if let pastedPrivateKey { + secrets[ConnectionSecretKind.sshPrivateKey.account(for: connectionId)] = pastedPrivateKey + } + return secrets + } + + func testConnection() async { isTesting = true testResult = nil defer { isTesting = false } @@ -298,30 +446,29 @@ final class ConnectionFormViewModel { let tempId = UUID() var testConn = buildConnection() testConn.id = tempId - - if !password.isEmpty { - try? appState.connectionManager.storePassword(password, for: tempId) - } - if sshEnabled && !sshPassword.isEmpty { - try? secureStore.store(sshPassword, forKey: "com.TablePro.sshpassword.\(tempId.uuidString)") - } - if sshEnabled && !sshKeyPassphrase.isEmpty { - try? secureStore.store(sshKeyPassphrase, forKey: "com.TablePro.keypassphrase.\(tempId.uuidString)") - } - if sshEnabled && !sshKeyContent.isEmpty { - try? secureStore.store(sshKeyContent, forKey: "com.TablePro.sshkeydata.\(tempId.uuidString)") + let scratchDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("ConnectionTest-\(tempId.uuidString)", isDirectory: true) + + let secrets = EphemeralSecureStore(testSecrets(for: tempId)) + let manager = ConnectionManager( + driverFactory: IOSDriverFactory(bookmarkStore: bookmarkStore, localFiles: localFiles), + secureStore: secrets, + sshProvider: IOSSSHProvider(secureStore: secrets, container: localFiles.container) + ) + if let bookmark = bookmarkForTest { + bookmarkStore.save(bookmark, for: tempId) } - defer { - try? appState.connectionManager.deletePassword(for: tempId) - try? secureStore.delete(forKey: "com.TablePro.sshpassword.\(tempId.uuidString)") - try? secureStore.delete(forKey: "com.TablePro.keypassphrase.\(tempId.uuidString)") - try? secureStore.delete(forKey: "com.TablePro.sshkeydata.\(tempId.uuidString)") + bookmarkStore.delete(for: tempId) + removeScratchDirectory(scratchDirectory) } do { - _ = try await appState.connectionManager.connect(testConn) - await appState.connectionManager.disconnect(tempId) + if let scratchPath = try await scratchDatabasePath(in: scratchDirectory) { + testConn.database = scratchPath + } + _ = try await manager.connect(testConn) + await manager.disconnect(tempId) testResult = TestResult( success: true, message: String(localized: "Connection successful"), @@ -344,138 +491,191 @@ final class ConnectionFormViewModel { } } + private var bookmarkForTest: Data? { + guard type == .duckdb, !duckDBInMemory else { return nil } + switch pendingFile { + case .bookmarked(let bookmark): + return bookmark + case .documentsFile, .newDocumentsFile: + return nil + case nil: + return existingConnection.flatMap { bookmarkStore.bookmark(for: $0.id) } + } + } + + private func scratchDatabasePath(in scratchDirectory: URL) async throws -> String? { + guard case .newDocumentsFile(let destination) = pendingFile else { return nil } + try FileManager.default.createDirectory(at: scratchDirectory, withIntermediateDirectories: true) + let scratchFile = scratchDirectory.appendingPathComponent(destination.lastPathComponent) + try await fileCreator.createDatabase(at: scratchFile, type: type) + return scratchFile.path + } + + private func removeScratchDirectory(_ directory: URL) { + guard FileManager.default.fileExists(atPath: directory.path) else { return } + do { + try FileManager.default.removeItem(at: directory) + } catch { + Self.logger.error("Removing a test database failed: \(error.localizedDescription, privacy: .private)") + } + } + // MARK: - Save - func save(appState: AppState, secureStore: any SecureStore) -> DatabaseConnection? { - let connection = buildConnection() - var storageFailed = false + func save(appState: AppState, secureStore: any SecureStore) async -> UUID? { + guard !isSaving else { return nil } + isSaving = true + defer { isSaving = false } + + guard await createPendingDatabaseFile() else { return nil } + let draft = buildConnection() + let outcome = writeToLibrary(draft, appState: appState) + if let failure = LibraryWriteFailure(outcome, kind: .connection) { + discardCreatedFile() + saveFailure = failure + return nil + } + createdFileURL = nil + settleBookmark() + let storedEverySecret = storeSecrets(appState: appState, secureStore: secureStore) + advanceBaselineToSavedState() + guard storedEverySecret else { return nil } + return connectionId + } - persistCertificates(for: connection.id) + func applyingEdits(to current: DatabaseConnection) -> DatabaseConnection { + edits.applied(to: current, changedSince: openingEdits) + } - if type == .duckdb { + func buildConnection() -> DatabaseConnection { + edits.applied(to: existingConnection ?? DatabaseConnection(id: connectionId), changedSince: nil) + } + + func dismissSaveFailure() { + saveFailure = nil + } + + private func writeToLibrary(_ draft: DatabaseConnection, appState: AppState) -> LibraryWriteOutcome { + guard isEditing || addedNewConnection else { + guard appState.addConnection(draft) else { return .refused } + addedNewConnection = true + return .applied + } + return appState.mutateConnection(draft.id) { $0 = applyingEdits(to: $0) } + } + + private func createPendingDatabaseFile() async -> Bool { + guard case .newDocumentsFile(let url) = pendingFile else { return true } + do { + try await fileCreator.createDatabase(at: url, type: type) + } catch { + fileError = error.localizedDescription + return false + } + createdFileURL = url + if pendingFile == .newDocumentsFile(url) { + pendingFile = .documentsFile + } + return true + } + + private func settleBookmark() { + guard type == .duckdb else { + if existingConnection?.type == .duckdb { + bookmarkStore.delete(for: connectionId) + } + return + } + switch pendingFile { + case .bookmarked(let bookmark): + bookmarkStore.save(bookmark, for: connectionId) + case .documentsFile, .newDocumentsFile: + bookmarkStore.delete(for: connectionId) + case nil: if duckDBInMemory { - bookmarkStore.delete(for: connection.id) - } else if let pendingBookmark { - bookmarkStore.save(pendingBookmark, for: connection.id) + bookmarkStore.delete(for: connectionId) } } + } + + private func discardCreatedFile() { + guard let createdFileURL else { return } + fileCreator.removeDatabase(at: createdFileURL) + self.createdFileURL = nil + if pendingFile == .documentsFile { + pendingFile = .newDocumentsFile(createdFileURL) + } + } - if !password.isEmpty { + private func storeSecrets(appState: AppState, secureStore: any SecureStore) -> Bool { + let writes = secretWrites + var storageFailed = !persistCertificates(for: connectionId) + + if let changed = writes.password { do { - try appState.connectionManager.storePassword(password, for: connection.id) + try appState.connectionManager.storePassword(changed, for: connectionId) + storedSecrets.password = changed } catch { Self.logger.error("Failed to store password: \(error.localizedDescription, privacy: .public)") storageFailed = true } } - if sshEnabled { - if !sshPassword.isEmpty { - do { - try secureStore.store(sshPassword, forKey: "com.TablePro.sshpassword.\(connection.id.uuidString)") - } catch { - Self.logger.error("Failed to store SSH password: \(error.localizedDescription, privacy: .public)") - storageFailed = true - } - } - if !sshKeyPassphrase.isEmpty { - do { - try secureStore.store(sshKeyPassphrase, forKey: "com.TablePro.keypassphrase.\(connection.id.uuidString)") - } catch { - Self.logger.error("Failed to store SSH key passphrase: \(error.localizedDescription, privacy: .public)") - storageFailed = true - } + if let changed = writes.sshPassword { + do { + try secureStore.store(changed, forKey: ConnectionSecretKind.sshPassword.account(for: connectionId)) + storedSecrets.sshPassword = changed + } catch { + Self.logger.error("Failed to store SSH password: \(error.localizedDescription, privacy: .public)") + storageFailed = true } - if !sshKeyContent.isEmpty { - do { - try secureStore.store(sshKeyContent, forKey: "com.TablePro.sshkeydata.\(connection.id.uuidString)") - } catch { - Self.logger.error("Failed to store SSH key data: \(error.localizedDescription, privacy: .public)") - storageFailed = true - } + } + if let changed = writes.sshKeyPassphrase { + do { + try secureStore.store(changed, forKey: ConnectionSecretKind.keyPassphrase.account(for: connectionId)) + storedSecrets.sshKeyPassphrase = changed + } catch { + Self.logger.error("Failed to store SSH key passphrase: \(error.localizedDescription, privacy: .public)") + storageFailed = true } } - if storageFailed { - credentialError = String(localized: "Some credentials could not be saved to the keychain. You may need to re-enter them later.") - return nil + do { + try persistPrivateKey(secureStore: secureStore) + } catch { + Self.logger.error("Failed to store SSH private key: \(error.localizedDescription, privacy: .public)") + storageFailed = true } - return connection - } - - func dismissCredentialError() { - credentialError = nil - } - - private var effectiveSSLEnabled: Bool { - switch type { - case .mssql: return mssqlSSLMode != .disable - case .oracle: return oracleSSLMode != .disable - default: return sslMode != .disable + guard !storageFailed else { + credentialError = String(localized: "Some credentials could not be saved to the keychain. You may need to re-enter them later.") + return false } + return true } - static func tagIds(selecting tagId: UUID?, over existing: [UUID]) -> [UUID] { - let others = existing.dropFirst().filter { $0 != tagId } - guard let tagId else { return Array(others) } - return [tagId] + others + private func advanceBaselineToSavedState() { + baseline = ConnectionFormSnapshot( + edits: edits, + secrets: storedSecrets, + stagedCertificates: [:], + removedStoredCertificates: [] + ) } - func buildConnection() -> DatabaseConnection { - var conn = existingConnection ?? DatabaseConnection() - conn.name = name.isEmpty ? (selectedFileURL?.lastPathComponent ?? host) : name - conn.type = type - conn.host = host - conn.port = Int(port) ?? 3_306 - conn.username = username - conn.database = database - conn.sshEnabled = sshEnabled - conn.sslEnabled = effectiveSSLEnabled - conn.groupId = groupId - conn.tagIds = Self.tagIds(selecting: tagId, over: existingConnection?.tagIds ?? []) - conn.sshConfiguration = nil - conn.additionalFields = existingConnection?.additionalFields ?? [:] - conn.sslConfiguration = existingConnection?.sslConfiguration - - if usesCertificateSection { - conn.sslConfiguration = sslConfigurationPreservingCertificates(mode: sslMode) - } - if type == .mssql { - conn.sslConfiguration = sslConfigurationPreservingCertificates(mode: mssqlSSLMode) - } - if type == .oracle { - conn.sslConfiguration = sslConfigurationPreservingCertificates(mode: oracleSSLMode) - conn.additionalFields[OracleConnectionOptions.AdditionalFieldKey.connectionType] = - oracleConnectionType.rawValue - conn.additionalFields[OracleConnectionOptions.AdditionalFieldKey.serviceName] = oracleServiceName - conn.additionalFields[OracleConnectionOptions.AdditionalFieldKey.sid] = oracleSID - conn.additionalFields[OracleConnectionOptions.AdditionalFieldKey.role] = oracleRole.rawValue - conn.additionalFields[OracleConnectionOptions.AdditionalFieldKey.networkEncryption] = - oracleNetworkEncryption.rawValue - } - conn.safeModeLevel = safeModeLevel - conn.isReadOnly = safeModeLevel.blocksWrites - if sshEnabled { - conn.sshConfiguration = SSHConfiguration( - host: sshHost, - port: Int(sshPort) ?? 22, - username: sshUsername, - authMethod: sshAuthMethod, - privateKeyPath: sshKeyPath.isEmpty ? nil : sshKeyPath, - privateKeyData: sshKeyContent.isEmpty ? nil : sshKeyContent - ) + func persistPrivateKey(secureStore: any SecureStore) throws { + let key = pastedPrivateKey + guard key != storedSecrets.privateKey else { return } + let account = ConnectionSecretKind.sshPrivateKey.account(for: connectionId) + if let key { + try secureStore.store(key, forKey: account) + } else { + try secureStore.delete(forKey: account) } - return conn + storedSecrets.privateKey = key } - private func sslConfigurationPreservingCertificates(mode: SSLConfiguration.SSLMode) -> SSLConfiguration { - let existing = existingConnection?.sslConfiguration - return SSLConfiguration( - mode: mode, - caCertificatePath: existing?.caCertificatePath, - clientCertificatePath: existing?.clientCertificatePath, - clientKeyPath: existing?.clientKeyPath - ) + func dismissCredentialError() { + credentialError = nil } } diff --git a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift index 608635c4bb..6ab16aabf5 100644 --- a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift @@ -64,6 +64,13 @@ final class DataBrowserViewModel { var activeFilterCount: Int { filters.filter { $0.isEnabled && $0.isValid }.count } var hasPrimaryKeys: Bool { columnDetails.contains(where: \.isPrimaryKey) } + var showsPaginationBar: Bool { + !legacyRows.isEmpty || hasActiveSearch || hasActiveFilters || isPageLoading + } + + var canGoToPreviousPage: Bool { pagination.currentPage > 0 && !isLoading } + var canGoToNextPage: Bool { pagination.hasNextPage && !isLoading } + var paginationLabel: String { guard !legacyRows.isEmpty else { return "" } let start = pagination.currentOffset + 1 diff --git a/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift b/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift index 5eb249c732..d343db18d5 100644 --- a/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift @@ -15,7 +15,7 @@ final class RowDetailViewModel { let session: ConnectionSession? let databaseType: DatabaseType let schema: String? - let safeModeLevel: SafeModeLevel + @ObservationIgnored private let readSafeModeLevel: () -> SafeModeLevel private(set) var rows: [Row] var currentIndex: Int @@ -28,7 +28,7 @@ final class RowDetailViewModel { var operationError: AppError? private(set) var showSaveSuccess = false - @ObservationIgnored private var pendingSaveSQL: String? + @ObservationIgnored private var writeGate = ConfirmedWriteGate() @ObservationIgnored let onSaved: (() -> Void)? @ObservationIgnored let loadFullValueProvider: ((CellRef) async throws -> String?)? @@ -43,7 +43,7 @@ final class RowDetailViewModel { columnDetails: [ColumnInfo] = [], databaseType: DatabaseType = .sqlite, schema: String? = nil, - safeModeLevel: SafeModeLevel = .off, + safeModeLevel: @escaping () -> SafeModeLevel = { .off }, foreignKeys: [ForeignKeyInfo] = [], onSaved: (() -> Void)? = nil, loadFullValue: ((CellRef) async throws -> String?)? = nil @@ -56,7 +56,7 @@ final class RowDetailViewModel { self.columnDetails = columnDetails self.databaseType = databaseType self.schema = schema - self.safeModeLevel = safeModeLevel + self.readSafeModeLevel = safeModeLevel self.foreignKeys = foreignKeys self.onSaved = onSaved self.loadFullValueProvider = loadFullValue @@ -68,6 +68,8 @@ final class RowDetailViewModel { // MARK: - Computed + var safeModeLevel: SafeModeLevel { readSafeModeLevel() } + /// Asked of the kind rather than compared against the two view cases, so a MariaDB sequence, /// which refuses UPDATE and DELETE with ERROR 1031, is read-only here as it is on Mac. var allowsRowEditing: Bool { @@ -115,6 +117,22 @@ final class RowDetailViewModel { return columnDetail(for: column.name)?.isNullable ?? column.isNullable } + // MARK: - Row Navigation + + var showsRowNavigator: Bool { !isEditing } + var canGoToPreviousRow: Bool { !isEditing && currentIndex > 0 } + var canGoToNextRow: Bool { !isEditing && currentIndex < rows.count - 1 } + + func goToPreviousRow() { + guard canGoToPreviousRow else { return } + currentIndex -= 1 + } + + func goToNextRow() { + guard canGoToNextRow else { return } + currentIndex += 1 + } + // MARK: - Edit Lifecycle func startEditing() { @@ -143,13 +161,30 @@ final class RowDetailViewModel { } } + var hasUnsavedEdits: Bool { + isEditing && !editedChanges.isEmpty + } + + private var editedChanges: [(column: String, value: String?)] { + let original = currentRow + var changes: [(column: String, value: String?)] = [] + for (index, column) in columns.enumerated() { + guard !isPrimaryKey(at: index), index < editedValues.count else { continue } + let oldValue = index < original.count ? original[index] : nil + let newValue = editedValues[index] + guard oldValue != newValue else { continue } + changes.append((column: column.name, value: newValue)) + } + return changes + } + // MARK: - Save func saveChanges() async -> Bool { guard let session, let table else { return false } pendingWriteConfirmation = false - pendingSaveSQL = nil + writeGate.cancel() let pkValues: [(column: String, value: String)] = columnDetails.compactMap { col in guard col.isPrimaryKey else { return nil } @@ -169,16 +204,7 @@ final class RowDetailViewModel { return false } - var changes: [(column: String, value: String?)] = [] - for (index, column) in columns.enumerated() { - if isPrimaryKey(at: index) { continue } - guard index < editedValues.count else { continue } - let oldValue = index < currentRow.count ? currentRow[index] : nil - let newValue = editedValues[index] - if oldValue != newValue { - changes.append((column: column.name, value: newValue)) - } - } + let changes = editedChanges guard !changes.isEmpty else { isEditing = false @@ -195,23 +221,22 @@ final class RowDetailViewModel { primaryKeys: pkValues ) - switch safeModeLevel.writePermission { + switch writeGate.submit(sql, under: safeModeLevel) { case .blocked: return false - case .requiresConfirmation: - pendingSaveSQL = sql + case .awaitConfirmation: pendingWriteConfirmation = true return false - case .proceed: - return await execute(sql: sql, session: session) + case .run(let statement): + return await execute(sql: statement, session: session) } } func executePendingSave() async -> Bool { pendingWriteConfirmation = false - guard let session, let sql = pendingSaveSQL else { return false } - pendingSaveSQL = nil - return await execute(sql: sql, session: session) + let confirmed = writeGate.confirm(under: safeModeLevel) + guard let session, let confirmed else { return false } + return await execute(sql: confirmed, session: session) } private func execute(sql: String, session: ConnectionSession) async -> Bool { diff --git a/TableProMobile/TableProMobile/Views/Components/BottomSafeAreaBar.swift b/TableProMobile/TableProMobile/Views/Components/BottomSafeAreaBar.swift new file mode 100644 index 0000000000..719b06f88d --- /dev/null +++ b/TableProMobile/TableProMobile/Views/Components/BottomSafeAreaBar.swift @@ -0,0 +1,22 @@ +import SwiftUI + +extension View { + func bottomSafeAreaBar(spacing: CGFloat? = 0, @ViewBuilder _ bar: () -> Bar) -> some View { + modifier(BottomSafeAreaBar(spacing: spacing, bar: bar())) + } +} + +private struct BottomSafeAreaBar: ViewModifier { + let spacing: CGFloat? + let bar: Bar + + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content.safeAreaBar(edge: .bottom, spacing: spacing) { bar } + } else { + content.safeAreaInset(edge: .bottom, spacing: spacing) { + bar.background(.bar) + } + } + } +} diff --git a/TableProMobile/TableProMobile/Views/Components/GroupFormSheet.swift b/TableProMobile/TableProMobile/Views/Components/GroupFormSheet.swift index cae3d77a54..d5c081feb8 100644 --- a/TableProMobile/TableProMobile/Views/Components/GroupFormSheet.swift +++ b/TableProMobile/TableProMobile/Views/Components/GroupFormSheet.swift @@ -9,21 +9,25 @@ struct GroupFormSheet: View { @State private var name: String @State private var color: ConnectionColor @State private var parentId: UUID? + @State private var failure: LibraryWriteFailure? private let existingGroup: ConnectionGroup? - var onSave: (ConnectionGroup) -> Void + private let opening: GroupFormEdits - init( - editing group: ConnectionGroup? = nil, - parentId: UUID? = nil, - onSave: @escaping (ConnectionGroup) -> Void - ) { + init(editing group: ConnectionGroup? = nil, parentId: UUID? = nil) { + let opening = GroupFormEdits(opening: group, parentId: parentId) self.existingGroup = group - self.onSave = onSave - _name = State(initialValue: group?.name ?? "") - _color = State(initialValue: group?.color ?? .none) - _parentId = State(initialValue: group?.parentId ?? parentId) + self.opening = opening + _name = State(initialValue: opening.name) + _color = State(initialValue: opening.color) + _parentId = State(initialValue: opening.parentId) } + private var edits: GroupFormEdits { + GroupFormEdits(name: name, color: color, parentId: parentId) + } + + private var hasChanges: Bool { edits != opening } + private var placementGroupId: UUID { existingGroup?.id ?? UUID() } @@ -56,24 +60,26 @@ struct GroupFormSheet: View { ConnectionColorPicker(selection: $color) } } + .interactiveDismissDisabled(hasChanges) .navigationTitle(existingGroup != nil ? String(localized: "Edit Group") : String(localized: "New Group")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - CancelButton { dismiss() } + DiscardChangesCancelButton(hasChanges: hasChanges) { dismiss() } } ToolbarItem(placement: .confirmationAction) { - ConfirmButton(title: "Save") { - var group = existingGroup ?? ConnectionGroup() - group.name = name.trimmingCharacters(in: .whitespaces) - group.color = color - group.parentId = parentId - onSave(group) - dismiss() - } - .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + ConfirmButton(title: "Save", action: save) + .disabled(edits.name.isEmpty) } } + .libraryWriteFailureAlert(failure, onDismiss: { failure = nil }, closeForm: { dismiss() }) } } + + private func save() { + let outcome = edits.save(editing: existingGroup, in: appState) + failure = LibraryWriteFailure(outcome, kind: .group) + guard failure == nil else { return } + dismiss() + } } diff --git a/TableProMobile/TableProMobile/Views/Components/LibraryWriteFailureAlert.swift b/TableProMobile/TableProMobile/Views/Components/LibraryWriteFailureAlert.swift new file mode 100644 index 0000000000..ba85d9156e --- /dev/null +++ b/TableProMobile/TableProMobile/Views/Components/LibraryWriteFailureAlert.swift @@ -0,0 +1,35 @@ +import SwiftUI + +struct LibraryWriteFailureAlert: ViewModifier { + let failure: LibraryWriteFailure? + let onDismiss: () -> Void + let closeForm: () -> Void + + private var isPresented: Binding { + Binding( + get: { failure != nil }, + set: { if !$0 { onDismiss() } } + ) + } + + func body(content: Content) -> some View { + content.alert(failure?.title ?? "", isPresented: isPresented, presenting: failure) { presented in + Button("OK", role: .cancel) { + guard presented.closesForm else { return } + closeForm() + } + } message: { presented in + Text(presented.message) + } + } +} + +extension View { + func libraryWriteFailureAlert( + _ failure: LibraryWriteFailure?, + onDismiss: @escaping () -> Void, + closeForm: @escaping () -> Void + ) -> some View { + modifier(LibraryWriteFailureAlert(failure: failure, onDismiss: onDismiss, closeForm: closeForm)) + } +} diff --git a/TableProMobile/TableProMobile/Views/Components/PagingBar.swift b/TableProMobile/TableProMobile/Views/Components/PagingBar.swift new file mode 100644 index 0000000000..8782f1c26c --- /dev/null +++ b/TableProMobile/TableProMobile/Views/Components/PagingBar.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct PagingBar: View { + let previousTitle: LocalizedStringResource + let nextTitle: LocalizedStringResource + let canGoPrevious: Bool + let canGoNext: Bool + let onPrevious: () -> Void + let onNext: () -> Void + @ViewBuilder let status: Status + + var body: some View { + HStack { + step(previousTitle, systemImage: "chevron.backward", isEnabled: canGoPrevious, action: onPrevious) + Spacer() + status + Spacer() + step(nextTitle, systemImage: "chevron.forward", isEnabled: canGoNext, action: onNext) + } + .padding(.horizontal) + } + + private func step( + _ title: LocalizedStringResource, + systemImage: String, + isEnabled: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Label { + Text(title) + } icon: { + Image(systemName: systemImage) + } + .labelStyle(.iconOnly) + .imageScale(.large) + .frame(minWidth: 44, minHeight: 44) + .contentShape(.rect) + } + .disabled(!isEnabled) + } +} diff --git a/TableProMobile/TableProMobile/Views/Components/SemanticButtons.swift b/TableProMobile/TableProMobile/Views/Components/SemanticButtons.swift index ae6735f440..b06313cdc3 100644 --- a/TableProMobile/TableProMobile/Views/Components/SemanticButtons.swift +++ b/TableProMobile/TableProMobile/Views/Components/SemanticButtons.swift @@ -24,6 +24,54 @@ struct CancelButton: View { } } +struct DiscardChangesCancelButton: View { + let hasChanges: Bool + let discard: () -> Void + + @State private var isConfirmingDiscard = false + + var body: some View { + CancelButton { + guard hasChanges else { + discard() + return + } + isConfirmingDiscard = true + } + .discardChangesDialog(isPresented: $isConfirmingDiscard, discard: discard) + } +} + +struct DiscardChangesButton: View { + let hasChanges: Bool + let discard: () -> Void + @ViewBuilder let label: () -> Label + + @State private var isConfirmingDiscard = false + + var body: some View { + Button { + guard hasChanges else { + discard() + return + } + isConfirmingDiscard = true + } label: { + label() + } + .discardChangesDialog(isPresented: $isConfirmingDiscard, discard: discard) + } +} + +extension View { + func discardChangesDialog(isPresented: Binding, discard: @escaping () -> Void) -> some View { + confirmationDialog("Discard Changes?", isPresented: isPresented, titleVisibility: .hidden) { + Button("Discard Changes", role: .destructive, action: discard) + Button("Keep Editing", role: .cancel) {} + } + } +} + struct ConfirmButton: View { let title: LocalizedStringKey var isInProgress = false diff --git a/TableProMobile/TableProMobile/Views/Components/TagFormSheet.swift b/TableProMobile/TableProMobile/Views/Components/TagFormSheet.swift index 202ae722a2..0d451174c8 100644 --- a/TableProMobile/TableProMobile/Views/Components/TagFormSheet.swift +++ b/TableProMobile/TableProMobile/Views/Components/TagFormSheet.swift @@ -3,19 +3,28 @@ import TableProModels struct TagFormSheet: View { @Environment(\.dismiss) private var dismiss + @Environment(AppState.self) private var appState @State private var name: String @State private var color: ConnectionColor + @State private var failure: LibraryWriteFailure? private let existingTag: ConnectionTag? - var onSave: (ConnectionTag) -> Void + private let opening: TagFormEdits - init(editing tag: ConnectionTag? = nil, onSave: @escaping (ConnectionTag) -> Void) { + init(editing tag: ConnectionTag? = nil) { + let opening = TagFormEdits(opening: tag) self.existingTag = tag - self.onSave = onSave - _name = State(initialValue: tag?.name ?? "") - _color = State(initialValue: tag?.color ?? .gray) + self.opening = opening + _name = State(initialValue: opening.name) + _color = State(initialValue: opening.color) } + private var edits: TagFormEdits { + TagFormEdits(name: name, color: color) + } + + private var hasChanges: Bool { edits != opening } + var body: some View { NavigationStack { Form { @@ -28,23 +37,26 @@ struct TagFormSheet: View { ConnectionColorPicker(selection: $color) } } + .interactiveDismissDisabled(hasChanges) .navigationTitle(existingTag != nil ? String(localized: "Edit Tag") : String(localized: "New Tag")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - CancelButton { dismiss() } + DiscardChangesCancelButton(hasChanges: hasChanges) { dismiss() } } ToolbarItem(placement: .confirmationAction) { - ConfirmButton(title: "Save") { - var tag = existingTag ?? ConnectionTag() - tag.name = name - tag.color = color - onSave(tag) - dismiss() - } - .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + ConfirmButton(title: "Save", action: save) + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) } } + .libraryWriteFailureAlert(failure, onDismiss: { failure = nil }, closeForm: { dismiss() }) } } + + private func save() { + let outcome = edits.save(editing: existingTag, in: appState) + failure = LibraryWriteFailure(outcome, kind: .tag) + guard failure == nil else { return } + dismiss() + } } diff --git a/TableProMobile/TableProMobile/Views/ConnectedScreen.swift b/TableProMobile/TableProMobile/Views/ConnectedScreen.swift new file mode 100644 index 0000000000..9c8783dfb3 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/ConnectedScreen.swift @@ -0,0 +1,19 @@ +import Foundation + +enum ConnectedScreen { + case connecting + case failed(AppError) + case tabs + + static func resolve(phase: ConnectionCoordinator.ConnectionPhase, isHeldByEditor: Bool) -> ConnectedScreen { + guard !isHeldByEditor else { return .tabs } + switch phase { + case .connecting: + return .connecting + case .error(let error): + return .failed(error) + case .connected: + return .tabs + } + } +} diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index b778038bd1..1e8759f09a 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -19,37 +19,47 @@ struct ConnectedView: View { connection.name.isEmpty ? connection.host : connection.name } + private var liveRecord: DatabaseConnection? { + appState.connections.first { $0.id == connection.id } + } + + private var isRemoved: Bool { + appState.isConnectionRemoved(connection.id) + } + + private var connectionEditorPresented: Binding { + Binding( + get: { presenter.isEditingConnection(connection.id) }, + set: { if !$0 { presenter.dismissConnectionEditor() } } + ) + } + var body: some View { Group { if let coordinator { - switch coordinator.phase { - case .connecting: - statusScreen { connectingView } - case .error(let error): - statusScreen { - ErrorView(error: error) { - await coordinator.connect() - } - } - case .connected: - connectedContent(coordinator) - } + screen(for: coordinator) } else { statusScreen { connectingView } } } - .onChange(of: appState.connections) { _, newConnections in - if !newConnections.contains(where: { $0.id == connection.id }) { - showDeletedAlert = true - } + .onChange(of: isRemoved, initial: true) { _, removed in + guard removed else { return } + presenter.dismissConnectionEditor() + showDeletedAlert = true } .alert(String(localized: "Connection Deleted"), isPresented: $showDeletedAlert) { Button("OK", role: .cancel) { dismiss() } } message: { Text("This connection no longer exists. It may have been removed from another device.") } - .task(id: coordinatorStore.revision) { - let resolved = coordinatorStore.coordinator(for: connection, appState: appState) + .sheet(isPresented: connectionEditorPresented) { + ConnectionFormView(editing: liveRecord ?? connection) { _ in + presenter.dismissConnectionEditor() + } + } + .task(id: coordinatorStore.generation(for: connection.id)) { + guard let record = liveRecord else { return } + let resolved = coordinatorStore.coordinator(for: record, appState: appState) coordinator = resolved if let table = presenter.takeTable(for: connection.id) { resolved.pendingTableName = table @@ -81,6 +91,22 @@ struct ConnectedView: View { .sensoryFeedback(.error, trigger: hapticError) } + @ViewBuilder + private func screen(for coordinator: ConnectionCoordinator) -> some View { + switch ConnectedScreen.resolve(phase: coordinator.phase, isHeldByEditor: presenter.isHeldByEditor) { + case .connecting: + statusScreen { connectingView } + case .failed(let error): + statusScreen { + ErrorView(error: error) { + await coordinator.connect() + } + } + case .tabs: + connectedContent(coordinator) + } + } + // MARK: - Chrome private func statusScreen(@ViewBuilder _ content: () -> some View) -> some View { @@ -95,7 +121,7 @@ struct ConnectedView: View { @ToolbarContentBuilder private var closeToolbar: some ToolbarContent { ToolbarItem(placement: .topBarLeading) { - Button { + DiscardChangesButton(hasChanges: presenter.isHeldByEditor) { dismiss() } label: { Label("Connections", systemImage: "chevron.backward") @@ -209,7 +235,7 @@ struct ConnectedView: View { } message: { Text(coordinator.failureAlertMessage ?? "") } - .userActivity(SceneIntent.viewConnectionActivity, isActive: !connection.isSample) { activity in + .userActivity(SceneIntent.viewConnectionActivity, isActive: appState.offersHandoff(for: connection)) { activity in activity.title = connection.name.isEmpty ? connection.host : connection.name activity.isEligibleForHandoff = true activity.userInfo = ["connectionId": connection.id.uuidString] @@ -235,7 +261,7 @@ struct ConnectedView: View { if coordinator.selectedTab == .info, !connection.isSample { ToolbarItem(placement: .topBarTrailing) { Button { - coordinator.showingEditSheet = true + presenter.presentConnectionEditor(for: connection.id) } label: { Image(systemName: "pencil") .accessibilityLabel(Text("Edit Connection")) diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index b53a5566bb..0d5543c78e 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -8,6 +8,7 @@ import UniformTypeIdentifiers struct ConnectionFormView: View { @Environment(\.dismiss) private var dismiss @Environment(AppState.self) private var appState + @Environment(ConnectionCoordinatorStore.self) private var coordinatorStore @State private var viewModel: ConnectionFormViewModel @State private var activeFilePicker: ActiveFilePicker? @@ -18,7 +19,7 @@ struct ConnectionFormView: View { @State private var pasteTarget: CertificateRole? @State private var showPKCS12Password = false - var onSave: (DatabaseConnection) -> Void + var onSaved: (UUID) -> Void enum ActiveFilePicker: Identifiable, Hashable { case sqliteDatabase @@ -29,9 +30,9 @@ struct ConnectionFormView: View { var id: Int { hashValue } } - init(editing connection: DatabaseConnection? = nil, onSave: @escaping (DatabaseConnection) -> Void) { + init(editing connection: DatabaseConnection? = nil, onSaved: @escaping (UUID) -> Void) { _viewModel = State(wrappedValue: ConnectionFormViewModel(editing: connection)) - self.onSave = onSave + self.onSaved = onSaved } private var showFilePicker: Binding { @@ -60,6 +61,20 @@ struct ConnectionFormView: View { ) } + private var showFileError: Binding { + Binding( + get: { viewModel.fileError != nil }, + set: { if !$0 { viewModel.dismissFileError() } } + ) + } + + private var showSSHKeyFileError: Binding { + Binding( + get: { viewModel.sshKeyFileError != nil }, + set: { if !$0 { viewModel.dismissSSHKeyFileError() } } + ) + } + var body: some View { @Bindable var viewModel = viewModel return NavigationStack { @@ -112,6 +127,8 @@ struct ConnectionFormView: View { testSection } .scrollDismissesKeyboard(.interactively) + .interactiveDismissDisabled(viewModel.hasChanges) + .holdsScene(withUnsavedChanges: viewModel.hasChanges) .task { viewModel.loadCertificateSummaries() await viewModel.loadStoredCredentials(secureStore: appState.secureStore) @@ -120,11 +137,11 @@ struct ConnectionFormView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - CancelButton { dismiss() } + DiscardChangesCancelButton(hasChanges: viewModel.hasChanges) { dismiss() } } ToolbarItem(placement: .confirmationAction) { ConfirmButton(title: "Save", action: handleSave) - .disabled(!viewModel.canSave) + .disabled(!viewModel.canSave || viewModel.isSaving) } } .fileImporter( @@ -172,6 +189,21 @@ struct ConnectionFormView: View { } message: { Text(viewModel.credentialError ?? String(localized: "Failed to save credentials.")) } + .alert("Database File", isPresented: showFileError) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.fileError ?? "") + } + .alert("Private Key", isPresented: showSSHKeyFileError) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.sshKeyFileError ?? "") + } + .libraryWriteFailureAlert( + viewModel.saveFailure, + onDismiss: viewModel.dismissSaveFailure, + closeForm: { dismiss() } + ) .sensoryFeedback(.success, trigger: hapticSuccess) .sensoryFeedback(.error, trigger: hapticError) } @@ -517,15 +549,23 @@ struct ConnectionFormView: View { // MARK: - Actions private func handleTest() async { - await viewModel.testConnection(appState: appState, secureStore: appState.secureStore) + await viewModel.testConnection() if let result = viewModel.testResult { if result.success { hapticSuccess.toggle() } else { hapticError.toggle() } } } private func handleSave() { - guard let connection = viewModel.save(appState: appState, secureStore: appState.secureStore) else { return } - onSave(connection) + let reconnects = viewModel.reconnectsAfterSave + Task { + guard let savedId = await viewModel.save(appState: appState, secureStore: appState.secureStore) else { + return + } + if reconnects { + coordinatorStore.invalidate(savedId) + } + onSaved(savedId) + } } // MARK: - Helpers diff --git a/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift b/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift index 22fb0c7dbb..1c0af322ff 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift @@ -5,7 +5,6 @@ import TableProModels struct ConnectionInfoView: View { @Environment(ConnectionCoordinator.self) private var coordinator @Environment(AppState.self) private var appState - @Environment(ConnectionCoordinatorStore.self) private var coordinatorStore private var connection: DatabaseConnection { coordinator.connection } @@ -47,16 +46,6 @@ struct ConnectionInfoView: View { statsSection } - .sheet(isPresented: Binding( - get: { coordinator.showingEditSheet }, - set: { coordinator.showingEditSheet = $0 } - )) { - ConnectionFormView(editing: connection) { updated in - appState.updateConnection(updated) - coordinatorStore.invalidate(updated.id) - coordinator.showingEditSheet = false - } - } } @ViewBuilder @@ -120,13 +109,18 @@ struct ConnectionInfoView: View { } } + private var databaseFileURL: URL? { + guard !connection.isSample else { return nil } + return appState.localDatabaseFiles.location(forStoredPath: connection.database).fileURL + } + @ViewBuilder private var sqliteFileSection: some View { Section("File") { - let url = URL(fileURLWithPath: connection.database) - LabeledContent("Name", value: url.lastPathComponent) + let fileURL = databaseFileURL + LabeledContent("Name", value: fileURL?.lastPathComponent ?? connection.database) LabeledContent("Path") { - Text(connection.database) + Text(fileURL?.path ?? connection.database) .font(.caption) .foregroundStyle(.secondary) .textSelection(.enabled) diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 86345898be..9a6b4bf501 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -43,7 +43,7 @@ struct ConnectionListView: View { Binding( get: { guard !presenter.holdsConnectionRestore, let id = selectedConnectionUUID else { return nil } - return appState.connections.first { $0.id == id } + return coordinatorStore.presentedRecord(for: id, in: appState.connections) }, set: { selectedConnectionIdString = $0?.id.uuidString } ) @@ -153,11 +153,12 @@ struct ConnectionListView: View { iCloudAccountAvailable = await appState.syncCoordinator.accountStatus() == .available } .task { + clearUnknownSelection() presenter.beginLaunch(with: appState) deliverPendingIntent() } } - .fullScreenCover(item: openConnection, onDismiss: presentImportAfterCoverDismissal) { connection in + .fullScreenCover(item: openConnection, onDismiss: connectionCoverDidDismiss) { connection in ConnectedView(connection: connection) .id(connection.id) } @@ -178,12 +179,19 @@ struct ConnectionListView: View { .onChange(of: presenter.holdsConnectionRestore) { _, _ in deliverPendingIntent() } + .onChange(of: presenter.isHeldByEditor) { _, _ in + deliverPendingIntent() + } .onChange(of: lockState.isLocked) { _, _ in deliverPendingIntent() } .onChange(of: appState.loadStatus) { _, _ in + clearUnknownSelection() deliverPendingIntent() } + .onChange(of: appState.connections) { _, _ in + clearUnknownSelection() + } .alert(importResultMessage, isPresented: importResultPresented) { Button(String(localized: "OK")) { importResultCount = nil } } @@ -653,26 +661,19 @@ struct ConnectionListView: View { case .whatsNew(let version): WhatsNewSheet(version: version) case .addConnection: - ConnectionFormView { connection in - appState.addConnection(connection) + ConnectionFormView { _ in presenter.sheet = nil } case .editConnection(let connection): - ConnectionFormView(editing: connection) { updated in - appState.updateConnection(updated) - coordinatorStore.invalidate(updated.id) + ConnectionFormView(editing: connection) { _ in presenter.sheet = nil } case .moveConnections(let ids): MoveToGroupSheet(connectionIds: ids) case .newGroup(let parentId): - GroupFormSheet(parentId: parentId) { group in - appState.addGroup(group) - } + GroupFormSheet(parentId: parentId) case .editGroup(let group): - GroupFormSheet(editing: group) { updated in - appState.updateGroup(updated) - } + GroupFormSheet(editing: group) case .tags: TagManagementView() case .settings: @@ -845,6 +846,19 @@ struct ConnectionListView: View { } } + private func clearUnknownSelection() { + guard appState.loadStatus == .ready, + let id = selectedConnectionUUID, + coordinatorStore.presentedRecord(for: id, in: appState.connections) == nil else { return } + selectedConnectionIdString = nil + } + + private func connectionCoverDidDismiss() { + presenter.dismissConnectionEditor() + coordinatorStore.discardRemovedRecords() + presentImportAfterCoverDismissal() + } + private func presentImportAfterCoverDismissal() { guard let url = importAfterCoverDismissal else { return } importAfterCoverDismissal = nil diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 26e6351ed6..f2d4fb0ec1 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -4,6 +4,7 @@ import TableProModels import TableProQuery struct DataBrowserView: View { + @Environment(AppState.self) private var appState @Environment(ConnectionCoordinator.self) private var coordinator let table: TableInfo @@ -76,7 +77,7 @@ struct DataBrowserView: View { var body: some View { @Bindable var viewModel = viewModel return searchableContent - .userActivity(SceneIntent.viewTableActivity, isActive: !connection.isSample) { activity in + .userActivity(SceneIntent.viewTableActivity, isActive: appState.offersHandoff(for: connection)) { activity in activity.title = table.name activity.isEligibleForHandoff = true activity.userInfo = [ @@ -85,8 +86,11 @@ struct DataBrowserView: View { ] } .toolbar { topToolbar } - .toolbar(rows.isEmpty && !viewModel.hasActiveSearch && !viewModel.hasActiveFilters && !viewModel.isPageLoading ? .hidden : .visible, for: .bottomBar) - .toolbar { paginationToolbar } + .bottomSafeAreaBar { + if showsPaginationBar { + paginationBar + } + } .task { viewModel.attach( session: session, table: table, databaseType: connection.type, @@ -274,7 +278,7 @@ struct DataBrowserView: View { columnDetails: viewModel.columnDetails, databaseType: connection.type, schema: viewModel.schema, - safeModeLevel: connection.safeModeLevel, + safeModeLevel: { [coordinator] in coordinator.connection.safeModeLevel }, foreignKeys: viewModel.foreignKeys, onSaved: { Task { await viewModel.load() } }, loadFullValue: { ref in @@ -423,53 +427,53 @@ struct DataBrowserView: View { } } - @ToolbarContentBuilder - private var paginationToolbar: some ToolbarContent { - ToolbarItemGroup(placement: .bottomBar) { - Button { Task { await viewModel.goToPreviousPage() } } label: { - Image(systemName: "chevron.left") - } - .disabled(viewModel.pagination.currentPage == 0 || viewModel.isLoading) + private var showsPaginationBar: Bool { + viewModel.showsPaginationBar && !searchFocused + } - Spacer() + private var paginationBar: some View { + PagingBar( + previousTitle: "Previous Page", + nextTitle: "Next Page", + canGoPrevious: viewModel.canGoToPreviousPage, + canGoNext: viewModel.canGoToNextPage, + onPrevious: { Task { await viewModel.goToPreviousPage() } }, + onNext: { Task { await viewModel.goToNextPage() } } + ) { + pageMenu + } + } - Menu { - Section("Rows per Page") { - ForEach([50, 100, 200, 500], id: \.self) { size in - Button { - Task { await viewModel.changePageSize(size) } - } label: { - HStack { - Text("\(size) rows") - if viewModel.pagination.pageSize == size { - Image(systemName: "checkmark") - } - } - } - } - } - Section { + private var pageMenu: some View { + Menu { + Section("Rows per Page") { + ForEach([50, 100, 200, 500], id: \.self) { size in Button { - goToPageInput = "" - showGoToPage = true + Task { await viewModel.changePageSize(size) } } label: { - Label("Go to Page...", systemImage: "arrow.right.to.line") + HStack { + Text("\(size) rows") + if viewModel.pagination.pageSize == size { + Image(systemName: "checkmark") + } + } } } - } label: { - Text(viewModel.paginationLabel) - .font(.footnote) - .monospacedDigit() - .foregroundStyle(.secondary) - .fixedSize() } - - Spacer() - - Button { Task { await viewModel.goToNextPage() } } label: { - Image(systemName: "chevron.right") + Section { + Button { + goToPageInput = "" + showGoToPage = true + } label: { + Label("Go to Page...", systemImage: "arrow.right.to.line") + } } - .disabled(!viewModel.pagination.hasNextPage || viewModel.isLoading) + } label: { + Text(viewModel.paginationLabel) + .font(.footnote) + .monospacedDigit() + .foregroundStyle(.secondary) + .fixedSize() } } @@ -480,7 +484,7 @@ struct DataBrowserView: View { session: session, databaseType: connection.type, schema: viewModel.schema, - safeModeLevel: connection.safeModeLevel, + safeModeLevel: { [coordinator] in coordinator.connection.safeModeLevel }, onInserted: { Task { await viewModel.load() } } ) } diff --git a/TableProMobile/TableProMobile/Views/InsertRowView.swift b/TableProMobile/TableProMobile/Views/InsertRowView.swift index 6e75b5b74b..2071a88d59 100644 --- a/TableProMobile/TableProMobile/Views/InsertRowView.swift +++ b/TableProMobile/TableProMobile/Views/InsertRowView.swift @@ -8,7 +8,7 @@ struct InsertRowView: View { let session: ConnectionSession? let databaseType: DatabaseType let schema: String? - let safeModeLevel: SafeModeLevel + let safeModeLevel: () -> SafeModeLevel var onInserted: (() -> Void)? @Environment(\.dismiss) private var dismiss @@ -21,11 +21,12 @@ struct InsertRowView: View { @State private var operationError: AppError? @State private var showOperationError = false @State private var showInsertConfirmation = false - @State private var pendingInsertSQL: String? + @State private var writeGate = ConfirmedWriteGate() @State private var hapticSuccess = false @State private var hapticError = false private var columnNames: [String] { columnDetails.map(\.name) } + private var hasChanges: Bool { !fields.isEmpty } private var canSave: Bool { guard let driver = session?.driver else { return false } @@ -54,12 +55,14 @@ struct InsertRowView: View { } } .scrollDismissesKeyboard(.interactively) + .interactiveDismissDisabled(isSaving || hasChanges) + .holdsScene(withUnsavedChanges: isSaving || hasChanges) .formStyle(.grouped) .navigationTitle("Insert Row") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - CancelButton { dismiss() } + DiscardChangesCancelButton(hasChanges: hasChanges) { dismiss() } .disabled(isSaving) } ToolbarItem(placement: .confirmationAction) { @@ -88,7 +91,7 @@ struct InsertRowView: View { Button(String(localized: "Insert"), role: .destructive) { Task { await executePendingInsert() } } - Button(String(localized: "Cancel"), role: .cancel) {} + Button(String(localized: "Cancel"), role: .cancel) { writeGate.cancel() } } message: { Text(String(format: String(localized: "This will insert a row into %@. Continue?"), table.name)) } @@ -250,21 +253,20 @@ struct InsertRowView: View { guard let sql = buildInsertSQL(driver: session.driver) else { return } - switch safeModeLevel.writePermission { + switch writeGate.submit(sql, under: safeModeLevel()) { case .blocked: return - case .requiresConfirmation: - pendingInsertSQL = sql + case .awaitConfirmation: showInsertConfirmation = true - case .proceed: - await executeInsert(sql: sql, session: session) + case .run(let statement): + await executeInsert(sql: statement, session: session) } } private func executePendingInsert() async { - guard let session, let sql = pendingInsertSQL else { return } - pendingInsertSQL = nil - await executeInsert(sql: sql, session: session) + let confirmed = writeGate.confirm(under: safeModeLevel()) + guard let session, let confirmed else { return } + await executeInsert(sql: confirmed, session: session) } private func buildInsertSQL(driver: any DatabaseDriver) -> String? { diff --git a/TableProMobile/TableProMobile/Views/MobileConnectionExportSheet.swift b/TableProMobile/TableProMobile/Views/MobileConnectionExportSheet.swift index 3e13b5f741..781090e9e1 100644 --- a/TableProMobile/TableProMobile/Views/MobileConnectionExportSheet.swift +++ b/TableProMobile/TableProMobile/Views/MobileConnectionExportSheet.swift @@ -18,6 +18,7 @@ struct MobileConnectionExportSheet: View { @State private var error: String? @State private var shareItem: IdentifiableURL? @State private var exportedURL: URL? + @State private var isExporting = false private var canExport: Bool { guard includePasswords else { return true } @@ -57,6 +58,7 @@ struct MobileConnectionExportSheet: View { } } } + .disabled(isExporting) .navigationTitle(Text("Export Connections")) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -64,10 +66,19 @@ struct MobileConnectionExportSheet: View { Button(String(localized: "Cancel")) { dismiss() } } ToolbarItem(placement: .confirmationAction) { - Button(String(localized: "Export")) { export() } - .disabled(!canExport || connections.isEmpty) + if isExporting { + ProgressView() + } else { + Button(String(localized: "Export")) { isExporting = true } + .disabled(!canExport || connections.isEmpty) + } } } + .task(id: isExporting) { + guard isExporting else { return } + await export() + isExporting = false + } .sheet(item: $shareItem, onDismiss: { if let exportedURL { try? FileManager.default.removeItem(at: exportedURL) @@ -85,19 +96,23 @@ struct MobileConnectionExportSheet: View { : String(format: String(localized: "%d connections will be exported."), connections.count) } - private func export() { + private func export() async { + error = nil do { - let data = try IOSConnectionExportService.exportData( + let data = try await IOSConnectionExportService.exportData( connections: connections, appState: appState, includeCredentials: includePasswords, passphrase: includePasswords ? passphrase : nil ) + try Task.checkCancellation() let filename = IOSConnectionExportService.suggestedFilename(for: connections) let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename) try data.write(to: url, options: .atomic) exportedURL = url shareItem = IdentifiableURL(url: url) + } catch is CancellationError { + return } catch { self.error = error.localizedDescription } diff --git a/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift b/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift index 8816f2a09e..2e7690a4c7 100644 --- a/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift +++ b/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift @@ -17,6 +17,7 @@ struct MobileConnectionImportSheet: View { @State private var passphrase = "" @State private var passphraseError: String? @State private var wasEncryptedImport = false + @State private var isDecrypting = false private enum Phase: Equatable { case loading @@ -43,6 +44,11 @@ struct MobileConnectionImportSheet: View { } } .task { await loadFile() } + .task(id: isDecrypting) { + guard isDecrypting else { return } + await decrypt() + isDecrypting = false + } } @ViewBuilder @@ -68,7 +74,7 @@ struct MobileConnectionImportSheet: View { Section { SecureField(String(localized: "Passphrase"), text: $passphrase) .textContentType(.password) - .onSubmit { Task { await decrypt() } } + .onSubmit(requestDecryption) } header: { Text("This file is encrypted") } footer: { @@ -78,8 +84,14 @@ struct MobileConnectionImportSheet: View { Text("Enter the passphrase to decrypt and import connections.") } } - Button(String(localized: "Decrypt")) { Task { await decrypt() } } - .disabled(passphrase.isEmpty) + HStack { + Button(String(localized: "Decrypt"), action: requestDecryption) + .disabled(passphrase.isEmpty || isDecrypting) + if isDecrypting { + Spacer() + ProgressView() + } + } } } @@ -188,12 +200,21 @@ struct MobileConnectionImportSheet: View { } } + private func requestDecryption() { + guard !passphrase.isEmpty, !isDecrypting else { return } + passphraseError = nil + isDecrypting = true + } + private func decrypt() async { guard let data = encryptedData, !passphrase.isEmpty else { return } do { - let envelope = try ConnectionImportDecoder.decodeEncryptedData(data, passphrase: passphrase) + let envelope = try await ConnectionImportDecoder.decodeEncryptedData(data, passphrase: passphrase) + try Task.checkCancellation() wasEncryptedImport = true applyPreview(IOSConnectionImportService.analyze(envelope, appState: appState)) + } catch is CancellationError { + return } catch { passphraseError = error.localizedDescription passphrase = "" diff --git a/TableProMobile/TableProMobile/Views/RowDetailView.swift b/TableProMobile/TableProMobile/Views/RowDetailView.swift index 56af1bea76..4c8e8b0607 100644 --- a/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -21,7 +21,7 @@ struct RowDetailView: View { columnDetails: [ColumnInfo] = [], databaseType: DatabaseType = .sqlite, schema: String? = nil, - safeModeLevel: SafeModeLevel = .off, + safeModeLevel: @escaping () -> SafeModeLevel = { .off }, foreignKeys: [ForeignKeyInfo] = [], onSaved: (() -> Void)? = nil, loadFullValue: ((CellRef) async throws -> String?)? = nil @@ -71,6 +71,13 @@ struct RowDetailView: View { .transition(.move(edge: .bottom).combined(with: .opacity)) } } + .bottomSafeAreaBar { + if viewModel.showsRowNavigator { + rowNavigator + } + } + .navigationBarBackButtonHidden(viewModel.isEditing) + .holdsScene(withUnsavedChanges: viewModel.hasUnsavedEdits) .navigationTitle(viewModel.table?.name ?? String(format: String(localized: "Row %d of %d"), viewModel.currentIndex + 1, viewModel.rows.count)) .navigationBarTitleDisplayMode(.inline) .toolbar { rowDetailToolbar } @@ -138,35 +145,28 @@ struct RowDetailView: View { if viewModel.isEditing { ToolbarItem(placement: .cancellationAction) { - CancelButton { viewModel.cancelEditing() } - .disabled(viewModel.isSaving) + DiscardChangesCancelButton(hasChanges: viewModel.hasUnsavedEdits) { + viewModel.cancelEditing() + } + .disabled(viewModel.isSaving) } } + } - ToolbarItemGroup(placement: .bottomBar) { - Button { - viewModel.currentIndex -= 1 - } label: { - Image(systemName: "chevron.left") - } - .disabled(viewModel.currentIndex <= 0 || viewModel.isEditing) - - Spacer() - + private var rowNavigator: some View { + PagingBar( + previousTitle: "Previous Row", + nextTitle: "Next Row", + canGoPrevious: viewModel.canGoToPreviousRow, + canGoNext: viewModel.canGoToNextRow, + onPrevious: viewModel.goToPreviousRow, + onNext: viewModel.goToNextRow + ) { Text("\(viewModel.currentIndex + 1) of \(viewModel.rows.count)") .font(.footnote) .foregroundStyle(.secondary) .monospacedDigit() .fixedSize() - - Spacer() - - Button { - viewModel.currentIndex += 1 - } label: { - Image(systemName: "chevron.right") - } - .disabled(viewModel.currentIndex >= viewModel.rows.count - 1 || viewModel.isEditing) } } diff --git a/TableProMobile/TableProMobile/Views/SceneRootView.swift b/TableProMobile/TableProMobile/Views/SceneRootView.swift index b09b528e3d..729109acec 100644 --- a/TableProMobile/TableProMobile/Views/SceneRootView.swift +++ b/TableProMobile/TableProMobile/Views/SceneRootView.swift @@ -24,6 +24,9 @@ struct SceneRootView: View { .onChange(of: appState.connections) { previous, current in coordinatorStore.reconcile(from: previous, to: current) } + .onChange(of: presenter.isHeldByEditor, initial: true) { _, isHeld in + coordinatorStore.holdRebuilds(isHeld) + } .onChange(of: appState.sampleResetRevision) { _, _ in for sample in appState.connections where sample.isSample { coordinatorStore.invalidate(sample.id, droppingSession: false) diff --git a/TableProMobile/TableProMobile/Views/TagManagementView.swift b/TableProMobile/TableProMobile/Views/TagManagementView.swift index ac96679c99..25053ceb22 100644 --- a/TableProMobile/TableProMobile/Views/TagManagementView.swift +++ b/TableProMobile/TableProMobile/Views/TagManagementView.swift @@ -6,6 +6,7 @@ struct TagManagementView: View { @Environment(\.dismiss) private var dismiss @State private var editingTag: ConnectionTag? @State private var showingAddTag = false + @State private var tagPendingDeletion: TagDeletionRequest? var body: some View { let usage = ConnectionLibraryEditing.tagUsageCounts(in: appState.connections) @@ -40,18 +41,47 @@ struct TagManagementView: View { } .swipeActions(edge: .trailing, allowsFullSwipe: false) { if !tag.isPreset { - Button(role: .destructive) { - appState.deleteTag(tag.id) + Button { + requestDeletion(of: tag) } label: { Label("Delete", systemImage: "trash") } + .tint(.red) } } - .accessibilityAction(named: Text("Delete tag")) { - guard !tag.isPreset else { return } - appState.deleteTag(tag.id) + .contextMenu { + if !tag.isPreset { + Button { + editingTag = tag + } label: { + Label("Edit Tag", systemImage: "pencil") + } + Divider() + Button(role: .destructive) { + requestDeletion(of: tag) + } label: { + Label("Delete Tag", systemImage: "trash") + } + } } + .accessibilityActions { + if !tag.isPreset { + Button("Delete Tag") { requestDeletion(of: tag) } + } + } + } + } + .confirmationDialog( + String(localized: "Delete Tag"), + isPresented: deletionPresented, + titleVisibility: .visible, + presenting: tagPendingDeletion + ) { request in + Button(String(localized: "Delete"), role: .destructive) { + appState.deleteTag(request.tag.id) } + } message: { request in + Text(request.message) } .overlay { if appState.tags.isEmpty { @@ -79,15 +109,26 @@ struct TagManagementView: View { } } .sheet(isPresented: $showingAddTag) { - TagFormSheet { tag in - appState.addTag(tag) - } + TagFormSheet() } .sheet(item: $editingTag) { tag in - TagFormSheet(editing: tag) { updated in - appState.updateTag(updated) - } + TagFormSheet(editing: tag) } } } + + private var deletionPresented: Binding { + Binding( + get: { tagPendingDeletion != nil }, + set: { if !$0 { tagPendingDeletion = nil } } + ) + } + + private func requestDeletion(of tag: ConnectionTag) { + tagPendingDeletion = ConnectionLibraryEditing.tagDeletionRequest( + tag.id, + tags: appState.tags, + connections: appState.connections + ) + } } diff --git a/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift b/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift index 8ce107babb..dc48fa52da 100644 --- a/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI @testable import TableProMobile @testable import TableProModels import Testing @@ -76,5 +77,282 @@ struct ConnectionRedialTests { var refielded = original refielded.additionalFields = ["schema": "reporting"] #expect(!refielded.dialsTheSameWay(as: original)) + + var sampled = original + sampled.isSample = true + #expect(!sampled.dialsTheSameWay(as: original)) + } +} + +@Suite("Connection record changes") +struct ConnectionRecordChangeTests { + private func connection(name: String = "Prod") -> DatabaseConnection { + DatabaseConnection(name: name, type: .postgresql, host: "db.example.com", port: 5_432, username: "app") + } + + @Test("A change that leaves the dialing alone is an edit") + func presentationChangesAreEdits() { + let original = connection() + let edits: [(DatabaseConnection) -> DatabaseConnection] = [ + { var copy = $0; copy.name = "Production"; return copy }, + { var copy = $0; copy.color = .red; return copy }, + { var copy = $0; copy.sortOrder = 7; return copy }, + { var copy = $0; copy.groupId = UUID(); return copy }, + { var copy = $0; copy.tagIds = [UUID()]; return copy }, + { var copy = $0; copy.isFavorite = true; return copy }, + { var copy = $0; copy.safeModeLevel = .readOnly; return copy } + ] + + for edit in edits { + let updated = edit(original) + #expect(ConnectionRecordChange.changes(from: [original], to: [updated]) == [.edited(updated)]) + } + } + + @Test("A new host is a redial, a missing id a removal, and nothing else counts") + func redialsRemovalsAndNoise() { + let original = connection() + var rehosted = original + rehosted.host = "replica.example.com" + let added = connection(name: "New") + + #expect(ConnectionRecordChange.changes(from: [original], to: [rehosted]) == [.redialed(rehosted)]) + #expect(ConnectionRecordChange.changes(from: [original], to: []) == [.removed(original.id)]) + #expect(ConnectionRecordChange.changes(from: [original], to: [original, added]).isEmpty) + } + + @Test("Duplicate ids on either side do not trap") + func duplicatesAreTolerated() { + let original = connection() + var renamed = original + renamed.name = "Production" + + let changes = ConnectionRecordChange.changes(from: [original, original], to: [renamed, renamed]) + #expect(changes == [.edited(renamed)]) + } +} + +@MainActor +@Suite("Connection coordinator store") +struct ConnectionCoordinatorStoreTests { + private let fixture: AppStateFixture + private let appState: AppState + private let store: ConnectionCoordinatorStore + + init() throws { + fixture = try AppStateFixture() + appState = fixture.makeState(syncEnabled: false) + store = ConnectionCoordinatorStore(connectionManager: appState.connectionManager) + } + + private func connection(_ name: String) -> DatabaseConnection { + DatabaseConnection(name: name, type: .postgresql, host: "\(name.lowercased()).example.com", port: 5_432) + } + + @Test("A reorder reaches the open screen without rebuilding it") + func reorderKeepsTheCoordinator() { + let original = connection("A") + let coordinator = store.coordinator(for: original, appState: appState) + coordinator.tablesPath.append(TableInfo(name: "users")) + var reordered = original + reordered.sortOrder = 5 + + store.reconcile(from: [original], to: [reordered]) + + let resolved = store.coordinator(for: reordered, appState: appState) + #expect(resolved === coordinator) + #expect(resolved.connection.sortOrder == 5) + #expect(resolved.tablesPath.count == 1) + #expect(store.generation(for: original.id) == 0) + } + + @Test("A tighter safe mode reaches the open screen in place") + func safeModeReachesOpenScreen() { + let original = connection("A") + let coordinator = store.coordinator(for: original, appState: appState) + var tightened = original + tightened.safeModeLevel = .readOnly + + store.reconcile(from: [original], to: [tightened]) + + #expect(store.coordinator(for: tightened, appState: appState) === coordinator) + #expect(coordinator.connection.safeModeLevel == .readOnly) + } + + @Test("A redial rebuilds only the connection that changed") + func redialRebuildsOnlyItsOwnScreen() { + let first = connection("A") + let second = connection("B") + let firstCoordinator = store.coordinator(for: first, appState: appState) + let secondCoordinator = store.coordinator(for: second, appState: appState) + var rehosted = first + rehosted.host = "replica.example.com" + + store.reconcile(from: [first, second], to: [rehosted, second]) + + #expect(store.generation(for: first.id) == 1) + #expect(store.coordinator(for: rehosted, appState: appState) !== firstCoordinator) + #expect(store.generation(for: second.id) == 0) + #expect(store.coordinator(for: second, appState: appState) === secondCoordinator) + } + + @Test("A deleted connection keeps its screen up until the cover goes, and never reconnects") + func deletionKeepsTheLastRecord() { + let original = connection("A") + let coordinator = store.coordinator(for: original, appState: appState) + var renamed = original + renamed.name = "Renamed" + store.reconcile(from: [original], to: [renamed]) + + store.reconcile(from: [renamed], to: []) + + #expect(store.generation(for: original.id) == 0) + #expect(coordinator.session == nil) + #expect(store.presentedRecord(for: original.id, in: [])?.name == "Renamed") + #expect(store.presentedRecord(for: UUID(), in: []) == nil) + + store.discardRemovedRecords() + #expect(store.presentedRecord(for: original.id, in: []) == nil) + } + + @Test("A connection deleted before it was opened has nothing to present") + func unopenedDeletionPresentsNothing() { + let original = connection("A") + + store.reconcile(from: [original], to: []) + + #expect(store.presentedRecord(for: original.id, in: []) == nil) + } + + @Test("Saving a rename over synced changes keeps them, and the open screen with it") + func renameSaveKeepsSyncedChanges() async throws { + let original = connection("A") + #expect(appState.addConnection(original)) + let form = fixture.makeFormViewModel(editing: original) + _ = store.coordinator(for: original, appState: appState) + + let beforeSync = appState.connections + var synced = try #require(beforeSync.first) + synced.safeModeLevel = .readOnly + synced.isReadOnly = true + synced.additionalFields["schema"] = "reporting" + appState.applySyncedConnections([synced]) + store.reconcile(from: beforeSync, to: appState.connections) + let generationAfterSync = store.generation(for: original.id) + let coordinator = store.coordinator(for: synced, appState: appState) + + form.name = "Renamed" + let beforeSave = appState.connections + #expect(form.reconnectsAfterSave == false) + let savedId = try #require(await form.save(appState: appState, secureStore: MockSecureStore())) + store.reconcile(from: beforeSave, to: appState.connections) + + #expect(savedId == original.id) + + let stored = try #require(appState.connections.first) + #expect(stored.name == "Renamed") + #expect(stored.safeModeLevel == .readOnly) + #expect(stored.additionalFields["schema"] == "reporting") + #expect(store.generation(for: original.id) == generationAfterSync) + #expect(store.coordinator(for: stored, appState: appState) === coordinator) + #expect(coordinator.connection.name == "Renamed") + #expect(coordinator.connection.safeModeLevel == .readOnly) + } + + @Test("A password and host saved together while the form holds the scene rebuild the open screen once") + func secretAndRedialSaveRebuildsOnce() async throws { + let original = connection("A") + #expect(appState.addConnection(original)) + let coordinator = store.coordinator(for: original, appState: appState) + let form = fixture.makeFormViewModel(editing: original) + store.holdRebuilds(true) + + form.password = "rotated" + form.host = "replica.example.com" + let reconnects = form.reconnectsAfterSave + let beforeSave = appState.connections + let savedId = try #require(await form.save(appState: appState, secureStore: MockSecureStore())) + if reconnects { + store.invalidate(savedId) + } + store.reconcile(from: beforeSave, to: appState.connections) + + #expect(reconnects) + #expect(store.generation(for: original.id) == 0) + #expect(coordinator.connection.host == "replica.example.com") + + store.holdRebuilds(false) + + #expect(store.generation(for: original.id) == 1) + #expect(store.coordinator(for: original, appState: appState) !== coordinator) + } + + @Test("A redial that syncs in under unsaved edits updates the screen in place and rebuilds it once they go") + func redialWaitsForUnsavedEdits() { + let original = connection("A") + let coordinator = store.coordinator(for: original, appState: appState) + var rehosted = original + rehosted.host = "replica.example.com" + rehosted.safeModeLevel = .readOnly + + store.holdRebuilds(true) + store.reconcile(from: [original], to: [rehosted]) + + #expect(store.generation(for: original.id) == 0) + #expect(store.coordinator(for: rehosted, appState: appState) === coordinator) + #expect(coordinator.connection.safeModeLevel == .readOnly) + + store.holdRebuilds(false) + + #expect(store.generation(for: original.id) == 1) + #expect(store.coordinator(for: rehosted, appState: appState) !== coordinator) + } + + @Test("A reconnect asked for while an edit is unsaved runs once that edit goes") + func reconnectWaitsForUnsavedEdits() { + let original = connection("A") + _ = store.coordinator(for: original, appState: appState) + + store.holdRebuilds(true) + store.invalidate(original.id) + #expect(store.generation(for: original.id) == 0) + + store.holdRebuilds(false) + #expect(store.generation(for: original.id) == 1) + } + + @Test("Several rebuilds held for one connection run once") + func heldRebuildsCollapse() { + let original = connection("A") + _ = store.coordinator(for: original, appState: appState) + var rehosted = original + rehosted.host = "replica.example.com" + var reported = rehosted + reported.port = 5_433 + + store.holdRebuilds(true) + store.reconcile(from: [original], to: [rehosted]) + store.reconcile(from: [rehosted], to: [reported]) + store.holdRebuilds(true) + store.holdRebuilds(false) + + #expect(store.generation(for: original.id) == 1) + } + + @Test("A connection deleted while its rebuild waits is not rebuilt") + func deletionDropsHeldRebuild() { + let original = connection("A") + let coordinator = store.coordinator(for: original, appState: appState) + var rehosted = original + rehosted.host = "replica.example.com" + + store.holdRebuilds(true) + store.reconcile(from: [original], to: [rehosted]) + store.reconcile(from: [rehosted], to: []) + store.holdRebuilds(false) + + #expect(store.generation(for: original.id) == 0) + #expect(coordinator.session == nil) + #expect(store.presentedRecord(for: original.id, in: [])?.host == "replica.example.com") } } diff --git a/TableProMobile/TableProMobileTests/ConnectionFormViewModelDuckDBTests.swift b/TableProMobile/TableProMobileTests/ConnectionFormViewModelDuckDBTests.swift index 333c620e13..1c1c1b0e48 100644 --- a/TableProMobile/TableProMobileTests/ConnectionFormViewModelDuckDBTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionFormViewModelDuckDBTests.swift @@ -1,7 +1,7 @@ import Foundation -import Testing -import TableProModels @testable import TableProMobile +import TableProModels +import Testing @MainActor @Suite("ConnectionFormViewModel DuckDB") @@ -19,7 +19,7 @@ struct ConnectionFormViewModelDuckDBTests { vm.type = .duckdb vm.duckDBInMemory = true - #expect(vm.database == DuckDBDriver.inMemoryPath) + #expect(vm.database == LocalDatabaseLocation.inMemoryPath) #expect(vm.canSave) #expect(vm.selectedFileURL == nil) } @@ -36,16 +36,41 @@ struct ConnectionFormViewModelDuckDBTests { } @Test("create new database uses the .duckdb extension") - func createNewUsesDuckDBExtension() { - let vm = ConnectionFormViewModel() + func createNewUsesDuckDBExtension() throws { + let fixture = try AppStateFixture() + let vm = fixture.makeFormViewModel() vm.type = .duckdb vm.newDatabaseName = "analytics" vm.createNewDatabase() - #expect(vm.database.hasSuffix("analytics.duckdb")) + #expect(vm.database == fixture.documentsFile("analytics.duckdb").path) #expect(vm.canSave) } + @Test("A DuckDB file picked inside Documents is used in place, with no bookmark") + func documentsPickNeedsNoBookmark() throws { + let fixture = try AppStateFixture() + let file = fixture.documentsFile("cube.duckdb") + try Data().write(to: file) + let vm = fixture.makeFormViewModel() + vm.type = .duckdb + + vm.handleDuckDBFilePicker(.success([file])) + + #expect(vm.database == file.path) + #expect(vm.pendingFile == .documentsFile) + } + + @Test("An in-memory DuckDB connection opens the form in in-memory mode") + func inMemoryConnectionHydrates() { + let stored = DatabaseConnection(type: .duckdb, database: LocalDatabaseLocation.inMemoryPath) + let vm = ConnectionFormViewModel(editing: stored) + + #expect(vm.duckDBInMemory) + #expect(vm.selectedFileURL == nil) + #expect(vm.database == LocalDatabaseLocation.inMemoryPath) + } + @Test("switching type away from DuckDB resets in-memory state") func switchingTypeResets() { let vm = ConnectionFormViewModel() diff --git a/TableProMobile/TableProMobileTests/ConnectionFormViewModelSSHKeyTests.swift b/TableProMobile/TableProMobileTests/ConnectionFormViewModelSSHKeyTests.swift new file mode 100644 index 0000000000..73a26a9064 --- /dev/null +++ b/TableProMobile/TableProMobileTests/ConnectionFormViewModelSSHKeyTests.swift @@ -0,0 +1,311 @@ +import Foundation +import TableProDatabase +import TableProModels +import Testing + +@testable import TableProMobile + +enum PastedKeyAbandonment: CaseIterable, Sendable { + case passwordAuth + case tunnelOff + case importFile +} + +@MainActor +@Suite("Connection form SSH private key") +struct ConnectionFormViewModelSSHKeyTests { + private let keyMarker = "b3BlbnNzaC1rZXktdjEAAAAABG5vbmU" + + private var pastedKey: String { + "-----BEGIN OPENSSH PRIVATE KEY-----\n\(keyMarker)\n-----END OPENSSH PRIVATE KEY-----\n" + } + + private func makeTunnelledConnection(keyPath: String? = nil) -> DatabaseConnection { + DatabaseConnection( + name: "Bastion", + type: .postgresql, + host: "10.0.0.5", + port: 5_432, + sshEnabled: true, + sshConfiguration: SSHConfiguration( + host: "bastion.example.com", + username: "deploy", + authMethod: .privateKey, + privateKeyPath: keyPath + ) + ) + } + + private func keyAccount(_ id: UUID) -> String { + "com.TablePro.sshkeydata.\(id.uuidString)" + } + + private func loadedForm(_ connection: DatabaseConnection, store: MockSecureStore) async -> ConnectionFormViewModel { + let form = ConnectionFormViewModel(editing: connection) + await form.loadStoredCredentials(secureStore: store) + return form + } + + private func makeAppState() throws -> AppState { + try AppStateFixture().makeState(syncEnabled: false) + } + + @Test("A new connection keeps one id for as long as its form is open") + func newConnectionIdIsStable() { + let form = ConnectionFormViewModel() + let first = form.buildConnection() + let second = form.buildConnection() + + #expect(first.id == form.connectionId) + #expect(second.id == form.connectionId) + + let existing = makeTunnelledConnection() + #expect(ConnectionFormViewModel(editing: existing).connectionId == existing.id) + } + + @Test("A Save retried after a refused credential write keeps the key under the id it saves") + func retriedSaveKeepsKeyWithConnection() async throws { + let appState = try makeAppState() + let store = MockSecureStore() + let form = ConnectionFormViewModel() + form.host = "10.0.0.5" + form.sshEnabled = true + form.sshHost = "bastion.example.com" + form.sshAuthMethod = .privateKey + form.sshKeyInputMode = .paste + form.sshKeyContent = pastedKey + form.sshKeyPassphrase = "phrase" + store.failNextStore = true + + #expect(await form.save(appState: appState, secureStore: store) == nil) + #expect(form.credentialError != nil) + + let savedId = try #require(await form.save(appState: appState, secureStore: store)) + + #expect(savedId == form.connectionId) + #expect(try store.retrieve(forKey: keyAccount(savedId)) == pastedKey) + #expect(try store.retrieve(forKey: "com.TablePro.keypassphrase.\(savedId.uuidString)") == "phrase") + } + + @Test("A pasted key never reaches the connection that is written to the file") + func buildConnectionOmitsPastedKey() throws { + let form = ConnectionFormViewModel() + form.host = "10.0.0.5" + form.sshEnabled = true + form.sshHost = "bastion.example.com" + form.sshAuthMethod = .privateKey + form.sshKeyInputMode = .paste + form.sshKeyContent = pastedKey + + let encoded = try JSONEncoder().encode([form.buildConnection()]) + let text = try #require(String(data: encoded, encoding: .utf8)) + + #expect(!text.contains(keyMarker)) + #expect(!text.contains("privateKeyData")) + #expect(form.pastedPrivateKey == pastedKey) + } + + @Test("Saving a pasted key stores it in the secure store under the connection id") + func persistStoresPastedKey() throws { + let store = MockSecureStore() + let form = ConnectionFormViewModel() + form.sshEnabled = true + form.sshAuthMethod = .privateKey + form.sshKeyInputMode = .paste + form.sshKeyContent = pastedKey + + try form.persistPrivateKey(secureStore: store) + + #expect(try store.retrieve(forKey: keyAccount(form.buildConnection().id)) == pastedKey) + } + + @Test("Editing a connection loads its stored key and selects Paste Key") + func loadSelectsPasteKey() async throws { + let connection = makeTunnelledConnection(keyPath: "/keys/id_ed25519") + let store = MockSecureStore() + store.seed(keyAccount(connection.id), pastedKey) + + let form = ConnectionFormViewModel(editing: connection) + #expect(form.sshKeyInputMode == .file) + + await form.loadStoredCredentials(secureStore: store) + + #expect(form.sshKeyContent == pastedKey) + #expect(form.sshKeyInputMode == .paste) + #expect(form.pastedPrivateKey == pastedKey) + } + + @Test("A private key connection with no key file opens on Paste Key, one with a file on Import File") + func initialInputMode() { + #expect(ConnectionFormViewModel(editing: makeTunnelledConnection()).sshKeyInputMode == .paste) + #expect( + ConnectionFormViewModel(editing: makeTunnelledConnection(keyPath: "/keys/id_rsa")).sshKeyInputMode == .file + ) + } + + @Test("Leaving the pasted key behind deletes it on save", arguments: PastedKeyAbandonment.allCases) + func switchingAwayDeletesKey(_ change: PastedKeyAbandonment) async throws { + let connection = makeTunnelledConnection() + let store = MockSecureStore() + store.seed(keyAccount(connection.id), pastedKey) + let form = await loadedForm(connection, store: store) + + switch change { + case .passwordAuth: form.sshAuthMethod = .password + case .tunnelOff: form.sshEnabled = false + case .importFile: form.sshKeyInputMode = .file + } + try form.persistPrivateKey(secureStore: store) + + #expect(try store.retrieve(forKey: keyAccount(connection.id)) == nil) + } + + @Test("Saving before the stored key has loaded leaves it alone") + func saveBeforeLoadKeepsKey() throws { + let connection = makeTunnelledConnection() + let store = MockSecureStore() + store.seed(keyAccount(connection.id), pastedKey) + let form = ConnectionFormViewModel(editing: connection) + + try form.persistPrivateKey(secureStore: store) + + #expect(try store.retrieve(forKey: keyAccount(connection.id)) == pastedKey) + } + + @Test("Saving an unchanged key writes nothing") + func unchangedKeyIsNotRewritten() async throws { + let connection = makeTunnelledConnection() + let store = MockSecureStore() + store.seed(keyAccount(connection.id), pastedKey) + let form = await loadedForm(connection, store: store) + store.failNextStore = true + + try form.persistPrivateKey(secureStore: store) + + #expect(store.failNextStore) + #expect(try store.retrieve(forKey: keyAccount(connection.id)) == pastedKey) + } + + @Test("A refused key write is reported") + func refusedStoreThrows() { + let store = MockSecureStore() + store.failNextStore = true + let form = ConnectionFormViewModel() + form.sshEnabled = true + form.sshAuthMethod = .privateKey + form.sshKeyInputMode = .paste + form.sshKeyContent = pastedKey + + #expect(throws: (any Error).self) { + try form.persistPrivateKey(secureStore: store) + } + } + + @Test("A picked key file that is not text is copied beside another connection's key of the same name") + func pickedKeyFileNeverOverwritesAnother() throws { + let fixture = try AppStateFixture() + let firstKey = Data([0xFF, 0xFE, 0x00, 0x81, 0x01]) + let secondKey = Data([0xFF, 0xFE, 0x00, 0x81, 0x02]) + let firstPick = try pickedFile(named: "id_key", in: "Laptop", contents: firstKey, fixture: fixture) + let secondPick = try pickedFile(named: "id_key", in: "Server", contents: secondKey, fixture: fixture) + let first = fixture.makeFormViewModel() + let second = fixture.makeFormViewModel() + + first.handleSSHKeyFilePicker(.success([firstPick])) + second.handleSSHKeyFilePicker(.success([secondPick])) + + #expect(first.sshKeyFileError == nil) + #expect(second.sshKeyFileError == nil) + #expect(first.sshKeyPath != second.sshKeyPath) + #expect(fixture.localFiles.isInDocuments(URL(fileURLWithPath: first.sshKeyPath))) + #expect(fixture.localFiles.isInDocuments(URL(fileURLWithPath: second.sshKeyPath))) + #expect(try Data(contentsOf: URL(fileURLWithPath: first.sshKeyPath)) == firstKey) + #expect(try Data(contentsOf: URL(fileURLWithPath: second.sshKeyPath)) == secondKey) + } + + private func pickedFile(named name: String, in folder: String, contents: Data, fixture: AppStateFixture) throws -> URL { + let directory = fixture.root.appendingPathComponent(folder, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent(name) + try contents.write(to: url) + return url + } + + @Test("A key file that cannot be copied records no path and says why") + func failedKeyCopyRecordsNoPath() throws { + let fixture = try AppStateFixture() + let form = fixture.makeFormViewModel() + let missing = fixture.root.appendingPathComponent("Picked/gone_key") + + form.handleSSHKeyFilePicker(.success([missing])) + + #expect(form.sshKeyPath.isEmpty) + #expect(form.sshKeyFileError != nil) + } + + @Test("A key file picked from the app's own documents is used where it is") + func documentsKeyFileIsUsedInPlace() throws { + let fixture = try AppStateFixture() + let form = fixture.makeFormViewModel() + let inDocuments = fixture.documentsFile("deploy_key") + try Data([0xFF, 0xFE, 0x01]).write(to: inDocuments) + + form.handleSSHKeyFilePicker(.success([inDocuments])) + + #expect(form.sshKeyPath == inDocuments.path) + #expect(form.sshKeyFileError == nil) + } + + @Test("A picked text key becomes a pasted key and no file is copied") + func pickedTextKeyIsPasted() throws { + let fixture = try AppStateFixture() + let form = fixture.makeFormViewModel() + let picked = fixture.root.appendingPathComponent("id_ed25519") + try Data(pastedKey.utf8).write(to: picked) + + form.handleSSHKeyFilePicker(.success([picked])) + + #expect(form.sshKeyContent == pastedKey) + #expect(form.sshKeyInputMode == .paste) + #expect(form.sshKeyPath.isEmpty) + #expect(try FileManager.default.contentsOfDirectory(atPath: fixture.documentsDirectory.path).isEmpty) + } + + @Test("Test Connection hands the tunnel the form's secrets under the throwaway id only") + func testSecretsCarryFormValues() { + let form = ConnectionFormViewModel() + form.password = "db-secret" + form.sshEnabled = true + form.sshAuthMethod = .privateKey + form.sshKeyInputMode = .paste + form.sshKeyContent = pastedKey + form.sshKeyPassphrase = "phrase" + let tempId = UUID() + + let credentials = SSHTunnelCredentials( + connectionId: tempId, + secureStore: EphemeralSecureStore(form.testSecrets(for: tempId)) + ) + + #expect(form.testSecrets(for: tempId)["com.TablePro.password.\(tempId.uuidString)"] == "db-secret") + #expect(credentials.privateKeySource(keyPath: nil) == .inMemory(pastedKey)) + #expect(credentials.keyPassphrase == "phrase") + #expect(form.testSecrets(for: tempId).keys.allSatisfy { $0.hasSuffix(tempId.uuidString) }) + } + + @Test("Test Connection leaves out SSH secrets when the tunnel is off and the key in Import File mode") + func testSecretsFollowTheForm() { + let form = ConnectionFormViewModel() + form.sshEnabled = true + form.sshAuthMethod = .privateKey + form.sshKeyInputMode = .file + form.sshKeyContent = pastedKey + form.sshPassword = "ssh-secret" + let tempId = UUID() + + #expect(form.testSecrets(for: tempId)[keyAccount(tempId)] == nil) + + form.sshEnabled = false + #expect(form.testSecrets(for: tempId).isEmpty) + } +} diff --git a/TableProMobile/TableProMobileTests/ConnectionFormViewModelTests.swift b/TableProMobile/TableProMobileTests/ConnectionFormViewModelTests.swift index 0468eaaeee..5187ea8add 100644 --- a/TableProMobile/TableProMobileTests/ConnectionFormViewModelTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionFormViewModelTests.swift @@ -1,20 +1,19 @@ import Foundation -import Testing import TableProDatabase -import TableProModels @testable import TableProMobile +import TableProModels +import Testing @MainActor @Suite("ConnectionFormViewModel") struct ConnectionFormViewModelTests { - private func makeStoredConnection() -> DatabaseConnection { var conn = DatabaseConnection( id: UUID(), name: "Local", type: .postgresql, host: "10.0.0.1", - port: 5432, + port: 5_432, username: "alice", database: "appdb", sshEnabled: false, @@ -115,17 +114,87 @@ struct ConnectionFormViewModelTests { #expect(vm.database == "") } - @Test("createNewDatabase creates a .db URL in Documents") - func createDatabase() { - let vm = ConnectionFormViewModel() + @Test("createNewDatabase stores the file's path in Documents and creates nothing yet") + func createDatabase() throws { + let fixture = try AppStateFixture() + let vm = fixture.makeFormViewModel() vm.type = .sqlite vm.newDatabaseName = "scratch" vm.createNewDatabase() #expect(vm.selectedFileURL?.lastPathComponent == "scratch.db") - #expect(vm.database.hasSuffix("/scratch.db")) + #expect(vm.database == fixture.documentsFile("scratch.db").path) #expect(vm.name == "scratch") #expect(vm.newDatabaseName == "") + #expect(vm.pendingFile == .newDocumentsFile(fixture.documentsFile("scratch.db"))) + #expect(!FileManager.default.fileExists(atPath: fixture.documentsFile("scratch.db").path)) + } + + @Test("A new database with a name already in Documents is refused") + func createDatabaseWithTakenName() throws { + let fixture = try AppStateFixture() + try Data().write(to: fixture.documentsFile("scratch.db")) + let vm = fixture.makeFormViewModel() + vm.type = .sqlite + vm.newDatabaseName = "scratch" + + vm.createNewDatabase() + + #expect(vm.fileError == LocalDatabaseFileError.alreadyExists(fileName: "scratch.db").localizedDescription) + #expect(vm.database.isEmpty) + #expect(vm.pendingFile == nil) + } + + @Test("A file that cannot be copied in says so and leaves the form without a database") + func failedCopyIsReported() throws { + let fixture = try AppStateFixture() + let vm = fixture.makeFormViewModel() + vm.type = .sqlite + + vm.handleSQLiteFilePicker(.success([fixture.root.appendingPathComponent("missing.db")])) + + #expect(vm.fileError != nil) + #expect(vm.database.isEmpty) + #expect(vm.selectedFileURL == nil) + } + + @Test("A picked SQLite file is copied into Documents and stored by its path there") + func pickedFileIsCopied() throws { + let fixture = try AppStateFixture() + let source = fixture.root.appendingPathComponent("orders.sqlite") + try Data("orders".utf8).write(to: source) + let vm = fixture.makeFormViewModel() + vm.type = .sqlite + + vm.handleSQLiteFilePicker(.success([source])) + + #expect(vm.database == fixture.documentsFile("orders.sqlite").path) + #expect(vm.pendingFile == .documentsFile) + #expect(FileManager.default.fileExists(atPath: fixture.documentsFile("orders.sqlite").path)) + } + + @Test("A file connection from before a restore shows today's file and keeps its stored path") + func restoredFileHydrates() throws { + let fixture = try AppStateFixture() + let storedPath = fixture.earlierContainerPath(to: "notes.db") + let vm = fixture.makeFormViewModel( + editing: DatabaseConnection(type: .sqlite, port: 0, database: storedPath) + ) + + #expect(vm.selectedFileURL == fixture.documentsFile("notes.db")) + #expect(vm.database == storedPath) + } + + @Test("A file connection synced from another device is not shown as a file on this one") + func otherDeviceFileIsNotReRooted() throws { + let fixture = try AppStateFixture() + let storedPath = fixture.otherInstallContainerPath(to: "notes.db") + let vm = fixture.makeFormViewModel( + editing: DatabaseConnection(type: .sqlite, port: 0, database: storedPath) + ) + + #expect(vm.selectedFileURL == URL(fileURLWithPath: storedPath)) + #expect(vm.database == storedPath) } } diff --git a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionDetailFormatterTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionDetailFormatterTests.swift index a6cf51f333..087945e4fb 100644 --- a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionDetailFormatterTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionDetailFormatterTests.swift @@ -20,7 +20,7 @@ struct ConnectionDetailFormatterTests { @Test("A file database shows its file name, and an in-memory one says so") func fileDatabases() { let sqlite = DatabaseConnection(type: .sqlite, database: "/var/mobile/Documents/app.sqlite") - let memory = DatabaseConnection(type: .duckdb, database: ConnectionDetailFormatter.inMemoryDatabasePath) + let memory = DatabaseConnection(type: .duckdb, database: LocalDatabaseLocation.inMemoryPath) #expect(ConnectionDetailFormatter.detail(for: sqlite) == "app.sqlite") #expect(ConnectionDetailFormatter.detail(for: memory) == String(localized: "In Memory")) diff --git a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift new file mode 100644 index 0000000000..67d59eae9d --- /dev/null +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift @@ -0,0 +1,250 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import TableProOracleCore +import Testing + +@MainActor +@Suite("Connection form edits") +struct ConnectionFormEditsTests { + private func storedConnection() -> DatabaseConnection { + DatabaseConnection( + name: "Prod", + type: .postgresql, + host: "db.example.com", + port: 5_432, + username: "app", + database: "app", + sslEnabled: true, + sslConfiguration: SSLConfiguration(mode: .require), + tagIds: [UUID()], + sortOrder: 2 + ) + } + + @Test("A port edit keeps every change made to the record after the form opened") + func keepsChangesMadeWhileOpen() { + let snapshot = storedConnection() + let viewModel = ConnectionFormViewModel(editing: snapshot) + let groupId = UUID() + let otherTag = UUID() + var current = snapshot + current.isFavorite = true + current.color = .purple + current.groupId = groupId + current.tagIds = snapshot.tagIds + [otherTag] + current.sortOrder = 9 + current.queryTimeoutSeconds = 45 + + viewModel.port = "5433" + let saved = viewModel.applyingEdits(to: current) + + #expect(saved.port == 5_433) + #expect(saved.isFavorite) + #expect(saved.color == .purple) + #expect(saved.groupId == groupId) + #expect(saved.tagIds == snapshot.tagIds + [otherTag]) + #expect(saved.sortOrder == 9) + #expect(saved.queryTimeoutSeconds == 45) + } + + @Test("An untouched form hands back the current record unchanged") + func untouchedFormChangesNothing() { + let snapshot = storedConnection() + let viewModel = ConnectionFormViewModel(editing: snapshot) + var current = snapshot + current.host = "replica.example.com" + current.name = "Renamed on the Mac" + + #expect(viewModel.applyingEdits(to: current) == current) + } + + @Test("A name typed here wins while a host changed elsewhere survives") + func typedNameAndSyncedHostBothLand() { + let snapshot = storedConnection() + let viewModel = ConnectionFormViewModel(editing: snapshot) + var current = snapshot + current.host = "replica.example.com" + + viewModel.name = "Production" + let saved = viewModel.applyingEdits(to: current) + + #expect(saved.name == "Production") + #expect(saved.host == "replica.example.com") + } + + @Test("A tag pick replaces only the first of the tags the record holds now") + func tagPickReplacesTheCurrentFirstTag() { + let snapshot = storedConnection() + let viewModel = ConnectionFormViewModel(editing: snapshot) + let macFirst = UUID() + let macSecond = UUID() + let picked = UUID() + var current = snapshot + current.tagIds = [macFirst, macSecond] + + viewModel.tagId = picked + let saved = viewModel.applyingEdits(to: current) + + #expect(saved.tagIds == [picked, macSecond]) + } + + @Test("An SSH port edit keeps the jump hosts and the Mac's own tunnel settings") + func sshEditKeepsMacFields() { + var ssh = SSHConfiguration(host: "bastion.example.com", port: 22, username: "deploy") + ssh.jumpHosts = [SSHJumpHost(host: "jump.example.com")] + ssh.macTotpMode = "autoGenerate" + ssh.macAgentSocketPath = "/tmp/agent.sock" + var snapshot = storedConnection() + snapshot.sshEnabled = true + snapshot.sshConfiguration = ssh + let viewModel = ConnectionFormViewModel(editing: snapshot) + + viewModel.sshPort = "2222" + let saved = viewModel.applyingEdits(to: snapshot) + + #expect(saved.sshConfiguration?.port == 2_222) + #expect(saved.sshConfiguration?.jumpHosts == ssh.jumpHosts) + #expect(saved.sshConfiguration?.macTotpMode == "autoGenerate") + #expect(saved.sshConfiguration?.macAgentSocketPath == "/tmp/agent.sock") + } + + @Test("An SSH port edit keeps an SSH host that synced in while the form was open") + func sshPortEditKeepsSyncedHost() { + var snapshot = storedConnection() + snapshot.sshEnabled = true + snapshot.sshConfiguration = SSHConfiguration(host: "bastion-old.example.com", port: 22, username: "deploy") + let viewModel = ConnectionFormViewModel(editing: snapshot) + var current = snapshot + current.sshConfiguration?.host = "bastion-new.example.com" + current.sshConfiguration?.authMethod = .privateKey + + viewModel.sshPort = "2222" + let saved = viewModel.applyingEdits(to: current) + + #expect(saved.sshConfiguration?.host == "bastion-new.example.com") + #expect(saved.sshConfiguration?.authMethod == .privateKey) + #expect(saved.sshConfiguration?.port == 2_222) + #expect(saved.sshConfiguration?.username == "deploy") + #expect(saved.sshEnabled) + } + + @Test("An SSH edit keeps a tunnel another device turned off") + func sshEditKeepsSyncedTunnelOff() { + var snapshot = storedConnection() + snapshot.sshEnabled = true + snapshot.sshConfiguration = SSHConfiguration(host: "bastion.example.com", port: 22, username: "deploy") + let viewModel = ConnectionFormViewModel(editing: snapshot) + var current = snapshot + current.sshEnabled = false + + viewModel.sshUsername = "ops" + let saved = viewModel.applyingEdits(to: current) + + #expect(saved.sshEnabled == false) + #expect(saved.sshConfiguration?.username == "ops") + #expect(saved.sshConfiguration?.host == "bastion.example.com") + } + + @Test("Turning SSH on over a tunnel the Mac had switched off switches it on there too") + func sshOnFlipsMacEnabled() { + var ssh = SSHConfiguration(host: "bastion.example.com", port: 22, username: "deploy") + ssh.macEnabled = false + var snapshot = storedConnection() + snapshot.sshEnabled = false + snapshot.sshConfiguration = ssh + let viewModel = ConnectionFormViewModel(editing: snapshot) + + viewModel.sshEnabled = true + viewModel.sshHost = "bastion.example.com" + viewModel.sshUsername = "deploy" + let saved = viewModel.applyingEdits(to: snapshot) + + #expect(saved.sshEnabled) + #expect(saved.sshConfiguration?.macEnabled == true) + } + + @Test("Turning SSH off clears the tunnel") + func sshOffClearsTheTunnel() { + var snapshot = storedConnection() + snapshot.sshEnabled = true + snapshot.sshConfiguration = SSHConfiguration(host: "bastion.example.com", port: 22, username: "deploy") + let viewModel = ConnectionFormViewModel(editing: snapshot) + + viewModel.sshEnabled = false + let saved = viewModel.applyingEdits(to: snapshot) + + #expect(!saved.sshEnabled) + #expect(saved.sshConfiguration == nil) + } + + @Test("An SSL mode change keeps certificate paths set after the form opened") + func sslChangeKeepsNewCertificatePaths() { + let snapshot = storedConnection() + let viewModel = ConnectionFormViewModel(editing: snapshot) + var current = snapshot + current.sslConfiguration = SSLConfiguration(mode: .require, caCertificatePath: "/Users/mac/ca.pem") + + viewModel.sslMode = .verifyFull + let saved = viewModel.applyingEdits(to: current) + + #expect(saved.sslConfiguration?.mode == .verifyFull) + #expect(saved.sslConfiguration?.caCertificatePath == "/Users/mac/ca.pem") + #expect(saved.sslEnabled) + } + + @Test("An Oracle edit keeps additional fields added after the form opened") + func oracleEditKeepsNewFields() { + let snapshot = DatabaseConnection( + name: "Oracle", + type: .oracle, + host: "db.example.com", + port: 1_521, + additionalFields: [OracleConnectionOptions.AdditionalFieldKey.serviceName: "ORCL"] + ) + let viewModel = ConnectionFormViewModel(editing: snapshot) + var current = snapshot + current.additionalFields["custom"] = "kept" + + viewModel.oracleServiceName = "ORCLPDB1" + let saved = viewModel.applyingEdits(to: current) + + #expect(saved.additionalFields[OracleConnectionOptions.AdditionalFieldKey.serviceName] == "ORCLPDB1") + #expect(saved.additionalFields["custom"] == "kept") + } + + @Test("An Oracle service name edit keeps a SID and role that synced in while the form was open") + func oracleServiceNameEditKeepsSyncedFields() { + typealias Key = OracleConnectionOptions.AdditionalFieldKey + let snapshot = DatabaseConnection( + name: "Oracle", + type: .oracle, + host: "db.example.com", + port: 1_521, + additionalFields: [Key.serviceName: "ORCL", Key.sid: "OLDSID"] + ) + let viewModel = ConnectionFormViewModel(editing: snapshot) + var current = snapshot + current.additionalFields[Key.sid] = "NEWSID" + current.additionalFields[Key.role] = OracleConnectionOptions.Role.sysdba.rawValue + + viewModel.oracleServiceName = "ORCLPDB1" + let saved = viewModel.applyingEdits(to: current) + + #expect(saved.additionalFields[Key.serviceName] == "ORCLPDB1") + #expect(saved.additionalFields[Key.sid] == "NEWSID") + #expect(saved.additionalFields[Key.role] == OracleConnectionOptions.Role.sysdba.rawValue) + } + + @Test("A Safe Mode change writes the legacy read-only flag with it") + func safeModeWritesReadOnly() { + let snapshot = storedConnection() + let viewModel = ConnectionFormViewModel(editing: snapshot) + + viewModel.safeModeLevel = .readOnly + let saved = viewModel.applyingEdits(to: snapshot) + + #expect(saved.safeModeLevel == .readOnly) + #expect(saved.isReadOnly) + } +} diff --git a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormViewModelChangesTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormViewModelChangesTests.swift new file mode 100644 index 0000000000..9c27ffc01f --- /dev/null +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormViewModelChangesTests.swift @@ -0,0 +1,438 @@ +import Foundation +import TableProDatabase +@testable import TableProMobile +import TableProModels +import TableProOracleCore +import Testing + +@MainActor +@Suite("Connection form changes") +struct ConnectionFormViewModelChangesTests { + private let certificates = InMemoryCertificateStore() + private let fixture: AppStateFixture + + init() throws { + fixture = try AppStateFixture() + } + + private func form(editing connection: DatabaseConnection? = nil) -> ConnectionFormViewModel { + fixture.makeFormViewModel(editing: connection, certificateStore: certificates) + } + + private func makeAppState(holding connection: DatabaseConnection) -> AppState { + let state = fixture.makeState(syncEnabled: false) + #expect(state.addConnection(connection)) + return state + } + + private func seededStore(for connection: DatabaseConnection) -> MockSecureStore { + let store = MockSecureStore() + let suffix = connection.id.uuidString + store.seed("com.TablePro.password.\(suffix)", "stored") + store.seed("com.TablePro.sshpassword.\(suffix)", "tunnel") + store.seed("com.TablePro.keypassphrase.\(suffix)", "unlock") + return store + } + + private func postgres() -> DatabaseConnection { + DatabaseConnection( + name: "Prod", type: .postgresql, host: "db.example.com", port: 5_432, + username: "app", database: "app", sslEnabled: true, + sslConfiguration: SSLConfiguration( + mode: .verifyFull, + caCertificatePath: "/ca.pem", + clientCertificatePath: "/client.pem", + clientKeyPath: "/client.key" + ) + ) + } + + private func tunnel(authMethod: SSHConfiguration.SSHAuthMethod, enabled: Bool = true) -> DatabaseConnection { + DatabaseConnection( + name: "Tunnelled", type: .mysql, host: "10.0.0.5", port: 3_306, username: "app", + database: "shop", sshEnabled: enabled, + sshConfiguration: SSHConfiguration(host: "bastion", port: 22, username: "deploy", authMethod: authMethod) + ) + } + + private func existingConnections() -> [DatabaseConnection] { + [ + postgres(), + DatabaseConnection( + name: "Reports", type: .mssql, host: "reports", port: 1_433, username: "sa", + database: "reporting", sslEnabled: true, sslConfiguration: SSLConfiguration(mode: .verifyCa) + ), + DatabaseConnection( + name: "Ledger", type: .oracle, host: "ledger", port: 1_521, username: "scott", + additionalFields: [ + OracleConnectionOptions.AdditionalFieldKey.connectionType: "service", + OracleConnectionOptions.AdditionalFieldKey.serviceName: "ORCLPDB1" + ] + ), + DatabaseConnection(name: "Local", type: .sqlite, host: "", port: 0, database: "/tmp/local.db"), + DatabaseConnection( + name: "Scratch", type: .duckdb, host: "", port: 0, database: LocalDatabaseLocation.inMemoryPath + ), + DatabaseConnection(name: "Events", type: .duckdb, host: "", port: 0, database: "/tmp/events.duckdb"), + DatabaseConnection( + name: "Tunnelled", type: .mysql, host: "10.0.0.5", port: 3_306, username: "app", + database: "shop", sshEnabled: true, + sshConfiguration: SSHConfiguration( + host: "bastion", port: 22, username: "deploy", authMethod: .privateKey + ) + ) + ] + } + + @Test("A form that has not been touched has no changes") + func untouchedFormsAreClean() { + #expect(form().hasChanges == false) + for connection in existingConnections() { + #expect(form(editing: connection).hasChanges == false, "\(connection.name)") + } + } + + @Test("Every edited field is a change, and putting it back is not") + func editsAreChangesUntilRestored() { + let group = UUID() + let tag = UUID() + let edits: [(String, (ConnectionFormViewModel) -> Void, (ConnectionFormViewModel) -> Void)] = [ + ("name", { $0.name = "Renamed" }, { $0.name = "Prod" }), + ("host", { $0.host = "replica" }, { $0.host = "db.example.com" }), + ("port", { $0.port = "6432" }, { $0.port = "5432" }), + ("username", { $0.username = "admin" }, { $0.username = "app" }), + ("password", { $0.password = "s3cret" }, { $0.password = "" }), + ("database", { $0.database = "analytics" }, { $0.database = "app" }), + ("safe mode", { $0.safeModeLevel = .readOnly }, { $0.safeModeLevel = .off }), + ("group", { $0.groupId = group }, { $0.groupId = nil }), + ("tag", { $0.tagId = tag }, { $0.tagId = nil }), + ("SSL mode", { $0.sslMode = .require }, { $0.sslMode = .verifyFull }), + ("SSH", { $0.sshEnabled = true }, { $0.sshEnabled = false }) + ] + + for (label, change, restore) in edits { + let viewModel = form(editing: postgres()) + change(viewModel) + #expect(viewModel.hasChanges, "\(label)") + restore(viewModel) + #expect(viewModel.hasChanges == false, "\(label)") + } + } + + @Test("Every SSH field is a change while the tunnel is on") + func sshFieldsAreChanges() throws { + let tunnelled = try #require(existingConnections().first { $0.sshEnabled }) + let edits: [(String, (ConnectionFormViewModel) -> Void)] = [ + ("host", { $0.sshHost = "jump" }), + ("port", { $0.sshPort = "2222" }), + ("user", { $0.sshUsername = "ops" }), + ("password", { $0.sshPassword = "tunnel" }), + ("key path", { $0.sshKeyPath = "/keys/id_ed25519" }), + ("key text", { $0.sshKeyContent = "-----BEGIN RSA PRIVATE KEY-----" }), + ("passphrase", { $0.sshKeyPassphrase = "unlock" }) + ] + + for (label, change) in edits { + let viewModel = form(editing: tunnelled) + #expect(viewModel.hasChanges == false, "\(label)") + change(viewModel) + #expect(viewModel.hasChanges, "\(label)") + } + } + + @Test("Changing the Oracle service name is a change") + func oracleServiceNameIsAChange() throws { + let oracle = try #require(existingConnections().first { $0.type == .oracle }) + let viewModel = form(editing: oracle) + + viewModel.oracleServiceName = "ORCLPDB2" + #expect(viewModel.hasChanges) + viewModel.oracleServiceName = "ORCLPDB1" + #expect(viewModel.hasChanges == false) + } + + @Test("Loading the stored secrets is not a change, and editing one is") + func loadedSecretsAreClean() async { + let connection = postgres() + let viewModel = form(editing: connection) + + await viewModel.loadStoredCredentials(secureStore: seededStore(for: connection)) + #expect(viewModel.password == "stored") + #expect(viewModel.hasChanges == false) + + viewModel.password = "" + #expect(viewModel.hasChanges == false, "an empty field keeps the stored password") + + viewModel.password = "rotated" + #expect(viewModel.hasChanges) + #expect(viewModel.changesSecrets) + } + + @Test("A key an older build left in the Keychain is not a change while the connection does not use it") + func unusedStoredKeyIsClean() async { + let unused = [tunnel(authMethod: .password), tunnel(authMethod: .privateKey, enabled: false)] + for connection in unused { + let store = seededStore(for: connection) + store.seed("com.TablePro.sshkeydata.\(connection.id.uuidString)", "LEFTOVER KEY") + let viewModel = form(editing: connection) + + await viewModel.loadStoredCredentials(secureStore: store) + + #expect(viewModel.hasChanges == false, "SSH on: \(connection.sshEnabled)") + #expect(viewModel.changesSecrets == false, "SSH on: \(connection.sshEnabled)") + #expect(viewModel.reconnectsAfterSave == false, "SSH on: \(connection.sshEnabled)") + } + } + + @Test("Saving an edit drops a leftover key the connection does not use") + func saveDropsUnusedStoredKey() async throws { + let connection = tunnel(authMethod: .password) + let keyAccount = "com.TablePro.sshkeydata.\(connection.id.uuidString)" + let store = seededStore(for: connection) + store.seed(keyAccount, "LEFTOVER KEY") + let viewModel = form(editing: connection) + await viewModel.loadStoredCredentials(secureStore: store) + + viewModel.name = "Renamed" + let appState = makeAppState(holding: connection) + _ = try #require(await viewModel.save(appState: appState, secureStore: store)) + + #expect(try store.retrieve(forKey: keyAccount) == nil) + #expect(try store.retrieve(forKey: "com.TablePro.sshpassword.\(connection.id.uuidString)") == "tunnel") + } + + @Test("A save whose Keychain write fails leaves only that secret to discard, and saving again stores it") + func partialSaveKeepsOnlyTheFailedSecretDirty() async throws { + let connection = postgres() + let passwordAccount = "com.TablePro.password.\(connection.id.uuidString)" + let store = seededStore(for: connection) + let appState = fixture.makeState(syncEnabled: false, secureStore: store) + #expect(appState.addConnection(connection)) + let viewModel = form(editing: connection) + await viewModel.loadStoredCredentials(secureStore: store) + viewModel.name = "Renamed" + viewModel.password = "rotated" + store.failNextStore = true + + #expect(await viewModel.save(appState: appState, secureStore: store) == nil) + + #expect(viewModel.credentialError != nil) + #expect(appState.connections.first?.name == "Renamed") + #expect(try store.retrieve(forKey: passwordAccount) == "stored") + #expect(viewModel.hasChanges) + #expect(viewModel.changesSecrets) + + viewModel.password = "" + #expect(viewModel.hasChanges == false) + + viewModel.password = "rotated" + let retriedId = try #require(await viewModel.save(appState: appState, secureStore: store)) + #expect(retriedId == connection.id) + #expect(try store.retrieve(forKey: passwordAccount) == "rotated") + #expect(viewModel.hasChanges == false) + #expect(appState.connections.first?.name == "Renamed") + } + + @Test("A secret that saved is not offered for discard when a later one fails") + func savedSecretIsCleanAfterLaterFailure() async throws { + let connection = tunnel(authMethod: .password) + let tunnelStore = seededStore(for: connection) + let appState = makeAppState(holding: connection) + let viewModel = form(editing: connection) + await viewModel.loadStoredCredentials(secureStore: tunnelStore) + viewModel.password = "rotated" + viewModel.sshPassword = "rotated-tunnel" + tunnelStore.failNextStore = true + + #expect(await viewModel.save(appState: appState, secureStore: tunnelStore) == nil) + + #expect(try appState.secureStore.retrieve(forKey: "com.TablePro.password.\(connection.id.uuidString)") == "rotated") + #expect(viewModel.hasChanges) + + viewModel.sshPassword = "" + #expect(viewModel.hasChanges == false) + } + + @Test("A certificate the store refuses stays a change once the rest is saved") + func refusedCertificateStaysStaged() async { + let connection = postgres() + let appState = makeAppState(holding: connection) + let viewModel = form(editing: connection) + viewModel.name = "Renamed" + viewModel.pastedCertificate = PEMDocument.encode(Data([1, 2, 3]), as: .certificate) + viewModel.importPastedCertificate(role: .certificateAuthority) + certificates.refusesStores = true + + #expect(await viewModel.save(appState: appState, secureStore: MockSecureStore()) == nil) + + #expect(viewModel.credentialError != nil) + #expect(viewModel.changesSecrets) + + viewModel.removeCertificate(.certificateAuthority) + #expect(viewModel.hasChanges == false) + } + + @Test("Switching a new form to another engine and back is not a change") + func typeRoundTripIsClean() { + let viewModel = form() + viewModel.type = .postgresql + #expect(viewModel.hasChanges) + viewModel.type = .mysql + #expect(viewModel.hasChanges == false) + } + + @Test("SSH typed into and then turned off is not a change, since Save would store none of it") + func discardedTunnelIsClean() { + let viewModel = form() + viewModel.sshEnabled = true + viewModel.sshHost = "bastion" + viewModel.sshPassword = "tunnel" + viewModel.sshKeyPassphrase = "unlock" + #expect(viewModel.hasChanges) + + viewModel.sshEnabled = false + #expect(viewModel.hasChanges == false) + } + + @Test("A staged certificate is a change, and removing it before Save is not") + func stagedCertificateIsAChange() { + let viewModel = form(editing: postgres()) + viewModel.pastedCertificate = PEMDocument.encode(Data([1, 2, 3]), as: .certificate) + + viewModel.importPastedCertificate(role: .certificateAuthority) + #expect(viewModel.hasChanges) + #expect(viewModel.changesSecrets) + + viewModel.removeCertificate(.certificateAuthority) + #expect(viewModel.hasChanges == false) + } + + @Test("A stored certificate is clean once loaded and a change once removed") + func storedCertificateRemovalIsAChange() throws { + let connection = postgres() + try certificates.store( + PEMDocument.encode(Data([1, 2, 3]), as: .certificate), + role: .certificateAuthority, + for: connection.id + ) + let viewModel = form(editing: connection) + + viewModel.loadCertificateSummaries() + #expect(viewModel.hasChanges == false) + + viewModel.removeCertificate(.certificateAuthority) + #expect(viewModel.hasChanges) + #expect(viewModel.changesSecrets) + } + + @Test("Saving a rename writes no secret back, so secrets changed on another device survive") + func renameLeavesChangedSecretsAlone() async throws { + let tunnelled = try #require(existingConnections().first { $0.sshEnabled }) + let suffix = tunnelled.id.uuidString + let store = seededStore(for: tunnelled) + let viewModel = form(editing: tunnelled) + await viewModel.loadStoredCredentials(secureStore: store) + + store.seed("com.TablePro.sshpassword.\(suffix)", "rotated-tunnel") + store.seed("com.TablePro.keypassphrase.\(suffix)", "rotated-unlock") + viewModel.name = "Renamed" + + #expect(viewModel.secretWrites == ConnectionFormSecretWrites()) + #expect(viewModel.reconnectsAfterSave == false) + let appState = makeAppState(holding: tunnelled) + _ = try #require(await viewModel.save(appState: appState, secureStore: store)) + + #expect(try store.retrieve(forKey: "com.TablePro.sshpassword.\(suffix)") == "rotated-tunnel") + #expect(try store.retrieve(forKey: "com.TablePro.keypassphrase.\(suffix)") == "rotated-unlock") + #expect(try store.retrieve(forKey: "com.TablePro.sshkeydata.\(suffix)") == nil) + } + + @Test("Only the secrets edited in the form are written") + func editedSecretsAreWritten() async throws { + let tunnelled = try #require(existingConnections().first { $0.sshEnabled }) + let suffix = tunnelled.id.uuidString + let store = seededStore(for: tunnelled) + let viewModel = form(editing: tunnelled) + await viewModel.loadStoredCredentials(secureStore: store) + + viewModel.sshKeyPassphrase = "new-unlock" + viewModel.sshKeyContent = "-----BEGIN RSA PRIVATE KEY-----" + + #expect(viewModel.secretWrites == ConnectionFormSecretWrites(sshKeyPassphrase: "new-unlock")) + #expect(viewModel.pastedPrivateKey == "-----BEGIN RSA PRIVATE KEY-----") + #expect(viewModel.reconnectsAfterSave) + let appState = makeAppState(holding: tunnelled) + _ = try #require(await viewModel.save(appState: appState, secureStore: store)) + + #expect(try store.retrieve(forKey: "com.TablePro.sshpassword.\(suffix)") == "tunnel") + #expect(try store.retrieve(forKey: "com.TablePro.keypassphrase.\(suffix)") == "new-unlock") + #expect(try store.retrieve(forKey: "com.TablePro.sshkeydata.\(suffix)") == "-----BEGIN RSA PRIVATE KEY-----") + } + + @Test("A changed password is written, and one typed back to the stored value is not") + func passwordWriteFollowsTheLoadedValue() async { + let connection = postgres() + let viewModel = form(editing: connection) + await viewModel.loadStoredCredentials(secureStore: seededStore(for: connection)) + + viewModel.password = "rotated" + #expect(viewModel.secretWrites == ConnectionFormSecretWrites(password: "rotated")) + + viewModel.password = "stored" + #expect(viewModel.secretWrites == ConnectionFormSecretWrites()) + } + + @Test("A new connection writes every secret typed into it, and SSH secrets only with SSH on") + func newConnectionWritesTypedSecrets() { + let viewModel = form() + viewModel.password = "secret" + viewModel.sshEnabled = true + viewModel.sshPassword = "tunnel" + + #expect(viewModel.secretWrites == ConnectionFormSecretWrites(password: "secret", sshPassword: "tunnel")) + + viewModel.sshEnabled = false + #expect(viewModel.secretWrites == ConnectionFormSecretWrites(password: "secret")) + } + + @Test("A rename saved over a newer record keeps what changed elsewhere") + func editAppliesOnlyChangedFields() { + let stored = postgres() + let viewModel = form(editing: stored) + viewModel.name = "Renamed" + + var synced = stored + synced.safeModeLevel = .readOnly + synced.isReadOnly = true + synced.isFavorite = true + synced.sortOrder = 9 + let merged = viewModel.applyingEdits(to: synced) + + #expect(merged.name == "Renamed") + #expect(merged.safeModeLevel == .readOnly) + #expect(merged.isReadOnly) + #expect(merged.isFavorite) + #expect(merged.sortOrder == 9) + #expect(merged.dialsTheSameWay(as: synced)) + } + + @Test("Only an edit of a saved connection that changes a secret reconnects its open screen") + func reconnectFollowsSecretChanges() async { + let connection = postgres() + let renamed = form(editing: connection) + await renamed.loadStoredCredentials(secureStore: seededStore(for: connection)) + renamed.name = "Renamed" + #expect(renamed.hasChanges) + #expect(renamed.reconnectsAfterSave == false) + + let rotated = form(editing: connection) + await rotated.loadStoredCredentials(secureStore: seededStore(for: connection)) + rotated.password = "rotated" + #expect(rotated.reconnectsAfterSave) + + let created = form() + created.password = "secret" + #expect(created.changesSecrets) + #expect(created.reconnectsAfterSave == false) + } +} diff --git a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormViewModelPreservationTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormViewModelPreservationTests.swift index 94c7aabdaa..d33403e0a9 100644 --- a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormViewModelPreservationTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormViewModelPreservationTests.swift @@ -50,9 +50,9 @@ struct ConnectionFormViewModelPreservationTests { let other = UUID() let picked = UUID() - #expect(ConnectionFormViewModel.tagIds(selecting: picked, over: [shown, other]) == [picked, other]) - #expect(ConnectionFormViewModel.tagIds(selecting: nil, over: [shown, other]) == [other]) - #expect(ConnectionFormViewModel.tagIds(selecting: other, over: [shown, other]) == [other]) - #expect(ConnectionFormViewModel.tagIds(selecting: shown, over: []) == [shown]) + #expect(ConnectionFormEdits.tagIds(selecting: picked, over: [shown, other]) == [picked, other]) + #expect(ConnectionFormEdits.tagIds(selecting: nil, over: [shown, other]) == [other]) + #expect(ConnectionFormEdits.tagIds(selecting: other, over: [shown, other]) == [other]) + #expect(ConnectionFormEdits.tagIds(selecting: shown, over: []) == [shown]) } } diff --git a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionLibraryEditingTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionLibraryEditingTests.swift index a89d31f6e1..8aff9c1ea0 100644 --- a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionLibraryEditingTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionLibraryEditingTests.swift @@ -35,22 +35,81 @@ struct ConnectionLibraryEditingTests { let member = DatabaseConnection(name: "Member", type: .mysql, groupId: groupId, sortOrder: 3) let editing = DatabaseConnection(name: "Editing", type: .mysql, sortOrder: 1) - var renamed = editing - renamed.name = "Renamed" - let inPlace = try #require(ConnectionLibraryEditing.updating( - renamed, in: [member, editing], validGroupIds: [groupId] - )) + let inPlace = try #require(ConnectionLibraryEditing.mutatingConnection( + editing.id, in: [member, editing], validGroupIds: [groupId] + ) { $0.name = "Renamed" }) - var regrouped = editing - regrouped.groupId = groupId - let moved = try #require(ConnectionLibraryEditing.updating( - regrouped, in: [member, editing], validGroupIds: [groupId] - )) + let moved = try #require(ConnectionLibraryEditing.mutatingConnection( + editing.id, in: [member, editing], validGroupIds: [groupId] + ) { $0.groupId = groupId }) #expect(inPlace.connections.first { $0.id == editing.id }?.sortOrder == 1) + #expect(inPlace.changedConnectionIds == [editing.id]) #expect(moved.connections.first { $0.id == editing.id }?.sortOrder == 4) } + @Test("Editing a connection that is no longer stored changes nothing") + func mutatingMissingConnection() { + let stored = DatabaseConnection(name: "Stored", type: .mysql) + + let change = ConnectionLibraryEditing.mutatingConnection(UUID(), in: [stored], validGroupIds: []) { + $0.name = "Resurrected" + } + + #expect(change == nil) + } + + @Test("An edit that leaves the record as it was reports no changed connection") + func mutatingWithoutChange() throws { + let stored = DatabaseConnection(name: "Stored", type: .mysql, sortOrder: 3) + + let change = try #require(ConnectionLibraryEditing.mutatingConnection( + stored.id, in: [stored], validGroupIds: [] + ) { $0.name = "Stored" }) + + #expect(change.changedConnectionIds.isEmpty) + #expect(change.connections == [stored]) + } + + @Test("A group edit keeps its place unless its parent changes") + func groupEditKeepsSortOrder() throws { + let parent = ConnectionGroup(name: "Parent", sortOrder: 0) + let sibling = ConnectionGroup(name: "Sibling", sortOrder: 0, parentId: parent.id) + let editing = ConnectionGroup(name: "Editing", sortOrder: 5) + let groups = [parent, sibling, editing] + + let renamed = try #require(ConnectionLibraryEditing.mutatingGroup(editing.id, in: groups) { + $0.name = "Renamed" + }) + let moved = try #require(ConnectionLibraryEditing.mutatingGroup(editing.id, in: groups) { + $0.parentId = parent.id + }) + + #expect(renamed.changed) + #expect(renamed.groups.first { $0.id == editing.id }?.sortOrder == 5) + #expect(moved.groups.first { $0.id == editing.id }?.sortOrder == 1) + #expect(ConnectionLibraryEditing.mutatingGroup(UUID(), in: groups) { $0.name = "Gone" } == nil) + } + + @Test("A tag edit touches that one tag") + func mutatingTagTouchesOneTag() throws { + let edited = ConnectionTag(name: "staging", color: .blue) + let other = ConnectionTag(name: "prod", color: .red) + + let result = try #require(ConnectionLibraryEditing.mutatingTag(edited.id, in: [edited, other]) { + $0.name = "stage" + }) + let untouched = try #require(ConnectionLibraryEditing.mutatingTag(edited.id, in: [edited, other]) { + $0.color = .blue + }) + + #expect(result.changed) + #expect(result.tags.map(\.name) == ["stage", "prod"]) + #expect(result.tags.last == other) + #expect(!untouched.changed) + #expect(ConnectionLibraryEditing.mutatingTag(UUID(), in: [edited]) { $0.name = "Gone" } == nil) + } + @Test("Moving before a sibling renumbers that group in the new order") func movingBeforeRenumbers() { let a = DatabaseConnection(name: "A", type: .mysql, sortOrder: 0) @@ -155,8 +214,65 @@ struct ConnectionLibraryEditingTests { let added = try #require(ConnectionLibraryEditing.addingGroup(ConnectionGroup(name: "Sibling", parentId: one.id), to: groups)) #expect(added.last?.sortOrder == 1) - var cyclic = one - cyclic.parentId = two.id - #expect(ConnectionLibraryEditing.updatingGroup(cyclic, in: groups) == nil) + #expect(ConnectionLibraryEditing.mutatingGroup(one.id, in: groups) { $0.parentId = two.id } == nil) + } + + @Test("Deleting a tag drops it and strips it from exactly the connections that carry it") + func deletingTagStripsCarriers() throws { + let doomed = ConnectionTag(name: "Staging") + let other = ConnectionTag(name: "Billing") + let carrier = DatabaseConnection(name: "Carrier", type: .mysql, tagIds: [doomed.id]) + let sharer = DatabaseConnection(name: "Sharer", type: .mysql, tagIds: [other.id, doomed.id]) + let bystander = DatabaseConnection(name: "Bystander", type: .mysql, tagIds: [other.id]) + + let change = try #require(ConnectionLibraryEditing.deletingTag( + doomed.id, + tags: [doomed, other], + connections: [carrier, sharer, bystander] + )) + + #expect(change.tags == [other]) + #expect(change.removedTagId == doomed.id) + #expect(change.connections.map(\.tagIds) == [[], [other.id], [other.id]]) + #expect(change.connections[2] == bystander) + #expect(change.changedConnectionIds == [carrier.id, sharer.id]) + } + + @Test("A built-in or unknown tag cannot be deleted or offered for deletion") + func presetAndUnknownTagsAreRefused() throws { + let preset = try #require(ConnectionTag.presets.first) + let tags = ConnectionTag.presets + let carrier = DatabaseConnection(name: "Carrier", type: .mysql, tagIds: [preset.id]) + + #expect(ConnectionLibraryEditing.deletingTag(preset.id, tags: tags, connections: [carrier]) == nil) + #expect(ConnectionLibraryEditing.tagDeletionRequest(preset.id, tags: tags, connections: [carrier]) == nil) + #expect(ConnectionLibraryEditing.deletingTag(UUID(), tags: tags, connections: [carrier]) == nil) + #expect(ConnectionLibraryEditing.tagDeletionRequest(UUID(), tags: tags, connections: [carrier]) == nil) + } + + @Test("The prompt counts exactly the connections the delete rewrites, a repeated tag once") + func promptCountMatchesTheChange() throws { + let doomed = ConnectionTag(name: "Staging") + let connections = [ + DatabaseConnection(name: "Twice", type: .mysql, tagIds: [doomed.id, doomed.id]), + DatabaseConnection(name: "Once", type: .mysql, tagIds: [doomed.id]), + DatabaseConnection(name: "None", type: .mysql) + ] + + let change = try #require(ConnectionLibraryEditing.deletingTag(doomed.id, tags: [doomed], connections: connections)) + let request = try #require(ConnectionLibraryEditing.tagDeletionRequest(doomed.id, tags: [doomed], connections: connections)) + + #expect(request.tag == doomed) + #expect(request.connectionCount == 2) + #expect(request.connectionCount == change.changedConnectionIds.count) + } + + @Test("The prompt names the tag and how many connections lose it") + func promptMessages() { + let tag = ConnectionTag(name: "Staging") + + #expect(TagDeletionRequest(tag: tag, connectionCount: 0).message == "“Staging” is not on any connection.") + #expect(TagDeletionRequest(tag: tag, connectionCount: 1).message == "“Staging” will be removed from 1 connection.") + #expect(TagDeletionRequest(tag: tag, connectionCount: 3).message == "“Staging” will be removed from 3 connections.") } } diff --git a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionLibraryPublisherTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionLibraryPublisherTests.swift new file mode 100644 index 0000000000..096af79f50 --- /dev/null +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionLibraryPublisherTests.swift @@ -0,0 +1,170 @@ +import CoreSpotlight +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +@MainActor +@Suite("Connection library publisher") +struct ConnectionLibraryPublisherTests { + private final class Recorder { + var shortcutRefreshes = 0 + var widgetWrites: [[WidgetConnectionItem]] = [] + } + + private let index = RecordingSearchIndex() + private let recorder = Recorder() + + private func makePublisher() -> ConnectionLibraryPublisher { + let recorder = recorder + return ConnectionLibraryPublisher( + searchIndex: index, + writeWidgetItems: { recorder.widgetWrites.append($0) }, + refreshShortcutParameters: { recorder.shortcutRefreshes += 1 } + ) + } + + private func connection(_ name: String, host: String = "db.example.com", sortOrder: Int = 0) -> DatabaseConnection { + DatabaseConnection(name: name, type: .postgresql, host: host, port: 5_432, sortOrder: sortOrder) + } + + @Test("Each publish replaces the whole domain, so a removed connection leaves the index") + func publishReplacesTheDomain() async { + let publisher = makePublisher() + let first = connection("A") + let second = connection("B") + + publisher.publish([first, second]) + await publisher.settle() + publisher.publish([first]) + await publisher.settle() + #expect(index.indexedIds == [first.id]) + + publisher.publish([]) + await publisher.settle() + #expect(index.contents.isEmpty) + #expect(index.replacements.count == 3) + } + + @Test("Publishes made in one turn cost one replace, with the newest list") + func burstCoalesces() async { + let publisher = makePublisher() + let first = connection("A") + let second = connection("B") + let third = connection("C") + + publisher.publish([first]) + publisher.publish([first, second]) + publisher.publish([third]) + await publisher.settle() + + let replacements = index.replacements + #expect(replacements.count == 1) + #expect(replacements.first?.map(\.id) == [third.id]) + } + + @Test("A publish during a replace waits its turn, and only the newest of the waiting lists runs") + func replacesNeverOverlap() async { + let publisher = makePublisher() + let first = connection("A") + let second = connection("B") + let third = connection("C") + + index.holdNextReplace() + publisher.publish([first]) + await index.waitUntilHeld() + publisher.publish([first, second]) + publisher.publish([third]) + index.release() + await publisher.settle() + + #expect(index.replacements.map { $0.map(\.id) } == [[first.id], [third.id]]) + #expect(index.maxConcurrent == 1) + #expect(index.indexedIds == [third.id]) + } + + @Test("Reordering or favoriting changes nothing the index or Shortcuts shows") + func presentationOnlyChangesAreSkipped() async { + let publisher = makePublisher() + let first = connection("A") + let second = connection("B") + publisher.publish([first, second]) + await publisher.settle() + + var reordered = first + reordered.sortOrder = 9 + var favorite = second + favorite.isFavorite = true + publisher.publish([favorite, reordered]) + await publisher.settle() + + #expect(index.replacements.count == 1) + #expect(recorder.shortcutRefreshes == 1) + #expect(recorder.widgetWrites.count == 2) + } + + @Test("A failed replace is tried again on the next publish") + func failureRetries() async { + let publisher = makePublisher() + let first = connection("A") + index.failNext() + + publisher.publish([first]) + await publisher.settle() + #expect(index.contents.isEmpty) + + publisher.publish([first]) + await publisher.settle() + #expect(index.replacements.count == 2) + #expect(index.indexedIds == [first.id]) + } + + @Test("Shortcut suggestions refresh on add, rename, host change and delete") + func shortcutRefreshTriggers() async { + let publisher = makePublisher() + let first = connection("A") + publisher.publish([first]) + #expect(recorder.shortcutRefreshes == 1) + + let second = connection("B") + publisher.publish([first, second]) + #expect(recorder.shortcutRefreshes == 2) + + var renamed = second + renamed.name = "Renamed" + publisher.publish([first, renamed]) + #expect(recorder.shortcutRefreshes == 3) + + var rehosted = renamed + rehosted.host = "replica.example.com" + publisher.publish([first, rehosted]) + #expect(recorder.shortcutRefreshes == 4) + + publisher.publish([first]) + #expect(recorder.shortcutRefreshes == 5) + await publisher.settle() + } + + @Test("Widget items sort by order, then name, and a blank name shows the host") + func widgetItemMapping() { + let unnamed = DatabaseConnection(name: "", type: .mysql, host: "cache.local", sortOrder: 1) + let later = connection("Zulu", sortOrder: 1) + let first = connection("Alpha", sortOrder: 0) + + let items = ConnectionLibraryPublisher.widgetItems(for: [later, unnamed, first]) + + #expect(items.map(\.name) == ["Alpha", "cache.local", "Zulu"]) + #expect(items.map(\.sortOrder) == [0, 1, 1]) + } + + @Test("A Spotlight item keeps the identifier and domain earlier builds indexed under") + func spotlightItemFields() { + let searchable = SearchableConnection(connection: connection("Orders")) + let item = SpotlightConnectionIndex.searchableItem(for: searchable) + + #expect(item.uniqueIdentifier == searchable.id.uuidString) + #expect(item.domainIdentifier == "com.TablePro.connections") + #expect(item.attributeSet.title == "Orders") + #expect(item.attributeSet.contentDescription == searchable.summary) + } +} diff --git a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift index 5ec9a7af19..f3b39e4f1b 100644 --- a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift @@ -24,33 +24,6 @@ private final class InMemorySecureStore: SecureStore, @unchecked Sendable { } } -private final class InMemoryCertificateStore: CertificateMaterialStoring, @unchecked Sendable { - private let lock = NSLock() - private var values: [String: String] = [:] - - private func key(_ role: CertificateRole, _ id: UUID) -> String { - "\(id.uuidString).\(role.rawValue)" - } - - func store(_ pem: String, role: CertificateRole, for connectionId: UUID) throws { - lock.withLock { values[key(role, connectionId)] = pem } - } - - func pem(role: CertificateRole, for connectionId: UUID) -> String? { - lock.withLock { values[key(role, connectionId)] } - } - - func delete(role: CertificateRole, for connectionId: UUID) { - _ = lock.withLock { values.removeValue(forKey: key(role, connectionId)) } - } - - func deleteAll(for connectionId: UUID) { - for role in CertificateRole.allCases { - delete(role: role, for: connectionId) - } - } -} - @Suite("Connection secrets") struct ConnectionSecretsTests { private let secureStore = InMemorySecureStore() @@ -88,6 +61,95 @@ struct ConnectionSecretsTests { #expect(bookmarks.bookmark(for: copy) == Data([1, 2, 3])) } + @Test("Every secret a connection owns is covered, under the account names the Keychain already holds") + func prefixesMatchStoredAccounts() { + #expect(ConnectionSecrets.secureStoreKeyPrefixes == [ + "com.TablePro.password.", + "com.TablePro.sshpassword.", + "com.TablePro.keypassphrase.", + "com.TablePro.sshkeydata." + ]) + let id = UUID() + #expect(ConnectionSecretKind.sshPrivateKey.account(for: id) == "com.TablePro.sshkeydata.\(id.uuidString)") + } + + @Test("The orphan sweep takes the passwords of a connection that is gone and nothing else") + func sweepTakesOrphanedPasswordsOnly() { + let kept = UUID() + let removed = UUID() + let owned = ConnectionSecretKind.allCases.flatMap { [$0.account(for: kept), $0.account(for: removed)] } + let foreign = ["com.TablePro.credprofile.\(removed.uuidString)", "com.TablePro.password.not-a-uuid"] + + let orphaned = ConnectionSecretKind.orphanedAccounts(owned + foreign, keeping: [kept]) + + #expect(Set(orphaned) == [ + ConnectionSecretKind.password.account(for: removed), + ConnectionSecretKind.sshPassword.account(for: removed), + ConnectionSecretKind.keyPassphrase.account(for: removed) + ]) + } + + @Test("The orphan sweep never takes a pasted private key, which may be the only copy") + func sweepSparesPastedKeys() { + let account = ConnectionSecretKind.sshPrivateKey.account(for: UUID()) + + #expect(ConnectionSecretKind.orphanedAccounts([account], keeping: [UUID()]).isEmpty) + } + + @Test("The orphan sweep takes nothing before the connections have loaded") + func sweepWaitsForTheLibrary() { + let account = ConnectionSecretKind.password.account(for: UUID()) + + #expect(ConnectionSecretKind.orphanedAccounts([account], keeping: []).isEmpty) + } + + @Test("Moving keys out of the file stores each one the store does not hold yet") + func storesMissingKeys() throws { + let first = UUID() + let second = UUID() + + #expect(secrets.storeMissingPrivateKeys([first: "KEY A", second: "KEY B"]).isEmpty) + + #expect(try secureStore.retrieve(forKey: ConnectionSecretKind.sshPrivateKey.account(for: first)) == "KEY A") + #expect(try secureStore.retrieve(forKey: ConnectionSecretKind.sshPrivateKey.account(for: second)) == "KEY B") + } + + @Test("A key the store already holds is never replaced by the file's copy") + func keepsExistingKey() throws { + let id = UUID() + let account = ConnectionSecretKind.sshPrivateKey.account(for: id) + try secureStore.store("SYNCED KEY", forKey: account) + + #expect(secrets.storeMissingPrivateKeys([id: "FILE KEY"]).isEmpty) + + #expect(try secureStore.retrieve(forKey: account) == "SYNCED KEY") + } + + @Test("An empty stored key is filled from the file") + func fillsEmptyKey() throws { + let id = UUID() + let account = ConnectionSecretKind.sshPrivateKey.account(for: id) + try secureStore.store("", forKey: account) + + #expect(secrets.storeMissingPrivateKeys([id: "FILE KEY"]).isEmpty) + + #expect(try secureStore.retrieve(forKey: account) == "FILE KEY") + } + + @Test("A refused write hands that key back and the other keys still move") + func refusedWriteReturnsKey() throws { + let ids = [UUID(), UUID()].sorted { $0.uuidString < $1.uuidString } + let refusing = MockSecureStore() + refusing.failNextStore = true + let secrets = ConnectionSecrets(secureStore: refusing, certificateStore: certificates, bookmarkStore: bookmarks) + + let unstored = secrets.storeMissingPrivateKeys([ids[0]: "KEY A", ids[1]: "KEY B"]) + + #expect(unstored == [ids[0]: "KEY A"]) + #expect(try refusing.retrieve(forKey: ConnectionSecretKind.sshPrivateKey.account(for: ids[0])) == nil) + #expect(try refusing.retrieve(forKey: ConnectionSecretKind.sshPrivateKey.account(for: ids[1])) == "KEY B") + } + @Test("Deleting one connection's secrets leaves its duplicate's alone") func deleteIsScoped() throws { let source = UUID() diff --git a/TableProMobile/TableProMobileTests/ConnectionLibrary/LibraryFormEditsTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/LibraryFormEditsTests.swift new file mode 100644 index 0000000000..8202ec8f81 --- /dev/null +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/LibraryFormEditsTests.swift @@ -0,0 +1,103 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +@Suite("Group and tag form edits") +struct LibraryFormEditsTests { + @Test("A group rename keeps a color and a parent changed after the sheet opened, and trims the name") + func groupRenameKeepsOtherChanges() { + let parentId = UUID() + let opened = ConnectionGroup(name: "Clients", sortOrder: 2, color: .red) + var current = opened + current.color = .blue + current.parentId = parentId + + let edits = GroupFormEdits(name: " Customers ", color: .red, parentId: nil) + let saved = edits.applied(to: current, changedSince: GroupFormEdits(group: opened)) + + #expect(saved.name == "Customers") + #expect(saved.color == .blue) + #expect(saved.parentId == parentId) + #expect(saved.sortOrder == 2) + } + + @Test("A tag rename keeps a color changed after the sheet opened") + func tagRenameKeepsColor() { + let opened = ConnectionTag(name: "staging", color: .orange) + var current = opened + current.color = .pink + + let edits = TagFormEdits(name: "stage", color: .orange) + let saved = edits.applied(to: current, changedSince: TagFormEdits(tag: opened)) + + #expect(saved.name == "stage") + #expect(saved.color == .pink) + } + + @Test("With nothing to compare against, every field is written") + func nilOpeningWritesEverything() { + let parentId = UUID() + let group = GroupFormEdits(name: "Team", color: .green, parentId: parentId) + .applied(to: ConnectionGroup(), changedSince: nil) + let tag = TagFormEdits(name: "local", color: .yellow) + .applied(to: ConnectionTag(), changedSince: nil) + + #expect(group.name == "Team") + #expect(group.color == .green) + #expect(group.parentId == parentId) + #expect(tag.name == "local") + #expect(tag.color == .yellow) + } + + @Test("A new group opens empty under the group it was created from, and an edited one mirrors its record") + func groupOpeningState() { + let parent = UUID() + let blank = GroupFormEdits(opening: nil, parentId: parent) + #expect(blank.name.isEmpty) + #expect(blank.color == .none) + #expect(blank.parentId == parent) + + let group = ConnectionGroup(name: "Team", color: .blue, parentId: parent) + #expect(GroupFormEdits(opening: group, parentId: nil) == GroupFormEdits(group: group)) + } + + @Test("A new tag opens empty and gray, and an edited one mirrors its record") + func tagOpeningState() { + let blank = TagFormEdits(opening: nil) + #expect(blank.name.isEmpty) + #expect(blank.color == .gray) + + let tag = ConnectionTag(name: "Staging", color: .orange) + #expect(TagFormEdits(opening: tag) == TagFormEdits(tag: tag)) + } + + @Test("A rename, recolor or move differs from where the form opened, and spaces around a group name do not") + func editsDifferFromOpening() { + let tag = TagFormEdits(opening: ConnectionTag(name: "Staging", color: .orange)) + #expect(TagFormEdits(name: "QA", color: .orange) != tag) + #expect(TagFormEdits(name: "Staging", color: .red) != tag) + #expect(TagFormEdits(name: "Staging", color: .orange) == tag) + + let group = GroupFormEdits(opening: ConnectionGroup(name: "Team"), parentId: nil) + #expect(GroupFormEdits(name: "Team", color: group.color, parentId: UUID()) != group) + #expect(GroupFormEdits(name: " Team ", color: group.color, parentId: nil) == group) + #expect(GroupFormEdits(name: " ", color: .none, parentId: nil) == GroupFormEdits(opening: nil, parentId: nil)) + } + + @Test("Only a saved write closes a form without an alert, and only a removed item closes it after one") + func writeOutcomesMapToFailures() throws { + #expect(LibraryWriteFailure(.applied, kind: .group) == nil) + #expect(LibraryWriteFailure(.unchanged, kind: .tag) == nil) + #expect(LibraryWriteFailure(.missing, kind: .tag) == .removed(.tag)) + #expect(LibraryWriteFailure(.refused, kind: .connection) == .libraryUnavailable(.connection)) + #expect(LibraryWriteFailure(.invalidPlacement, kind: .group) == .invalidPlacement) + + let removed = try #require(LibraryWriteFailure(.missing, kind: .group)) + let refused = try #require(LibraryWriteFailure(.refused, kind: .group)) + let misplaced = try #require(LibraryWriteFailure(.invalidPlacement, kind: .group)) + #expect(removed.closesForm) + #expect(!refused.closesForm) + #expect(!misplaced.closesForm) + } +} diff --git a/TableProMobile/TableProMobileTests/ConnectionLibrary/PastedSSHKeyMigrationTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/PastedSSHKeyMigrationTests.swift new file mode 100644 index 0000000000..70353ca84c --- /dev/null +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/PastedSSHKeyMigrationTests.swift @@ -0,0 +1,107 @@ +import Foundation +import TableProModels +import Testing + +@testable import TableProMobile + +@Suite("Pasted SSH key migration") +struct PastedSSHKeyMigrationTests { + private func libraryFile(_ entries: [[String: Any]]) throws -> Data { + try JSONSerialization.data(withJSONObject: entries) + } + + private func entry(_ id: UUID, ssh: [String: Any]?) -> [String: Any] { + var entry: [String: Any] = ["id": id.uuidString, "name": "Conn", "type": "PostgreSQL"] + if let ssh { + entry["sshConfiguration"] = ssh + } + return entry + } + + private func tunnelled() -> DatabaseConnection { + DatabaseConnection( + name: "Bastion", + type: .postgresql, + host: "10.0.0.5", + sshEnabled: true, + sshConfiguration: SSHConfiguration(host: "bastion.example.com", username: "deploy", authMethod: .privateKey) + ) + } + + @Test("Reads each pasted key by connection id") + func readsKeysById() throws { + let first = UUID() + let second = UUID() + let data = try libraryFile([ + entry(first, ssh: ["host": "a", "authMethod": "privateKey", "privateKeyData": "KEY A"]), + entry(second, ssh: ["host": "b", "authMethod": "privateKey", "privateKeyData": "KEY B"]) + ]) + + #expect(PastedSSHKeyMigration.pendingKeys(inLibraryFile: data) == [first: "KEY A", second: "KEY B"]) + } + + @Test("Skips connections with no SSH, an empty key, or only a key file") + func skipsEntriesWithoutKeyText() throws { + let data = try libraryFile([ + entry(UUID(), ssh: nil), + entry(UUID(), ssh: ["host": "a", "authMethod": "privateKey", "privateKeyData": ""]), + entry(UUID(), ssh: ["host": "b", "authMethod": "privateKey", "privateKeyPath": "/keys/id_rsa"]) + ]) + + #expect(PastedSSHKeyMigration.pendingKeys(inLibraryFile: data).isEmpty) + } + + @Test("The first key for a repeated id wins") + func firstKeyWins() throws { + let id = UUID() + let data = try libraryFile([ + entry(id, ssh: ["privateKeyData": "FIRST"]), + entry(id, ssh: ["privateKeyData": "SECOND"]) + ]) + + #expect(PastedSSHKeyMigration.pendingKeys(inLibraryFile: data) == [id: "FIRST"]) + } + + @Test("A file it cannot read yields nothing to migrate") + func unreadableFile() { + #expect(PastedSSHKeyMigration.pendingKeys(inLibraryFile: Data("{ not json".utf8)).isEmpty) + #expect(PastedSSHKeyMigration.pendingKeys(inLibraryFile: Data("[]".utf8)).isEmpty) + } + + @Test("A key kept in the file reads back as pending, and the file still decodes to the same connections") + func keptKeyRoundTrips() throws { + let connections = [tunnelled(), DatabaseConnection(name: "Local", type: .mysql)] + let encoded = try JSONEncoder().encode(connections) + + let written = try PastedSSHKeyMigration.libraryFile(encoded, keeping: [connections[0].id: "KEY A"]) + + #expect(PastedSSHKeyMigration.pendingKeys(inLibraryFile: written) == [connections[0].id: "KEY A"]) + #expect(try JSONDecoder().decode([DatabaseConnection].self, from: written) == connections) + } + + @Test("With nothing to keep, the encoded file is written as it is") + func nothingToKeep() throws { + let encoded = try JSONEncoder().encode([tunnelled()]) + + #expect(try PastedSSHKeyMigration.libraryFile(encoded, keeping: [:]) == encoded) + } + + @Test("A key has nowhere to go without its connection or that connection's SSH settings") + func keyNeedsItsConnection() throws { + let plain = DatabaseConnection(name: "Local", type: .mysql) + let encoded = try JSONEncoder().encode([plain]) + + let written = try PastedSSHKeyMigration.libraryFile(encoded, keeping: [plain.id: "KEY A", UUID(): "KEY B"]) + + #expect(PastedSSHKeyMigration.pendingKeys(inLibraryFile: written).isEmpty) + } + + @Test("Only keys whose connection still has SSH settings are still held") + func keysStillHeld() { + let kept = tunnelled() + let plain = DatabaseConnection(name: "Local", type: .mysql) + let keys = [kept.id: "KEY A", plain.id: "KEY B", UUID(): "KEY C"] + + #expect(PastedSSHKeyMigration.keysStillHeld(keys, by: [kept, plain]) == [kept.id: "KEY A"]) + } +} diff --git a/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift b/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift index 3f30e1e293..977fbe4eed 100644 --- a/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift +++ b/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift @@ -279,4 +279,86 @@ struct DataBrowserViewModelTests { #expect(vm.pagination.pageSize == 50) #expect(vm.pagination.currentPage == 0) } + + private func emptyResult() -> Result { + .success(QueryResult(columns: makeColumns(), rows: [], rowsAffected: 0, executionTime: 0)) + } + + @Test("The page bar stays hidden for an empty table and shows once rows load") + func pageBarFollowsRows() async { + let driver = MockDatabaseDriver() + driver.scriptedColumns = makeColumns() + let vm = DataBrowserViewModel() + #expect(vm.showsPaginationBar == false) + + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + driver.scriptedExecuteResults = [emptyResult()] + await vm.load(isInitial: true) + #expect(vm.showsPaginationBar == false) + + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["1", "Alice"]], rowsAffected: 0, executionTime: 0)) + ] + await vm.load(isInitial: true) + #expect(vm.showsPaginationBar) + } + + @Test("The page bar stays up when a search finds nothing, so the search can be paged back out of") + func pageBarSurvivesEmptySearch() async { + let driver = MockDatabaseDriver() + driver.scriptedColumns = makeColumns() + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + driver.scriptedExecuteResults = [emptyResult()] + await vm.load(isInitial: true) + + driver.scriptedExecuteResults = [emptyResult()] + await vm.applySearch("nobody") + + #expect(vm.legacyRows.isEmpty) + #expect(vm.showsPaginationBar) + } + + @Test("The page bar stays up when an enabled filter matches nothing") + func pageBarSurvivesEmptyFilter() async { + let driver = MockDatabaseDriver() + driver.scriptedColumns = makeColumns() + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + driver.scriptedExecuteResults = [emptyResult()] + await vm.load(isInitial: true) + + vm.filters = [TableFilter(columnName: "name", value: "nobody")] + driver.scriptedExecuteResults = [emptyResult()] + await vm.applyFilters() + + #expect(vm.legacyRows.isEmpty) + #expect(vm.showsPaginationBar) + } + + @Test("Page steps are offered only where a page exists") + func pageStepsFollowPosition() async { + let driver = MockDatabaseDriver() + driver.scriptedColumns = makeColumns() + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + #expect(vm.canGoToPreviousPage == false) + + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["1", "Alice"], ["2", "Bob"]], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["3"]], rowsAffected: 0, executionTime: 0)) + ] + await vm.changePageSize(2) + #expect(vm.pagination.totalRows == 3) + #expect(vm.canGoToPreviousPage == false) + #expect(vm.canGoToNextPage) + + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["3", "Carol"]], rowsAffected: 0, executionTime: 0)) + ] + await vm.goToNextPage() + #expect(vm.pagination.currentPage == 1) + #expect(vm.canGoToPreviousPage) + #expect(vm.canGoToNextPage == false) + } } diff --git a/TableProMobile/TableProMobileTests/DatabaseFileErrorClassifierTests.swift b/TableProMobile/TableProMobileTests/DatabaseFileErrorClassifierTests.swift new file mode 100644 index 0000000000..97f21a1604 --- /dev/null +++ b/TableProMobile/TableProMobileTests/DatabaseFileErrorClassifierTests.swift @@ -0,0 +1,20 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +@Suite("Database file error classification") +struct DatabaseFileErrorClassifierTests { + @Test("A database file that is not on this device is a configuration problem with a way out") + func unavailableFileIsConfiguration() { + let error = ErrorClassifier.classify( + LocalDatabaseFileError.unavailable(fileName: "notes.db", reason: .notOnThisDevice), + context: ErrorContext(operation: "connect", databaseType: .sqlite) + ) + + #expect(error.category == .config) + #expect(error.title == String(localized: "Database File Unavailable")) + #expect(error.message == String(format: String(localized: "“%@” isn't available on this device."), "notes.db")) + #expect(error.recovery == String(localized: "Edit the connection and choose the database file again.")) + } +} diff --git a/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverSuspensionTests.swift b/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverSuspensionTests.swift index 2417d13e73..a44692f2c1 100644 --- a/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverSuspensionTests.swift +++ b/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverSuspensionTests.swift @@ -1,15 +1,15 @@ -import XCTest @testable import TableProMobile +import XCTest final class DuckDBDriverSuspensionTests: XCTestCase { func testInMemoryDatabaseHoldsNoSuspensionBlockingResource() { - let driver = DuckDBDriver(path: DuckDBDriver.inMemoryPath, bookmark: nil) + let driver = DuckDBDriver(source: .inMemory) XCTAssertFalse(driver.holdsSuspensionBlockingResource) } func testFileBackedDatabaseHoldsSuspensionBlockingResource() { - let driver = DuckDBDriver(path: "/tmp/analytics.duckdb", bookmark: nil) + let driver = DuckDBDriver(source: .file(URL(fileURLWithPath: "/tmp/analytics.duckdb"))) XCTAssertTrue(driver.holdsSuspensionBlockingResource) } diff --git a/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverTests.swift b/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverTests.swift index ba38ca8987..79161f5668 100644 --- a/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverTests.swift +++ b/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverTests.swift @@ -1,17 +1,55 @@ -import XCTest import TableProDatabase -import TableProModels @testable import TableProMobile +import TableProModels +import XCTest final class DuckDBDriverTests: XCTestCase { private var driver: DuckDBDriver? override func setUp() async throws { - let driver = DuckDBDriver(path: DuckDBDriver.inMemoryPath, bookmark: nil) + let driver = DuckDBDriver(source: .inMemory) try await driver.connect() self.driver = driver } + func testMissingFileIsAnErrorAndCreatesNothing() async throws { + let missing = FileManager.default.temporaryDirectory + .appendingPathComponent("duckdb-missing-\(UUID().uuidString).duckdb") + let fileDriver = DuckDBDriver(source: .file(missing)) + + do { + try await fileDriver.connect() + XCTFail("Opening a missing DuckDB file must fail") + } catch { + XCTAssertEqual( + error as? LocalDatabaseFileError, + .unavailable(fileName: missing.lastPathComponent, reason: .missing) + ) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: missing.path)) + } + + func testCreatedDatabaseReopensWithItsTable() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("duckdb-create-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("cube.duckdb") + + let creator = DuckDBDriver(source: .file(file), openMode: .createNew) + try await creator.connect() + _ = try await creator.execute(query: "CREATE TABLE facts (id INTEGER, label VARCHAR)") + _ = try await creator.execute(query: "INSERT INTO facts VALUES (1, 'kept')") + try await creator.disconnect() + + let reopened = DuckDBDriver(source: .file(file)) + try await reopened.connect() + let result = try await reopened.execute(query: "SELECT label FROM facts") + try await reopened.disconnect() + + XCTAssertEqual(result.rows.first?.first ?? nil, "kept") + } + override func tearDown() async throws { try await driver?.disconnect() driver = nil diff --git a/TableProMobile/TableProMobileTests/Drivers/SQLiteDriverFileTests.swift b/TableProMobile/TableProMobileTests/Drivers/SQLiteDriverFileTests.swift new file mode 100644 index 0000000000..ba519e2276 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Drivers/SQLiteDriverFileTests.swift @@ -0,0 +1,45 @@ +import Foundation +import TableProDatabase +@testable import TableProMobile +import TableProModels +import Testing + +@Suite("SQLite driver file handling") +struct SQLiteDriverFileTests { + private let directory: URL + + init() throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("sqlite-driver-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + @Test("Opening a file that is not there fails and creates nothing") + func missingFileIsAnError() async { + let missing = directory.appendingPathComponent("gone.db") + let driver = SQLiteDriver(source: .file(missing)) + + await #expect(throws: LocalDatabaseFileError.unavailable(fileName: "gone.db", reason: .missing)) { + try await driver.connect() + } + #expect(!FileManager.default.fileExists(atPath: missing.path)) + } + + @Test("A database created new reopens later with its table intact") + func createdDatabaseReopens() async throws { + let file = directory.appendingPathComponent("scratch.db") + let creator = SQLiteDriver(source: .file(file), openMode: .createNew) + try await creator.connect() + _ = try await creator.execute(query: "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT)") + _ = try await creator.execute(query: "INSERT INTO notes (body) VALUES ('kept')") + try await creator.disconnect() + + let reopened = SQLiteDriver(source: .file(file)) + try await reopened.connect() + let result = try await reopened.execute(query: "SELECT body FROM notes") + try await reopened.disconnect() + let body = result.rows.first?.first ?? nil + + #expect(body == "kept") + } +} diff --git a/TableProMobile/TableProMobileTests/Helpers/ConfirmedWriteGateTests.swift b/TableProMobile/TableProMobileTests/Helpers/ConfirmedWriteGateTests.swift new file mode 100644 index 0000000000..d3e228fda4 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Helpers/ConfirmedWriteGateTests.swift @@ -0,0 +1,53 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +@Suite("Confirmed write gate") +struct ConfirmedWriteGateTests { + private let statement = "INSERT INTO t (a) VALUES (1)" + + @Test("Safe mode off runs the write at once and keeps nothing pending") + func offRunsAtOnce() { + var gate = ConfirmedWriteGate() + + #expect(gate.submit(statement, under: .off) == .run(statement)) + #expect(gate.pendingStatement == nil) + } + + @Test("Read-only blocks the write and keeps nothing pending") + func readOnlyBlocks() { + var gate = ConfirmedWriteGate() + + #expect(gate.submit(statement, under: .readOnly) == .blocked) + #expect(gate.pendingStatement == nil) + } + + @Test("Confirm Writes holds the write until it is confirmed, and hands it over once") + func confirmWritesWaitsForConfirmation() { + var gate = ConfirmedWriteGate() + + #expect(gate.submit(statement, under: .confirmWrites) == .awaitConfirmation) + #expect(gate.confirm(under: .confirmWrites) == statement) + #expect(gate.confirm(under: .confirmWrites) == nil) + } + + @Test("A write confirmed after safe mode turned read-only does not run") + func readOnlyAfterConfirmationBlocks() { + var gate = ConfirmedWriteGate() + #expect(gate.submit(statement, under: .confirmWrites) == .awaitConfirmation) + + #expect(gate.confirm(under: .readOnly) == nil) + #expect(gate.pendingStatement == nil) + } + + @Test("A cancelled confirmation leaves nothing to run") + func cancelDropsThePendingWrite() { + var gate = ConfirmedWriteGate() + #expect(gate.submit(statement, under: .confirmWrites) == .awaitConfirmation) + + gate.cancel() + + #expect(gate.confirm(under: .off) == nil) + } +} diff --git a/TableProMobile/TableProMobileTests/IOSConnectionExportFileDataTests.swift b/TableProMobile/TableProMobileTests/IOSConnectionExportFileDataTests.swift new file mode 100644 index 0000000000..2710b3525c --- /dev/null +++ b/TableProMobile/TableProMobileTests/IOSConnectionExportFileDataTests.swift @@ -0,0 +1,94 @@ +import Foundation +import TableProImport +@testable import TableProMobile +import Testing + +@MainActor +@Suite("Connection export file data") +struct IOSConnectionExportFileDataTests { + private func makeEnvelope(password: String?) -> ConnectionExportEnvelope { + let connection = ExportableConnection( + name: "Prod", host: "db.example.com", port: 5_432, database: "app", username: "admin", + type: "PostgreSQL", sshConfig: nil, sslConfig: nil, color: nil, tagName: nil, groupName: nil, + sshProfileId: nil, safeModeLevel: nil, aiPolicy: nil, additionalFields: nil, + redisDatabase: nil, startupCommands: nil, localOnly: nil + ) + let credentials = password.map { password in + [ + "0": ExportableCredentials( + password: password, sshPassword: nil, keyPassphrase: nil, + sslClientKeyPassphrase: nil, totpSecret: nil, pluginSecureFields: nil + ) + ] + } + return ConnectionExportEnvelope( + formatVersion: 1, exportedAt: Date(timeIntervalSince1970: 0), appVersion: "1.0", + connections: [connection], groups: nil, tags: nil, credentials: credentials + ) + } + + @Test("A file without passwords and without a passphrase is plain JSON") + func plainFileWithoutCredentials() async throws { + let data = try await IOSConnectionExportService.fileData(for: makeEnvelope(password: nil), passphrase: nil) + + #expect(!ConnectionExportCrypto.isEncrypted(data)) + let decoded = try ConnectionImportDecoder.decodeData(data) + #expect(decoded.connections.map(\.name) == ["Prod"]) + #expect(decoded.credentials == nil) + } + + @Test("Passwords are never written without a passphrase", arguments: [nil, ""] as [String?]) + func credentialsNeedPassphrase(passphrase: String?) async { + await #expect(throws: IOSConnectionExportService.ExportError.credentialsNeedPassphrase) { + try await IOSConnectionExportService.fileData(for: makeEnvelope(password: "s3cret"), passphrase: passphrase) + } + } + + @Test("A passphrase seals the file and the same passphrase opens it with its passwords") + func sealedFileRoundTrips() async throws { + let data = try await IOSConnectionExportService.fileData( + for: makeEnvelope(password: "s3cret"), + passphrase: "correct horse" + ) + + #expect(ConnectionExportCrypto.isEncrypted(data)) + let decoded = try await ConnectionImportDecoder.decodeEncryptedData(data, passphrase: "correct horse") + #expect(decoded.credentials?["0"]?.password == "s3cret") + } + + @Test("A sealed file refuses the wrong passphrase") + func wrongPassphraseIsRefused() async throws { + let data = try await IOSConnectionExportService.fileData( + for: makeEnvelope(password: "s3cret"), + passphrase: "correct horse" + ) + + await #expect(throws: ConnectionExportError.self) { + try await ConnectionImportDecoder.decodeEncryptedData(data, passphrase: "wrong horse") + } + } + + @Test("Sealing a file with a passphrase leaves the main actor free") + func sealingLeavesMainActorFree() async throws { + let envelope = makeEnvelope(password: "s3cret") + let probe = SealingProbe() + let task = Task { @MainActor in + probe.hasStarted = true + _ = try await IOSConnectionExportService.fileData(for: envelope, passphrase: "correct horse") + probe.hasFinished = true + } + while !probe.hasStarted { + await Task.yield() + } + + #expect(!probe.hasFinished) + try await task.value + #expect(probe.hasFinished) + } +} + +@MainActor +private final class SealingProbe { + var hasStarted = false + var hasFinished = false +} diff --git a/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift b/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift new file mode 100644 index 0000000000..4c5f48de17 --- /dev/null +++ b/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift @@ -0,0 +1,67 @@ +import Foundation +import TableProImport +@testable import TableProMobile +import TableProModels +import Testing + +@MainActor +@Suite("iOS connection export") +struct IOSConnectionExportServiceTests { + private let fixture: AppStateFixture + private let keyMarker = "b3BlbnNzaC1rZXktdjEAAAAABG5vbmU" + + init() throws { + fixture = try AppStateFixture() + } + + private func makeState(secureStore: MockSecureStore) -> AppState { + fixture.makeState(syncEnabled: false, secureStore: secureStore) + } + + private func exportedText(includeCredentials: Bool) async throws -> String { + let store = MockSecureStore() + let state = makeState(secureStore: store) + let connection = DatabaseConnection( + name: "Bastion", + type: .postgresql, + host: "10.0.0.5", + sshEnabled: true, + sshConfiguration: SSHConfiguration(host: "bastion.example.com", username: "deploy", authMethod: .privateKey) + ) + #expect(state.addConnection(connection)) + store.seed("com.TablePro.password.\(connection.id.uuidString)", "db-secret") + store.seed( + "com.TablePro.sshkeydata.\(connection.id.uuidString)", + "-----BEGIN OPENSSH PRIVATE KEY-----\n\(keyMarker)\n-----END OPENSSH PRIVATE KEY-----" + ) + + let passphrase = includeCredentials ? "export-passphrase" : nil + let data = try await IOSConnectionExportService.exportData( + connections: state.connections, + appState: state, + includeCredentials: includeCredentials, + passphrase: passphrase + ) + guard let passphrase else { + return try #require(String(data: data, encoding: .utf8)) + } + #expect(ConnectionExportCrypto.isEncrypted(data)) + let decrypted = try await ConnectionExportCrypto.decrypt(data: data, passphrase: passphrase) + return try #require(String(data: decrypted, encoding: .utf8)) + } + + @Test("An export without credentials never carries a stored private key") + func plainExportOmitsKey() async throws { + let text = try await exportedText(includeCredentials: false) + #expect(text.contains("bastion.example.com")) + #expect(!text.contains(keyMarker)) + #expect(!text.contains("db-secret")) + } + + @Test("An export with credentials carries the password but never the private key") + func credentialExportOmitsKey() async throws { + let text = try await exportedText(includeCredentials: true) + #expect(text.contains("db-secret")) + #expect(!text.contains(keyMarker)) + } +} diff --git a/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift b/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift index db758ad80a..3960834503 100644 --- a/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift +++ b/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift @@ -81,3 +81,77 @@ struct IOSConnectionImportServiceTests { #expect(IOSConnectionExportService.suggestedFilename(for: [a, b]) == "TablePro Connections.tablepro") } } + +@MainActor +@Suite("iOS connection import replace") +struct IOSConnectionImportReplaceTests { + private let fixture: AppStateFixture + private let store = MockSecureStore() + private let appState: AppState + private let existing = DatabaseConnection( + name: "Bastion", + type: .postgresql, + host: "db.example.com", + port: 5_432, + sshEnabled: true, + sshConfiguration: SSHConfiguration(host: "bastion.example.com", username: "deploy", authMethod: .privateKey) + ) + + init() throws { + fixture = try AppStateFixture() + appState = fixture.makeState(syncEnabled: false, secureStore: store) + } + + private var keyAccount: String { + ConnectionSecretKind.sshPrivateKey.account(for: existing.id) + } + + private func tunnel(authMethod: String, keyPath: String = "") -> ExportableSSHConfig { + ExportableSSHConfig( + enabled: true, host: "bastion.example.com", port: 22, username: "deploy", + authMethod: authMethod, privateKeyPath: keyPath, agentSocketPath: "", jumpHosts: nil, + totpMode: nil, totpAlgorithm: nil, totpDigits: nil, totpPeriod: nil + ) + } + + private func replaceExisting(with ssh: ExportableSSHConfig?) -> Int { + let imported = ExportableConnection( + name: existing.name, host: existing.host, port: existing.port, database: "", username: "", + type: DatabaseType.postgresql.rawValue, sshConfig: ssh, sslConfig: nil, color: nil, tagName: nil, + groupName: nil, sshProfileId: nil, safeModeLevel: nil, aiPolicy: nil, additionalFields: nil, + redisDatabase: nil, startupCommands: nil, localOnly: nil + ) + let item = ImportItem( + connection: imported, + status: .duplicate(existingId: existing.id, existingName: existing.name) + ) + let envelope = ConnectionExportEnvelope( + formatVersion: 1, exportedAt: Date(), appVersion: "Tests", + connections: [imported], groups: nil, tags: nil, credentials: nil + ) + return IOSConnectionImportService.performImport( + ConnectionImportPreview(envelope: envelope, items: [item]), + resolutions: [item.id: .replace(existingId: existing.id)], + appState: appState + ).importedCount + } + + @Test("Replacing a connection keeps its pasted key, whatever tunnel the import brings") + func replaceKeepsPastedKey() throws { + #expect(appState.addConnection(existing)) + let incoming: [ExportableSSHConfig?] = [ + tunnel(authMethod: "privateKey"), + tunnel(authMethod: "privateKey", keyPath: "~/.ssh/id_ed25519"), + tunnel(authMethod: "password"), + nil + ] + + for ssh in incoming { + store.seed(keyAccount, "PASTED KEY") + + #expect(replaceExisting(with: ssh) == 1) + + #expect(try store.retrieve(forKey: keyAccount) == "PASTED KEY", "\(ssh?.authMethod ?? "no tunnel")") + } + } +} diff --git a/TableProMobile/TableProMobileTests/Mocks/InMemoryCertificateStore.swift b/TableProMobile/TableProMobileTests/Mocks/InMemoryCertificateStore.swift new file mode 100644 index 0000000000..ae629f96f1 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Mocks/InMemoryCertificateStore.swift @@ -0,0 +1,33 @@ +import Foundation +@testable import TableProMobile + +final class InMemoryCertificateStore: CertificateMaterialStoring, @unchecked Sendable { + enum StoreError: Error { case refused } + + private let lock = NSLock() + private var values: [String: String] = [:] + var refusesStores = false + + private func key(_ role: CertificateRole, _ id: UUID) -> String { + "\(id.uuidString).\(role.rawValue)" + } + + func store(_ pem: String, role: CertificateRole, for connectionId: UUID) throws { + guard !refusesStores else { throw StoreError.refused } + lock.withLock { values[key(role, connectionId)] = pem } + } + + func pem(role: CertificateRole, for connectionId: UUID) -> String? { + lock.withLock { values[key(role, connectionId)] } + } + + func delete(role: CertificateRole, for connectionId: UUID) { + _ = lock.withLock { values.removeValue(forKey: key(role, connectionId)) } + } + + func deleteAll(for connectionId: UUID) { + for role in CertificateRole.allCases { + delete(role: role, for: connectionId) + } + } +} diff --git a/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift b/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift index a26dcef849..12e17ab327 100644 --- a/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift +++ b/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift @@ -90,8 +90,12 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { final class MockSecureStore: SecureStore, @unchecked Sendable { private var storage: [String: String] = [:] var failNextStore = false + var refusesStores = false func store(_ value: String, forKey key: String) throws { + if refusesStores { + throw MockDatabaseDriver.MockError.scripted + } if failNextStore { failNextStore = false throw MockDatabaseDriver.MockError.scripted diff --git a/TableProMobile/TableProMobileTests/Mocks/RecordingSearchIndex.swift b/TableProMobile/TableProMobileTests/Mocks/RecordingSearchIndex.swift new file mode 100644 index 0000000000..3b2a8bdf14 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Mocks/RecordingSearchIndex.swift @@ -0,0 +1,61 @@ +import Foundation +@testable import TableProMobile + +@MainActor +final class RecordingSearchIndex: ConnectionSearchIndexing { + enum Failure: Error { + case scripted + } + + private(set) var replacements: [[SearchableConnection]] = [] + private(set) var contents: [SearchableConnection] = [] + private(set) var maxConcurrent = 0 + private var inFlight = 0 + private var failuresRemaining = 0 + private var holdsNext = false + private var held: CheckedContinuation? + private var heldWaiter: CheckedContinuation? + + var indexedIds: Set { + Set(contents.map(\.id)) + } + + func failNext() { + failuresRemaining += 1 + } + + func holdNextReplace() { + holdsNext = true + } + + func waitUntilHeld() async { + guard held == nil else { return } + await withCheckedContinuation { heldWaiter = $0 } + } + + func release() { + held?.resume() + held = nil + } + + @MainActor + func replaceConnections(with connections: [SearchableConnection]) async throws { + inFlight += 1 + maxConcurrent = max(maxConcurrent, inFlight) + defer { inFlight -= 1 } + replacements.append(connections) + if holdsNext { + holdsNext = false + await withCheckedContinuation { continuation in + held = continuation + heldWaiter?.resume() + heldWaiter = nil + } + } + if failuresRemaining > 0 { + failuresRemaining -= 1 + throw Failure.scripted + } + contents = connections + } +} diff --git a/TableProMobile/TableProMobileTests/Mocks/UnreachableSyncTransport.swift b/TableProMobile/TableProMobileTests/Mocks/UnreachableSyncTransport.swift new file mode 100644 index 0000000000..371d6c9dd3 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Mocks/UnreachableSyncTransport.swift @@ -0,0 +1,29 @@ +import CloudKit +import Foundation +@testable import TableProMobile +import TableProSync +import TableProSyncTransport + +struct UnreachableSyncTransport: IOSSyncTransport { + var currentZoneID: CKRecordZone.ID { + get async { CKRecordZone.ID(zoneName: "Unused", ownerName: CKCurrentUserDefaultName) } + } + + func accountStatus() async throws -> CKAccountStatus { + .noAccount + } + + func currentAccountId() async throws -> String { + throw CKError(.notAuthenticated) + } + + func ensureZoneExists() async throws {} + + func pull(since token: CKServerChangeToken?) async throws -> PullResult { + PullResult(changedRecords: [], deletedRecordIDs: [], newToken: nil) + } + + func push(records: [CKRecord], deletions: [CKRecord.ID]) async throws -> PushOutcome { + PushOutcome(savedRecords: [:], deletedRecordIDs: []) + } +} diff --git a/TableProMobile/TableProMobileTests/Onboarding/AppStateFixture.swift b/TableProMobile/TableProMobileTests/Onboarding/AppStateFixture.swift new file mode 100644 index 0000000000..3bbb45d740 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Onboarding/AppStateFixture.swift @@ -0,0 +1,116 @@ +import Foundation +import TableProDatabase +@testable import TableProMobile +import TableProModels +import TableProSync +import TableProSyncTransport +import Testing + +@MainActor +struct AppStateFixture { + let root: URL + let libraryDirectory: URL + let documentsDirectory: URL + let defaults: UserDefaults + let metadata: SyncMetadataStorage + let bookmarkStore: FileBookmarkStore + let containerHistory: AppContainerHistory + let bundledSample: URL + + var container: AppContainerPaths { + AppContainerPaths(documentsDirectory: documentsDirectory, history: containerHistory) + } + + var localFiles: LocalDatabaseFileLocator { + LocalDatabaseFileLocator(container: container) + } + + var connectionsFile: URL { + libraryDirectory.appendingPathComponent("connections.json") + } + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("app-state-\(UUID().uuidString)", isDirectory: true) + libraryDirectory = root.appendingPathComponent("Library", isDirectory: true) + documentsDirectory = root + .appendingPathComponent("Data/Application/\(UUID().uuidString)/Documents", isDirectory: true) + try FileManager.default.createDirectory(at: libraryDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: documentsDirectory, withIntermediateDirectories: true) + let suffix = UUID().uuidString + defaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.AppState.\(suffix)")) + metadata = SyncMetadataStorage(userDefaults: defaults) + bookmarkStore = FileBookmarkStore(suiteName: "com.TablePro.tests.Bookmarks.\(suffix)") + containerHistory = AppContainerHistory(suiteName: "com.TablePro.tests.Containers.\(suffix)") + bundledSample = root.appendingPathComponent("Chinook.sqlite") + try Data("sample".utf8).write(to: bundledSample) + } + + func makeState( + syncEnabled: Bool, + secureStore: any SecureStore = MockSecureStore(), + libraryPublisher: ConnectionLibraryPublisher? = nil + ) -> AppState { + let coordinator = IOSSyncCoordinator( + metadata: metadata, + recordCache: SyncRecordCache(directory: root.appendingPathComponent("Cache"), defaults: nil), + makeTransport: { UnreachableSyncTransport() }, + isEnabled: { syncEnabled }, + notificationCenter: NotificationCenter() + ) + return AppState( + libraryDirectory: libraryDirectory, + defaults: defaults, + secureStore: secureStore, + syncCoordinator: coordinator, + sampleInstaller: SampleDatabaseInstaller( + bundledURL: bundledSample, + directory: root.appendingPathComponent("Samples", isDirectory: true) + ), + localDatabaseFiles: localFiles, + bookmarkStore: bookmarkStore, + libraryPublisher: libraryPublisher ?? ConnectionLibraryPublisher( + searchIndex: RecordingSearchIndex(), + writeWidgetItems: { _ in }, + refreshShortcutParameters: {} + ) + ) + } + + func makeFormViewModel( + editing connection: DatabaseConnection? = nil, + certificateStore: any CertificateMaterialStoring = InMemoryCertificateStore() + ) -> ConnectionFormViewModel { + ConnectionFormViewModel( + editing: connection, + localFiles: localFiles, + fileCreator: DriverDatabaseFileCreator(), + bookmarkStore: bookmarkStore, + certificateStore: certificateStore + ) + } + + func earlierContainerPath(to relativePath: String) -> String { + let containerId = UUID().uuidString + containerHistory.record(containerId) + return containerPath(containerId, to: relativePath) + } + + func otherInstallContainerPath(to relativePath: String) -> String { + containerPath(UUID().uuidString, to: relativePath) + } + + private func containerPath(_ containerId: String, to relativePath: String) -> String { + documentsDirectory + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent(containerId, isDirectory: true) + .appendingPathComponent("Documents", isDirectory: true) + .appendingPathComponent(relativePath) + .path + } + + func documentsFile(_ name: String) -> URL { + documentsDirectory.appendingPathComponent(name) + } +} diff --git a/TableProMobile/TableProMobileTests/Onboarding/AppStateLibraryTests.swift b/TableProMobile/TableProMobileTests/Onboarding/AppStateLibraryTests.swift index f5baf2279e..376668ddf7 100644 --- a/TableProMobile/TableProMobileTests/Onboarding/AppStateLibraryTests.swift +++ b/TableProMobile/TableProMobileTests/Onboarding/AppStateLibraryTests.swift @@ -1,5 +1,5 @@ -import CloudKit import Foundation +import TableProDatabase @testable import TableProMobile import TableProModels import TableProSync @@ -9,56 +9,55 @@ import Testing @MainActor @Suite("App state library writes") struct AppStateLibraryTests { - private let root: URL - private let libraryDirectory: URL - private let defaults: UserDefaults - private let metadata: SyncMetadataStorage - private let bundledSample: URL + private let fixture: AppStateFixture + private let searchIndex = RecordingSearchIndex() + private let widgetWrites = WidgetWriteCounter() + + private var metadata: SyncMetadataStorage { fixture.metadata } init() throws { - root = FileManager.default.temporaryDirectory - .appendingPathComponent("app-state-\(UUID().uuidString)", isDirectory: true) - libraryDirectory = root.appendingPathComponent("Library", isDirectory: true) - try FileManager.default.createDirectory(at: libraryDirectory, withIntermediateDirectories: true) - defaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.AppState.\(UUID().uuidString)")) - metadata = SyncMetadataStorage(userDefaults: defaults) - bundledSample = root.appendingPathComponent("Chinook.sqlite") - try Data("sample".utf8).write(to: bundledSample) - } - - private func makeState(syncEnabled: Bool) -> AppState { - let coordinator = IOSSyncCoordinator( - metadata: metadata, - recordCache: SyncRecordCache(directory: root.appendingPathComponent("Cache"), defaults: nil), - makeTransport: { UnreachableTransport() }, - isEnabled: { syncEnabled } + fixture = try AppStateFixture() + } + + private func makePublisher() -> ConnectionLibraryPublisher { + let widgetWrites = widgetWrites + return ConnectionLibraryPublisher( + searchIndex: searchIndex, + writeWidgetItems: { _ in widgetWrites.total += 1 }, + refreshShortcutParameters: {} ) - return AppState( - libraryDirectory: libraryDirectory, - defaults: defaults, - syncCoordinator: coordinator, - sampleInstaller: SampleDatabaseInstaller( - bundledURL: bundledSample, - directory: root.appendingPathComponent("Samples", isDirectory: true) - ) + } + + private func makeState( + syncEnabled: Bool, + secureStore: any SecureStore = MockSecureStore(), + publisher: ConnectionLibraryPublisher? = nil + ) -> AppState { + fixture.makeState( + syncEnabled: syncEnabled, + secureStore: secureStore, + libraryPublisher: publisher ?? makePublisher() ) } @Test("A library that failed to load refuses every write and leaves the file alone") func failedLoadRefusesWrites() throws { - let file = libraryDirectory.appendingPathComponent("connections.json") let unreadable = Data("{ not json".utf8) - try unreadable.write(to: file) + try unreadable.write(to: fixture.connectionsFile) let state = makeState(syncEnabled: false) #expect(state.loadStatus == .failed) #expect(state.isLibraryWritable == false) #expect(state.addConnection(DatabaseConnection(name: "New", type: .mysql)) == false) - #expect(state.addGroup(ConnectionGroup(name: "Team")) == false) + #expect(state.addGroup(ConnectionGroup(name: "Team")) == .refused) + #expect(state.addTag(ConnectionTag(name: "prod")) == .refused) + #expect(state.mutateConnection(UUID()) { $0.name = "Edited" } == .refused) + #expect(state.mutateGroup(UUID()) { $0.name = "Edited" } == .refused) + #expect(state.mutateTag(UUID()) { $0.name = "Edited" } == .refused) #expect(throws: SampleDatabaseError.libraryUnavailable) { try state.openSampleDatabase() } - #expect(try Data(contentsOf: file) == unreadable) + #expect(try Data(contentsOf: fixture.connectionsFile) == unreadable) } @Test("Opening the sample twice keeps one sample connection, and it is never marked for sync") @@ -115,24 +114,660 @@ struct AppStateLibraryTests { #expect(state.onboarding.usageDataChoice == true) } -} -private struct UnreachableTransport: IOSSyncTransport { - var currentZoneID: CKRecordZone.ID { - get async { CKRecordZone.ID(zoneName: "Unused", ownerName: CKCurrentUserDefaultName) } + // MARK: - Spotlight, tags and Handoff + + @Test("Deleting a connection takes it out of Spotlight and leaves the rest") + func deletionLeavesTheIndex() async { + let publisher = makePublisher() + let state = makeState(syncEnabled: false, publisher: publisher) + let kept = DatabaseConnection(name: "Kept", type: .postgresql) + let deleted = DatabaseConnection(name: "Deleted", type: .postgresql) + #expect(state.addConnection(kept)) + #expect(state.addConnection(deleted)) + + state.removeConnections([deleted.id]) + await publisher.settle() + + #expect(searchIndex.indexedIds == [kept.id]) + } + + @Test("A connection deleted on another device leaves Spotlight when sync merges") + func syncedDeletionLeavesTheIndex() async throws { + let publisher = makePublisher() + let state = makeState(syncEnabled: false, publisher: publisher) + let kept = DatabaseConnection(name: "Kept", type: .postgresql) + let deleted = DatabaseConnection(name: "Deleted", type: .postgresql) + #expect(state.addConnection(kept)) + #expect(state.addConnection(deleted)) + + let merged = state.connections.filter { $0.id != deleted.id } + state.applySyncedConnections(merged) + await publisher.settle() + + #expect(state.connections.map(\.id) == [kept.id]) + #expect(searchIndex.indexedIds == [kept.id]) + } + + @Test("Deleting a group keeps its connections searchable at the top level") + func groupDeletionKeepsConnectionsIndexed() async { + let publisher = makePublisher() + let state = makeState(syncEnabled: false, publisher: publisher) + let group = ConnectionGroup(name: "Team") + #expect(state.addGroup(group) == .applied) + let member = DatabaseConnection(name: "Member", type: .postgresql, groupId: group.id) + #expect(state.addConnection(member)) + + state.deleteGroup(group.id) + await publisher.settle() + + #expect(state.connections.first?.groupId == nil) + #expect(searchIndex.indexedIds == [member.id]) + } + + @Test("A library that failed to load refuses a sync merge and publishes nothing until it loads") + func failedLoadPublishesNothing() async throws { + let file = fixture.connectionsFile + let unreadable = Data("{ not json".utf8) + try unreadable.write(to: file) + let publisher = makePublisher() + let state = makeState(syncEnabled: false, publisher: publisher) + + state.applySyncedConnections([DatabaseConnection(name: "Merged", type: .postgresql)]) + await publisher.settle() + + #expect(try Data(contentsOf: file) == unreadable) + #expect(searchIndex.replacements.isEmpty) + #expect(widgetWrites.total == 0) + + let repaired = DatabaseConnection(name: "Repaired", type: .postgresql) + try JSONEncoder().encode([repaired]).write(to: file) + state.retryLoadIfFailed() + await publisher.settle() + + #expect(searchIndex.indexedIds == [repaired.id]) + } + + @Test("Deleting the sample and opening it again leaves only the new sample in Spotlight") + func reopenedSampleReplacesTheOld() async throws { + let publisher = makePublisher() + let state = makeState(syncEnabled: false, publisher: publisher) + + let first = try state.openSampleDatabase() + state.removeConnections([first]) + let second = try state.openSampleDatabase() + await publisher.settle() + + #expect(first != second) + #expect(searchIndex.indexedIds == [second]) + } + + @Test("Deleting a tag strips it from the connections that carry it and syncs only those") + func tagDeletionStripsCarriers() throws { + let state = makeState(syncEnabled: true) + let tag = ConnectionTag(name: "Staging", color: .orange) + state.addTag(tag) + let carrier = DatabaseConnection(name: "Carrier", type: .postgresql, tagIds: [tag.id]) + let bystander = DatabaseConnection(name: "Bystander", type: .postgresql) + #expect(state.addConnection(carrier)) + #expect(state.addConnection(bystander)) + let sample = try state.openSampleDatabase() + #expect(state.mutateConnection(sample) { $0.tagIds = [tag.id] } == .applied) + metadata.clearDirty(type: .connection) + + #expect(state.deleteTag(tag.id)) + + #expect(!state.tags.contains { $0.id == tag.id }) + #expect(state.connections.allSatisfy { !$0.tagIds.contains(tag.id) }) + #expect(metadata.dirtyIds(for: .connection) == [carrier.id.uuidString]) + #expect(metadata.tombstones(for: .tag).map(\.id) == [tag.id.uuidString]) + + let reloaded = makeState(syncEnabled: true) + #expect(!reloaded.tags.contains { $0.id == tag.id }) + #expect(reloaded.connections.allSatisfy { !$0.tagIds.contains(tag.id) }) + } + + @Test("A built-in tag cannot be deleted") + func presetTagIsKept() throws { + let state = makeState(syncEnabled: true) + let preset = try #require(ConnectionTag.presets.first) + let carrier = DatabaseConnection(name: "Carrier", type: .postgresql, tagIds: [preset.id]) + #expect(state.addConnection(carrier)) + + #expect(state.deleteTag(preset.id) == false) + + #expect(state.tags.contains { $0.id == preset.id }) + #expect(state.connections.first?.tagIds == [preset.id]) + #expect(metadata.tombstones(for: .tag).isEmpty) + } + + @Test("Handoff is offered for a saved connection until it is deleted, and never for the sample") + func handoffFollowsTheLibrary() throws { + let state = makeState(syncEnabled: false) + let saved = DatabaseConnection(name: "Prod", type: .postgresql, host: "db.example.com", port: 5_432) + #expect(state.addConnection(saved)) + let sampleId = try state.openSampleDatabase() + let sample = try #require(state.connections.first { $0.id == sampleId }) + + #expect(state.offersHandoff(for: saved)) + #expect(state.offersHandoff(for: sample) == false) + #expect(state.isConnectionRemoved(saved.id) == false) + + state.removeConnections([saved.id]) + + #expect(state.isConnectionRemoved(saved.id)) + #expect(state.offersHandoff(for: saved) == false) + } + + @Test("A library that failed to load reports no connection as deleted") + func unloadedLibraryDeletesNothing() throws { + try Data("{ not json".utf8).write(to: fixture.connectionsFile) + let state = makeState(syncEnabled: false) + + #expect(state.loadStatus == .failed) + #expect(state.isConnectionRemoved(UUID()) == false) + } + + // MARK: - Editing through the form + + @Test("Saving an edit keeps a favorite and a group set while the form was open") + func saveKeepsChangesMadeWhileOpen() async throws { + let state = makeState(syncEnabled: false) + let group = ConnectionGroup(name: "Team") + let stored = DatabaseConnection(name: "Prod", type: .postgresql, host: "db.example.com", port: 5_432) + #expect(state.addGroup(group) == .applied) + #expect(state.addConnection(stored)) + let viewModel = fixture.makeFormViewModel(editing: stored) + + state.setFavorite([stored.id], isFavorite: true) + state.moveConnections([stored.id], toGroup: group.id) + metadata.clearDirty(type: .connection) + viewModel.port = "5433" + let savedId = await viewModel.save(appState: state, secureStore: MockSecureStore()) + + let saved = try #require(state.connections.first { $0.id == stored.id }) + #expect(savedId == stored.id) + #expect(saved.isFavorite) + #expect(saved.groupId == group.id) + #expect(saved.port == 5_433) + #expect(metadata.dirtyIds(for: .connection).contains(stored.id.uuidString)) + } + + @Test("Saving an untouched form writes nothing and marks nothing for sync") + func untouchedSaveWritesNothing() async throws { + let state = makeState(syncEnabled: false) + let stored = DatabaseConnection(name: "Prod", type: .postgresql, host: "db.example.com", port: 5_432) + #expect(state.addConnection(stored)) + metadata.clearDirty(type: .connection) + let before = try Data(contentsOf: fixture.connectionsFile) + + let savedId = await fixture.makeFormViewModel(editing: stored) + .save(appState: state, secureStore: MockSecureStore()) + + #expect(savedId == stored.id) + #expect(metadata.dirtyIds(for: .connection).isEmpty) + #expect(try Data(contentsOf: fixture.connectionsFile) == before) + } + + @Test("Saving a form whose connection was deleted meanwhile does not bring it back") + func saveAfterDeleteDoesNotResurrect() async throws { + let state = makeState(syncEnabled: false) + let stored = DatabaseConnection(name: "Prod", type: .postgresql, host: "db.example.com", port: 5_432) + #expect(state.addConnection(stored)) + let viewModel = fixture.makeFormViewModel(editing: stored) + let secureStore = MockSecureStore() + + state.removeConnections([stored.id]) + viewModel.sshEnabled = true + viewModel.sshHost = "bastion.example.com" + viewModel.sshPassword = "secret" + let savedId = await viewModel.save(appState: state, secureStore: secureStore) + + #expect(savedId == nil) + #expect(viewModel.saveFailure == .removed(.connection)) + #expect(!state.connections.contains { $0.id == stored.id }) + #expect(try secureStore.retrieve(forKey: "com.TablePro.sshpassword.\(stored.id.uuidString)") == nil) + } + + @Test("A group rename after a reorder keeps the new order") + func groupEditKeepsReorder() throws { + let state = makeState(syncEnabled: false) + let first = ConnectionGroup(name: "First") + let second = ConnectionGroup(name: "Second") + #expect(state.addGroup(first) == .applied) + #expect(state.addGroup(second) == .applied) + let opened = try #require(state.groups.first { $0.id == first.id }) + + state.reorderGroups([second.id, first.id]) + let edits = GroupFormEdits(name: "Renamed", color: opened.color, parentId: opened.parentId) + let outcome = state.mutateGroup(first.id) { + $0 = edits.applied(to: $0, changedSince: GroupFormEdits(group: opened)) + } + + let saved = try #require(state.groups.first { $0.id == first.id }) + #expect(outcome == .applied) + #expect(saved.name == "Renamed") + #expect(saved.sortOrder == 1) + } + + @Test("A tag rename keeps a color set after the sheet opened") + func tagEditKeepsColor() throws { + let state = makeState(syncEnabled: false) + let tag = ConnectionTag(name: "staging", color: .orange) + state.addTag(tag) + + #expect(state.mutateTag(tag.id) { $0.color = .purple } == .applied) + let edits = TagFormEdits(name: "stage", color: tag.color) + let outcome = state.mutateTag(tag.id) { + $0 = edits.applied(to: $0, changedSince: TagFormEdits(tag: tag)) + } + + let saved = try #require(state.tags.first { $0.id == tag.id }) + #expect(outcome == .applied) + #expect(saved.name == "stage") + #expect(saved.color == .purple) + #expect(state.mutateTag(UUID()) { $0.name = "Gone" } == .missing) + } + + // MARK: - Saving secrets after the record + + @Test("An edit refused by an unloaded library leaves the bookmark, secrets and Documents untouched") + func refusedEditWritesNothing() async throws { + try Data("{ not json".utf8).write(to: fixture.connectionsFile) + let state = makeState(syncEnabled: false) + let stored = DatabaseConnection( + name: "Warehouse", + type: .duckdb, + port: 0, + database: "/private/var/shared/warehouse.duckdb" + ) + let bookmark = Data("bookmark".utf8) + fixture.bookmarkStore.save(bookmark, for: stored.id) + let viewModel = fixture.makeFormViewModel(editing: stored) + let secureStore = MockSecureStore() + + viewModel.newDatabaseName = "local" + viewModel.createNewDatabase() + viewModel.sshEnabled = true + viewModel.sshHost = "bastion.example.com" + viewModel.sshPassword = "secret" + let savedId = await viewModel.save(appState: state, secureStore: secureStore) + + #expect(savedId == nil) + #expect(viewModel.saveFailure == .libraryUnavailable(.connection)) + #expect(fixture.bookmarkStore.bookmark(for: stored.id) == bookmark) + #expect(try secureStore.retrieve(forKey: "com.TablePro.sshpassword.\(stored.id.uuidString)") == nil) + #expect(!FileManager.default.fileExists(atPath: fixture.documentsFile("local.duckdb").path)) + } + + @Test("A new connection refused by an unloaded library stores none of its secrets") + func refusedAddWritesNoSecrets() async throws { + try Data("{ not json".utf8).write(to: fixture.connectionsFile) + let state = makeState(syncEnabled: false) + let viewModel = fixture.makeFormViewModel() + let secureStore = MockSecureStore() + viewModel.host = "db.example.com" + viewModel.sshEnabled = true + viewModel.sshHost = "bastion.example.com" + viewModel.sshPassword = "secret" + let draftId = viewModel.buildConnection().id + + let savedId = await viewModel.save(appState: state, secureStore: secureStore) + + #expect(savedId == nil) + #expect(viewModel.saveFailure == .libraryUnavailable(.connection)) + #expect(try secureStore.retrieve(forKey: "com.TablePro.sshpassword.\(draftId.uuidString)") == nil) + } + + @Test("A new connection whose keychain write fails is added once, and saving again stores the secret") + func keychainFailureRetryAddsOnce() async throws { + let state = makeState(syncEnabled: false) + let viewModel = fixture.makeFormViewModel() + let secureStore = MockSecureStore() + viewModel.host = "db.example.com" + viewModel.sshEnabled = true + viewModel.sshHost = "bastion.example.com" + viewModel.sshPassword = "secret" + secureStore.failNextStore = true + + let firstAttempt = await viewModel.save(appState: state, secureStore: secureStore) + let addedId = try #require(state.connections.first?.id) + let secondAttempt = await viewModel.save(appState: state, secureStore: secureStore) + + #expect(firstAttempt == nil) + #expect(viewModel.credentialError != nil) + #expect(secondAttempt == addedId) + #expect(state.connections.count == 1) + #expect(try secureStore.retrieve(forKey: "com.TablePro.sshpassword.\(addedId.uuidString)") == "secret") + } + + // MARK: - Group and tag sheets + + @Test("A group whose chosen parent moved inside it is not saved, and says why") + func groupPlacementConflictIsReported() throws { + let state = makeState(syncEnabled: false) + let first = ConnectionGroup(name: "A") + let second = ConnectionGroup(name: "B") + #expect(state.addGroup(first) == .applied) + #expect(state.addGroup(second) == .applied) + let opened = try #require(state.groups.first { $0.id == first.id }) + + #expect(state.mutateGroup(second.id) { $0.parentId = first.id } == .applied) + let outcome = GroupFormEdits(name: "Renamed", color: opened.color, parentId: second.id) + .save(editing: opened, in: state) + + let failure = try #require(LibraryWriteFailure(outcome, kind: .group)) + #expect(outcome == .invalidPlacement) + #expect(!failure.closesForm) + #expect(state.groups.first { $0.id == first.id }?.name == "A") + } + + @Test("A group or tag deleted while its sheet was open is reported as removed, and not brought back") + func deletedGroupAndTagAreReported() throws { + let state = makeState(syncEnabled: false) + let group = ConnectionGroup(name: "Team") + let tag = ConnectionTag(name: "staging", color: .orange) + #expect(state.addGroup(group) == .applied) + #expect(state.addTag(tag) == .applied) + let openedGroup = try #require(state.groups.first { $0.id == group.id }) + + state.deleteGroup(group.id) + state.deleteTag(tag.id) + let groupOutcome = GroupFormEdits(name: "Renamed", color: .blue, parentId: nil) + .save(editing: openedGroup, in: state) + let tagOutcome = TagFormEdits(name: "stage", color: .orange).save(editing: tag, in: state) + + #expect(LibraryWriteFailure(groupOutcome, kind: .group) == .removed(.group)) + #expect(LibraryWriteFailure(tagOutcome, kind: .tag) == .removed(.tag)) + #expect(!state.groups.contains { $0.id == group.id }) + #expect(!state.tags.contains { $0.id == tag.id }) + } + + @Test("A new group and tag saved into an unloaded library are refused, and the sheet stays open") + func refusedGroupAndTagKeepTheSheet() throws { + try Data("{ not json".utf8).write(to: fixture.connectionsFile) + let state = makeState(syncEnabled: false) + + let groupOutcome = GroupFormEdits(name: "Team", color: .blue, parentId: nil).save(editing: nil, in: state) + let tagOutcome = TagFormEdits(name: "prod", color: .red).save(editing: nil, in: state) + + let groupFailure = try #require(LibraryWriteFailure(groupOutcome, kind: .group)) + #expect(groupFailure == .libraryUnavailable(.group)) + #expect(!groupFailure.closesForm) + #expect(LibraryWriteFailure(tagOutcome, kind: .tag) == .libraryUnavailable(.tag)) } - func accountStatus() async throws -> CKAccountStatus { - .noAccount + // MARK: - Database file paths + + @Test("Stored file paths load and save exactly as written, and nothing is marked for sync") + func storedPathsAreNeverRewritten() throws { + try Data().write(to: fixture.documentsFile("notes.db")) + let earlier = DatabaseConnection( + name: "Notes", + type: .sqlite, + port: 0, + database: fixture.earlierContainerPath(to: "notes.db") + ) + let otherDevice = DatabaseConnection( + name: "Other", + type: .sqlite, + port: 0, + database: fixture.otherInstallContainerPath(to: "notes.db") + ) + let mac = DatabaseConnection(name: "Mac", type: .sqlite, port: 0, database: "/Users/mac/Documents/app.db") + let stored = try JSONEncoder().encode([earlier, otherDevice, mac]) + try stored.write(to: fixture.connectionsFile) + + let state = makeState(syncEnabled: true) + let added = DatabaseConnection( + name: "Orders", + type: .sqlite, + port: 0, + database: fixture.earlierContainerPath(to: "orders.db") + ) + #expect(state.addConnection(added)) + + #expect(state.connections.map(\.database) == [earlier, otherDevice, mac, added].map(\.database)) + let reloaded = try JSONDecoder().decode( + [DatabaseConnection].self, + from: Data(contentsOf: fixture.connectionsFile) + ) + #expect(reloaded.map(\.database) == [earlier, otherDevice, mac, added].map(\.database)) + #expect(metadata.dirtyIds(for: .connection) == [added.id.uuidString]) + } + + @Test("Launching records the container the app runs in") + func launchRecordsTheContainer() { + _ = makeState(syncEnabled: false) + + let currentId = fixture.documentsDirectory.deletingLastPathComponent().lastPathComponent + #expect(fixture.containerHistory.containerIds.contains(currentId)) + } + + @Test("Testing a database that does not exist yet, then saving, creates it once") + func testThenSaveCreatesOnce() async throws { + let state = makeState(syncEnabled: false) + let viewModel = fixture.makeFormViewModel() + viewModel.type = .sqlite + viewModel.newDatabaseName = "scratch" + viewModel.createNewDatabase() + + await viewModel.testConnection() + #expect(viewModel.testResult?.success == true) + #expect(!FileManager.default.fileExists(atPath: fixture.documentsFile("scratch.db").path)) + + let savedId = await viewModel.save(appState: state, secureStore: MockSecureStore()) + + #expect(savedId != nil) + #expect(viewModel.fileError == nil) + #expect(FileManager.default.fileExists(atPath: fixture.documentsFile("scratch.db").path)) + #expect(state.connections.first { $0.id == savedId }?.database == fixture.documentsFile("scratch.db").path) } - func ensureZoneExists() async throws {} + @Test("Testing a database that does not exist yet, then cancelling, leaves nothing in Documents") + func testThenCancelLeavesNothing() async throws { + let state = makeState(syncEnabled: false) + let viewModel = fixture.makeFormViewModel() + viewModel.type = .duckdb + viewModel.newDatabaseName = "analytics" + viewModel.createNewDatabase() + + await viewModel.testConnection() - func pull(since token: CKServerChangeToken?) async throws -> PullResult { - PullResult(changedRecords: [], deletedRecordIDs: [], newToken: nil) + #expect(viewModel.testResult?.success == true) + #expect(try FileManager.default.contentsOfDirectory(atPath: fixture.documentsDirectory.path).isEmpty) + #expect(state.connections.isEmpty) } - func push(records: [CKRecord], deletions: [CKRecord.ID]) async throws -> PushOutcome { - PushOutcome(savedRecords: [:], deletedRecordIDs: []) + @Test("A bookmarked DuckDB connection switched to a new file loses its bookmark") + func switchingToADocumentsFileDropsTheBookmark() async throws { + let state = makeState(syncEnabled: false) + let stored = DatabaseConnection( + name: "Warehouse", + type: .duckdb, + port: 0, + database: "/private/var/shared/warehouse.duckdb" + ) + #expect(state.addConnection(stored)) + fixture.bookmarkStore.save(Data("bookmark".utf8), for: stored.id) + let viewModel = fixture.makeFormViewModel(editing: stored) + + viewModel.newDatabaseName = "local" + viewModel.createNewDatabase() + let savedId = await viewModel.save(appState: state, secureStore: MockSecureStore()) + + #expect(savedId == stored.id) + #expect(fixture.bookmarkStore.bookmark(for: stored.id) == nil) + #expect(state.connections.first { $0.id == stored.id }?.database == fixture.documentsFile("local.duckdb").path) + #expect(FileManager.default.fileExists(atPath: fixture.documentsFile("local.duckdb").path)) } + + // MARK: - Pasted SSH keys + + private let keyMarker = "b3BlbnNzaC1rZXktdjEAAAAABG5vbmU" + + private func legacyKey(for id: UUID) -> String { + "-----BEGIN OPENSSH PRIVATE KEY-----\n\(keyMarker)\(id.uuidString)\n-----END OPENSSH PRIVATE KEY-----" + } + + private func writeLegacyLibrary(id: UUID) throws -> Data { + try writeLegacyLibrary(ids: [id]) + } + + private func writeLegacyLibrary(ids: [UUID]) throws -> Data { + let connections = ids.map { id in + DatabaseConnection( + id: id, + name: "Bastion", + type: .postgresql, + host: "10.0.0.5", + sshEnabled: true, + sshConfiguration: SSHConfiguration( + host: "bastion.example.com", + username: "deploy", + authMethod: .privateKey + ) + ) + } + let encoded = try JSONEncoder().encode(connections) + var entries = try #require(JSONSerialization.jsonObject(with: encoded) as? [[String: Any]]) + for index in entries.indices { + var ssh = try #require(entries[index]["sshConfiguration"] as? [String: Any]) + ssh["privateKeyData"] = legacyKey(for: ids[index]) + entries[index]["sshConfiguration"] = ssh + } + let legacy = try JSONSerialization.data(withJSONObject: entries) + try legacy.write(to: fixture.connectionsFile) + return legacy + } + + private func keysInFile() throws -> [UUID: String] { + PastedSSHKeyMigration.pendingKeys(inLibraryFile: try Data(contentsOf: fixture.connectionsFile)) + } + + private func fileNumber() throws -> Int { + let attributes = try FileManager.default.attributesOfItem(atPath: fixture.connectionsFile.path) + return try #require(attributes[.systemFileNumber] as? Int) + } + + @Test("A key pasted on an older build moves into the secure store and out of the file, without a sync upload") + func legacyKeyMoves() throws { + let id = UUID() + _ = try writeLegacyLibrary(id: id) + let store = MockSecureStore() + + let state = makeState(syncEnabled: true, secureStore: store) + + #expect(state.loadStatus == .ready) + #expect(state.connections.map(\.id) == [id]) + let stored = try #require(try store.retrieve(forKey: "com.TablePro.sshkeydata.\(id.uuidString)")) + #expect(stored.contains(keyMarker)) + let rewritten = try #require(String(data: Data(contentsOf: fixture.connectionsFile), encoding: .utf8)) + #expect(!rewritten.contains("privateKeyData")) + #expect(!rewritten.contains(keyMarker)) + #expect(metadata.dirtyIds(for: .connection).isEmpty) + } + + @Test("A migrated file is left alone on the next launch") + func secondLaunchWritesNothing() throws { + _ = try writeLegacyLibrary(id: UUID()) + let store = MockSecureStore() + _ = makeState(syncEnabled: true, secureStore: store) + let migrated = try Data(contentsOf: fixture.connectionsFile) + let migratedFile = try fileNumber() + + let relaunched = makeState(syncEnabled: true, secureStore: store) + + #expect(relaunched.loadStatus == .ready) + #expect(try Data(contentsOf: fixture.connectionsFile) == migrated) + #expect(try fileNumber() == migratedFile) + } + + @Test("A key the store already holds for that connection is kept over the file's copy") + func existingStoredKeyWins() throws { + let id = UUID() + _ = try writeLegacyLibrary(id: id) + let store = MockSecureStore() + store.seed("com.TablePro.sshkeydata.\(id.uuidString)", "KEY FROM ANOTHER DEVICE") + + let state = makeState(syncEnabled: false, secureStore: store) + + #expect(state.loadStatus == .ready) + #expect(try store.retrieve(forKey: "com.TablePro.sshkeydata.\(id.uuidString)") == "KEY FROM ANOTHER DEVICE") + #expect(!(try String(contentsOf: fixture.connectionsFile, encoding: .utf8)).contains(keyMarker)) + } + + @Test("A key the store refuses stays in the file, and the library still loads and takes writes") + func refusedKeyStaysInFile() throws { + let id = UUID() + let legacy = try writeLegacyLibrary(id: id) + let store = MockSecureStore() + store.refusesStores = true + + let state = makeState(syncEnabled: true, secureStore: store) + + #expect(state.loadStatus == .ready) + #expect(state.connections.map(\.id) == [id]) + #expect(try Data(contentsOf: fixture.connectionsFile) == legacy) + #expect(try store.retrieve(forKey: "com.TablePro.sshkeydata.\(id.uuidString)") == nil) + #expect(metadata.dirtyIds(for: .connection).isEmpty) + } + + @Test("A key still waiting for the store survives a later library write and moves on the next launch") + func waitingKeySurvivesWrites() throws { + let id = UUID() + _ = try writeLegacyLibrary(id: id) + let store = MockSecureStore() + store.refusesStores = true + let state = makeState(syncEnabled: false, secureStore: store) + let added = DatabaseConnection(name: "New", type: .mysql) + + #expect(state.addConnection(added)) + + #expect(try keysInFile() == [id: legacyKey(for: id)]) + let written = try JSONDecoder().decode([DatabaseConnection].self, from: Data(contentsOf: fixture.connectionsFile)) + #expect(written.map(\.id) == [id, added.id]) + + store.refusesStores = false + let relaunched = makeState(syncEnabled: false, secureStore: store) + + #expect(relaunched.loadStatus == .ready) + #expect(try store.retrieve(forKey: "com.TablePro.sshkeydata.\(id.uuidString)") == legacyKey(for: id)) + #expect(try keysInFile().isEmpty) + #expect(!(try String(contentsOf: fixture.connectionsFile, encoding: .utf8)).contains(keyMarker)) + } + + @Test("Deleting a connection takes its waiting key out of the file") + func deletingDropsWaitingKey() throws { + let kept = UUID() + let deleted = UUID() + _ = try writeLegacyLibrary(ids: [kept, deleted]) + let store = MockSecureStore() + store.refusesStores = true + let state = makeState(syncEnabled: false, secureStore: store) + + state.removeConnections([deleted]) + + #expect(try keysInFile() == [kept: legacyKey(for: kept)]) + } + + @Test("Keys the store takes leave the file while a refused one stays") + func partialMoveKeepsRefusedKey() throws { + let ids = [UUID(), UUID()].sorted { $0.uuidString < $1.uuidString } + _ = try writeLegacyLibrary(ids: ids) + let store = MockSecureStore() + store.failNextStore = true + + let state = makeState(syncEnabled: true, secureStore: store) + + #expect(state.loadStatus == .ready) + #expect(try keysInFile() == [ids[0]: legacyKey(for: ids[0])]) + #expect(try store.retrieve(forKey: "com.TablePro.sshkeydata.\(ids[1].uuidString)") == legacyKey(for: ids[1])) + #expect(metadata.dirtyIds(for: .connection).isEmpty) + } +} + +@MainActor +private final class WidgetWriteCounter { + var total = 0 } diff --git a/TableProMobile/TableProMobileTests/Onboarding/ScenePresenterTests.swift b/TableProMobile/TableProMobileTests/Onboarding/ScenePresenterTests.swift index 817cba4a8e..14cf2611c4 100644 --- a/TableProMobile/TableProMobileTests/Onboarding/ScenePresenterTests.swift +++ b/TableProMobile/TableProMobileTests/Onboarding/ScenePresenterTests.swift @@ -58,4 +58,49 @@ struct ScenePresenterTests { #expect(presenter.takeTable(for: connectionId) == "Track") #expect(presenter.takeTable(for: connectionId) == nil) } + + @Test("A link waits while an editor holds unsaved changes, then arrives") + func waitsForEditor() { + let presenter = ScenePresenter() + let editor = UUID() + presenter.setEditorHold(editor, isHolding: true) + presenter.receive(.openConnection(connectionId, table: nil)) + + #expect(presenter.isHeldByEditor) + #expect(presenter.takeDeliverableIntent(isLocked: false, isLibraryWritable: true) == nil) + + presenter.setEditorHold(editor, isHolding: false) + #expect(presenter.isHeldByEditor == false) + #expect(presenter.takeDeliverableIntent(isLocked: false, isLibraryWritable: true) == .openConnection(connectionId, table: nil)) + } + + @Test("Every editor has to let go before a link arrives") + func waitsForEveryEditor() { + let presenter = ScenePresenter() + let first = UUID() + let second = UUID() + presenter.setEditorHold(first, isHolding: true) + presenter.setEditorHold(second, isHolding: true) + presenter.receive(.openConnection(connectionId, table: nil)) + + presenter.setEditorHold(first, isHolding: false) + #expect(presenter.takeDeliverableIntent(isLocked: false, isLibraryWritable: true) == nil) + + presenter.setEditorHold(second, isHolding: false) + #expect(presenter.takeDeliverableIntent(isLocked: false, isLibraryWritable: true) != nil) + } + + @Test("An editor that goes away lets go of its hold", .timeLimit(.minutes(1))) + func releasedEditorLetsGo() async { + let presenter = ScenePresenter() + var hold: SceneEditorHold? = SceneEditorHold() + hold?.update(isHolding: true, in: presenter) + #expect(presenter.isHeldByEditor) + + hold = nil + while presenter.isHeldByEditor { + await Task.yield() + } + #expect(presenter.isHeldByEditor == false) + } } diff --git a/TableProMobile/TableProMobileTests/Platform/AppContainerPathsTests.swift b/TableProMobile/TableProMobileTests/Platform/AppContainerPathsTests.swift new file mode 100644 index 0000000000..88ddfdbdf7 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Platform/AppContainerPathsTests.swift @@ -0,0 +1,130 @@ +import Foundation +@testable import TableProMobile +import Testing + +@Suite("App container paths") +struct AppContainerPathsTests { + private let family = "/var/mobile/Containers/Data/Application" + private let current = "11111111-1111-1111-1111-111111111111" + private let earlier = "22222222-2222-2222-2222-222222222222" + private let otherInstall = "33333333-3333-3333-3333-333333333333" + private let history: AppContainerHistory + + init() { + history = AppContainerHistory(suiteName: "com.TablePro.tests.AppContainers.\(UUID().uuidString)") + } + + private var documents: String { "\(family)/\(current)/Documents" } + + private func paths(currentContainer: String? = nil) -> AppContainerPaths { + let containerId = currentContainer ?? current + return AppContainerPaths( + documentsDirectory: URL(fileURLWithPath: "\(family)/\(containerId)/Documents"), + history: history + ) + } + + @Test("A path in the current container resolves to itself, with or without the private prefix") + func currentContainerResolvesInPlace() { + let container = paths() + + #expect( + container.resolve("\(documents)/notes.db") + == .inThisInstall(URL(fileURLWithPath: "\(documents)/notes.db")) + ) + #expect( + container.resolve("/private\(documents)/notes.db") + == .inThisInstall(URL(fileURLWithPath: "/private\(documents)/notes.db")) + ) + } + + @Test("A Documents path in a container this install recorded is re-rooted into today's Documents") + func recordedContainerIsReRooted() { + history.record(earlier) + let container = paths() + + #expect( + container.resolve("\(family)/\(earlier)/Documents/notes.db") + == .inThisInstall(URL(fileURLWithPath: "\(documents)/notes.db")) + ) + #expect( + container.resolve("/private\(family)/\(earlier)/Documents/archive/2024.db") + == .inThisInstall(URL(fileURLWithPath: "\(documents)/archive/2024.db")) + ) + } + + @Test("A container this install never recorded belongs to another device or app and is not re-rooted") + func unrecordedContainerIsNotOnThisDevice() { + history.record(earlier) + let container = paths() + + #expect(container.resolve("\(family)/\(otherInstall)/Documents/notes.db") == .notOnThisDevice) + #expect(container.resolve("\(family)/\(earlier)/Library/notes.db") == .notOnThisDevice) + #expect(container.resolve("\(family)/\(earlier)/Documents") == .notOnThisDevice) + } + + @Test("Relative, home-relative, empty and traversing paths are never resolved against this device") + func unresolvablePathsAreNotOnThisDevice() { + history.record(earlier) + let container = paths() + + #expect(container.resolve("notes.db") == .notOnThisDevice) + #expect(container.resolve("~/notes.db") == .notOnThisDevice) + #expect(container.resolve("") == .notOnThisDevice) + #expect(container.resolve("\(documents)/../secret.db") == .notOnThisDevice) + #expect(container.resolve("\(family)/\(earlier)/Documents/../../\(otherInstall)/x.db") == .notOnThisDevice) + } + + @Test("A path outside every app container is kept as it is") + func pathOutsideContainersIsKept() { + let container = paths() + + #expect( + container.resolve("/Users/mac/Documents/app.db") + == .outsideAppContainers(URL(fileURLWithPath: "/Users/mac/Documents/app.db")) + ) + } + + @Test("Recording each launch's container lets a later container re-root the earlier one's files") + func launchesAccumulateContainers() { + paths(currentContainer: earlier).recordCurrentContainer() + let afterUpdate = paths() + afterUpdate.recordCurrentContainer() + + #expect(history.containerIds == [earlier, current]) + #expect( + afterUpdate.resolve("\(family)/\(earlier)/Documents/notes.db") + == .inThisInstall(URL(fileURLWithPath: "\(documents)/notes.db")) + ) + #expect( + paths(currentContainer: earlier).resolve("\(documents)/notes.db") + == .inThisInstall(URL(fileURLWithPath: "\(family)/\(earlier)/Documents/notes.db")) + ) + } + + @Test("An SSH key path follows the same rule and is otherwise left for the tunnel to report") + func keyPathsFollowTheSameRule() { + history.record(earlier) + let container = paths() + + #expect( + container.localPath(forStoredPath: "\(family)/\(earlier)/Documents/ssh_id_ed25519") + == "\(documents)/ssh_id_ed25519" + ) + #expect( + container.localPath(forStoredPath: "\(family)/\(otherInstall)/Documents/ssh_id_ed25519") + == "\(family)/\(otherInstall)/Documents/ssh_id_ed25519" + ) + #expect(container.localPath(forStoredPath: "~/.ssh/id_rsa") == "~/.ssh/id_rsa") + } + + @Test("Only files below the current Documents count as inside it") + func documentsMembership() { + let container = paths() + + #expect(container.isInDocuments(URL(fileURLWithPath: "\(documents)/notes.db"))) + #expect(container.isInDocuments(URL(fileURLWithPath: "/private\(documents)/notes.db"))) + #expect(!container.isInDocuments(URL(fileURLWithPath: documents))) + #expect(!container.isInDocuments(URL(fileURLWithPath: "\(family)/\(earlier)/Documents/notes.db"))) + } +} diff --git a/TableProMobile/TableProMobileTests/Platform/IOSDriverFactoryLocalFileTests.swift b/TableProMobile/TableProMobileTests/Platform/IOSDriverFactoryLocalFileTests.swift new file mode 100644 index 0000000000..7aca7091f9 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Platform/IOSDriverFactoryLocalFileTests.swift @@ -0,0 +1,119 @@ +import Foundation +import TableProDatabase +@testable import TableProMobile +import TableProModels +import Testing + +@Suite("Driver factory with local database files") +struct IOSDriverFactoryLocalFileTests { + private let root: URL + private let documentsDirectory: URL + private let bookmarkStore: FileBookmarkStore + private let history: AppContainerHistory + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("driver-factory-\(UUID().uuidString)", isDirectory: true) + documentsDirectory = root + .appendingPathComponent("Data/Application/\(UUID().uuidString)/Documents", isDirectory: true) + try FileManager.default.createDirectory(at: documentsDirectory, withIntermediateDirectories: true) + bookmarkStore = FileBookmarkStore(suiteName: "com.TablePro.tests.FactoryBookmarks.\(UUID().uuidString)") + history = AppContainerHistory(suiteName: "com.TablePro.tests.FactoryContainers.\(UUID().uuidString)") + } + + private var factory: IOSDriverFactory { + IOSDriverFactory( + bookmarkStore: bookmarkStore, + localFiles: LocalDatabaseFileLocator( + container: AppContainerPaths(documentsDirectory: documentsDirectory, history: history) + ) + ) + } + + private func pathInContainer(_ containerId: String, _ fileName: String) -> String { + documentsDirectory + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("\(containerId)/Documents/\(fileName)") + .path + } + + private func createSQLiteFile(_ fileName: String) async throws { + let creator = SQLiteDriver( + source: .file(documentsDirectory.appendingPathComponent(fileName)), + openMode: .createNew + ) + try await creator.connect() + try await creator.disconnect() + } + + @Test("A connection restored with a path into this install's old container opens the file in Documents") + func restoredPathOpens() async throws { + try await createSQLiteFile("notes.db") + let earlier = UUID().uuidString + history.record(earlier) + let connection = DatabaseConnection(type: .sqlite, port: 0, database: pathInContainer(earlier, "notes.db")) + + let driver = try factory.createDriver(for: connection, password: nil) + try await driver.connect() + try await driver.disconnect() + } + + @Test("A path synced from another device never opens this device's file of the same name") + func otherDevicePathDoesNotOpenLocalFile() async throws { + try await createSQLiteFile("test.db") + let connection = DatabaseConnection( + type: .sqlite, + port: 0, + database: pathInContainer(UUID().uuidString, "test.db") + ) + + #expect(throws: LocalDatabaseFileError.unavailable(fileName: "test.db", reason: .notOnThisDevice)) { + try factory.createDriver(for: connection, password: nil) + } + } + + @Test("A missing file in Documents is an error, and no empty database takes its place") + func missingFileThrows() throws { + let missing = documentsDirectory.appendingPathComponent("gone.db") + let connection = DatabaseConnection(type: .sqlite, port: 0, database: missing.path) + + #expect(throws: LocalDatabaseFileError.unavailable(fileName: "gone.db", reason: .missing)) { + try factory.createDriver(for: connection, password: nil) + } + #expect(!FileManager.default.fileExists(atPath: missing.path)) + } + + @Test("A bare file name is never looked up in Documents") + func bareFileNameIsNotOnThisDevice() async throws { + try await createSQLiteFile("notes.db") + let connection = DatabaseConnection(type: .sqlite, port: 0, database: "notes.db") + + #expect(throws: LocalDatabaseFileError.unavailable(fileName: "notes.db", reason: .notOnThisDevice)) { + try factory.createDriver(for: connection, password: nil) + } + } + + @Test("A Mac path is not on this device") + func macPathThrows() { + let connection = DatabaseConnection(type: .duckdb, port: 0, database: "/Users/mac/warehouse.duckdb") + + #expect(throws: LocalDatabaseFileError.unavailable(fileName: "warehouse.duckdb", reason: .notOnThisDevice)) { + try factory.createDriver(for: connection, password: nil) + } + } + + @Test("A bookmark left over from an earlier pick is ignored for a DuckDB file in Documents") + func lingeringBookmarkIsIgnored() async throws { + let file = documentsDirectory.appendingPathComponent("cube.duckdb") + let creator = DuckDBDriver(source: .file(file), openMode: .createNew) + try await creator.connect() + try await creator.disconnect() + let connection = DatabaseConnection(type: .duckdb, port: 0, database: file.path) + bookmarkStore.save(Data("not a bookmark".utf8), for: connection.id) + + let driver = try factory.createDriver(for: connection, password: nil) + try await driver.connect() + try await driver.disconnect() + } +} diff --git a/TableProMobile/TableProMobileTests/Platform/LocalDatabaseFileLocatorTests.swift b/TableProMobile/TableProMobileTests/Platform/LocalDatabaseFileLocatorTests.swift new file mode 100644 index 0000000000..84c98002fd --- /dev/null +++ b/TableProMobile/TableProMobileTests/Platform/LocalDatabaseFileLocatorTests.swift @@ -0,0 +1,130 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +@Suite("Local database file locator") +struct LocalDatabaseFileLocatorTests { + private let family = "/var/mobile/Containers/Data/Application" + private let current = "11111111-1111-1111-1111-111111111111" + private let earlier = "22222222-2222-2222-2222-222222222222" + private let otherInstall = "33333333-3333-3333-3333-333333333333" + private let history: AppContainerHistory + + init() { + history = AppContainerHistory(suiteName: "com.TablePro.tests.LocatorContainers.\(UUID().uuidString)") + history.record(earlier) + } + + private var documents: String { "\(family)/\(current)/Documents" } + + private func locator(existing: Set = []) -> LocalDatabaseFileLocator { + LocalDatabaseFileLocator( + container: AppContainerPaths(documentsDirectory: URL(fileURLWithPath: documents), history: history) + ) { existing.contains($0) } + } + + @Test("Stored paths map to this install's files, files elsewhere, or nothing on this device") + func storedPathsMapToLocations() { + let files = locator() + + #expect(files.location(forStoredPath: LocalDatabaseLocation.inMemoryPath) == .inMemory) + #expect( + files.location(forStoredPath: "\(documents)/fresh.db") + == .appFile(URL(fileURLWithPath: "\(documents)/fresh.db")) + ) + #expect( + files.location(forStoredPath: "\(family)/\(earlier)/Documents/notes.db") + == .appFile(URL(fileURLWithPath: "\(documents)/notes.db")) + ) + #expect( + files.location(forStoredPath: "/Users/mac/app.db") == .externalFile(URL(fileURLWithPath: "/Users/mac/app.db")) + ) + let foreign = "\(family)/\(otherInstall)/Documents/shared.db" + #expect(files.location(forStoredPath: foreign) == .notOnThisDevice(storedPath: foreign)) + #expect(files.location(forStoredPath: "notes.db") == .notOnThisDevice(storedPath: "notes.db")) + #expect(files.location(forStoredPath: "~/app.db") == .notOnThisDevice(storedPath: "~/app.db")) + } + + @Test("Another install's path never opens this device's file of the same name") + func otherInstallNeverBindsToALocalFile() { + let foreign = "\(family)/\(otherInstall)/Documents/data.duckdb" + let files = locator(existing: [foreign, "\(documents)/data.duckdb"]) + + #expect(throws: LocalDatabaseFileError.unavailable(fileName: "data.duckdb", reason: .notOnThisDevice)) { + try files.existingSource(for: files.location(forStoredPath: foreign)) + } + } + + @Test("A missing file in this install and an unreachable Mac path both surface as unavailable") + func missingFilesThrow() { + let files = locator() + + #expect(throws: LocalDatabaseFileError.unavailable(fileName: "notes.db", reason: .missing)) { + try files.existingSource(for: .appFile(URL(fileURLWithPath: "\(documents)/notes.db"))) + } + #expect(throws: LocalDatabaseFileError.unavailable(fileName: "app.db", reason: .notOnThisDevice)) { + try files.existingSource(for: .externalFile(URL(fileURLWithPath: "/Users/mac/app.db"))) + } + #expect(throws: LocalDatabaseFileError.unavailable(fileName: "app.db", reason: .notOnThisDevice)) { + try files.existingSource(for: .notOnThisDevice(storedPath: "~/app.db")) + } + } + + @Test("An existing file resolves to its URL, and in-memory needs no file") + func existingFilesResolve() throws { + let files = locator(existing: ["\(documents)/notes.db"]) + + #expect( + try files.existingSource(for: files.location(forStoredPath: "\(family)/\(earlier)/Documents/notes.db")) + == .file(URL(fileURLWithPath: "\(documents)/notes.db")) + ) + #expect(try files.existingSource(for: .inMemory) == .inMemory) + } + + @Test("A new database name is checked before anything is created") + func newDatabaseNamesAreValidated() throws { + let files = locator(existing: ["\(documents)/taken.db"]) + + #expect(try files.newDatabaseFile(named: " scratch ", type: .sqlite).lastPathComponent == "scratch.db") + #expect(try files.newDatabaseFile(named: "cube.duckdb", type: .duckdb).lastPathComponent == "cube.duckdb") + #expect(throws: LocalDatabaseFileError.invalidName) { + try files.newDatabaseFile(named: " ", type: .sqlite) + } + #expect(throws: LocalDatabaseFileError.invalidName) { + try files.newDatabaseFile(named: "a/b", type: .sqlite) + } + #expect(throws: LocalDatabaseFileError.invalidName) { + try files.newDatabaseFile(named: ".hidden", type: .sqlite) + } + #expect(throws: LocalDatabaseFileError.alreadyExists(fileName: "taken.db")) { + try files.newDatabaseFile(named: "taken", type: .sqlite) + } + } + + @Test("An imported copy never overwrites a file, and a copy that fails says so") + func importCopyIsSafe() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("locator-\(UUID().uuidString)", isDirectory: true) + let documentsDirectory = root.appendingPathComponent("Documents", isDirectory: true) + try FileManager.default.createDirectory(at: documentsDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appendingPathComponent("orders.db") + try Data("new".utf8).write(to: source) + try Data("old".utf8).write(to: documentsDirectory.appendingPathComponent("orders.db")) + let files = LocalDatabaseFileLocator( + container: AppContainerPaths(documentsDirectory: documentsDirectory, history: history) + ) + + let copy = try files.importCopy(of: source) + + #expect(copy.lastPathComponent != "orders.db") + #expect(copy.pathExtension == "db") + #expect(try Data(contentsOf: copy) == Data("new".utf8)) + #expect(try Data(contentsOf: documentsDirectory.appendingPathComponent("orders.db")) == Data("old".utf8)) + #expect(throws: LocalDatabaseFileError.self) { + try files.importCopy(of: root.appendingPathComponent("missing.db")) + } + #expect(files.isInDocuments(copy)) + } +} diff --git a/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift b/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift index 683ebcb5e5..223060ae38 100644 --- a/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift +++ b/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift @@ -36,14 +36,14 @@ struct RowDetailViewModelTests { let blocked = RowDetailViewModel( columns: makeColumns(), rows: makeRows(), initialIndex: 0, table: TableInfo(name: "users"), session: makeSession(driver: driver), - columnDetails: makeColumns(), safeModeLevel: .readOnly + columnDetails: makeColumns(), safeModeLevel: { .readOnly } ) #expect(blocked.canEdit == false, "read-only safe mode → cannot edit") let editable = RowDetailViewModel( columns: makeColumns(), rows: makeRows(), initialIndex: 0, table: TableInfo(name: "users"), session: makeSession(driver: driver), - columnDetails: makeColumns(), safeModeLevel: .off + columnDetails: makeColumns(), safeModeLevel: { .off } ) #expect(editable.canEdit == true) } @@ -194,7 +194,7 @@ struct RowDetailViewModelTests { let vm = RowDetailViewModel( columns: makeColumns(), rows: makeRows(), initialIndex: 0, table: TableInfo(name: "users"), session: makeSession(driver: driver), - columnDetails: makeColumns(), safeModeLevel: .confirmWrites + columnDetails: makeColumns(), safeModeLevel: { .confirmWrites } ) vm.startEditing() vm.setEditedValue("Charlie", at: 1) @@ -215,7 +215,7 @@ struct RowDetailViewModelTests { let vm = RowDetailViewModel( columns: makeColumns(), rows: makeRows(), initialIndex: 0, table: TableInfo(name: "users"), session: makeSession(driver: driver), - columnDetails: makeColumns(), safeModeLevel: .confirmWrites + columnDetails: makeColumns(), safeModeLevel: { .confirmWrites } ) vm.startEditing() vm.setEditedValue("Charlie", at: 1) @@ -234,7 +234,7 @@ struct RowDetailViewModelTests { let vm = RowDetailViewModel( columns: makeColumns(), rows: makeRows(), initialIndex: 0, table: TableInfo(name: "users"), session: makeSession(driver: driver), - columnDetails: makeColumns(), safeModeLevel: .readOnly + columnDetails: makeColumns(), safeModeLevel: { .readOnly } ) vm.startEditing() vm.setEditedValue("Charlie", at: 1) @@ -290,4 +290,131 @@ struct RowDetailViewModelTests { #expect(vm.isNullable(at: 1) == true) #expect(vm.isNullable(at: 99) == true) } + + @Test("Stepping stops at the first and last row, and the step flags agree") + func rowStepsClampAtEnds() { + let vm = RowDetailViewModel(columns: makeColumns(), rows: makeRows(), initialIndex: 0) + + #expect(vm.canGoToPreviousRow == false) + #expect(vm.canGoToNextRow == true) + vm.goToPreviousRow() + #expect(vm.currentIndex == 0) + + vm.goToNextRow() + #expect(vm.currentIndex == 1) + #expect(vm.canGoToPreviousRow == true) + #expect(vm.canGoToNextRow == false) + vm.goToNextRow() + #expect(vm.currentIndex == 1) + + vm.goToPreviousRow() + #expect(vm.currentIndex == 0) + } + + @Test("A row being edited cannot be stepped away from, and the navigator hides until editing ends") + func editingHoldsTheRow() { + let vm = RowDetailViewModel(columns: makeColumns(), rows: makeRows(), initialIndex: 0) + #expect(vm.showsRowNavigator) + + vm.startEditing() + #expect(vm.showsRowNavigator == false) + #expect(vm.canGoToPreviousRow == false) + #expect(vm.canGoToNextRow == false) + vm.goToNextRow() + #expect(vm.currentIndex == 0) + + vm.cancelEditing() + #expect(vm.showsRowNavigator) + #expect(vm.canGoToNextRow) + } + + @Test("Only a changed value that Save would write counts as an unsaved edit") + func unsavedEditsTrackTheSaveDiff() { + let vm = RowDetailViewModel(columns: makeColumns(), rows: makeRows(), initialIndex: 0) + #expect(vm.hasUnsavedEdits == false) + + vm.startEditing() + #expect(vm.hasUnsavedEdits == false) + + vm.setEditedValue("Charlie", at: 1) + #expect(vm.hasUnsavedEdits) + vm.setEditedValue("Alice", at: 1) + #expect(vm.hasUnsavedEdits == false) + + vm.toggleNull(at: 1) + #expect(vm.hasUnsavedEdits) + + vm.cancelEditing() + #expect(vm.hasUnsavedEdits == false) + } + + @Test("An edited primary key is never an unsaved edit") + func primaryKeyEditIsIgnored() { + let vm = RowDetailViewModel( + columns: makeColumns(), rows: makeRows(), initialIndex: 0, + table: TableInfo(name: "users"), columnDetails: makeColumns() + ) + vm.startEditing() + vm.setEditedValue("99", at: 0) + + #expect(vm.hasUnsavedEdits == false) + } + + @Test("A successful save leaves no unsaved edit behind") + func savedEditIsClean() async { + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [], rowsAffected: 1, executionTime: 0)) + ] + let vm = RowDetailViewModel( + columns: makeColumns(), rows: makeRows(), initialIndex: 0, + table: TableInfo(name: "users"), session: makeSession(driver: driver), + columnDetails: makeColumns() + ) + vm.startEditing() + vm.setEditedValue("Charlie", at: 1) + + #expect(await vm.saveChanges()) + #expect(vm.hasUnsavedEdits == false) + } + + @Test("Safe mode tightened while the row is open stops editing and saving") + func tightenedSafeModeBlocksWrites() async { + let driver = MockDatabaseDriver() + var level = SafeModeLevel.off + let vm = RowDetailViewModel( + columns: makeColumns(), rows: makeRows(), initialIndex: 0, + table: TableInfo(name: "users"), session: makeSession(driver: driver), + columnDetails: makeColumns(), safeModeLevel: { level } + ) + #expect(vm.canEdit) + vm.startEditing() + vm.setEditedValue("Charlie", at: 1) + + level = .readOnly + + #expect(vm.canEdit == false) + #expect(await vm.saveChanges() == false) + #expect(driver.executedQueries.isEmpty) + } + + @Test("A save deferred for confirmation does not run once safe mode turns read-only") + func deferredSaveRespectsTightenedSafeMode() async { + let driver = MockDatabaseDriver() + var level = SafeModeLevel.confirmWrites + let vm = RowDetailViewModel( + columns: makeColumns(), rows: makeRows(), initialIndex: 0, + table: TableInfo(name: "users"), session: makeSession(driver: driver), + columnDetails: makeColumns(), safeModeLevel: { level } + ) + vm.startEditing() + vm.setEditedValue("Charlie", at: 1) + _ = await vm.saveChanges() + #expect(vm.pendingWriteConfirmation) + + level = .readOnly + + #expect(await vm.executePendingSave() == false) + #expect(driver.executedQueries.isEmpty) + } } diff --git a/TableProMobile/TableProMobileTests/SSH/SSHTunnelCredentialsTests.swift b/TableProMobile/TableProMobileTests/SSH/SSHTunnelCredentialsTests.swift new file mode 100644 index 0000000000..b0092a55a3 --- /dev/null +++ b/TableProMobile/TableProMobileTests/SSH/SSHTunnelCredentialsTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing + +@testable import TableProMobile + +@Suite("SSH tunnel credentials") +struct SSHTunnelCredentialsTests { + @Test("Reads the password, passphrase and key stored for that connection only") + func readsOneConnection() { + let id = UUID() + let other = UUID() + let store = MockSecureStore() + store.seed("com.TablePro.sshpassword.\(id.uuidString)", "ssh-secret") + store.seed("com.TablePro.keypassphrase.\(id.uuidString)", "phrase") + store.seed("com.TablePro.sshkeydata.\(id.uuidString)", "KEY") + store.seed("com.TablePro.sshkeydata.\(other.uuidString)", "OTHER KEY") + + let credentials = SSHTunnelCredentials(connectionId: id, secureStore: store) + + #expect(credentials == SSHTunnelCredentials(password: "ssh-secret", keyPassphrase: "phrase", privateKey: "KEY")) + } + + @Test("Empty stored values read as absent") + func emptyIsAbsent() { + let id = UUID() + let store = MockSecureStore() + store.seed("com.TablePro.sshpassword.\(id.uuidString)", "") + store.seed("com.TablePro.keypassphrase.\(id.uuidString)", "") + store.seed("com.TablePro.sshkeydata.\(id.uuidString)", "") + + let credentials = SSHTunnelCredentials(connectionId: id, secureStore: store) + + #expect(credentials.password == nil) + #expect(credentials.keyPassphrase == nil) + #expect(credentials.privateKey == nil) + } + + @Test("A stored key wins over a key file") + func storedKeyWins() { + let credentials = SSHTunnelCredentials(privateKey: "KEY") + #expect(credentials.privateKeySource(keyPath: "/keys/id_ed25519") == .inMemory("KEY")) + } + + @Test("A key file is used when no key is stored") + func keyFileWithoutStoredKey() { + let credentials = SSHTunnelCredentials() + #expect(credentials.privateKeySource(keyPath: "/keys/id_ed25519") == .file(path: "/keys/id_ed25519")) + } + + @Test("No stored key and no key file leaves nothing to authenticate with") + func neitherIsMissing() { + let credentials = SSHTunnelCredentials() + #expect(credentials.privateKeySource(keyPath: nil) == .missing) + #expect(credentials.privateKeySource(keyPath: "") == .missing) + } +} diff --git a/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift b/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift index cc8cd8a363..c33442db10 100644 --- a/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift +++ b/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift @@ -13,6 +13,7 @@ private final class LibraryStateBox { var tags: [ConnectionTag] = [] var duringPull: () -> Void = {} var duringPush: () -> Void = {} + var duringAccountCheck: () -> Void = {} var syncEnabled = true func runDuringPull() { @@ -22,24 +23,46 @@ private final class LibraryStateBox { func runDuringPush() { duringPush() } + + func runDuringAccountCheck() { + duringAccountCheck() + } } private actor FakeSyncTransport: IOSSyncTransport { let currentZoneID = CKRecordZone.ID(zoneName: "TestZone", ownerName: CKCurrentUserDefaultName) private let remoteRecords: [CKRecord] private let box: LibraryStateBox + private let accountId: String? + private let recordsTheServerNeverHad: Set private(set) var pushedRecords: [CKRecord] = [] + private(set) var pushedDeletions: [CKRecord.ID] = [] private(set) var pullCount = 0 - - init(remoteRecords: [CKRecord], box: LibraryStateBox) { + private(set) var accountLookups = 0 + + init( + remoteRecords: [CKRecord], + box: LibraryStateBox, + accountId: String? = "account-a", + recordsTheServerNeverHad: Set = [] + ) { self.remoteRecords = remoteRecords self.box = box + self.accountId = accountId + self.recordsTheServerNeverHad = recordsTheServerNeverHad } func accountStatus() async throws -> CKAccountStatus { .available } + func currentAccountId() async throws -> String { + accountLookups += 1 + await box.runDuringAccountCheck() + guard let accountId else { throw CKError(.notAuthenticated) } + return accountId + } + func ensureZoneExists() async throws {} func pull(since token: CKServerChangeToken?) async throws -> PullResult { @@ -50,10 +73,18 @@ private actor FakeSyncTransport: IOSSyncTransport { func push(records: [CKRecord], deletions: [CKRecord.ID]) async throws -> PushOutcome { pushedRecords.append(contentsOf: records) + pushedDeletions.append(contentsOf: deletions) await box.runDuringPush() + let missing = deletions.filter { recordsTheServerNeverHad.contains($0.recordName) } return PushOutcome( savedRecords: Dictionary(uniqueKeysWithValues: records.map { ($0.recordID, $0) }), - deletedRecordIDs: Set(deletions) + deletedRecordIDs: Set(deletions).subtracting(missing), + failures: Dictionary(uniqueKeysWithValues: missing.map { recordID in + ( + recordID, + SyncItemFailure(code: .unknownItem, serverRecord: nil, clientRecord: nil, message: "Record not found") + ) + }) ) } } @@ -72,12 +103,15 @@ struct IOSSyncCoordinatorTests { .appendingPathComponent("ios-sync-cache-\(UUID().uuidString)", isDirectory: true) } + private var tokenKey: String { "com.TablePro.sync.serverChangeToken" } + private func makeCoordinator(box: LibraryStateBox, transport: FakeSyncTransport) -> IOSSyncCoordinator { let coordinator = IOSSyncCoordinator( metadata: metadata, recordCache: SyncRecordCache(directory: cacheDirectory, defaults: nil), makeTransport: { transport }, - isEnabled: { box.syncEnabled } + isEnabled: { box.syncEnabled }, + notificationCenter: NotificationCenter() ) coordinator.getCurrentState = { (box.connections, box.groups, box.tags) } coordinator.onConnectionsChanged = { box.connections = $0 } @@ -173,6 +207,7 @@ struct IOSSyncCoordinatorTests { await coordinator.sync() #expect(await transport.pullCount == 0) + #expect(await transport.accountLookups == 0) #expect(await transport.pushedRecords.isEmpty) #expect(metadata.dirtyIds(for: .connection).contains(local.id.uuidString)) #expect(metadata.tombstones(for: .connection).count == 1) @@ -260,4 +295,248 @@ struct IOSSyncCoordinatorTests { #expect(coordinator.status == .disabled(.userDisabled)) #expect(coordinator.lastSyncDate == nil) } + + // MARK: - iCloud account + + @Test("Signing in to a different Apple Account starts sync over and still sends the edits waiting to go up") + func accountSwitchResetsSyncState() async throws { + let box = LibraryStateBox() + let local = DatabaseConnection(name: "Prod", type: .postgresql) + box.connections = [local] + let cachedID = SyncRecordMapper.recordID(type: .connection, id: local.id.uuidString, in: zoneID) + let cache = SyncRecordCache(directory: cacheDirectory, defaults: nil) + let staleRecord = SyncRecordMapper.toRecord(local, zoneID: zoneID) + staleRecord["staleAccountMarker"] = "account-a" as CKRecordValue + cache.store([staleRecord]) + metadata.lastAccountId = "account-a" + metadata.lastSyncDate = Date() + metadata.userDefaults.set(Data([1, 2, 3]), forKey: tokenKey) + let transport = FakeSyncTransport(remoteRecords: [], box: box, accountId: "account-b") + let coordinator = makeCoordinator(box: box, transport: transport) + coordinator.markDirty(local.id) + coordinator.markDeleted(UUID()) + var lastSyncDateDuringPull: Date? = Date() + var cachedDuringPull: CKRecord? + box.duringPull = { + lastSyncDateDuringPull = coordinator.lastSyncDate + cachedDuringPull = cache.record(for: cachedID) + } + + await coordinator.sync() + + let pushed = await transport.pushedRecords + #expect(pushed.compactMap(SyncRecordMapper.toConnection).map(\.id) == [local.id]) + #expect(pushed.allSatisfy { $0["staleAccountMarker"] == nil }) + #expect(await transport.pushedDeletions.isEmpty) + #expect(cachedDuringPull == nil) + #expect(metadata.dirtyIds(for: .connection).isEmpty) + #expect(metadata.tombstones(for: .connection).isEmpty) + #expect(metadata.userDefaults.data(forKey: tokenKey) == nil) + #expect(lastSyncDateDuringPull == nil) + #expect(metadata.lastAccountId == "account-b") + #expect(box.connections.contains { $0.id == local.id }) + #expect(coordinator.status == .idle) + } + + @Test("A connection added while the new account is being looked up reaches that account") + func editDuringAccountLookupIsPushed() async throws { + let box = LibraryStateBox() + metadata.lastAccountId = "account-a" + let transport = FakeSyncTransport(remoteRecords: [], box: box, accountId: "account-b") + let coordinator = makeCoordinator(box: box, transport: transport) + let added = DatabaseConnection(name: "Prod", type: .postgresql) + box.duringAccountCheck = { + box.connections.append(added) + coordinator.markDirty(added.id) + } + + await coordinator.sync() + + let pushed = await transport.pushedRecords.compactMap(SyncRecordMapper.toConnection) + #expect(pushed.map(\.id) == [added.id]) + #expect(metadata.dirtyIds(for: .connection).isEmpty) + #expect(metadata.lastAccountId == "account-b") + } + + @Test("The same account keeps its queued edits, deletions and change token") + func sameAccountKeepsState() async throws { + let box = LibraryStateBox() + let local = DatabaseConnection(name: "Prod", type: .postgresql) + box.connections = [local] + let deleted = UUID() + metadata.lastAccountId = "account-a" + metadata.userDefaults.set(Data([1, 2, 3]), forKey: tokenKey) + let transport = FakeSyncTransport(remoteRecords: [], box: box, accountId: "account-a") + let coordinator = makeCoordinator(box: box, transport: transport) + coordinator.markDirty(local.id) + coordinator.markDeleted(deleted) + + await coordinator.sync() + + let pushed = await transport.pushedRecords.compactMap(SyncRecordMapper.toConnection).map(\.id) + #expect(pushed == [local.id]) + #expect(await transport.pushedDeletions.map(\.recordName).contains { $0.contains(deleted.uuidString) }) + #expect(metadata.userDefaults.data(forKey: tokenKey) == Data([1, 2, 3])) + #expect(metadata.lastAccountId == "account-a") + } + + @Test("With no account recorded yet, queued changes go up and the account is recorded") + func firstSeenAccountPushesQueue() async throws { + let box = LibraryStateBox() + let local = DatabaseConnection(name: "Prod", type: .postgresql) + box.connections = [local] + let transport = FakeSyncTransport(remoteRecords: [], box: box, accountId: "account-a") + let coordinator = makeCoordinator(box: box, transport: transport) + coordinator.markDirty(local.id) + + await coordinator.sync() + + let pushed = await transport.pushedRecords.compactMap(SyncRecordMapper.toConnection).map(\.id) + #expect(pushed == [local.id]) + #expect(metadata.lastAccountId == "account-a") + } + + @Test("A device that synced on a build that never recorded its account starts over once and keeps its queue") + func unrecordedAccountStartsOverOnce() async throws { + let box = LibraryStateBox() + let local = DatabaseConnection(name: "Prod", type: .postgresql) + box.connections = [local] + let deleted = UUID() + let cachedID = SyncRecordMapper.recordID(type: .connection, id: local.id.uuidString, in: zoneID) + let cache = SyncRecordCache(directory: cacheDirectory, defaults: nil) + let staleRecord = SyncRecordMapper.toRecord(local, zoneID: zoneID) + staleRecord["staleAccountMarker"] = "account-a" as CKRecordValue + cache.store([staleRecord]) + metadata.lastSyncDate = Date() + metadata.userDefaults.set(Data([1, 2, 3]), forKey: tokenKey) + let transport = FakeSyncTransport(remoteRecords: [], box: box, accountId: "account-b") + let coordinator = makeCoordinator(box: box, transport: transport) + coordinator.markDirty(local.id) + coordinator.markDeleted(deleted) + var cachedDuringPull: CKRecord? = staleRecord + box.duringPull = { cachedDuringPull = cache.record(for: cachedID) } + + await coordinator.sync() + + let pushed = await transport.pushedRecords + #expect(cachedDuringPull == nil) + #expect(pushed.compactMap(SyncRecordMapper.toConnection).map(\.id) == [local.id]) + #expect(pushed.allSatisfy { $0["staleAccountMarker"] == nil }) + #expect(await transport.pushedDeletions.map(\.recordName).contains { $0.contains(deleted.uuidString) }) + #expect(metadata.userDefaults.data(forKey: tokenKey) == nil) + #expect(metadata.lastAccountId == "account-b") + + var cachedDuringSecondPull: CKRecord? + box.duringPull = { cachedDuringSecondPull = cache.record(for: cachedID) } + coordinator.markDirty(local.id) + await coordinator.sync() + + #expect(cachedDuringSecondPull != nil) + } + + @Test("An edit made during the first pull for a new account is pushed to that account") + func editAfterSwitchIsPushed() async throws { + let box = LibraryStateBox() + let local = DatabaseConnection(name: "Old", type: .mysql) + box.connections = [local] + metadata.lastAccountId = "account-a" + let transport = FakeSyncTransport(remoteRecords: [], box: box, accountId: "account-b") + let coordinator = makeCoordinator(box: box, transport: transport) + coordinator.markDirty(local.id) + box.duringPull = { + box.connections[0].name = "Renamed" + coordinator.markDirty(local.id) + } + + await coordinator.sync() + + let pushed = await transport.pushedRecords.compactMap(SyncRecordMapper.toConnection) + #expect(pushed.map(\.id) == [local.id]) + #expect(pushed.first?.name == "Renamed") + } + + @Test("After an account change made while sync was off, edits go up to the new account and deletions do not") + func accountChangedWhileOffSendsEditsOnly() async throws { + let box = LibraryStateBox() + let local = DatabaseConnection(name: "Local", type: .mysql) + box.connections = [local] + box.syncEnabled = false + metadata.lastAccountId = "account-a" + let transport = FakeSyncTransport(remoteRecords: [], box: box, accountId: "account-b") + let coordinator = makeCoordinator(box: box, transport: transport) + coordinator.markDirty(local.id) + coordinator.markDeleted(UUID()) + + box.syncEnabled = true + coordinator.setEnabled(true) + await coordinator.sync() + + let pushed = await transport.pushedRecords.compactMap(SyncRecordMapper.toConnection) + #expect(pushed.map(\.id) == [local.id]) + #expect(await transport.pushedDeletions.isEmpty) + #expect(box.connections == [local]) + } + + @Test("Turning sync off during the account check keeps the recorded account and the queue") + func disablingDuringAccountCheckKeepsState() async throws { + let box = LibraryStateBox() + let local = DatabaseConnection(name: "Local", type: .mysql) + box.connections = [local] + metadata.lastAccountId = "account-a" + let transport = FakeSyncTransport(remoteRecords: [], box: box, accountId: "account-b") + let coordinator = makeCoordinator(box: box, transport: transport) + coordinator.markDirty(local.id) + box.duringAccountCheck = { + box.syncEnabled = false + coordinator.setEnabled(false) + } + + await coordinator.sync() + + #expect(metadata.lastAccountId == "account-a") + #expect(metadata.dirtyIds(for: .connection).contains(local.id.uuidString)) + #expect(await transport.pullCount == 0) + #expect(coordinator.status == .disabled(.userDisabled)) + } + + @Test("An account that cannot be looked up is never pulled, and nothing recorded is dropped") + func failedAccountLookupNeverPulls() async throws { + let box = LibraryStateBox() + metadata.lastAccountId = "account-a" + metadata.userDefaults.set(Data([1, 2, 3]), forKey: tokenKey) + let transport = FakeSyncTransport(remoteRecords: [], box: box, accountId: nil) + let coordinator = makeCoordinator(box: box, transport: transport) + + await coordinator.sync() + + #expect(await transport.pullCount == 0) + #expect(coordinator.status == .error(.accountUnavailable)) + #expect(metadata.lastAccountId == "account-a") + #expect(metadata.userDefaults.data(forKey: tokenKey) == Data([1, 2, 3])) + } + + @Test("Deleting a connection the new account never had clears its deletion instead of retrying it") + func deletionTheServerNeverHadIsCleared() async throws { + let box = LibraryStateBox() + let kept = DatabaseConnection(name: "Kept", type: .mysql) + box.connections = [kept] + metadata.lastAccountId = "account-a" + let recordName = SyncRecordMapper.recordID(type: .connection, id: kept.id.uuidString, in: zoneID).recordName + let transport = FakeSyncTransport( + remoteRecords: [], + box: box, + accountId: "account-b", + recordsTheServerNeverHad: [recordName] + ) + let coordinator = makeCoordinator(box: box, transport: transport) + await coordinator.sync() + + box.connections = [] + coordinator.markDeleted(kept.id) + await coordinator.sync() + + #expect(await transport.pushedDeletions.map(\.recordName) == [recordName]) + #expect(metadata.tombstones(for: .connection).isEmpty) + #expect(coordinator.status == .idle) + } } diff --git a/TableProMobile/TableProMobileTests/Views/BottomSafeAreaBarLayoutTests.swift b/TableProMobile/TableProMobileTests/Views/BottomSafeAreaBarLayoutTests.swift new file mode 100644 index 0000000000..5eb219a01d --- /dev/null +++ b/TableProMobile/TableProMobileTests/Views/BottomSafeAreaBarLayoutTests.swift @@ -0,0 +1,177 @@ +import SwiftUI +@testable import TableProMobile +import Testing +import UIKit + +@MainActor +@Suite("Bottom safe area bar layout") +struct BottomSafeAreaBarLayoutTests { + @Test("A bar placed on a tab's content clears the tab bar and takes its own touches", .timeLimit(.minutes(1))) + func barClearsTheTabBar() async throws { + guard UIDevice.current.userInterfaceIdiom == .phone else { return } + let probe = LayoutProbe() + let host = try HostedTree(probe: probe, variant: .bar) + defer { host.tearDown() } + + let tabBar = try await host.settledTabBar() + let marker = probe.markerFrame + + #expect(!marker.isEmpty) + #expect(!marker.intersects(tabBar.convert(tabBar.bounds, to: host.window))) + let hit = host.window.hitTest(CGPoint(x: marker.midX, y: marker.midY), with: nil) + #expect(hit.map { !$0.isDescendant(of: tabBar) } ?? false) + } + + @Test("A hidden bar leaves no blank strip above the tab bar", .timeLimit(.minutes(1))) + func emptyBarAddsNoInset() async throws { + guard UIDevice.current.userInterfaceIdiom == .phone else { return } + let emptyProbe = LayoutProbe() + let emptyHost = try HostedTree(probe: emptyProbe, variant: .emptyBar) + defer { emptyHost.tearDown() } + _ = try await emptyHost.settledTabBar() + + let plainProbe = LayoutProbe() + let plainHost = try HostedTree(probe: plainProbe, variant: .noBar) + defer { plainHost.tearDown() } + _ = try await plainHost.settledTabBar() + + #expect(emptyProbe.listInsets.bottom == plainProbe.listInsets.bottom) + } +} + +@MainActor +private final class LayoutProbe { + var markerFrame: CGRect = .zero + var listInsets = EdgeInsets() + private var unseenChanges = 0 + private var waiter: CheckedContinuation? + + func record() { + unseenChanges += 1 + waiter?.resume() + waiter = nil + } + + func nextChange() async { + if unseenChanges == 0 { + await withCheckedContinuation { waiter = $0 } + } + unseenChanges = 0 + } +} + +@MainActor +private struct HostedTree { + enum Variant { + case bar + case emptyBar + case noBar + } + + let window: UIWindow + let probe: LayoutProbe + + init(probe: LayoutProbe, variant: Variant) throws { + let scene = try #require( + UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + ) + window = UIWindow(windowScene: scene) + self.probe = probe + window.rootViewController = UIHostingController(rootView: ProbeTabs(probe: probe, variant: variant)) + window.makeKeyAndVisible() + } + + func settledTabBar() async throws -> UITabBar { + while true { + window.layoutIfNeeded() + if let tabBar = visibleTabBar(in: window), isSettled(against: tabBar) { + return tabBar + } + await probe.nextChange() + } + } + + func tearDown() { + window.isHidden = true + window.rootViewController = nil + } + + private func isSettled(against tabBar: UITabBar) -> Bool { + let tabBarFrame = tabBar.convert(tabBar.bounds, to: window) + guard !tabBarFrame.isEmpty else { return false } + let tabBarBand = window.bounds.maxY - tabBarFrame.minY + return probe.listInsets.bottom >= tabBarBand - 0.5 + } + + private func visibleTabBar(in view: UIView) -> UITabBar? { + if let tabBar = view as? UITabBar, !tabBar.isHidden, tabBar.alpha > 0.01 { + return tabBar + } + for subview in view.subviews { + if let found = visibleTabBar(in: subview) { + return found + } + } + return nil + } +} + +private struct ProbeTabs: View { + let probe: LayoutProbe + let variant: HostedTree.Variant + + var body: some View { + TabView { + Tab("Tables", systemImage: "tablecells") { + NavigationStack { + content + .navigationTitle("Rows") + } + } + Tab("Query", systemImage: "terminal") { + Text(verbatim: "Query") + } + } + .tabViewStyle(.sidebarAdaptable) + } + + @ViewBuilder + private var content: some View { + switch variant { + case .bar: + rows.bottomSafeAreaBar { marker } + case .emptyBar: + rows.bottomSafeAreaBar { + if variant == .bar { + marker + } + } + case .noBar: + rows + } + } + + private var rows: some View { + List(0..<50, id: \.self) { index in + Text(verbatim: "Row \(index)") + } + .onGeometryChange(for: EdgeInsets.self) { proxy in + proxy.safeAreaInsets + } action: { insets in + probe.listInsets = insets + probe.record() + } + } + + private var marker: some View { + Color.red + .frame(height: 44) + .frame(maxWidth: .infinity) + .onGeometryChange(for: CGRect.self) { proxy in + proxy.frame(in: .global) + } action: { frame in + probe.markerFrame = frame + probe.record() + } + } +} diff --git a/TableProMobile/TableProMobileTests/Views/ConnectedScreenTests.swift b/TableProMobile/TableProMobileTests/Views/ConnectedScreenTests.swift new file mode 100644 index 0000000000..cab289894d --- /dev/null +++ b/TableProMobile/TableProMobileTests/Views/ConnectedScreenTests.swift @@ -0,0 +1,51 @@ +import Foundation +@testable import TableProMobile +import Testing + +@MainActor +@Suite("Connected screen") +struct ConnectedScreenTests { + private let failure = AppError( + category: .network, + title: "Connection Lost", + message: "The server closed the connection.", + recovery: nil, + underlying: nil + ) + + private func showsTabs(_ screen: ConnectedScreen) -> Bool { + guard case .tabs = screen else { return false } + return true + } + + @Test("A connected session shows the tabs whether or not an editor holds them") + func connectedShowsTabs() { + #expect(showsTabs(ConnectedScreen.resolve(phase: .connected, isHeldByEditor: false))) + #expect(showsTabs(ConnectedScreen.resolve(phase: .connected, isHeldByEditor: true))) + } + + @Test("A failure with no unsaved edits shows the error") + func failureShowsError() { + let screen = ConnectedScreen.resolve(phase: .error(failure), isHeldByEditor: false) + guard case .failed(let error) = screen else { + Issue.record("Expected the error screen, got \(screen)") + return + } + #expect(error.title == failure.title) + } + + @Test("A failed reconnect under unsaved edits keeps the tabs and the editor on screen") + func failureUnderEditsKeepsTabs() { + #expect(showsTabs(ConnectedScreen.resolve(phase: .error(failure), isHeldByEditor: true))) + #expect(showsTabs(ConnectedScreen.resolve(phase: .connecting, isHeldByEditor: true))) + } + + @Test("Connecting with no unsaved edits shows the connecting screen") + func connectingShowsProgress() { + let screen = ConnectedScreen.resolve(phase: .connecting, isHeldByEditor: false) + guard case .connecting = screen else { + Issue.record("Expected the connecting screen, got \(screen)") + return + } + } +} diff --git a/TableProTests/Core/Services/Export/ConnectionExportDataTests.swift b/TableProTests/Core/Services/Export/ConnectionExportDataTests.swift index 71522b80db..9b70816678 100644 --- a/TableProTests/Core/Services/Export/ConnectionExportDataTests.swift +++ b/TableProTests/Core/Services/Export/ConnectionExportDataTests.swift @@ -31,21 +31,21 @@ struct ConnectionExportDataTests { } @Test("exportEncryptedData decrypts with the right passphrase") - func testEncryptedRoundTrip() throws { + func testEncryptedRoundTrip() async throws { let connections = [makeConnection(name: "Secret")] - let data = try ConnectionExportService.exportEncryptedData(connections, passphrase: "correct horse") + let data = try await ConnectionExportService.exportEncryptedData(connections, passphrase: "correct horse") #expect(ConnectionExportCrypto.isEncrypted(data)) - let envelope = try ConnectionImportDecoder.decodeEncryptedData(data, passphrase: "correct horse") + let envelope = try await ConnectionImportDecoder.decodeEncryptedData(data, passphrase: "correct horse") #expect(envelope.connections.map(\.name) == ["Secret"]) } @Test("exportEncryptedData fails to decrypt with the wrong passphrase") - func testEncryptedWrongPassphrase() throws { - let data = try ConnectionExportService.exportEncryptedData([makeConnection()], passphrase: "right-one") + func testEncryptedWrongPassphrase() async throws { + let data = try await ConnectionExportService.exportEncryptedData([makeConnection()], passphrase: "right-one") - #expect(throws: (any Error).self) { - try ConnectionImportDecoder.decodeEncryptedData(data, passphrase: "wrong-one") + await #expect(throws: (any Error).self) { + try await ConnectionImportDecoder.decodeEncryptedData(data, passphrase: "wrong-one") } } } diff --git a/docs/ios/index.mdx b/docs/ios/index.mdx index 913b6a9d5c..c22577bfc6 100644 --- a/docs/ios/index.mdx +++ b/docs/ios/index.mdx @@ -61,32 +61,39 @@ Favorites come first, then **Recent** with the five connections opened last, the | Change several at once | Tap **Edit**, select the rows, and tap **Move**, **Favorite** or **Delete** | | Delete | Swipe left, or touch and hold and choose **Delete** | | Edit or delete a group | Swipe right on the group to edit it, swipe left to delete it | +| Delete a tag | Open **More > Manage Tags**, then swipe left on the tag, or touch and hold it and choose **Delete Tag**. Built-in tags cannot be deleted | | Sync now | Pull down on the list, with iCloud Sync on | **Duplicate** copies the saved password, SSH secrets, client certificates and file access along with the settings. Recent and the sort order stay on the device. +A connection, group or tag form with unsaved changes stays open when swiped down, and **Cancel** asks before it discards them. + ## The sample database **Open Sample Database** sits on the empty list and in the **More** menu. It opens Chinook, a SQLite database of a music store, at its **Track** table, with no server to set up. The sample stays on this device: it never syncs, exports or hands off to another device. To throw away your edits, touch and hold **Chinook (Sample)** and choose **Reset Database**. ## What syncs over -The same CloudKit container as the Mac. iCloud Sync is off on both until you turn it on: here during first launch or under **Settings > iCloud**. While it is off nothing leaves the device, and edits made in the meantime go up when you turn it back on. Three record types cross: connections (host, port, SSH tunnel and SSL settings, color, safe mode level, favorite), groups with their nesting, and tags. SSH profiles, table favorites, saved queries, and app settings are skipped, and query history on the phone is only what you ran on the phone. +The same CloudKit container as the Mac. iCloud Sync is off on both until you turn it on: here during first launch or under **Settings > iCloud**. While it is off nothing leaves the device, and edits made in the meantime go up when you turn it back on, provided the device is still signed in to the same Apple Account. Sign in to a different Apple Account and sync starts over for that account: its connections come down, edits not yet sent go up to it, deletions not yet sent are dropped, and the other connections already on the device stay and go up the next time you edit them. Three record types cross: connections (host, port, SSH tunnel and SSL settings, color, safe mode level, favorite), groups with their nesting, and tags. SSH profiles, table favorites, saved queries, and app settings are skipped, and query history on the phone is only what you ran on the phone. Three things a connection depends on are per device and never sync: | Per device | What you see | What to do | | --- | --- | --- | | CA and client certificates | The SSL section reads **Not set** | Import them here. One PKCS#12 file covers the client certificate and its key | -| An SSH private key | It arrived as a path on the Mac, so the tunnel cannot find it | Pick the key on this device, or paste it into **Private Key** | -| SQLite and DuckDB file paths | The path points at the Mac's disk | Pick the file again here | +| An SSH key file | It arrived as a path on the Mac, so the tunnel cannot find it | Pick the key on this device, or paste it into **Private Key** | +| SQLite and DuckDB files | Opening one says the file isn't available on this device | Edit the connection and pick the file here | + +Passwords are a separate opt-in, off by default, at **Settings > iCloud > Sync Passwords**. They ride iCloud Keychain and only new saves are affected, so re-save a password on the Mac to push it across. A private key saved on this device follows the same setting: with **Sync Passwords** on, iCloud Keychain copies the key to your other iPhones and iPads, which use it, and to your Mac, which does not. Pull down on the connection list to sync now; a background refresh runs about every 30 minutes. -Passwords are a separate opt-in, off by default, at **Settings > iCloud > Sync Passwords**. They ride iCloud Keychain and only new saves are affected, so re-save a password on the Mac to push it across. Pull down on the connection list to sync now; a background refresh runs about every 30 minutes. +A change that syncs in while a connection is open leaves the screen where it is. A change to how it connects, such as a new host, port, username, database, SSH tunnel or SSL mode, reconnects it once no row or form on it has unsaved changes. Saving a new password, SSH password, key passphrase, private key or certificate for an open connection reconnects it the same way. A connection deleted on another device shows **Connection Deleted** before returning to the list. Picking a SQLite file copies it into the app, and edits go to that copy, so the original never changes and the two drift apart. DuckDB writes back to the file you picked. +A database created with **Create New Database**, or copied in by picking it, stays with its connection through an app update and a restore from backup. A file that is gone is reported as unavailable, and so is a file connection synced from another iPhone or iPad; the connection never opens an empty database, or a file of the same name on this device, in its place. + ## What works An open connection fills the screen and carries four sections: **Tables**, **Query**, **History**, **Info**. They sit in a tab bar on iPhone and in a sidebar on iPad. `Cmd+1` through `Cmd+4` switch between them on a keyboard, a toolbar menu switches database and schema when the engine has more than one, and **Connections** returns to the list. @@ -97,7 +104,7 @@ Search the table list by name. Page a table at 50, 100, 200, or 500 rows, jump t ### Editing -Tap a row to open it full screen, page between rows, edit values, toggle one to `NULL`, and save. Inserting and deleting rows, and truncating or dropping a table, are here too. Editing needs a primary key. +Tap a row to open it full screen, page between rows, edit values, toggle one to `NULL`, and save. While you edit, **Cancel** takes the place of the back button and asks before it throws away a changed value, and so does **Connections** in another tab. Inserting and deleting rows, and truncating or dropping a table, are here too. Editing needs a primary key. A new row starts with every column on **DEFAULT**, which leaves that column out of the `INSERT` so the database fills it in. The badge beside a field switches it between **DEFAULT**, **NULL** and a typed value, and **NULL** is offered on nullable columns only. Generated columns are never written, and an auto-increment key stays on **DEFAULT** until you type one. @@ -121,7 +128,7 @@ Turn on Face ID, Touch ID, or Optic ID under **Settings > Security**. A cold lau Each connection carries its own [safe mode](/features/safe-mode) level, synced from the Mac and settable here. iOS has three: **Off**, **Confirm Writes**, and **Read-Only**, which refuses writes outright. **Settings > New Connections** sets the level a new connection starts at. -SSH tunnels authenticate with a password or a private key, and an unknown host key prompts with its fingerprint first. +SSH tunnels authenticate with a password or a private key, and an unknown host key prompts with its fingerprint first. A key pasted under **Paste Key**, or picked as a text file, is kept in the Keychain beside the connection's passwords, never in the saved connection list. A key file that is not text is copied into the app and read from there. Setting **Auth Method** to **Password**, choosing **Import File**, or turning **SSH Tunnel** off deletes the saved key when you tap **Save**. ## Privacy