From 8972309ada621494e12fe31e044f96ff54318df6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 18 Sep 2026 20:29:30 +0700 Subject: [PATCH 01/14] fix(ios): keep connection data correct across edits, account changes and restores --- CHANGELOG.md | 4 + .../CloudKitSyncEngine.swift | 2 +- .../TableProSyncTransport/PushOutcome.swift | 7 + .../SyncMetadataStorage.swift | 26 + .../SyncRecordCache.swift | 11 + .../FieldLevelMergeTests.swift | 33 ++ .../TableProSyncTests/PushOutcomeTests.swift | 23 + .../SyncMetadataStorageTests.swift | 53 ++ TableProMobile/TableProMobile/AppState.swift | 70 +-- .../Coordinators/ConnectionCoordinator.swift | 1 - .../Coordinators/ScenePresenter.swift | 13 + .../TableProMobile/Drivers/DuckDBDriver.swift | 73 +-- .../TableProMobile/Drivers/SQLiteDriver.swift | 36 +- .../TableProMobile/Helpers/AppError.swift | 10 + .../Helpers/ConnectionDetailFormatter.swift | 4 +- .../Helpers/ConnectionFormEdits.swift | 107 ++++ .../Helpers/ConnectionLibraryEditing.swift | 64 ++- .../Helpers/LibraryFormEdits.swift | 79 +++ .../Helpers/LibraryWriteOutcome.swift | 75 +++ .../Platform/AppContainerHistory.swift | 27 + .../Platform/AppContainerPaths.swift | 86 ++++ .../Platform/IOSDriverFactory.swift | 64 ++- .../Platform/LocalDatabaseFileAccess.swift | 45 ++ .../Platform/LocalDatabaseFileCreator.swift | 52 ++ .../Platform/LocalDatabaseFileLocator.swift | 109 +++++ .../Platform/LocalDatabaseLocation.swift | 65 +++ .../TableProMobile/SSH/IOSSSHProvider.swift | 5 +- .../Services/IOSConnectionImportService.swift | 5 +- .../Sync/IOSSyncCoordinator.swift | 25 +- .../Sync/IOSSyncTransport.swift | 1 + .../ViewModels/ConnectionFormViewModel.swift | 462 +++++++++++------- .../Views/Components/GroupFormSheet.swift | 29 +- .../Components/LibraryWriteFailureAlert.swift | 35 ++ .../Views/Components/TagFormSheet.swift | 24 +- .../TableProMobile/Views/ConnectedView.swift | 15 +- .../Views/ConnectionFormView.swift | 33 +- .../Views/ConnectionInfoView.swift | 22 +- .../Views/ConnectionListView.swift | 23 +- .../Views/TagManagementView.swift | 8 +- .../ConnectionFormViewModelDuckDBTests.swift | 37 +- .../ConnectionFormViewModelTests.swift | 85 +++- .../ConnectionDetailFormatterTests.swift | 2 +- .../ConnectionFormEditsTests.swift | 190 +++++++ ...ectionFormViewModelPreservationTests.swift | 8 +- .../ConnectionLibraryEditingTests.swift | 85 +++- .../LibraryFormEditsTests.swift | 68 +++ .../DatabaseFileErrorClassifierTests.swift | 20 + .../Drivers/DuckDBDriverSuspensionTests.swift | 6 +- .../Drivers/DuckDBDriverTests.swift | 44 +- .../Drivers/SQLiteDriverFileTests.swift | 45 ++ .../Onboarding/AppStateFixture.swift | 126 +++++ .../Onboarding/AppStateLibraryTests.swift | 381 +++++++++++++-- .../Platform/AppContainerPathsTests.swift | 130 +++++ .../IOSDriverFactoryLocalFileTests.swift | 119 +++++ .../LocalDatabaseFileLocatorTests.swift | 130 +++++ .../Sync/IOSSyncCoordinatorTests.swift | 249 +++++++++- docs/ios/index.mdx | 6 +- 57 files changed, 3104 insertions(+), 453 deletions(-) create mode 100644 TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift create mode 100644 TableProMobile/TableProMobile/Helpers/LibraryFormEdits.swift create mode 100644 TableProMobile/TableProMobile/Helpers/LibraryWriteOutcome.swift create mode 100644 TableProMobile/TableProMobile/Platform/AppContainerHistory.swift create mode 100644 TableProMobile/TableProMobile/Platform/AppContainerPaths.swift create mode 100644 TableProMobile/TableProMobile/Platform/LocalDatabaseFileAccess.swift create mode 100644 TableProMobile/TableProMobile/Platform/LocalDatabaseFileCreator.swift create mode 100644 TableProMobile/TableProMobile/Platform/LocalDatabaseFileLocator.swift create mode 100644 TableProMobile/TableProMobile/Platform/LocalDatabaseLocation.swift create mode 100644 TableProMobile/TableProMobile/Views/Components/LibraryWriteFailureAlert.swift create mode 100644 TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift create mode 100644 TableProMobile/TableProMobileTests/ConnectionLibrary/LibraryFormEditsTests.swift create mode 100644 TableProMobile/TableProMobileTests/DatabaseFileErrorClassifierTests.swift create mode 100644 TableProMobile/TableProMobileTests/Drivers/SQLiteDriverFileTests.swift create mode 100644 TableProMobile/TableProMobileTests/Onboarding/AppStateFixture.swift create mode 100644 TableProMobile/TableProMobileTests/Platform/AppContainerPathsTests.swift create mode 100644 TableProMobile/TableProMobileTests/Platform/IOSDriverFactoryLocalFileTests.swift create mode 100644 TableProMobile/TableProMobileTests/Platform/LocalDatabaseFileLocatorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b227ec1a7..a964e8a959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,10 @@ 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. ### Security 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..6b23f0d51c 100644 --- a/Packages/TableProCore/Sources/TableProSyncTransport/SyncMetadataStorage.swift +++ b/Packages/TableProCore/Sources/TableProSyncTransport/SyncMetadataStorage.swift @@ -12,6 +12,12 @@ public struct Tombstone: Codable, Sendable { } } +public enum SyncAccountChange: Equatable, Sendable { + case firstSeen + case unchanged + case switched +} + public final class SyncMetadataStorage: @unchecked Sendable { private static let logger = Logger(subsystem: "com.TablePro", category: "SyncMetadataStorage") @@ -143,6 +149,26 @@ 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 + return .firstSeen + } + guard recorded != accountId else { return .unchanged } + forgetServerState() + lastAccountId = accountId + return .switched + } + + private func forgetServerState() { + saveToken(nil) + userDefaults.removeObject(forKey: key("lastSyncDate")) + for type in SyncRecordType.allCases { + clearTombstones(type: type) + } + } + // 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/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..6db2a2f357 100644 --- a/Packages/TableProCore/Tests/TableProSyncTests/SyncMetadataStorageTests.swift +++ b/Packages/TableProCore/Tests/TableProSyncTests/SyncMetadataStorageTests.swift @@ -90,6 +90,59 @@ struct SyncMetadataStorageTests { #expect(storage.lastAccountId == "account") } + @Test("The first account seen 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) + defaults.set(Data([1, 2, 3]), forKey: "com.TablePro.sync.serverChangeToken") + + #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"]) + #expect(defaults.data(forKey: "com.TablePro.sync.serverChangeToken") == Data([1, 2, 3])) + } + + @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/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index 9b146d0c29..50dd80205a 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -40,6 +40,7 @@ final class AppState { let libraryPreferences: ConnectionLibraryPreferences let sshProvider: IOSSSHProvider let secureStore: KeychainSecureStore + let localDatabaseFiles: LocalDatabaseFileLocator private let sampleInstaller: SampleDatabaseInstaller private let storage: ConnectionPersistence @@ -50,19 +51,23 @@ final class AppState { libraryDirectory: URL = LibraryStorage.defaultDirectory, defaults: UserDefaults = .standard, syncCoordinator injectedSyncCoordinator: IOSSyncCoordinator? = nil, - sampleInstaller: SampleDatabaseInstaller = .live + sampleInstaller: SampleDatabaseInstaller = .live, + localDatabaseFiles: LocalDatabaseFileLocator = .live, + bookmarkStore: FileBookmarkStore = FileBookmarkStore() ) { self.sampleInstaller = sampleInstaller + 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 driverFactory = IOSDriverFactory(bookmarkStore: bookmarkStore, localFiles: localDatabaseFiles) let secureStore = KeychainSecureStore() self.secureStore = secureStore - let sshProvider = IOSSSHProvider(secureStore: secureStore) + let sshProvider = IOSSSHProvider(secureStore: secureStore, container: localDatabaseFiles.container) self.sshProvider = sshProvider let connectionManager = ConnectionManager( driverFactory: driverFactory, @@ -215,13 +220,15 @@ final class AppState { } @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 +319,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,23 +372,26 @@ 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) { diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index c65754040d..1dff5bf932 100644 --- a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -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() diff --git a/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift b/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift index 17ac0a1c21..a6259fc3ab 100644 --- a/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift +++ b/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift @@ -49,6 +49,7 @@ 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 @@ -92,6 +93,18 @@ 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 } 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/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..4332fc0e3c --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift @@ -0,0 +1,107 @@ +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? + var privateKeyData: 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 { + guard let opening else { return true } + return opening[keyPath: field] != self[keyPath: field] + } + + 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) } + if changed(\.oracle) { applyOracleOptions(to: &connection) } + return connection + } + + 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) { + guard let sshTunnel else { + connection.sshEnabled = false + connection.sshConfiguration = nil + return + } + var configuration = connection.sshConfiguration ?? SSHConfiguration() + configuration.host = sshTunnel.host + configuration.port = sshTunnel.port + configuration.username = sshTunnel.username + configuration.authMethod = sshTunnel.authMethod + configuration.privateKeyPath = sshTunnel.privateKeyPath + configuration.privateKeyData = sshTunnel.privateKeyData + if configuration.macEnabled != nil { + configuration.macEnabled = true + } + connection.sshEnabled = true + connection.sshConfiguration = configuration + } + + private func applyOracleOptions(to connection: inout DatabaseConnection) { + guard let oracle else { return } + typealias Key = OracleConnectionOptions.AdditionalFieldKey + connection.additionalFields[Key.connectionType] = oracle.identifierMode.rawValue + connection.additionalFields[Key.serviceName] = oracle.serviceName + connection.additionalFields[Key.sid] = oracle.sid + connection.additionalFields[Key.role] = oracle.role.rawValue + connection.additionalFields[Key.networkEncryption] = oracle.networkEncryption.rawValue + } +} diff --git a/TableProMobile/TableProMobile/Helpers/ConnectionLibraryEditing.swift b/TableProMobile/TableProMobile/Helpers/ConnectionLibraryEditing.swift index b4fce66700..a04bab1af9 100644 --- a/TableProMobile/TableProMobile/Helpers/ConnectionLibraryEditing.swift +++ b/TableProMobile/TableProMobile/Helpers/ConnectionLibraryEditing.swift @@ -46,24 +46,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 +192,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]) { diff --git a/TableProMobile/TableProMobile/Helpers/LibraryFormEdits.swift b/TableProMobile/TableProMobile/Helpers/LibraryFormEdits.swift new file mode 100644 index 0000000000..bc1d9fdea5 --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/LibraryFormEdits.swift @@ -0,0 +1,79 @@ +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) + } + + 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) + } + + 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/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/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/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/SSH/IOSSSHProvider.swift b/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift index dd80b5ccec..412668eb07 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( @@ -27,6 +29,7 @@ final class IOSSSHProvider: SSHProvider, @unchecked Sendable { resolvedConfig.privateKeyData = try? secureStore.retrieve( forKey: "com.TablePro.sshkeydata.\(connectionId.uuidString)") } + resolvedConfig.privateKeyPath = resolvedConfig.privateKeyPath.map(container.localPath(forStoredPath:)) let tunnel = try await SSHTunnelFactory.create( config: resolvedConfig, diff --git a/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift b/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift index 4d3e217c96..668afba5c2 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 } diff --git a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift index 6b31722ba6..eb265e0f0a 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,13 @@ final class IOSSyncCoordinator { } } + private func adoptAccount(_ accountId: String) { + guard metadata.adoptAccount(accountId) == .switched else { return } + recordCache.removeAll() + lastSyncDate = nil + Self.logger.notice("The iCloud account changed, so sync starts over and pending edits go to the new account") + } + @discardableResult private func decide(_ outcome: SyncStatus) -> Int { statusGeneration += 1 @@ -313,7 +333,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.swift b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift index d9ed175959..990f880498 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 @@ -81,18 +87,35 @@ 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? // 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 private(set) var openingEdits: ConnectionFormEdits? + @ObservationIgnored private var createdFileURL: URL? + @ObservationIgnored private let newConnectionId = UUID() + @ObservationIgnored private var addedNewConnection = false + 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() + ) { self.existingConnection = editing + self.localFiles = localFiles + self.fileCreator = fileCreator + self.bookmarkStore = bookmarkStore guard let conn = editing else { safeModeLevel = AppPreferences.defaultSafeMode return @@ -130,16 +153,21 @@ final class ConnectionFormViewModel { sshKeyInputMode = .paste } } - if conn.type == .sqlite { - selectedFileURL = URL(fileURLWithPath: conn.database) - } - if conn.type == .duckdb { - if conn.database == DuckDBDriver.inMemoryPath { + hydrateDatabaseFile(from: conn) + openingEdits = edits + } + + 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,6 +188,55 @@ final class ConnectionFormViewModel { var isEditing: Bool { existingConnection != nil } + 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, + privateKeyData: sshKeyContent.isEmpty ? nil : sshKeyContent + ) + } + + 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 { @@ -183,16 +260,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,27 +281,54 @@ 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 } @@ -247,45 +351,25 @@ final class ConnectionFormViewModel { } } - private func copyToDocuments(_ sourceURL: URL) -> URL { - guard let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { - return sourceURL - } - 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)") - } - - try? FileManager.default.copyItem(at: sourceURL, to: destURL) - return destURL - } - 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 @@ -298,28 +382,19 @@ final class ConnectionFormViewModel { let tempId = UUID() var testConn = buildConnection() testConn.id = tempId + let scratchDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("ConnectionTest-\(tempId.uuidString)", isDirectory: true) - 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)") - } - + storeTestSecrets(for: tempId, appState: appState, secureStore: secureStore) 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)") + deleteTestSecrets(for: tempId, appState: appState, secureStore: secureStore) + removeScratchDirectory(scratchDirectory) } do { + if let scratchPath = try await scratchDatabasePath(in: scratchDirectory) { + testConn.database = scratchPath + } _ = try await appState.connectionManager.connect(testConn) await appState.connectionManager.disconnect(tempId) testResult = TestResult( @@ -344,25 +419,154 @@ final class ConnectionFormViewModel { } } + private func storeTestSecrets(for tempId: UUID, appState: AppState, secureStore: any SecureStore) { + 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)") + } + if let bookmark = bookmarkForTest { + bookmarkStore.save(bookmark, for: tempId) + } + } + + private func deleteTestSecrets(for tempId: UUID, appState: AppState, secureStore: any SecureStore) { + 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) + } + + 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(for: draft.id) + guard storeSecrets(for: draft.id, appState: appState, secureStore: secureStore) else { return nil } + return draft.id + } - 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: newConnectionId), 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(for connectionId: UUID) { + 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) + } + } + + private func storeSecrets(for connectionId: UUID, appState: AppState, secureStore: any SecureStore) -> Bool { + var storageFailed = false + + persistCertificates(for: connectionId) if !password.isEmpty { do { - try appState.connectionManager.storePassword(password, for: connection.id) + try appState.connectionManager.storePassword(password, for: connectionId) } catch { Self.logger.error("Failed to store password: \(error.localizedDescription, privacy: .public)") storageFailed = true @@ -372,7 +576,7 @@ final class ConnectionFormViewModel { if sshEnabled { if !sshPassword.isEmpty { do { - try secureStore.store(sshPassword, forKey: "com.TablePro.sshpassword.\(connection.id.uuidString)") + try secureStore.store(sshPassword, forKey: "com.TablePro.sshpassword.\(connectionId.uuidString)") } catch { Self.logger.error("Failed to store SSH password: \(error.localizedDescription, privacy: .public)") storageFailed = true @@ -380,7 +584,7 @@ final class ConnectionFormViewModel { } if !sshKeyPassphrase.isEmpty { do { - try secureStore.store(sshKeyPassphrase, forKey: "com.TablePro.keypassphrase.\(connection.id.uuidString)") + try secureStore.store(sshKeyPassphrase, forKey: "com.TablePro.keypassphrase.\(connectionId.uuidString)") } catch { Self.logger.error("Failed to store SSH key passphrase: \(error.localizedDescription, privacy: .public)") storageFailed = true @@ -388,7 +592,7 @@ final class ConnectionFormViewModel { } if !sshKeyContent.isEmpty { do { - try secureStore.store(sshKeyContent, forKey: "com.TablePro.sshkeydata.\(connection.id.uuidString)") + try secureStore.store(sshKeyContent, forKey: "com.TablePro.sshkeydata.\(connectionId.uuidString)") } catch { Self.logger.error("Failed to store SSH key data: \(error.localizedDescription, privacy: .public)") storageFailed = true @@ -396,86 +600,14 @@ final class ConnectionFormViewModel { } } - if storageFailed { + guard !storageFailed else { credentialError = String(localized: "Some credentials could not be saved to the keychain. You may need to re-enter them later.") - return nil + return false } - - return connection + return true } func dismissCredentialError() { credentialError = nil } - - private var effectiveSSLEnabled: Bool { - switch type { - case .mssql: return mssqlSSLMode != .disable - case .oracle: return oracleSSLMode != .disable - default: return sslMode != .disable - } - } - - 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 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 - ) - } - return conn - } - - private func sslConfigurationPreservingCertificates(mode: SSLConfiguration.SSLMode) -> SSLConfiguration { - let existing = existingConnection?.sslConfiguration - return SSLConfiguration( - mode: mode, - caCertificatePath: existing?.caCertificatePath, - clientCertificatePath: existing?.clientCertificatePath, - clientKeyPath: existing?.clientKeyPath - ) - } } diff --git a/TableProMobile/TableProMobile/Views/Components/GroupFormSheet.swift b/TableProMobile/TableProMobile/Views/Components/GroupFormSheet.swift index cae3d77a54..138339707d 100644 --- a/TableProMobile/TableProMobile/Views/Components/GroupFormSheet.swift +++ b/TableProMobile/TableProMobile/Views/Components/GroupFormSheet.swift @@ -9,16 +9,11 @@ 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 - init( - editing group: ConnectionGroup? = nil, - parentId: UUID? = nil, - onSave: @escaping (ConnectionGroup) -> Void - ) { + init(editing group: ConnectionGroup? = nil, parentId: UUID? = nil) { self.existingGroup = group - self.onSave = onSave _name = State(initialValue: group?.name ?? "") _color = State(initialValue: group?.color ?? .none) _parentId = State(initialValue: group?.parentId ?? parentId) @@ -63,17 +58,19 @@ struct GroupFormSheet: View { CancelButton { 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(name.trimmingCharacters(in: .whitespaces).isEmpty) } } + .libraryWriteFailureAlert(failure, onDismiss: { failure = nil }, closeForm: { dismiss() }) } } + + private func save() { + let outcome = GroupFormEdits(name: name, color: color, parentId: parentId) + .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/TagFormSheet.swift b/TableProMobile/TableProMobile/Views/Components/TagFormSheet.swift index 202ae722a2..8bd62fc4af 100644 --- a/TableProMobile/TableProMobile/Views/Components/TagFormSheet.swift +++ b/TableProMobile/TableProMobile/Views/Components/TagFormSheet.swift @@ -3,15 +3,15 @@ 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 - init(editing tag: ConnectionTag? = nil, onSave: @escaping (ConnectionTag) -> Void) { + init(editing tag: ConnectionTag? = nil) { self.existingTag = tag - self.onSave = onSave _name = State(initialValue: tag?.name ?? "") _color = State(initialValue: tag?.color ?? .gray) } @@ -35,16 +35,18 @@ struct TagFormSheet: View { CancelButton { 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 = TagFormEdits(name: name, color: color).save(editing: existingTag, in: appState) + failure = LibraryWriteFailure(outcome, kind: .tag) + guard failure == nil else { return } + dismiss() + } } diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index b778038bd1..d3f0bae413 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -19,6 +19,13 @@ struct ConnectedView: View { connection.name.isEmpty ? connection.host : connection.name } + private var connectionEditorPresented: Binding { + Binding( + get: { presenter.isEditingConnection(connection.id) }, + set: { if !$0 { presenter.dismissConnectionEditor() } } + ) + } + var body: some View { Group { if let coordinator { @@ -48,6 +55,12 @@ struct ConnectedView: View { } message: { Text("This connection no longer exists. It may have been removed from another device.") } + .sheet(isPresented: connectionEditorPresented) { + ConnectionFormView(editing: connection) { savedId in + coordinatorStore.invalidate(savedId) + presenter.dismissConnectionEditor() + } + } .task(id: coordinatorStore.revision) { let resolved = coordinatorStore.coordinator(for: connection, appState: appState) coordinator = resolved @@ -235,7 +248,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..cdf1cf89f0 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -18,7 +18,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 +29,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 +60,13 @@ struct ConnectionFormView: View { ) } + private var showFileError: Binding { + Binding( + get: { viewModel.fileError != nil }, + set: { if !$0 { viewModel.dismissFileError() } } + ) + } + var body: some View { @Bindable var viewModel = viewModel return NavigationStack { @@ -124,7 +131,7 @@ struct ConnectionFormView: View { } ToolbarItem(placement: .confirmationAction) { ConfirmButton(title: "Save", action: handleSave) - .disabled(!viewModel.canSave) + .disabled(!viewModel.canSave || viewModel.isSaving) } } .fileImporter( @@ -172,6 +179,16 @@ 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 ?? "") + } + .libraryWriteFailureAlert( + viewModel.saveFailure, + onDismiss: viewModel.dismissSaveFailure, + closeForm: { dismiss() } + ) .sensoryFeedback(.success, trigger: hapticSuccess) .sensoryFeedback(.error, trigger: hapticError) } @@ -524,8 +541,12 @@ struct ConnectionFormView: View { } private func handleSave() { - guard let connection = viewModel.save(appState: appState, secureStore: appState.secureStore) else { return } - onSave(connection) + Task { + guard let savedId = await viewModel.save(appState: appState, secureStore: appState.secureStore) else { + return + } + 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..bbc093d8cd 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -157,7 +157,7 @@ struct ConnectionListView: View { deliverPendingIntent() } } - .fullScreenCover(item: openConnection, onDismiss: presentImportAfterCoverDismissal) { connection in + .fullScreenCover(item: openConnection, onDismiss: connectionCoverDidDismiss) { connection in ConnectedView(connection: connection) .id(connection.id) } @@ -653,26 +653,20 @@ 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) { savedId in + coordinatorStore.invalidate(savedId) 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 +839,11 @@ struct ConnectionListView: View { } } + private func connectionCoverDidDismiss() { + presenter.dismissConnectionEditor() + presentImportAfterCoverDismissal() + } + private func presentImportAfterCoverDismissal() { guard let url = importAfterCoverDismissal else { return } importAfterCoverDismissal = nil diff --git a/TableProMobile/TableProMobile/Views/TagManagementView.swift b/TableProMobile/TableProMobile/Views/TagManagementView.swift index ac96679c99..15bef3fe10 100644 --- a/TableProMobile/TableProMobile/Views/TagManagementView.swift +++ b/TableProMobile/TableProMobile/Views/TagManagementView.swift @@ -79,14 +79,10 @@ 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) } } } 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/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..fcf0978960 --- /dev/null +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift @@ -0,0 +1,190 @@ +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("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("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/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..a4fac1c4f5 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] - )) - - var regrouped = editing - regrouped.groupId = groupId - let moved = try #require(ConnectionLibraryEditing.updating( - regrouped, in: [member, editing], validGroupIds: [groupId] - )) + let inPlace = try #require(ConnectionLibraryEditing.mutatingConnection( + editing.id, in: [member, editing], validGroupIds: [groupId] + ) { $0.name = "Renamed" }) + + 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,6 @@ 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) } } diff --git a/TableProMobile/TableProMobileTests/ConnectionLibrary/LibraryFormEditsTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/LibraryFormEditsTests.swift new file mode 100644 index 0000000000..95fa28cc15 --- /dev/null +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/LibraryFormEditsTests.swift @@ -0,0 +1,68 @@ +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("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/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/Onboarding/AppStateFixture.swift b/TableProMobile/TableProMobileTests/Onboarding/AppStateFixture.swift new file mode 100644 index 0000000000..8637d57763 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Onboarding/AppStateFixture.swift @@ -0,0 +1,126 @@ +import CloudKit +import Foundation +@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) -> AppState { + let coordinator = IOSSyncCoordinator( + metadata: metadata, + recordCache: SyncRecordCache(directory: root.appendingPathComponent("Cache"), defaults: nil), + makeTransport: { UnreachableTransport() }, + isEnabled: { syncEnabled }, + notificationCenter: NotificationCenter() + ) + return AppState( + libraryDirectory: libraryDirectory, + defaults: defaults, + syncCoordinator: coordinator, + sampleInstaller: SampleDatabaseInstaller( + bundledURL: bundledSample, + directory: root.appendingPathComponent("Samples", isDirectory: true) + ), + localDatabaseFiles: localFiles, + bookmarkStore: bookmarkStore + ) + } + + func makeFormViewModel(editing connection: DatabaseConnection? = nil) -> ConnectionFormViewModel { + ConnectionFormViewModel( + editing: connection, + localFiles: localFiles, + fileCreator: DriverDatabaseFileCreator(), + bookmarkStore: bookmarkStore + ) + } + + 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) + } +} + +private struct UnreachableTransport: 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/AppStateLibraryTests.swift b/TableProMobile/TableProMobileTests/Onboarding/AppStateLibraryTests.swift index f5baf2279e..da0a09b55d 100644 --- a/TableProMobile/TableProMobileTests/Onboarding/AppStateLibraryTests.swift +++ b/TableProMobile/TableProMobileTests/Onboarding/AppStateLibraryTests.swift @@ -1,4 +1,3 @@ -import CloudKit import Foundation @testable import TableProMobile import TableProModels @@ -9,56 +8,36 @@ 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 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) + fixture = try AppStateFixture() } private func makeState(syncEnabled: Bool) -> AppState { - let coordinator = IOSSyncCoordinator( - metadata: metadata, - recordCache: SyncRecordCache(directory: root.appendingPathComponent("Cache"), defaults: nil), - makeTransport: { UnreachableTransport() }, - isEnabled: { syncEnabled } - ) - return AppState( - libraryDirectory: libraryDirectory, - defaults: defaults, - syncCoordinator: coordinator, - sampleInstaller: SampleDatabaseInstaller( - bundledURL: bundledSample, - directory: root.appendingPathComponent("Samples", isDirectory: true) - ) - ) + fixture.makeState(syncEnabled: syncEnabled) } @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 +94,338 @@ struct AppStateLibraryTests { #expect(state.onboarding.usageDataChoice == true) } -} -private struct UnreachableTransport: IOSSyncTransport { - var currentZoneID: CKRecordZone.ID { - get async { CKRecordZone.ID(zoneName: "Unused", ownerName: CKCurrentUserDefaultName) } + // 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") } - func accountStatus() async throws -> CKAccountStatus { - .noAccount + // 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 }) } - func ensureZoneExists() async throws {} + @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) - func pull(since token: CKServerChangeToken?) async throws -> PullResult { - PullResult(changedRecords: [], deletedRecordIDs: [], newToken: nil) + let groupFailure = try #require(LibraryWriteFailure(groupOutcome, kind: .group)) + #expect(groupFailure == .libraryUnavailable(.group)) + #expect(!groupFailure.closesForm) + #expect(LibraryWriteFailure(tagOutcome, kind: .tag) == .libraryUnavailable(.tag)) } - func push(records: [CKRecord], deletions: [CKRecord.ID]) async throws -> PushOutcome { - PushOutcome(savedRecords: [:], deletedRecordIDs: []) + // 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(appState: state, secureStore: MockSecureStore()) + #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) + } + + @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(appState: state, secureStore: MockSecureStore()) + + #expect(viewModel.testResult?.success == true) + #expect(try FileManager.default.contentsOfDirectory(atPath: fixture.documentsDirectory.path).isEmpty) + #expect(state.connections.isEmpty) + } + + @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)) } } 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/Sync/IOSSyncCoordinatorTests.swift b/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift index cc8cd8a363..c34fb9dea8 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,210 @@ 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("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/docs/ios/index.mdx b/docs/ios/index.mdx index 913b6a9d5c..aa146f0fff 100644 --- a/docs/ios/index.mdx +++ b/docs/ios/index.mdx @@ -71,7 +71,7 @@ Favorites come first, then **Recent** with the five connections opened last, the ## 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: @@ -79,7 +79,7 @@ Three things a connection depends on are per device and never sync: | --- | --- | --- | | 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 | +| 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. Pull down on the connection list to sync now; a background refresh runs about every 30 minutes. @@ -87,6 +87,8 @@ Passwords are a separate opt-in, off by default, at **Settings > iCloud > Sync P 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. From c527b7287c927bbbf8610dd26608dbc670f25e23 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 18 Sep 2026 20:11:40 +0700 Subject: [PATCH 02/14] fix(ios): keep pasted SSH private keys out of the connections file --- CHANGELOG.md | 2 + .../TableProModels/SSHConfiguration.swift | 7 +- .../TableProSync/SyncRecordMapper.swift | 8 +- .../SSHConfigurationTests.swift | 33 +++ .../SyncRecordMapperSSHConfigTests.swift | 73 ++++++ TableProMobile/TableProMobile/AppState.swift | 55 +++- .../Helpers/ConnectionFormEdits.swift | 2 - .../Platform/ConnectionSecretKind.swift | 32 +++ .../Platform/ConnectionSecretsCopier.swift | 26 +- .../Platform/EphemeralSecureStore.swift | 23 ++ .../Platform/KeychainSecureStore.swift | 41 +-- .../Platform/PastedSSHKeyMigration.swift | 69 +++++ .../TableProMobile/SSH/IOSSSHProvider.swift | 15 +- .../SSH/SSHTunnelCredentials.swift | 49 ++++ .../TableProMobile/SSH/SSHTunnelFactory.swift | 16 +- .../ViewModels/ConnectionFormViewModel.swift | 143 +++++++---- .../Views/ConnectionFormView.swift | 2 +- .../ConnectionFormViewModelSSHKeyTests.swift | 241 ++++++++++++++++++ .../ConnectionSecretsTests.swift | 69 +++++ .../PastedSSHKeyMigrationTests.swift | 107 ++++++++ .../IOSConnectionExportServiceTests.swift | 60 +++++ .../Mocks/MockDatabaseDriver.swift | 4 + .../Mocks/UnreachableSyncTransport.swift | 29 +++ .../Onboarding/AppStateFixture.swift | 31 +-- .../Onboarding/AppStateLibraryTests.swift | 173 ++++++++++++- .../SSH/SSHTunnelCredentialsTests.swift | 56 ++++ docs/ios/index.mdx | 6 +- 27 files changed, 1210 insertions(+), 162 deletions(-) create mode 100644 Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperSSHConfigTests.swift create mode 100644 TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift create mode 100644 TableProMobile/TableProMobile/Platform/EphemeralSecureStore.swift create mode 100644 TableProMobile/TableProMobile/Platform/PastedSSHKeyMigration.swift create mode 100644 TableProMobile/TableProMobile/SSH/SSHTunnelCredentials.swift create mode 100644 TableProMobile/TableProMobileTests/ConnectionFormViewModelSSHKeyTests.swift create mode 100644 TableProMobile/TableProMobileTests/ConnectionLibrary/PastedSSHKeyMigrationTests.swift create mode 100644 TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift create mode 100644 TableProMobile/TableProMobileTests/Mocks/UnreachableSyncTransport.swift create mode 100644 TableProMobile/TableProMobileTests/SSH/SSHTunnelCredentialsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a964e8a959..0f9ed4a86d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,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/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/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/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/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index 50dd80205a..72770eb7e1 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -37,9 +37,10 @@ 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 @@ -50,6 +51,7 @@ final class AppState { init( libraryDirectory: URL = LibraryStorage.defaultDirectory, defaults: UserDefaults = .standard, + secureStore: any SecureStore = KeychainSecureStore(), syncCoordinator injectedSyncCoordinator: IOSSyncCoordinator? = nil, sampleInstaller: SampleDatabaseInstaller = .live, localDatabaseFiles: LocalDatabaseFileLocator = .live, @@ -65,7 +67,6 @@ final class AppState { groupStorage = GroupPersistence(directory: libraryDirectory) tagStorage = TagPersistence(directory: libraryDirectory) let driverFactory = IOSDriverFactory(bookmarkStore: bookmarkStore, localFiles: localDatabaseFiles) - let secureStore = KeychainSecureStore() self.secureStore = secureStore let sshProvider = IOSSSHProvider(secureStore: secureStore, container: localDatabaseFiles.container) self.sshProvider = sshProvider @@ -81,7 +82,7 @@ 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() } @@ -147,7 +148,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)") @@ -168,12 +171,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)") } @@ -556,6 +580,11 @@ nonisolated enum LibraryStorage { } } +private struct StoredConnections { + let connections: [DatabaseConnection] + let pastedSSHKeys: [UUID: String] +} + private struct ConnectionPersistence { let directory: URL @@ -564,18 +593,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/Helpers/ConnectionFormEdits.swift b/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift index 4332fc0e3c..8a23b607c9 100644 --- a/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift +++ b/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift @@ -9,7 +9,6 @@ nonisolated struct ConnectionFormEdits: Equatable, Sendable { var username: String var authMethod: SSHConfiguration.SSHAuthMethod var privateKeyPath: String? - var privateKeyData: String? } nonisolated struct OracleOptions: Equatable, Sendable { @@ -87,7 +86,6 @@ nonisolated struct ConnectionFormEdits: Equatable, Sendable { configuration.username = sshTunnel.username configuration.authMethod = sshTunnel.authMethod configuration.privateKeyPath = sshTunnel.privateKeyPath - configuration.privateKeyData = sshTunnel.privateKeyData if configuration.macEnabled != nil { configuration.macEnabled = true } diff --git a/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift b/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift new file mode 100644 index 0000000000..21bc9dafea --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift @@ -0,0 +1,32 @@ +import Foundation + +nonisolated enum ConnectionSecretKind: CaseIterable, Sendable { + case password + case sshPassword + case keyPassphrase + case sshPrivateKey + + static var orphanSweepPrefixes: [String] { + allCases.filter(\.isSweptWhenOrphaned).map(\.prefix) + } + + 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 + } +} 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/KeychainSecureStore.swift b/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift index ee9105ec98..00d155295f 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) @@ -31,6 +31,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 +54,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 +63,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 +81,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 +104,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 +115,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,17 +137,17 @@ 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 { + for prefix in ConnectionSecretKind.orphanSweepPrefixes { 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) + deleteDeviceLocal(forKey: account, accessGroup: accessGroup) } } } @@ -150,7 +155,7 @@ nonisolated final class KeychainSecureStore: SecureStore { /// 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 +163,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/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/SSH/IOSSSHProvider.swift b/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift index 412668eb07..a7fa4a176a 100644 --- a/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift +++ b/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift @@ -19,24 +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 = resolvedConfig.privateKeyPath.map(container.localPath(forStoredPath:)) + 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/ViewModels/ConnectionFormViewModel.swift b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift index 990f880498..015cfc277e 100644 --- a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift @@ -80,6 +80,7 @@ final class ConnectionFormViewModel { var sshKeyContent = "" var sshKeyPassphrase = "" var sshKeyInputMode: KeyInputMode = .file + @ObservationIgnored private var storedPrivateKey: String? // File picker output var selectedFileURL: URL? @@ -98,9 +99,9 @@ final class ConnectionFormViewModel { private(set) var saveFailure: LibraryWriteFailure? @ObservationIgnored let existingConnection: DatabaseConnection? + @ObservationIgnored let connectionId: UUID @ObservationIgnored private(set) var openingEdits: ConnectionFormEdits? @ObservationIgnored private var createdFileURL: URL? - @ObservationIgnored private let newConnectionId = UUID() @ObservationIgnored private var addedNewConnection = false private let localFiles: LocalDatabaseFileLocator private let fileCreator: any LocalDatabaseFileCreating @@ -113,6 +114,7 @@ final class ConnectionFormViewModel { bookmarkStore: FileBookmarkStore = FileBookmarkStore() ) { self.existingConnection = editing + self.connectionId = editing?.id ?? UUID() self.localFiles = localFiles self.fileCreator = fileCreator self.bookmarkStore = bookmarkStore @@ -148,8 +150,7 @@ 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 } } @@ -188,6 +189,12 @@ 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, @@ -221,8 +228,7 @@ final class ConnectionFormViewModel { port: Int(sshPort) ?? 22, username: sshUsername, authMethod: sshAuthMethod, - privateKeyPath: sshKeyPath.isEmpty ? nil : sshKeyPath, - privateKeyData: sshKeyContent.isEmpty ? nil : sshKeyContent + privateKeyPath: sshKeyPath.isEmpty ? nil : sshKeyPath ) } @@ -241,16 +247,30 @@ final class ConnectionFormViewModel { 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 } - 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 } - 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 } + if let privateKey = Self.storedSecret(.sshPrivateKey, for: conn.id, in: secureStore) { + sshKeyContent = privateKey + storedPrivateKey = privateKey + sshKeyInputMode = .paste + } + } + + 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 @@ -374,7 +394,25 @@ final class ConnectionFormViewModel { // 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 } @@ -385,9 +423,17 @@ final class ConnectionFormViewModel { let scratchDirectory = FileManager.default.temporaryDirectory .appendingPathComponent("ConnectionTest-\(tempId.uuidString)", isDirectory: true) - storeTestSecrets(for: tempId, appState: appState, secureStore: secureStore) + 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 { - deleteTestSecrets(for: tempId, appState: appState, secureStore: secureStore) + bookmarkStore.delete(for: tempId) removeScratchDirectory(scratchDirectory) } @@ -395,8 +441,8 @@ final class ConnectionFormViewModel { if let scratchPath = try await scratchDatabasePath(in: scratchDirectory) { testConn.database = scratchPath } - _ = try await appState.connectionManager.connect(testConn) - await appState.connectionManager.disconnect(tempId) + _ = try await manager.connect(testConn) + await manager.disconnect(tempId) testResult = TestResult( success: true, message: String(localized: "Connection successful"), @@ -419,32 +465,6 @@ final class ConnectionFormViewModel { } } - private func storeTestSecrets(for tempId: UUID, appState: AppState, secureStore: any SecureStore) { - 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)") - } - if let bookmark = bookmarkForTest { - bookmarkStore.save(bookmark, for: tempId) - } - } - - private func deleteTestSecrets(for tempId: UUID, appState: AppState, secureStore: any SecureStore) { - 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) - } - private var bookmarkForTest: Data? { guard type == .duckdb, !duckDBInMemory else { return nil } switch pendingFile { @@ -490,9 +510,9 @@ final class ConnectionFormViewModel { return nil } createdFileURL = nil - settleBookmark(for: draft.id) - guard storeSecrets(for: draft.id, appState: appState, secureStore: secureStore) else { return nil } - return draft.id + settleBookmark() + guard storeSecrets(appState: appState, secureStore: secureStore) else { return nil } + return connectionId } func applyingEdits(to current: DatabaseConnection) -> DatabaseConnection { @@ -500,7 +520,7 @@ final class ConnectionFormViewModel { } func buildConnection() -> DatabaseConnection { - edits.applied(to: existingConnection ?? DatabaseConnection(id: newConnectionId), changedSince: nil) + edits.applied(to: existingConnection ?? DatabaseConnection(id: connectionId), changedSince: nil) } func dismissSaveFailure() { @@ -531,7 +551,7 @@ final class ConnectionFormViewModel { return true } - private func settleBookmark(for connectionId: UUID) { + private func settleBookmark() { guard type == .duckdb else { if existingConnection?.type == .duckdb { bookmarkStore.delete(for: connectionId) @@ -559,7 +579,7 @@ final class ConnectionFormViewModel { } } - private func storeSecrets(for connectionId: UUID, appState: AppState, secureStore: any SecureStore) -> Bool { + private func storeSecrets(appState: AppState, secureStore: any SecureStore) -> Bool { var storageFailed = false persistCertificates(for: connectionId) @@ -576,7 +596,7 @@ final class ConnectionFormViewModel { if sshEnabled { if !sshPassword.isEmpty { do { - try secureStore.store(sshPassword, forKey: "com.TablePro.sshpassword.\(connectionId.uuidString)") + try secureStore.store(sshPassword, forKey: ConnectionSecretKind.sshPassword.account(for: connectionId)) } catch { Self.logger.error("Failed to store SSH password: \(error.localizedDescription, privacy: .public)") storageFailed = true @@ -584,20 +604,19 @@ final class ConnectionFormViewModel { } if !sshKeyPassphrase.isEmpty { do { - try secureStore.store(sshKeyPassphrase, forKey: "com.TablePro.keypassphrase.\(connectionId.uuidString)") + try secureStore.store(sshKeyPassphrase, forKey: ConnectionSecretKind.keyPassphrase.account(for: connectionId)) } catch { Self.logger.error("Failed to store SSH key passphrase: \(error.localizedDescription, privacy: .public)") storageFailed = true } } - if !sshKeyContent.isEmpty { - do { - try secureStore.store(sshKeyContent, forKey: "com.TablePro.sshkeydata.\(connectionId.uuidString)") - } catch { - Self.logger.error("Failed to store SSH key data: \(error.localizedDescription, privacy: .public)") - storageFailed = true - } - } + } + + do { + try persistPrivateKey(secureStore: secureStore) + } catch { + Self.logger.error("Failed to store SSH private key: \(error.localizedDescription, privacy: .public)") + storageFailed = true } guard !storageFailed else { @@ -607,6 +626,18 @@ final class ConnectionFormViewModel { return true } + func persistPrivateKey(secureStore: any SecureStore) throws { + let key = pastedPrivateKey + guard key != storedPrivateKey else { return } + let account = ConnectionSecretKind.sshPrivateKey.account(for: connectionId) + if let key { + try secureStore.store(key, forKey: account) + } else { + try secureStore.delete(forKey: account) + } + storedPrivateKey = key + } + func dismissCredentialError() { credentialError = nil } diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index cdf1cf89f0..b56597e8c7 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -534,7 +534,7 @@ 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() } } diff --git a/TableProMobile/TableProMobileTests/ConnectionFormViewModelSSHKeyTests.swift b/TableProMobile/TableProMobileTests/ConnectionFormViewModelSSHKeyTests.swift new file mode 100644 index 0000000000..2d76174721 --- /dev/null +++ b/TableProMobile/TableProMobileTests/ConnectionFormViewModelSSHKeyTests.swift @@ -0,0 +1,241 @@ +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("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/ConnectionLibrary/ConnectionSecretsTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift index 5ec9a7af19..0b10f97aa4 100644 --- a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift @@ -88,6 +88,75 @@ 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 never takes a pasted private key") + func sweepSparesPrivateKeys() { + #expect(!ConnectionSecretKind.orphanSweepPrefixes.contains(ConnectionSecretKind.sshPrivateKey.prefix)) + #expect(ConnectionSecretKind.orphanSweepPrefixes == [ + "com.TablePro.password.", + "com.TablePro.sshpassword.", + "com.TablePro.keypassphrase." + ]) + } + + @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/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/IOSConnectionExportServiceTests.swift b/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift new file mode 100644 index 0000000000..d6b94f05dd --- /dev/null +++ b/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift @@ -0,0 +1,60 @@ +import Foundation +@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) 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 data = try IOSConnectionExportService.exportData( + connections: state.connections, + appState: state, + includeCredentials: includeCredentials, + passphrase: nil + ) + return try #require(String(data: data, encoding: .utf8)) + } + + @Test("An export without credentials never carries a stored private key") + func plainExportOmitsKey() throws { + let text = try exportedText(includeCredentials: false) + #expect(text.contains("bastion.example.com")) + #expect(!text.contains(keyMarker)) + #expect(!text.contains("db-secret")) + } + + @Test("An unencrypted export with credentials carries the password but never the private key") + func credentialExportOmitsKey() throws { + let text = try exportedText(includeCredentials: true) + #expect(text.contains("db-secret")) + #expect(!text.contains(keyMarker)) + } +} 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/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 index 8637d57763..6780650575 100644 --- a/TableProMobile/TableProMobileTests/Onboarding/AppStateFixture.swift +++ b/TableProMobile/TableProMobileTests/Onboarding/AppStateFixture.swift @@ -1,5 +1,5 @@ -import CloudKit import Foundation +import TableProDatabase @testable import TableProMobile import TableProModels import TableProSync @@ -46,17 +46,18 @@ struct AppStateFixture { try Data("sample".utf8).write(to: bundledSample) } - func makeState(syncEnabled: Bool) -> AppState { + func makeState(syncEnabled: Bool, secureStore: any SecureStore = MockSecureStore()) -> AppState { let coordinator = IOSSyncCoordinator( metadata: metadata, recordCache: SyncRecordCache(directory: root.appendingPathComponent("Cache"), defaults: nil), - makeTransport: { UnreachableTransport() }, + makeTransport: { UnreachableSyncTransport() }, isEnabled: { syncEnabled }, notificationCenter: NotificationCenter() ) return AppState( libraryDirectory: libraryDirectory, defaults: defaults, + secureStore: secureStore, syncCoordinator: coordinator, sampleInstaller: SampleDatabaseInstaller( bundledURL: bundledSample, @@ -100,27 +101,3 @@ struct AppStateFixture { documentsDirectory.appendingPathComponent(name) } } - -private struct UnreachableTransport: 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/AppStateLibraryTests.swift b/TableProMobile/TableProMobileTests/Onboarding/AppStateLibraryTests.swift index da0a09b55d..b10416b025 100644 --- a/TableProMobile/TableProMobileTests/Onboarding/AppStateLibraryTests.swift +++ b/TableProMobile/TableProMobileTests/Onboarding/AppStateLibraryTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProDatabase @testable import TableProMobile import TableProModels import TableProSync @@ -16,8 +17,8 @@ struct AppStateLibraryTests { fixture = try AppStateFixture() } - private func makeState(syncEnabled: Bool) -> AppState { - fixture.makeState(syncEnabled: syncEnabled) + private func makeState(syncEnabled: Bool, secureStore: any SecureStore = MockSecureStore()) -> AppState { + fixture.makeState(syncEnabled: syncEnabled, secureStore: secureStore) } @Test("A library that failed to load refuses every write and leaves the file alone") @@ -379,7 +380,7 @@ struct AppStateLibraryTests { viewModel.newDatabaseName = "scratch" viewModel.createNewDatabase() - await viewModel.testConnection(appState: state, secureStore: MockSecureStore()) + await viewModel.testConnection() #expect(viewModel.testResult?.success == true) #expect(!FileManager.default.fileExists(atPath: fixture.documentsFile("scratch.db").path)) @@ -399,7 +400,7 @@ struct AppStateLibraryTests { viewModel.newDatabaseName = "analytics" viewModel.createNewDatabase() - await viewModel.testConnection(appState: state, secureStore: MockSecureStore()) + await viewModel.testConnection() #expect(viewModel.testResult?.success == true) #expect(try FileManager.default.contentsOfDirectory(atPath: fixture.documentsDirectory.path).isEmpty) @@ -428,4 +429,168 @@ struct AppStateLibraryTests { #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) + } } 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/docs/ios/index.mdx b/docs/ios/index.mdx index aa146f0fff..46f338aa6a 100644 --- a/docs/ios/index.mdx +++ b/docs/ios/index.mdx @@ -78,10 +78,10 @@ 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** | +| 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. 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. 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. 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. @@ -123,7 +123,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 From 3ee74500d14b4148e11c1f1773efaa014903136a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 18 Sep 2026 20:29:11 +0700 Subject: [PATCH 03/14] fix(ios): polish the connection screen, forms and Spotlight before release --- CHANGELOG.md | 6 + .../ConnectionExportCrypto.swift | 6 +- .../ConnectionImportTypes.swift | 5 +- ...ConnectionExportCryptoIsolationTests.swift | 57 +++ .../ConnectionExportCryptoTests.swift | 45 ++- .../ConnectionImportDecoderTests.swift | 20 +- .../Export/ConnectionExportService.swift | 19 +- .../ConnectionExportOptionsSheet.swift | 32 +- .../Connection/ConnectionImportSheet.swift | 2 +- TableProMobile/TableProMobile/AppState.swift | 97 ++--- .../Coordinators/ConnectionCoordinator.swift | 17 +- .../ConnectionCoordinatorStore.swift | 95 ++++- .../Coordinators/SceneEditorHold.swift | 41 +++ .../Coordinators/ScenePresenter.swift | 14 +- .../Helpers/ConnectionLibraryEditing.swift | 34 ++ .../Helpers/LibraryFormEdits.swift | 16 + .../Helpers/TagDeletionRequest.swift | 18 + .../TableProMobile/Localizable.xcstrings | 344 +++++++++++++++++- .../Onboarding/OnboardingPageLayout.swift | 8 +- .../Platform/ConnectionLibraryPublisher.swift | 92 +++++ .../Platform/SpotlightConnectionIndex.swift | 49 +++ .../Services/IOSConnectionExportService.swift | 26 +- ...ConnectionFormViewModel+Certificates.swift | 1 + .../ConnectionFormViewModel+Changes.swift | 78 ++++ .../ViewModels/ConnectionFormViewModel.swift | 60 +-- .../ViewModels/DataBrowserViewModel.swift | 7 + .../ViewModels/RowDetailViewModel.swift | 53 ++- .../Views/Components/BottomSafeAreaBar.swift | 22 ++ .../Views/Components/GroupFormSheet.swift | 23 +- .../Views/Components/PagingBar.swift | 42 +++ .../Views/Components/SemanticButtons.swift | 48 +++ .../Views/Components/TagFormSheet.swift | 18 +- .../Views/ConnectedScreen.swift | 19 + .../TableProMobile/Views/ConnectedView.swift | 57 +-- .../Views/ConnectionFormView.swift | 9 +- .../Views/ConnectionListView.swift | 21 +- .../Views/DataBrowserView.swift | 90 ++--- .../TableProMobile/Views/InsertRowView.swift | 5 +- .../Views/MobileConnectionExportSheet.swift | 23 +- .../Views/MobileConnectionImportSheet.swift | 29 +- .../TableProMobile/Views/RowDetailView.swift | 44 +-- .../TableProMobile/Views/SceneRootView.swift | 3 + .../Views/TagManagementView.swift | 55 ++- .../ConnectionCoordinatorStoreTests.swift | 278 ++++++++++++++ .../ConnectionFormViewModelChangesTests.swift | 325 +++++++++++++++++ .../ConnectionLibraryEditingTests.swift | 59 +++ .../ConnectionLibraryPublisherTests.swift | 170 +++++++++ .../ConnectionSecretsTests.swift | 27 -- .../LibraryFormEditsTests.swift | 35 ++ .../DataBrowserViewModelTests.swift | 82 +++++ .../IOSConnectionExportFileDataTests.swift | 94 +++++ .../IOSConnectionExportServiceTests.swift | 25 +- .../Mocks/InMemoryCertificateStore.swift | 29 ++ .../Mocks/RecordingSearchIndex.swift | 61 ++++ .../Onboarding/AppStateFixture.swift | 21 +- .../Onboarding/AppStateLibraryTests.swift | 181 ++++++++- .../Onboarding/ScenePresenterTests.swift | 45 +++ .../RowDetailViewModelTests.swift | 137 ++++++- .../Views/BottomSafeAreaBarLayoutTests.swift | 177 +++++++++ .../Views/ConnectedScreenTests.swift | 51 +++ .../Export/ConnectionExportDataTests.swift | 14 +- docs/ios/index.mdx | 7 +- 62 files changed, 3196 insertions(+), 372 deletions(-) create mode 100644 Packages/TableProCore/Tests/TableProImportTests/ConnectionExportCryptoIsolationTests.swift create mode 100644 TableProMobile/TableProMobile/Coordinators/SceneEditorHold.swift create mode 100644 TableProMobile/TableProMobile/Helpers/TagDeletionRequest.swift create mode 100644 TableProMobile/TableProMobile/Platform/ConnectionLibraryPublisher.swift create mode 100644 TableProMobile/TableProMobile/Platform/SpotlightConnectionIndex.swift create mode 100644 TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Changes.swift create mode 100644 TableProMobile/TableProMobile/Views/Components/BottomSafeAreaBar.swift create mode 100644 TableProMobile/TableProMobile/Views/Components/PagingBar.swift create mode 100644 TableProMobile/TableProMobile/Views/ConnectedScreen.swift create mode 100644 TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormViewModelChangesTests.swift create mode 100644 TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionLibraryPublisherTests.swift create mode 100644 TableProMobile/TableProMobileTests/IOSConnectionExportFileDataTests.swift create mode 100644 TableProMobile/TableProMobileTests/Mocks/InMemoryCertificateStore.swift create mode 100644 TableProMobile/TableProMobileTests/Mocks/RecordingSearchIndex.swift create mode 100644 TableProMobile/TableProMobileTests/Views/BottomSafeAreaBarLayoutTests.swift create mode 100644 TableProMobile/TableProMobileTests/Views/ConnectedScreenTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f9ed4a86d..af987fc0cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. ### Security 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/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/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 72770eb7e1..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 { @@ -44,6 +42,7 @@ final class AppState { let localDatabaseFiles: LocalDatabaseFileLocator private let sampleInstaller: SampleDatabaseInstaller + private let libraryPublisher: ConnectionLibraryPublisher private let storage: ConnectionPersistence private let groupStorage: GroupPersistence private let tagStorage: TagPersistence @@ -55,9 +54,11 @@ final class AppState { syncCoordinator injectedSyncCoordinator: IOSSyncCoordinator? = nil, sampleInstaller: SampleDatabaseInstaller = .live, localDatabaseFiles: LocalDatabaseFileLocator = .live, - bookmarkStore: FileBookmarkStore = FileBookmarkStore() + bookmarkStore: FileBookmarkStore = FileBookmarkStore(), + libraryPublisher: ConnectionLibraryPublisher? = nil ) { self.sampleInstaller = sampleInstaller + self.libraryPublisher = libraryPublisher ?? .live() self.localDatabaseFiles = localDatabaseFiles localDatabaseFiles.container.recordCurrentContainer() onboarding = OnboardingPreferences(defaults: defaults) @@ -89,11 +90,7 @@ final class AppState { } 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 @@ -138,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 { @@ -238,6 +241,14 @@ 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)) @@ -418,26 +429,22 @@ final class AppState { 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 @@ -519,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? { diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index 1dff5bf932..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 @@ -105,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 a6259fc3ab..3c663fab7f 100644 --- a/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift +++ b/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift @@ -53,6 +53,9 @@ final class ScenePresenter { 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 @@ -109,8 +112,17 @@ final class ScenePresenter { 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/Helpers/ConnectionLibraryEditing.swift b/TableProMobile/TableProMobile/Helpers/ConnectionLibraryEditing.swift index a04bab1af9..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 } @@ -270,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 index bc1d9fdea5..98119bd88c 100644 --- a/TableProMobile/TableProMobile/Helpers/LibraryFormEdits.swift +++ b/TableProMobile/TableProMobile/Helpers/LibraryFormEdits.swift @@ -16,6 +16,14 @@ nonisolated struct GroupFormEdits: Equatable, Sendable { 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 } @@ -43,6 +51,14 @@ nonisolated struct TagFormEdits: Equatable, Sendable { 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 } 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..1eac8f6213 100644 --- a/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/TableProMobile/TableProMobile/Localizable.xcstrings @@ -5085,6 +5085,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 +5170,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" : "放棄變更?" } } } @@ -7904,6 +7960,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 +9255,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 +11165,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 +13753,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" : { @@ -19808,6 +20032,96 @@ } } }, + "“%@” 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" : "沒有連線使用標籤「%@」。" + } + } + } + }, + "“%@” 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/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/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/Services/IOSConnectionExportService.swift b/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift index 1cffb72182..1141fa1bb1 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 { diff --git a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Certificates.swift b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Certificates.swift index 1f6d15505e..56ff6205ce 100644 --- a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Certificates.swift +++ b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Certificates.swift @@ -14,6 +14,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) } } 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 015cfc277e..27e8cbb09b 100644 --- a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift @@ -54,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 = "" @@ -80,7 +81,6 @@ final class ConnectionFormViewModel { var sshKeyContent = "" var sshKeyPassphrase = "" var sshKeyInputMode: KeyInputMode = .file - @ObservationIgnored private var storedPrivateKey: String? // File picker output var selectedFileURL: URL? @@ -100,9 +100,10 @@ final class ConnectionFormViewModel { @ObservationIgnored let existingConnection: DatabaseConnection? @ObservationIgnored let connectionId: UUID - @ObservationIgnored private(set) var openingEdits: ConnectionFormEdits? @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 @@ -111,13 +112,16 @@ final class ConnectionFormViewModel { editing: DatabaseConnection? = nil, localFiles: LocalDatabaseFileLocator = .live, fileCreator: any LocalDatabaseFileCreating = DriverDatabaseFileCreator(), - bookmarkStore: FileBookmarkStore = FileBookmarkStore() + 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 @@ -155,7 +159,6 @@ final class ConnectionFormViewModel { } } hydrateDatabaseFile(from: conn) - openingEdits = edits } private func hydrateDatabaseFile(from connection: DatabaseConnection) { @@ -249,18 +252,22 @@ final class ConnectionFormViewModel { guard let conn = existingConnection else { return } if let stored = Self.storedSecret(.password, for: conn.id, in: secureStore) { password = stored + storedSecrets.password = stored } if let sshPwd = Self.storedSecret(.sshPassword, for: conn.id, in: secureStore) { sshPassword = sshPwd + storedSecrets.sshPassword = sshPwd } 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 - storedPrivateKey = privateKey + storedSecrets.privateKey = privateKey sshKeyInputMode = .paste } + baseline?.secrets = storedSecrets } private static func storedSecret( @@ -580,35 +587,34 @@ final class ConnectionFormViewModel { } private func storeSecrets(appState: AppState, secureStore: any SecureStore) -> Bool { + let writes = secretWrites var storageFailed = false persistCertificates(for: connectionId) - if !password.isEmpty { + if let changed = writes.password { do { - try appState.connectionManager.storePassword(password, for: connectionId) + try appState.connectionManager.storePassword(changed, for: connectionId) } 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: ConnectionSecretKind.sshPassword.account(for: connectionId)) - } catch { - Self.logger.error("Failed to store SSH password: \(error.localizedDescription, privacy: .public)") - storageFailed = true - } + if let changed = writes.sshPassword { + do { + try secureStore.store(changed, forKey: ConnectionSecretKind.sshPassword.account(for: connectionId)) + } catch { + Self.logger.error("Failed to store SSH password: \(error.localizedDescription, privacy: .public)") + storageFailed = true } - if !sshKeyPassphrase.isEmpty { - do { - try secureStore.store(sshKeyPassphrase, forKey: ConnectionSecretKind.keyPassphrase.account(for: connectionId)) - } catch { - Self.logger.error("Failed to store SSH key passphrase: \(error.localizedDescription, privacy: .public)") - storageFailed = true - } + } + if let changed = writes.sshKeyPassphrase { + do { + try secureStore.store(changed, forKey: ConnectionSecretKind.keyPassphrase.account(for: connectionId)) + } catch { + Self.logger.error("Failed to store SSH key passphrase: \(error.localizedDescription, privacy: .public)") + storageFailed = true } } @@ -628,14 +634,14 @@ final class ConnectionFormViewModel { func persistPrivateKey(secureStore: any SecureStore) throws { let key = pastedPrivateKey - guard key != storedPrivateKey else { return } + 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) } - storedPrivateKey = key + storedSecrets.privateKey = key } func dismissCredentialError() { 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..572c462958 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 @@ -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,6 +161,23 @@ 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 { @@ -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 @@ -211,6 +237,7 @@ final class RowDetailViewModel { pendingWriteConfirmation = false guard let session, let sql = pendingSaveSQL else { return false } pendingSaveSQL = nil + guard !safeModeLevel.blocksWrites else { return false } return await execute(sql: sql, session: session) } 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 138339707d..d5c081feb8 100644 --- a/TableProMobile/TableProMobile/Views/Components/GroupFormSheet.swift +++ b/TableProMobile/TableProMobile/Views/Components/GroupFormSheet.swift @@ -11,14 +11,23 @@ struct GroupFormSheet: View { @State private var parentId: UUID? @State private var failure: LibraryWriteFailure? private let existingGroup: ConnectionGroup? + private let opening: GroupFormEdits init(editing group: ConnectionGroup? = nil, parentId: UUID? = nil) { + let opening = GroupFormEdits(opening: group, parentId: parentId) self.existingGroup = group - _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() } @@ -51,15 +60,16 @@ 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", action: save) - .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + .disabled(edits.name.isEmpty) } } .libraryWriteFailureAlert(failure, onDismiss: { failure = nil }, closeForm: { dismiss() }) @@ -67,8 +77,7 @@ struct GroupFormSheet: View { } private func save() { - let outcome = GroupFormEdits(name: name, color: color, parentId: parentId) - .save(editing: existingGroup, in: appState) + 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/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 8bd62fc4af..0d451174c8 100644 --- a/TableProMobile/TableProMobile/Views/Components/TagFormSheet.swift +++ b/TableProMobile/TableProMobile/Views/Components/TagFormSheet.swift @@ -9,13 +9,22 @@ struct TagFormSheet: View { @State private var color: ConnectionColor @State private var failure: LibraryWriteFailure? private let existingTag: ConnectionTag? + private let opening: TagFormEdits init(editing tag: ConnectionTag? = nil) { + let opening = TagFormEdits(opening: tag) self.existingTag = tag - _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,11 +37,12 @@ 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", action: save) @@ -44,7 +54,7 @@ struct TagFormSheet: View { } private func save() { - let outcome = TagFormEdits(name: name, color: color).save(editing: existingTag, in: appState) + 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 d3f0bae413..1e8759f09a 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -19,6 +19,14 @@ 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) }, @@ -29,26 +37,15 @@ struct ConnectedView: View { 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() } @@ -56,13 +53,13 @@ struct ConnectedView: View { Text("This connection no longer exists. It may have been removed from another device.") } .sheet(isPresented: connectionEditorPresented) { - ConnectionFormView(editing: connection) { savedId in - coordinatorStore.invalidate(savedId) + ConnectionFormView(editing: liveRecord ?? connection) { _ in presenter.dismissConnectionEditor() } } - .task(id: coordinatorStore.revision) { - let resolved = coordinatorStore.coordinator(for: connection, appState: appState) + .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 @@ -94,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 { @@ -108,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") @@ -222,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] diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index b56597e8c7..4e0bbfa898 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? @@ -119,6 +120,8 @@ struct ConnectionFormView: View { testSection } .scrollDismissesKeyboard(.interactively) + .interactiveDismissDisabled(viewModel.hasChanges) + .holdsScene(withUnsavedChanges: viewModel.hasChanges) .task { viewModel.loadCertificateSummaries() await viewModel.loadStoredCredentials(secureStore: appState.secureStore) @@ -127,7 +130,7 @@ struct ConnectionFormView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - CancelButton { dismiss() } + DiscardChangesCancelButton(hasChanges: viewModel.hasChanges) { dismiss() } } ToolbarItem(placement: .confirmationAction) { ConfirmButton(title: "Save", action: handleSave) @@ -541,10 +544,14 @@ struct ConnectionFormView: View { } private func handleSave() { + 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) } } diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index bbc093d8cd..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,6 +153,7 @@ struct ConnectionListView: View { iCloudAccountAvailable = await appState.syncCoordinator.accountStatus() == .available } .task { + clearUnknownSelection() presenter.beginLaunch(with: appState) deliverPendingIntent() } @@ -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 } } @@ -657,8 +665,7 @@ struct ConnectionListView: View { presenter.sheet = nil } case .editConnection(let connection): - ConnectionFormView(editing: connection) { savedId in - coordinatorStore.invalidate(savedId) + ConnectionFormView(editing: connection) { _ in presenter.sheet = nil } case .moveConnections(let ids): @@ -839,8 +846,16 @@ 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() } diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 26e6351ed6..bc9bacca9f 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() } } diff --git a/TableProMobile/TableProMobile/Views/InsertRowView.swift b/TableProMobile/TableProMobile/Views/InsertRowView.swift index 6e75b5b74b..f77fdda173 100644 --- a/TableProMobile/TableProMobile/Views/InsertRowView.swift +++ b/TableProMobile/TableProMobile/Views/InsertRowView.swift @@ -26,6 +26,7 @@ struct InsertRowView: View { @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) { 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 15bef3fe10..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 { @@ -86,4 +116,19 @@ struct TagManagementView: View { } } } + + 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/ConnectionLibrary/ConnectionFormViewModelChangesTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormViewModelChangesTests.swift new file mode 100644 index 0000000000..d00d65aad9 --- /dev/null +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormViewModelChangesTests.swift @@ -0,0 +1,325 @@ +import Foundation +@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 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("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/ConnectionLibraryEditingTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionLibraryEditingTests.swift index a4fac1c4f5..8aff9c1ea0 100644 --- a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionLibraryEditingTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionLibraryEditingTests.swift @@ -216,4 +216,63 @@ struct ConnectionLibraryEditingTests { #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 0b10f97aa4..1a5ce23041 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() diff --git a/TableProMobile/TableProMobileTests/ConnectionLibrary/LibraryFormEditsTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/LibraryFormEditsTests.swift index 95fa28cc15..8202ec8f81 100644 --- a/TableProMobile/TableProMobileTests/ConnectionLibrary/LibraryFormEditsTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/LibraryFormEditsTests.swift @@ -50,6 +50,41 @@ struct LibraryFormEditsTests { #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) 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/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 index d6b94f05dd..4c5f48de17 100644 --- a/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift +++ b/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProImport @testable import TableProMobile import TableProModels import Testing @@ -17,7 +18,7 @@ struct IOSConnectionExportServiceTests { fixture.makeState(syncEnabled: false, secureStore: secureStore) } - private func exportedText(includeCredentials: Bool) throws -> String { + private func exportedText(includeCredentials: Bool) async throws -> String { let store = MockSecureStore() let state = makeState(secureStore: store) let connection = DatabaseConnection( @@ -34,26 +35,32 @@ struct IOSConnectionExportServiceTests { "-----BEGIN OPENSSH PRIVATE KEY-----\n\(keyMarker)\n-----END OPENSSH PRIVATE KEY-----" ) - let data = try IOSConnectionExportService.exportData( + let passphrase = includeCredentials ? "export-passphrase" : nil + let data = try await IOSConnectionExportService.exportData( connections: state.connections, appState: state, includeCredentials: includeCredentials, - passphrase: nil + passphrase: passphrase ) - return try #require(String(data: data, encoding: .utf8)) + 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() throws { - let text = try exportedText(includeCredentials: false) + 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 unencrypted export with credentials carries the password but never the private key") - func credentialExportOmitsKey() throws { - let text = try exportedText(includeCredentials: true) + @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/Mocks/InMemoryCertificateStore.swift b/TableProMobile/TableProMobileTests/Mocks/InMemoryCertificateStore.swift new file mode 100644 index 0000000000..7e6fe3543e --- /dev/null +++ b/TableProMobile/TableProMobileTests/Mocks/InMemoryCertificateStore.swift @@ -0,0 +1,29 @@ +import Foundation +@testable import TableProMobile + +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) + } + } +} 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/Onboarding/AppStateFixture.swift b/TableProMobile/TableProMobileTests/Onboarding/AppStateFixture.swift index 6780650575..3bbb45d740 100644 --- a/TableProMobile/TableProMobileTests/Onboarding/AppStateFixture.swift +++ b/TableProMobile/TableProMobileTests/Onboarding/AppStateFixture.swift @@ -46,7 +46,11 @@ struct AppStateFixture { try Data("sample".utf8).write(to: bundledSample) } - func makeState(syncEnabled: Bool, secureStore: any SecureStore = MockSecureStore()) -> AppState { + 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), @@ -64,16 +68,25 @@ struct AppStateFixture { directory: root.appendingPathComponent("Samples", isDirectory: true) ), localDatabaseFiles: localFiles, - bookmarkStore: bookmarkStore + bookmarkStore: bookmarkStore, + libraryPublisher: libraryPublisher ?? ConnectionLibraryPublisher( + searchIndex: RecordingSearchIndex(), + writeWidgetItems: { _ in }, + refreshShortcutParameters: {} + ) ) } - func makeFormViewModel(editing connection: DatabaseConnection? = nil) -> ConnectionFormViewModel { + func makeFormViewModel( + editing connection: DatabaseConnection? = nil, + certificateStore: any CertificateMaterialStoring = InMemoryCertificateStore() + ) -> ConnectionFormViewModel { ConnectionFormViewModel( editing: connection, localFiles: localFiles, fileCreator: DriverDatabaseFileCreator(), - bookmarkStore: bookmarkStore + bookmarkStore: bookmarkStore, + certificateStore: certificateStore ) } diff --git a/TableProMobile/TableProMobileTests/Onboarding/AppStateLibraryTests.swift b/TableProMobile/TableProMobileTests/Onboarding/AppStateLibraryTests.swift index b10416b025..376668ddf7 100644 --- a/TableProMobile/TableProMobileTests/Onboarding/AppStateLibraryTests.swift +++ b/TableProMobile/TableProMobileTests/Onboarding/AppStateLibraryTests.swift @@ -10,6 +10,8 @@ import Testing @Suite("App state library writes") struct AppStateLibraryTests { private let fixture: AppStateFixture + private let searchIndex = RecordingSearchIndex() + private let widgetWrites = WidgetWriteCounter() private var metadata: SyncMetadataStorage { fixture.metadata } @@ -17,8 +19,25 @@ struct AppStateLibraryTests { fixture = try AppStateFixture() } - private func makeState(syncEnabled: Bool, secureStore: any SecureStore = MockSecureStore()) -> AppState { - fixture.makeState(syncEnabled: syncEnabled, secureStore: secureStore) + private func makePublisher() -> ConnectionLibraryPublisher { + let widgetWrites = widgetWrites + return ConnectionLibraryPublisher( + searchIndex: searchIndex, + writeWidgetItems: { _ in widgetWrites.total += 1 }, + refreshShortcutParameters: {} + ) + } + + 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") @@ -96,6 +115,159 @@ struct AppStateLibraryTests { #expect(state.onboarding.usageDataChoice == true) } + // 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") @@ -594,3 +766,8 @@ struct AppStateLibraryTests { #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/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/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 46f338aa6a..edc4ba8c6c 100644 --- a/docs/ios/index.mdx +++ b/docs/ios/index.mdx @@ -61,10 +61,13 @@ 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**. @@ -83,6 +86,8 @@ Three things a connection depends on are per device and never sync: 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. +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. 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. @@ -99,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. From 61f9bb02adc639f528702f03ae514950175f619c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 18 Sep 2026 21:17:46 +0700 Subject: [PATCH 04/14] fix(ios): stop an unused stored key or a partly saved form counting as unsaved changes --- ...ConnectionFormViewModel+Certificates.swift | 22 +++- .../ViewModels/ConnectionFormViewModel.swift | 22 +++- .../ConnectionFormViewModelChangesTests.swift | 113 ++++++++++++++++++ .../Mocks/InMemoryCertificateStore.swift | 4 + 4 files changed, 152 insertions(+), 9 deletions(-) diff --git a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Certificates.swift b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Certificates.swift index 56ff6205ce..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 } @@ -75,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.swift b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift index 27e8cbb09b..092d5528cd 100644 --- a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift @@ -267,7 +267,7 @@ final class ConnectionFormViewModel { storedSecrets.privateKey = privateKey sshKeyInputMode = .paste } - baseline?.secrets = storedSecrets + baseline?.secrets = snapshot.secrets } private static func storedSecret( @@ -518,7 +518,9 @@ final class ConnectionFormViewModel { } createdFileURL = nil settleBookmark() - guard storeSecrets(appState: appState, secureStore: secureStore) else { return nil } + let storedEverySecret = storeSecrets(appState: appState, secureStore: secureStore) + advanceBaselineToSavedState() + guard storedEverySecret else { return nil } return connectionId } @@ -588,13 +590,12 @@ final class ConnectionFormViewModel { private func storeSecrets(appState: AppState, secureStore: any SecureStore) -> Bool { let writes = secretWrites - var storageFailed = false - - persistCertificates(for: connectionId) + var storageFailed = !persistCertificates(for: connectionId) if let changed = writes.password { do { try appState.connectionManager.storePassword(changed, for: connectionId) + storedSecrets.password = changed } catch { Self.logger.error("Failed to store password: \(error.localizedDescription, privacy: .public)") storageFailed = true @@ -604,6 +605,7 @@ final class ConnectionFormViewModel { 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 @@ -612,6 +614,7 @@ final class ConnectionFormViewModel { 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 @@ -632,6 +635,15 @@ final class ConnectionFormViewModel { return true } + private func advanceBaselineToSavedState() { + baseline = ConnectionFormSnapshot( + edits: edits, + secrets: storedSecrets, + stagedCertificates: [:], + removedStoredCertificates: [] + ) + } + func persistPrivateKey(secureStore: any SecureStore) throws { let key = pastedPrivateKey guard key != storedSecrets.privateKey else { return } diff --git a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormViewModelChangesTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormViewModelChangesTests.swift index d00d65aad9..9c27ffc01f 100644 --- a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormViewModelChangesTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormViewModelChangesTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProDatabase @testable import TableProMobile import TableProModels import TableProOracleCore @@ -46,6 +47,14 @@ struct ConnectionFormViewModelChangesTests { ) } + 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(), @@ -159,6 +168,110 @@ struct ConnectionFormViewModelChangesTests { #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() diff --git a/TableProMobile/TableProMobileTests/Mocks/InMemoryCertificateStore.swift b/TableProMobile/TableProMobileTests/Mocks/InMemoryCertificateStore.swift index 7e6fe3543e..ae629f96f1 100644 --- a/TableProMobile/TableProMobileTests/Mocks/InMemoryCertificateStore.swift +++ b/TableProMobile/TableProMobileTests/Mocks/InMemoryCertificateStore.swift @@ -2,14 +2,18 @@ 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 } } From cce7216db2dbcf7484fe7b5bb384bf7f46736d8a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 18 Sep 2026 21:20:06 +0700 Subject: [PATCH 05/14] fix(ios): hold a redial for unsaved edits in every window, not only the one that sees it --- TableProMobile/TableProMobile/AppState.swift | 1 + .../ConnectionCoordinatorStore.swift | 45 ++++-- .../Coordinators/EditorHoldRegistry.swift | 42 +++++ .../Coordinators/ScenePresenter.swift | 15 +- .../TableProMobile/TableProMobileApp.swift | 2 +- .../TableProMobile/Views/SceneRootView.swift | 10 +- .../ConnectionCoordinatorStoreTests.swift | 151 ++++++++++++++++-- 7 files changed, 222 insertions(+), 44 deletions(-) create mode 100644 TableProMobile/TableProMobile/Coordinators/EditorHoldRegistry.swift diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index f977ce5425..fea915e7f0 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -32,6 +32,7 @@ final class AppState { let connectionManager: ConnectionManager let backgroundRelease: BackgroundReleaseCoordinator let queryActivities = QueryActivityController() + let editorHolds = EditorHoldRegistry() let syncCoordinator: IOSSyncCoordinator @ObservationIgnored private var automaticPresentationOwner: UUID? diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinatorStore.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinatorStore.swift index 1120e1fd8f..a7da2a35a9 100644 --- a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinatorStore.swift +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinatorStore.swift @@ -10,12 +10,20 @@ final class ConnectionCoordinatorStore { private var coordinators: [UUID: ConnectionCoordinator] = [:] private var removedRecords: [UUID: DatabaseConnection] = [:] - private var isHoldingRebuilds = false private var heldRebuilds: [UUID: Bool] = [:] - private let connectionManager: ConnectionManager + private var awaitsEditorRelease = false + private let editorHolds: EditorHoldRegistry + private let dropSession: (UUID) -> Void - init(connectionManager: ConnectionManager) { - self.connectionManager = connectionManager + init(editorHolds: EditorHoldRegistry, dropSession: @escaping (UUID) -> Void) { + self.editorHolds = editorHolds + self.dropSession = dropSession + } + + convenience init(connectionManager: ConnectionManager, editorHolds: EditorHoldRegistry) { + self.init(editorHolds: editorHolds) { id in + Task { await connectionManager.disconnect(id) } + } } func generation(for id: UUID) -> Int { @@ -39,16 +47,24 @@ final class ConnectionCoordinatorStore { } func invalidate(_ id: UUID, droppingSession: Bool = true) { - guard !isHoldingRebuilds else { - heldRebuilds[id] = heldRebuilds[id, default: false] || droppingSession + guard editorHolds.isHolding else { + rebuild(id, droppingSession: droppingSession) return } - rebuild(id, droppingSession: droppingSession) + heldRebuilds[id] = heldRebuilds[id, default: false] || droppingSession + awaitEditorRelease() } - func holdRebuilds(_ isHolding: Bool) { - isHoldingRebuilds = isHolding - guard !isHolding else { return } + private func awaitEditorRelease() { + guard !awaitsEditorRelease else { return } + awaitsEditorRelease = true + editorHolds.performWhenReleased { [weak self] in + self?.runHeldRebuilds() + } + } + + private func runHeldRebuilds() { + awaitsEditorRelease = false let released = heldRebuilds heldRebuilds.removeAll() for (id, droppingSession) in released { @@ -74,7 +90,7 @@ final class ConnectionCoordinatorStore { coordinators.removeValue(forKey: id)?.retire() generations[id, default: 0] += 1 guard droppingSession else { return } - disconnect(id) + dropSession(id) } private func remove(_ id: UUID) { @@ -83,12 +99,7 @@ final class ConnectionCoordinatorStore { removedRecords[id] = retired.connection retired.retire() } - disconnect(id) - } - - private func disconnect(_ id: UUID) { - let manager = connectionManager - Task { await manager.disconnect(id) } + dropSession(id) } } diff --git a/TableProMobile/TableProMobile/Coordinators/EditorHoldRegistry.swift b/TableProMobile/TableProMobile/Coordinators/EditorHoldRegistry.swift new file mode 100644 index 0000000000..8b90af3514 --- /dev/null +++ b/TableProMobile/TableProMobile/Coordinators/EditorHoldRegistry.swift @@ -0,0 +1,42 @@ +import Foundation +import Observation + +@MainActor @Observable +final class EditorHoldRegistry { + private(set) var holdingScenes: [UUID: UUID] = [:] + + @ObservationIgnored private var releaseActions: [() -> Void] = [] + + var isHolding: Bool { !holdingScenes.isEmpty } + + func isHolding(scene sceneId: UUID) -> Bool { + holdingScenes.values.contains(sceneId) + } + + func setHold(_ token: UUID, in sceneId: UUID, isHolding: Bool) { + guard (holdingScenes[token] != nil) != isHolding else { return } + guard isHolding else { + holdingScenes.removeValue(forKey: token) + runReleaseActionsIfReleased() + return + } + holdingScenes[token] = sceneId + } + + func performWhenReleased(_ action: @escaping () -> Void) { + guard isHolding else { + action() + return + } + releaseActions.append(action) + } + + private func runReleaseActionsIfReleased() { + guard !isHolding, !releaseActions.isEmpty else { return } + let actions = releaseActions + releaseActions.removeAll() + for action in actions { + action() + } + } +} diff --git a/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift b/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift index 3c663fab7f..95bc7ea0bb 100644 --- a/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift +++ b/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift @@ -53,14 +53,18 @@ final class ScenePresenter { 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 } + var isHeldByEditor: Bool { editorHolds.isHolding(scene: sceneId) } + @ObservationIgnored private let editorHolds: EditorHoldRegistry @ObservationIgnored private var hasBegunLaunch = false @ObservationIgnored private var presentedLaunchSheet = false @ObservationIgnored private var presentedFirstRunPages: [FirstRunPage] = [] + init(editorHolds: EditorHoldRegistry = EditorHoldRegistry()) { + self.editorHolds = editorHolds + } + func beginLaunch(with appState: AppState) { guard !hasBegunLaunch else { return } hasBegunLaunch = true @@ -113,12 +117,7 @@ final class ScenePresenter { } func setEditorHold(_ token: UUID, isHolding: Bool) { - guard editorHolds.contains(token) != isHolding else { return } - if isHolding { - editorHolds.insert(token) - } else { - editorHolds.remove(token) - } + editorHolds.setHold(token, in: sceneId, isHolding: isHolding) } func takeDeliverableIntent(isLocked: Bool, isLibraryWritable: Bool) -> SceneIntent? { diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index 9dc3a4f55d..3d1c39081f 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -24,7 +24,7 @@ struct TableProMobileApp: App { var body: some Scene { WindowGroup { - SceneRootView(connectionManager: appState.connectionManager) + SceneRootView(connectionManager: appState.connectionManager, editorHolds: appState.editorHolds) .environment(appState) .environment(lockState) .hostKeyPrompt() diff --git a/TableProMobile/TableProMobile/Views/SceneRootView.swift b/TableProMobile/TableProMobile/Views/SceneRootView.swift index 729109acec..34057e634e 100644 --- a/TableProMobile/TableProMobile/Views/SceneRootView.swift +++ b/TableProMobile/TableProMobile/Views/SceneRootView.swift @@ -9,12 +9,13 @@ struct SceneRootView: View { @Environment(\.scenePhase) private var scenePhase @EnvironmentObject private var sceneDelegate: TableProSceneDelegate @State private var coordinatorStore: ConnectionCoordinatorStore - @State private var presenter = ScenePresenter() + @State private var presenter: ScenePresenter - init(connectionManager: ConnectionManager) { + init(connectionManager: ConnectionManager, editorHolds: EditorHoldRegistry) { _coordinatorStore = State( - initialValue: ConnectionCoordinatorStore(connectionManager: connectionManager) + initialValue: ConnectionCoordinatorStore(connectionManager: connectionManager, editorHolds: editorHolds) ) + _presenter = State(initialValue: ScenePresenter(editorHolds: editorHolds)) } var body: some View { @@ -24,9 +25,6 @@ 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/TableProMobileTests/ConnectionCoordinatorStoreTests.swift b/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift index dc48fa52da..5181a4f461 100644 --- a/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift @@ -138,11 +138,24 @@ struct ConnectionCoordinatorStoreTests { private let fixture: AppStateFixture private let appState: AppState private let store: ConnectionCoordinatorStore + private let scene = UUID() + private let editor = UUID() init() throws { fixture = try AppStateFixture() appState = fixture.makeState(syncEnabled: false) - store = ConnectionCoordinatorStore(connectionManager: appState.connectionManager) + store = ConnectionCoordinatorStore( + connectionManager: appState.connectionManager, + editorHolds: appState.editorHolds + ) + } + + private func holdForUnsavedEdits() { + appState.editorHolds.setHold(editor, in: scene, isHolding: true) + } + + private func releaseUnsavedEdits() { + appState.editorHolds.setHold(editor, in: scene, isHolding: false) } private func connection(_ name: String) -> DatabaseConnection { @@ -265,7 +278,7 @@ struct ConnectionCoordinatorStoreTests { #expect(appState.addConnection(original)) let coordinator = store.coordinator(for: original, appState: appState) let form = fixture.makeFormViewModel(editing: original) - store.holdRebuilds(true) + holdForUnsavedEdits() form.password = "rotated" form.host = "replica.example.com" @@ -281,7 +294,7 @@ struct ConnectionCoordinatorStoreTests { #expect(store.generation(for: original.id) == 0) #expect(coordinator.connection.host == "replica.example.com") - store.holdRebuilds(false) + releaseUnsavedEdits() #expect(store.generation(for: original.id) == 1) #expect(store.coordinator(for: original, appState: appState) !== coordinator) @@ -295,14 +308,14 @@ struct ConnectionCoordinatorStoreTests { rehosted.host = "replica.example.com" rehosted.safeModeLevel = .readOnly - store.holdRebuilds(true) + holdForUnsavedEdits() 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) + releaseUnsavedEdits() #expect(store.generation(for: original.id) == 1) #expect(store.coordinator(for: rehosted, appState: appState) !== coordinator) @@ -313,11 +326,11 @@ struct ConnectionCoordinatorStoreTests { let original = connection("A") _ = store.coordinator(for: original, appState: appState) - store.holdRebuilds(true) + holdForUnsavedEdits() store.invalidate(original.id) #expect(store.generation(for: original.id) == 0) - store.holdRebuilds(false) + releaseUnsavedEdits() #expect(store.generation(for: original.id) == 1) } @@ -330,11 +343,11 @@ struct ConnectionCoordinatorStoreTests { var reported = rehosted reported.port = 5_433 - store.holdRebuilds(true) + holdForUnsavedEdits() store.reconcile(from: [original], to: [rehosted]) store.reconcile(from: [rehosted], to: [reported]) - store.holdRebuilds(true) - store.holdRebuilds(false) + holdForUnsavedEdits() + releaseUnsavedEdits() #expect(store.generation(for: original.id) == 1) } @@ -346,13 +359,127 @@ struct ConnectionCoordinatorStoreTests { var rehosted = original rehosted.host = "replica.example.com" - store.holdRebuilds(true) + holdForUnsavedEdits() store.reconcile(from: [original], to: [rehosted]) store.reconcile(from: [rehosted], to: []) - store.holdRebuilds(false) + releaseUnsavedEdits() #expect(store.generation(for: original.id) == 0) #expect(coordinator.session == nil) #expect(store.presentedRecord(for: original.id, in: [])?.host == "replica.example.com") } } + +@MainActor +private final class DroppedSessions { + private(set) var drops: [String] = [] + + func record(_ window: String, _ id: UUID) { + drops.append("\(window) \(id.uuidString)") + } +} + +@MainActor +@Suite("Editor holds across windows") +struct EditorHoldAcrossWindowsTests { + private let fixture: AppStateFixture + private let appState: AppState + private let holds = EditorHoldRegistry() + private let dropped = DroppedSessions() + + init() throws { + fixture = try AppStateFixture() + appState = fixture.makeState(syncEnabled: false) + } + + private func window(_ name: String) -> ConnectionCoordinatorStore { + ConnectionCoordinatorStore(editorHolds: holds) { [dropped] id in + dropped.record(name, id) + } + } + + private func connection() -> DatabaseConnection { + DatabaseConnection(name: "A", type: .postgresql, host: "a.example.com", port: 5_432) + } + + @Test("A redial seen by a window with no editor waits for an editor in another window") + func redialWaitsForAnEditorInAnyWindow() { + let original = connection() + let editing = window("editing") + let listing = window("listing") + let coordinator = editing.coordinator(for: original, appState: appState) + let editor = UUID() + let editingScene = UUID() + var rehosted = original + rehosted.host = "replica.example.com" + holds.setHold(editor, in: editingScene, isHolding: true) + + editing.reconcile(from: [original], to: [rehosted]) + listing.reconcile(from: [original], to: [rehosted]) + + #expect(dropped.drops.isEmpty) + #expect(editing.coordinator(for: rehosted, appState: appState) === coordinator) + #expect(coordinator.connection.host == "replica.example.com") + #expect(listing.generation(for: original.id) == 0) + + holds.setHold(editor, in: editingScene, isHolding: false) + + #expect(dropped.drops.sorted() == ["editing \(original.id.uuidString)", "listing \(original.id.uuidString)"]) + #expect(editing.generation(for: original.id) == 1) + #expect(listing.generation(for: original.id) == 1) + #expect(editing.coordinator(for: rehosted, appState: appState) !== coordinator) + } + + @Test("A session held by editors in two windows is dropped only after the last one lets go") + func lastEditorReleasesTheDrop() { + let original = connection() + let listing = window("listing") + let first = UUID() + let second = UUID() + var rehosted = original + rehosted.port = 5_433 + holds.setHold(first, in: UUID(), isHolding: true) + holds.setHold(second, in: UUID(), isHolding: true) + + listing.reconcile(from: [original], to: [rehosted]) + holds.setHold(first, in: UUID(), isHolding: false) + + #expect(dropped.drops.isEmpty) + #expect(listing.generation(for: original.id) == 0) + + holds.setHold(second, in: UUID(), isHolding: false) + + #expect(dropped.drops == ["listing \(original.id.uuidString)"]) + #expect(listing.generation(for: original.id) == 1) + } + + @Test("A redial with no editor anywhere drops the session at once") + func redialWithoutEditorDropsAtOnce() { + let original = connection() + let listing = window("listing") + var rehosted = original + rehosted.username = "readonly" + + listing.reconcile(from: [original], to: [rehosted]) + + #expect(dropped.drops == ["listing \(original.id.uuidString)"]) + } + + @Test("Each window's presenter reports only its own editors, while every window's rebuilds wait") + func presentersShareOneRegistry() { + let first = ScenePresenter(editorHolds: holds) + let second = ScenePresenter(editorHolds: holds) + let editor = UUID() + + first.setEditorHold(editor, isHolding: true) + + #expect(first.isHeldByEditor) + #expect(second.isHeldByEditor == false) + #expect(holds.isHolding) + + first.setEditorHold(editor, isHolding: false) + + #expect(first.isHeldByEditor == false) + #expect(holds.isHolding == false) + } +} From 090a3f61fcecc4c589b8501061d2a48ad2ddf86f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 18 Sep 2026 21:21:27 +0700 Subject: [PATCH 06/14] fix(ios): merge SSH tunnel and Oracle edits field by field so synced sub-fields survive --- .../Helpers/ConnectionFormEdits.swift | 60 +++++++++++------ .../ConnectionFormEditsTests.swift | 64 ++++++++++++++++++- 2 files changed, 103 insertions(+), 21 deletions(-) diff --git a/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift b/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift index 8a23b607c9..8c2e50f6a5 100644 --- a/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift +++ b/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift @@ -40,8 +40,7 @@ nonisolated struct ConnectionFormEdits: Equatable, Sendable { func applied(to base: DatabaseConnection, changedSince opening: ConnectionFormEdits?) -> DatabaseConnection { func changed(_ field: KeyPath) -> Bool { - guard let opening else { return true } - return opening[keyPath: field] != self[keyPath: field] + Self.differs(field, from: opening, to: self) } var connection = base @@ -58,11 +57,20 @@ nonisolated struct ConnectionFormEdits: Equatable, Sendable { connection.isReadOnly = safeModeLevel.blocksWrites } if changed(\.sslMode) { applySSLMode(to: &connection) } - if changed(\.sshTunnel) { applySSHTunnel(to: &connection) } - if changed(\.oracle) { applyOracleOptions(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 @@ -74,32 +82,46 @@ nonisolated struct ConnectionFormEdits: Equatable, Sendable { connection.sslEnabled = sslMode != .disable } - private func applySSHTunnel(to connection: inout DatabaseConnection) { + 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() - configuration.host = sshTunnel.host - configuration.port = sshTunnel.port - configuration.username = sshTunnel.username - configuration.authMethod = sshTunnel.authMethod - configuration.privateKeyPath = sshTunnel.privateKeyPath - if configuration.macEnabled != nil { - configuration.macEnabled = true + 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.sshEnabled = true connection.sshConfiguration = configuration } - private func applyOracleOptions(to connection: inout DatabaseConnection) { + 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 - connection.additionalFields[Key.connectionType] = oracle.identifierMode.rawValue - connection.additionalFields[Key.serviceName] = oracle.serviceName - connection.additionalFields[Key.sid] = oracle.sid - connection.additionalFields[Key.role] = oracle.role.rawValue - connection.additionalFields[Key.networkEncryption] = oracle.networkEncryption.rawValue + 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/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift index fcf0978960..67d59eae9d 100644 --- a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift @@ -109,7 +109,44 @@ struct ConnectionFormEditsTests { #expect(saved.sshConfiguration?.macAgentSocketPath == "/tmp/agent.sock") } - @Test("Turning SSH on over a tunnel the Mac had switched off switches it on there too") + @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 @@ -176,7 +213,30 @@ struct ConnectionFormEditsTests { #expect(saved.additionalFields["custom"] == "kept") } - @Test("A Safe Mode change writes the legacy read-only flag with it") + @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) From 0206a9b6a469baadac77be9ad7ef263db83d6cb3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 18 Sep 2026 21:23:15 +0700 Subject: [PATCH 07/14] fix(ios): start sync over once when an earlier build synced without recording its account --- .../SyncMetadataStorage.swift | 19 +++++++--- .../SyncMetadataStorageTests.swift | 26 +++++++++++-- .../Sync/IOSSyncCoordinator.swift | 10 ++++- .../Sync/IOSSyncCoordinatorTests.swift | 38 +++++++++++++++++++ 4 files changed, 82 insertions(+), 11 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProSyncTransport/SyncMetadataStorage.swift b/Packages/TableProCore/Sources/TableProSyncTransport/SyncMetadataStorage.swift index 6b23f0d51c..d615f5f17a 100644 --- a/Packages/TableProCore/Sources/TableProSyncTransport/SyncMetadataStorage.swift +++ b/Packages/TableProCore/Sources/TableProSyncTransport/SyncMetadataStorage.swift @@ -16,6 +16,7 @@ public enum SyncAccountChange: Equatable, Sendable { case firstSeen case unchanged case switched + case previousAccountUnknown } public final class SyncMetadataStorage: @unchecked Sendable { @@ -153,20 +154,26 @@ public final class SyncMetadataStorage: @unchecked Sendable { public func adoptAccount(_ accountId: String) -> SyncAccountChange { guard let recorded = lastAccountId else { lastAccountId = accountId - return .firstSeen + guard hasStoredToken else { return .firstSeen } + forgetServerPosition() + return .previousAccountUnknown } guard recorded != accountId else { return .unchanged } - forgetServerState() + forgetServerPosition() + for type in SyncRecordType.allCases { + clearTombstones(type: type) + } lastAccountId = accountId return .switched } - private func forgetServerState() { + private var hasStoredToken: Bool { + userDefaults.object(forKey: key("serverChangeToken")) != nil + } + + private func forgetServerPosition() { saveToken(nil) userDefaults.removeObject(forKey: key("lastSyncDate")) - for type in SyncRecordType.allCases { - clearTombstones(type: type) - } } // MARK: - Reset diff --git a/Packages/TableProCore/Tests/TableProSyncTests/SyncMetadataStorageTests.swift b/Packages/TableProCore/Tests/TableProSyncTests/SyncMetadataStorageTests.swift index 6db2a2f357..eaf9a0d9cd 100644 --- a/Packages/TableProCore/Tests/TableProSyncTests/SyncMetadataStorageTests.swift +++ b/Packages/TableProCore/Tests/TableProSyncTests/SyncMetadataStorageTests.swift @@ -90,20 +90,40 @@ struct SyncMetadataStorageTests { #expect(storage.lastAccountId == "account") } - @Test("The first account seen is recorded and nothing queued is dropped") + @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) - defaults.set(Data([1, 2, 3]), forKey: "com.TablePro.sync.serverChangeToken") #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"]) - #expect(defaults.data(forKey: "com.TablePro.sync.serverChangeToken") == Data([1, 2, 3])) + } + + @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") diff --git a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift index eb265e0f0a..cce603bdb4 100644 --- a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift +++ b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift @@ -184,10 +184,16 @@ final class IOSSyncCoordinator { } private func adoptAccount(_ accountId: String) { - guard metadata.adoptAccount(accountId) == .switched else { return } + 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 - Self.logger.notice("The iCloud account changed, so sync starts over and pending edits go to the new account") } @discardableResult diff --git a/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift b/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift index c34fb9dea8..c33442db10 100644 --- a/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift +++ b/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift @@ -396,6 +396,44 @@ struct IOSSyncCoordinatorTests { #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() From b906a214faa68be0f8cf60086f1ca5f2740b456d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 18 Sep 2026 21:26:13 +0700 Subject: [PATCH 08/14] fix(ios): sweep orphaned device-local SSH keys and name every imported or exported secret by its kind --- CHANGELOG.md | 2 + .../Platform/ConnectionSecretKind.swift | 24 +++--- .../Platform/KeychainSecureStore.swift | 31 ++++--- .../Services/IOSConnectionExportService.swift | 15 ++-- .../Services/IOSConnectionImportService.swift | 45 ++++++++-- .../ConnectionSecretsTests.swift | 26 ++++-- .../IOSConnectionImportServiceTests.swift | 83 +++++++++++++++++++ 7 files changed, 175 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af987fc0cc..3755877dea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- A connection replaced by an import on iPhone and iPad still signing in with its old pasted SSH key. ### Security @@ -127,6 +128,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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. +- SSH private keys of deleted connections kept in the iPhone and iPad Keychain with Sync Passwords off. ## [0.75.0] - 2026-09-18 diff --git a/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift b/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift index 21bc9dafea..a16383baf4 100644 --- a/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift +++ b/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift @@ -6,10 +6,6 @@ nonisolated enum ConnectionSecretKind: CaseIterable, Sendable { case keyPassphrase case sshPrivateKey - static var orphanSweepPrefixes: [String] { - allCases.filter(\.isSweptWhenOrphaned).map(\.prefix) - } - var prefix: String { switch self { case .password: "com.TablePro.password." @@ -19,14 +15,20 @@ nonisolated enum ConnectionSecretKind: CaseIterable, Sendable { } } - var isSweptWhenOrphaned: Bool { - switch self { - case .password, .sshPassword, .keyPassphrase: true - case .sshPrivateKey: false - } - } - func account(for connectionId: UUID) -> String { prefix + connectionId.uuidString } + + static func connectionId(inAccount account: String) -> UUID? { + guard let kind = allCases.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 = connectionId(inAccount: account) else { return false } + return !validConnectionIds.contains(connectionId) + } + } } diff --git a/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift b/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift index 00d155295f..17d1d47d33 100644 --- a/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift +++ b/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift @@ -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 } @@ -115,15 +118,15 @@ nonisolated final class KeychainSecureStore: SecureStore { } } - /// Remove passwords left under ids no saved connection uses, such as a test connection an older - /// build stored and was killed before deleting. + /// Remove secrets left under ids no saved connection uses, such as a test connection an older + /// build stored and was killed before deleting, or a connection deleted on another device. /// - /// Three limits, because this deletes by prefix and cannot tell a throwaway id from an id it has + /// Two 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. 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. + /// 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, and a synchronizable pasted + /// private key may be the only copy of a key for a connection sync has not delivered yet. static func cleanOrphanedCredentials(validConnectionIds: Set) { guard !validConnectionIds.isEmpty else { return } @@ -140,15 +143,9 @@ nonisolated final class KeychainSecureStore: SecureStore { 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 ConnectionSecretKind.orphanSweepPrefixes { - 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, accessGroup: accessGroup) - } + let accounts = items.compactMap { $0[kSecAttrAccount as String] as? String } + for account in ConnectionSecretKind.orphanedAccounts(accounts, keeping: validConnectionIds) { + deleteDeviceLocal(forKey: account, accessGroup: accessGroup) } } diff --git a/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift b/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift index 1141fa1bb1..4ca4bdf7c5 100644 --- a/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift +++ b/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift @@ -119,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( @@ -148,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 668afba5c2..46422d32ae 100644 --- a/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift +++ b/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift @@ -97,6 +97,9 @@ enum IOSConnectionImportService { logger.error("Import could not replace a connection that is no longer in the library") continue } + if !expectsPastedPrivateKey(connection) { + deleteSecret(.sshPrivateKey, of: existingId, from: appState.secureStore) + } connectionIdMap[index] = existingId importedCount += 1 } @@ -118,19 +121,43 @@ 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)") + } + } + + private static func deleteSecret(_ kind: ConnectionSecretKind, of connectionId: UUID, from secureStore: any SecureStore) { + do { + try secureStore.delete(forKey: kind.account(for: connectionId)) + } catch { + logger.error("Clearing a replaced connection's secret failed: \(error.localizedDescription, privacy: .public)") + } + } + + private static func expectsPastedPrivateKey(_ connection: DatabaseConnection) -> Bool { + guard connection.sshEnabled, let ssh = connection.sshConfiguration else { return false } + return ssh.authMethod == .privateKey && (ssh.privateKeyPath ?? "").isEmpty + } + // MARK: - Building private static func buildConnection( diff --git a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift index 1a5ce23041..7ccd9f94d1 100644 --- a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift @@ -73,14 +73,24 @@ struct ConnectionSecretsTests { #expect(ConnectionSecretKind.sshPrivateKey.account(for: id) == "com.TablePro.sshkeydata.\(id.uuidString)") } - @Test("The orphan sweep never takes a pasted private key") - func sweepSparesPrivateKeys() { - #expect(!ConnectionSecretKind.orphanSweepPrefixes.contains(ConnectionSecretKind.sshPrivateKey.prefix)) - #expect(ConnectionSecretKind.orphanSweepPrefixes == [ - "com.TablePro.password.", - "com.TablePro.sshpassword.", - "com.TablePro.keypassphrase." - ]) + @Test("The orphan sweep takes every secret of a connection that is gone, pasted keys included, and nothing else") + func sweepTakesOrphanedSecretsOnly() { + 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) == Set(ConnectionSecretKind.allCases.map { $0.account(for: removed) })) + #expect(orphaned.contains("com.TablePro.sshkeydata.\(removed.uuidString)")) + } + + @Test("The orphan sweep takes nothing before the connections have loaded") + func sweepWaitsForTheLibrary() { + let account = ConnectionSecretKind.sshPrivateKey.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") diff --git a/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift b/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift index db758ad80a..ac4c8cea01 100644 --- a/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift +++ b/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift @@ -81,3 +81,86 @@ 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 with a key file, a password or no tunnel clears the pasted key the old connection used") + func replaceClearsUnusedPastedKey() throws { + #expect(appState.addConnection(existing)) + let incoming: [ExportableSSHConfig?] = [ + 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) == nil, "\(ssh?.authMethod ?? "no tunnel")") + } + } + + @Test("Replacing with a private key and no key file keeps the pasted key for it") + func replaceKeepsPastedKeyItStillNeeds() throws { + #expect(appState.addConnection(existing)) + store.seed(keyAccount, "PASTED KEY") + + #expect(replaceExisting(with: tunnel(authMethod: "privateKey")) == 1) + + #expect(try store.retrieve(forKey: keyAccount) == "PASTED KEY") + } +} From fc46c1fb1ac57f939fe8f03299538b18ab577529 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 18 Sep 2026 21:28:39 +0700 Subject: [PATCH 09/14] fix(ios): copy a picked SSH key file under a unique name and keep no path when the copy fails --- CHANGELOG.md | 1 + .../ViewModels/ConnectionFormViewModel.swift | 37 +++++++--- .../Views/ConnectionFormView.swift | 12 ++++ .../ConnectionFormViewModelSSHKeyTests.swift | 70 +++++++++++++++++++ 4 files changed, 111 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3755877dea..1be191f7b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Deleted connections still showing in Spotlight, Siri, Shortcuts and Handoff on iPhone and iPad. - No confirmation before deleting a tag on iPhone and iPad. - A connection replaced by an import on iPhone and iPad still signing in with its old pasted SSH key. +- Picking an SSH key file on iPhone and iPad replacing another connection's key file of the same name. ### Security diff --git a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift index 092d5528cd..e461ccb116 100644 --- a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift @@ -90,6 +90,7 @@ final class ConnectionFormViewModel { } private(set) var pendingFile: PendingDatabaseFile? private(set) var fileError: String? + private(set) var sshKeyFileError: String? // Async state private(set) var isTesting = false @@ -362,20 +363,38 @@ final class ConnectionFormViewModel { } 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 } + guard !localFiles.isInDocuments(url) else { + sshKeyPath = url.path + return + } + do { + sshKeyPath = try localFiles.importCopy(of: url).path + } catch { + sshKeyFileError = error.localizedDescription + } + } + + func dismissSSHKeyFileError() { + sshKeyFileError = nil } func clearSelectedFile() { diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index 4e0bbfa898..0d5543c78e 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -68,6 +68,13 @@ struct ConnectionFormView: View { ) } + private var showSSHKeyFileError: Binding { + Binding( + get: { viewModel.sshKeyFileError != nil }, + set: { if !$0 { viewModel.dismissSSHKeyFileError() } } + ) + } + var body: some View { @Bindable var viewModel = viewModel return NavigationStack { @@ -187,6 +194,11 @@ struct ConnectionFormView: View { } message: { Text(viewModel.fileError ?? "") } + .alert("Private Key", isPresented: showSSHKeyFileError) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.sshKeyFileError ?? "") + } .libraryWriteFailureAlert( viewModel.saveFailure, onDismiss: viewModel.dismissSaveFailure, diff --git a/TableProMobile/TableProMobileTests/ConnectionFormViewModelSSHKeyTests.swift b/TableProMobile/TableProMobileTests/ConnectionFormViewModelSSHKeyTests.swift index 2d76174721..73a26a9064 100644 --- a/TableProMobile/TableProMobileTests/ConnectionFormViewModelSSHKeyTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionFormViewModelSSHKeyTests.swift @@ -201,6 +201,76 @@ struct ConnectionFormViewModelSSHKeyTests { } } + @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() From 3c58b57f3660685aa5a08ac84e77e3d26d2e196b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 18 Sep 2026 21:30:17 +0700 Subject: [PATCH 10/14] fix(ios): re-check safe mode when a row insert is confirmed, through the same gate as row edits --- .../Helpers/ConfirmedWriteGate.swift | 35 ++++++++++++ .../ViewModels/RowDetailViewModel.swift | 20 ++++--- .../Views/DataBrowserView.swift | 2 +- .../TableProMobile/Views/InsertRowView.swift | 21 ++++---- .../Helpers/ConfirmedWriteGateTests.swift | 53 +++++++++++++++++++ 5 files changed, 108 insertions(+), 23 deletions(-) create mode 100644 TableProMobile/TableProMobile/Helpers/ConfirmedWriteGate.swift create mode 100644 TableProMobile/TableProMobileTests/Helpers/ConfirmedWriteGateTests.swift 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/ViewModels/RowDetailViewModel.swift b/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift index 572c462958..d343db18d5 100644 --- a/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift @@ -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?)? @@ -184,7 +184,7 @@ final class RowDetailViewModel { 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 } @@ -221,24 +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 - guard !safeModeLevel.blocksWrites else { return false } - 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/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index bc9bacca9f..f2d4fb0ec1 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -484,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 f77fdda173..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,7 +21,7 @@ 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 @@ -91,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)) } @@ -253,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/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) + } +} From a10edf1e08fb51702251dcaf1136f13ce424e3fb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 18 Sep 2026 21:31:25 +0700 Subject: [PATCH 11/14] fix(ios): translate the save failure and database file messages --- .../TableProMobile/Localizable.xcstrings | 476 ++++++++++++++++++ 1 file changed, 476 insertions(+) diff --git a/TableProMobile/TableProMobile/Localizable.xcstrings b/TableProMobile/TableProMobile/Localizable.xcstrings index 1eac8f6213..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" : { @@ -5562,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" : { @@ -7000,6 +7196,62 @@ } } }, + "Group Deleted" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "그룹 삭제됨" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhóm đã bị xóa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分组已删除" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "群組已刪除" + } + } + } + }, + "Group Not Saved" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "그룹이 저장되지 않음" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa lưu nhóm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分组未保存" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "群組未儲存" + } + } + } + }, "Group by Folder" : { "extractionState" : "stale", "localizations" : { @@ -14969,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" : { @@ -15286,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" : { @@ -17270,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" : { @@ -17494,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" : { @@ -19070,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" : { @@ -20060,6 +20508,34 @@ } } }, + "“%@” 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" : { From 79eadad29107902522d411779f021d08d9a6badb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 18 Sep 2026 21:31:50 +0700 Subject: [PATCH 12/14] docs(ios): say that a saved secret reconnects an open connection and that holds span windows --- docs/ios/index.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ios/index.mdx b/docs/ios/index.mdx index edc4ba8c6c..8cc156d063 100644 --- a/docs/ios/index.mdx +++ b/docs/ios/index.mdx @@ -86,7 +86,7 @@ Three things a connection depends on are per device and never sync: 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. -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. A connection deleted on another device shows **Connection Deleted** before returning to the list. +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 connection form has unsaved changes, in this window or another. 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. From 93c2c58b2f3f55d23c0e27232d35280e49115a56 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 18 Sep 2026 21:45:50 +0700 Subject: [PATCH 13/14] Revert "fix(ios): hold a redial for unsaved edits in every window, not only the one that sees it" This reverts commit bb966741d95d31135ffc1a58c4b80f63d28f0a33. --- TableProMobile/TableProMobile/AppState.swift | 1 - .../ConnectionCoordinatorStore.swift | 45 ++---- .../Coordinators/EditorHoldRegistry.swift | 42 ----- .../Coordinators/ScenePresenter.swift | 15 +- .../TableProMobile/TableProMobileApp.swift | 2 +- .../TableProMobile/Views/SceneRootView.swift | 10 +- .../ConnectionCoordinatorStoreTests.swift | 151 ++---------------- 7 files changed, 44 insertions(+), 222 deletions(-) delete mode 100644 TableProMobile/TableProMobile/Coordinators/EditorHoldRegistry.swift diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index fea915e7f0..f977ce5425 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -32,7 +32,6 @@ final class AppState { let connectionManager: ConnectionManager let backgroundRelease: BackgroundReleaseCoordinator let queryActivities = QueryActivityController() - let editorHolds = EditorHoldRegistry() let syncCoordinator: IOSSyncCoordinator @ObservationIgnored private var automaticPresentationOwner: UUID? diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinatorStore.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinatorStore.swift index a7da2a35a9..1120e1fd8f 100644 --- a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinatorStore.swift +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinatorStore.swift @@ -10,20 +10,12 @@ final class ConnectionCoordinatorStore { private var coordinators: [UUID: ConnectionCoordinator] = [:] private var removedRecords: [UUID: DatabaseConnection] = [:] + private var isHoldingRebuilds = false private var heldRebuilds: [UUID: Bool] = [:] - private var awaitsEditorRelease = false - private let editorHolds: EditorHoldRegistry - private let dropSession: (UUID) -> Void + private let connectionManager: ConnectionManager - init(editorHolds: EditorHoldRegistry, dropSession: @escaping (UUID) -> Void) { - self.editorHolds = editorHolds - self.dropSession = dropSession - } - - convenience init(connectionManager: ConnectionManager, editorHolds: EditorHoldRegistry) { - self.init(editorHolds: editorHolds) { id in - Task { await connectionManager.disconnect(id) } - } + init(connectionManager: ConnectionManager) { + self.connectionManager = connectionManager } func generation(for id: UUID) -> Int { @@ -47,24 +39,16 @@ final class ConnectionCoordinatorStore { } func invalidate(_ id: UUID, droppingSession: Bool = true) { - guard editorHolds.isHolding else { - rebuild(id, droppingSession: droppingSession) + guard !isHoldingRebuilds else { + heldRebuilds[id] = heldRebuilds[id, default: false] || droppingSession return } - heldRebuilds[id] = heldRebuilds[id, default: false] || droppingSession - awaitEditorRelease() + rebuild(id, droppingSession: droppingSession) } - private func awaitEditorRelease() { - guard !awaitsEditorRelease else { return } - awaitsEditorRelease = true - editorHolds.performWhenReleased { [weak self] in - self?.runHeldRebuilds() - } - } - - private func runHeldRebuilds() { - awaitsEditorRelease = false + func holdRebuilds(_ isHolding: Bool) { + isHoldingRebuilds = isHolding + guard !isHolding else { return } let released = heldRebuilds heldRebuilds.removeAll() for (id, droppingSession) in released { @@ -90,7 +74,7 @@ final class ConnectionCoordinatorStore { coordinators.removeValue(forKey: id)?.retire() generations[id, default: 0] += 1 guard droppingSession else { return } - dropSession(id) + disconnect(id) } private func remove(_ id: UUID) { @@ -99,7 +83,12 @@ final class ConnectionCoordinatorStore { removedRecords[id] = retired.connection retired.retire() } - dropSession(id) + disconnect(id) + } + + private func disconnect(_ id: UUID) { + let manager = connectionManager + Task { await manager.disconnect(id) } } } diff --git a/TableProMobile/TableProMobile/Coordinators/EditorHoldRegistry.swift b/TableProMobile/TableProMobile/Coordinators/EditorHoldRegistry.swift deleted file mode 100644 index 8b90af3514..0000000000 --- a/TableProMobile/TableProMobile/Coordinators/EditorHoldRegistry.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation -import Observation - -@MainActor @Observable -final class EditorHoldRegistry { - private(set) var holdingScenes: [UUID: UUID] = [:] - - @ObservationIgnored private var releaseActions: [() -> Void] = [] - - var isHolding: Bool { !holdingScenes.isEmpty } - - func isHolding(scene sceneId: UUID) -> Bool { - holdingScenes.values.contains(sceneId) - } - - func setHold(_ token: UUID, in sceneId: UUID, isHolding: Bool) { - guard (holdingScenes[token] != nil) != isHolding else { return } - guard isHolding else { - holdingScenes.removeValue(forKey: token) - runReleaseActionsIfReleased() - return - } - holdingScenes[token] = sceneId - } - - func performWhenReleased(_ action: @escaping () -> Void) { - guard isHolding else { - action() - return - } - releaseActions.append(action) - } - - private func runReleaseActionsIfReleased() { - guard !isHolding, !releaseActions.isEmpty else { return } - let actions = releaseActions - releaseActions.removeAll() - for action in actions { - action() - } - } -} diff --git a/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift b/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift index 95bc7ea0bb..3c663fab7f 100644 --- a/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift +++ b/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift @@ -53,18 +53,14 @@ final class ScenePresenter { private(set) var pendingIntent: SceneIntent? private(set) var pendingTable: PendingTableRequest? private(set) var holdsConnectionRestore = false + private(set) var editorHolds: Set = [] - var isHeldByEditor: Bool { editorHolds.isHolding(scene: sceneId) } + var isHeldByEditor: Bool { !editorHolds.isEmpty } - @ObservationIgnored private let editorHolds: EditorHoldRegistry @ObservationIgnored private var hasBegunLaunch = false @ObservationIgnored private var presentedLaunchSheet = false @ObservationIgnored private var presentedFirstRunPages: [FirstRunPage] = [] - init(editorHolds: EditorHoldRegistry = EditorHoldRegistry()) { - self.editorHolds = editorHolds - } - func beginLaunch(with appState: AppState) { guard !hasBegunLaunch else { return } hasBegunLaunch = true @@ -117,7 +113,12 @@ final class ScenePresenter { } func setEditorHold(_ token: UUID, isHolding: Bool) { - editorHolds.setHold(token, in: sceneId, isHolding: isHolding) + guard editorHolds.contains(token) != isHolding else { return } + if isHolding { + editorHolds.insert(token) + } else { + editorHolds.remove(token) + } } func takeDeliverableIntent(isLocked: Bool, isLibraryWritable: Bool) -> SceneIntent? { diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index 3d1c39081f..9dc3a4f55d 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -24,7 +24,7 @@ struct TableProMobileApp: App { var body: some Scene { WindowGroup { - SceneRootView(connectionManager: appState.connectionManager, editorHolds: appState.editorHolds) + SceneRootView(connectionManager: appState.connectionManager) .environment(appState) .environment(lockState) .hostKeyPrompt() diff --git a/TableProMobile/TableProMobile/Views/SceneRootView.swift b/TableProMobile/TableProMobile/Views/SceneRootView.swift index 34057e634e..729109acec 100644 --- a/TableProMobile/TableProMobile/Views/SceneRootView.swift +++ b/TableProMobile/TableProMobile/Views/SceneRootView.swift @@ -9,13 +9,12 @@ struct SceneRootView: View { @Environment(\.scenePhase) private var scenePhase @EnvironmentObject private var sceneDelegate: TableProSceneDelegate @State private var coordinatorStore: ConnectionCoordinatorStore - @State private var presenter: ScenePresenter + @State private var presenter = ScenePresenter() - init(connectionManager: ConnectionManager, editorHolds: EditorHoldRegistry) { + init(connectionManager: ConnectionManager) { _coordinatorStore = State( - initialValue: ConnectionCoordinatorStore(connectionManager: connectionManager, editorHolds: editorHolds) + initialValue: ConnectionCoordinatorStore(connectionManager: connectionManager) ) - _presenter = State(initialValue: ScenePresenter(editorHolds: editorHolds)) } var body: some View { @@ -25,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/TableProMobileTests/ConnectionCoordinatorStoreTests.swift b/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift index 5181a4f461..dc48fa52da 100644 --- a/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift @@ -138,24 +138,11 @@ struct ConnectionCoordinatorStoreTests { private let fixture: AppStateFixture private let appState: AppState private let store: ConnectionCoordinatorStore - private let scene = UUID() - private let editor = UUID() init() throws { fixture = try AppStateFixture() appState = fixture.makeState(syncEnabled: false) - store = ConnectionCoordinatorStore( - connectionManager: appState.connectionManager, - editorHolds: appState.editorHolds - ) - } - - private func holdForUnsavedEdits() { - appState.editorHolds.setHold(editor, in: scene, isHolding: true) - } - - private func releaseUnsavedEdits() { - appState.editorHolds.setHold(editor, in: scene, isHolding: false) + store = ConnectionCoordinatorStore(connectionManager: appState.connectionManager) } private func connection(_ name: String) -> DatabaseConnection { @@ -278,7 +265,7 @@ struct ConnectionCoordinatorStoreTests { #expect(appState.addConnection(original)) let coordinator = store.coordinator(for: original, appState: appState) let form = fixture.makeFormViewModel(editing: original) - holdForUnsavedEdits() + store.holdRebuilds(true) form.password = "rotated" form.host = "replica.example.com" @@ -294,7 +281,7 @@ struct ConnectionCoordinatorStoreTests { #expect(store.generation(for: original.id) == 0) #expect(coordinator.connection.host == "replica.example.com") - releaseUnsavedEdits() + store.holdRebuilds(false) #expect(store.generation(for: original.id) == 1) #expect(store.coordinator(for: original, appState: appState) !== coordinator) @@ -308,14 +295,14 @@ struct ConnectionCoordinatorStoreTests { rehosted.host = "replica.example.com" rehosted.safeModeLevel = .readOnly - holdForUnsavedEdits() + 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) - releaseUnsavedEdits() + store.holdRebuilds(false) #expect(store.generation(for: original.id) == 1) #expect(store.coordinator(for: rehosted, appState: appState) !== coordinator) @@ -326,11 +313,11 @@ struct ConnectionCoordinatorStoreTests { let original = connection("A") _ = store.coordinator(for: original, appState: appState) - holdForUnsavedEdits() + store.holdRebuilds(true) store.invalidate(original.id) #expect(store.generation(for: original.id) == 0) - releaseUnsavedEdits() + store.holdRebuilds(false) #expect(store.generation(for: original.id) == 1) } @@ -343,11 +330,11 @@ struct ConnectionCoordinatorStoreTests { var reported = rehosted reported.port = 5_433 - holdForUnsavedEdits() + store.holdRebuilds(true) store.reconcile(from: [original], to: [rehosted]) store.reconcile(from: [rehosted], to: [reported]) - holdForUnsavedEdits() - releaseUnsavedEdits() + store.holdRebuilds(true) + store.holdRebuilds(false) #expect(store.generation(for: original.id) == 1) } @@ -359,127 +346,13 @@ struct ConnectionCoordinatorStoreTests { var rehosted = original rehosted.host = "replica.example.com" - holdForUnsavedEdits() + store.holdRebuilds(true) store.reconcile(from: [original], to: [rehosted]) store.reconcile(from: [rehosted], to: []) - releaseUnsavedEdits() + 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") } } - -@MainActor -private final class DroppedSessions { - private(set) var drops: [String] = [] - - func record(_ window: String, _ id: UUID) { - drops.append("\(window) \(id.uuidString)") - } -} - -@MainActor -@Suite("Editor holds across windows") -struct EditorHoldAcrossWindowsTests { - private let fixture: AppStateFixture - private let appState: AppState - private let holds = EditorHoldRegistry() - private let dropped = DroppedSessions() - - init() throws { - fixture = try AppStateFixture() - appState = fixture.makeState(syncEnabled: false) - } - - private func window(_ name: String) -> ConnectionCoordinatorStore { - ConnectionCoordinatorStore(editorHolds: holds) { [dropped] id in - dropped.record(name, id) - } - } - - private func connection() -> DatabaseConnection { - DatabaseConnection(name: "A", type: .postgresql, host: "a.example.com", port: 5_432) - } - - @Test("A redial seen by a window with no editor waits for an editor in another window") - func redialWaitsForAnEditorInAnyWindow() { - let original = connection() - let editing = window("editing") - let listing = window("listing") - let coordinator = editing.coordinator(for: original, appState: appState) - let editor = UUID() - let editingScene = UUID() - var rehosted = original - rehosted.host = "replica.example.com" - holds.setHold(editor, in: editingScene, isHolding: true) - - editing.reconcile(from: [original], to: [rehosted]) - listing.reconcile(from: [original], to: [rehosted]) - - #expect(dropped.drops.isEmpty) - #expect(editing.coordinator(for: rehosted, appState: appState) === coordinator) - #expect(coordinator.connection.host == "replica.example.com") - #expect(listing.generation(for: original.id) == 0) - - holds.setHold(editor, in: editingScene, isHolding: false) - - #expect(dropped.drops.sorted() == ["editing \(original.id.uuidString)", "listing \(original.id.uuidString)"]) - #expect(editing.generation(for: original.id) == 1) - #expect(listing.generation(for: original.id) == 1) - #expect(editing.coordinator(for: rehosted, appState: appState) !== coordinator) - } - - @Test("A session held by editors in two windows is dropped only after the last one lets go") - func lastEditorReleasesTheDrop() { - let original = connection() - let listing = window("listing") - let first = UUID() - let second = UUID() - var rehosted = original - rehosted.port = 5_433 - holds.setHold(first, in: UUID(), isHolding: true) - holds.setHold(second, in: UUID(), isHolding: true) - - listing.reconcile(from: [original], to: [rehosted]) - holds.setHold(first, in: UUID(), isHolding: false) - - #expect(dropped.drops.isEmpty) - #expect(listing.generation(for: original.id) == 0) - - holds.setHold(second, in: UUID(), isHolding: false) - - #expect(dropped.drops == ["listing \(original.id.uuidString)"]) - #expect(listing.generation(for: original.id) == 1) - } - - @Test("A redial with no editor anywhere drops the session at once") - func redialWithoutEditorDropsAtOnce() { - let original = connection() - let listing = window("listing") - var rehosted = original - rehosted.username = "readonly" - - listing.reconcile(from: [original], to: [rehosted]) - - #expect(dropped.drops == ["listing \(original.id.uuidString)"]) - } - - @Test("Each window's presenter reports only its own editors, while every window's rebuilds wait") - func presentersShareOneRegistry() { - let first = ScenePresenter(editorHolds: holds) - let second = ScenePresenter(editorHolds: holds) - let editor = UUID() - - first.setEditorHold(editor, isHolding: true) - - #expect(first.isHeldByEditor) - #expect(second.isHeldByEditor == false) - #expect(holds.isHolding) - - first.setEditorHold(editor, isHolding: false) - - #expect(first.isHeldByEditor == false) - #expect(holds.isHolding == false) - } -} From 84ed32e3500f62ef28bb1585027de5eb0a2c49b6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 18 Sep 2026 21:46:40 +0700 Subject: [PATCH 14/14] fix(ios): keep pasted SSH keys through the orphan sweep and an import Replace --- CHANGELOG.md | 2 -- .../Platform/ConnectionSecretKind.swift | 14 ++++++++++--- .../Platform/KeychainSecureStore.swift | 14 ++++++------- .../Services/IOSConnectionImportService.swift | 16 --------------- .../ConnectionSecretsTests.swift | 20 ++++++++++++++----- .../IOSConnectionImportServiceTests.swift | 17 ++++------------ docs/ios/index.mdx | 2 +- 7 files changed, 38 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1be191f7b4..e27885c5a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,7 +117,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. -- A connection replaced by an import on iPhone and iPad still signing in with its old pasted SSH key. - Picking an SSH key file on iPhone and iPad replacing another connection's key file of the same name. ### Security @@ -129,7 +128,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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. -- SSH private keys of deleted connections kept in the iPhone and iPad Keychain with Sync Passwords off. ## [0.75.0] - 2026-09-18 diff --git a/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift b/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift index a16383baf4..f40ad25abc 100644 --- a/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift +++ b/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift @@ -15,19 +15,27 @@ nonisolated enum ConnectionSecretKind: CaseIterable, Sendable { } } + var isSweptWhenOrphaned: Bool { + switch self { + case .password, .sshPassword, .keyPassphrase: true + case .sshPrivateKey: false + } + } + func account(for connectionId: UUID) -> String { prefix + connectionId.uuidString } - static func connectionId(inAccount account: String) -> UUID? { - guard let kind = allCases.first(where: { account.hasPrefix($0.prefix) }) else { return nil } + 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 = connectionId(inAccount: account) else { return false } + guard let connectionId = sweptConnectionId(inAccount: account) else { return false } return !validConnectionIds.contains(connectionId) } } diff --git a/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift b/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift index 17d1d47d33..23f3620aaa 100644 --- a/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift +++ b/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift @@ -118,15 +118,15 @@ nonisolated final class KeychainSecureStore: SecureStore { } } - /// Remove secrets left under ids no saved connection uses, such as a test connection an older - /// build stored and was killed before deleting, or a connection deleted on another device. + /// 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, and a synchronizable pasted - /// private key may be the only copy of a key for a connection sync has not delivered yet. + /// 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 } diff --git a/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift b/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift index 46422d32ae..4c54664866 100644 --- a/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift +++ b/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift @@ -97,9 +97,6 @@ enum IOSConnectionImportService { logger.error("Import could not replace a connection that is no longer in the library") continue } - if !expectsPastedPrivateKey(connection) { - deleteSecret(.sshPrivateKey, of: existingId, from: appState.secureStore) - } connectionIdMap[index] = existingId importedCount += 1 } @@ -145,19 +142,6 @@ enum IOSConnectionImportService { } } - private static func deleteSecret(_ kind: ConnectionSecretKind, of connectionId: UUID, from secureStore: any SecureStore) { - do { - try secureStore.delete(forKey: kind.account(for: connectionId)) - } catch { - logger.error("Clearing a replaced connection's secret failed: \(error.localizedDescription, privacy: .public)") - } - } - - private static func expectsPastedPrivateKey(_ connection: DatabaseConnection) -> Bool { - guard connection.sshEnabled, let ssh = connection.sshConfiguration else { return false } - return ssh.authMethod == .privateKey && (ssh.privateKeyPath ?? "").isEmpty - } - // MARK: - Building private static func buildConnection( diff --git a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift index 7ccd9f94d1..f3b39e4f1b 100644 --- a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift @@ -73,8 +73,8 @@ struct ConnectionSecretsTests { #expect(ConnectionSecretKind.sshPrivateKey.account(for: id) == "com.TablePro.sshkeydata.\(id.uuidString)") } - @Test("The orphan sweep takes every secret of a connection that is gone, pasted keys included, and nothing else") - func sweepTakesOrphanedSecretsOnly() { + @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)] } @@ -82,13 +82,23 @@ struct ConnectionSecretsTests { let orphaned = ConnectionSecretKind.orphanedAccounts(owned + foreign, keeping: [kept]) - #expect(Set(orphaned) == Set(ConnectionSecretKind.allCases.map { $0.account(for: removed) })) - #expect(orphaned.contains("com.TablePro.sshkeydata.\(removed.uuidString)")) + #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.sshPrivateKey.account(for: UUID()) + let account = ConnectionSecretKind.password.account(for: UUID()) #expect(ConnectionSecretKind.orphanedAccounts([account], keeping: []).isEmpty) } diff --git a/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift b/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift index ac4c8cea01..3960834503 100644 --- a/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift +++ b/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift @@ -136,10 +136,11 @@ struct IOSConnectionImportReplaceTests { ).importedCount } - @Test("Replacing with a key file, a password or no tunnel clears the pasted key the old connection used") - func replaceClearsUnusedPastedKey() throws { + @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 @@ -150,17 +151,7 @@ struct IOSConnectionImportReplaceTests { #expect(replaceExisting(with: ssh) == 1) - #expect(try store.retrieve(forKey: keyAccount) == nil, "\(ssh?.authMethod ?? "no tunnel")") + #expect(try store.retrieve(forKey: keyAccount) == "PASTED KEY", "\(ssh?.authMethod ?? "no tunnel")") } } - - @Test("Replacing with a private key and no key file keeps the pasted key for it") - func replaceKeepsPastedKeyItStillNeeds() throws { - #expect(appState.addConnection(existing)) - store.seed(keyAccount, "PASTED KEY") - - #expect(replaceExisting(with: tunnel(authMethod: "privateKey")) == 1) - - #expect(try store.retrieve(forKey: keyAccount) == "PASTED KEY") - } } diff --git a/docs/ios/index.mdx b/docs/ios/index.mdx index 8cc156d063..c22577bfc6 100644 --- a/docs/ios/index.mdx +++ b/docs/ios/index.mdx @@ -86,7 +86,7 @@ Three things a connection depends on are per device and never sync: 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. -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 connection form has unsaved changes, in this window or another. 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. +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.