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 | |
| parent | f28e5cee1643e3f99d7bf90476d1dcf9347229a0 (diff) | |
| download | hutch-3.4.0.tar.gz hutch-3.4.0.tar.bz2 hutch-3.4.0.zip | |
feat: add Safari extension for sourcehut deep linksv3.4.0
27 files changed, 1047 insertions, 35 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index a7ef936..3db982e 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 8B2FA9D22F7E211800E609E2 /* todo.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9CA2F7E211800E609E2 /* todo.json */; }; 8B2FA9D32F7E211800E609E2 /* lists.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9C72F7E211800E609E2 /* lists.json */; }; 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 8BE083012F81000100000001 /* HutchSafariExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE083032F81000100000001 /* HutchSafariExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 8BF100032F9A000100000001 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100022F9A000100000001 /* Markdown */; }; 8BF100052F9A000100000002 /* Splash in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100042F9A000100000002 /* Splash */; }; /* End PBXBuildFile section */ @@ -37,6 +38,13 @@ remoteGlobalIDString = 8BE081F72F80000100000001; remoteInfo = HutchWidgetExtension; }; + 8BE083022F81000100000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8B4B28C92F6704280045FA19 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8BE083092F81000100000001; + remoteInfo = HutchSafariExtension; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -47,6 +55,7 @@ dstSubfolderSpec = 13; files = ( 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed Foundation Extensions */, + 8BE083012F81000100000001 /* HutchSafariExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -70,6 +79,7 @@ 8B8182C22F6B742B000AE049 /* HutchTests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = HutchTests; sourceTree = "<group>"; }; 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = "<group>"; }; 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 8BE083032F81000100000001 /* HutchSafariExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchSafariExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -87,6 +97,13 @@ ); target = 8BE081F72F80000100000001 /* HutchWidgetExtension */; }; + 8BE083042F81000100000001 /* Exceptions for "HutchSafariExtension" folder in "HutchSafariExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 8BE083092F81000100000001 /* HutchSafariExtension */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -116,6 +133,14 @@ path = HutchWidgetExtension; sourceTree = "<group>"; }; + 8BE083052F81000100000001 /* HutchSafariExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 8BE083042F81000100000001 /* Exceptions for "HutchSafariExtension" folder in "HutchSafariExtension" target */, + ); + path = HutchSafariExtension; + sourceTree = "<group>"; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -142,6 +167,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8BE083062F81000100000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -180,6 +212,7 @@ 8B4B28D32F6704280045FA19 /* Hutch */, 8BE081F42F80000100000001 /* Shared */, 8BE081F52F80000100000001 /* HutchWidgetExtension */, + 8BE083052F81000100000001 /* HutchSafariExtension */, 8B8182B82F6B73F3000AE049 /* HutchTests */, 8B4B28D22F6704280045FA19 /* Products */, ); @@ -191,6 +224,7 @@ 8B4B28D12F6704280045FA19 /* Hutch.app */, 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */, 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */, + 8BE083032F81000100000001 /* HutchSafariExtension.appex */, ); name = Products; sourceTree = "<group>"; @@ -211,6 +245,7 @@ ); dependencies = ( 8BE082042F80000100000001 /* PBXTargetDependency */, + 8BE0830A2F81000100000001 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 8B4B28D32F6704280045FA19 /* Hutch */, @@ -271,6 +306,28 @@ productReference = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; productType = "com.apple.product-type.app-extension"; }; + 8BE083092F81000100000001 /* HutchSafariExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8BE0830D2F81000100000001 /* Build configuration list for PBXNativeTarget "HutchSafariExtension" */; + buildPhases = ( + 8BE083072F81000100000001 /* Sources */, + 8BE083062F81000100000001 /* Frameworks */, + 8BE083082F81000100000001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 8BE083052F81000100000001 /* HutchSafariExtension */, + ); + name = HutchSafariExtension; + packageProductDependencies = ( + ); + productName = HutchSafariExtension; + productReference = 8BE083032F81000100000001 /* HutchSafariExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -288,6 +345,9 @@ CreatedOnToolsVersion = 26.3; TestTargetID = 8B4B28D02F6704280045FA19; }; + 8BE083092F81000100000001 = { + CreatedOnToolsVersion = 26.3; + }; }; }; buildConfigurationList = 8B4B28CC2F6704280045FA19 /* Build configuration list for PBXProject "Hutch" */; @@ -310,6 +370,7 @@ targets = ( 8B4B28D02F6704280045FA19 /* Hutch */, 8BE081F72F80000100000001 /* HutchWidgetExtension */, + 8BE083092F81000100000001 /* HutchSafariExtension */, 8B8182B62F6B73F3000AE049 /* HutchTests */, ); }; @@ -347,6 +408,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8BE083082F81000100000001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -371,6 +439,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8BE083072F81000100000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -384,6 +459,11 @@ target = 8BE081F72F80000100000001 /* HutchWidgetExtension */; targetProxy = 8BE082022F80000100000001 /* PBXContainerItemProxy */; }; + 8BE0830A2F81000100000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8BE083092F81000100000001 /* HutchSafariExtension */; + targetProxy = 8BE083022F81000100000001 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -517,7 +597,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 84; + CURRENT_PROJECT_VERSION = 85; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -534,7 +614,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.3.1; + MARKETING_VERSION = 3.4.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -554,7 +634,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 84; + CURRENT_PROJECT_VERSION = 85; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -571,7 +651,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.3.1; + MARKETING_VERSION = 3.4.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -634,7 +714,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 84; + CURRENT_PROJECT_VERSION = 85; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -644,7 +724,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.3.1; + MARKETING_VERSION = 3.4.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -663,7 +743,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 84; + CURRENT_PROJECT_VERSION = 85; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -673,7 +753,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.3.1; + MARKETING_VERSION = 3.4.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -686,6 +766,64 @@ }; name = Release; }; + 8BE0830B2F81000100000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 84; + DEVELOPMENT_TEAM = ZCNAX3VL9D; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = HutchSafariExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 3.3.1; + PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 8BE0830C2F81000100000001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 84; + DEVELOPMENT_TEAM = ZCNAX3VL9D; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = HutchSafariExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 3.3.1; + PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -725,6 +863,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 8BE0830D2F81000100000001 /* Build configuration list for PBXNativeTarget "HutchSafariExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8BE0830B2F81000100000001 /* Debug */, + 8BE0830C2F81000100000001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ 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) { diff --git a/HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/Contents.json b/HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..fca77dc --- /dev/null +++ b/HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "hutch_light.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "hutch_light_dark.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/hutch_light.png b/HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/hutch_light.png Binary files differnew file mode 100644 index 0000000..cc1ce81 --- /dev/null +++ b/HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/hutch_light.png diff --git a/HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/hutch_light_dark.png b/HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/hutch_light_dark.png Binary files differnew file mode 100644 index 0000000..cc1ce81 --- /dev/null +++ b/HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/hutch_light_dark.png diff --git a/HutchSafariExtension/Info.plist b/HutchSafariExtension/Info.plist new file mode 100644 index 0000000..698ec43 --- /dev/null +++ b/HutchSafariExtension/Info.plist @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleDevelopmentRegion</key> + <string>$(DEVELOPMENT_LANGUAGE)</string> + <key>CFBundleDisplayName</key> + <string>Open in Hutch</string> + <key>CFBundleExecutable</key> + <string>$(EXECUTABLE_NAME)</string> + <key>CFBundleIdentifier</key> + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>CFBundleName</key> + <string>$(PRODUCT_NAME)</string> + <key>CFBundlePackageType</key> + <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> + <key>CFBundleShortVersionString</key> + <string>$(MARKETING_VERSION)</string> + <key>CFBundleVersion</key> + <string>$(CURRENT_PROJECT_VERSION)</string> + <key>NSExtension</key> + <dict> + <key>NSExtensionPointIdentifier</key> + <string>com.apple.Safari.web-extension</string> + <key>NSExtensionPrincipalClass</key> + <string>$(PRODUCT_MODULE_NAME).SafariWebExtensionHandler</string> + </dict> +</dict> +</plist> diff --git a/HutchSafariExtension/Resources/background.js b/HutchSafariExtension/Resources/background.js new file mode 100644 index 0000000..81bf8b6 --- /dev/null +++ b/HutchSafariExtension/Resources/background.js @@ -0,0 +1,115 @@ +const supportedHosts = new Set([ + "git.sr.ht", + "hg.sr.ht", + "todo.sr.ht", + "builds.sr.ht", + "lists.sr.ht", + "meta.sr.ht", + "sr.ht", +]); + +const browserAPI = globalThis.browser ?? globalThis.chrome; + +function isOwnerRootPath(path) { + const components = path.split("/").filter(Boolean); + return components.length === 1 && components[0].startsWith("~"); +} + +function normalizedPath(pathname) { + return pathname.split("/").filter(Boolean).join("/"); +} + +function deepLinkPath(hostname, path) { + const components = path.split("/").filter(Boolean); + if ( + hostname === "sr.ht" && + components.length === 2 && + components[0] === "projects" && + components[1].startsWith("~") + ) { + return components[1]; + } + + return path; +} + +function deepLinkService(hostname, path) { + if (isOwnerRootPath(path)) { + return "lookup"; + } + + switch (hostname) { + case "git.sr.ht": + return "git"; + case "hg.sr.ht": + return "hg"; + case "todo.sr.ht": + return "todo"; + case "builds.sr.ht": + return "builds"; + case "lists.sr.ht": + return "lists"; + default: + return "lookup"; + } +} + +function hutchDeepLinkFor(rawURL) { + let url; + try { + url = new URL(rawURL); + } catch { + return null; + } + + if (url.protocol !== "https:" || !supportedHosts.has(url.hostname)) { + return null; + } + + const path = deepLinkPath(url.hostname, normalizedPath(url.pathname)); + const service = deepLinkService(url.hostname, path); + return `hutch://${service}${path ? `/${path}` : ""}${url.search}${url.hash}`; +} + +function showUnsupportedMessage(tabId) { + if (!tabId) { + return; + } + + const message = "Open in Hutch supports git, hg, todo, builds, lists, meta, and sr.ht pages."; + + if (browserAPI?.scripting?.executeScript) { + browserAPI.scripting.executeScript({ + target: { tabId }, + func: (text) => { + window.alert(text); + }, + args: [message], + }); + return; + } + + browserAPI?.tabs?.sendMessage?.(tabId, { type: "hutchUnsupportedPage", message }); +} + +function openURL(tabId, url) { + if (browserAPI?.tabs?.update && tabId) { + browserAPI.tabs.update(tabId, { url }); + return; + } + + if (browserAPI?.tabs?.create) { + browserAPI.tabs.create({ url }); + } +} + +browserAPI.action.onClicked.addListener((tab) => { + const hutchURL = tab?.url ? hutchDeepLinkFor(tab.url) : null; + if (!hutchURL) { + showUnsupportedMessage(tab?.id); + return; + } + + console.debug("[Hutch] Opening deep link", { sourceURL: tab.url, hutchURL }); + openURL(tab.id, hutchURL); +}); diff --git a/HutchSafariExtension/Resources/content.js b/HutchSafariExtension/Resources/content.js new file mode 100644 index 0000000..dba7c5d --- /dev/null +++ b/HutchSafariExtension/Resources/content.js @@ -0,0 +1,183 @@ +const hutchSupportedHosts = new Set([ + "git.sr.ht", + "hg.sr.ht", + "todo.sr.ht", + "builds.sr.ht", + "lists.sr.ht", + "meta.sr.ht", + "sr.ht", +]); + +const hutchBrowserAPI = globalThis.browser ?? globalThis.chrome; +const bannerStorageKey = "showOpenInHutchBanner"; +const dismissedStorageKey = `dismissedOpenInHutchBanner:${location.hostname}`; + +function hutchIsOwnerRootPath(path) { + const components = path.split("/").filter(Boolean); + return components.length === 1 && components[0].startsWith("~"); +} + +function hutchNormalizedPath(pathname) { + return pathname.split("/").filter(Boolean).join("/"); +} + +function hutchDeepLinkPath(hostname, path) { + const components = path.split("/").filter(Boolean); + if ( + hostname === "sr.ht" && + components.length === 2 && + components[0] === "projects" && + components[1].startsWith("~") + ) { + return components[1]; + } + + return path; +} + +function hutchDeepLinkService(hostname, path) { + if (hutchIsOwnerRootPath(path)) { + return "lookup"; + } + + switch (hostname) { + case "git.sr.ht": + return "git"; + case "hg.sr.ht": + return "hg"; + case "todo.sr.ht": + return "todo"; + case "builds.sr.ht": + return "builds"; + case "lists.sr.ht": + return "lists"; + default: + return "lookup"; + } +} + +function hutchDeepLinkForLocation() { + if (location.protocol !== "https:" || !hutchSupportedHosts.has(location.hostname)) { + return null; + } + + const path = hutchDeepLinkPath(location.hostname, hutchNormalizedPath(location.pathname)); + const service = hutchDeepLinkService(location.hostname, path); + return `hutch://${service}${path ? `/${path}` : ""}${location.search}${location.hash}`; +} + +function storageGet(defaults) { + return new Promise((resolve) => { + hutchBrowserAPI.storage.local.get(defaults, resolve); + }); +} + +function storageSet(values) { + return new Promise((resolve) => { + hutchBrowserAPI.storage.local.set(values, resolve); + }); +} + +function showNotice(message) { + const existing = document.getElementById("hutch-open-notice"); + existing?.remove(); + + const notice = document.createElement("div"); + notice.id = "hutch-open-notice"; + notice.textContent = message; + notice.style.cssText = [ + "position:fixed", + "left:50%", + "bottom:18px", + "z-index:2147483647", + "transform:translateX(-50%)", + "background:#1f2937", + "color:white", + "font:13px -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif", + "padding:8px 12px", + "border-radius:8px", + "box-shadow:0 8px 24px rgba(0,0,0,.24)", + ].join(";"); + + document.documentElement.appendChild(notice); + setTimeout(() => notice.remove(), 3500); +} + +async function injectBannerIfEnabled() { + const hutchURL = hutchDeepLinkForLocation(); + if (!hutchURL || document.getElementById("hutch-open-banner")) { + return; + } + + const settings = await storageGet({ + [bannerStorageKey]: false, + [dismissedStorageKey]: false, + }); + if (!settings[bannerStorageKey] || settings[dismissedStorageKey]) { + return; + } + + const banner = document.createElement("div"); + banner.id = "hutch-open-banner"; + banner.style.cssText = [ + "position:fixed", + "right:14px", + "bottom:14px", + "z-index:2147483647", + "display:flex", + "align-items:center", + "gap:8px", + "background:#111827", + "color:white", + "font:13px -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif", + "padding:8px 10px", + "border-radius:8px", + "box-shadow:0 8px 24px rgba(0,0,0,.22)", + ].join(";"); + + const button = document.createElement("button"); + button.type = "button"; + button.textContent = "Open in Hutch"; + button.style.cssText = [ + "appearance:none", + "border:0", + "background:transparent", + "color:inherit", + "font:inherit", + "font-weight:600", + "padding:0", + ].join(";"); + button.addEventListener("click", () => { + console.debug("[Hutch] Banner opening deep link", { sourceURL: location.href, hutchURL }); + location.href = hutchURL; + }); + + const dismiss = document.createElement("button"); + dismiss.type = "button"; + dismiss.textContent = "Close"; + dismiss.setAttribute("aria-label", "Dismiss Open in Hutch banner"); + dismiss.style.cssText = [ + "appearance:none", + "border:0", + "background:rgba(255,255,255,.14)", + "color:inherit", + "font:12px -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif", + "padding:3px 6px", + "border-radius:6px", + ].join(";"); + dismiss.addEventListener("click", async () => { + await storageSet({ [dismissedStorageKey]: true }); + banner.remove(); + }); + + banner.append(button, dismiss); + document.documentElement.appendChild(banner); +} + +hutchBrowserAPI.runtime.onMessage.addListener((message) => { + if (message?.type === "hutchUnsupportedPage") { + showNotice(message.message); + } +}); + +injectBannerIfEnabled(); diff --git a/HutchSafariExtension/Resources/hutch_light.png b/HutchSafariExtension/Resources/hutch_light.png Binary files differnew file mode 100644 index 0000000..cc1ce81 --- /dev/null +++ b/HutchSafariExtension/Resources/hutch_light.png diff --git a/HutchSafariExtension/Resources/icon-128.png b/HutchSafariExtension/Resources/icon-128.png Binary files differnew file mode 100644 index 0000000..cce234a --- /dev/null +++ b/HutchSafariExtension/Resources/icon-128.png diff --git a/HutchSafariExtension/Resources/icon-16.png b/HutchSafariExtension/Resources/icon-16.png Binary files differnew file mode 100644 index 0000000..da0eaaf --- /dev/null +++ b/HutchSafariExtension/Resources/icon-16.png diff --git a/HutchSafariExtension/Resources/icon-32.png b/HutchSafariExtension/Resources/icon-32.png Binary files differnew file mode 100644 index 0000000..5287f7d --- /dev/null +++ b/HutchSafariExtension/Resources/icon-32.png diff --git a/HutchSafariExtension/Resources/icon-48.png b/HutchSafariExtension/Resources/icon-48.png Binary files differnew file mode 100644 index 0000000..9cfb800 --- /dev/null +++ b/HutchSafariExtension/Resources/icon-48.png diff --git a/HutchSafariExtension/Resources/manifest.json b/HutchSafariExtension/Resources/manifest.json new file mode 100644 index 0000000..7024f6d --- /dev/null +++ b/HutchSafariExtension/Resources/manifest.json @@ -0,0 +1,57 @@ +{ + "manifest_version": 3, + "name": "Open in Hutch", + "version": "1.0.0", + "description": "Open supported SourceHut pages in Hutch.", + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + }, + "action": { + "default_title": "Open in Hutch", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "background": { + "scripts": [ + "background.js" + ] + }, + "content_scripts": [ + { + "matches": [ + "https://git.sr.ht/*", + "https://hg.sr.ht/*", + "https://todo.sr.ht/*", + "https://builds.sr.ht/*", + "https://lists.sr.ht/*", + "https://meta.sr.ht/*", + "https://sr.ht/*" + ], + "js": [ + "content.js" + ], + "run_at": "document_idle" + } + ], + "permissions": [ + "activeTab", + "scripting", + "storage" + ], + "host_permissions": [ + "https://git.sr.ht/*", + "https://hg.sr.ht/*", + "https://todo.sr.ht/*", + "https://builds.sr.ht/*", + "https://lists.sr.ht/*", + "https://meta.sr.ht/*", + "https://sr.ht/*" + ] +} diff --git a/HutchSafariExtension/SafariWebExtensionHandler.swift b/HutchSafariExtension/SafariWebExtensionHandler.swift new file mode 100644 index 0000000..8a85cd4 --- /dev/null +++ b/HutchSafariExtension/SafariWebExtensionHandler.swift @@ -0,0 +1,7 @@ +import Foundation + +final class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { + func beginRequest(with context: NSExtensionContext) { + context.completeRequest(returningItems: [], completionHandler: nil) + } +} diff --git a/HutchTests/AppStateTests.swift b/HutchTests/AppStateTests.swift index 88a8272..b7e1755 100644 --- a/HutchTests/AppStateTests.swift +++ b/HutchTests/AppStateTests.swift @@ -6,6 +6,14 @@ struct AppStateTests { @Test @MainActor + func srhtUsernameNormalizesWebPathOwner() { + #expect(AppState.srhtUsername(from: "~vicho") == "vicho") + #expect(AppState.srhtUsername(from: " vicho ") == "vicho") + #expect(AppState.srhtUsername(from: " ~vicho ") == "vicho") + } + + @Test + @MainActor func presentRepositoryDeepLinkErrorSetsUserFacingMessage() { let appState = AppState() diff --git a/HutchTests/DeepLinkTests.swift b/HutchTests/DeepLinkTests.swift index c57cfce..500c4cd 100644 --- a/HutchTests/DeepLinkTests.swift +++ b/HutchTests/DeepLinkTests.swift @@ -19,7 +19,19 @@ struct DeepLinkTests { @Test func parsesRepositoryLink() { let link = DeepLink(url: HutchDeepLinkURL.repositoryGit) - #expect(link == .repository(owner: "~user", repo: "repo")) + #expect(link == .repository(service: .git, owner: "~user", repo: "repo")) + } + + @Test + func parsesHgRepositoryLink() { + let link = DeepLink(url: HutchDeepLinkURL.repositoryHg) + #expect(link == .repository(service: .hg, owner: "~user", repo: "repo")) + } + + @Test + func parsesServiceOwnerRootAsUserProfile() { + let link = DeepLink(url: HutchDeepLinkURL.gitOwnerRoot) + #expect(link == .userProfile(owner: "~owner")) } @Test @@ -35,6 +47,18 @@ struct DeepLinkTests { } @Test + func parsesBuildJobLinkWithOwnerPath() { + let link = DeepLink(url: HutchDeepLinkURL.buildJobWithOwner) + #expect(link == .build(jobId: 12345)) + } + + @Test + func parsesMailingListLink() { + let link = DeepLink(url: HutchDeepLinkURL.mailingList) + #expect(link == .mailingList(owner: "~owner", list: "list")) + } + + @Test func parsesBuildsTabLink() { let link = DeepLink(url: HutchDeepLinkURL.builds) #expect(link == .buildsTab) @@ -65,6 +89,12 @@ struct DeepLinkTests { } @Test + func parsesUserProfileLink() { + let link = DeepLink(url: HutchDeepLinkURL.userProfile) + #expect(link == .userProfile(owner: "~owner")) + } + + @Test func rejectsNonHutchScheme() { let link = DeepLink(url: URL(string: "https://example.com/home")!) #expect(link == nil) diff --git a/HutchTests/SourceHutWebDeepLinkMapperTests.swift b/HutchTests/SourceHutWebDeepLinkMapperTests.swift new file mode 100644 index 0000000..0fa3aaa --- /dev/null +++ b/HutchTests/SourceHutWebDeepLinkMapperTests.swift @@ -0,0 +1,44 @@ +import Foundation +import Testing +@testable import Hutch + +struct SourceHutWebDeepLinkMapperTests { + @Test + func mapsRepositoryURLs() { + #expect(mapped("https://git.sr.ht/~ccleberg/Hutch") == "hutch://git/~ccleberg/Hutch") + #expect(mapped("https://hg.sr.ht/~user/repo") == "hutch://hg/~user/repo") + } + + @Test + func mapsTrackerBuildAndListURLs() { + #expect(mapped("https://todo.sr.ht/~ccleberg/hutch/123") == "hutch://todo/~ccleberg/hutch/123") + #expect(mapped("https://builds.sr.ht/~ccleberg/job/12345") == "hutch://builds/~ccleberg/job/12345") + #expect(mapped("https://lists.sr.ht/~ccleberg/hutch-announce") == "hutch://lists/~ccleberg/hutch-announce") + } + + @Test + func mapsProfileHostsToLookup() { + #expect(mapped("https://sr.ht/~ccleberg") == "hutch://lookup/~ccleberg") + #expect(mapped("https://meta.sr.ht/~ccleberg") == "hutch://lookup/~ccleberg") + #expect(mapped("https://git.sr.ht/~ccleberg/") == "hutch://lookup/~ccleberg") + #expect(mapped("https://hg.sr.ht/~user") == "hutch://lookup/~user") + #expect(mapped("https://todo.sr.ht/~user") == "hutch://lookup/~user") + #expect(mapped("https://lists.sr.ht/~user") == "hutch://lookup/~user") + #expect(mapped("https://sr.ht/projects/~user") == "hutch://lookup/~user") + } + + @Test + func preservesQueryAndFragment() { + #expect(mapped("https://git.sr.ht/~ccleberg/Hutch/tree/main/item/README.md?plain=1#L10") == "hutch://git/~ccleberg/Hutch/tree/main/item/README.md?plain=1#L10") + } + + @Test + func rejectsUnsupportedHostsAndSchemes() { + #expect(SourceHutWebDeepLinkMapper.deepLink(for: URL(string: "https://chat.sr.ht")!) == nil) + #expect(SourceHutWebDeepLinkMapper.deepLink(for: URL(string: "http://git.sr.ht/~ccleberg/Hutch")!) == nil) + } + + private func mapped(_ string: String) -> String? { + SourceHutWebDeepLinkMapper.deepLink(for: URL(string: string)!)?.absoluteString + } +} diff --git a/Shared/HutchDeepLinkURLs.swift b/Shared/HutchDeepLinkURLs.swift index 9b60086..eed678c 100644 --- a/Shared/HutchDeepLinkURLs.swift +++ b/Shared/HutchDeepLinkURLs.swift @@ -5,13 +5,18 @@ enum HutchDeepLinkURL { static let home = URL(string: "hutch://home")! static let emptyHost = URL(string: "hutch://")! static let repositoryGit = URL(string: "hutch://git/~user/repo")! + static let repositoryHg = URL(string: "hutch://hg/~user/repo")! + static let gitOwnerRoot = URL(string: "hutch://git/~owner/")! static let ticket = URL(string: "hutch://todo/~owner/tracker/42")! static let buildJob = URL(string: "hutch://builds/12345")! + static let buildJobWithOwner = URL(string: "hutch://builds/~owner/job/12345")! + static let mailingList = URL(string: "hutch://lists/~owner/list")! static let trackers = URL(string: "hutch://trackers")! static let builds = URL(string: "hutch://builds")! static let repositories = URL(string: "hutch://repositories")! static let status = URL(string: "hutch://status")! static let lookup = URL(string: "hutch://lookup")! + static let userProfile = URL(string: "hutch://lookup/~owner")! static let unknown = URL(string: "hutch://unknown")! static let invalidTicketId = URL(string: "hutch://todo/~owner/tracker/abc")! static let invalidBuildId = URL(string: "hutch://builds/abc")! diff --git a/Shared/SourceHutWebDeepLinkMapper.swift b/Shared/SourceHutWebDeepLinkMapper.swift new file mode 100644 index 0000000..236e32e --- /dev/null +++ b/Shared/SourceHutWebDeepLinkMapper.swift @@ -0,0 +1,80 @@ +import Foundation + +enum SourceHutWebDeepLinkMapper { + static let supportedHosts: Set<String> = [ + "git.sr.ht", + "hg.sr.ht", + "todo.sr.ht", + "builds.sr.ht", + "lists.sr.ht", + "meta.sr.ht", + "sr.ht", + ] + + static func deepLink(for webURL: URL) -> URL? { + guard let components = URLComponents(url: webURL, resolvingAgainstBaseURL: false), + components.scheme == "https", + let host = components.host?.lowercased(), + supportedHosts.contains(host) + else { + return nil + } + + let path = normalizedPercentEncodedPath(from: components.percentEncodedPath) + let deepLinkPath = normalizedDeepLinkPath(for: host, path: path) + let service = deepLinkService(for: host, path: deepLinkPath) + var deepLinkComponents = URLComponents() + deepLinkComponents.scheme = "hutch" + deepLinkComponents.host = service + + if !deepLinkPath.isEmpty { + deepLinkComponents.percentEncodedPath = "/" + deepLinkPath + } + deepLinkComponents.percentEncodedQuery = components.percentEncodedQuery + deepLinkComponents.percentEncodedFragment = components.percentEncodedFragment + return deepLinkComponents.url + } + + private static func deepLinkService(for host: String, path: String) -> String { + if isOwnerRootPath(path) { + return "lookup" + } + + switch host { + case "git.sr.ht": + return "git" + case "hg.sr.ht": + return "hg" + case "todo.sr.ht": + return "todo" + case "builds.sr.ht": + return "builds" + case "lists.sr.ht": + return "lists" + default: + return "lookup" + } + } + + private static func isOwnerRootPath(_ path: String) -> Bool { + let components = path.split(separator: "/", omittingEmptySubsequences: true) + return components.count == 1 && components[0].hasPrefix("~") + } + + private static func normalizedDeepLinkPath(for host: String, path: String) -> String { + let components = path.split(separator: "/", omittingEmptySubsequences: true) + if host == "sr.ht", + components.count == 2, + components[0] == "projects", + components[1].hasPrefix("~") { + return String(components[1]) + } + return path + } + + private static func normalizedPercentEncodedPath(from path: String) -> String { + path + .split(separator: "/", omittingEmptySubsequences: true) + .joined(separator: "/") + } +} |
