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}