summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch.xcodeproj/project.pbxproj163
-rw-r--r--Hutch/App/AppState.swift78
-rw-r--r--Hutch/App/DeepLink.swift64
-rw-r--r--Hutch/App/HutchApp.swift6
-rw-r--r--Hutch/App/RootView.swift103
-rw-r--r--Hutch/Models/User.swift2
-rw-r--r--Hutch/Views/Lookup/LookupView.swift2
-rw-r--r--Hutch/Views/Settings/SafariExtensionHelpView.swift59
-rw-r--r--Hutch/Views/Settings/SettingsView.swift17
-rw-r--r--HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/Contents.json26
-rw-r--r--HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/hutch_light.pngbin0 -> 47870 bytes
-rw-r--r--HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/hutch_light_dark.pngbin0 -> 47870 bytes
-rw-r--r--HutchSafariExtension/Info.plist31
-rw-r--r--HutchSafariExtension/Resources/background.js115
-rw-r--r--HutchSafariExtension/Resources/content.js183
-rw-r--r--HutchSafariExtension/Resources/hutch_light.pngbin0 -> 47870 bytes
-rw-r--r--HutchSafariExtension/Resources/icon-128.pngbin0 -> 4563 bytes
-rw-r--r--HutchSafariExtension/Resources/icon-16.pngbin0 -> 1825 bytes
-rw-r--r--HutchSafariExtension/Resources/icon-32.pngbin0 -> 2186 bytes
-rw-r--r--HutchSafariExtension/Resources/icon-48.pngbin0 -> 2533 bytes
-rw-r--r--HutchSafariExtension/Resources/manifest.json57
-rw-r--r--HutchSafariExtension/SafariWebExtensionHandler.swift7
-rw-r--r--HutchTests/AppStateTests.swift8
-rw-r--r--HutchTests/DeepLinkTests.swift32
-rw-r--r--HutchTests/SourceHutWebDeepLinkMapperTests.swift44
-rw-r--r--Shared/HutchDeepLinkURLs.swift5
-rw-r--r--Shared/SourceHutWebDeepLinkMapper.swift80
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
new file mode 100644
index 0000000..cc1ce81
--- /dev/null
+++ b/HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/hutch_light.png
Binary files 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
--- /dev/null
+++ b/HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/hutch_light_dark.png
Binary files 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
--- /dev/null
+++ b/HutchSafariExtension/Resources/hutch_light.png
Binary files differ
diff --git a/HutchSafariExtension/Resources/icon-128.png b/HutchSafariExtension/Resources/icon-128.png
new file mode 100644
index 0000000..cce234a
--- /dev/null
+++ b/HutchSafariExtension/Resources/icon-128.png
Binary files differ
diff --git a/HutchSafariExtension/Resources/icon-16.png b/HutchSafariExtension/Resources/icon-16.png
new file mode 100644
index 0000000..da0eaaf
--- /dev/null
+++ b/HutchSafariExtension/Resources/icon-16.png
Binary files differ
diff --git a/HutchSafariExtension/Resources/icon-32.png b/HutchSafariExtension/Resources/icon-32.png
new file mode 100644
index 0000000..5287f7d
--- /dev/null
+++ b/HutchSafariExtension/Resources/icon-32.png
Binary files differ
diff --git a/HutchSafariExtension/Resources/icon-48.png b/HutchSafariExtension/Resources/icon-48.png
new file mode 100644
index 0000000..9cfb800
--- /dev/null
+++ b/HutchSafariExtension/Resources/icon-48.png
Binary files 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
@@ -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: "/")
+ }
+}