summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/App/AppState.swift78
-rw-r--r--Hutch/App/DeepLink.swift64
-rw-r--r--Hutch/App/HutchApp.swift6
-rw-r--r--Hutch/App/RootView.swift103
-rw-r--r--Hutch/Models/User.swift2
-rw-r--r--Hutch/Views/Lookup/LookupView.swift2
-rw-r--r--Hutch/Views/Settings/SafariExtensionHelpView.swift59
-rw-r--r--Hutch/Views/Settings/SettingsView.swift17
8 files changed, 305 insertions, 26 deletions
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
index f91f1a4..a5500fe 100644
--- a/Hutch/App/AppState.swift
+++ b/Hutch/App/AppState.swift
@@ -2,6 +2,9 @@ import Foundation
import SwiftUI
import UIKit
import WebKit
+import os
+
+private let appStateDeepLinkLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
/// Central application state shared across the view hierarchy.
@Observable
@@ -260,10 +263,11 @@ final class AppState {
/// Resolve a repository by owner and name for deep linking.
func resolveRepository(owner: String, name: String, service: SRHTService = .git) async throws -> RepositorySummary {
+ let normalizedOwner = Self.srhtUsername(from: owner)
let result = try await client.execute(
service: service,
query: Self.repoLookupQuery,
- variables: ["owner": owner, "name": name],
+ variables: ["owner": normalizedOwner, "name": name],
responseType: RepoLookupResponse.self
)
return result.user.repository
@@ -271,15 +275,39 @@ final class AppState {
/// Resolve a tracker by owner and name for deep linking.
func resolveTracker(owner: String, name: String) async throws -> TrackerSummary {
+ let normalizedOwner = Self.srhtUsername(from: owner)
let result = try await client.execute(
service: .todo,
query: Self.trackerLookupQuery,
- variables: ["owner": owner, "name": name],
+ variables: ["owner": normalizedOwner, "name": name],
responseType: TrackerLookupResponse.self
)
return result.user.tracker
}
+ func resolveMailingList(owner: String, name: String) async throws -> InboxMailingListReference {
+ let normalizedOwner = Self.srhtUsername(from: owner)
+ let result = try await client.execute(
+ service: .lists,
+ query: Self.mailingListLookupQuery,
+ variables: ["owner": normalizedOwner, "name": name],
+ responseType: MailingListLookupResponse.self
+ )
+ return result.user.mailingList
+ }
+
+ func resolveUser(username: String) async throws -> User {
+ let normalizedUsername = Self.srhtUsername(from: username)
+ appStateDeepLinkLogger.info("Resolving user. raw=\(username, privacy: .public), normalized=\(normalizedUsername, privacy: .public)")
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.userLookupQuery,
+ variables: ["username": normalizedUsername],
+ responseType: UserLookupResponse.self
+ )
+ return result.user
+ }
+
func resolveProjectSource(_ source: Project.SourceRepo) async throws -> RepositorySummary {
try await resolveRepository(
owner: source.ownerUsername,
@@ -384,6 +412,11 @@ final class AppState {
return result.me
}
+ static func srhtUsername(from value: String) -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
+ }
+
// MARK: - Deep link queries
private static let repoLookupQuery = """
@@ -425,6 +458,47 @@ final class AppState {
let tracker: TrackerSummary
}
+ private static let mailingListLookupQuery = """
+ query mailingListLookup($owner: String!, $name: String!) {
+ user(username: $owner) {
+ mailingList: list(name: $name) {
+ id rid name owner { canonicalName }
+ }
+ }
+ }
+ """
+
+ private struct MailingListLookupResponse: Decodable, Sendable {
+ let user: MailingListLookupUser
+ }
+
+ private struct MailingListLookupUser: Decodable, Sendable {
+ let mailingList: InboxMailingListReference
+ }
+
+ private static let userLookupQuery = """
+ query userLookup($username: String!) {
+ user: userByName(username: $username) {
+ id
+ created
+ updated
+ canonicalName
+ username
+ email
+ url
+ location
+ bio
+ avatar
+ pronouns
+ userType
+ }
+ }
+ """
+
+ private struct UserLookupResponse: Decodable, Sendable {
+ let user: User
+ }
+
private func addValidatedAccount(token: String, activateNewAccount: Bool) async throws {
let tempClient = SRHTClient(token: token)
let user = try await fetchMe(using: tempClient)
diff --git a/Hutch/App/DeepLink.swift b/Hutch/App/DeepLink.swift
index 786891d..574481d 100644
--- a/Hutch/App/DeepLink.swift
+++ b/Hutch/App/DeepLink.swift
@@ -1,15 +1,22 @@
import Foundation
+import os
+
+private let deepLinkParserLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
/// Represents a parsed `hutch://` deep link.
enum DeepLink: Equatable {
case home
case work
- /// hutch://git/<owner>/<repo>
- case repository(owner: String, repo: String)
+ /// hutch://git/<owner>/<repo> or hutch://hg/<owner>/<repo>
+ case repository(service: SRHTService, owner: String, repo: String)
/// hutch://todo/<owner>/<tracker>/<ticketId>
case ticket(owner: String, tracker: String, ticketId: Int)
- /// hutch://builds/<jobId>
+ /// hutch://builds/<jobId> or hutch://builds/<owner>/job/<jobId>
case build(jobId: Int)
+ /// hutch://lists/<owner>/<list>
+ case mailingList(owner: String, list: String)
+ /// hutch://lookup/<owner>
+ case userProfile(owner: String)
/// hutch://builds (tab-level)
case buildsTab
/// hutch://repositories (tab-level)
@@ -26,9 +33,8 @@ enum DeepLink: Equatable {
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")
+ let components = url.deepLinkPathComponents
+ deepLinkParserLogger.info("DeepLink parser components for \(url.absoluteString, privacy: .public): \(components.joined(separator: ","), privacy: .public)")
switch components.first {
case "home", nil:
@@ -37,10 +43,18 @@ enum DeepLink: Equatable {
case "work", "inbox":
self = .work
- case "git" where components.count >= 3:
+ case let .some(serviceName) where ["git", "hg", "todo", "builds", "lists"].contains(serviceName)
+ && components.count == 2
+ && components[1].hasPrefix("~"):
+ deepLinkParserLogger.info("Treating owner-root service URL as user profile: service=\(serviceName, privacy: .public), owner=\(components[1], privacy: .public)")
+ self = .userProfile(owner: components[1])
+
+ case let .some(serviceName) where ["git", "hg"].contains(serviceName) && components.count >= 3:
+ guard let service = SRHTService(rawValue: serviceName)
+ else { return nil }
let owner = components[1]
let repo = components[2]
- self = .repository(owner: owner, repo: repo)
+ self = .repository(service: service, owner: owner, repo: repo)
case "todo" where components.count >= 4:
let owner = components[1]
@@ -49,12 +63,21 @@ enum DeepLink: Equatable {
self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId)
case "builds" where components.count >= 2:
- guard let jobId = Int(components[1]) else { return nil }
+ let rawJobId: String
+ if components.count >= 4, components[2] == "job" {
+ rawJobId = components[3]
+ } else {
+ rawJobId = components[1]
+ }
+ guard let jobId = Int(rawJobId) else { return nil }
self = .build(jobId: jobId)
case "builds":
self = .buildsTab
+ case "lists" where components.count >= 3:
+ self = .mailingList(owner: components[1], list: components[2])
+
case "repositories":
self = .repositoriesTab
@@ -64,6 +87,9 @@ enum DeepLink: Equatable {
case "status":
self = .systemStatus
+ case "lookup" where components.count >= 2:
+ self = .userProfile(owner: components[1])
+
case "lookup":
self = .lookup
@@ -74,14 +100,18 @@ enum DeepLink: Equatable {
}
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))
+ var deepLinkPathComponents: [String] {
+ guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false) else {
+ return []
+ }
+
+ let pathComponents = components.path
+ .split(separator: "/", omittingEmptySubsequences: true)
+ .map(String.init)
+
+ if let host = components.host, !host.isEmpty {
+ return [host] + pathComponents
}
- return str.split(separator: "/").map(String.init)
+ return pathComponents
}
}
diff --git a/Hutch/App/HutchApp.swift b/Hutch/App/HutchApp.swift
index 9c643d9..69d1311 100644
--- a/Hutch/App/HutchApp.swift
+++ b/Hutch/App/HutchApp.swift
@@ -1,7 +1,9 @@
import SwiftUI
+import os
@main
struct HutchApp: App {
+ private let deepLinkLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
@State private var appState = AppState()
@State private var networkMonitor = NetworkMonitor()
@AppStorage(AppStorageKeys.appTheme, store: .standard) private var appTheme: AppTheme = .system
@@ -18,8 +20,12 @@ struct HutchApp: App {
.background(appTheme == .amoled ? Color.black : Color.clear)
.preferredColorScheme(appTheme.colorScheme)
.onOpenURL { url in
+ deepLinkLogger.info("Received URL: \(url.absoluteString, privacy: .public)")
if let link = DeepLink(url: url) {
+ deepLinkLogger.info("Parsed deep link: \(String(describing: link), privacy: .public)")
appState.pendingDeepLink = link
+ } else {
+ deepLinkLogger.error("Rejected URL: \(url.absoluteString, privacy: .public)")
}
}
.onChange(of: HutchIntentNavigator.shared.pendingDestination) { _, destination in
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index ac20f0c..7c8037e 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -1,4 +1,7 @@
import SwiftUI
+import os
+
+private let rootDeepLinkLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
/// The root view of the app. Shows a TabView when authenticated, or a
/// full-screen sheet for token entry on first launch.
@@ -154,6 +157,7 @@ struct RootView: View {
// MARK: - Deep Link Handling
private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) {
+ rootDeepLinkLogger.info("Auth phase changed: \(String(describing: newPhase), privacy: .public); pendingDeepLink=\(String(describing: appState.pendingDeepLink), privacy: .public)")
switch newPhase {
case .launching:
break
@@ -171,7 +175,11 @@ struct RootView: View {
}
private func consumePendingDeepLinkIfPossible(_ link: DeepLink?) {
- guard appState.isAuthenticated, let link else { return }
+ rootDeepLinkLogger.info("Attempting to consume pending deep link. authenticated=\(appState.isAuthenticated, privacy: .public), link=\(String(describing: link), privacy: .public)")
+ guard appState.isAuthenticated, let link else {
+ rootDeepLinkLogger.info("Deferred deep link consumption.")
+ return
+ }
handleDeepLink(link)
appState.pendingDeepLink = nil
}
@@ -183,15 +191,19 @@ struct RootView: View {
}
private func handleDeepLink(_ link: DeepLink) {
- guard appState.isAuthenticated else { return }
+ rootDeepLinkLogger.info("Handling deep link: \(String(describing: link), privacy: .public)")
+ guard appState.isAuthenticated else {
+ rootDeepLinkLogger.info("Ignoring deep link while unauthenticated: \(String(describing: link), privacy: .public)")
+ return
+ }
switch link {
case .home:
homePath = NavigationPath()
appState.selectedTab = .home
- case .repository(let owner, let repo):
- resolveRepositoryLink(owner: owner, repo: repo)
+ case .repository(let service, let owner, let repo):
+ resolveRepositoryLink(service: service, owner: owner, repo: repo)
case .build(let jobId):
buildsPath = NavigationPath()
@@ -204,6 +216,12 @@ struct RootView: View {
case .ticket(let owner, let tracker, let ticketId):
resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
+ case .mailingList(let owner, let list):
+ resolveMailingListLink(owner: owner, list: list)
+
+ case .userProfile(let owner):
+ resolveUserProfileLink(owner: owner)
+
case .work:
homePath = NavigationPath()
appState.selectedTab = .home
@@ -276,12 +294,12 @@ struct RootView: View {
}
}
- private func resolveRepositoryLink(owner: String, repo: String) {
+ private func resolveRepositoryLink(service: SRHTService, owner: String, repo: String) {
isResolvingDeepLink = true
Task {
defer { isResolvingDeepLink = false }
do {
- let summary = try await appState.resolveRepository(owner: owner, name: repo)
+ let summary = try await appState.resolveRepository(owner: owner, name: repo, service: service)
repoPath = NavigationPath()
appState.selectedTab = .repositories
await settleNavigationTransition()
@@ -292,6 +310,34 @@ struct RootView: View {
}
}
+ private func resolveMailingListLink(owner: String, list: String) {
+ isResolvingDeepLink = true
+ Task {
+ defer { isResolvingDeepLink = false }
+ do {
+ let mailingList = try await appState.resolveMailingList(owner: owner, name: list)
+ morePath = NavigationPath()
+ appState.selectedTab = .more
+ await settleNavigationTransition()
+ morePath.append(MoreRoute.lists)
+ morePath.append(MoreRoute.mailingList(mailingList))
+ } catch {
+ appState.deepLinkError = "The mailing list could not be found or is inaccessible."
+ }
+ }
+ }
+
+ private func resolveUserProfileLink(owner: String) {
+ rootDeepLinkLogger.info("Routing user profile deep link for owner=\(owner, privacy: .public)")
+ morePath = NavigationPath()
+ appState.selectedTab = .more
+ Task {
+ await settleNavigationTransition()
+ rootDeepLinkLogger.info("Appending user profile route for owner=\(owner, privacy: .public)")
+ morePath.append(MoreRoute.userProfile(owner))
+ }
+ }
+
private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
isResolvingDeepLink = true
Task {
@@ -337,6 +383,7 @@ enum MoreRoute: Hashable {
case systemStatus
case settings
case about
+ case userProfile(String)
case mailingList(InboxMailingListReference)
case thread(InboxThreadSummary)
case manPageBrowser
@@ -366,6 +413,8 @@ private struct MoreNavigationRoot: View {
SettingsView()
case .about:
AboutView()
+ case .userProfile(let owner):
+ UserProfileDeepLinkView(owner: owner)
case .mailingList(let mailingList):
MailingListDetailView(mailingList: mailingList)
case .thread(let thread):
@@ -393,6 +442,48 @@ private struct MoreNavigationRoot: View {
}
}
+struct UserProfileDeepLinkView: View {
+ private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
+ @Environment(AppState.self) private var appState
+ let owner: String
+ @State private var user: User?
+ @State private var errorMessage: String?
+
+ var body: some View {
+ Group {
+ if let user {
+ UserProfileView(user: user)
+ } else if let errorMessage {
+ ContentUnavailableView("Couldn't Open Profile", systemImage: "person.crop.circle.badge.exclamationmark", description: Text(errorMessage))
+ } else {
+ SRHTLoadingStateView(message: "Loading profile...")
+ }
+ }
+ .navigationTitle(displayOwner)
+ .navigationBarTitleDisplayMode(.inline)
+ .task(id: owner) {
+ await loadProfile()
+ }
+ }
+
+ private var displayOwner: String {
+ owner.hasPrefix("~") ? owner : "~\(owner)"
+ }
+
+ @MainActor
+ private func loadProfile() async {
+ logger.info("Loading user profile for owner=\(owner, privacy: .public)")
+ errorMessage = nil
+ do {
+ user = try await appState.resolveUser(username: owner)
+ logger.info("Loaded user profile for owner=\(owner, privacy: .public), canonical=\(user?.canonicalName ?? "nil", privacy: .public)")
+ } catch {
+ logger.error("Failed loading user profile for owner=\(owner, privacy: .public): \(String(describing: error), privacy: .public)")
+ errorMessage = "The user profile could not be found or is inaccessible."
+ }
+ }
+}
+
// MARK: - Ticket Deep Link Navigation Target
/// Hashable wrapper to push a ticket detail view from a deep link.
diff --git a/Hutch/Models/User.swift b/Hutch/Models/User.swift
index 625cfdc..4ab7315 100644
--- a/Hutch/Models/User.swift
+++ b/Hutch/Models/User.swift
@@ -1,7 +1,7 @@
import Foundation
/// A Sourcehut user from meta.sr.ht.
-struct User: Decodable, Sendable {
+struct User: Decodable, Sendable, Hashable {
let id: Int
let created: String?
let updated: String?
diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift
index f464da5..55c421b 100644
--- a/Hutch/Views/Lookup/LookupView.swift
+++ b/Hutch/Views/Lookup/LookupView.swift
@@ -443,6 +443,8 @@ struct LookupView: View {
SettingsView()
case .about:
AboutView()
+ case .userProfile(let owner):
+ UserProfileDeepLinkView(owner: owner)
case .mailingList(let mailingList):
MailingListDetailView(mailingList: mailingList)
case .thread(let thread):
diff --git a/Hutch/Views/Settings/SafariExtensionHelpView.swift b/Hutch/Views/Settings/SafariExtensionHelpView.swift
new file mode 100644
index 0000000..5da1a3d
--- /dev/null
+++ b/Hutch/Views/Settings/SafariExtensionHelpView.swift
@@ -0,0 +1,59 @@
+import SwiftUI
+
+struct SafariExtensionHelpView: View {
+ private let supportedServices = [
+ "git.sr.ht",
+ "hg.sr.ht",
+ "todo.sr.ht",
+ "builds.sr.ht",
+ "lists.sr.ht",
+ "meta.sr.ht",
+ "sr.ht",
+ ]
+
+ var body: some View {
+ Form {
+ Section {
+ Text("Enable Open in Hutch from Settings > Apps > Safari > Extensions, then turn on Hutch and allow it for SourceHut websites.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .themedRow()
+ } header: {
+ Text("Enable")
+ }
+
+ Section("Supported Services") {
+ ForEach(supportedServices, id: \.self) { service in
+ Label(service, systemImage: "checkmark.circle")
+ .themedRow()
+ }
+ }
+
+ Section {
+ Text("The extension adds a Safari action named Open in Hutch. It converts supported SourceHut web URLs into Hutch deep links, then opens the matching screen in the app.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .themedRow()
+
+ Text("This is separate from Universal Links. Hutch cannot claim sr.ht links system-wide because those domains are owned by SourceHut and would need Apple App Site Association files hosted there.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .themedRow()
+ } header: {
+ Text("How It Works")
+ }
+
+ Section {
+ Text("Pages are never redirected automatically by default. The optional in-page Open in Hutch banner is off unless the extension setting is explicitly enabled.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .themedRow()
+ } header: {
+ Text("Privacy")
+ }
+ }
+ .themedList()
+ .navigationTitle("Safari Extension")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+}
diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift
index f307429..0dd4319 100644
--- a/Hutch/Views/Settings/SettingsView.swift
+++ b/Hutch/Views/Settings/SettingsView.swift
@@ -15,6 +15,7 @@ struct SettingsView: View {
Form {
appearanceSection()
behaviorSection()
+ safariExtensionSection()
authenticationSection()
}
.themedList()
@@ -56,6 +57,22 @@ struct SettingsView: View {
}
@ViewBuilder
+ private func safariExtensionSection() -> some View {
+ Section {
+ NavigationLink {
+ SafariExtensionHelpView()
+ } label: {
+ Label("Safari Extension", systemImage: "safari")
+ }
+ .themedRow()
+ } header: {
+ Text("Browser")
+ } footer: {
+ Text("Open supported SourceHut pages in Hutch from Safari without claiming sr.ht links system-wide.")
+ }
+ }
+
+ @ViewBuilder
private func appearanceSection() -> some View {
Section {
Picker("Theme", selection: $appTheme) {