krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.4.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    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}