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