summaryrefslogtreecommitdiff
path: root/Hutch/App/AppState.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-17 23:19:43 -0500
committerChristian Cleberg <[email protected]>2026-03-17 23:19:43 -0500
commit32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5 (patch)
treeee36d421d704e508d5617d803b4c8cbdb4804fe1 /Hutch/App/AppState.swift
parent8f2057c53e9009c2529c9c4849c914666c0e4b40 (diff)
downloadhutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.gz
hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.bz2
hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.zip
v1.0
Diffstat (limited to 'Hutch/App/AppState.swift')
-rw-r--r--Hutch/App/AppState.swift222
1 files changed, 222 insertions, 0 deletions
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
new file mode 100644
index 0000000..624c78d
--- /dev/null
+++ b/Hutch/App/AppState.swift
@@ -0,0 +1,222 @@
+import Foundation
+import SwiftUI
+import WebKit
+
+/// Central application state shared across the view hierarchy.
+@Observable
+@MainActor
+final class AppState {
+
+ enum AuthPhase {
+ /// App just launched, checking for an existing token.
+ case launching
+ /// No valid token — show the token entry screen.
+ case unauthenticated
+ /// Token validated, user is signed in.
+ case authenticated
+ }
+
+ // MARK: - Authentication
+
+ private(set) var authPhase: AuthPhase = .launching
+
+ /// Convenience for views that need a simple bool.
+ var isAuthenticated: Bool {
+ authPhase == .authenticated && currentUser != nil
+ }
+
+ // MARK: - Current user (populated after successful validation)
+
+ private(set) var currentUser: User?
+
+ // MARK: - Networking
+
+ let client: SRHTClient
+
+ // MARK: - Deep link pending navigation
+
+ /// Set by the deep link handler; consumed by RootView to drive navigation.
+ var pendingDeepLink: DeepLink?
+
+ // MARK: - Init
+
+ init() {
+ let token = KeychainHelper.loadToken()
+ self.client = SRHTClient(token: token)
+ }
+
+ // MARK: - Launch validation
+
+ /// Called once at app launch. If a token exists in Keychain, validates it
+ /// silently. On failure, clears the token and falls through to unauthenticated.
+ func validateOnLaunch() async {
+ guard client.hasToken else {
+ authPhase = .unauthenticated
+ return
+ }
+
+ do {
+ let user = try await fetchMe()
+ currentUser = user
+ authPhase = .authenticated
+ } catch {
+ try? KeychainHelper.deleteToken()
+ client.setToken(nil)
+ currentUser = nil
+ authPhase = .unauthenticated
+ }
+ }
+
+ // MARK: - Token management
+
+ /// Validate a new token by querying meta.sr.ht, then persist it.
+ /// Throws on network/GraphQL errors so the caller can display the message.
+ func connect(with token: String) async throws {
+ // Temporarily set the token so the client can use it for the request.
+ client.setToken(token)
+
+ do {
+ let user = try await fetchMe()
+ try KeychainHelper.saveToken(token)
+ currentUser = user
+ authPhase = .authenticated
+ } catch {
+ // Roll back — don't leave an invalid token in the client.
+ client.setToken(nil)
+ throw error
+ }
+ }
+
+ func signOut() async {
+ clearSessionState()
+ URLCache.shared.removeAllCachedResponses()
+ HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
+ await clearWebData()
+ clearWebContentRenderCaches()
+ authPhase = .unauthenticated
+ }
+
+ func resetAppData() async {
+ clearSessionState()
+
+ if let bundleIdentifier = Bundle.main.bundleIdentifier {
+ UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
+ }
+ URLCache.shared.removeAllCachedResponses()
+ HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
+ await clearWebData()
+ clearWebContentRenderCaches()
+
+ authPhase = .unauthenticated
+ }
+
+ // MARK: - Deep link resolution
+
+ /// Resolve a repository by owner and name for deep linking.
+ func resolveRepository(owner: String, name: String) async throws -> RepositorySummary {
+ let result = try await client.execute(
+ service: .git,
+ query: Self.repoLookupQuery,
+ variables: ["owner": owner, "name": name],
+ responseType: RepoLookupResponse.self
+ )
+ return result.user.repository
+ }
+
+ /// Resolve a tracker by owner and name for deep linking.
+ func resolveTracker(owner: String, name: String) async throws -> TrackerSummary {
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.trackerLookupQuery,
+ variables: ["owner": owner, "name": name],
+ responseType: TrackerLookupResponse.self
+ )
+ return result.user.tracker
+ }
+
+ // MARK: - Private
+
+ private static let meQuery = """
+ {
+ me {
+ id
+ username
+ canonicalName
+ email
+ avatar
+ }
+ }
+ """
+
+ private struct MeResponse: Decodable {
+ let me: User
+ }
+
+ private func fetchMe() async throws -> User {
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.meQuery,
+ responseType: MeResponse.self
+ )
+ return result.me
+ }
+
+ // MARK: - Deep link queries
+
+ private static let repoLookupQuery = """
+ query repoLookup($owner: String!, $name: String!) {
+ user(username: $owner) {
+ repository(name: $name) {
+ id rid name description visibility updated
+ owner { canonicalName }
+ HEAD { name target }
+ }
+ }
+ }
+ """
+
+ private struct RepoLookupResponse: Decodable, Sendable {
+ let user: RepoLookupUser
+ }
+
+ private struct RepoLookupUser: Decodable, Sendable {
+ let repository: RepositorySummary
+ }
+
+ private static let trackerLookupQuery = """
+ query trackerLookup($owner: String!, $name: String!) {
+ user(username: $owner) {
+ tracker(name: $name) {
+ id rid name description visibility updated
+ owner { canonicalName }
+ }
+ }
+ }
+ """
+
+ private struct TrackerLookupResponse: Decodable, Sendable {
+ let user: TrackerLookupUser
+ }
+
+ private struct TrackerLookupUser: Decodable, Sendable {
+ let tracker: TrackerSummary
+ }
+
+ private func clearSessionState() {
+ try? KeychainHelper.deleteAll()
+ client.setToken(nil)
+ client.responseCache.clear()
+ currentUser = nil
+ pendingDeepLink = nil
+ }
+
+ private func clearWebData() async {
+ await withCheckedContinuation { continuation in
+ let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
+ let since = Date(timeIntervalSince1970: 0)
+ WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: since) {
+ continuation.resume()
+ }
+ }
+ }
+}