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}