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