summaryrefslogtreecommitdiff
path: root/Hutch/App
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
parent8f2057c53e9009c2529c9c4849c914666c0e4b40 (diff)
downloadhutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.gz
hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.bz2
hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.zip
v1.0
Diffstat (limited to 'Hutch/App')
-rw-r--r--Hutch/App/AppState.swift222
-rw-r--r--Hutch/App/DeepLink.swift54
-rw-r--r--Hutch/App/HutchApp.swift20
-rw-r--r--Hutch/App/RootView.swift197
4 files changed, 493 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()
+ }
+ }
+ }
+}
diff --git a/Hutch/App/DeepLink.swift b/Hutch/App/DeepLink.swift
new file mode 100644
index 0000000..a4b8269
--- /dev/null
+++ b/Hutch/App/DeepLink.swift
@@ -0,0 +1,54 @@
+import Foundation
+
+/// Represents a parsed `hutch://` deep link.
+enum DeepLink: Equatable {
+ /// hutch://git/<owner>/<repo>
+ case repository(owner: String, repo: String)
+ /// hutch://todo/<owner>/<tracker>/<ticketId>
+ case ticket(owner: String, tracker: String, ticketId: Int)
+ /// hutch://builds/<jobId>
+ case build(jobId: Int)
+
+ /// Attempt to parse a URL into a DeepLink.
+ /// Expected format: hutch://<path>
+ init?(url: URL) {
+ guard url.scheme == "hutch" else { return nil }
+
+ // url.host gives the first path component for opaque URLs;
+ // use standardized path components from the full string.
+ let components = url.pathComponents(fromScheme: "hutch")
+
+ switch components.first {
+ case "git" where components.count >= 3:
+ let owner = components[1]
+ let repo = components[2]
+ self = .repository(owner: owner, repo: repo)
+
+ case "todo" where components.count >= 4:
+ let owner = components[1]
+ let tracker = components[2]
+ guard let ticketId = Int(components[3]) else { return nil }
+ self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId)
+
+ case "builds" where components.count >= 2:
+ guard let jobId = Int(components[1]) else { return nil }
+ self = .build(jobId: jobId)
+
+ default:
+ return nil
+ }
+ }
+}
+
+private extension URL {
+ /// Parse path components from a custom-scheme URL.
+ /// For `hutch://git/~user/repo`, returns `["git", "~user", "repo"]`.
+ func pathComponents(fromScheme scheme: String) -> [String] {
+ // Remove scheme prefix and split by "/"
+ var str = absoluteString
+ if str.hasPrefix("\(scheme)://") {
+ str = String(str.dropFirst("\(scheme)://".count))
+ }
+ return str.split(separator: "/").map(String.init)
+ }
+}
diff --git a/Hutch/App/HutchApp.swift b/Hutch/App/HutchApp.swift
new file mode 100644
index 0000000..fda0f06
--- /dev/null
+++ b/Hutch/App/HutchApp.swift
@@ -0,0 +1,20 @@
+import SwiftUI
+
+@main
+struct HutchApp: App {
+ @State private var appState = AppState()
+ @State private var networkMonitor = NetworkMonitor()
+
+ var body: some Scene {
+ WindowGroup {
+ RootView()
+ .environment(appState)
+ .environment(networkMonitor)
+ .onOpenURL { url in
+ if let link = DeepLink(url: url) {
+ appState.pendingDeepLink = link
+ }
+ }
+ }
+ }
+}
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
new file mode 100644
index 0000000..7af162d
--- /dev/null
+++ b/Hutch/App/RootView.swift
@@ -0,0 +1,197 @@
+import SwiftUI
+
+/// The root view of the app. Shows a TabView when authenticated, or a
+/// full-screen sheet for token entry on first launch.
+struct RootView: View {
+ @Environment(AppState.self) private var appState
+
+ enum Tab: Hashable {
+ case repositories
+ case builds
+ case tickets
+ case settings
+ }
+
+ @State private var selectedTab: Tab = .repositories
+ @State private var repoPath = NavigationPath()
+ @State private var buildsPath = NavigationPath()
+ @State private var ticketsPath = NavigationPath()
+ @State private var isResolvingDeepLink = false
+
+ var body: some View {
+ Group {
+ switch appState.authPhase {
+ case .launching:
+ ProgressView("Connecting…")
+ .task {
+ await appState.validateOnLaunch()
+ }
+
+ case .unauthenticated:
+ // Full-screen token entry that cannot be dismissed.
+ TokenEntryView()
+
+ case .authenticated:
+ tabContent
+ }
+ }
+ .onChange(of: appState.pendingDeepLink) { _, newValue in
+ consumePendingDeepLinkIfPossible(newValue)
+ }
+ .onChange(of: appState.authPhase) { _, newPhase in
+ handleAuthPhaseChange(newPhase)
+ }
+ }
+
+ // MARK: - Tab View
+
+ private var tabContent: some View {
+ TabView(selection: $selectedTab) {
+ NavigationStack(path: $repoPath) {
+ RepositoryListView()
+ }
+ .tag(Tab.repositories)
+ .tabItem {
+ Label("Repositories", systemImage: "book.closed")
+ }
+
+ NavigationStack(path: $buildsPath) {
+ BuildListView()
+ // Int destination used by deep links (hutch://builds/<id>).
+ // JobSummary destination is registered inside BuildListView.
+ .navigationDestination(for: Int.self) { jobId in
+ BuildDetailView(jobId: jobId)
+ }
+ }
+ .tag(Tab.builds)
+ .tabItem {
+ Label("Builds", systemImage: "hammer")
+ }
+
+ NavigationStack(path: $ticketsPath) {
+ TrackerListView()
+ // Deep link destination for jumping straight to a ticket.
+ .navigationDestination(for: TicketDeepLinkTarget.self) { target in
+ TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId)
+ }
+ }
+ .tag(Tab.tickets)
+ .tabItem {
+ Label("Tickets", systemImage: "ticket")
+ }
+
+ SettingsView()
+ .tag(Tab.settings)
+ .tabItem {
+ Label("Settings", systemImage: "gear")
+ }
+ }
+ .overlay {
+ if isResolvingDeepLink {
+ ZStack {
+ Color.black.opacity(0.3)
+ .ignoresSafeArea()
+ ProgressView("Opening link…")
+ .padding()
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
+ }
+ }
+ }
+ }
+
+ // MARK: - Deep Link Handling
+
+ private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) {
+ switch newPhase {
+ case .launching:
+ break
+ case .unauthenticated:
+ repoPath = NavigationPath()
+ buildsPath = NavigationPath()
+ ticketsPath = NavigationPath()
+ selectedTab = .repositories
+ isResolvingDeepLink = false
+ case .authenticated:
+ consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
+ }
+ }
+
+ private func consumePendingDeepLinkIfPossible(_ link: DeepLink?) {
+ guard appState.isAuthenticated, let link else { return }
+ handleDeepLink(link)
+ appState.pendingDeepLink = nil
+ }
+
+ private func handleDeepLink(_ link: DeepLink) {
+ guard appState.isAuthenticated else { return }
+
+ switch link {
+ case .repository(let owner, let repo):
+ resolveRepositoryLink(owner: owner, repo: repo)
+
+ case .build(let jobId):
+ // Reset the builds navigation and push the detail
+ buildsPath = NavigationPath()
+ selectedTab = .builds
+ // Defer the push slightly so the tab switch takes effect
+ Task { @MainActor in
+ try? await Task.sleep(for: .milliseconds(100))
+ buildsPath.append(jobId)
+ }
+
+ case .ticket(let owner, let tracker, let ticketId):
+ resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
+ }
+ }
+
+ private func resolveRepositoryLink(owner: String, repo: String) {
+ isResolvingDeepLink = true
+ Task {
+ defer { isResolvingDeepLink = false }
+ do {
+ let summary = try await appState.resolveRepository(owner: owner, name: repo)
+ repoPath = NavigationPath()
+ selectedTab = .repositories
+ try? await Task.sleep(for: .milliseconds(100))
+ repoPath.append(summary)
+ } catch {
+ // Silently fail — the repo may not exist or be inaccessible
+ }
+ }
+ }
+
+ private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
+ isResolvingDeepLink = true
+ Task {
+ defer { isResolvingDeepLink = false }
+ do {
+ let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
+ ticketsPath = NavigationPath()
+ selectedTab = .tickets
+ try? await Task.sleep(for: .milliseconds(100))
+ ticketsPath.append(trackerSummary)
+ try? await Task.sleep(for: .milliseconds(100))
+ ticketsPath.append(TicketDeepLinkTarget(
+ ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
+ trackerName: trackerSummary.name,
+ trackerId: trackerSummary.id,
+ trackerRid: trackerSummary.rid,
+ ticketId: ticketId
+ ))
+ } catch {
+ // Silently fail
+ }
+ }
+ }
+}
+
+// MARK: - Ticket Deep Link Navigation Target
+
+/// Hashable wrapper to push a ticket detail view from a deep link.
+struct TicketDeepLinkTarget: Hashable {
+ let ownerUsername: String
+ let trackerName: String
+ let trackerId: Int
+ let trackerRid: String
+ let ticketId: Int
+}