krz/hutch

an ios client for sourcehut

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

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