krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

0d61e05e98f6f020722ee4d43f024c1da348d2a9

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-05-14T17:41:55Z

feat: add Safari extension for sourcehut deep links
 Hutch.xcodeproj/project.pbxproj                    | 163 +++++++++++++++++-
 Hutch/App/AppState.swift                           |  78 ++++++++-
 Hutch/App/DeepLink.swift                           |  64 +++++--
 Hutch/App/HutchApp.swift                           |   6 +
 Hutch/App/RootView.swift                           | 103 +++++++++++-
 Hutch/Models/User.swift                            |   2 +-
 Hutch/Views/Lookup/LookupView.swift                |   2 +
 Hutch/Views/Settings/SafariExtensionHelpView.swift |  59 +++++++
 Hutch/Views/Settings/SettingsView.swift            |  17 ++
 .../AppIcon.appiconset/Contents.json               |  26 +++
 .../AppIcon.appiconset/hutch_light.png             | Bin 0 -> 47870 bytes
 .../AppIcon.appiconset/hutch_light_dark.png        | Bin 0 -> 47870 bytes
 HutchSafariExtension/Info.plist                    |  31 ++++
 HutchSafariExtension/Resources/background.js       | 115 +++++++++++++
 HutchSafariExtension/Resources/content.js          | 183 +++++++++++++++++++++
 HutchSafariExtension/Resources/hutch_light.png     | Bin 0 -> 47870 bytes
 HutchSafariExtension/Resources/icon-128.png        | Bin 0 -> 4563 bytes
 HutchSafariExtension/Resources/icon-16.png         | Bin 0 -> 1825 bytes
 HutchSafariExtension/Resources/icon-32.png         | Bin 0 -> 2186 bytes
 HutchSafariExtension/Resources/icon-48.png         | Bin 0 -> 2533 bytes
 HutchSafariExtension/Resources/manifest.json       |  57 +++++++
 .../SafariWebExtensionHandler.swift                |   7 +
 HutchTests/AppStateTests.swift                     |   8 +
 HutchTests/DeepLinkTests.swift                     |  32 +++-
 HutchTests/SourceHutWebDeepLinkMapperTests.swift   |  44 +++++
 Shared/HutchDeepLinkURLs.swift                     |   5 +
 Shared/SourceHutWebDeepLinkMapper.swift            |  80 +++++++++
 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()
@@ -55,6 +56,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 {
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
new file mode 100644
index 0000000..cc1ce81
Binary files /dev/null and b/HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/hutch_light.png differ
diff --git a/HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/hutch_light_dark.png b/HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/hutch_light_dark.png
new file mode 100644
index 0000000..cc1ce81
Binary files /dev/null and b/HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/hutch_light_dark.png differ
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
new file mode 100644
index 0000000..cc1ce81
Binary files /dev/null and b/HutchSafariExtension/Resources/hutch_light.png differ
diff --git a/HutchSafariExtension/Resources/icon-128.png b/HutchSafariExtension/Resources/icon-128.png
new file mode 100644
index 0000000..cce234a
Binary files /dev/null and b/HutchSafariExtension/Resources/icon-128.png differ
diff --git a/HutchSafariExtension/Resources/icon-16.png b/HutchSafariExtension/Resources/icon-16.png
new file mode 100644
index 0000000..da0eaaf
Binary files /dev/null and b/HutchSafariExtension/Resources/icon-16.png differ
diff --git a/HutchSafariExtension/Resources/icon-32.png b/HutchSafariExtension/Resources/icon-32.png
new file mode 100644
index 0000000..5287f7d
Binary files /dev/null and b/HutchSafariExtension/Resources/icon-32.png differ
diff --git a/HutchSafariExtension/Resources/icon-48.png b/HutchSafariExtension/Resources/icon-48.png
new file mode 100644
index 0000000..9cfb800
Binary files /dev/null and b/HutchSafariExtension/Resources/icon-48.png differ
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
@@ -4,6 +4,14 @@ import Testing
 
 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() {
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
@@ -34,6 +46,18 @@ struct DeepLinkTests {
         #expect(link == .build(jobId: 12345))
     }
 
+    @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)
@@ -64,6 +88,12 @@ struct DeepLinkTests {
         #expect(link == .lookup)
     }
 
+    @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")!)
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: "/")
+    }
+}