Hutch/App/AppState.swift
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}