krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.8.2: 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}