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