diff options
| author | Christian Cleberg <[email protected]> | 2026-05-14 12:41:55 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-05-14 12:41:55 -0500 |
| commit | 0d61e05e98f6f020722ee4d43f024c1da348d2a9 (patch) | |
| tree | b388972b86cf83de380381c52c4390c3beafc69a /Hutch | |
| parent | f28e5cee1643e3f99d7bf90476d1dcf9347229a0 (diff) | |
| download | hutch-0d61e05e98f6f020722ee4d43f024c1da348d2a9.tar.gz hutch-0d61e05e98f6f020722ee4d43f024c1da348d2a9.tar.bz2 hutch-0d61e05e98f6f020722ee4d43f024c1da348d2a9.zip | |
feat: add Safari extension for sourcehut deep linksv3.4.0
Diffstat (limited to 'Hutch')
| -rw-r--r-- | Hutch/App/AppState.swift | 78 | ||||
| -rw-r--r-- | Hutch/App/DeepLink.swift | 64 | ||||
| -rw-r--r-- | Hutch/App/HutchApp.swift | 6 | ||||
| -rw-r--r-- | Hutch/App/RootView.swift | 103 | ||||
| -rw-r--r-- | Hutch/Models/User.swift | 2 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/LookupView.swift | 2 | ||||
| -rw-r--r-- | Hutch/Views/Settings/SafariExtensionHelpView.swift | 59 | ||||
| -rw-r--r-- | Hutch/Views/Settings/SettingsView.swift | 17 |
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) { |
