krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.13.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 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 pendingTabNavigation = .repository(repository)
265 selectedTab = .repositories
266 }
267
268 func openProjectTracker(_ tracker: Project.Tracker) async throws {
269 let resolvedTracker = try await resolveProjectTracker(tracker)
270 pendingTabNavigation = .tracker(resolvedTracker)
271 selectedTab = .tickets
272 }
273
274 func openMailingList(_ mailingList: InboxMailingListReference) {
275 pendingTabNavigation = .mailingList(mailingList)
276 selectedTab = .more
277 }
278
279 func openSystemStatus() {
280 pendingTabNavigation = .systemStatus
281 selectedTab = .more
282 }
283
284 func presentRepositoryDeepLinkError() {
285 deepLinkError = "The repository could not be found or is inaccessible."
286 }
287
288 func presentTicketDeepLinkError() {
289 deepLinkError = "The ticket could not be found or is inaccessible."
290 }
291
292 // MARK: - Private
293
294 private static let meQuery = """
295 {
296 me {
297 id
298 username
299 canonicalName
300 email
301 avatar
302 }
303 }
304 """
305
306 private struct MeResponse: Decodable {
307 let me: User
308 }
309
310 private func fetchMe() async throws -> User {
311 try await fetchMe(using: client)
312 }
313
314 private func fetchMe(using srhtClient: SRHTClient) async throws -> User {
315 let result = try await srhtClient.execute(
316 service: .meta,
317 query: Self.meQuery,
318 responseType: MeResponse.self
319 )
320 return result.me
321 }
322
323 // MARK: - Deep link queries
324
325 private static let repoLookupQuery = """
326 query repoLookup($owner: String!, $name: String!) {
327 user(username: $owner) {
328 repository(name: $name) {
329 id rid name description visibility updated
330 owner { canonicalName }
331 HEAD { name target }
332 }
333 }
334 }
335 """
336
337 private struct RepoLookupResponse: Decodable, Sendable {
338 let user: RepoLookupUser
339 }
340
341 private struct RepoLookupUser: Decodable, Sendable {
342 let repository: RepositorySummary
343 }
344
345 private static let trackerLookupQuery = """
346 query trackerLookup($owner: String!, $name: String!) {
347 user(username: $owner) {
348 tracker(name: $name) {
349 id rid name description visibility updated
350 owner { canonicalName }
351 }
352 }
353 }
354 """
355
356 private struct TrackerLookupResponse: Decodable, Sendable {
357 let user: TrackerLookupUser
358 }
359
360 private struct TrackerLookupUser: Decodable, Sendable {
361 let tracker: TrackerSummary
362 }
363
364 private func clearSessionState() {
365 try? KeychainHelper.deleteAll()
366 client.setToken(nil)
367 client.responseCache.clear()
368 accounts = []
369 activeAccountID = ""
370 UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID)
371 currentUser = nil
372 ContributionWidgetContextStore.clear()
373 pendingDeepLink = nil
374 pendingTabNavigation = nil
375 deepLinkError = nil
376 selectedTab = .home
377 }
378
379 private func refreshNeedsAttentionSnapshot() async {
380 guard let currentUser else {
381 NeedsAttentionSnapshotStore.clear()
382 return
383 }
384
385 let viewModel = HomeViewModel(
386 currentUser: currentUser,
387 client: client,
388 systemStatusRepository: systemStatusRepository
389 )
390 await viewModel.loadDashboard()
391 }
392
393 private func clearWebData() async {
394 await withCheckedContinuation { continuation in
395 let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
396 let since = Date(timeIntervalSince1970: 0)
397 WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: since) {
398 continuation.resume()
399 }
400 }
401 }
402}