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}