diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a26cd5fc..b676e0c1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iOS: SQL Server (MSSQL) connections via FreeTDS over TDS 7.4. Uses the shared `SSLConfiguration` model from connection settings. Supports connect, query, streaming results, schema browsing (tables, columns, indexes, foreign keys), database and schema switching, and explicit transactions. - iOS: data browser, search, filter, and pagination now render correct SQL Server syntax (bracket-quoted identifiers, `OFFSET ... ROWS FETCH NEXT ... ROWS ONLY` pagination, `SELECT TOP 1` for cell value fetch). +- iOS: Settings > Sync now shows last sync time, a Sync Now button, and a Refresh from iCloud action that re-downloads every connection, group, and tag when items are missing on this device but visible on another. + +### Fixed + +- iOS: connections, groups, and tags no longer silently disappear after a TestFlight or App Store update. Persistence files are now stored with `.completeFileProtectionUntilFirstUserAuthentication` so they stay readable across background sync runs, load failures are no longer swallowed, and the sync engine refuses to overwrite local data when the load was not actually empty. ### Removed diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index a86ce97d8..bb3c65b3e 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -1,17 +1,26 @@ import CoreSpotlight import Foundation import Observation +import os 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] = [] var pendingConnectionId: UUID? var pendingTableName: String? + var persistenceIntegrity: PersistenceIntegrity = .ok let connectionManager: ConnectionManager let syncCoordinator = IOSSyncCoordinator() let sshProvider: IOSSSHProvider @@ -32,9 +41,7 @@ final class AppState { secureStore: secureStore, sshProvider: sshProvider ) - connections = storage.load() - groups = groupStorage.load() - tags = tagStorage.load() + loadPersistedData() // Skip side-effecting callbacks (Spotlight, WidgetKit, sync wiring) when // running unit tests inside the host app. These rely on entitlements @@ -52,6 +59,7 @@ final class AppState { guard let self else { return } self.connections = merged self.storage.save(merged) + self.persistenceIntegrity = .ok self.updateWidgetData() self.updateSpotlightIndex() } @@ -60,12 +68,14 @@ final class AppState { guard let self else { return } self.groups = merged self.groupStorage.save(merged) + self.persistenceIntegrity = .ok } syncCoordinator.onTagsChanged = { [weak self] merged in guard let self else { return } self.tags = merged self.tagStorage.save(merged) + self.persistenceIntegrity = .ok } syncCoordinator.getCurrentState = { [weak self] in @@ -74,6 +84,44 @@ final class AppState { } } + // MARK: - Load / Retry + + func retryLoadIfFailed() { + guard persistenceIntegrity == .loadFailed else { return } + Self.logger.info("Retrying persistence load after previous failure") + loadPersistedData() + } + + private func loadPersistedData() { + var failed = false + + do { + connections = try storage.load() + } catch { + connections = [] + failed = true + Self.logger.error("Connections load failed: \(error.localizedDescription, privacy: .public)") + } + + do { + groups = try groupStorage.load() + } catch { + groups = [] + failed = true + Self.logger.error("Groups load failed: \(error.localizedDescription, privacy: .public)") + } + + do { + tags = try tagStorage.load() + } catch { + tags = ConnectionTag.presets + failed = true + Self.logger.error("Tags load failed: \(error.localizedDescription, privacy: .public)") + } + + persistenceIntegrity = failed ? .loadFailed : .ok + } + // MARK: - Connections func addConnection(_ connection: DatabaseConnection) { @@ -264,6 +312,8 @@ 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 @@ -274,15 +324,21 @@ private struct ConnectionPersistence { } func save(_ connections: [DatabaseConnection]) { - guard let fileURL, let data = try? JSONEncoder().encode(connections) else { return } - try? data.write(to: fileURL, options: [.atomic, .completeFileProtection]) + 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)") + } } - func load() -> [DatabaseConnection] { - guard let fileURL, let data = try? Data(contentsOf: fileURL), - let connections = try? JSONDecoder().decode([DatabaseConnection].self, from: data) else { + func load() throws -> [DatabaseConnection] { + guard let fileURL else { return [] } + if !FileManager.default.fileExists(atPath: fileURL.path) { return [] } - return connections + let data = try Data(contentsOf: fileURL) + return try JSONDecoder().decode([DatabaseConnection].self, from: data) } } diff --git a/TableProMobile/TableProMobile/Helpers/GroupPersistence.swift b/TableProMobile/TableProMobile/Helpers/GroupPersistence.swift index 883e264cf..7847c0158 100644 --- a/TableProMobile/TableProMobile/Helpers/GroupPersistence.swift +++ b/TableProMobile/TableProMobile/Helpers/GroupPersistence.swift @@ -1,7 +1,10 @@ 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 @@ -12,15 +15,21 @@ struct GroupPersistence { } func save(_ groups: [ConnectionGroup]) { - guard let fileURL, let data = try? JSONEncoder().encode(groups) else { return } - try? data.write(to: fileURL, options: [.atomic, .completeFileProtection]) + 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)") + } } - func load() -> [ConnectionGroup] { - guard let fileURL, let data = try? Data(contentsOf: fileURL), - let groups = try? JSONDecoder().decode([ConnectionGroup].self, from: data) else { + func load() throws -> [ConnectionGroup] { + guard let fileURL else { return [] } + if !FileManager.default.fileExists(atPath: fileURL.path) { return [] } - return groups + let data = try Data(contentsOf: fileURL) + return try JSONDecoder().decode([ConnectionGroup].self, from: data) } } diff --git a/TableProMobile/TableProMobile/Helpers/TagPersistence.swift b/TableProMobile/TableProMobile/Helpers/TagPersistence.swift index 0214a76f7..03476436c 100644 --- a/TableProMobile/TableProMobile/Helpers/TagPersistence.swift +++ b/TableProMobile/TableProMobile/Helpers/TagPersistence.swift @@ -1,7 +1,10 @@ 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 @@ -12,16 +15,22 @@ struct TagPersistence { } func save(_ tags: [ConnectionTag]) { - guard let fileURL, let data = try? JSONEncoder().encode(tags) else { return } - try? data.write(to: fileURL, options: [.atomic, .completeFileProtection]) + 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)") + } } - func load() -> [ConnectionTag] { - guard let fileURL, let data = try? Data(contentsOf: fileURL), - let tags = try? JSONDecoder().decode([ConnectionTag].self, from: data), - !tags.isEmpty else { + func load() throws -> [ConnectionTag] { + guard let fileURL else { return ConnectionTag.presets } + if !FileManager.default.fileExists(atPath: fileURL.path) { return ConnectionTag.presets } - return tags + let data = try Data(contentsOf: fileURL) + let tags = try JSONDecoder().decode([ConnectionTag].self, from: data) + return tags.isEmpty ? ConnectionTag.presets : tags } } diff --git a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift index ab0920db6..35595f731 100644 --- a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift +++ b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift @@ -71,9 +71,15 @@ final class IOSSyncCoordinator { localTags: mergedTags ) - onConnectionsChanged?(mergedConnections) - onGroupsChanged?(mergedGroups) - onTagsChanged?(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) + } metadata.lastSyncDate = Date() lastSyncDate = metadata.lastSyncDate @@ -95,6 +101,26 @@ final class IOSSyncCoordinator { } } + // MARK: - Token Reset + + func resetSyncToken( + localConnections: [DatabaseConnection], + localGroups: [ConnectionGroup], + localTags: [ConnectionTag] + ) async { + debounceTask?.cancel() + metadata.saveToken(nil) + cachedRecords.removeAll() + cachedGroupRecords.removeAll() + cachedTagRecords.removeAll() + Self.logger.info("Sync token cleared; forcing full pull from iCloud") + await sync( + localConnections: localConnections, + localGroups: localGroups, + localTags: localTags + ) + } + // MARK: - Dirty / Tombstone Tracking func markDirty(_ connectionId: UUID) { diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index 36a66ae69..6ad4e86a7 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -72,7 +72,8 @@ struct TableProMobileApp: App { switch phase { case .active: MemoryPressureMonitor.shared.start() - if AppPreferences.isCloudSyncEnabled { + appState.retryLoadIfFailed() + if AppPreferences.isCloudSyncEnabled && appState.persistenceIntegrity == .ok { syncTask?.cancel() syncTask = Task { await appState.syncCoordinator.sync( @@ -121,6 +122,12 @@ struct TableProMobileApp: App { private func runBackgroundSync() async { 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)") + return + } Self.backgroundLogger.info("Background sync starting") await appState.syncCoordinator.sync( localConnections: appState.connections, diff --git a/TableProMobile/TableProMobile/Views/SettingsView.swift b/TableProMobile/TableProMobile/Views/SettingsView.swift index 1efd620d0..d214f7f8a 100644 --- a/TableProMobile/TableProMobile/Views/SettingsView.swift +++ b/TableProMobile/TableProMobile/Views/SettingsView.swift @@ -1,7 +1,10 @@ import SwiftUI import TableProModels +import TableProSync struct SettingsView: View { + @Environment(AppState.self) private var appState + @AppStorage("com.TablePro.settings.shareAnalytics") private var shareAnalytics = true @AppStorage(AppLockState.lockEnabledKey) private var lockEnabled = false @AppStorage(AppLockState.lockTimeoutKey) private var lockTimeoutSeconds = AppLockState.AutoLockTimeout.fiveMinutes.rawValue @@ -10,12 +13,17 @@ struct SettingsView: View { @AppStorage(AppPreferences.defaultSafeModeKey) private var defaultSafeModeRaw = SafeModeLevel.off.rawValue @AppStorage(AppPreferences.hideQueryPreviewInActivityKey) private var hideQueryPreviewInActivity = false + @State private var showRefreshConfirmation = false + private let auth = BiometricAuthService() var body: some View { Form { biometricSection syncSection + if cloudSyncEnabled { + refreshFromICloudSection + } defaultsSection Section { @@ -69,6 +77,23 @@ struct SettingsView: View { private var syncSection: some View { Section { Toggle(String(localized: "iCloud Sync"), isOn: $cloudSyncEnabled) + if cloudSyncEnabled { + LabeledContent(String(localized: "Last Sync")) { + syncStatusLabel + } + Button { + Task { await runSync() } + } label: { + HStack { + Text(String(localized: "Sync Now")) + Spacer() + if isSyncing { + ProgressView().controlSize(.small) + } + } + } + .disabled(isSyncing) + } } header: { Text("Sync") } footer: { @@ -76,6 +101,80 @@ struct SettingsView: View { } } + private var refreshFromICloudSection: some View { + Section { + Button { + showRefreshConfirmation = true + } label: { + Label { + Text(String(localized: "Refresh from iCloud")) + } icon: { + Image(systemName: "arrow.clockwise.icloud") + } + } + .disabled(isSyncing) + .confirmationDialog( + String(localized: "Refresh from iCloud?"), + isPresented: $showRefreshConfirmation, + titleVisibility: .visible + ) { + Button(String(localized: "Refresh from iCloud")) { + Task { await runRefresh() } + } + Button(String(localized: "Cancel"), role: .cancel) {} + } message: { + Text("TablePro will re-download every connection, group, and tag from your iCloud account. Local data on this device is not deleted.") + } + } footer: { + Text("If items appear on another device but not here, refresh forces a full re-download from iCloud. This may take a moment on slow networks.") + } + } + + @ViewBuilder + private var syncStatusLabel: some View { + switch appState.syncCoordinator.status { + case .syncing: + HStack(spacing: 6) { + ProgressView().controlSize(.mini) + Text(String(localized: "Syncing\u{2026}")) + .foregroundStyle(.secondary) + } + case .error(let message): + Text(message) + .foregroundStyle(.red) + .multilineTextAlignment(.trailing) + .lineLimit(2) + case .idle: + if let date = appState.syncCoordinator.lastSyncDate { + Text(date, style: .relative) + .foregroundStyle(.secondary) + } else { + Text(String(localized: "Never")) + .foregroundStyle(.secondary) + } + } + } + + private var isSyncing: Bool { + appState.syncCoordinator.status == .syncing + } + + private func runSync() async { + await appState.syncCoordinator.sync( + localConnections: appState.connections, + localGroups: appState.groups, + localTags: appState.tags + ) + } + + private func runRefresh() async { + await appState.syncCoordinator.resetSyncToken( + localConnections: appState.connections, + localGroups: appState.groups, + localTags: appState.tags + ) + } + private var defaultsSection: some View { Section { Picker(String(localized: "Rows per Page"), selection: $defaultPageSize) {