diff options
| author | Christian Cleberg <[email protected]> | 2026-03-17 23:19:43 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-17 23:19:43 -0500 |
| commit | 32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5 (patch) | |
| tree | ee36d421d704e508d5617d803b4c8cbdb4804fe1 /Hutch/App | |
| parent | 8f2057c53e9009c2529c9c4849c914666c0e4b40 (diff) | |
| download | hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.gz hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.bz2 hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.zip | |
v1.0
Diffstat (limited to 'Hutch/App')
| -rw-r--r-- | Hutch/App/AppState.swift | 222 | ||||
| -rw-r--r-- | Hutch/App/DeepLink.swift | 54 | ||||
| -rw-r--r-- | Hutch/App/HutchApp.swift | 20 | ||||
| -rw-r--r-- | Hutch/App/RootView.swift | 197 |
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 +} |
