diff options
Diffstat (limited to 'Hutch/App/AppState.swift')
| -rw-r--r-- | Hutch/App/AppState.swift | 222 |
1 files changed, 222 insertions, 0 deletions
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift new file mode 100644 index 0000000..624c78d --- /dev/null +++ b/Hutch/App/AppState.swift @@ -0,0 +1,222 @@ +import Foundation +import SwiftUI +import WebKit + +/// Central application state shared across the view hierarchy. +@Observable +@MainActor +final class AppState { + + enum AuthPhase { + /// App just launched, checking for an existing token. + case launching + /// No valid token — show the token entry screen. + case unauthenticated + /// Token validated, user is signed in. + case authenticated + } + + // MARK: - Authentication + + private(set) var authPhase: AuthPhase = .launching + + /// Convenience for views that need a simple bool. + var isAuthenticated: Bool { + authPhase == .authenticated && currentUser != nil + } + + // MARK: - Current user (populated after successful validation) + + private(set) var currentUser: User? + + // MARK: - Networking + + let client: SRHTClient + + // MARK: - Deep link pending navigation + + /// Set by the deep link handler; consumed by RootView to drive navigation. + var pendingDeepLink: DeepLink? + + // MARK: - Init + + init() { + let token = KeychainHelper.loadToken() + self.client = SRHTClient(token: token) + } + + // MARK: - Launch validation + + /// Called once at app launch. If a token exists in Keychain, validates it + /// silently. On failure, clears the token and falls through to unauthenticated. + func validateOnLaunch() async { + guard client.hasToken else { + authPhase = .unauthenticated + return + } + + do { + let user = try await fetchMe() + currentUser = user + authPhase = .authenticated + } catch { + try? KeychainHelper.deleteToken() + client.setToken(nil) + currentUser = nil + authPhase = .unauthenticated + } + } + + // MARK: - Token management + + /// Validate a new token by querying meta.sr.ht, then persist it. + /// Throws on network/GraphQL errors so the caller can display the message. + func connect(with token: String) async throws { + // Temporarily set the token so the client can use it for the request. + client.setToken(token) + + do { + let user = try await fetchMe() + try KeychainHelper.saveToken(token) + currentUser = user + authPhase = .authenticated + } catch { + // Roll back — don't leave an invalid token in the client. + client.setToken(nil) + throw error + } + } + + func signOut() async { + clearSessionState() + URLCache.shared.removeAllCachedResponses() + HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } + await clearWebData() + clearWebContentRenderCaches() + authPhase = .unauthenticated + } + + func resetAppData() async { + clearSessionState() + + if let bundleIdentifier = Bundle.main.bundleIdentifier { + UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier) + } + URLCache.shared.removeAllCachedResponses() + HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } + await clearWebData() + clearWebContentRenderCaches() + + authPhase = .unauthenticated + } + + // MARK: - Deep link resolution + + /// Resolve a repository by owner and name for deep linking. + func resolveRepository(owner: String, name: String) async throws -> RepositorySummary { + let result = try await client.execute( + service: .git, + query: Self.repoLookupQuery, + variables: ["owner": owner, "name": name], + responseType: RepoLookupResponse.self + ) + return result.user.repository + } + + /// Resolve a tracker by owner and name for deep linking. + func resolveTracker(owner: String, name: String) async throws -> TrackerSummary { + let result = try await client.execute( + service: .todo, + query: Self.trackerLookupQuery, + variables: ["owner": owner, "name": name], + responseType: TrackerLookupResponse.self + ) + return result.user.tracker + } + + // MARK: - Private + + private static let meQuery = """ + { + me { + id + username + canonicalName + email + avatar + } + } + """ + + private struct MeResponse: Decodable { + let me: User + } + + private func fetchMe() async throws -> User { + let result = try await client.execute( + service: .meta, + query: Self.meQuery, + responseType: MeResponse.self + ) + return result.me + } + + // MARK: - Deep link queries + + private static let repoLookupQuery = """ + query repoLookup($owner: String!, $name: String!) { + user(username: $owner) { + repository(name: $name) { + id rid name description visibility updated + owner { canonicalName } + HEAD { name target } + } + } + } + """ + + private struct RepoLookupResponse: Decodable, Sendable { + let user: RepoLookupUser + } + + private struct RepoLookupUser: Decodable, Sendable { + let repository: RepositorySummary + } + + private static let trackerLookupQuery = """ + query trackerLookup($owner: String!, $name: String!) { + user(username: $owner) { + tracker(name: $name) { + id rid name description visibility updated + owner { canonicalName } + } + } + } + """ + + private struct TrackerLookupResponse: Decodable, Sendable { + let user: TrackerLookupUser + } + + private struct TrackerLookupUser: Decodable, Sendable { + let tracker: TrackerSummary + } + + private func clearSessionState() { + try? KeychainHelper.deleteAll() + client.setToken(nil) + client.responseCache.clear() + currentUser = nil + pendingDeepLink = nil + } + + private func clearWebData() async { + await withCheckedContinuation { continuation in + let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes() + let since = Date(timeIntervalSince1970: 0) + WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: since) { + continuation.resume() + } + } + } +} |
