krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.16.0: Hutch/App/AppState.swift · raw
1import Foundation
2import SwiftUI
3import WebKit
4
5/// Central application state shared across the view hierarchy.
6@Observable
7@MainActor
8final class AppState {
9
10 enum Tab: Hashable {
11 case home
12 case repositories
13 case tickets
14 case builds
15 case more
16 }
17
18 enum TabNavigationTarget: Hashable {
19 case repository(RepositorySummary)
20 case tracker(TrackerSummary)
21 case mailingList(InboxMailingListReference)
22 case systemStatus
23 case builds
24 }
25
26 enum AuthPhase {
27 /// App just launched, checking for an existing token.
28 case launching
29 /// No valid token — show the token entry screen.
30 case unauthenticated
31 /// Token validated, user is signed in.
32 case authenticated
33 }
34
35 // MARK: - Authentication
36
37 private(set) var authPhase: AuthPhase = .launching
38
39 /// Convenience for views that need a simple bool.
40 var isAuthenticated: Bool {
41 authPhase == .authenticated && currentUser != nil
42 }
43
44 // MARK: - Multi-account
45
46 /// All stored accounts. Loaded from Keychain; kept in sync on add/remove/switch.
47 private(set) var accounts: [AccountEntry] = []
48
49 /// The ID of the account currently in use. Persisted in UserDefaults.
50 private(set) var activeAccountID: String = ""
51
52 var selectedTab: Tab = .home
53
54 // MARK: - Current user (populated after successful validation)
55
56 private(set) var currentUser: User?
57
58 // MARK: - Networking
59
60 let client: SRHTClient
61 let configuration: AppConfiguration
62 let systemStatusRepository: SystemStatusRepository
63
64 // MARK: - Deep link pending navigation
65
66 /// Set by the deep link handler; consumed by RootView to drive navigation.
67 var pendingDeepLink: DeepLink?
68 var pendingTabNavigation: TabNavigationTarget?
69 var deepLinkError: String?
70
71 // MARK: - Init
72
73 init() {
74 self.configuration = AppConfiguration()
75 let token = KeychainHelper.loadToken()
76 self.client = SRHTClient(token: token)
77 self.systemStatusRepository = SystemStatusRepository()
78 }
79
80 // MARK: - Launch validation
81
82 /// Called once at app launch. If a token exists in Keychain, validates it
83 /// silently. On failure, clears the token and falls through to unauthenticated.
84 func validateOnLaunch() async {
85 var storedAccounts = KeychainHelper.loadAccounts()
86
87 if storedAccounts.isEmpty, let legacyToken = KeychainHelper.loadToken() {
88 client.setToken(legacyToken)
89 if let user = try? await fetchMe() {
90 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: legacyToken)
91 storedAccounts = [entry]
92 try? KeychainHelper.saveAccounts(storedAccounts)
93 try? KeychainHelper.deleteToken()
94 } else {
95 try? KeychainHelper.deleteToken()
96 client.setToken(nil)
97 authPhase = .unauthenticated
98 return
99 }
100 }
101
102 guard !storedAccounts.isEmpty else {
103 authPhase = .unauthenticated
104 return
105 }
106
107 let savedID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? ""
108 let target = storedAccounts.first(where: { $0.id == savedID }) ?? storedAccounts[0]
109
110 client.setToken(target.token)
111 do {
112 let user = try await fetchMe()
113 accounts = storedAccounts
114 activeAccountID = target.id
115 currentUser = user
116 ContributionWidgetContextStore.saveActor(user.canonicalName)
117 authPhase = .authenticated
118 await refreshNeedsAttentionSnapshot()
119 } catch {
120 client.setToken(nil)
121 currentUser = nil
122 ContributionWidgetContextStore.clear()
123 authPhase = .unauthenticated
124 NeedsAttentionSnapshotStore.clear()
125 }
126 }
127
128 // MARK: - Token management
129
130 /// Validate a new token by querying meta.sr.ht, then persist it.
131 /// Throws on network/GraphQL errors so the caller can display the message.
132 func connect(with token: String) async throws {
133 client.setToken(token)
134 do {
135 let user = try await fetchMe()
136 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
137 accounts.append(entry)
138 activeAccountID = entry.id
139 UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID)
140 try KeychainHelper.saveAccounts(accounts)
141 currentUser = user
142 ContributionWidgetContextStore.saveActor(user.canonicalName)
143 authPhase = .authenticated
144 await refreshNeedsAttentionSnapshot()
145 } catch {
146 client.setToken(nil)
147 throw error
148 }
149 }
150
151 /// Validate a new token, add it as an account, and switch to it immediately.
152 func addAccount(token: String) async throws {
153 let tempClient = SRHTClient(token: token)
154 let user = try await fetchMe(using: tempClient)
155 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
156 accounts.append(entry)
157 try KeychainHelper.saveAccounts(accounts)
158 try await switchAccount(to: entry.id)
159 }
160
161 /// Switch the active account and fully refresh the app.
162 func switchAccount(to id: String) async throws {
163 guard let entry = accounts.first(where: { $0.id == id }) else { return }
164
165 client.responseCache.clear()
166 currentUser = nil
167 pendingDeepLink = nil
168 pendingTabNavigation = nil
169 deepLinkError = nil
170 selectedTab = .home
171
172 authPhase = .unauthenticated
173
174 client.setToken(entry.token)
175 activeAccountID = entry.id
176 UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID)
177
178 let user = try await fetchMe()
179 currentUser = user
180 ContributionWidgetContextStore.saveActor(user.canonicalName)
181 authPhase = .authenticated
182 await refreshNeedsAttentionSnapshot()
183 }
184
185 /// Remove a stored account. Switches to another account if the removed account
186 /// was active; signs out fully if it was the last account.
187 func removeAccount(id: String) async {
188 accounts.removeAll { $0.id == id }
189 try? KeychainHelper.saveAccounts(accounts)
190
191 guard id == activeAccountID else { return }
192
193 if let next = accounts.first {
194 try? await switchAccount(to: next.id)
195 } else {
196 await signOut()
197 }
198 }
199
200 func signOut() async {
201 clearSessionState()
202 URLCache.shared.removeAllCachedResponses()
203 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
204 await clearWebData()
205 clearWebContentRenderCaches()
206 NeedsAttentionSnapshotStore.clear()
207 SystemStatusWidgetSnapshotStore.clear()
208 authPhase = .unauthenticated
209 selectedTab = .home
210 }
211
212 func resetAppData() async {
213 clearSessionState()
214
215 if let bundleIdentifier = Bundle.main.bundleIdentifier {
216 UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
217 }
218 URLCache.shared.removeAllCachedResponses()
219 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
220 await clearWebData()
221 clearWebContentRenderCaches()
222 NeedsAttentionSnapshotStore.clear()
223 SystemStatusWidgetSnapshotStore.clear()
224
225 authPhase = .unauthenticated
226 selectedTab = .home
227 }
228
229 // MARK: - Deep link resolution
230
231 /// Resolve a repository by owner and name for deep linking.
232 func resolveRepository(owner: String, name: String, service: SRHTService = .git) async throws -> RepositorySummary {
233 let result = try await client.execute(
234 service: service,
235 query: Self.repoLookupQuery,
236 variables: ["owner": owner, "name": name],
237 responseType: RepoLookupResponse.self
238 )
239 return result.user.repository
240 }
241
242 /// Resolve a tracker by owner and name for deep linking.
243 func resolveTracker(owner: String, name: String) async throws -> TrackerSummary {
244 let result = try await client.execute(
245 service: .todo,
246 query: Self.trackerLookupQuery,
247 variables: ["owner": owner, "name": name],
248 responseType: TrackerLookupResponse.self
249 )
250 return result.user.tracker
251 }
252
253 func resolveProjectSource(_ source: Project.SourceRepo) async throws -> RepositorySummary {
254 try await resolveRepository(
255 owner: source.ownerUsername,
256 name: source.name,
257 service: source.repoType.service
258 )
259 }
260
261 func resolveProjectTracker(_ tracker: Project.Tracker) async throws -> TrackerSummary {
262 try await resolveTracker(owner: tracker.ownerUsername, name: tracker.name)
263 }
264
265 func openProjectSource(_ source: Project.SourceRepo) async throws {
266 let repository = try await resolveProjectSource(source)
267 navigateToRepository(repository)
268 }
269
270 func openProjectTracker(_ tracker: Project.Tracker) async throws {
271 let resolvedTracker = try await resolveProjectTracker(tracker)
272 navigateToTracker(resolvedTracker)
273 }
274
275 func openMailingList(_ mailingList: InboxMailingListReference) {
276 navigateToMailingList(mailingList)
277 }
278
279 func openSystemStatus() {
280 navigateToSystemStatus()
281 }
282
283 func navigateToRepository(_ repository: RepositorySummary) {
284 pendingTabNavigation = .repository(repository)
285 selectedTab = .repositories
286 }
287
288 func navigateToTracker(_ tracker: TrackerSummary) {
289 pendingTabNavigation = .tracker(tracker)
290 selectedTab = .tickets
291 }
292
293 func navigateToBuild(jobId: Int) {
294 pendingDeepLink = .build(jobId: jobId)
295 selectedTab = .builds
296 }
297
298 func navigateToTicket(ownerUsername: String, trackerName: String, ticketId: Int) {
299 pendingDeepLink = .ticket(owner: ownerUsername, tracker: trackerName, ticketId: ticketId)
300 selectedTab = .tickets
301 }
302
303 func navigateToMailingList(_ mailingList: InboxMailingListReference) {
304 pendingTabNavigation = .mailingList(mailingList)
305 selectedTab = .more
306 }
307
308 func navigateToSystemStatus() {
309 pendingTabNavigation = .systemStatus
310 selectedTab = .more
311 }
312
313 func navigateToBuildsList() {
314 pendingTabNavigation = .builds
315 selectedTab = .builds
316 }
317
318 func presentRepositoryDeepLinkError() {
319 deepLinkError = "The repository could not be found or is inaccessible."
320 }
321
322 func presentTicketDeepLinkError() {
323 deepLinkError = "The ticket could not be found or is inaccessible."
324 }
325
326 // MARK: - Private
327
328 private static let meQuery = """
329 {
330 me {
331 id
332 username
333 canonicalName
334 email
335 avatar
336 }
337 }
338 """
339
340 private struct MeResponse: Decodable {
341 let me: User
342 }
343
344 private func fetchMe() async throws -> User {
345 try await fetchMe(using: client)
346 }
347
348 private func fetchMe(using srhtClient: SRHTClient) async throws -> User {
349 let result = try await srhtClient.execute(
350 service: .meta,
351 query: Self.meQuery,
352 responseType: MeResponse.self
353 )
354 return result.me
355 }
356
357 // MARK: - Deep link queries
358
359 private static let repoLookupQuery = """
360 query repoLookup($owner: String!, $name: String!) {
361 user(username: $owner) {
362 repository(name: $name) {
363 id rid name description visibility updated
364 owner { canonicalName }
365 HEAD { name target }
366 }
367 }
368 }
369 """
370
371 private struct RepoLookupResponse: Decodable, Sendable {
372 let user: RepoLookupUser
373 }
374
375 private struct RepoLookupUser: Decodable, Sendable {
376 let repository: RepositorySummary
377 }
378
379 private static let trackerLookupQuery = """
380 query trackerLookup($owner: String!, $name: String!) {
381 user(username: $owner) {
382 tracker(name: $name) {
383 id rid name description visibility updated
384 owner { canonicalName }
385 }
386 }
387 }
388 """
389
390 private struct TrackerLookupResponse: Decodable, Sendable {
391 let user: TrackerLookupUser
392 }
393
394 private struct TrackerLookupUser: Decodable, Sendable {
395 let tracker: TrackerSummary
396 }
397
398 private func clearSessionState() {
399 try? KeychainHelper.deleteAll()
400 client.setToken(nil)
401 client.responseCache.clear()
402 accounts = []
403 activeAccountID = ""
404 UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID)
405 currentUser = nil
406 ContributionWidgetContextStore.clear()
407 pendingDeepLink = nil
408 pendingTabNavigation = nil
409 deepLinkError = nil
410 selectedTab = .home
411 }
412
413 private func refreshNeedsAttentionSnapshot() async {
414 guard let currentUser else {
415 NeedsAttentionSnapshotStore.clear()
416 return
417 }
418
419 let viewModel = HomeViewModel(
420 currentUser: currentUser,
421 client: client,
422 systemStatusRepository: systemStatusRepository
423 )
424 await viewModel.loadDashboard()
425 }
426
427 private func clearWebData() async {
428 await withCheckedContinuation { continuation in
429 let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
430 let since = Date(timeIntervalSince1970: 0)
431 WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: since) {
432 continuation.resume()
433 }
434 }
435 }
436}