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