krz/hutch

an ios client for sourcehut

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

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