Commit 5da0306f24

5da0306f24950c65358004f5be8bd3fbc6007dae

parent: bac14b2e22

Unsigned

cmc <hello@cleberg.net> · 2026-05-14 17:41 UTC

feat: add Safari extension for sourcehut deep links

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +155 −8
@@ -18,6 +18,7 @@
1818 8B2FA9D22F7E211800E609E2 /* todo.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9CA2F7E211800E609E2 /* todo.json */; };
1919 8B2FA9D32F7E211800E609E2 /* lists.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9C72F7E211800E609E2 /* lists.json */; };
2020 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
21 8BE083012F81000100000001 /* HutchSafariExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE083032F81000100000001 /* HutchSafariExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
2122 8BF100032F9A000100000001 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100022F9A000100000001 /* Markdown */; };
2223 8BF100052F9A000100000002 /* Splash in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100042F9A000100000002 /* Splash */; };
2324/* End PBXBuildFile section */
@@ -37,6 +38,13 @@
3738 remoteGlobalIDString = 8BE081F72F80000100000001;
3839 remoteInfo = HutchWidgetExtension;
3940 };
41 8BE083022F81000100000001 /* PBXContainerItemProxy */ = {
42 isa = PBXContainerItemProxy;
43 containerPortal = 8B4B28C92F6704280045FA19 /* Project object */;
44 proxyType = 1;
45 remoteGlobalIDString = 8BE083092F81000100000001;
46 remoteInfo = HutchSafariExtension;
47 };
4048/* End PBXContainerItemProxy section */
4149
4250/* Begin PBXCopyFilesBuildPhase section */
@@ -47,6 +55,7 @@
4755 dstSubfolderSpec = 13;
4856 files = (
4957 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed Foundation Extensions */,
58 8BE083012F81000100000001 /* HutchSafariExtension.appex in Embed Foundation Extensions */,
5059 );
5160 name = "Embed Foundation Extensions";
5261 runOnlyForDeploymentPostprocessing = 0;
@@ -70,6 +79,7 @@
7079 8B8182C22F6B742B000AE049 /* HutchTests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = HutchTests; sourceTree = "<group>"; };
7180 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = "<group>"; };
7281 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
82 8BE083032F81000100000001 /* HutchSafariExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchSafariExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
7383/* End PBXFileReference section */
7484
7585/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -87,6 +97,13 @@
8797 );
8898 target = 8BE081F72F80000100000001 /* HutchWidgetExtension */;
8999 };
100 8BE083042F81000100000001 /* Exceptions for "HutchSafariExtension" folder in "HutchSafariExtension" target */ = {
101 isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
102 membershipExceptions = (
103 Info.plist,
104 );
105 target = 8BE083092F81000100000001 /* HutchSafariExtension */;
106 };
90107/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
91108
92109/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -116,6 +133,14 @@
116133 path = HutchWidgetExtension;
117134 sourceTree = "<group>";
118135 };
136 8BE083052F81000100000001 /* HutchSafariExtension */ = {
137 isa = PBXFileSystemSynchronizedRootGroup;
138 exceptions = (
139 8BE083042F81000100000001 /* Exceptions for "HutchSafariExtension" folder in "HutchSafariExtension" target */,
140 );
141 path = HutchSafariExtension;
142 sourceTree = "<group>";
143 };
119144/* End PBXFileSystemSynchronizedRootGroup section */
120145
121146/* Begin PBXFrameworksBuildPhase section */
@@ -142,6 +167,13 @@
142167 );
143168 runOnlyForDeploymentPostprocessing = 0;
144169 };
170 8BE083062F81000100000001 /* Frameworks */ = {
171 isa = PBXFrameworksBuildPhase;
172 buildActionMask = 2147483647;
173 files = (
174 );
175 runOnlyForDeploymentPostprocessing = 0;
176 };
145177/* End PBXFrameworksBuildPhase section */
146178
147179/* Begin PBXGroup section */
@@ -180,6 +212,7 @@
180212 8B4B28D32F6704280045FA19 /* Hutch */,
181213 8BE081F42F80000100000001 /* Shared */,
182214 8BE081F52F80000100000001 /* HutchWidgetExtension */,
215 8BE083052F81000100000001 /* HutchSafariExtension */,
183216 8B8182B82F6B73F3000AE049 /* HutchTests */,
184217 8B4B28D22F6704280045FA19 /* Products */,
185218 );
@@ -191,6 +224,7 @@
191224 8B4B28D12F6704280045FA19 /* Hutch.app */,
192225 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */,
193226 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */,
227 8BE083032F81000100000001 /* HutchSafariExtension.appex */,
194228 );
195229 name = Products;
196230 sourceTree = "<group>";
@@ -211,6 +245,7 @@
211245 );
212246 dependencies = (
213247 8BE082042F80000100000001 /* PBXTargetDependency */,
248 8BE0830A2F81000100000001 /* PBXTargetDependency */,
214249 );
215250 fileSystemSynchronizedGroups = (
216251 8B4B28D32F6704280045FA19 /* Hutch */,
@@ -271,6 +306,28 @@
271306 productReference = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */;
272307 productType = "com.apple.product-type.app-extension";
273308 };
309 8BE083092F81000100000001 /* HutchSafariExtension */ = {
310 isa = PBXNativeTarget;
311 buildConfigurationList = 8BE0830D2F81000100000001 /* Build configuration list for PBXNativeTarget "HutchSafariExtension" */;
312 buildPhases = (
313 8BE083072F81000100000001 /* Sources */,
314 8BE083062F81000100000001 /* Frameworks */,
315 8BE083082F81000100000001 /* Resources */,
316 );
317 buildRules = (
318 );
319 dependencies = (
320 );
321 fileSystemSynchronizedGroups = (
322 8BE083052F81000100000001 /* HutchSafariExtension */,
323 );
324 name = HutchSafariExtension;
325 packageProductDependencies = (
326 );
327 productName = HutchSafariExtension;
328 productReference = 8BE083032F81000100000001 /* HutchSafariExtension.appex */;
329 productType = "com.apple.product-type.app-extension";
330 };
274331/* End PBXNativeTarget section */
275332
276333/* Begin PBXProject section */
@@ -288,6 +345,9 @@
288345 CreatedOnToolsVersion = 26.3;
289346 TestTargetID = 8B4B28D02F6704280045FA19;
290347 };
348 8BE083092F81000100000001 = {
349 CreatedOnToolsVersion = 26.3;
350 };
291351 };
292352 };
293353 buildConfigurationList = 8B4B28CC2F6704280045FA19 /* Build configuration list for PBXProject "Hutch" */;
@@ -310,6 +370,7 @@
310370 targets = (
311371 8B4B28D02F6704280045FA19 /* Hutch */,
312372 8BE081F72F80000100000001 /* HutchWidgetExtension */,
373 8BE083092F81000100000001 /* HutchSafariExtension */,
313374 8B8182B62F6B73F3000AE049 /* HutchTests */,
314375 );
315376 };
@@ -347,6 +408,13 @@
347408 );
348409 runOnlyForDeploymentPostprocessing = 0;
349410 };
411 8BE083082F81000100000001 /* Resources */ = {
412 isa = PBXResourcesBuildPhase;
413 buildActionMask = 2147483647;
414 files = (
415 );
416 runOnlyForDeploymentPostprocessing = 0;
417 };
350418/* End PBXResourcesBuildPhase section */
351419
352420/* Begin PBXSourcesBuildPhase section */
@@ -371,6 +439,13 @@
371439 );
372440 runOnlyForDeploymentPostprocessing = 0;
373441 };
442 8BE083072F81000100000001 /* Sources */ = {
443 isa = PBXSourcesBuildPhase;
444 buildActionMask = 2147483647;
445 files = (
446 );
447 runOnlyForDeploymentPostprocessing = 0;
448 };
374449/* End PBXSourcesBuildPhase section */
375450
376451/* Begin PBXTargetDependency section */
@@ -384,6 +459,11 @@
384459 target = 8BE081F72F80000100000001 /* HutchWidgetExtension */;
385460 targetProxy = 8BE082022F80000100000001 /* PBXContainerItemProxy */;
386461 };
462 8BE0830A2F81000100000001 /* PBXTargetDependency */ = {
463 isa = PBXTargetDependency;
464 target = 8BE083092F81000100000001 /* HutchSafariExtension */;
465 targetProxy = 8BE083022F81000100000001 /* PBXContainerItemProxy */;
466 };
387467/* End PBXTargetDependency section */
388468
389469/* Begin XCBuildConfiguration section */
@@ -517,7 +597,7 @@
517597 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
518598 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
519599 CODE_SIGN_STYLE = Automatic;
520 CURRENT_PROJECT_VERSION = 84;
600 CURRENT_PROJECT_VERSION = 85;
521601 DEVELOPMENT_TEAM = ZCNAX3VL9D;
522602 ENABLE_PREVIEWS = YES;
523603 GENERATE_INFOPLIST_FILE = YES;
@@ -534,7 +614,7 @@
534614 "$(inherited)",
535615 "@executable_path/Frameworks",
536616 );
537 MARKETING_VERSION = 3.3.1;
617 MARKETING_VERSION = 3.4.0;
538618 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
539619 PRODUCT_NAME = "$(TARGET_NAME)";
540620 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -554,7 +634,7 @@
554634 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
555635 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
556636 CODE_SIGN_STYLE = Automatic;
557 CURRENT_PROJECT_VERSION = 84;
637 CURRENT_PROJECT_VERSION = 85;
558638 DEVELOPMENT_TEAM = ZCNAX3VL9D;
559639 ENABLE_PREVIEWS = YES;
560640 GENERATE_INFOPLIST_FILE = YES;
@@ -571,7 +651,7 @@
571651 "$(inherited)",
572652 "@executable_path/Frameworks",
573653 );
574 MARKETING_VERSION = 3.3.1;
654 MARKETING_VERSION = 3.4.0;
575655 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
576656 PRODUCT_NAME = "$(TARGET_NAME)";
577657 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -634,7 +714,7 @@
634714 APPLICATION_EXTENSION_API_ONLY = YES;
635715 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
636716 CODE_SIGN_STYLE = Automatic;
637 CURRENT_PROJECT_VERSION = 84;
717 CURRENT_PROJECT_VERSION = 85;
638718 DEVELOPMENT_TEAM = ZCNAX3VL9D;
639719 GENERATE_INFOPLIST_FILE = NO;
640720 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -644,7 +724,7 @@
644724 "@executable_path/Frameworks",
645725 "@executable_path/../../Frameworks",
646726 );
647 MARKETING_VERSION = 3.3.1;
727 MARKETING_VERSION = 3.4.0;
648728 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
649729 PRODUCT_NAME = "$(TARGET_NAME)";
650730 SKIP_INSTALL = YES;
@@ -663,7 +743,7 @@
663743 APPLICATION_EXTENSION_API_ONLY = YES;
664744 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
665745 CODE_SIGN_STYLE = Automatic;
666 CURRENT_PROJECT_VERSION = 84;
746 CURRENT_PROJECT_VERSION = 85;
667747 DEVELOPMENT_TEAM = ZCNAX3VL9D;
668748 GENERATE_INFOPLIST_FILE = NO;
669749 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -673,7 +753,7 @@
673753 "@executable_path/Frameworks",
674754 "@executable_path/../../Frameworks",
675755 );
676 MARKETING_VERSION = 3.3.1;
756 MARKETING_VERSION = 3.4.0;
677757 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
678758 PRODUCT_NAME = "$(TARGET_NAME)";
679759 SKIP_INSTALL = YES;
@@ -686,6 +766,64 @@
686766 };
687767 name = Release;
688768 };
769 8BE0830B2F81000100000001 /* Debug */ = {
770 isa = XCBuildConfiguration;
771 buildSettings = {
772 APPLICATION_EXTENSION_API_ONLY = YES;
773 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
774 CODE_SIGN_STYLE = Automatic;
775 CURRENT_PROJECT_VERSION = 84;
776 DEVELOPMENT_TEAM = ZCNAX3VL9D;
777 GENERATE_INFOPLIST_FILE = NO;
778 INFOPLIST_FILE = HutchSafariExtension/Info.plist;
779 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
780 LD_RUNPATH_SEARCH_PATHS = (
781 "$(inherited)",
782 "@executable_path/Frameworks",
783 "@executable_path/../../Frameworks",
784 );
785 MARKETING_VERSION = 3.3.1;
786 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
787 PRODUCT_NAME = "$(TARGET_NAME)";
788 SKIP_INSTALL = YES;
789 STRING_CATALOG_GENERATE_SYMBOLS = YES;
790 SWIFT_APPROACHABLE_CONCURRENCY = YES;
791 SWIFT_EMIT_LOC_STRINGS = YES;
792 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
793 SWIFT_VERSION = 5.0;
794 TARGETED_DEVICE_FAMILY = "1,2";
795 };
796 name = Debug;
797 };
798 8BE0830C2F81000100000001 /* Release */ = {
799 isa = XCBuildConfiguration;
800 buildSettings = {
801 APPLICATION_EXTENSION_API_ONLY = YES;
802 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
803 CODE_SIGN_STYLE = Automatic;
804 CURRENT_PROJECT_VERSION = 84;
805 DEVELOPMENT_TEAM = ZCNAX3VL9D;
806 GENERATE_INFOPLIST_FILE = NO;
807 INFOPLIST_FILE = HutchSafariExtension/Info.plist;
808 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
809 LD_RUNPATH_SEARCH_PATHS = (
810 "$(inherited)",
811 "@executable_path/Frameworks",
812 "@executable_path/../../Frameworks",
813 );
814 MARKETING_VERSION = 3.3.1;
815 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
816 PRODUCT_NAME = "$(TARGET_NAME)";
817 SKIP_INSTALL = YES;
818 STRING_CATALOG_GENERATE_SYMBOLS = YES;
819 SWIFT_APPROACHABLE_CONCURRENCY = YES;
820 SWIFT_EMIT_LOC_STRINGS = YES;
821 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
822 SWIFT_VERSION = 5.0;
823 TARGETED_DEVICE_FAMILY = "1,2";
824 };
825 name = Release;
826 };
689827/* End XCBuildConfiguration section */
690828
691829/* Begin XCConfigurationList section */
@@ -725,6 +863,15 @@
725863 defaultConfigurationIsVisible = 0;
726864 defaultConfigurationName = Release;
727865 };
866 8BE0830D2F81000100000001 /* Build configuration list for PBXNativeTarget "HutchSafariExtension" */ = {
867 isa = XCConfigurationList;
868 buildConfigurations = (
869 8BE0830B2F81000100000001 /* Debug */,
870 8BE0830C2F81000100000001 /* Release */,
871 );
872 defaultConfigurationIsVisible = 0;
873 defaultConfigurationName = Release;
874 };
728875/* End XCConfigurationList section */
729876
730877/* Begin XCRemoteSwiftPackageReference section */
Hutch/App/AppState.swift +76 −2
@@ -2,6 +2,9 @@ import Foundation
22import SwiftUI
33import UIKit
44import WebKit
5import os
6
7private let appStateDeepLinkLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
58
69/// Central application state shared across the view hierarchy.
710@Observable
@@ -260,10 +263,11 @@ final class AppState {
260263
261264 /// Resolve a repository by owner and name for deep linking.
262265 func resolveRepository(owner: String, name: String, service: SRHTService = .git) async throws -> RepositorySummary {
266 let normalizedOwner = Self.srhtUsername(from: owner)
263267 let result = try await client.execute(
264268 service: service,
265269 query: Self.repoLookupQuery,
266 variables: ["owner": owner, "name": name],
270 variables: ["owner": normalizedOwner, "name": name],
267271 responseType: RepoLookupResponse.self
268272 )
269273 return result.user.repository
@@ -271,15 +275,39 @@ final class AppState {
271275
272276 /// Resolve a tracker by owner and name for deep linking.
273277 func resolveTracker(owner: String, name: String) async throws -> TrackerSummary {
278 let normalizedOwner = Self.srhtUsername(from: owner)
274279 let result = try await client.execute(
275280 service: .todo,
276281 query: Self.trackerLookupQuery,
277 variables: ["owner": owner, "name": name],
282 variables: ["owner": normalizedOwner, "name": name],
278283 responseType: TrackerLookupResponse.self
279284 )
280285 return result.user.tracker
281286 }
282287
288 func resolveMailingList(owner: String, name: String) async throws -> InboxMailingListReference {
289 let normalizedOwner = Self.srhtUsername(from: owner)
290 let result = try await client.execute(
291 service: .lists,
292 query: Self.mailingListLookupQuery,
293 variables: ["owner": normalizedOwner, "name": name],
294 responseType: MailingListLookupResponse.self
295 )
296 return result.user.mailingList
297 }
298
299 func resolveUser(username: String) async throws -> User {
300 let normalizedUsername = Self.srhtUsername(from: username)
301 appStateDeepLinkLogger.info("Resolving user. raw=\(username, privacy: .public), normalized=\(normalizedUsername, privacy: .public)")
302 let result = try await client.execute(
303 service: .meta,
304 query: Self.userLookupQuery,
305 variables: ["username": normalizedUsername],
306 responseType: UserLookupResponse.self
307 )
308 return result.user
309 }
310
283311 func resolveProjectSource(_ source: Project.SourceRepo) async throws -> RepositorySummary {
284312 try await resolveRepository(
285313 owner: source.ownerUsername,
@@ -384,6 +412,11 @@ final class AppState {
384412 return result.me
385413 }
386414
415 static func srhtUsername(from value: String) -> String {
416 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
417 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
418 }
419
387420 // MARK: - Deep link queries
388421
389422 private static let repoLookupQuery = """
@@ -425,6 +458,47 @@ final class AppState {
425458 let tracker: TrackerSummary
426459 }
427460
461 private static let mailingListLookupQuery = """
462 query mailingListLookup($owner: String!, $name: String!) {
463 user(username: $owner) {
464 mailingList: list(name: $name) {
465 id rid name owner { canonicalName }
466 }
467 }
468 }
469 """
470
471 private struct MailingListLookupResponse: Decodable, Sendable {
472 let user: MailingListLookupUser
473 }
474
475 private struct MailingListLookupUser: Decodable, Sendable {
476 let mailingList: InboxMailingListReference
477 }
478
479 private static let userLookupQuery = """
480 query userLookup($username: String!) {
481 user: userByName(username: $username) {
482 id
483 created
484 updated
485 canonicalName
486 username
487 email
488 url
489 location
490 bio
491 avatar
492 pronouns
493 userType
494 }
495 }
496 """
497
498 private struct UserLookupResponse: Decodable, Sendable {
499 let user: User
500 }
501
428502 private func addValidatedAccount(token: String, activateNewAccount: Bool) async throws {
429503 let tempClient = SRHTClient(token: token)
430504 let user = try await fetchMe(using: tempClient)
Hutch/App/DeepLink.swift +47 −17
@@ -1,15 +1,22 @@
11import Foundation
2import os
3
4private let deepLinkParserLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
25
36/// Represents a parsed `hutch://` deep link.
47enum DeepLink: Equatable {
58 case home
69 case work
7 /// hutch://git/<owner>/<repo>
8 case repository(owner: String, repo: String)
10 /// hutch://git/<owner>/<repo> or hutch://hg/<owner>/<repo>
11 case repository(service: SRHTService, owner: String, repo: String)
912 /// hutch://todo/<owner>/<tracker>/<ticketId>
1013 case ticket(owner: String, tracker: String, ticketId: Int)
11 /// hutch://builds/<jobId>
14 /// hutch://builds/<jobId> or hutch://builds/<owner>/job/<jobId>
1215 case build(jobId: Int)
16 /// hutch://lists/<owner>/<list>
17 case mailingList(owner: String, list: String)
18 /// hutch://lookup/<owner>
19 case userProfile(owner: String)
1320 /// hutch://builds (tab-level)
1421 case buildsTab
1522 /// hutch://repositories (tab-level)
@@ -26,9 +33,8 @@ enum DeepLink: Equatable {
2633 init?(url: URL) {
2734 guard url.scheme == "hutch" else { return nil }
2835
29 // url.host gives the first path component for opaque URLs;
30 // use standardized path components from the full string.
31 let components = url.pathComponents(fromScheme: "hutch")
36 let components = url.deepLinkPathComponents
37 deepLinkParserLogger.info("DeepLink parser components for \(url.absoluteString, privacy: .public): \(components.joined(separator: ","), privacy: .public)")
3238
3339 switch components.first {
3440 case "home", nil:
@@ -37,10 +43,18 @@ enum DeepLink: Equatable {
3743 case "work", "inbox":
3844 self = .work
3945
40 case "git" where components.count >= 3:
46 case let .some(serviceName) where ["git", "hg", "todo", "builds", "lists"].contains(serviceName)
47 && components.count == 2
48 && components[1].hasPrefix("~"):
49 deepLinkParserLogger.info("Treating owner-root service URL as user profile: service=\(serviceName, privacy: .public), owner=\(components[1], privacy: .public)")
50 self = .userProfile(owner: components[1])
51
52 case let .some(serviceName) where ["git", "hg"].contains(serviceName) && components.count >= 3:
53 guard let service = SRHTService(rawValue: serviceName)
54 else { return nil }
4155 let owner = components[1]
4256 let repo = components[2]
43 self = .repository(owner: owner, repo: repo)
57 self = .repository(service: service, owner: owner, repo: repo)
4458
4559 case "todo" where components.count >= 4:
4660 let owner = components[1]
@@ -49,12 +63,21 @@ enum DeepLink: Equatable {
4963 self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId)
5064
5165 case "builds" where components.count >= 2:
52 guard let jobId = Int(components[1]) else { return nil }
66 let rawJobId: String
67 if components.count >= 4, components[2] == "job" {
68 rawJobId = components[3]
69 } else {
70 rawJobId = components[1]
71 }
72 guard let jobId = Int(rawJobId) else { return nil }
5373 self = .build(jobId: jobId)
5474
5575 case "builds":
5676 self = .buildsTab
5777
78 case "lists" where components.count >= 3:
79 self = .mailingList(owner: components[1], list: components[2])
80
5881 case "repositories":
5982 self = .repositoriesTab
6083
@@ -64,6 +87,9 @@ enum DeepLink: Equatable {
6487 case "status":
6588 self = .systemStatus
6689
90 case "lookup" where components.count >= 2:
91 self = .userProfile(owner: components[1])
92
6793 case "lookup":
6894 self = .lookup
6995
@@ -74,14 +100,18 @@ enum DeepLink: Equatable {
74100}
75101
76102private extension URL {
77 /// Parse path components from a custom-scheme URL.
78 /// For `hutch://git/~user/repo`, returns `["git", "~user", "repo"]`.
79 func pathComponents(fromScheme scheme: String) -> [String] {
80 // Remove scheme prefix and split by "/"
81 var str = absoluteString
82 if str.hasPrefix("\(scheme)://") {
83 str = String(str.dropFirst("\(scheme)://".count))
103 var deepLinkPathComponents: [String] {
104 guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false) else {
105 return []
106 }
107
108 let pathComponents = components.path
109 .split(separator: "/", omittingEmptySubsequences: true)
110 .map(String.init)
111
112 if let host = components.host, !host.isEmpty {
113 return [host] + pathComponents
84114 }
85 return str.split(separator: "/").map(String.init)
115 return pathComponents
86116 }
87117}
Hutch/App/HutchApp.swift +6
@@ -1,7 +1,9 @@
11import SwiftUI
2import os
23
34@main
45struct HutchApp: App {
6 private let deepLinkLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
57 @State private var appState = AppState()
68 @State private var networkMonitor = NetworkMonitor()
79 @AppStorage(AppStorageKeys.appTheme, store: .standard) private var appTheme: AppTheme = .system
@@ -18,8 +20,12 @@ struct HutchApp: App {
1820 .background(appTheme == .amoled ? Color.black : Color.clear)
1921 .preferredColorScheme(appTheme.colorScheme)
2022 .onOpenURL { url in
23 deepLinkLogger.info("Received URL: \(url.absoluteString, privacy: .public)")
2124 if let link = DeepLink(url: url) {
25 deepLinkLogger.info("Parsed deep link: \(String(describing: link), privacy: .public)")
2226 appState.pendingDeepLink = link
27 } else {
28 deepLinkLogger.error("Rejected URL: \(url.absoluteString, privacy: .public)")
2329 }
2430 }
2531 .onChange(of: HutchIntentNavigator.shared.pendingDestination) { _, destination in
Hutch/App/RootView.swift +97 −6
@@ -1,4 +1,7 @@
11import SwiftUI
2import os
3
4private let rootDeepLinkLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
25
36/// The root view of the app. Shows a TabView when authenticated, or a
47/// full-screen sheet for token entry on first launch.
@@ -154,6 +157,7 @@ struct RootView: View {
154157 // MARK: - Deep Link Handling
155158
156159 private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) {
160 rootDeepLinkLogger.info("Auth phase changed: \(String(describing: newPhase), privacy: .public); pendingDeepLink=\(String(describing: appState.pendingDeepLink), privacy: .public)")
157161 switch newPhase {
158162 case .launching:
159163 break
@@ -171,7 +175,11 @@ struct RootView: View {
171175 }
172176
173177 private func consumePendingDeepLinkIfPossible(_ link: DeepLink?) {
174 guard appState.isAuthenticated, let link else { return }
178 rootDeepLinkLogger.info("Attempting to consume pending deep link. authenticated=\(appState.isAuthenticated, privacy: .public), link=\(String(describing: link), privacy: .public)")
179 guard appState.isAuthenticated, let link else {
180 rootDeepLinkLogger.info("Deferred deep link consumption.")
181 return
182 }
175183 handleDeepLink(link)
176184 appState.pendingDeepLink = nil
177185 }
@@ -183,15 +191,19 @@ struct RootView: View {
183191 }
184192
185193 private func handleDeepLink(_ link: DeepLink) {
186 guard appState.isAuthenticated else { return }
194 rootDeepLinkLogger.info("Handling deep link: \(String(describing: link), privacy: .public)")
195 guard appState.isAuthenticated else {
196 rootDeepLinkLogger.info("Ignoring deep link while unauthenticated: \(String(describing: link), privacy: .public)")
197 return
198 }
187199
188200 switch link {
189201 case .home:
190202 homePath = NavigationPath()
191203 appState.selectedTab = .home
192204
193 case .repository(let owner, let repo):
194 resolveRepositoryLink(owner: owner, repo: repo)
205 case .repository(let service, let owner, let repo):
206 resolveRepositoryLink(service: service, owner: owner, repo: repo)
195207
196208 case .build(let jobId):
197209 buildsPath = NavigationPath()
@@ -204,6 +216,12 @@ struct RootView: View {
204216 case .ticket(let owner, let tracker, let ticketId):
205217 resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
206218
219 case .mailingList(let owner, let list):
220 resolveMailingListLink(owner: owner, list: list)
221
222 case .userProfile(let owner):
223 resolveUserProfileLink(owner: owner)
224
207225 case .work:
208226 homePath = NavigationPath()
209227 appState.selectedTab = .home
@@ -276,12 +294,12 @@ struct RootView: View {
276294 }
277295 }
278296
279 private func resolveRepositoryLink(owner: String, repo: String) {
297 private func resolveRepositoryLink(service: SRHTService, owner: String, repo: String) {
280298 isResolvingDeepLink = true
281299 Task {
282300 defer { isResolvingDeepLink = false }
283301 do {
284 let summary = try await appState.resolveRepository(owner: owner, name: repo)
302 let summary = try await appState.resolveRepository(owner: owner, name: repo, service: service)
285303 repoPath = NavigationPath()
286304 appState.selectedTab = .repositories
287305 await settleNavigationTransition()
@@ -292,6 +310,34 @@ struct RootView: View {
292310 }
293311 }
294312
313 private func resolveMailingListLink(owner: String, list: String) {
314 isResolvingDeepLink = true
315 Task {
316 defer { isResolvingDeepLink = false }
317 do {
318 let mailingList = try await appState.resolveMailingList(owner: owner, name: list)
319 morePath = NavigationPath()
320 appState.selectedTab = .more
321 await settleNavigationTransition()
322 morePath.append(MoreRoute.lists)
323 morePath.append(MoreRoute.mailingList(mailingList))
324 } catch {
325 appState.deepLinkError = "The mailing list could not be found or is inaccessible."
326 }
327 }
328 }
329
330 private func resolveUserProfileLink(owner: String) {
331 rootDeepLinkLogger.info("Routing user profile deep link for owner=\(owner, privacy: .public)")
332 morePath = NavigationPath()
333 appState.selectedTab = .more
334 Task {
335 await settleNavigationTransition()
336 rootDeepLinkLogger.info("Appending user profile route for owner=\(owner, privacy: .public)")
337 morePath.append(MoreRoute.userProfile(owner))
338 }
339 }
340
295341 private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
296342 isResolvingDeepLink = true
297343 Task {
@@ -337,6 +383,7 @@ enum MoreRoute: Hashable {
337383 case systemStatus
338384 case settings
339385 case about
386 case userProfile(String)
340387 case mailingList(InboxMailingListReference)
341388 case thread(InboxThreadSummary)
342389 case manPageBrowser
@@ -366,6 +413,8 @@ private struct MoreNavigationRoot: View {
366413 SettingsView()
367414 case .about:
368415 AboutView()
416 case .userProfile(let owner):
417 UserProfileDeepLinkView(owner: owner)
369418 case .mailingList(let mailingList):
370419 MailingListDetailView(mailingList: mailingList)
371420 case .thread(let thread):
@@ -393,6 +442,48 @@ private struct MoreNavigationRoot: View {
393442 }
394443}
395444
445struct UserProfileDeepLinkView: View {
446 private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
447 @Environment(AppState.self) private var appState
448 let owner: String
449 @State private var user: User?
450 @State private var errorMessage: String?
451
452 var body: some View {
453 Group {
454 if let user {
455 UserProfileView(user: user)
456 } else if let errorMessage {
457 ContentUnavailableView("Couldn't Open Profile", systemImage: "person.crop.circle.badge.exclamationmark", description: Text(errorMessage))
458 } else {
459 SRHTLoadingStateView(message: "Loading profile...")
460 }
461 }
462 .navigationTitle(displayOwner)
463 .navigationBarTitleDisplayMode(.inline)
464 .task(id: owner) {
465 await loadProfile()
466 }
467 }
468
469 private var displayOwner: String {
470 owner.hasPrefix("~") ? owner : "~\(owner)"
471 }
472
473 @MainActor
474 private func loadProfile() async {
475 logger.info("Loading user profile for owner=\(owner, privacy: .public)")
476 errorMessage = nil
477 do {
478 user = try await appState.resolveUser(username: owner)
479 logger.info("Loaded user profile for owner=\(owner, privacy: .public), canonical=\(user?.canonicalName ?? "nil", privacy: .public)")
480 } catch {
481 logger.error("Failed loading user profile for owner=\(owner, privacy: .public): \(String(describing: error), privacy: .public)")
482 errorMessage = "The user profile could not be found or is inaccessible."
483 }
484 }
485}
486
396487// MARK: - Ticket Deep Link Navigation Target
397488
398489/// Hashable wrapper to push a ticket detail view from a deep link.
Hutch/Models/User.swift +1 −1
@@ -1,7 +1,7 @@
11import Foundation
22
33/// A Sourcehut user from meta.sr.ht.
4struct User: Decodable, Sendable {
4struct User: Decodable, Sendable, Hashable {
55 let id: Int
66 let created: String?
77 let updated: String?
Hutch/Views/Lookup/LookupView.swift +2
@@ -443,6 +443,8 @@ struct LookupView: View {
443443 SettingsView()
444444 case .about:
445445 AboutView()
446 case .userProfile(let owner):
447 UserProfileDeepLinkView(owner: owner)
446448 case .mailingList(let mailingList):
447449 MailingListDetailView(mailingList: mailingList)
448450 case .thread(let thread):
Hutch/Views/Settings/SafariExtensionHelpView.swift added +59
@@ -0,0 +1,59 @@
1import SwiftUI
2
3struct SafariExtensionHelpView: View {
4 private let supportedServices = [
5 "git.sr.ht",
6 "hg.sr.ht",
7 "todo.sr.ht",
8 "builds.sr.ht",
9 "lists.sr.ht",
10 "meta.sr.ht",
11 "sr.ht",
12 ]
13
14 var body: some View {
15 Form {
16 Section {
17 Text("Enable Open in Hutch from Settings > Apps > Safari > Extensions, then turn on Hutch and allow it for SourceHut websites.")
18 .font(.subheadline)
19 .foregroundStyle(.secondary)
20 .themedRow()
21 } header: {
22 Text("Enable")
23 }
24
25 Section("Supported Services") {
26 ForEach(supportedServices, id: \.self) { service in
27 Label(service, systemImage: "checkmark.circle")
28 .themedRow()
29 }
30 }
31
32 Section {
33 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.")
34 .font(.subheadline)
35 .foregroundStyle(.secondary)
36 .themedRow()
37
38 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.")
39 .font(.subheadline)
40 .foregroundStyle(.secondary)
41 .themedRow()
42 } header: {
43 Text("How It Works")
44 }
45
46 Section {
47 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.")
48 .font(.subheadline)
49 .foregroundStyle(.secondary)
50 .themedRow()
51 } header: {
52 Text("Privacy")
53 }
54 }
55 .themedList()
56 .navigationTitle("Safari Extension")
57 .navigationBarTitleDisplayMode(.inline)
58 }
59}
Hutch/Views/Settings/SettingsView.swift +17
@@ -15,6 +15,7 @@ struct SettingsView: View {
1515 Form {
1616 appearanceSection()
1717 behaviorSection()
18 safariExtensionSection()
1819 authenticationSection()
1920 }
2021 .themedList()
@@ -55,6 +56,22 @@ struct SettingsView: View {
5556 }
5657 }
5758
59 @ViewBuilder
60 private func safariExtensionSection() -> some View {
61 Section {
62 NavigationLink {
63 SafariExtensionHelpView()
64 } label: {
65 Label("Safari Extension", systemImage: "safari")
66 }
67 .themedRow()
68 } header: {
69 Text("Browser")
70 } footer: {
71 Text("Open supported SourceHut pages in Hutch from Safari without claiming sr.ht links system-wide.")
72 }
73 }
74
5875 @ViewBuilder
5976 private func appearanceSection() -> some View {
6077 Section {
HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/Contents.json added +26
@@ -0,0 +1,26 @@
1{
2 "images" : [
3 {
4 "filename" : "hutch_light.png",
5 "idiom" : "universal",
6 "platform" : "ios",
7 "size" : "1024x1024"
8 },
9 {
10 "appearances" : [
11 {
12 "appearance" : "luminosity",
13 "value" : "dark"
14 }
15 ],
16 "filename" : "hutch_light_dark.png",
17 "idiom" : "universal",
18 "platform" : "ios",
19 "size" : "1024x1024"
20 }
21 ],
22 "info" : {
23 "author" : "xcode",
24 "version" : 1
25 }
26}
HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/hutch_light.png added

Binary file not shown.

HutchSafariExtension/Assets.xcassets/AppIcon.appiconset/hutch_light_dark.png added

Binary file not shown.

HutchSafariExtension/Info.plist added +31
@@ -0,0 +1,31 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>CFBundleDevelopmentRegion</key>
6 <string>$(DEVELOPMENT_LANGUAGE)</string>
7 <key>CFBundleDisplayName</key>
8 <string>Open in Hutch</string>
9 <key>CFBundleExecutable</key>
10 <string>$(EXECUTABLE_NAME)</string>
11 <key>CFBundleIdentifier</key>
12 <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13 <key>CFBundleInfoDictionaryVersion</key>
14 <string>6.0</string>
15 <key>CFBundleName</key>
16 <string>$(PRODUCT_NAME)</string>
17 <key>CFBundlePackageType</key>
18 <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
19 <key>CFBundleShortVersionString</key>
20 <string>$(MARKETING_VERSION)</string>
21 <key>CFBundleVersion</key>
22 <string>$(CURRENT_PROJECT_VERSION)</string>
23 <key>NSExtension</key>
24 <dict>
25 <key>NSExtensionPointIdentifier</key>
26 <string>com.apple.Safari.web-extension</string>
27 <key>NSExtensionPrincipalClass</key>
28 <string>$(PRODUCT_MODULE_NAME).SafariWebExtensionHandler</string>
29 </dict>
30</dict>
31</plist>
HutchSafariExtension/Resources/background.js added +115
@@ -0,0 +1,115 @@
1const supportedHosts = new Set([
2 "git.sr.ht",
3 "hg.sr.ht",
4 "todo.sr.ht",
5 "builds.sr.ht",
6 "lists.sr.ht",
7 "meta.sr.ht",
8 "sr.ht",
9]);
10
11const browserAPI = globalThis.browser ?? globalThis.chrome;
12
13function isOwnerRootPath(path) {
14 const components = path.split("/").filter(Boolean);
15 return components.length === 1 && components[0].startsWith("~");
16}
17
18function normalizedPath(pathname) {
19 return pathname.split("/").filter(Boolean).join("/");
20}
21
22function deepLinkPath(hostname, path) {
23 const components = path.split("/").filter(Boolean);
24 if (
25 hostname === "sr.ht" &&
26 components.length === 2 &&
27 components[0] === "projects" &&
28 components[1].startsWith("~")
29 ) {
30 return components[1];
31 }
32
33 return path;
34}
35
36function deepLinkService(hostname, path) {
37 if (isOwnerRootPath(path)) {
38 return "lookup";
39 }
40
41 switch (hostname) {
42 case "git.sr.ht":
43 return "git";
44 case "hg.sr.ht":
45 return "hg";
46 case "todo.sr.ht":
47 return "todo";
48 case "builds.sr.ht":
49 return "builds";
50 case "lists.sr.ht":
51 return "lists";
52 default:
53 return "lookup";
54 }
55}
56
57function hutchDeepLinkFor(rawURL) {
58 let url;
59 try {
60 url = new URL(rawURL);
61 } catch {
62 return null;
63 }
64
65 if (url.protocol !== "https:" || !supportedHosts.has(url.hostname)) {
66 return null;
67 }
68
69 const path = deepLinkPath(url.hostname, normalizedPath(url.pathname));
70 const service = deepLinkService(url.hostname, path);
71 return `hutch://${service}${path ? `/${path}` : ""}${url.search}${url.hash}`;
72}
73
74function showUnsupportedMessage(tabId) {
75 if (!tabId) {
76 return;
77 }
78
79 const message = "Open in Hutch supports git, hg, todo, builds, lists, meta, and sr.ht pages.";
80
81 if (browserAPI?.scripting?.executeScript) {
82 browserAPI.scripting.executeScript({
83 target: { tabId },
84 func: (text) => {
85 window.alert(text);
86 },
87 args: [message],
88 });
89 return;
90 }
91
92 browserAPI?.tabs?.sendMessage?.(tabId, { type: "hutchUnsupportedPage", message });
93}
94
95function openURL(tabId, url) {
96 if (browserAPI?.tabs?.update && tabId) {
97 browserAPI.tabs.update(tabId, { url });
98 return;
99 }
100
101 if (browserAPI?.tabs?.create) {
102 browserAPI.tabs.create({ url });
103 }
104}
105
106browserAPI.action.onClicked.addListener((tab) => {
107 const hutchURL = tab?.url ? hutchDeepLinkFor(tab.url) : null;
108 if (!hutchURL) {
109 showUnsupportedMessage(tab?.id);
110 return;
111 }
112
113 console.debug("[Hutch] Opening deep link", { sourceURL: tab.url, hutchURL });
114 openURL(tab.id, hutchURL);
115});
HutchSafariExtension/Resources/content.js added +183
@@ -0,0 +1,183 @@
1const hutchSupportedHosts = new Set([
2 "git.sr.ht",
3 "hg.sr.ht",
4 "todo.sr.ht",
5 "builds.sr.ht",
6 "lists.sr.ht",
7 "meta.sr.ht",
8 "sr.ht",
9]);
10
11const hutchBrowserAPI = globalThis.browser ?? globalThis.chrome;
12const bannerStorageKey = "showOpenInHutchBanner";
13const dismissedStorageKey = `dismissedOpenInHutchBanner:${location.hostname}`;
14
15function hutchIsOwnerRootPath(path) {
16 const components = path.split("/").filter(Boolean);
17 return components.length === 1 && components[0].startsWith("~");
18}
19
20function hutchNormalizedPath(pathname) {
21 return pathname.split("/").filter(Boolean).join("/");
22}
23
24function hutchDeepLinkPath(hostname, path) {
25 const components = path.split("/").filter(Boolean);
26 if (
27 hostname === "sr.ht" &&
28 components.length === 2 &&
29 components[0] === "projects" &&
30 components[1].startsWith("~")
31 ) {
32 return components[1];
33 }
34
35 return path;
36}
37
38function hutchDeepLinkService(hostname, path) {
39 if (hutchIsOwnerRootPath(path)) {
40 return "lookup";
41 }
42
43 switch (hostname) {
44 case "git.sr.ht":
45 return "git";
46 case "hg.sr.ht":
47 return "hg";
48 case "todo.sr.ht":
49 return "todo";
50 case "builds.sr.ht":
51 return "builds";
52 case "lists.sr.ht":
53 return "lists";
54 default:
55 return "lookup";
56 }
57}
58
59function hutchDeepLinkForLocation() {
60 if (location.protocol !== "https:" || !hutchSupportedHosts.has(location.hostname)) {
61 return null;
62 }
63
64 const path = hutchDeepLinkPath(location.hostname, hutchNormalizedPath(location.pathname));
65 const service = hutchDeepLinkService(location.hostname, path);
66 return `hutch://${service}${path ? `/${path}` : ""}${location.search}${location.hash}`;
67}
68
69function storageGet(defaults) {
70 return new Promise((resolve) => {
71 hutchBrowserAPI.storage.local.get(defaults, resolve);
72 });
73}
74
75function storageSet(values) {
76 return new Promise((resolve) => {
77 hutchBrowserAPI.storage.local.set(values, resolve);
78 });
79}
80
81function showNotice(message) {
82 const existing = document.getElementById("hutch-open-notice");
83 existing?.remove();
84
85 const notice = document.createElement("div");
86 notice.id = "hutch-open-notice";
87 notice.textContent = message;
88 notice.style.cssText = [
89 "position:fixed",
90 "left:50%",
91 "bottom:18px",
92 "z-index:2147483647",
93 "transform:translateX(-50%)",
94 "background:#1f2937",
95 "color:white",
96 "font:13px -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif",
97 "padding:8px 12px",
98 "border-radius:8px",
99 "box-shadow:0 8px 24px rgba(0,0,0,.24)",
100 ].join(";");
101
102 document.documentElement.appendChild(notice);
103 setTimeout(() => notice.remove(), 3500);
104}
105
106async function injectBannerIfEnabled() {
107 const hutchURL = hutchDeepLinkForLocation();
108 if (!hutchURL || document.getElementById("hutch-open-banner")) {
109 return;
110 }
111
112 const settings = await storageGet({
113 [bannerStorageKey]: false,
114 [dismissedStorageKey]: false,
115 });
116 if (!settings[bannerStorageKey] || settings[dismissedStorageKey]) {
117 return;
118 }
119
120 const banner = document.createElement("div");
121 banner.id = "hutch-open-banner";
122 banner.style.cssText = [
123 "position:fixed",
124 "right:14px",
125 "bottom:14px",
126 "z-index:2147483647",
127 "display:flex",
128 "align-items:center",
129 "gap:8px",
130 "background:#111827",
131 "color:white",
132 "font:13px -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif",
133 "padding:8px 10px",
134 "border-radius:8px",
135 "box-shadow:0 8px 24px rgba(0,0,0,.22)",
136 ].join(";");
137
138 const button = document.createElement("button");
139 button.type = "button";
140 button.textContent = "Open in Hutch";
141 button.style.cssText = [
142 "appearance:none",
143 "border:0",
144 "background:transparent",
145 "color:inherit",
146 "font:inherit",
147 "font-weight:600",
148 "padding:0",
149 ].join(";");
150 button.addEventListener("click", () => {
151 console.debug("[Hutch] Banner opening deep link", { sourceURL: location.href, hutchURL });
152 location.href = hutchURL;
153 });
154
155 const dismiss = document.createElement("button");
156 dismiss.type = "button";
157 dismiss.textContent = "Close";
158 dismiss.setAttribute("aria-label", "Dismiss Open in Hutch banner");
159 dismiss.style.cssText = [
160 "appearance:none",
161 "border:0",
162 "background:rgba(255,255,255,.14)",
163 "color:inherit",
164 "font:12px -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif",
165 "padding:3px 6px",
166 "border-radius:6px",
167 ].join(";");
168 dismiss.addEventListener("click", async () => {
169 await storageSet({ [dismissedStorageKey]: true });
170 banner.remove();
171 });
172
173 banner.append(button, dismiss);
174 document.documentElement.appendChild(banner);
175}
176
177hutchBrowserAPI.runtime.onMessage.addListener((message) => {
178 if (message?.type === "hutchUnsupportedPage") {
179 showNotice(message.message);
180 }
181});
182
183injectBannerIfEnabled();
HutchSafariExtension/Resources/hutch_light.png added

Binary file not shown.

HutchSafariExtension/Resources/icon-128.png added

Binary file not shown.

HutchSafariExtension/Resources/icon-16.png added

Binary file not shown.

HutchSafariExtension/Resources/icon-32.png added

Binary file not shown.

HutchSafariExtension/Resources/icon-48.png added

Binary file not shown.

HutchSafariExtension/Resources/manifest.json added +57
@@ -0,0 +1,57 @@
1{
2 "manifest_version": 3,
3 "name": "Open in Hutch",
4 "version": "1.0.0",
5 "description": "Open supported SourceHut pages in Hutch.",
6 "icons": {
7 "16": "icon-16.png",
8 "32": "icon-32.png",
9 "48": "icon-48.png",
10 "128": "icon-128.png"
11 },
12 "action": {
13 "default_title": "Open in Hutch",
14 "default_icon": {
15 "16": "icon-16.png",
16 "32": "icon-32.png",
17 "48": "icon-48.png",
18 "128": "icon-128.png"
19 }
20 },
21 "background": {
22 "scripts": [
23 "background.js"
24 ]
25 },
26 "content_scripts": [
27 {
28 "matches": [
29 "https://git.sr.ht/*",
30 "https://hg.sr.ht/*",
31 "https://todo.sr.ht/*",
32 "https://builds.sr.ht/*",
33 "https://lists.sr.ht/*",
34 "https://meta.sr.ht/*",
35 "https://sr.ht/*"
36 ],
37 "js": [
38 "content.js"
39 ],
40 "run_at": "document_idle"
41 }
42 ],
43 "permissions": [
44 "activeTab",
45 "scripting",
46 "storage"
47 ],
48 "host_permissions": [
49 "https://git.sr.ht/*",
50 "https://hg.sr.ht/*",
51 "https://todo.sr.ht/*",
52 "https://builds.sr.ht/*",
53 "https://lists.sr.ht/*",
54 "https://meta.sr.ht/*",
55 "https://sr.ht/*"
56 ]
57}
HutchSafariExtension/SafariWebExtensionHandler.swift added +7
@@ -0,0 +1,7 @@
1import Foundation
2
3final class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
4 func beginRequest(with context: NSExtensionContext) {
5 context.completeRequest(returningItems: [], completionHandler: nil)
6 }
7}
HutchTests/AppStateTests.swift +8
@@ -4,6 +4,14 @@ import Testing
44
55struct AppStateTests {
66
7 @Test
8 @MainActor
9 func srhtUsernameNormalizesWebPathOwner() {
10 #expect(AppState.srhtUsername(from: "~vicho") == "vicho")
11 #expect(AppState.srhtUsername(from: " vicho ") == "vicho")
12 #expect(AppState.srhtUsername(from: " ~vicho ") == "vicho")
13 }
14
715 @Test
816 @MainActor
917 func presentRepositoryDeepLinkErrorSetsUserFacingMessage() {
HutchTests/DeepLinkTests.swift +31 −1
@@ -19,7 +19,19 @@ struct DeepLinkTests {
1919 @Test
2020 func parsesRepositoryLink() {
2121 let link = DeepLink(url: HutchDeepLinkURL.repositoryGit)
22 #expect(link == .repository(owner: "~user", repo: "repo"))
22 #expect(link == .repository(service: .git, owner: "~user", repo: "repo"))
23 }
24
25 @Test
26 func parsesHgRepositoryLink() {
27 let link = DeepLink(url: HutchDeepLinkURL.repositoryHg)
28 #expect(link == .repository(service: .hg, owner: "~user", repo: "repo"))
29 }
30
31 @Test
32 func parsesServiceOwnerRootAsUserProfile() {
33 let link = DeepLink(url: HutchDeepLinkURL.gitOwnerRoot)
34 #expect(link == .userProfile(owner: "~owner"))
2335 }
2436
2537 @Test
@@ -34,6 +46,18 @@ struct DeepLinkTests {
3446 #expect(link == .build(jobId: 12345))
3547 }
3648
49 @Test
50 func parsesBuildJobLinkWithOwnerPath() {
51 let link = DeepLink(url: HutchDeepLinkURL.buildJobWithOwner)
52 #expect(link == .build(jobId: 12345))
53 }
54
55 @Test
56 func parsesMailingListLink() {
57 let link = DeepLink(url: HutchDeepLinkURL.mailingList)
58 #expect(link == .mailingList(owner: "~owner", list: "list"))
59 }
60
3761 @Test
3862 func parsesBuildsTabLink() {
3963 let link = DeepLink(url: HutchDeepLinkURL.builds)
@@ -64,6 +88,12 @@ struct DeepLinkTests {
6488 #expect(link == .lookup)
6589 }
6690
91 @Test
92 func parsesUserProfileLink() {
93 let link = DeepLink(url: HutchDeepLinkURL.userProfile)
94 #expect(link == .userProfile(owner: "~owner"))
95 }
96
6797 @Test
6898 func rejectsNonHutchScheme() {
6999 let link = DeepLink(url: URL(string: "https://example.com/home")!)
HutchTests/SourceHutWebDeepLinkMapperTests.swift added +44
@@ -0,0 +1,44 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct SourceHutWebDeepLinkMapperTests {
6 @Test
7 func mapsRepositoryURLs() {
8 #expect(mapped("https://git.sr.ht/~ccleberg/Hutch") == "hutch://git/~ccleberg/Hutch")
9 #expect(mapped("https://hg.sr.ht/~user/repo") == "hutch://hg/~user/repo")
10 }
11
12 @Test
13 func mapsTrackerBuildAndListURLs() {
14 #expect(mapped("https://todo.sr.ht/~ccleberg/hutch/123") == "hutch://todo/~ccleberg/hutch/123")
15 #expect(mapped("https://builds.sr.ht/~ccleberg/job/12345") == "hutch://builds/~ccleberg/job/12345")
16 #expect(mapped("https://lists.sr.ht/~ccleberg/hutch-announce") == "hutch://lists/~ccleberg/hutch-announce")
17 }
18
19 @Test
20 func mapsProfileHostsToLookup() {
21 #expect(mapped("https://sr.ht/~ccleberg") == "hutch://lookup/~ccleberg")
22 #expect(mapped("https://meta.sr.ht/~ccleberg") == "hutch://lookup/~ccleberg")
23 #expect(mapped("https://git.sr.ht/~ccleberg/") == "hutch://lookup/~ccleberg")
24 #expect(mapped("https://hg.sr.ht/~user") == "hutch://lookup/~user")
25 #expect(mapped("https://todo.sr.ht/~user") == "hutch://lookup/~user")
26 #expect(mapped("https://lists.sr.ht/~user") == "hutch://lookup/~user")
27 #expect(mapped("https://sr.ht/projects/~user") == "hutch://lookup/~user")
28 }
29
30 @Test
31 func preservesQueryAndFragment() {
32 #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")
33 }
34
35 @Test
36 func rejectsUnsupportedHostsAndSchemes() {
37 #expect(SourceHutWebDeepLinkMapper.deepLink(for: URL(string: "https://chat.sr.ht")!) == nil)
38 #expect(SourceHutWebDeepLinkMapper.deepLink(for: URL(string: "http://git.sr.ht/~ccleberg/Hutch")!) == nil)
39 }
40
41 private func mapped(_ string: String) -> String? {
42 SourceHutWebDeepLinkMapper.deepLink(for: URL(string: string)!)?.absoluteString
43 }
44}
Shared/HutchDeepLinkURLs.swift +5
@@ -5,13 +5,18 @@ enum HutchDeepLinkURL {
55 static let home = URL(string: "hutch://home")!
66 static let emptyHost = URL(string: "hutch://")!
77 static let repositoryGit = URL(string: "hutch://git/~user/repo")!
8 static let repositoryHg = URL(string: "hutch://hg/~user/repo")!
9 static let gitOwnerRoot = URL(string: "hutch://git/~owner/")!
810 static let ticket = URL(string: "hutch://todo/~owner/tracker/42")!
911 static let buildJob = URL(string: "hutch://builds/12345")!
12 static let buildJobWithOwner = URL(string: "hutch://builds/~owner/job/12345")!
13 static let mailingList = URL(string: "hutch://lists/~owner/list")!
1014 static let trackers = URL(string: "hutch://trackers")!
1115 static let builds = URL(string: "hutch://builds")!
1216 static let repositories = URL(string: "hutch://repositories")!
1317 static let status = URL(string: "hutch://status")!
1418 static let lookup = URL(string: "hutch://lookup")!
19 static let userProfile = URL(string: "hutch://lookup/~owner")!
1520 static let unknown = URL(string: "hutch://unknown")!
1621 static let invalidTicketId = URL(string: "hutch://todo/~owner/tracker/abc")!
1722 static let invalidBuildId = URL(string: "hutch://builds/abc")!
Shared/SourceHutWebDeepLinkMapper.swift added +80
@@ -0,0 +1,80 @@
1import Foundation
2
3enum SourceHutWebDeepLinkMapper {
4 static let supportedHosts: Set<String> = [
5 "git.sr.ht",
6 "hg.sr.ht",
7 "todo.sr.ht",
8 "builds.sr.ht",
9 "lists.sr.ht",
10 "meta.sr.ht",
11 "sr.ht",
12 ]
13
14 static func deepLink(for webURL: URL) -> URL? {
15 guard let components = URLComponents(url: webURL, resolvingAgainstBaseURL: false),
16 components.scheme == "https",
17 let host = components.host?.lowercased(),
18 supportedHosts.contains(host)
19 else {
20 return nil
21 }
22
23 let path = normalizedPercentEncodedPath(from: components.percentEncodedPath)
24 let deepLinkPath = normalizedDeepLinkPath(for: host, path: path)
25 let service = deepLinkService(for: host, path: deepLinkPath)
26 var deepLinkComponents = URLComponents()
27 deepLinkComponents.scheme = "hutch"
28 deepLinkComponents.host = service
29
30 if !deepLinkPath.isEmpty {
31 deepLinkComponents.percentEncodedPath = "/" + deepLinkPath
32 }
33 deepLinkComponents.percentEncodedQuery = components.percentEncodedQuery
34 deepLinkComponents.percentEncodedFragment = components.percentEncodedFragment
35 return deepLinkComponents.url
36 }
37
38 private static func deepLinkService(for host: String, path: String) -> String {
39 if isOwnerRootPath(path) {
40 return "lookup"
41 }
42
43 switch host {
44 case "git.sr.ht":
45 return "git"
46 case "hg.sr.ht":
47 return "hg"
48 case "todo.sr.ht":
49 return "todo"
50 case "builds.sr.ht":
51 return "builds"
52 case "lists.sr.ht":
53 return "lists"
54 default:
55 return "lookup"
56 }
57 }
58
59 private static func isOwnerRootPath(_ path: String) -> Bool {
60 let components = path.split(separator: "/", omittingEmptySubsequences: true)
61 return components.count == 1 && components[0].hasPrefix("~")
62 }
63
64 private static func normalizedDeepLinkPath(for host: String, path: String) -> String {
65 let components = path.split(separator: "/", omittingEmptySubsequences: true)
66 if host == "sr.ht",
67 components.count == 2,
68 components[0] == "projects",
69 components[1].hasPrefix("~") {
70 return String(components[1])
71 }
72 return path
73 }
74
75 private static func normalizedPercentEncodedPath(from path: String) -> String {
76 path
77 .split(separator: "/", omittingEmptySubsequences: true)
78 .joined(separator: "/")
79 }
80}