krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.4: Hutch/Networking/SystemStatusRepository.swift · raw
1import Foundation
2
3protocol SystemStatusServing: Sendable {
4 func fetchSnapshotHTML() async throws -> String
5 func fetchIncidentFeedData() async throws -> Data
6}
7
8struct CachedSystemStatusValue<Value: Sendable>: Sendable {
9 let value: Value
10 let lastSuccessfulAt: Date
11 let isStale: Bool
12 let refreshErrorMessage: String?
13}
14
15actor SystemStatusRepository {
16 private let service: any SystemStatusServing
17 private let ttl: TimeInterval
18 private let cacheStore: SystemStatusCacheStore
19 private let now: @Sendable () -> Date
20
21 private var snapshotCache: CacheEntry<SystemStatusSnapshot>?
22 private var incidentsCache: CacheEntry<[StatusIncident]>?
23 private var hasLoadedPersistentCache = false
24
25 init(
26 service: any SystemStatusServing = SystemStatusService(),
27 ttl: TimeInterval = 10 * 60,
28 cacheStore: SystemStatusCacheStore = SystemStatusCacheStore(),
29 now: @escaping @Sendable () -> Date = Date.init
30 ) {
31 self.service = service
32 self.ttl = ttl
33 self.cacheStore = cacheStore
34 self.now = now
35 }
36
37 func snapshot(forceRefresh: Bool = false) async throws -> SystemStatusSnapshot {
38 try await snapshotResult(forceRefresh: forceRefresh).value
39 }
40
41 func recentIncidents(forceRefresh: Bool = false) async throws -> [StatusIncident] {
42 try await recentIncidentsResult(forceRefresh: forceRefresh).value
43 }
44
45 func snapshotResult(forceRefresh: Bool = false) async throws -> CachedSystemStatusValue<SystemStatusSnapshot> {
46 await loadPersistentCacheIfNeeded()
47
48 if let cached = snapshotCache, !forceRefresh, !cached.isExpired(ttl: ttl, now: now) {
49 return CachedSystemStatusValue(
50 value: cached.value,
51 lastSuccessfulAt: cached.timestamp,
52 isStale: false,
53 refreshErrorMessage: nil
54 )
55 }
56
57 do {
58 let html = try await service.fetchSnapshotHTML()
59 let snapshot = try SystemStatusService.parseSnapshotHTML(html, fetchedAt: now())
60 let entry = CacheEntry(value: snapshot, timestamp: now())
61 snapshotCache = entry
62 await cacheStore.saveSnapshotHTML(html, timestamp: entry.timestamp)
63 return CachedSystemStatusValue(
64 value: snapshot,
65 lastSuccessfulAt: entry.timestamp,
66 isStale: false,
67 refreshErrorMessage: nil
68 )
69 } catch {
70 if let cached = snapshotCache {
71 return CachedSystemStatusValue(
72 value: cached.value,
73 lastSuccessfulAt: cached.timestamp,
74 isStale: true,
75 refreshErrorMessage: refreshErrorMessage(from: error)
76 )
77 }
78 throw error
79 }
80 }
81
82 func recentIncidentsResult(forceRefresh: Bool = false) async throws -> CachedSystemStatusValue<[StatusIncident]> {
83 await loadPersistentCacheIfNeeded()
84
85 if let cached = incidentsCache, !forceRefresh, !cached.isExpired(ttl: ttl, now: now) {
86 return CachedSystemStatusValue(
87 value: cached.value,
88 lastSuccessfulAt: cached.timestamp,
89 isStale: false,
90 refreshErrorMessage: nil
91 )
92 }
93
94 do {
95 let feedData = try await service.fetchIncidentFeedData()
96 let incidents = try await SystemStatusService.parseIncidentFeedXML(feedData)
97 let entry = CacheEntry(value: incidents, timestamp: now())
98 incidentsCache = entry
99 await cacheStore.saveIncidentFeedData(feedData, timestamp: entry.timestamp)
100 return CachedSystemStatusValue(
101 value: incidents,
102 lastSuccessfulAt: entry.timestamp,
103 isStale: false,
104 refreshErrorMessage: nil
105 )
106 } catch {
107 if let cached = incidentsCache {
108 return CachedSystemStatusValue(
109 value: cached.value,
110 lastSuccessfulAt: cached.timestamp,
111 isStale: true,
112 refreshErrorMessage: refreshErrorMessage(from: error)
113 )
114 }
115 throw error
116 }
117 }
118
119 private func loadPersistentCacheIfNeeded() async {
120 guard !hasLoadedPersistentCache else { return }
121 if let persistedSnapshot = await cacheStore.loadSnapshotHTML(),
122 let snapshot = try? SystemStatusService.parseSnapshotHTML(persistedSnapshot.html, fetchedAt: persistedSnapshot.timestamp) {
123 snapshotCache = CacheEntry(value: snapshot, timestamp: persistedSnapshot.timestamp)
124 }
125 if let persistedFeed = await cacheStore.loadIncidentFeedData(),
126 let incidents = try? await SystemStatusService.parseIncidentFeedXML(persistedFeed.data) {
127 incidentsCache = CacheEntry(value: incidents, timestamp: persistedFeed.timestamp)
128 }
129 hasLoadedPersistentCache = true
130 }
131
132 private func refreshErrorMessage(from error: any Error) -> String {
133 error.userFacingMessage
134 }
135}
136
137struct CacheEntry<Value: Sendable>: Sendable {
138 let value: Value
139 let timestamp: Date
140
141 nonisolated func isExpired(ttl: TimeInterval, now: @escaping @Sendable () -> Date = Date.init) -> Bool {
142 now().timeIntervalSince(timestamp) > ttl
143 }
144}