krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.3: 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 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
216 await clearWebData()
217 clearWebContentRenderCaches()
218 clearAllAccountArtifacts()
219 authPhase = .unauthenticated
220 selectedTab = .home
221 dismissCopyConfirmation()
222 }
223
224 func resetAppData() async {
225 clearActiveSessionState()
226
227 if let bundleIdentifier = Bundle.main.bundleIdentifier {
228 UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
229 }
230 for account in accounts {
231 AccountDefaultsStore.clear(accountID: account.id)
232 }
233 try? KeychainHelper.deleteAll()
234 URLCache.shared.removeAllCachedResponses()
235 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
236 await clearWebData()
237 clearWebContentRenderCaches()
238 clearAllAccountArtifacts()
239
240 authPhase = .unauthenticated
241 selectedTab = .home
242 isDebugModeEnabled = false
243 dismissCopyConfirmation()
244 }
245
246 func copyToPasteboard(_ value: String, label: String) {
247 UIPasteboard.general.string = value
248 showCopyConfirmation(message: "Copied \(label)")
249 }
250
251 func dismissCopyConfirmation() {
252 copyConfirmationTask?.cancel()
253 copyConfirmationTask = nil
254 copyConfirmationMessage = nil
255 }
256
257 // MARK: - Deep link resolution
258
259 /// Resolve a repository by owner and name for deep linking.
260 func resolveRepository(owner: String, name: String, service: SRHTService = .git) async throws -> RepositorySummary {
261 let result = try await client.execute(
262 service: service,
263 query: Self.repoLookupQuery,
264 variables: ["owner": owner, "name": name],
265 responseType: RepoLookupResponse.self
266 )
267 return result.user.repository
268 }
269
270 /// Resolve a tracker by owner and name for deep linking.
271 func resolveTracker(owner: String, name: String) async throws -> TrackerSummary {
272 let result = try await client.execute(
273 service: .todo,
274 query: Self.trackerLookupQuery,
275 variables: ["owner": owner, "name": name],
276 responseType: TrackerLookupResponse.self
277 )
278 return result.user.tracker
279 }
280
281 func resolveProjectSource(_ source: Project.SourceRepo) async throws -> RepositorySummary {
282 try await resolveRepository(
283 owner: source.ownerUsername,
284 name: source.name,
285 service: source.repoType.service
286 )
287 }
288
289 func resolveProjectTracker(_ tracker: Project.Tracker) async throws -> TrackerSummary {
290 try await resolveTracker(owner: tracker.ownerUsername, name: tracker.name)
291 }
292
293 func openProjectSource(_ source: Project.SourceRepo) async throws {
294 let repository = try await resolveProjectSource(source)
295 navigateToRepository(repository)
296 }
297
298 func openProjectTracker(_ tracker: Project.Tracker) async throws {
299 let resolvedTracker = try await resolveProjectTracker(tracker)
300 navigateToTracker(resolvedTracker)
301 }
302
303 func openMailingList(_ mailingList: InboxMailingListReference) {
304 navigateToMailingList(mailingList)
305 }
306
307 func openSystemStatus() {
308 navigateToSystemStatus()
309 }
310
311 func navigateToRepository(_ repository: RepositorySummary) {
312 pendingTabNavigation = .repository(repository)
313 selectedTab = .repositories
314 }
315
316 func navigateToTracker(_ tracker: TrackerSummary) {
317 pendingTabNavigation = .tracker(tracker)
318 selectedTab = .tickets
319 }
320
321 func navigateToBuild(jobId: Int) {
322 pendingDeepLink = .build(jobId: jobId)
323 selectedTab = .builds
324 }
325
326 func navigateToTicket(ownerUsername: String, trackerName: String, ticketId: Int) {
327 pendingDeepLink = .ticket(owner: ownerUsername, tracker: trackerName, ticketId: ticketId)
328 selectedTab = .tickets
329 }
330
331 func navigateToMailingList(_ mailingList: InboxMailingListReference) {
332 pendingTabNavigation = .mailingList(mailingList)
333 selectedTab = .more
334 }
335
336 func navigateToSystemStatus() {
337 pendingTabNavigation = .systemStatus
338 selectedTab = .more
339 }
340
341 func navigateToBuildsList() {
342 pendingTabNavigation = .builds
343 selectedTab = .builds
344 }
345
346 func presentRepositoryDeepLinkError() {
347 deepLinkError = "The repository could not be found or is inaccessible."
348 }
349
350 func presentTicketDeepLinkError() {
351 deepLinkError = "The ticket could not be found or is inaccessible."
352 }
353
354 // MARK: - Private
355
356 private static let meQuery = """
357 {
358 me {
359 id
360 username
361 canonicalName
362 email
363 avatar
364 }
365 }
366 """
367
368 private struct MeResponse: Decodable {
369 let me: User
370 }
371
372 private func fetchMe() async throws -> User {
373 try await fetchMe(using: client)
374 }
375
376 private func fetchMe(using srhtClient: SRHTClient) async throws -> User {
377 let result = try await srhtClient.execute(
378 service: .meta,
379 query: Self.meQuery,
380 responseType: MeResponse.self
381 )
382 return result.me
383 }
384
385 // MARK: - Deep link queries
386
387 private static let repoLookupQuery = """
388 query repoLookup($owner: String!, $name: String!) {
389 user(username: $owner) {
390 repository(name: $name) {
391 id rid name description visibility updated
392 owner { canonicalName }
393 HEAD { name target }
394 }
395 }
396 }
397 """
398
399 private struct RepoLookupResponse: Decodable, Sendable {
400 let user: RepoLookupUser
401 }
402
403 private struct RepoLookupUser: Decodable, Sendable {
404 let repository: RepositorySummary
405 }
406
407 private static let trackerLookupQuery = """
408 query trackerLookup($owner: String!, $name: String!) {
409 user(username: $owner) {
410 tracker(name: $name) {
411 id rid name description visibility updated
412 owner { canonicalName }
413 }
414 }
415 }
416 """
417
418 private struct TrackerLookupResponse: Decodable, Sendable {
419 let user: TrackerLookupUser
420 }
421
422 private struct TrackerLookupUser: Decodable, Sendable {
423 let tracker: TrackerSummary
424 }
425
426 private func addValidatedAccount(token: String, activateNewAccount: Bool) async throws {
427 let tempClient = SRHTClient(token: token)
428 let user = try await fetchMe(using: tempClient)
429
430 if let existing = accounts.first(where: {
431 $0.username.caseInsensitiveCompare(user.username) == .orderedSame || $0.token == token
432 }) {
433 _ = existing
434 throw AppStateError.duplicateAccount(username: user.username)
435 }
436
437 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
438 accounts.append(entry)
439 try KeychainHelper.saveAccounts(accounts)
440
441 guard activateNewAccount else { return }
442 let session = try await makeSession(for: entry, knownUser: user)
443 authStatusMessage = "Switching Accounts…"
444 authPhase = .launching
445 sessionIdentity = UUID()
446 activate(session)
447 resetNavigationState()
448 authPhase = .authenticated
449 await refreshNeedsAttentionSnapshot()
450 }
451
452 private func makeSession(for account: AccountEntry, knownUser: User? = nil) async throws -> AccountSession {
453 let sessionClient = SRHTClient(token: account.token)
454 let user: User
455 if let knownUser {
456 user = knownUser
457 } else {
458 user = try await fetchMe(using: sessionClient)
459 }
460 let defaults = AccountDefaultsStore.userDefaults(for: account.id)
461 let repository = SystemStatusRepository(cacheStore: SystemStatusCacheStore(defaults: defaults))
462 return AccountSession(
463 account: account,
464 user: user,
465 client: sessionClient,
466 defaults: defaults,
467 systemStatusRepository: repository
468 )
469 }
470
471 private func activate(_ session: AccountSession) {
472 activeSession = session
473 client = session.client
474 systemStatusRepository = session.systemStatusRepository
475 currentUser = session.user
476 activeAccountID = session.account.id
477 UserDefaults.standard.set(session.account.id, forKey: AppStorageKeys.activeAccountID)
478 ActiveAccountContextStore.save(session.account.id)
479 ContributionWidgetContextStore.saveActor(session.user.canonicalName, accountID: session.account.id)
480 authStatusMessage = "Connecting…"
481 }
482
483 private func clearActiveSessionState() {
484 client = SRHTClient()
485 systemStatusRepository = SystemStatusRepository()
486 activeSession = nil
487 activeAccountID = ""
488 UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID)
489 ActiveAccountContextStore.clear()
490 currentUser = nil
491 sessionIdentity = UUID()
492 resetNavigationState()
493 }
494
495 private func resetNavigationState() {
496 pendingDeepLink = nil
497 pendingTabNavigation = nil
498 deepLinkError = nil
499 selectedTab = .home
500 }
501
502 private func clearAccountArtifacts(for accountID: String) {
503 AccountDefaultsStore.clear(accountID: accountID)
504 ContributionWidgetContextStore.clear(accountID: accountID)
505 NeedsAttentionSnapshotStore.clear(accountID: accountID)
506 SystemStatusWidgetSnapshotStore.clear(accountID: accountID)
507 }
508
509 private func clearAllAccountArtifacts() {
510 for account in accounts {
511 clearAccountArtifacts(for: account.id)
512 }
513 ContributionWidgetContextStore.clear(accountID: nil)
514 NeedsAttentionSnapshotStore.clear(accountID: nil)
515 SystemStatusWidgetSnapshotStore.clear(accountID: nil)
516 ActiveAccountContextStore.clear()
517 accounts = []
518 }
519
520 private func prioritizedAccounts(_ accounts: [AccountEntry], preferredID: String) -> [AccountEntry] {
521 guard let preferred = accounts.first(where: { $0.id == preferredID }) else { return accounts }
522 return [preferred] + accounts.filter { $0.id != preferredID }
523 }
524
525 private func refreshNeedsAttentionSnapshot() async {
526 guard let currentUser else {
527 NeedsAttentionSnapshotStore.clear(accountID: activeAccountID)
528 return
529 }
530
531 let viewModel = HomeViewModel(
532 currentUser: currentUser,
533 client: client,
534 systemStatusRepository: systemStatusRepository,
535 defaults: accountDefaults,
536 accountID: activeAccountID
537 )
538 await viewModel.loadDashboard()
539 }
540
541 private func clearWebData() async {
542 await withCheckedContinuation { continuation in
543 let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
544 let since = Date(timeIntervalSince1970: 0)
545 WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: since) {
546 continuation.resume()
547 }
548 }
549 }
550
551 private func showCopyConfirmation(message: String) {
552 copyConfirmationTask?.cancel()
553 copyConfirmationMessage = message
554 copyConfirmationTask = Task { @MainActor in
555 try? await Task.sleep(for: .seconds(1.6))
556 guard !Task.isCancelled else { return }
557 copyConfirmationMessage = nil
558 copyConfirmationTask = nil
559 }
560 }
561}
562
563enum AppStateError: LocalizedError {
564 case duplicateAccount(username: String)
565
566 var errorDescription: String? {
567 switch self {
568 case .duplicateAccount(let username):
569 "The account ~\(username) is already saved."
570 }
571 }
572}