From eb53682e8ae6ab5453288ca4bf68d1dffd5e226d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 14 May 2026 08:12:04 +0700 Subject: [PATCH] refactor(ios): encode persistence state in Loadable enum, throw on save --- TableProMobile/TableProMobile/AppState.swift | 204 ++++++++++-------- .../Helpers/GroupPersistence.swift | 13 +- .../TableProMobile/Helpers/Loadable.swift | 40 ++++ .../Helpers/TagPersistence.swift | 13 +- .../Sync/IOSSyncCoordinator.swift | 14 +- .../TableProMobile/TableProMobileApp.swift | 8 +- 6 files changed, 168 insertions(+), 124 deletions(-) create mode 100644 TableProMobile/TableProMobile/Helpers/Loadable.swift diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index bb3c65b3e..bef5f224b 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -6,21 +6,30 @@ import TableProDatabase import TableProModels import WidgetKit -enum PersistenceIntegrity: Equatable { - case ok - case loadFailed -} - @MainActor @Observable final class AppState { private static let logger = Logger(subsystem: "com.TablePro", category: "AppState") - var connections: [DatabaseConnection] = [] - var groups: [ConnectionGroup] = [] - var tags: [ConnectionTag] = [] + private var connectionsState: Loadable<[DatabaseConnection]> = .loading + private var groupsState: Loadable<[ConnectionGroup]> = .loading + private var tagsState: Loadable<[ConnectionTag]> = .loading + + var connections: [DatabaseConnection] { connectionsState.value ?? [] } + var groups: [ConnectionGroup] { groupsState.value ?? [] } + var tags: [ConnectionTag] { tagsState.value ?? ConnectionTag.presets } + + var loadStatus: LoadStatus { + if connectionsState.isFailed || groupsState.isFailed || tagsState.isFailed { + return .failed + } + if connectionsState.isLoaded && groupsState.isLoaded && tagsState.isLoaded { + return .ready + } + return .loading + } + var pendingConnectionId: UUID? var pendingTableName: String? - var persistenceIntegrity: PersistenceIntegrity = .ok let connectionManager: ConnectionManager let syncCoordinator = IOSSyncCoordinator() let sshProvider: IOSSSHProvider @@ -43,10 +52,6 @@ final class AppState { ) loadPersistedData() - // Skip side-effecting callbacks (Spotlight, WidgetKit, sync wiring) when - // running unit tests inside the host app. These rely on entitlements - // that the CI simulator does not have and have caused the test runner - // to crash before it could connect to xctest. guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return } secureStore.cleanOrphanedCredentials(validConnectionIds: Set(connections.map(\.id))) @@ -57,29 +62,26 @@ final class AppState { syncCoordinator.onConnectionsChanged = { [weak self] merged in guard let self else { return } - self.connections = merged - self.storage.save(merged) - self.persistenceIntegrity = .ok + guard merged != self.connections else { return } + self.persist(connections: merged) self.updateWidgetData() self.updateSpotlightIndex() } syncCoordinator.onGroupsChanged = { [weak self] merged in guard let self else { return } - self.groups = merged - self.groupStorage.save(merged) - self.persistenceIntegrity = .ok + guard merged != self.groups else { return } + self.persist(groups: merged) } syncCoordinator.onTagsChanged = { [weak self] merged in guard let self else { return } - self.tags = merged - self.tagStorage.save(merged) - self.persistenceIntegrity = .ok + guard merged != self.tags else { return } + self.persist(tags: merged) } syncCoordinator.getCurrentState = { [weak self] in - guard let self else { return ([], [], []) } + guard let self, self.loadStatus == .ready else { return nil } return (self.connections, self.groups, self.tags) } } @@ -87,46 +89,69 @@ final class AppState { // MARK: - Load / Retry func retryLoadIfFailed() { - guard persistenceIntegrity == .loadFailed else { return } + guard loadStatus == .failed else { return } Self.logger.info("Retrying persistence load after previous failure") loadPersistedData() } private func loadPersistedData() { - var failed = false - do { - connections = try storage.load() + connectionsState = .loaded(try storage.load()) } catch { - connections = [] - failed = true + connectionsState = .failed(error) Self.logger.error("Connections load failed: \(error.localizedDescription, privacy: .public)") } do { - groups = try groupStorage.load() + groupsState = .loaded(try groupStorage.load()) } catch { - groups = [] - failed = true + groupsState = .failed(error) Self.logger.error("Groups load failed: \(error.localizedDescription, privacy: .public)") } do { - tags = try tagStorage.load() + tagsState = .loaded(try tagStorage.load()) } catch { - tags = ConnectionTag.presets - failed = true + tagsState = .failed(error) Self.logger.error("Tags load failed: \(error.localizedDescription, privacy: .public)") } + } + + // MARK: - Persistence Bridges - persistenceIntegrity = failed ? .loadFailed : .ok + private func persist(connections: [DatabaseConnection]) { + connectionsState = .loaded(connections) + do { + try storage.save(connections) + } catch { + Self.logger.error("Failed to save connections: \(error.localizedDescription, privacy: .public)") + } + } + + private func persist(groups: [ConnectionGroup]) { + groupsState = .loaded(groups) + do { + try groupStorage.save(groups) + } catch { + Self.logger.error("Failed to save groups: \(error.localizedDescription, privacy: .public)") + } + } + + private func persist(tags: [ConnectionTag]) { + tagsState = .loaded(tags) + do { + try tagStorage.save(tags) + } catch { + Self.logger.error("Failed to save tags: \(error.localizedDescription, privacy: .public)") + } } // MARK: - Connections func addConnection(_ connection: DatabaseConnection) { - connections.append(connection) - storage.save(connections) + var updated = connections + updated.append(connection) + persist(connections: updated) updateWidgetData() updateSpotlightIndex() syncCoordinator.markDirty(connection.id) @@ -134,14 +159,14 @@ final class AppState { } func updateConnection(_ connection: DatabaseConnection) { - if let index = connections.firstIndex(where: { $0.id == connection.id }) { - connections[index] = connection - storage.save(connections) - updateWidgetData() - updateSpotlightIndex() - syncCoordinator.markDirty(connection.id) - syncCoordinator.scheduleSyncAfterChange() - } + var updated = connections + guard let index = updated.firstIndex(where: { $0.id == connection.id }) else { return } + updated[index] = connection + persist(connections: updated) + updateWidgetData() + updateSpotlightIndex() + syncCoordinator.markDirty(connection.id) + syncCoordinator.scheduleSyncAfterChange() } var hasCompletedOnboarding: Bool = UserDefaults.standard.bool(forKey: "com.TablePro.hasCompletedOnboarding") { @@ -149,8 +174,7 @@ final class AppState { } func reorderConnections(_ reordered: [DatabaseConnection]) { - connections = reordered - storage.save(connections) + persist(connections: reordered) updateWidgetData() for connection in reordered { syncCoordinator.markDirty(connection.id) @@ -159,13 +183,14 @@ final class AppState { } func removeConnection(_ connection: DatabaseConnection) { - connections.removeAll { $0.id == connection.id } + var updated = connections + updated.removeAll { $0.id == connection.id } try? connectionManager.deletePassword(for: connection.id) try? secureStore.delete(forKey: "com.TablePro.sshpassword.\(connection.id.uuidString)") try? secureStore.delete(forKey: "com.TablePro.keypassphrase.\(connection.id.uuidString)") try? secureStore.delete(forKey: "com.TablePro.sshkeydata.\(connection.id.uuidString)") clearPerConnectionPreferences(for: connection.id) - storage.save(connections) + persist(connections: updated) updateWidgetData() updateSpotlightIndex() syncCoordinator.markDeleted(connection.id) @@ -183,24 +208,24 @@ final class AppState { // MARK: - Groups func addGroup(_ group: ConnectionGroup) { - groups.append(group) - groupStorage.save(groups) + var updated = groups + updated.append(group) + persist(groups: updated) syncCoordinator.markDirtyGroup(group.id) syncCoordinator.scheduleSyncAfterChange() } func updateGroup(_ group: ConnectionGroup) { - if let index = groups.firstIndex(where: { $0.id == group.id }) { - groups[index] = group - groupStorage.save(groups) - syncCoordinator.markDirtyGroup(group.id) - syncCoordinator.scheduleSyncAfterChange() - } + var updated = groups + guard let index = updated.firstIndex(where: { $0.id == group.id }) else { return } + updated[index] = group + persist(groups: updated) + syncCoordinator.markDirtyGroup(group.id) + syncCoordinator.scheduleSyncAfterChange() } func reorderGroups(_ reordered: [ConnectionGroup]) { - groups = reordered - groupStorage.save(groups) + persist(groups: reordered) for group in reordered { syncCoordinator.markDirtyGroup(group.id) } @@ -208,14 +233,16 @@ final class AppState { } func deleteGroup(_ groupId: UUID) { - groups.removeAll { $0.id == groupId } - groupStorage.save(groups) - - for index in connections.indices where connections[index].groupId == groupId { - connections[index].groupId = nil - syncCoordinator.markDirty(connections[index].id) + var updatedGroups = groups + updatedGroups.removeAll { $0.id == groupId } + persist(groups: updatedGroups) + + var updatedConnections = connections + for index in updatedConnections.indices where updatedConnections[index].groupId == groupId { + updatedConnections[index].groupId = nil + syncCoordinator.markDirty(updatedConnections[index].id) } - storage.save(connections) + persist(connections: updatedConnections) updateWidgetData() syncCoordinator.markDeletedGroup(groupId) @@ -225,32 +252,35 @@ final class AppState { // MARK: - Tags func addTag(_ tag: ConnectionTag) { - tags.append(tag) - tagStorage.save(tags) + var updated = tags + updated.append(tag) + persist(tags: updated) syncCoordinator.markDirtyTag(tag.id) syncCoordinator.scheduleSyncAfterChange() } func updateTag(_ tag: ConnectionTag) { - if let index = tags.firstIndex(where: { $0.id == tag.id }) { - tags[index] = tag - tagStorage.save(tags) - syncCoordinator.markDirtyTag(tag.id) - syncCoordinator.scheduleSyncAfterChange() - } + 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) + syncCoordinator.scheduleSyncAfterChange() } func deleteTag(_ tagId: UUID) { guard let tag = tags.first(where: { $0.id == tagId }), !tag.isPreset else { return } - tags.removeAll { $0.id == tagId } - tagStorage.save(tags) + var updatedTags = tags + updatedTags.removeAll { $0.id == tagId } + persist(tags: updatedTags) - for index in connections.indices where connections[index].tagId == tagId { - connections[index].tagId = nil - syncCoordinator.markDirty(connections[index].id) + var updatedConnections = connections + for index in updatedConnections.indices where updatedConnections[index].tagId == tagId { + updatedConnections[index].tagId = nil + syncCoordinator.markDirty(updatedConnections[index].id) } - storage.save(connections) + persist(connections: updatedConnections) updateWidgetData() syncCoordinator.markDeletedTag(tagId) @@ -312,8 +342,6 @@ final class AppState { // MARK: - Persistence private struct ConnectionPersistence { - private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionPersistence") - private var fileURL: URL? { guard let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil @@ -323,14 +351,10 @@ private struct ConnectionPersistence { return appDir.appendingPathComponent("connections.json") } - func save(_ connections: [DatabaseConnection]) { + func save(_ connections: [DatabaseConnection]) throws { guard let fileURL else { return } - do { - let data = try JSONEncoder().encode(connections) - try data.write(to: fileURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) - } catch { - Self.logger.error("Failed to save connections: \(error.localizedDescription, privacy: .public)") - } + let data = try JSONEncoder().encode(connections) + try data.write(to: fileURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) } func load() throws -> [DatabaseConnection] { diff --git a/TableProMobile/TableProMobile/Helpers/GroupPersistence.swift b/TableProMobile/TableProMobile/Helpers/GroupPersistence.swift index 7847c0158..249300e16 100644 --- a/TableProMobile/TableProMobile/Helpers/GroupPersistence.swift +++ b/TableProMobile/TableProMobile/Helpers/GroupPersistence.swift @@ -1,10 +1,7 @@ import Foundation -import os import TableProModels struct GroupPersistence { - private static let logger = Logger(subsystem: "com.TablePro", category: "GroupPersistence") - private var fileURL: URL? { guard let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil @@ -14,14 +11,10 @@ struct GroupPersistence { return appDir.appendingPathComponent("groups.json") } - func save(_ groups: [ConnectionGroup]) { + func save(_ groups: [ConnectionGroup]) throws { guard let fileURL else { return } - do { - let data = try JSONEncoder().encode(groups) - try data.write(to: fileURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) - } catch { - Self.logger.error("Failed to save groups: \(error.localizedDescription, privacy: .public)") - } + let data = try JSONEncoder().encode(groups) + try data.write(to: fileURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) } func load() throws -> [ConnectionGroup] { diff --git a/TableProMobile/TableProMobile/Helpers/Loadable.swift b/TableProMobile/TableProMobile/Helpers/Loadable.swift new file mode 100644 index 000000000..91ad7d3e5 --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/Loadable.swift @@ -0,0 +1,40 @@ +import Foundation + +enum Loadable { + case loading + case loaded(T) + case failed(Error) +} + +extension Loadable { + var value: T? { + if case .loaded(let value) = self { return value } + return nil + } + + var isLoaded: Bool { + if case .loaded = self { return true } + return false + } + + var isFailed: Bool { + if case .failed = self { return true } + return false + } +} + +enum LoadStatus: Equatable, Sendable { + case loading + case ready + case failed +} + +extension Loadable { + var status: LoadStatus { + switch self { + case .loading: return .loading + case .loaded: return .ready + case .failed: return .failed + } + } +} diff --git a/TableProMobile/TableProMobile/Helpers/TagPersistence.swift b/TableProMobile/TableProMobile/Helpers/TagPersistence.swift index 03476436c..a2ce03aa5 100644 --- a/TableProMobile/TableProMobile/Helpers/TagPersistence.swift +++ b/TableProMobile/TableProMobile/Helpers/TagPersistence.swift @@ -1,10 +1,7 @@ import Foundation -import os import TableProModels struct TagPersistence { - private static let logger = Logger(subsystem: "com.TablePro", category: "TagPersistence") - private var fileURL: URL? { guard let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil @@ -14,14 +11,10 @@ struct TagPersistence { return appDir.appendingPathComponent("tags.json") } - func save(_ tags: [ConnectionTag]) { + func save(_ tags: [ConnectionTag]) throws { guard let fileURL else { return } - do { - let data = try JSONEncoder().encode(tags) - try data.write(to: fileURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) - } catch { - Self.logger.error("Failed to save tags: \(error.localizedDescription, privacy: .public)") - } + let data = try JSONEncoder().encode(tags) + try data.write(to: fileURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) } func load() throws -> [ConnectionTag] { diff --git a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift index 35595f731..6a831c1fe 100644 --- a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift +++ b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift @@ -30,7 +30,7 @@ final class IOSSyncCoordinator { var onConnectionsChanged: (([DatabaseConnection]) -> Void)? var onGroupsChanged: (([ConnectionGroup]) -> Void)? var onTagsChanged: (([ConnectionTag]) -> Void)? - var getCurrentState: (() -> (connections: [DatabaseConnection], groups: [ConnectionGroup], tags: [ConnectionTag]))? + var getCurrentState: (() -> (connections: [DatabaseConnection], groups: [ConnectionGroup], tags: [ConnectionTag])?)? // MARK: - Sync @@ -71,15 +71,9 @@ final class IOSSyncCoordinator { localTags: mergedTags ) - if !remoteChanges.changedConnections.isEmpty || !remoteChanges.deletedConnectionIDs.isEmpty { - onConnectionsChanged?(mergedConnections) - } - if !remoteChanges.changedGroups.isEmpty || !remoteChanges.deletedGroupIDs.isEmpty { - onGroupsChanged?(mergedGroups) - } - if !remoteChanges.changedTags.isEmpty || !remoteChanges.deletedTagIDs.isEmpty { - onTagsChanged?(mergedTags) - } + onConnectionsChanged?(mergedConnections) + onGroupsChanged?(mergedGroups) + onTagsChanged?(mergedTags) metadata.lastSyncDate = Date() lastSyncDate = metadata.lastSyncDate diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index 6ad4e86a7..484a40f78 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -73,7 +73,7 @@ struct TableProMobileApp: App { case .active: MemoryPressureMonitor.shared.start() appState.retryLoadIfFailed() - if AppPreferences.isCloudSyncEnabled && appState.persistenceIntegrity == .ok { + if AppPreferences.isCloudSyncEnabled && appState.loadStatus == .ready { syncTask?.cancel() syncTask = Task { await appState.syncCoordinator.sync( @@ -123,9 +123,9 @@ struct TableProMobileApp: App { scheduleBackgroundSync() guard AppPreferences.isCloudSyncEnabled else { return } await MainActor.run { appState.retryLoadIfFailed() } - let integrity = await MainActor.run { appState.persistenceIntegrity } - guard integrity == .ok else { - Self.backgroundLogger.warning("Background sync skipped: persistence load failed (likely device locked)") + let status = await MainActor.run { appState.loadStatus } + guard status == .ready else { + Self.backgroundLogger.warning("Background sync skipped: persistence load not ready (likely device locked)") return } Self.backgroundLogger.info("Background sync starting")