krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.3.1: Hutch/App/AppState.swift · raw
1import Foundation
2import SwiftUI
3import UIKit
4import WebKit
5
6/// Central application state shared across the view hierarchy.
7@Observable
8@MainActor
9final class AppState {
10
11 enum Tab: Hashable {
12 case home
13 case repositories
14 case tickets
15 case builds
16 case more
17 }
18
19 enum TabNavigationTarget: Hashable {
20 case repository(RepositorySummary)
21 case tracker(TrackerSummary)
22 case mailingList(InboxMailingListReference)
23 case systemStatus
24 case builds
25 }
26
27 enum AuthPhase {
28 /// App just launched, checking for an existing token.
29 case launching
30 /// No valid token — show the token entry screen.
31 case unauthenticated
32 /// Token validated, user is signed in.
33 case authenticated
34 }
35
36 // MARK: - Authentication
37
38 private(set) var authPhase: AuthPhase = .launching
39 private(set) var authStatusMessage = "Connecting…"
40
41 /// Convenience for views that need a simple bool.
42 var isAuthenticated: Bool {
43 authPhase == .authenticated && currentUser != nil
44 }
45
46 // MARK: - Multi-account
47
48 /// All stored accounts. Loaded from Keychain; kept in sync on add/remove/switch.
49 private(set) var accounts: [AccountEntry] = []
50
51 /// The ID of the account currently in use. Persisted in UserDefaults.
52 private(set) var activeAccountID: String = ""
53
54 var selectedTab: Tab = .home
55
56 // MARK: - Current user (populated after successful validation)
57
58 private(set) var currentUser: User?
59
60 // MARK: - Networking
61
62 private(set) var client: SRHTClient
63 let configuration: AppConfiguration
64 private(set) var systemStatusRepository: SystemStatusRepository
65 private var activeSession: AccountSession?
66 private(set) var sessionIdentity = UUID()
67 var isDebugModeEnabled = UserDefaults.standard.bool(forKey: AppStorageKeys.debugModeEnabled) {
68 didSet {
69 UserDefaults.standard.set(isDebugModeEnabled, forKey: AppStorageKeys.debugModeEnabled)
70 }
71 }
72 private(set) var copyConfirmationMessage: String?
73 private var copyConfirmationTask: Task<Void, Never>?
74
75 var accountDefaults: UserDefaults {
76 activeSession?.defaults ?? .standard
77 }
78
79 // MARK: - Deep link pending navigation
80
81 /// Set by the deep link handler; consumed by RootView to drive navigation.
82 var pendingDeepLink: DeepLink?
83 var pendingTabNavigation: TabNavigationTarget?
84 var deepLinkError: String?
85
86 // MARK: - Init
87
88 init() {
89 self.configuration = AppConfiguration()
90 self.client = SRHTClient()
91 self.systemStatusRepository = SystemStatusRepository()
92 }
93
94 // MARK: - Launch validation
95
96 /// Called once at app launch. If a token exists in Keychain, validates it
97 /// silently. On failure, clears the token and falls through to unauthenticated.
98 func validateOnLaunch() async {
99 authStatusMessage = "Connecting…"
100 var storedAccounts = KeychainHelper.loadAccounts()
101
102 if storedAccounts.isEmpty, let legacyToken = KeychainHelper.loadToken() {
103 let legacyClient = SRHTClient(token: legacyToken)
104 if let user = try? await fetchMe(using: legacyClient) {
105 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: legacyToken)
106 storedAccounts = [entry]
107 try? KeychainHelper.saveAccounts(storedAccounts)
108 try? KeychainHelper.deleteToken()
109 } else {
110 try? KeychainHelper.deleteToken()
111 authPhase = .unauthenticated
112 return
113 }
114 }
115
116 guard !storedAccounts.isEmpty else {
117 authPhase = .unauthenticated
118 return
119 }
120
121 let savedID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? ""
122 let orderedAccounts = prioritizedAccounts(storedAccounts, preferredID: savedID)
123 var invalidIDs = Set<String>()
124
125 for account in orderedAccounts {
126 do {
127 let session = try await makeSession(for: account)
128 let filteredAccounts = storedAccounts.filter { !invalidIDs.contains($0.id) }
129 accounts = filteredAccounts
130 try? KeychainHelper.saveAccounts(filteredAccounts)
131 activate(session)
132 authPhase = .authenticated
133 await refreshNeedsAttentionSnapshot()
134 return
135 } catch {
136 invalidIDs.insert(account.id)
137 clearAccountArtifacts(for: account.id)
138 }
139 }
140
141 accounts = storedAccounts.filter { !invalidIDs.contains($0.id) }
142 try? KeychainHelper.saveAccounts(accounts)
143 clearActiveSessionState()
144 authPhase = .unauthenticated
145 }
146
147 // MARK: - Token management
148
149 /// Validate a new token by querying meta.sr.ht, then persist it.
150 /// Throws on network/GraphQL errors so the caller can display the message.
151 func connect(with token: String) async throws {
152 let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
153 try await addValidatedAccount(token: normalizedToken, activateNewAccount: true)
154 }
155
156 /// Validate a new token, add it as an account, and switch to it immediately.
157 func addAccount(token: String) async throws {
158 let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
159 try await addValidatedAccount(token: normalizedToken, activateNewAccount: true)
160 }
161
162 /// Switch the active account and fully refresh the app.
163 func switchAccount(to id: String) async throws {
164 guard let entry = accounts.first(where: { $0.id == id }) else { return }
165 let previousSession = activeSession
166
167 authStatusMessage = "Switching Accounts…"
168 authPhase = .launching
169 sessionIdentity = UUID()
170
171 do {
172 let session = try await makeSession(for: entry)
173 activate(session)
174 resetNavigationState()
175 } catch {
176 if let previousSession {
177 activate(previousSession)
178 authPhase = .authenticated
179 } else {
180 clearActiveSessionState()
181 authPhase = .unauthenticated
182 }
183 throw error
184 }
185
186 authPhase = .authenticated
187 await refreshNeedsAttentionSnapshot()
188 }
189
190 /// Remove a stored account. Switches to another account if the removed account
191 /// was active; signs out fully if it was the last account.
192 func removeAccount(id: String) async {
193 let removedWasActive = id == activeAccountID
194 accounts.removeAll { $0.id == id }
195 try? KeychainHelper.saveAccounts(accounts)
196 clearAccountArtifacts(for: id)
197
198 guard removedWasActive else { return }
199
200 if let next = accounts.first {
201 do {
202 try await switchAccount(to: next.id)
203 } catch {
204 await removeAccount(id: next.id)
205 }
206 } else {
207 await signOut()
208 }
209 }
210
211 func signOut() async {
212 clearActiveSessionState()
213 try? KeychainHelper.deleteAll()
214 URLCache.shared.removeAllCachedResponses()
215 await client.clearPersistentCache()
216 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
217 await clearWebData()
218 clearWebContentRenderCaches()
219 clearAllAccountArtifacts()
220 authPhase = .unauthenticated
221 selectedTab = .home
222 dismissCopyConfirmation()
223 }
224
225 func resetAppData() async {
226 clearActiveSessionState()
227
228 if let bundleIdentifier = Bundle.main.bundleIdentifier {
229 UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
230 }
231 for account in accounts {
232 AccountDefaultsStore.clear(accountID: account.id)
233 }
234 try? KeychainHelper.deleteAll()
235 URLCache.shared.removeAllCachedResponses()
236 await client.clearPersistentCache()
237 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
238 await clearWebData()
239 clearWebContentRenderCaches()
240 clearAllAccountArtifacts()
241
242 authPhase = .unauthenticated
243 selectedTab = .home
244 isDebugModeEnabled = false
245 dismissCopyConfirmation()
246 }
247
248 func copyToPasteboard(_ value: String, label: String) {
249 UIPasteboard.general.string = value
250 showCopyConfirmation(message: "Copied \(label)")
251 }
252
253 func dismissCopyConfirmation() {
254 copyConfirmationTask?.cancel()
255 copyConfirmationTask = nil
256 copyConfirmationMessage = nil
257 }
258
259 // MARK: - Deep link resolution
260
261 /// Resolve a repository by owner and name for deep linking.
262 func resolveRepository(owner: String, name: String, service: SRHTService = .git) async throws -> RepositorySummary {
263 let result = try await client.execute(
264 service: service,
265 query: Self.repoLookupQuery,
266 variables: ["owner": owner, "name": name],
267 responseType: RepoLookupResponse.self
268 )
269 return result.user.repository
270 }
271
272 /// Resolve a tracker by owner and name for deep linking.
273 func resolveTracker(owner: String, name: String) async throws -> TrackerSummary {
274 let result = try await client.execute(
275 service: .todo,
276 query: Self.trackerLookupQuery,
277 variables: ["owner": owner, "name": name],
278 responseType: TrackerLookupResponse.self
279 )
280 return result.user.tracker
281 }
282
283 func resolveProjectSource(_ source: Project.SourceRepo) async throws -> RepositorySummary {
284 try await resolveRepository(
285 owner: source.ownerUsername,
286 name: source.name,
287 service: source.repoType.service
288 )
289 }
290
291 func resolveProjectTracker(_ tracker: Project.Tracker) async throws -> TrackerSummary {
292 try await resolveTracker(owner: tracker.ownerUsername, name: tracker.name)
293 }
294
295 func openProjectSource(_ source: Project.SourceRepo) async throws {
296 let repository = try await resolveProjectSource(source)
297 navigateToRepository(repository)
298 }
299
300 func openProjectTracker(_ tracker: Project.Tracker) async throws {
301 let resolvedTracker = try await resolveProjectTracker(tracker)
302 navigateToTracker(resolvedTracker)
303 }
304
305 func openMailingList(_ mailingList: InboxMailingListReference) {
306 navigateToMailingList(mailingList)
307 }
308
309 func openSystemStatus() {
310 navigateToSystemStatus()
311 }
312
313 func navigateToRepository(_ repository: RepositorySummary) {
314 pendingTabNavigation = .repository(repository)
315 selectedTab = .repositories
316 }
317
318 func navigateToTracker(_ tracker: TrackerSummary) {
319 pendingTabNavigation = .tracker(tracker)
320 selectedTab = .tickets
321 }
322
323 func navigateToBuild(jobId: Int) {
324 pendingDeepLink = .build(jobId: jobId)
325 selectedTab = .builds
326 }
327
328 func navigateToTicket(ownerUsername: String, trackerName: String, ticketId: Int) {
329 pendingDeepLink = .ticket(owner: ownerUsername, tracker: trackerName, ticketId: ticketId)
330 selectedTab = .tickets
331 }
332
333 func navigateToMailingList(_ mailingList: InboxMailingListReference) {
334 pendingTabNavigation = .mailingList(mailingList)
335 selectedTab = .more
336 }
337
338 func navigateToSystemStatus() {
339 pendingTabNavigation = .systemStatus
340 selectedTab = .more
341 }
342
343 func navigateToBuildsList() {
344 pendingTabNavigation = .builds
345 selectedTab = .builds
346 }
347
348 func presentRepositoryDeepLinkError() {
349 deepLinkError = "The repository could not be found or is inaccessible."
350 }
351
352 func presentTicketDeepLinkError() {
353 deepLinkError = "The ticket could not be found or is inaccessible."
354 }
355
356 // MARK: - Private
357
358 private static let meQuery = """
359 {
360 me {
361 id
362 username
363 canonicalName
364 email
365 avatar
366 }
367 }
368 """
369
370 private struct MeResponse: Decodable {
371 let me: User
372 }
373
374 private func fetchMe() async throws -> User {
375 try await fetchMe(using: client)
376 }
377
378 private func fetchMe(using srhtClient: SRHTClient) async throws -> User {
379 let result = try await srhtClient.execute(
380 service: .meta,
381 query: Self.meQuery,
382 responseType: MeResponse.self
383 )
384 return result.me
385 }
386
387 // MARK: - Deep link queries
388
389 private static let repoLookupQuery = """
390 query repoLookup($owner: String!, $name: String!) {
391 user(username: $owner) {
392 repository(name: $name) {
393 id rid name description visibility updated
394 owner { canonicalName }
395 HEAD { name target }
396 }
397 }
398 }
399 """
400
401 private struct RepoLookupResponse: Decodable, Sendable {
402 let user: RepoLookupUser
403 }
404
405 private struct RepoLookupUser: Decodable, Sendable {
406 let repository: RepositorySummary
407 }
408
409 private static let trackerLookupQuery = """
410 query trackerLookup($owner: String!, $name: String!) {
411 user(username: $owner) {
412 tracker(name: $name) {
413 id rid name description visibility updated
414 owner { canonicalName }
415 }
416 }
417 }
418 """
419
420 private struct TrackerLookupResponse: Decodable, Sendable {
421 let user: TrackerLookupUser
422 }
423
424 private struct TrackerLookupUser: Decodable, Sendable {
425 let tracker: TrackerSummary
426 }
427
428 private func addValidatedAccount(token: String, activateNewAccount: Bool) async throws {
429 let tempClient = SRHTClient(token: token)
430 let user = try await fetchMe(using: tempClient)
431
432 if let existing = accounts.first(where: {
433 $0.username.caseInsensitiveCompare(user.username) == .orderedSame || $0.token == token
434 }) {
435 _ = existing
436 throw AppStateError.duplicateAccount(username: user.username)
437 }
438
439 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
440 accounts.append(entry)
441 try KeychainHelper.saveAccounts(accounts)
442
443 guard activateNewAccount else { return }
444 let session = try await makeSession(for: entry, knownUser: user)
445 authStatusMessage = "Switching Accounts…"
446 authPhase = .launching
447 sessionIdentity = UUID()
448 activate(session)
449 resetNavigationState()
450 authPhase = .authenticated
451 await refreshNeedsAttentionSnapshot()
452 }
453
454 private func makeSession(for account: AccountEntry, knownUser: User? = nil) async throws -> AccountSession {
455 let sessionClient = SRHTClient(
456 token: account.token,
457 cache: PersistentAPICache(configuration: .accountScoped(accountID: account.id))
458 )
459 let user: User
460 if let knownUser {
461 user = knownUser
462 } else {
463 user = try await fetchMe(using: sessionClient)
464 }
465 let defaults = AccountDefaultsStore.userDefaults(for: account.id)
466 let repository = SystemStatusRepository(cacheStore: SystemStatusCacheStore(defaults: defaults))
467 return AccountSession(
468 account: account,
469 user: user,
470 client: sessionClient,
471 defaults: defaults,
472 systemStatusRepository: repository
473 )
474 }
475
476 private func activate(_ session: AccountSession) {
477 activeSession = session
478 client = session.client
479 systemStatusRepository = session.systemStatusRepository
480 currentUser = session.user
481 activeAccountID = session.account.id
482 UserDefaults.standard.set(session.account.id, forKey: AppStorageKeys.activeAccountID)
483 ActiveAccountContextStore.save(session.account.id)
484 ContributionWidgetContextStore.saveActor(session.user.canonicalName, accountID: session.account.id)
485 authStatusMessage = "Connecting…"
486 }
487
488 private func clearActiveSessionState() {
489 client = SRHTClient()
490 systemStatusRepository = SystemStatusRepository()
491 activeSession = nil
492 activeAccountID = ""
493 UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID)
494 ActiveAccountContextStore.clear()
495 currentUser = nil
496 sessionIdentity = UUID()
497 resetNavigationState()
498 }
499
500 private func resetNavigationState() {
501 pendingDeepLink = nil
502 pendingTabNavigation = nil
503 deepLinkError = nil
504 selectedTab = .home
505 }
506
507 private func clearAccountArtifacts(for accountID: String) {
508 AccountDefaultsStore.clear(accountID: accountID)
509 ContributionWidgetContextStore.clear(accountID: accountID)
510 NeedsAttentionSnapshotStore.clear(accountID: accountID)
511 SystemStatusWidgetSnapshotStore.clear(accountID: accountID)
512 }
513
514 private func clearAllAccountArtifacts() {
515 for account in accounts {
516 clearAccountArtifacts(for: account.id)
517 }
518 ContributionWidgetContextStore.clear(accountID: nil)
519 NeedsAttentionSnapshotStore.clear(accountID: nil)
520 SystemStatusWidgetSnapshotStore.clear(accountID: nil)
521 ActiveAccountContextStore.clear()
522 accounts = []
523 }
524
525 private func prioritizedAccounts(_ accounts: [AccountEntry], preferredID: String) -> [AccountEntry] {
526 guard let preferred = accounts.first(where: { $0.id == preferredID }) else { return accounts }
527 return [preferred] + accounts.filter { $0.id != preferredID }
528 }
529
530 private func refreshNeedsAttentionSnapshot() async {
531 guard let currentUser else {
532 NeedsAttentionSnapshotStore.clear(accountID: activeAccountID)
533 return
534 }
535
536 let viewModel = HomeViewModel(
537 currentUser: currentUser,
538 client: client,
539 systemStatusRepository: systemStatusRepository,
540 defaults: accountDefaults,
541 accountID: activeAccountID
542 )
543 await viewModel.loadDashboard()
544 }
545
546 private func clearWebData() async {
547 await withCheckedContinuation { continuation in
548 let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
549 let since = Date(timeIntervalSince1970: 0)
550 WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: since) {
551 continuation.resume()
552 }
553 }
554 }
555
556 private func showCopyConfirmation(message: String) {
557 copyConfirmationTask?.cancel()
558 copyConfirmationMessage = message
559 copyConfirmationTask = Task { @MainActor in
560 try? await Task.sleep(for: .seconds(1.6))
561 guard !Task.isCancelled else { return }
562 copyConfirmationMessage = nil
563 copyConfirmationTask = nil
564 }
565 }
566}
567
568enum AppStateError: LocalizedError {
569 case duplicateAccount(username: String)
570
571 var errorDescription: String? {
572 switch self {
573 case .duplicateAccount(let username):
574 "The account ~\(username) is already saved."
575 }
576 }
577}