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