krz/hutch

an ios client for sourcehut

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

v3.10.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(awaitInboxUnread: true)
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}