Hutch/App/AppState.swift

b5af561d68e7535726b79d4ceec0d0a45abf0042
hutch/Hutch/App/AppState.swift history · blame · raw

222 lines · 6385 bytes

  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 AuthPhase {
 11        /// App just launched, checking for an existing token.
 12        case launching
 13        /// No valid token — show the token entry screen.
 14        case unauthenticated
 15        /// Token validated, user is signed in.
 16        case authenticated
 17    }
 18
 19    // MARK: - Authentication
 20
 21    private(set) var authPhase: AuthPhase = .launching
 22
 23    /// Convenience for views that need a simple bool.
 24    var isAuthenticated: Bool {
 25        authPhase == .authenticated && currentUser != nil
 26    }
 27
 28    // MARK: - Current user (populated after successful validation)
 29
 30    private(set) var currentUser: User?
 31
 32    // MARK: - Networking
 33
 34    let client: SRHTClient
 35
 36    // MARK: - Deep link pending navigation
 37
 38    /// Set by the deep link handler; consumed by RootView to drive navigation.
 39    var pendingDeepLink: DeepLink?
 40
 41    // MARK: - Init
 42
 43    init() {
 44        let token = KeychainHelper.loadToken()
 45        self.client = SRHTClient(token: token)
 46    }
 47
 48    // MARK: - Launch validation
 49
 50    /// Called once at app launch. If a token exists in Keychain, validates it
 51    /// silently. On failure, clears the token and falls through to unauthenticated.
 52    func validateOnLaunch() async {
 53        guard client.hasToken else {
 54            authPhase = .unauthenticated
 55            return
 56        }
 57
 58        do {
 59            let user = try await fetchMe()
 60            currentUser = user
 61            authPhase = .authenticated
 62        } catch {
 63            try? KeychainHelper.deleteToken()
 64            client.setToken(nil)
 65            currentUser = nil
 66            authPhase = .unauthenticated
 67        }
 68    }
 69
 70    // MARK: - Token management
 71
 72    /// Validate a new token by querying meta.sr.ht, then persist it.
 73    /// Throws on network/GraphQL errors so the caller can display the message.
 74    func connect(with token: String) async throws {
 75        // Temporarily set the token so the client can use it for the request.
 76        client.setToken(token)
 77
 78        do {
 79            let user = try await fetchMe()
 80            try KeychainHelper.saveToken(token)
 81            currentUser = user
 82            authPhase = .authenticated
 83        } catch {
 84            // Roll back — don't leave an invalid token in the client.
 85            client.setToken(nil)
 86            throw error
 87        }
 88    }
 89
 90    func signOut() async {
 91        clearSessionState()
 92        URLCache.shared.removeAllCachedResponses()
 93        HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
 94        await clearWebData()
 95        clearWebContentRenderCaches()
 96        authPhase = .unauthenticated
 97    }
 98
 99    func resetAppData() async {
100        clearSessionState()
101
102        if let bundleIdentifier = Bundle.main.bundleIdentifier {
103            UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
104        }
105        URLCache.shared.removeAllCachedResponses()
106        HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
107        await clearWebData()
108        clearWebContentRenderCaches()
109
110        authPhase = .unauthenticated
111    }
112
113    // MARK: - Deep link resolution
114
115    /// Resolve a repository by owner and name for deep linking.
116    func resolveRepository(owner: String, name: String) async throws -> RepositorySummary {
117        let result = try await client.execute(
118            service: .git,
119            query: Self.repoLookupQuery,
120            variables: ["owner": owner, "name": name],
121            responseType: RepoLookupResponse.self
122        )
123        return result.user.repository
124    }
125
126    /// Resolve a tracker by owner and name for deep linking.
127    func resolveTracker(owner: String, name: String) async throws -> TrackerSummary {
128        let result = try await client.execute(
129            service: .todo,
130            query: Self.trackerLookupQuery,
131            variables: ["owner": owner, "name": name],
132            responseType: TrackerLookupResponse.self
133        )
134        return result.user.tracker
135    }
136
137    // MARK: - Private
138
139    private static let meQuery = """
140    {
141        me {
142            id
143            username
144            canonicalName
145            email
146            avatar
147        }
148    }
149    """
150
151    private struct MeResponse: Decodable {
152        let me: User
153    }
154
155    private func fetchMe() async throws -> User {
156        let result = try await client.execute(
157            service: .meta,
158            query: Self.meQuery,
159            responseType: MeResponse.self
160        )
161        return result.me
162    }
163
164    // MARK: - Deep link queries
165
166    private static let repoLookupQuery = """
167    query repoLookup($owner: String!, $name: String!) {
168        user(username: $owner) {
169            repository(name: $name) {
170                id rid name description visibility updated
171                owner { canonicalName }
172                HEAD { name target }
173            }
174        }
175    }
176    """
177
178    private struct RepoLookupResponse: Decodable, Sendable {
179        let user: RepoLookupUser
180    }
181
182    private struct RepoLookupUser: Decodable, Sendable {
183        let repository: RepositorySummary
184    }
185
186    private static let trackerLookupQuery = """
187    query trackerLookup($owner: String!, $name: String!) {
188        user(username: $owner) {
189            tracker(name: $name) {
190                id rid name description visibility updated
191                owner { canonicalName }
192            }
193        }
194    }
195    """
196
197    private struct TrackerLookupResponse: Decodable, Sendable {
198        let user: TrackerLookupUser
199    }
200
201    private struct TrackerLookupUser: Decodable, Sendable {
202        let tracker: TrackerSummary
203    }
204
205    private func clearSessionState() {
206        try? KeychainHelper.deleteAll()
207        client.setToken(nil)
208        client.responseCache.clear()
209        currentUser = nil
210        pendingDeepLink = nil
211    }
212
213    private func clearWebData() async {
214        await withCheckedContinuation { continuation in
215            let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
216            let since = Date(timeIntervalSince1970: 0)
217            WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: since) {
218                continuation.resume()
219            }
220        }
221    }
222}