krz/hutch

an ios client for sourcehut

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

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