mirror of
https://codeberg.org/secana/Forji.git
synced 2026-06-16 05:13:55 -07:00
fix: multi-instance persistent cache
This commit is contained in:
parent
2c2b61e249
commit
bf65e6d13d
7 changed files with 212 additions and 48 deletions
|
|
@ -20,7 +20,7 @@ struct ContentView: View {
|
|||
@AppStorage("appearance") private var appearance: AppAppearance = .system
|
||||
@AppStorage("defaultAllInstances") private var defaultAllInstances = false
|
||||
@State var authService: AuthenticationService
|
||||
@State private var multiInstanceManager: MultiInstanceManager?
|
||||
@Binding var multiInstanceManager: MultiInstanceManager?
|
||||
@Query(filter: #Predicate<ForgejoInstance> { $0.isDefault }) private var defaultInstances: [ForgejoInstance]
|
||||
@Query private var allInstances: [ForgejoInstance]
|
||||
@State private var hasAttemptedAutoLogin = false
|
||||
|
|
@ -44,6 +44,7 @@ struct ContentView: View {
|
|||
multiInstanceManager?.disconnect()
|
||||
multiInstanceManager = nil
|
||||
}
|
||||
.task { await completeMultiInstanceRestore(manager: manager) }
|
||||
} else if authService.isAuthenticated, authService.client != nil {
|
||||
HomeView(authService: authService)
|
||||
.task { await completeSessionRestore() }
|
||||
|
|
@ -69,6 +70,12 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func completeMultiInstanceRestore(manager: MultiInstanceManager) async {
|
||||
guard !sessionFullyRestored else { return }
|
||||
await manager.connect(instances: allInstances)
|
||||
sessionFullyRestored = true
|
||||
}
|
||||
|
||||
private func completeSessionRestore() async {
|
||||
guard !sessionFullyRestored, let instance = authService.currentInstance else { return }
|
||||
do {
|
||||
|
|
@ -104,48 +111,47 @@ struct ContentView: View {
|
|||
#endif
|
||||
|
||||
if defaultAllInstances, allInstances.count > 1 {
|
||||
let manager = MultiInstanceManager()
|
||||
manager.bootstrap(instances: allInstances)
|
||||
if manager.isConnected {
|
||||
multiInstanceManager = manager
|
||||
hasAttemptedAutoLogin = true
|
||||
Task {
|
||||
await manager.connect(instances: allInstances)
|
||||
}
|
||||
return
|
||||
}
|
||||
await attemptMultiInstanceLogin()
|
||||
} else {
|
||||
await attemptSingleInstanceLogin()
|
||||
}
|
||||
hasAttemptedAutoLogin = true
|
||||
}
|
||||
|
||||
await manager.connect(instances: allInstances)
|
||||
if manager.isConnected {
|
||||
multiInstanceManager = manager
|
||||
}
|
||||
hasAttemptedAutoLogin = true
|
||||
private func attemptMultiInstanceLogin() async {
|
||||
if multiInstanceManager != nil {
|
||||
// Already bootstrapped synchronously in ForjiApp.init
|
||||
return
|
||||
}
|
||||
let manager = MultiInstanceManager()
|
||||
await manager.connect(instances: allInstances)
|
||||
if manager.isConnected {
|
||||
multiInstanceManager = manager
|
||||
}
|
||||
}
|
||||
|
||||
if let defaultInstance = defaultInstances.first {
|
||||
if authService.bootstrapSession(instance: defaultInstance) {
|
||||
hasAttemptedAutoLogin = true
|
||||
do {
|
||||
try await authService.restoreSession(instance: defaultInstance)
|
||||
defaultInstance.lastUsed = Date()
|
||||
try? modelContext.save()
|
||||
sessionFullyRestored = true
|
||||
} catch {
|
||||
await authService.logout(modelContext: modelContext)
|
||||
}
|
||||
return
|
||||
}
|
||||
private func attemptSingleInstanceLogin() async {
|
||||
guard let defaultInstance = defaultInstances.first else { return }
|
||||
|
||||
if authService.bootstrapSession(instance: defaultInstance) {
|
||||
do {
|
||||
try await authService.restoreSession(instance: defaultInstance)
|
||||
defaultInstance.lastUsed = Date()
|
||||
try? modelContext.save()
|
||||
sessionFullyRestored = true
|
||||
} catch {
|
||||
// Auto-login failed — fall through to instance list
|
||||
await authService.logout(modelContext: modelContext)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await authService.restoreSession(instance: defaultInstance)
|
||||
defaultInstance.lastUsed = Date()
|
||||
try? modelContext.save()
|
||||
} catch {
|
||||
// Auto-login failed — fall through to instance list
|
||||
}
|
||||
hasAttemptedAutoLogin = true
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
|
@ -191,7 +197,7 @@ struct ContentView: View {
|
|||
|
||||
#if DEBUG
|
||||
#Preview {
|
||||
ContentView(authService: AuthenticationService())
|
||||
ContentView(authService: AuthenticationService(), multiInstanceManager: .constant(nil))
|
||||
.modelContainer(for: ForgejoInstance.self, inMemory: true)
|
||||
.environment(NavigationState.shared)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ struct ForjiApp: App {
|
|||
|
||||
let sharedModelContainer: ModelContainer
|
||||
@State private var bootstrappedAuthService: AuthenticationService
|
||||
@State private var bootstrappedMultiInstanceManager: MultiInstanceManager?
|
||||
|
||||
init() {
|
||||
let schema = Schema([ForgejoInstance.self])
|
||||
|
|
@ -23,6 +24,7 @@ struct ForjiApp: App {
|
|||
sharedModelContainer = container
|
||||
|
||||
let auth = AuthenticationService()
|
||||
var multiManager: MultiInstanceManager?
|
||||
|
||||
#if DEBUG
|
||||
let devServerURL = UserDefaults.standard.string(forKey: "dev_serverURL") ?? ""
|
||||
|
|
@ -40,20 +42,34 @@ struct ForjiApp: App {
|
|||
)
|
||||
let allDescriptor = FetchDescriptor<ForgejoInstance>()
|
||||
let allInstances = (try? context.fetch(allDescriptor)) ?? []
|
||||
let instance = (try? context.fetch(defaultDescriptor).first)
|
||||
?? (allInstances.count == 1 ? allInstances.first : nil)
|
||||
if let instance {
|
||||
auth.bootstrapSession(instance: instance)
|
||||
|
||||
let defaultAllInstances = UserDefaults.standard.bool(forKey: "defaultAllInstances")
|
||||
if defaultAllInstances, allInstances.count > 1 {
|
||||
let manager = MultiInstanceManager()
|
||||
manager.bootstrap(instances: allInstances)
|
||||
if manager.isConnected {
|
||||
multiManager = manager
|
||||
}
|
||||
} else {
|
||||
let instance = (try? context.fetch(defaultDescriptor).first)
|
||||
?? (allInstances.count == 1 ? allInstances.first : nil)
|
||||
if let instance {
|
||||
auth.bootstrapSession(instance: instance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_bootstrappedAuthService = State(initialValue: auth)
|
||||
_bootstrappedMultiInstanceManager = State(initialValue: multiManager)
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView(authService: bootstrappedAuthService)
|
||||
.environment(NavigationState.shared)
|
||||
ContentView(
|
||||
authService: bootstrappedAuthService,
|
||||
multiInstanceManager: $bootstrappedMultiInstanceManager,
|
||||
)
|
||||
.environment(NavigationState.shared)
|
||||
}
|
||||
.modelContainer(sharedModelContainer)
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import Foundation
|
|||
|
||||
struct TaggedItem<T: Identifiable>: Identifiable {
|
||||
let id: String
|
||||
let sourceKey: String
|
||||
let item: T
|
||||
let instanceName: String
|
||||
let authService: AuthenticationService
|
||||
var authService: AuthenticationService
|
||||
|
||||
init(item: T, sourceKey: String, instanceName: String, authService: AuthenticationService) {
|
||||
id = "\(sourceKey):\(item.id)"
|
||||
self.sourceKey = sourceKey
|
||||
self.item = item
|
||||
self.instanceName = instanceName
|
||||
self.authService = authService
|
||||
|
|
@ -34,3 +36,51 @@ struct TaggedItem<T: Identifiable>: Identifiable {
|
|||
return merged
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Codable (for disk cache)
|
||||
|
||||
extension TaggedItem: Codable where T: Codable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, sourceKey, item, instanceName
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
sourceKey = try container.decode(String.self, forKey: .sourceKey)
|
||||
item = try container.decode(T.self, forKey: .item)
|
||||
instanceName = try container.decode(String.self, forKey: .instanceName)
|
||||
authService = AuthenticationService()
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(sourceKey, forKey: .sourceKey)
|
||||
try container.encode(item, forKey: .item)
|
||||
try container.encode(instanceName, forKey: .instanceName)
|
||||
}
|
||||
}
|
||||
|
||||
extension PaginationState where Item: Identifiable {
|
||||
/// Replace placeholder auth services on cached TaggedItems with live ones from the manager.
|
||||
/// Items whose source is no longer connected are removed.
|
||||
func rehydrate<T>(from manager: MultiInstanceManager) where Item == TaggedItem<T> {
|
||||
let sources = manager.connectionSources()
|
||||
rehydrate(from: sources)
|
||||
}
|
||||
|
||||
/// Replace placeholder auth services using the given connection sources.
|
||||
/// Items whose sourceKey has no matching source are removed.
|
||||
func rehydrate<T>(from sources: [ConnectionSource]) where Item == TaggedItem<T> {
|
||||
let lookup = Dictionary(
|
||||
uniqueKeysWithValues: sources.map { ($0.sourceKey, $0.authService) },
|
||||
)
|
||||
items = items.compactMap { item in
|
||||
guard let auth = lookup[item.sourceKey] else { return nil }
|
||||
var rehydrated = item
|
||||
rehydrated.authService = auth
|
||||
return rehydrated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,18 @@ import SwiftUI
|
|||
struct MergedNotificationsOverviewView: View {
|
||||
let manager: MultiInstanceManager
|
||||
|
||||
@State private var pagination = PaginationState<TaggedItem<NotificationThread>>()
|
||||
@State private var pagination: PaginationState<TaggedItem<NotificationThread>>
|
||||
@State private var statusFilter: String = "unread"
|
||||
|
||||
init(manager: MultiInstanceManager) {
|
||||
self.manager = manager
|
||||
let state = PaginationState<TaggedItem<NotificationThread>>()
|
||||
state.cacheName = "merged_notifications"
|
||||
state.loadFromCache()
|
||||
state.rehydrate(from: manager)
|
||||
_pagination = State(initialValue: state)
|
||||
}
|
||||
|
||||
private var statusTypes: [String] {
|
||||
switch statusFilter {
|
||||
case "unread": ["unread"]
|
||||
|
|
@ -123,13 +132,13 @@ struct MergedNotificationsOverviewView: View {
|
|||
|
||||
@discardableResult
|
||||
private func reloadNotifications(clearItems: Bool = false) -> Task<Void, Never> {
|
||||
pagination.reload(clearItems: clearItems) { page, limit in
|
||||
pagination.reloadAndCache(clearItems: clearItems) { page, limit in
|
||||
try await fetchFromAllInstances(page: page, limit: limit)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMore() async {
|
||||
await pagination.loadMore { page, limit in
|
||||
await pagination.loadMoreAndCache { page, limit in
|
||||
try await fetchFromAllInstances(page: page, limit: limit)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,21 @@ import SwiftUI
|
|||
struct MergedRepositoryListView: View {
|
||||
let manager: MultiInstanceManager
|
||||
|
||||
@State private var pagination = PaginationState<TaggedItem<Repository>>()
|
||||
@State private var pagination: PaginationState<TaggedItem<Repository>>
|
||||
@AppStorage("repoFilterShowArchived") private var showArchived = true
|
||||
@AppStorage("repoFilterShowMirrors") private var showMirrors = true
|
||||
@State private var searchText = ""
|
||||
@State private var searchTask: Task<Void, Never>?
|
||||
|
||||
init(manager: MultiInstanceManager) {
|
||||
self.manager = manager
|
||||
let state = PaginationState<TaggedItem<Repository>>()
|
||||
state.cacheName = "merged_repos"
|
||||
state.loadFromCache()
|
||||
state.rehydrate(from: manager)
|
||||
_pagination = State(initialValue: state)
|
||||
}
|
||||
|
||||
private var hasNonDefaultFilters: Bool {
|
||||
!showArchived || !showMirrors
|
||||
}
|
||||
|
|
@ -118,13 +127,13 @@ struct MergedRepositoryListView: View {
|
|||
|
||||
@discardableResult
|
||||
private func reloadItems(clearItems: Bool = false) -> Task<Void, Never> {
|
||||
pagination.reload(clearItems: clearItems) { page, limit in
|
||||
pagination.reloadAndCache(clearItems: clearItems) { page, limit in
|
||||
try await fetchFromAllInstances(page: page, limit: limit)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMore() async {
|
||||
await pagination.loadMore { page, limit in
|
||||
await pagination.loadMoreAndCache { page, limit in
|
||||
try await fetchFromAllInstances(page: page, limit: limit)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ struct MergedSearchableOverviewView<Row: View, Detail: View, CreateView: View>:
|
|||
let manager: MultiInstanceManager
|
||||
let config: MergedSearchableConfig
|
||||
|
||||
@State private var pagination = PaginationState<TaggedItem<Issue>>()
|
||||
@State private var pagination: PaginationState<TaggedItem<Issue>>
|
||||
@State private var stateFilter: IssueFilterState = .open
|
||||
@State private var searchText = ""
|
||||
@State private var searchTask: Task<Void, Never>?
|
||||
|
|
@ -65,6 +65,12 @@ struct MergedSearchableOverviewView<Row: View, Detail: View, CreateView: View>:
|
|||
rowContent = row
|
||||
detailContent = detail
|
||||
createContent = createView
|
||||
|
||||
let state = PaginationState<TaggedItem<Issue>>()
|
||||
state.cacheName = "merged_\(config.issueType)"
|
||||
state.loadFromCache()
|
||||
state.rehydrate(from: manager)
|
||||
_pagination = State(initialValue: state)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -186,13 +192,13 @@ struct MergedSearchableOverviewView<Row: View, Detail: View, CreateView: View>:
|
|||
|
||||
@discardableResult
|
||||
private func reloadItems(clearItems: Bool = false) -> Task<Void, Never> {
|
||||
pagination.reload(clearItems: clearItems) { page, limit in
|
||||
pagination.reloadAndCache(clearItems: clearItems) { page, limit in
|
||||
try await fetchFromAllInstances(page: page, limit: limit)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMore() async {
|
||||
await pagination.loadMore { page, limit in
|
||||
await pagination.loadMoreAndCache { page, limit in
|
||||
try await fetchFromAllInstances(page: page, limit: limit)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,4 +122,72 @@ struct TaggedItemTests {
|
|||
let result: [TaggedItem<FIssue>] = TaggedItem.mergeAndDeduplicate(batches: [], sources: [])
|
||||
#expect(result.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Codable Round-Trip
|
||||
|
||||
@Test func codableRoundTripPreservesFields() throws {
|
||||
let issue = makeIssue(id: 3, title: "Cached")
|
||||
let tagged = TaggedItem(
|
||||
item: issue, sourceKey: "https://srv.com:admin",
|
||||
instanceName: "Server", authService: makeAuth()
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode([tagged])
|
||||
let decoded = try JSONDecoder().decode([TaggedItem<FIssue>].self, from: data)
|
||||
|
||||
#expect(decoded.count == 1)
|
||||
#expect(decoded[0].id == "https://srv.com:admin:3")
|
||||
#expect(decoded[0].sourceKey == "https://srv.com:admin")
|
||||
#expect(decoded[0].item.title == "Cached")
|
||||
#expect(decoded[0].instanceName == "Server")
|
||||
}
|
||||
|
||||
@Test func codableDecodedAuthServiceIsPlaceholder() throws {
|
||||
let issue = makeIssue(id: 1)
|
||||
let tagged = TaggedItem(
|
||||
item: issue, sourceKey: "key",
|
||||
instanceName: "name", authService: makeAuth()
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode([tagged])
|
||||
let decoded = try JSONDecoder().decode([TaggedItem<FIssue>].self, from: data)
|
||||
|
||||
#expect(decoded[0].authService.isAuthenticated == false)
|
||||
#expect(decoded[0].authService.client == nil)
|
||||
}
|
||||
|
||||
// MARK: - Rehydrate
|
||||
|
||||
@MainActor
|
||||
@Test func rehydrateReplacesAuthService() {
|
||||
let liveAuth = AuthenticationService()
|
||||
let source = ConnectionSource(
|
||||
sourceKey: "https://a.com:u", name: "A",
|
||||
client: ForgejoClient(serverURL: "https://a.com", username: "u", token: "t"),
|
||||
authService: liveAuth
|
||||
)
|
||||
let issue = makeIssue(id: 1)
|
||||
let pagination = PaginationState<TaggedItem<FIssue>>()
|
||||
pagination.items = [
|
||||
TaggedItem(item: issue, sourceKey: "https://a.com:u", instanceName: "A", authService: makeAuth()),
|
||||
]
|
||||
|
||||
pagination.rehydrate(from: [source])
|
||||
|
||||
#expect(pagination.items.count == 1)
|
||||
#expect(pagination.items[0].authService === liveAuth)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func rehydrateRemovesItemsWithNoMatchingSource() {
|
||||
let issue = makeIssue(id: 1)
|
||||
let pagination = PaginationState<TaggedItem<FIssue>>()
|
||||
pagination.items = [
|
||||
TaggedItem(item: issue, sourceKey: "https://removed.com:u", instanceName: "Gone", authService: makeAuth()),
|
||||
]
|
||||
|
||||
pagination.rehydrate(from: [ConnectionSource]())
|
||||
|
||||
#expect(pagination.items.isEmpty)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue