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}