krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.2.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        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}