Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
74 changes: 65 additions & 9 deletions TableProMobile/TableProMobile/AppState.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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()
}
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
}
21 changes: 15 additions & 6 deletions TableProMobile/TableProMobile/Helpers/GroupPersistence.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
}
}
23 changes: 16 additions & 7 deletions TableProMobile/TableProMobile/Helpers/TagPersistence.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}
}
32 changes: 29 additions & 3 deletions TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
9 changes: 8 additions & 1 deletion TableProMobile/TableProMobile/TableProMobileApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading