krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.14.0: 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 case systemStatus
23 }
24
25 enum AuthPhase {
26 /// App just launched, checking for an existing token.
27 case launching
28 /// No valid token — show the token entry screen.
29 case unauthenticated
30 /// Token validated, user is signed in.
31 case authenticated
32 }
33
34 // MARK: - Authentication
35
36 private(set) var authPhase: AuthPhase = .launching
37
38 /// Convenience for views that need a simple bool.
39 var isAuthenticated: Bool {
40 authPhase == .authenticated && currentUser != nil
41 }
42
43 // MARK: - Multi-account
44
45 /// All stored accounts. Loaded from Keychain; kept in sync on add/remove/switch.
46 private(set) var accounts: [AccountEntry] = []
47
48 /// The ID of the account currently in use. Persisted in UserDefaults.
49 private(set) var activeAccountID: String = ""
50
51 var selectedTab: Tab = .home
52
53 // MARK: - Current user (populated after successful validation)
54
55 private(set) var currentUser: User?
56
57 // MARK: - Networking
58
59 let client: SRHTClient
60 let configuration: AppConfiguration
61 let systemStatusRepository: SystemStatusRepository
62
63 // MARK: - Deep link pending navigation
64
65 /// Set by the deep link handler; consumed by RootView to drive navigation.
66 var pendingDeepLink: DeepLink?
67 var pendingTabNavigation: TabNavigationTarget?
68 var deepLinkError: String?
69
70 // MARK: - Init
71
72 init() {
73 self.configuration = AppConfiguration()
74 let token = KeychainHelper.loadToken()
75 self.client = SRHTClient(token: token)
76 self.systemStatusRepository = SystemStatusRepository()
77 }
78
79 // MARK: - Launch validation
80
81 /// Called once at app launch. If a token exists in Keychain, validates it
82 /// silently. On failure, clears the token and falls through to unauthenticated.
83 func validateOnLaunch() async {
84 var storedAccounts = KeychainHelper.loadAccounts()
85
86 if storedAccounts.isEmpty, let legacyToken = KeychainHelper.loadToken() {
87 client.setToken(legacyToken)
88 if let user = try? await fetchMe() {
89 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: legacyToken)
90 storedAccounts = [entry]
91 try? KeychainHelper.saveAccounts(storedAccounts)
92 try? KeychainHelper.deleteToken()
93 } else {
94 try? KeychainHelper.deleteToken()
95 client.setToken(nil)
96 authPhase = .unauthenticated
97 return
98 }
99 }
100
101 guard !storedAccounts.isEmpty else {
102 authPhase = .unauthenticated
103 return
104 }
105
106 let savedID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? ""
107 let target = storedAccounts.first(where: { $0.id == savedID }) ?? storedAccounts[0]
108
109 client.setToken(target.token)
110 do {
111 let user = try await fetchMe()
112 accounts = storedAccounts
113 activeAccountID = target.id
114 currentUser = user
115 ContributionWidgetContextStore.saveActor(user.canonicalName)
116 authPhase = .authenticated
117 await refreshNeedsAttentionSnapshot()
118 } catch {
119 client.setToken(nil)
120 currentUser = nil
121 ContributionWidgetContextStore.clear()
122 authPhase = .unauthenticated
123 NeedsAttentionSnapshotStore.clear()
124 }
125 }
126
127 // MARK: - Token management
128
129 /// Validate a new token by querying meta.sr.ht, then persist it.
130 /// Throws on network/GraphQL errors so the caller can display the message.
131 func connect(with token: String) async throws {
132 client.setToken(token)
133 do {
134 let user = try await fetchMe()
135 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
136 accounts.append(entry)
137 activeAccountID = entry.id
138 UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID)
139 try KeychainHelper.saveAccounts(accounts)
140 currentUser = user
141 ContributionWidgetContextStore.saveActor(user.canonicalName)
142 authPhase = .authenticated
143 await refreshNeedsAttentionSnapshot()
144 } catch {
145 client.setToken(nil)
146 throw error
147 }
148 }
149
150 /// Validate a new token, add it as an account, and switch to it immediately.
151 func addAccount(token: String) async throws {
152 let tempClient = SRHTClient(token: token)
153 let user = try await fetchMe(using: tempClient)
154 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
155 accounts.append(entry)
156 try KeychainHelper.saveAccounts(accounts)
157 try await switchAccount(to: entry.id)
158 }
159
160 /// Switch the active account and fully refresh the app.
161 func switchAccount(to id: String) async throws {
162 guard let entry = accounts.first(where: { $0.id == id }) else { return }
163
164 client.responseCache.clear()
165 currentUser = nil
166 pendingDeepLink = nil
167 pendingTabNavigation = nil
168 deepLinkError = nil
169 selectedTab = .home
170
171 authPhase = .unauthenticated
172
173 client.setToken(entry.token)
174 activeAccountID = entry.id
175 UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID)
176
177 let user = try await fetchMe()
178 currentUser = user
179 ContributionWidgetContextStore.saveActor(user.canonicalName)
180 authPhase = .authenticated
181 await refreshNeedsAttentionSnapshot()
182 }
183
184 /// Remove a stored account. Switches to another account if the removed account
185 /// was active; signs out fully if it was the last account.
186 func removeAccount(id: String) async {
187 accounts.removeAll { $0.id == id }
188 try? KeychainHelper.saveAccounts(accounts)
189
190 guard id == activeAccountID else { return }
191
192 if let next = accounts.first {
193 try? await switchAccount(to: next.id)
194 } else {
195 await signOut()
196 }
197 }
198
199 func signOut() async {
200 clearSessionState()
201 URLCache.shared.removeAllCachedResponses()
202 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
203 await clearWebData()
204 clearWebContentRenderCaches()
205 NeedsAttentionSnapshotStore.clear()
206 authPhase = .unauthenticated
207 selectedTab = .home
208 }
209
210 func resetAppData() async {
211 clearSessionState()
212
213 if let bundleIdentifier = Bundle.main.bundleIdentifier {
214 UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
215 }
216 URLCache.shared.removeAllCachedResponses()
217 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
218 await clearWebData()
219 clearWebContentRenderCaches()
220 NeedsAttentionSnapshotStore.clear()
221
222 authPhase = .unauthenticated
223 selectedTab = .home
224 }
225
226 // MARK: - Deep link resolution
227
228 /// Resolve a repository by owner and name for deep linking.
229 func resolveRepository(owner: String, name: String, service: SRHTService = .git) async throws -> RepositorySummary {
230 let result = try await client.execute(
231 service: service,
232 query: Self.repoLookupQuery,
233 variables: ["owner": owner, "name": name],
234 responseType: RepoLookupResponse.self
235 )
236 return result.user.repository
237 }
238
239 /// Resolve a tracker by owner and name for deep linking.
240 func resolveTracker(owner: String, name: String) async throws -> TrackerSummary {
241 let result = try await client.execute(
242 service: .todo,
243 query: Self.trackerLookupQuery,
244 variables: ["owner": owner, "name": name],
245 responseType: TrackerLookupResponse.self
246 )
247 return result.user.tracker
248 }
249
250 func resolveProjectSource(_ source: Project.SourceRepo) async throws -> RepositorySummary {
251 try await resolveRepository(
252 owner: source.ownerUsername,
253 name: source.name,
254 service: source.repoType.service
255 )
256 }
257
258 func resolveProjectTracker(_ tracker: Project.Tracker) async throws -> TrackerSummary {
259 try await resolveTracker(owner: tracker.ownerUsername, name: tracker.name)
260 }
261
262 func openProjectSource(_ source: Project.SourceRepo) async throws {
263 let repository = try await resolveProjectSource(source)
264 navigateToRepository(repository)
265 }
266
267 func openProjectTracker(_ tracker: Project.Tracker) async throws {
268 let resolvedTracker = try await resolveProjectTracker(tracker)
269 navigateToTracker(resolvedTracker)
270 }
271
272 func openMailingList(_ mailingList: InboxMailingListReference) {
273 navigateToMailingList(mailingList)
274 }
275
276 func openSystemStatus() {
277 navigateToSystemStatus()
278 }
279
280 func navigateToRepository(_ repository: RepositorySummary) {
281 pendingTabNavigation = .repository(repository)
282 selectedTab = .repositories
283 }
284
285 func navigateToTracker(_ tracker: TrackerSummary) {
286 pendingTabNavigation = .tracker(tracker)
287 selectedTab = .tickets
288 }
289
290 func navigateToBuild(jobId: Int) {
291 pendingDeepLink = .build(jobId: jobId)
292 selectedTab = .builds
293 }
294
295 func navigateToTicket(ownerUsername: String, trackerName: String, ticketId: Int) {
296 pendingDeepLink = .ticket(owner: ownerUsername, tracker: trackerName, ticketId: ticketId)
297 selectedTab = .tickets
298 }
299
300 func navigateToMailingList(_ mailingList: InboxMailingListReference) {
301 pendingTabNavigation = .mailingList(mailingList)
302 selectedTab = .more
303 }
304
305 func navigateToSystemStatus() {
306 pendingTabNavigation = .systemStatus
307 selectedTab = .more
308 }
309
310 func presentRepositoryDeepLinkError() {
311 deepLinkError = "The repository could not be found or is inaccessible."
312 }
313
314 func presentTicketDeepLinkError() {
315 deepLinkError = "The ticket could not be found or is inaccessible."
316 }
317
318 // MARK: - Private
319
320 private static let meQuery = """
321 {
322 me {
323 id
324 username
325 canonicalName
326 email
327 avatar
328 }
329 }
330 """
331
332 private struct MeResponse: Decodable {
333 let me: User
334 }
335
336 private func fetchMe() async throws -> User {
337 try await fetchMe(using: client)
338 }
339
340 private func fetchMe(using srhtClient: SRHTClient) async throws -> User {
341 let result = try await srhtClient.execute(
342 service: .meta,
343 query: Self.meQuery,
344 responseType: MeResponse.self
345 )
346 return result.me
347 }
348
349 // MARK: - Deep link queries
350
351 private static let repoLookupQuery = """
352 query repoLookup($owner: String!, $name: String!) {
353 user(username: $owner) {
354 repository(name: $name) {
355 id rid name description visibility updated
356 owner { canonicalName }
357 HEAD { name target }
358 }
359 }
360 }
361 """
362
363 private struct RepoLookupResponse: Decodable, Sendable {
364 let user: RepoLookupUser
365 }
366
367 private struct RepoLookupUser: Decodable, Sendable {
368 let repository: RepositorySummary
369 }
370
371 private static let trackerLookupQuery = """
372 query trackerLookup($owner: String!, $name: String!) {
373 user(username: $owner) {
374 tracker(name: $name) {
375 id rid name description visibility updated
376 owner { canonicalName }
377 }
378 }
379 }
380 """
381
382 private struct TrackerLookupResponse: Decodable, Sendable {
383 let user: TrackerLookupUser
384 }
385
386 private struct TrackerLookupUser: Decodable, Sendable {
387 let tracker: TrackerSummary
388 }
389
390 private func clearSessionState() {
391 try? KeychainHelper.deleteAll()
392 client.setToken(nil)
393 client.responseCache.clear()
394 accounts = []
395 activeAccountID = ""
396 UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID)
397 currentUser = nil
398 ContributionWidgetContextStore.clear()
399 pendingDeepLink = nil
400 pendingTabNavigation = nil
401 deepLinkError = nil
402 selectedTab = .home
403 }
404
405 private func refreshNeedsAttentionSnapshot() async {
406 guard let currentUser else {
407 NeedsAttentionSnapshotStore.clear()
408 return
409 }
410
411 let viewModel = HomeViewModel(
412 currentUser: currentUser,
413 client: client,
414 systemStatusRepository: systemStatusRepository
415 )
416 await viewModel.loadDashboard()
417 }
418
419 private func clearWebData() async {
420 await withCheckedContinuation { continuation in
421 let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
422 let since = Date(timeIntervalSince1970: 0)
423 WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: since) {
424 continuation.resume()
425 }
426 }
427 }
428}