krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.15.1: 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 SystemStatusWidgetSnapshotStore.clear()
207 authPhase = .unauthenticated
208 selectedTab = .home
209 }
210
211 func resetAppData() async {
212 clearSessionState()
213
214 if let bundleIdentifier = Bundle.main.bundleIdentifier {
215 UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
216 }
217 URLCache.shared.removeAllCachedResponses()
218 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
219 await clearWebData()
220 clearWebContentRenderCaches()
221 NeedsAttentionSnapshotStore.clear()
222 SystemStatusWidgetSnapshotStore.clear()
223
224 authPhase = .unauthenticated
225 selectedTab = .home
226 }
227
228 // MARK: - Deep link resolution
229
230 /// Resolve a repository by owner and name for deep linking.
231 func resolveRepository(owner: String, name: String, service: SRHTService = .git) async throws -> RepositorySummary {
232 let result = try await client.execute(
233 service: service,
234 query: Self.repoLookupQuery,
235 variables: ["owner": owner, "name": name],
236 responseType: RepoLookupResponse.self
237 )
238 return result.user.repository
239 }
240
241 /// Resolve a tracker by owner and name for deep linking.
242 func resolveTracker(owner: String, name: String) async throws -> TrackerSummary {
243 let result = try await client.execute(
244 service: .todo,
245 query: Self.trackerLookupQuery,
246 variables: ["owner": owner, "name": name],
247 responseType: TrackerLookupResponse.self
248 )
249 return result.user.tracker
250 }
251
252 func resolveProjectSource(_ source: Project.SourceRepo) async throws -> RepositorySummary {
253 try await resolveRepository(
254 owner: source.ownerUsername,
255 name: source.name,
256 service: source.repoType.service
257 )
258 }
259
260 func resolveProjectTracker(_ tracker: Project.Tracker) async throws -> TrackerSummary {
261 try await resolveTracker(owner: tracker.ownerUsername, name: tracker.name)
262 }
263
264 func openProjectSource(_ source: Project.SourceRepo) async throws {
265 let repository = try await resolveProjectSource(source)
266 navigateToRepository(repository)
267 }
268
269 func openProjectTracker(_ tracker: Project.Tracker) async throws {
270 let resolvedTracker = try await resolveProjectTracker(tracker)
271 navigateToTracker(resolvedTracker)
272 }
273
274 func openMailingList(_ mailingList: InboxMailingListReference) {
275 navigateToMailingList(mailingList)
276 }
277
278 func openSystemStatus() {
279 navigateToSystemStatus()
280 }
281
282 func navigateToRepository(_ repository: RepositorySummary) {
283 pendingTabNavigation = .repository(repository)
284 selectedTab = .repositories
285 }
286
287 func navigateToTracker(_ tracker: TrackerSummary) {
288 pendingTabNavigation = .tracker(tracker)
289 selectedTab = .tickets
290 }
291
292 func navigateToBuild(jobId: Int) {
293 pendingDeepLink = .build(jobId: jobId)
294 selectedTab = .builds
295 }
296
297 func navigateToTicket(ownerUsername: String, trackerName: String, ticketId: Int) {
298 pendingDeepLink = .ticket(owner: ownerUsername, tracker: trackerName, ticketId: ticketId)
299 selectedTab = .tickets
300 }
301
302 func navigateToMailingList(_ mailingList: InboxMailingListReference) {
303 pendingTabNavigation = .mailingList(mailingList)
304 selectedTab = .more
305 }
306
307 func navigateToSystemStatus() {
308 pendingTabNavigation = .systemStatus
309 selectedTab = .more
310 }
311
312 func presentRepositoryDeepLinkError() {
313 deepLinkError = "The repository could not be found or is inaccessible."
314 }
315
316 func presentTicketDeepLinkError() {
317 deepLinkError = "The ticket could not be found or is inaccessible."
318 }
319
320 // MARK: - Private
321
322 private static let meQuery = """
323 {
324 me {
325 id
326 username
327 canonicalName
328 email
329 avatar
330 }
331 }
332 """
333
334 private struct MeResponse: Decodable {
335 let me: User
336 }
337
338 private func fetchMe() async throws -> User {
339 try await fetchMe(using: client)
340 }
341
342 private func fetchMe(using srhtClient: SRHTClient) async throws -> User {
343 let result = try await srhtClient.execute(
344 service: .meta,
345 query: Self.meQuery,
346 responseType: MeResponse.self
347 )
348 return result.me
349 }
350
351 // MARK: - Deep link queries
352
353 private static let repoLookupQuery = """
354 query repoLookup($owner: String!, $name: String!) {
355 user(username: $owner) {
356 repository(name: $name) {
357 id rid name description visibility updated
358 owner { canonicalName }
359 HEAD { name target }
360 }
361 }
362 }
363 """
364
365 private struct RepoLookupResponse: Decodable, Sendable {
366 let user: RepoLookupUser
367 }
368
369 private struct RepoLookupUser: Decodable, Sendable {
370 let repository: RepositorySummary
371 }
372
373 private static let trackerLookupQuery = """
374 query trackerLookup($owner: String!, $name: String!) {
375 user(username: $owner) {
376 tracker(name: $name) {
377 id rid name description visibility updated
378 owner { canonicalName }
379 }
380 }
381 }
382 """
383
384 private struct TrackerLookupResponse: Decodable, Sendable {
385 let user: TrackerLookupUser
386 }
387
388 private struct TrackerLookupUser: Decodable, Sendable {
389 let tracker: TrackerSummary
390 }
391
392 private func clearSessionState() {
393 try? KeychainHelper.deleteAll()
394 client.setToken(nil)
395 client.responseCache.clear()
396 accounts = []
397 activeAccountID = ""
398 UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID)
399 currentUser = nil
400 ContributionWidgetContextStore.clear()
401 pendingDeepLink = nil
402 pendingTabNavigation = nil
403 deepLinkError = nil
404 selectedTab = .home
405 }
406
407 private func refreshNeedsAttentionSnapshot() async {
408 guard let currentUser else {
409 NeedsAttentionSnapshotStore.clear()
410 return
411 }
412
413 let viewModel = HomeViewModel(
414 currentUser: currentUser,
415 client: client,
416 systemStatusRepository: systemStatusRepository
417 )
418 await viewModel.loadDashboard()
419 }
420
421 private func clearWebData() async {
422 await withCheckedContinuation { continuation in
423 let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
424 let since = Date(timeIntervalSince1970: 0)
425 WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: since) {
426 continuation.resume()
427 }
428 }
429 }
430}