krz/hutch

an ios client for sourcehut

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

v2.13.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        pendingTabNavigation = .repository(repository)
265        selectedTab = .repositories
266    }
267
268    func openProjectTracker(_ tracker: Project.Tracker) async throws {
269        let resolvedTracker = try await resolveProjectTracker(tracker)
270        pendingTabNavigation = .tracker(resolvedTracker)
271        selectedTab = .tickets
272    }
273
274    func openMailingList(_ mailingList: InboxMailingListReference) {
275        pendingTabNavigation = .mailingList(mailingList)
276        selectedTab = .more
277    }
278
279    func openSystemStatus() {
280        pendingTabNavigation = .systemStatus
281        selectedTab = .more
282    }
283
284    func presentRepositoryDeepLinkError() {
285        deepLinkError = "The repository could not be found or is inaccessible."
286    }
287
288    func presentTicketDeepLinkError() {
289        deepLinkError = "The ticket could not be found or is inaccessible."
290    }
291
292    // MARK: - Private
293
294    private static let meQuery = """
295    {
296        me {
297            id
298            username
299            canonicalName
300            email
301            avatar
302        }
303    }
304    """
305
306    private struct MeResponse: Decodable {
307        let me: User
308    }
309
310    private func fetchMe() async throws -> User {
311        try await fetchMe(using: client)
312    }
313
314    private func fetchMe(using srhtClient: SRHTClient) async throws -> User {
315        let result = try await srhtClient.execute(
316            service: .meta,
317            query: Self.meQuery,
318            responseType: MeResponse.self
319        )
320        return result.me
321    }
322
323    // MARK: - Deep link queries
324
325    private static let repoLookupQuery = """
326    query repoLookup($owner: String!, $name: String!) {
327        user(username: $owner) {
328            repository(name: $name) {
329                id rid name description visibility updated
330                owner { canonicalName }
331                HEAD { name target }
332            }
333        }
334    }
335    """
336
337    private struct RepoLookupResponse: Decodable, Sendable {
338        let user: RepoLookupUser
339    }
340
341    private struct RepoLookupUser: Decodable, Sendable {
342        let repository: RepositorySummary
343    }
344
345    private static let trackerLookupQuery = """
346    query trackerLookup($owner: String!, $name: String!) {
347        user(username: $owner) {
348            tracker(name: $name) {
349                id rid name description visibility updated
350                owner { canonicalName }
351            }
352        }
353    }
354    """
355
356    private struct TrackerLookupResponse: Decodable, Sendable {
357        let user: TrackerLookupUser
358    }
359
360    private struct TrackerLookupUser: Decodable, Sendable {
361        let tracker: TrackerSummary
362    }
363
364    private func clearSessionState() {
365        try? KeychainHelper.deleteAll()
366        client.setToken(nil)
367        client.responseCache.clear()
368        accounts = []
369        activeAccountID = ""
370        UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID)
371        currentUser = nil
372        ContributionWidgetContextStore.clear()
373        pendingDeepLink = nil
374        pendingTabNavigation = nil
375        deepLinkError = nil
376        selectedTab = .home
377    }
378
379    private func refreshNeedsAttentionSnapshot() async {
380        guard let currentUser else {
381            NeedsAttentionSnapshotStore.clear()
382            return
383        }
384
385        let viewModel = HomeViewModel(
386            currentUser: currentUser,
387            client: client,
388            systemStatusRepository: systemStatusRepository
389        )
390        await viewModel.loadDashboard()
391    }
392
393    private func clearWebData() async {
394        await withCheckedContinuation { continuation in
395            let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
396            let since = Date(timeIntervalSince1970: 0)
397            WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: since) {
398                continuation.resume()
399            }
400        }
401    }
402}