krz/hutch

an ios client for sourcehut

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

v2.16.0: Hutch/App/AppState.swift · raw

  1import Foundation
  2import SwiftUI
  3import WebKit
  4
  5/// Central application state shared across the view hierarchy.
  6@Observable
  7@MainActor
  8final class AppState {
  9
 10    enum Tab: Hashable {
 11        case home
 12        case repositories
 13        case tickets
 14        case builds
 15        case more
 16    }
 17
 18    enum TabNavigationTarget: Hashable {
 19        case repository(RepositorySummary)
 20        case tracker(TrackerSummary)
 21        case mailingList(InboxMailingListReference)
 22        case systemStatus
 23        case builds
 24    }
 25
 26    enum AuthPhase {
 27        /// App just launched, checking for an existing token.
 28        case launching
 29        /// No valid token  show the token entry screen.
 30        case unauthenticated
 31        /// Token validated, user is signed in.
 32        case authenticated
 33    }
 34
 35    // MARK: - Authentication
 36
 37    private(set) var authPhase: AuthPhase = .launching
 38
 39    /// Convenience for views that need a simple bool.
 40    var isAuthenticated: Bool {
 41        authPhase == .authenticated && currentUser != nil
 42    }
 43
 44    // MARK: - Multi-account
 45
 46    /// All stored accounts. Loaded from Keychain; kept in sync on add/remove/switch.
 47    private(set) var accounts: [AccountEntry] = []
 48
 49    /// The ID of the account currently in use. Persisted in UserDefaults.
 50    private(set) var activeAccountID: String = ""
 51
 52    var selectedTab: Tab = .home
 53
 54    // MARK: - Current user (populated after successful validation)
 55
 56    private(set) var currentUser: User?
 57
 58    // MARK: - Networking
 59
 60    let client: SRHTClient
 61    let configuration: AppConfiguration
 62    let systemStatusRepository: SystemStatusRepository
 63
 64    // MARK: - Deep link pending navigation
 65
 66    /// Set by the deep link handler; consumed by RootView to drive navigation.
 67    var pendingDeepLink: DeepLink?
 68    var pendingTabNavigation: TabNavigationTarget?
 69    var deepLinkError: String?
 70
 71    // MARK: - Init
 72
 73    init() {
 74        self.configuration = AppConfiguration()
 75        let token = KeychainHelper.loadToken()
 76        self.client = SRHTClient(token: token)
 77        self.systemStatusRepository = SystemStatusRepository()
 78    }
 79
 80    // MARK: - Launch validation
 81
 82    /// Called once at app launch. If a token exists in Keychain, validates it
 83    /// silently. On failure, clears the token and falls through to unauthenticated.
 84    func validateOnLaunch() async {
 85        var storedAccounts = KeychainHelper.loadAccounts()
 86
 87        if storedAccounts.isEmpty, let legacyToken = KeychainHelper.loadToken() {
 88            client.setToken(legacyToken)
 89            if let user = try? await fetchMe() {
 90                let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: legacyToken)
 91                storedAccounts = [entry]
 92                try? KeychainHelper.saveAccounts(storedAccounts)
 93                try? KeychainHelper.deleteToken()
 94            } else {
 95                try? KeychainHelper.deleteToken()
 96                client.setToken(nil)
 97                authPhase = .unauthenticated
 98                return
 99            }
100        }
101
102        guard !storedAccounts.isEmpty else {
103            authPhase = .unauthenticated
104            return
105        }
106
107        let savedID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? ""
108        let target = storedAccounts.first(where: { $0.id == savedID }) ?? storedAccounts[0]
109
110        client.setToken(target.token)
111        do {
112            let user = try await fetchMe()
113            accounts = storedAccounts
114            activeAccountID = target.id
115            currentUser = user
116            ContributionWidgetContextStore.saveActor(user.canonicalName)
117            authPhase = .authenticated
118            await refreshNeedsAttentionSnapshot()
119        } catch {
120            client.setToken(nil)
121            currentUser = nil
122            ContributionWidgetContextStore.clear()
123            authPhase = .unauthenticated
124            NeedsAttentionSnapshotStore.clear()
125        }
126    }
127
128    // MARK: - Token management
129
130    /// Validate a new token by querying meta.sr.ht, then persist it.
131    /// Throws on network/GraphQL errors so the caller can display the message.
132    func connect(with token: String) async throws {
133        client.setToken(token)
134        do {
135            let user = try await fetchMe()
136            let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
137            accounts.append(entry)
138            activeAccountID = entry.id
139            UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID)
140            try KeychainHelper.saveAccounts(accounts)
141            currentUser = user
142            ContributionWidgetContextStore.saveActor(user.canonicalName)
143            authPhase = .authenticated
144            await refreshNeedsAttentionSnapshot()
145        } catch {
146            client.setToken(nil)
147            throw error
148        }
149    }
150
151    /// Validate a new token, add it as an account, and switch to it immediately.
152    func addAccount(token: String) async throws {
153        let tempClient = SRHTClient(token: token)
154        let user = try await fetchMe(using: tempClient)
155        let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
156        accounts.append(entry)
157        try KeychainHelper.saveAccounts(accounts)
158        try await switchAccount(to: entry.id)
159    }
160
161    /// Switch the active account and fully refresh the app.
162    func switchAccount(to id: String) async throws {
163        guard let entry = accounts.first(where: { $0.id == id }) else { return }
164
165        client.responseCache.clear()
166        currentUser = nil
167        pendingDeepLink = nil
168        pendingTabNavigation = nil
169        deepLinkError = nil
170        selectedTab = .home
171
172        authPhase = .unauthenticated
173
174        client.setToken(entry.token)
175        activeAccountID = entry.id
176        UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID)
177
178        let user = try await fetchMe()
179        currentUser = user
180        ContributionWidgetContextStore.saveActor(user.canonicalName)
181        authPhase = .authenticated
182        await refreshNeedsAttentionSnapshot()
183    }
184
185    /// Remove a stored account. Switches to another account if the removed account
186    /// was active; signs out fully if it was the last account.
187    func removeAccount(id: String) async {
188        accounts.removeAll { $0.id == id }
189        try? KeychainHelper.saveAccounts(accounts)
190
191        guard id == activeAccountID else { return }
192
193        if let next = accounts.first {
194            try? await switchAccount(to: next.id)
195        } else {
196            await signOut()
197        }
198    }
199
200    func signOut() async {
201        clearSessionState()
202        URLCache.shared.removeAllCachedResponses()
203        HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
204        await clearWebData()
205        clearWebContentRenderCaches()
206        NeedsAttentionSnapshotStore.clear()
207        SystemStatusWidgetSnapshotStore.clear()
208        authPhase = .unauthenticated
209        selectedTab = .home
210    }
211
212    func resetAppData() async {
213        clearSessionState()
214
215        if let bundleIdentifier = Bundle.main.bundleIdentifier {
216            UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
217        }
218        URLCache.shared.removeAllCachedResponses()
219        HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
220        await clearWebData()
221        clearWebContentRenderCaches()
222        NeedsAttentionSnapshotStore.clear()
223        SystemStatusWidgetSnapshotStore.clear()
224
225        authPhase = .unauthenticated
226        selectedTab = .home
227    }
228
229    // MARK: - Deep link resolution
230
231    /// Resolve a repository by owner and name for deep linking.
232    func resolveRepository(owner: String, name: String, service: SRHTService = .git) async throws -> RepositorySummary {
233        let result = try await client.execute(
234            service: service,
235            query: Self.repoLookupQuery,
236            variables: ["owner": owner, "name": name],
237            responseType: RepoLookupResponse.self
238        )
239        return result.user.repository
240    }
241
242    /// Resolve a tracker by owner and name for deep linking.
243    func resolveTracker(owner: String, name: String) async throws -> TrackerSummary {
244        let result = try await client.execute(
245            service: .todo,
246            query: Self.trackerLookupQuery,
247            variables: ["owner": owner, "name": name],
248            responseType: TrackerLookupResponse.self
249        )
250        return result.user.tracker
251    }
252
253    func resolveProjectSource(_ source: Project.SourceRepo) async throws -> RepositorySummary {
254        try await resolveRepository(
255            owner: source.ownerUsername,
256            name: source.name,
257            service: source.repoType.service
258        )
259    }
260
261    func resolveProjectTracker(_ tracker: Project.Tracker) async throws -> TrackerSummary {
262        try await resolveTracker(owner: tracker.ownerUsername, name: tracker.name)
263    }
264
265    func openProjectSource(_ source: Project.SourceRepo) async throws {
266        let repository = try await resolveProjectSource(source)
267        navigateToRepository(repository)
268    }
269
270    func openProjectTracker(_ tracker: Project.Tracker) async throws {
271        let resolvedTracker = try await resolveProjectTracker(tracker)
272        navigateToTracker(resolvedTracker)
273    }
274
275    func openMailingList(_ mailingList: InboxMailingListReference) {
276        navigateToMailingList(mailingList)
277    }
278
279    func openSystemStatus() {
280        navigateToSystemStatus()
281    }
282
283    func navigateToRepository(_ repository: RepositorySummary) {
284        pendingTabNavigation = .repository(repository)
285        selectedTab = .repositories
286    }
287
288    func navigateToTracker(_ tracker: TrackerSummary) {
289        pendingTabNavigation = .tracker(tracker)
290        selectedTab = .tickets
291    }
292
293    func navigateToBuild(jobId: Int) {
294        pendingDeepLink = .build(jobId: jobId)
295        selectedTab = .builds
296    }
297
298    func navigateToTicket(ownerUsername: String, trackerName: String, ticketId: Int) {
299        pendingDeepLink = .ticket(owner: ownerUsername, tracker: trackerName, ticketId: ticketId)
300        selectedTab = .tickets
301    }
302
303    func navigateToMailingList(_ mailingList: InboxMailingListReference) {
304        pendingTabNavigation = .mailingList(mailingList)
305        selectedTab = .more
306    }
307
308    func navigateToSystemStatus() {
309        pendingTabNavigation = .systemStatus
310        selectedTab = .more
311    }
312
313    func navigateToBuildsList() {
314        pendingTabNavigation = .builds
315        selectedTab = .builds
316    }
317
318    func presentRepositoryDeepLinkError() {
319        deepLinkError = "The repository could not be found or is inaccessible."
320    }
321
322    func presentTicketDeepLinkError() {
323        deepLinkError = "The ticket could not be found or is inaccessible."
324    }
325
326    // MARK: - Private
327
328    private static let meQuery = """
329    {
330        me {
331            id
332            username
333            canonicalName
334            email
335            avatar
336        }
337    }
338    """
339
340    private struct MeResponse: Decodable {
341        let me: User
342    }
343
344    private func fetchMe() async throws -> User {
345        try await fetchMe(using: client)
346    }
347
348    private func fetchMe(using srhtClient: SRHTClient) async throws -> User {
349        let result = try await srhtClient.execute(
350            service: .meta,
351            query: Self.meQuery,
352            responseType: MeResponse.self
353        )
354        return result.me
355    }
356
357    // MARK: - Deep link queries
358
359    private static let repoLookupQuery = """
360    query repoLookup($owner: String!, $name: String!) {
361        user(username: $owner) {
362            repository(name: $name) {
363                id rid name description visibility updated
364                owner { canonicalName }
365                HEAD { name target }
366            }
367        }
368    }
369    """
370
371    private struct RepoLookupResponse: Decodable, Sendable {
372        let user: RepoLookupUser
373    }
374
375    private struct RepoLookupUser: Decodable, Sendable {
376        let repository: RepositorySummary
377    }
378
379    private static let trackerLookupQuery = """
380    query trackerLookup($owner: String!, $name: String!) {
381        user(username: $owner) {
382            tracker(name: $name) {
383                id rid name description visibility updated
384                owner { canonicalName }
385            }
386        }
387    }
388    """
389
390    private struct TrackerLookupResponse: Decodable, Sendable {
391        let user: TrackerLookupUser
392    }
393
394    private struct TrackerLookupUser: Decodable, Sendable {
395        let tracker: TrackerSummary
396    }
397
398    private func clearSessionState() {
399        try? KeychainHelper.deleteAll()
400        client.setToken(nil)
401        client.responseCache.clear()
402        accounts = []
403        activeAccountID = ""
404        UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID)
405        currentUser = nil
406        ContributionWidgetContextStore.clear()
407        pendingDeepLink = nil
408        pendingTabNavigation = nil
409        deepLinkError = nil
410        selectedTab = .home
411    }
412
413    private func refreshNeedsAttentionSnapshot() async {
414        guard let currentUser else {
415            NeedsAttentionSnapshotStore.clear()
416            return
417        }
418
419        let viewModel = HomeViewModel(
420            currentUser: currentUser,
421            client: client,
422            systemStatusRepository: systemStatusRepository
423        )
424        await viewModel.loadDashboard()
425    }
426
427    private func clearWebData() async {
428        await withCheckedContinuation { continuation in
429            let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
430            let since = Date(timeIntervalSince1970: 0)
431            WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: since) {
432                continuation.resume()
433            }
434        }
435    }
436}