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
204 changes: 114 additions & 90 deletions TableProMobile/TableProMobile/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)))
Expand All @@ -57,100 +62,119 @@ 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)
}
}

// 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)
syncCoordinator.scheduleSyncAfterChange()
}

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") {
didSet { UserDefaults.standard.set(hasCompletedOnboarding, forKey: "com.TablePro.hasCompletedOnboarding") }
}

func reorderConnections(_ reordered: [DatabaseConnection]) {
connections = reordered
storage.save(connections)
persist(connections: reordered)
updateWidgetData()
for connection in reordered {
syncCoordinator.markDirty(connection.id)
Expand All @@ -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)
Expand All @@ -183,39 +208,41 @@ 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)
}
syncCoordinator.scheduleSyncAfterChange()
}

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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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] {
Expand Down
13 changes: 3 additions & 10 deletions TableProMobile/TableProMobile/Helpers/GroupPersistence.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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] {
Expand Down
Loading
Loading