krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.5.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 var selectedTab: Tab = .home
43
44 // MARK: - Current user (populated after successful validation)
45
46 private(set) var currentUser: User?
47
48 // MARK: - Networking
49
50 let client: SRHTClient
51
52 // MARK: - Deep link pending navigation
53
54 /// Set by the deep link handler; consumed by RootView to drive navigation.
55 var pendingDeepLink: DeepLink?
56 var pendingTabNavigation: TabNavigationTarget?
57 var deepLinkError: String?
58
59 // MARK: - Init
60
61 init() {
62 let token = KeychainHelper.loadToken()
63 self.client = SRHTClient(token: token)
64 }
65
66 // MARK: - Launch validation
67
68 /// Called once at app launch. If a token exists in Keychain, validates it
69 /// silently. On failure, clears the token and falls through to unauthenticated.
70 func validateOnLaunch() async {
71 guard client.hasToken else {
72 authPhase = .unauthenticated
73 return
74 }
75
76 do {
77 let user = try await fetchMe()
78 currentUser = user
79 authPhase = .authenticated
80 await refreshNeedsAttentionSnapshot()
81 } catch {
82 try? KeychainHelper.deleteToken()
83 client.setToken(nil)
84 currentUser = nil
85 authPhase = .unauthenticated
86 NeedsAttentionSnapshotStore.clear()
87 }
88 }
89
90 // MARK: - Token management
91
92 /// Validate a new token by querying meta.sr.ht, then persist it.
93 /// Throws on network/GraphQL errors so the caller can display the message.
94 func connect(with token: String) async throws {
95 // Temporarily set the token so the client can use it for the request.
96 client.setToken(token)
97
98 do {
99 let user = try await fetchMe()
100 try KeychainHelper.saveToken(token)
101 currentUser = user
102 authPhase = .authenticated
103 await refreshNeedsAttentionSnapshot()
104 } catch {
105 // Roll back — don't leave an invalid token in the client.
106 client.setToken(nil)
107 throw error
108 }
109 }
110
111 func signOut() async {
112 clearSessionState()
113 URLCache.shared.removeAllCachedResponses()
114 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
115 await clearWebData()
116 clearWebContentRenderCaches()
117 NeedsAttentionSnapshotStore.clear()
118 authPhase = .unauthenticated
119 selectedTab = .home
120 }
121
122 func resetAppData() async {
123 clearSessionState()
124
125 if let bundleIdentifier = Bundle.main.bundleIdentifier {
126 UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
127 }
128 URLCache.shared.removeAllCachedResponses()
129 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
130 await clearWebData()
131 clearWebContentRenderCaches()
132 NeedsAttentionSnapshotStore.clear()
133
134 authPhase = .unauthenticated
135 selectedTab = .home
136 }
137
138 // MARK: - Deep link resolution
139
140 /// Resolve a repository by owner and name for deep linking.
141 func resolveRepository(owner: String, name: String, service: SRHTService = .git) async throws -> RepositorySummary {
142 let result = try await client.execute(
143 service: service,
144 query: Self.repoLookupQuery,
145 variables: ["owner": owner, "name": name],
146 responseType: RepoLookupResponse.self
147 )
148 return result.user.repository
149 }
150
151 /// Resolve a tracker by owner and name for deep linking.
152 func resolveTracker(owner: String, name: String) async throws -> TrackerSummary {
153 let result = try await client.execute(
154 service: .todo,
155 query: Self.trackerLookupQuery,
156 variables: ["owner": owner, "name": name],
157 responseType: TrackerLookupResponse.self
158 )
159 return result.user.tracker
160 }
161
162 func resolveProjectSource(_ source: Project.SourceRepo) async throws -> RepositorySummary {
163 try await resolveRepository(
164 owner: source.ownerUsername,
165 name: source.name,
166 service: source.repoType.service
167 )
168 }
169
170 func resolveProjectTracker(_ tracker: Project.Tracker) async throws -> TrackerSummary {
171 try await resolveTracker(owner: tracker.ownerUsername, name: tracker.name)
172 }
173
174 func openProjectSource(_ source: Project.SourceRepo) async throws {
175 let repository = try await resolveProjectSource(source)
176 pendingTabNavigation = .repository(repository)
177 selectedTab = .repositories
178 }
179
180 func openProjectTracker(_ tracker: Project.Tracker) async throws {
181 let resolvedTracker = try await resolveProjectTracker(tracker)
182 pendingTabNavigation = .tracker(resolvedTracker)
183 selectedTab = .tickets
184 }
185
186 func openMailingList(_ mailingList: InboxMailingListReference) {
187 pendingTabNavigation = .mailingList(mailingList)
188 selectedTab = .more
189 }
190
191 func presentRepositoryDeepLinkError() {
192 deepLinkError = "The repository could not be found or is inaccessible."
193 }
194
195 func presentTicketDeepLinkError() {
196 deepLinkError = "The ticket could not be found or is inaccessible."
197 }
198
199 // MARK: - Private
200
201 private static let meQuery = """
202 {
203 me {
204 id
205 username
206 canonicalName
207 email
208 avatar
209 }
210 }
211 """
212
213 private struct MeResponse: Decodable {
214 let me: User
215 }
216
217 private func fetchMe() async throws -> User {
218 let result = try await client.execute(
219 service: .meta,
220 query: Self.meQuery,
221 responseType: MeResponse.self
222 )
223 return result.me
224 }
225
226 // MARK: - Deep link queries
227
228 private static let repoLookupQuery = """
229 query repoLookup($owner: String!, $name: String!) {
230 user(username: $owner) {
231 repository(name: $name) {
232 id rid name description visibility updated
233 owner { canonicalName }
234 HEAD { name target }
235 }
236 }
237 }
238 """
239
240 private struct RepoLookupResponse: Decodable, Sendable {
241 let user: RepoLookupUser
242 }
243
244 private struct RepoLookupUser: Decodable, Sendable {
245 let repository: RepositorySummary
246 }
247
248 private static let trackerLookupQuery = """
249 query trackerLookup($owner: String!, $name: String!) {
250 user(username: $owner) {
251 tracker(name: $name) {
252 id rid name description visibility updated
253 owner { canonicalName }
254 }
255 }
256 }
257 """
258
259 private struct TrackerLookupResponse: Decodable, Sendable {
260 let user: TrackerLookupUser
261 }
262
263 private struct TrackerLookupUser: Decodable, Sendable {
264 let tracker: TrackerSummary
265 }
266
267 private func clearSessionState() {
268 try? KeychainHelper.deleteAll()
269 client.setToken(nil)
270 client.responseCache.clear()
271 currentUser = nil
272 pendingDeepLink = nil
273 pendingTabNavigation = nil
274 deepLinkError = nil
275 selectedTab = .home
276 }
277
278 private func refreshNeedsAttentionSnapshot() async {
279 guard let currentUser else {
280 NeedsAttentionSnapshotStore.clear()
281 return
282 }
283
284 let viewModel = HomeViewModel(currentUser: currentUser, client: client)
285 await viewModel.loadDashboard()
286 }
287
288 private func clearWebData() async {
289 await withCheckedContinuation { continuation in
290 let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
291 let since = Date(timeIntervalSince1970: 0)
292 WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: since) {
293 continuation.resume()
294 }
295 }
296 }
297}