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 @@
18 8B2FA9D22F7E211800E609E2 /* todo.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9CA2F7E211800E609E2 /* todo.json */; }; 18 8B2FA9D22F7E211800E609E2 /* todo.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9CA2F7E211800E609E2 /* todo.json */; };
19 8B2FA9D32F7E211800E609E2 /* lists.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9C72F7E211800E609E2 /* lists.json */; }; 19 8B2FA9D32F7E211800E609E2 /* lists.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9C72F7E211800E609E2 /* lists.json */; };
20 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 20 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, ); }; };
21 8BF100032F9A000100000001 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100022F9A000100000001 /* Markdown */; }; 22 8BF100032F9A000100000001 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100022F9A000100000001 /* Markdown */; };
22 8BF100052F9A000100000002 /* Splash in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100042F9A000100000002 /* Splash */; }; 23 8BF100052F9A000100000002 /* Splash in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100042F9A000100000002 /* Splash */; };
23/* End PBXBuildFile section */ 24/* End PBXBuildFile section */
@@ -37,6 +38,13 @@
37 remoteGlobalIDString = 8BE081F72F80000100000001; 38 remoteGlobalIDString = 8BE081F72F80000100000001;
38 remoteInfo = HutchWidgetExtension; 39 remoteInfo = HutchWidgetExtension;
39 }; 40 };
41 8BE083022F81000100000001 /* PBXContainerItemProxy */ = {
42 isa = PBXContainerItemProxy;
43 containerPortal = 8B4B28C92F6704280045FA19 /* Project object */;
44 proxyType = 1;
45 remoteGlobalIDString = 8BE083092F81000100000001;
46 remoteInfo = HutchSafariExtension;
47 };
40/* End PBXContainerItemProxy section */ 48/* End PBXContainerItemProxy section */
41 49
42/* Begin PBXCopyFilesBuildPhase section */ 50/* Begin PBXCopyFilesBuildPhase section */
@@ -47,6 +55,7 @@
47 dstSubfolderSpec = 13; 55 dstSubfolderSpec = 13;
48 files = ( 56 files = (
49 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed Foundation Extensions */, 57 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed Foundation Extensions */,
58 8BE083012F81000100000001 /* HutchSafariExtension.appex in Embed Foundation Extensions */,
50 ); 59 );
51 name = "Embed Foundation Extensions"; 60 name = "Embed Foundation Extensions";
52 runOnlyForDeploymentPostprocessing = 0; 61 runOnlyForDeploymentPostprocessing = 0;
@@ -70,6 +79,7 @@
70 8B8182C22F6B742B000AE049 /* HutchTests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = HutchTests; sourceTree = "<group>"; }; 79 8B8182C22F6B742B000AE049 /* HutchTests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = HutchTests; sourceTree = "<group>"; };
71 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = "<group>"; }; 80 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = "<group>"; };
72 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 81 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; };
73/* End PBXFileReference section */ 83/* End PBXFileReference section */
74 84
75/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 85/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -87,6 +97,13 @@
87 ); 97 );
88 target = 8BE081F72F80000100000001 /* HutchWidgetExtension */; 98 target = 8BE081F72F80000100000001 /* HutchWidgetExtension */;
89 }; 99 };
100 8BE083042F81000100000001 /* Exceptions for "HutchSafariExtension" folder in "HutchSafariExtension" target */ = {
101 isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
102 membershipExceptions = (
103 Info.plist,
104 );
105 target = 8BE083092F81000100000001 /* HutchSafariExtension */;
106 };
90/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ 107/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
91 108
92/* Begin PBXFileSystemSynchronizedRootGroup section */ 109/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -116,6 +133,14 @@
116 path = HutchWidgetExtension; 133 path = HutchWidgetExtension;
117 sourceTree = "<group>"; 134 sourceTree = "<group>";
118 }; 135 };
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 };
119/* End PBXFileSystemSynchronizedRootGroup section */ 144/* End PBXFileSystemSynchronizedRootGroup section */
120 145
121/* Begin PBXFrameworksBuildPhase section */ 146/* Begin PBXFrameworksBuildPhase section */
@@ -142,6 +167,13 @@
142 ); 167 );
143 runOnlyForDeploymentPostprocessing = 0; 168 runOnlyForDeploymentPostprocessing = 0;
144 }; 169 };
170 8BE083062F81000100000001 /* Frameworks */ = {
171 isa = PBXFrameworksBuildPhase;
172 buildActionMask = 2147483647;
173 files = (
174 );
175 runOnlyForDeploymentPostprocessing = 0;
176 };
145/* End PBXFrameworksBuildPhase section */ 177/* End PBXFrameworksBuildPhase section */
146 178
147/* Begin PBXGroup section */ 179/* Begin PBXGroup section */
@@ -180,6 +212,7 @@
180 8B4B28D32F6704280045FA19 /* Hutch */, 212 8B4B28D32F6704280045FA19 /* Hutch */,
181 8BE081F42F80000100000001 /* Shared */, 213 8BE081F42F80000100000001 /* Shared */,
182 8BE081F52F80000100000001 /* HutchWidgetExtension */, 214 8BE081F52F80000100000001 /* HutchWidgetExtension */,
215 8BE083052F81000100000001 /* HutchSafariExtension */,
183 8B8182B82F6B73F3000AE049 /* HutchTests */, 216 8B8182B82F6B73F3000AE049 /* HutchTests */,
184 8B4B28D22F6704280045FA19 /* Products */, 217 8B4B28D22F6704280045FA19 /* Products */,
185 ); 218 );
@@ -191,6 +224,7 @@
191 8B4B28D12F6704280045FA19 /* Hutch.app */, 224 8B4B28D12F6704280045FA19 /* Hutch.app */,
192 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */, 225 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */,
193 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */, 226 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */,
227 8BE083032F81000100000001 /* HutchSafariExtension.appex */,
194 ); 228 );
195 name = Products; 229 name = Products;
196 sourceTree = "<group>"; 230 sourceTree = "<group>";
@@ -211,6 +245,7 @@
211 ); 245 );
212 dependencies = ( 246 dependencies = (
213 8BE082042F80000100000001 /* PBXTargetDependency */, 247 8BE082042F80000100000001 /* PBXTargetDependency */,
248 8BE0830A2F81000100000001 /* PBXTargetDependency */,
214 ); 249 );
215 fileSystemSynchronizedGroups = ( 250 fileSystemSynchronizedGroups = (
216 8B4B28D32F6704280045FA19 /* Hutch */, 251 8B4B28D32F6704280045FA19 /* Hutch */,
@@ -271,6 +306,28 @@
271 productReference = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; 306 productReference = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */;
272 productType = "com.apple.product-type.app-extension"; 307 productType = "com.apple.product-type.app-extension";
273 }; 308 };
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 };
274/* End PBXNativeTarget section */ 331/* End PBXNativeTarget section */
275 332
276/* Begin PBXProject section */ 333/* Begin PBXProject section */
@@ -288,6 +345,9 @@
288 CreatedOnToolsVersion = 26.3; 345 CreatedOnToolsVersion = 26.3;
289 TestTargetID = 8B4B28D02F6704280045FA19; 346 TestTargetID = 8B4B28D02F6704280045FA19;
290 }; 347 };
348 8BE083092F81000100000001 = {
349 CreatedOnToolsVersion = 26.3;
350 };
291 }; 351 };
292 }; 352 };
293 buildConfigurationList = 8B4B28CC2F6704280045FA19 /* Build configuration list for PBXProject "Hutch" */; 353 buildConfigurationList = 8B4B28CC2F6704280045FA19 /* Build configuration list for PBXProject "Hutch" */;
@@ -310,6 +370,7 @@
310 targets = ( 370 targets = (
311 8B4B28D02F6704280045FA19 /* Hutch */, 371 8B4B28D02F6704280045FA19 /* Hutch */,
312 8BE081F72F80000100000001 /* HutchWidgetExtension */, 372 8BE081F72F80000100000001 /* HutchWidgetExtension */,
373 8BE083092F81000100000001 /* HutchSafariExtension */,
313 8B8182B62F6B73F3000AE049 /* HutchTests */, 374 8B8182B62F6B73F3000AE049 /* HutchTests */,
314 ); 375 );
315 }; 376 };
@@ -347,6 +408,13 @@
347 ); 408 );
348 runOnlyForDeploymentPostprocessing = 0; 409 runOnlyForDeploymentPostprocessing = 0;
349 }; 410 };
411 8BE083082F81000100000001 /* Resources */ = {
412 isa = PBXResourcesBuildPhase;
413 buildActionMask = 2147483647;
414 files = (
415 );
416 runOnlyForDeploymentPostprocessing = 0;
417 };
350/* End PBXResourcesBuildPhase section */ 418/* End PBXResourcesBuildPhase section */
351 419
352/* Begin PBXSourcesBuildPhase section */ 420/* Begin PBXSourcesBuildPhase section */
@@ -371,6 +439,13 @@
371 ); 439 );
372 runOnlyForDeploymentPostprocessing = 0; 440 runOnlyForDeploymentPostprocessing = 0;
373 }; 441 };
442 8BE083072F81000100000001 /* Sources */ = {
443 isa = PBXSourcesBuildPhase;
444 buildActionMask = 2147483647;
445 files = (
446 );
447 runOnlyForDeploymentPostprocessing = 0;
448 };
374/* End PBXSourcesBuildPhase section */ 449/* End PBXSourcesBuildPhase section */
375 450
376/* Begin PBXTargetDependency section */ 451/* Begin PBXTargetDependency section */
@@ -384,6 +459,11 @@
384 target = 8BE081F72F80000100000001 /* HutchWidgetExtension */; 459 target = 8BE081F72F80000100000001 /* HutchWidgetExtension */;
385 targetProxy = 8BE082022F80000100000001 /* PBXContainerItemProxy */; 460 targetProxy = 8BE082022F80000100000001 /* PBXContainerItemProxy */;
386 }; 461 };
462 8BE0830A2F81000100000001 /* PBXTargetDependency */ = {
463 isa = PBXTargetDependency;
464 target = 8BE083092F81000100000001 /* HutchSafariExtension */;
465 targetProxy = 8BE083022F81000100000001 /* PBXContainerItemProxy */;
466 };
387/* End PBXTargetDependency section */ 467/* End PBXTargetDependency section */
388 468
389/* Begin XCBuildConfiguration section */ 469/* Begin XCBuildConfiguration section */
@@ -517,7 +597,7 @@
517 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 597 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
518 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 598 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
519 CODE_SIGN_STYLE = Automatic; 599 CODE_SIGN_STYLE = Automatic;
520 CURRENT_PROJECT_VERSION = 84; 600 CURRENT_PROJECT_VERSION = 85;
521 DEVELOPMENT_TEAM = ZCNAX3VL9D; 601 DEVELOPMENT_TEAM = ZCNAX3VL9D;
522 ENABLE_PREVIEWS = YES; 602 ENABLE_PREVIEWS = YES;
523 GENERATE_INFOPLIST_FILE = YES; 603 GENERATE_INFOPLIST_FILE = YES;
@@ -534,7 +614,7 @@
534 "$(inherited)", 614 "$(inherited)",
535 "@executable_path/Frameworks", 615 "@executable_path/Frameworks",
536 ); 616 );
537 MARKETING_VERSION = 3.3.1; 617 MARKETING_VERSION = 3.4.0;
538 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 618 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
539 PRODUCT_NAME = "$(TARGET_NAME)"; 619 PRODUCT_NAME = "$(TARGET_NAME)";
540 STRING_CATALOG_GENERATE_SYMBOLS = YES; 620 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -554,7 +634,7 @@
554 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 634 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
555 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 635 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
556 CODE_SIGN_STYLE = Automatic; 636 CODE_SIGN_STYLE = Automatic;
557 CURRENT_PROJECT_VERSION = 84; 637 CURRENT_PROJECT_VERSION = 85;
558 DEVELOPMENT_TEAM = ZCNAX3VL9D; 638 DEVELOPMENT_TEAM = ZCNAX3VL9D;
559 ENABLE_PREVIEWS = YES; 639 ENABLE_PREVIEWS = YES;
560 GENERATE_INFOPLIST_FILE = YES; 640 GENERATE_INFOPLIST_FILE = YES;
@@ -571,7 +651,7 @@
571 "$(inherited)", 651 "$(inherited)",
572 "@executable_path/Frameworks", 652 "@executable_path/Frameworks",
573 ); 653 );
574 MARKETING_VERSION = 3.3.1; 654 MARKETING_VERSION = 3.4.0;
575 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 655 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
576 PRODUCT_NAME = "$(TARGET_NAME)"; 656 PRODUCT_NAME = "$(TARGET_NAME)";
577 STRING_CATALOG_GENERATE_SYMBOLS = YES; 657 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -634,7 +714,7 @@
634 APPLICATION_EXTENSION_API_ONLY = YES; 714 APPLICATION_EXTENSION_API_ONLY = YES;
635 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 715 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
636 CODE_SIGN_STYLE = Automatic; 716 CODE_SIGN_STYLE = Automatic;
637 CURRENT_PROJECT_VERSION = 84; 717 CURRENT_PROJECT_VERSION = 85;
638 DEVELOPMENT_TEAM = ZCNAX3VL9D; 718 DEVELOPMENT_TEAM = ZCNAX3VL9D;
639 GENERATE_INFOPLIST_FILE = NO; 719 GENERATE_INFOPLIST_FILE = NO;
640 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 720 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -644,7 +724,7 @@
644 "@executable_path/Frameworks", 724 "@executable_path/Frameworks",
645 "@executable_path/../../Frameworks", 725 "@executable_path/../../Frameworks",
646 ); 726 );
647 MARKETING_VERSION = 3.3.1; 727 MARKETING_VERSION = 3.4.0;
648 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 728 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
649 PRODUCT_NAME = "$(TARGET_NAME)"; 729 PRODUCT_NAME = "$(TARGET_NAME)";
650 SKIP_INSTALL = YES; 730 SKIP_INSTALL = YES;
@@ -663,7 +743,7 @@
663 APPLICATION_EXTENSION_API_ONLY = YES; 743 APPLICATION_EXTENSION_API_ONLY = YES;
664 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 744 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
665 CODE_SIGN_STYLE = Automatic; 745 CODE_SIGN_STYLE = Automatic;
666 CURRENT_PROJECT_VERSION = 84; 746 CURRENT_PROJECT_VERSION = 85;
667 DEVELOPMENT_TEAM = ZCNAX3VL9D; 747 DEVELOPMENT_TEAM = ZCNAX3VL9D;
668 GENERATE_INFOPLIST_FILE = NO; 748 GENERATE_INFOPLIST_FILE = NO;
669 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 749 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -673,7 +753,7 @@
673 "@executable_path/Frameworks", 753 "@executable_path/Frameworks",
674 "@executable_path/../../Frameworks", 754 "@executable_path/../../Frameworks",
675 ); 755 );
676 MARKETING_VERSION = 3.3.1; 756 MARKETING_VERSION = 3.4.0;
677 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 757 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
678 PRODUCT_NAME = "$(TARGET_NAME)"; 758 PRODUCT_NAME = "$(TARGET_NAME)";
679 SKIP_INSTALL = YES; 759 SKIP_INSTALL = YES;
@@ -686,6 +766,64 @@
686 }; 766 };
687 name = Release; 767 name = Release;
688 }; 768 };
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 };
689/* End XCBuildConfiguration section */ 827/* End XCBuildConfiguration section */
690 828
691/* Begin XCConfigurationList section */ 829/* Begin XCConfigurationList section */
@@ -725,6 +863,15 @@
725 defaultConfigurationIsVisible = 0; 863 defaultConfigurationIsVisible = 0;
726 defaultConfigurationName = Release; 864 defaultConfigurationName = Release;
727 }; 865 };
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 };
728/* End XCConfigurationList section */ 875/* End XCConfigurationList section */
729 876
730/* Begin XCRemoteSwiftPackageReference section */ 877/* Begin XCRemoteSwiftPackageReference section */
Hutch/App/AppState.swift +76 −2
@@ -2,6 +2,9 @@ import Foundation
2import SwiftUI 2import SwiftUI
3import UIKit 3import UIKit
4import WebKit 4import WebKit
5import os
6
7private let appStateDeepLinkLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
5 8
6/// Central application state shared across the view hierarchy. 9/// Central application state shared across the view hierarchy.
7@Observable 10@Observable
@@ -260,10 +263,11 @@ final class AppState {
260 263
261 /// Resolve a repository by owner and name for deep linking. 264 /// Resolve a repository by owner and name for deep linking.
262 func resolveRepository(owner: String, name: String, service: SRHTService = .git) async throws -> RepositorySummary { 265 func resolveRepository(owner: String, name: String, service: SRHTService = .git) async throws -> RepositorySummary {
266 let normalizedOwner = Self.srhtUsername(from: owner)
263 let result = try await client.execute( 267 let result = try await client.execute(
264 service: service, 268 service: service,
265 query: Self.repoLookupQuery, 269 query: Self.repoLookupQuery,
266 variables: ["owner": owner, "name": name], 270 variables: ["owner": normalizedOwner, "name": name],
267 responseType: RepoLookupResponse.self 271 responseType: RepoLookupResponse.self
268 ) 272 )
269 return result.user.repository 273 return result.user.repository
@@ -271,15 +275,39 @@ final class AppState {
271 275
272 /// Resolve a tracker by owner and name for deep linking. 276 /// Resolve a tracker by owner and name for deep linking.
273 func resolveTracker(owner: String, name: String) async throws -> TrackerSummary { 277 func resolveTracker(owner: String, name: String) async throws -> TrackerSummary {
278 let normalizedOwner = Self.srhtUsername(from: owner)
274 let result = try await client.execute( 279 let result = try await client.execute(
275 service: .todo, 280 service: .todo,
276 query: Self.trackerLookupQuery, 281 query: Self.trackerLookupQuery,
277 variables: ["owner": owner, "name": name], 282 variables: ["owner": normalizedOwner, "name": name],
278 responseType: TrackerLookupResponse.self 283 responseType: TrackerLookupResponse.self
279 ) 284 )
280 return result.user.tracker 285 return result.user.tracker
281 } 286 }
282 287
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
283 func resolveProjectSource(_ source: Project.SourceRepo) async throws -> RepositorySummary { 311 func resolveProjectSource(_ source: Project.SourceRepo) async throws -> RepositorySummary {
284 try await resolveRepository( 312 try await resolveRepository(
285 owner: source.ownerUsername, 313 owner: source.ownerUsername,
@@ -384,6 +412,11 @@ final class AppState {
384 return result.me 412 return result.me
385 } 413 }
386 414
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
387 // MARK: - Deep link queries 420 // MARK: - Deep link queries
388 421
389 private static let repoLookupQuery = """ 422 private static let repoLookupQuery = """
@@ -425,6 +458,47 @@ final class AppState {
425 let tracker: TrackerSummary 458 let tracker: TrackerSummary
426 } 459 }
427 460
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
428 private func addValidatedAccount(token: String, activateNewAccount: Bool) async throws { 502 private func addValidatedAccount(token: String, activateNewAccount: Bool) async throws {
429 let tempClient = SRHTClient(token: token) 503 let tempClient = SRHTClient(token: token)
430 let user = try await fetchMe(using: tempClient) 504 let user = try await fetchMe(using: tempClient)
Hutch/App/DeepLink.swift +47 −17
@@ -1,15 +1,22 @@
1import Foundation 1import Foundation
2import os
3
4private let deepLinkParserLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
2 5
3/// Represents a parsed `hutch://` deep link. 6/// Represents a parsed `hutch://` deep link.
4enum DeepLink: Equatable { 7enum DeepLink: Equatable {
5 case home 8 case home
6 case work 9 case work
7 /// hutch://git/<owner>/<repo> 10 /// hutch://git/<owner>/<repo> or hutch://hg/<owner>/<repo>
8 case repository(owner: String, repo: String) 11 case repository(service: SRHTService, owner: String, repo: String)
9 /// hutch://todo/<owner>/<tracker>/<ticketId> 12 /// hutch://todo/<owner>/<tracker>/<ticketId>
10 case ticket(owner: String, tracker: String, ticketId: Int) 13 case ticket(owner: String, tracker: String, ticketId: Int)
11 /// hutch://builds/<jobId> 14 /// hutch://builds/<jobId> or hutch://builds/<owner>/job/<jobId>
12 case build(jobId: Int) 15 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)
13 /// hutch://builds (tab-level) 20 /// hutch://builds (tab-level)
14 case buildsTab 21 case buildsTab
15 /// hutch://repositories (tab-level) 22 /// hutch://repositories (tab-level)
@@ -26,9 +33,8 @@ enum DeepLink: Equatable {
26 init?(url: URL) { 33 init?(url: URL) {
27 guard url.scheme == "hutch" else { return nil } 34 guard url.scheme == "hutch" else { return nil }
28 35
29 // url.host gives the first path component for opaque URLs; 36 let components = url.deepLinkPathComponents
30 // use standardized path components from the full string. 37 deepLinkParserLogger.info("DeepLink parser components for \(url.absoluteString, privacy: .public): \(components.joined(separator: ","), privacy: .public)")
31 let components = url.pathComponents(fromScheme: "hutch")
32 38
33 switch components.first { 39 switch components.first {
34 case "home", nil: 40 case "home", nil:
@@ -37,10 +43,18 @@ enum DeepLink: Equatable {
37 case "work", "inbox": 43 case "work", "inbox":
38 self = .work 44 self = .work
39 45
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 }
41 let owner = components[1] 55 let owner = components[1]
42 let repo = components[2] 56 let repo = components[2]
43 self = .repository(owner: owner, repo: repo) 57 self = .repository(service: service, owner: owner, repo: repo)
44 58
45 case "todo" where components.count >= 4: 59 case "todo" where components.count >= 4:
46 let owner = components[1] 60 let owner = components[1]
@@ -49,12 +63,21 @@ enum DeepLink: Equatable {
49 self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId) 63 self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId)
50 64
51 case "builds" where components.count >= 2: 65 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 }
53 self = .build(jobId: jobId) 73 self = .build(jobId: jobId)
54 74
55 case "builds": 75 case "builds":
56 self = .buildsTab 76 self = .buildsTab
57 77
78 case "lists" where components.count >= 3:
79 self = .mailingList(owner: components[1], list: components[2])
80
58 case "repositories": 81 case "repositories":
59 self = .repositoriesTab 82 self = .repositoriesTab
60 83
@@ -64,6 +87,9 @@ enum DeepLink: Equatable {
64 case "status": 87 case "status":
65 self = .systemStatus 88 self = .systemStatus
66 89
90 case "lookup" where components.count >= 2:
91 self = .userProfile(owner: components[1])
92
67 case "lookup": 93 case "lookup":
68 self = .lookup 94 self = .lookup
69 95
@@ -74,14 +100,18 @@ enum DeepLink: Equatable {
74} 100}
75 101
76private extension URL { 102private extension URL {
77 /// Parse path components from a custom-scheme URL. 103 var deepLinkPathComponents: [String] {
78 /// For `hutch://git/~user/repo`, returns `["git", "~user", "repo"]`. 104 guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false) else {
79 func pathComponents(fromScheme scheme: String) -> [String] { 105 return []
80 // Remove scheme prefix and split by "/" 106 }
81 var str = absoluteString 107
82 if str.hasPrefix("\(scheme)://") { 108 let pathComponents = components.path
83 str = String(str.dropFirst("\(scheme)://".count)) 109 .split(separator: "/", omittingEmptySubsequences: true)
110 .map(String.init)
111
112 if let host = components.host, !host.isEmpty {
113 return [host] + pathComponents
84 } 114 }
85 return str.split(separator: "/").map(String.init) 115 return pathComponents
86 } 116 }
87} 117}
Hutch/App/HutchApp.swift +6
@@ -1,7 +1,9 @@
1import SwiftUI 1import SwiftUI
2import os
2 3
3@main 4@main
4struct HutchApp: App { 5struct HutchApp: App {
6 private let deepLinkLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
5 @State private var appState = AppState() 7 @State private var appState = AppState()
6 @State private var networkMonitor = NetworkMonitor() 8 @State private var networkMonitor = NetworkMonitor()
7 @AppStorage(AppStorageKeys.appTheme, store: .standard) private var appTheme: AppTheme = .system 9 @AppStorage(AppStorageKeys.appTheme, store: .standard) private var appTheme: AppTheme = .system
@@ -18,8 +20,12 @@ struct HutchApp: App {
18 .background(appTheme == .amoled ? Color.black : Color.clear) 20 .background(appTheme == .amoled ? Color.black : Color.clear)
19 .preferredColorScheme(appTheme.colorScheme) 21 .preferredColorScheme(appTheme.colorScheme)
20 .onOpenURL { url in 22 .onOpenURL { url in
23 deepLinkLogger.info("Received URL: \(url.absoluteString, privacy: .public)")
21 if let link = DeepLink(url: url) { 24 if let link = DeepLink(url: url) {
25 deepLinkLogger.info("Parsed deep link: \(String(describing: link), privacy: .public)")
22 appState.pendingDeepLink = link 26 appState.pendingDeepLink = link
27 } else {
28 deepLinkLogger.error("Rejected URL: \(url.absoluteString, privacy: .public)")
23 } 29 }
24 } 30 }
25 .onChange(of: HutchIntentNavigator.shared.pendingDestination) { _, destination in 31 .onChange(of: HutchIntentNavigator.shared.pendingDestination) { _, destination in
Hutch/App/RootView.swift +97 −6
@@ -1,4 +1,7 @@
1import SwiftUI 1import SwiftUI
2import os
3
4private let rootDeepLinkLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
2 5
3/// The root view of the app. Shows a TabView when authenticated, or a 6/// The root view of the app. Shows a TabView when authenticated, or a
4/// full-screen sheet for token entry on first launch. 7/// full-screen sheet for token entry on first launch.
@@ -154,6 +157,7 @@ struct RootView: View {
154 // MARK: - Deep Link Handling 157 // MARK: - Deep Link Handling
155 158
156 private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) { 159 private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) {
160 rootDeepLinkLogger.info("Auth phase changed: \(String(describing: newPhase), privacy: .public); pendingDeepLink=\(String(describing: appState.pendingDeepLink), privacy: .public)")
157 switch newPhase { 161 switch newPhase {
158 case .launching: 162 case .launching:
159 break 163 break
@@ -171,7 +175,11 @@ struct RootView: View {
171 } 175 }
172 176
173 private func consumePendingDeepLinkIfPossible(_ link: DeepLink?) { 177 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 }
175 handleDeepLink(link) 183 handleDeepLink(link)
176 appState.pendingDeepLink = nil 184 appState.pendingDeepLink = nil
177 } 185 }
@@ -183,15 +191,19 @@ struct RootView: View {
183 } 191 }
184 192
185 private func handleDeepLink(_ link: DeepLink) { 193 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 }
187 199
188 switch link { 200 switch link {
189 case .home: 201 case .home:
190 homePath = NavigationPath() 202 homePath = NavigationPath()
191 appState.selectedTab = .home 203 appState.selectedTab = .home
192 204
193 case .repository(let owner, let repo): 205 case .repository(let service, let owner, let repo):
194 resolveRepositoryLink(owner: owner, repo: repo) 206 resolveRepositoryLink(service: service, owner: owner, repo: repo)
195 207
196 case .build(let jobId): 208 case .build(let jobId):
197 buildsPath = NavigationPath() 209 buildsPath = NavigationPath()
@@ -204,6 +216,12 @@ struct RootView: View {
204 case .ticket(let owner, let tracker, let ticketId): 216 case .ticket(let owner, let tracker, let ticketId):
205 resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId) 217 resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
206 218
219 case .mailingList(let owner, let list):
220 resolveMailingListLink(owner: owner, list: list)
221
222 case .userProfile(let owner):
223 resolveUserProfileLink(owner: owner)
224
207 case .work: 225 case .work:
208 homePath = NavigationPath() 226 homePath = NavigationPath()
209 appState.selectedTab = .home 227 appState.selectedTab = .home
@@ -276,12 +294,12 @@ struct RootView: View {
276 } 294 }
277 } 295 }
278 296
279 private func resolveRepositoryLink(owner: String, repo: String) { 297 private func resolveRepositoryLink(service: SRHTService, owner: String, repo: String) {
280 isResolvingDeepLink = true 298 isResolvingDeepLink = true
281 Task { 299 Task {
282 defer { isResolvingDeepLink = false } 300 defer { isResolvingDeepLink = false }
283 do { 301 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)
285 repoPath = NavigationPath() 303 repoPath = NavigationPath()
286 appState.selectedTab = .repositories 304 appState.selectedTab = .repositories
287 await settleNavigationTransition() 305 await settleNavigationTransition()
@@ -292,6 +310,34 @@ struct RootView: View {
292 } 310 }
293 } 311 }
294 312
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
295 private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) { 341 private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
296 isResolvingDeepLink = true 342 isResolvingDeepLink = true
297 Task { 343 Task {
@@ -337,6 +383,7 @@ enum MoreRoute: Hashable {
337 case systemStatus 383 case systemStatus
338 case settings 384 case settings
339 case about 385 case about
386 case userProfile(String)
340 case mailingList(InboxMailingListReference) 387 case mailingList(InboxMailingListReference)
341 case thread(InboxThreadSummary) 388 case thread(InboxThreadSummary)
342 case manPageBrowser 389 case manPageBrowser
@@ -366,6 +413,8 @@ private struct MoreNavigationRoot: View {
366 SettingsView() 413 SettingsView()
367 case .about: 414 case .about:
368 AboutView() 415 AboutView()
416 case .userProfile(let owner):
417 UserProfileDeepLinkView(owner: owner)
369 case .mailingList(let mailingList): 418 case .mailingList(let mailingList):
370 MailingListDetailView(mailingList: mailingList) 419 MailingListDetailView(mailingList: mailingList)
371 case .thread(let thread): 420 case .thread(let thread):
@@ -393,6 +442,48 @@ private struct MoreNavigationRoot: View {
393 } 442 }
394} 443}
395 444
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
396// MARK: - Ticket Deep Link Navigation Target 487// MARK: - Ticket Deep Link Navigation Target
397 488
398/// Hashable wrapper to push a ticket detail view from a deep link. 489/// Hashable wrapper to push a ticket detail view from a deep link.
Hutch/Models/User.swift +1 −1
@@ -1,7 +1,7 @@
1import Foundation 1import Foundation
2 2
3/// A Sourcehut user from meta.sr.ht. 3/// A Sourcehut user from meta.sr.ht.
4struct User: Decodable, Sendable { 4struct User: Decodable, Sendable, Hashable {
5 let id: Int 5 let id: Int
6 let created: String? 6 let created: String?
7 let updated: String? 7 let updated: String?
Hutch/Views/Lookup/LookupView.swift +2
@@ -443,6 +443,8 @@ struct LookupView: View {
443 SettingsView() 443 SettingsView()
444 case .about: 444 case .about:
445 AboutView() 445 AboutView()
446 case .userProfile(let owner):
447 UserProfileDeepLinkView(owner: owner)
446 case .mailingList(let mailingList): 448 case .mailingList(let mailingList):
447 MailingListDetailView(mailingList: mailingList) 449 MailingListDetailView(mailingList: mailingList)
448 case .thread(let thread): 450 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 {
15 Form { 15 Form {
16 appearanceSection() 16 appearanceSection()
17 behaviorSection() 17 behaviorSection()
18 safariExtensionSection()
18 authenticationSection() 19 authenticationSection()
19 } 20 }
20 .themedList() 21 .themedList()
@@ -55,6 +56,22 @@ struct SettingsView: View {
55 } 56 }
56 } 57 }
57 58
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
58 @ViewBuilder 75 @ViewBuilder
59 private func appearanceSection() -> some View { 76 private func appearanceSection() -> some View {
60 Section { 77 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
4 4
5struct AppStateTests { 5struct AppStateTests {
6 6
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
7 @Test 15 @Test
8 @MainActor 16 @MainActor
9 func presentRepositoryDeepLinkErrorSetsUserFacingMessage() { 17 func presentRepositoryDeepLinkErrorSetsUserFacingMessage() {
HutchTests/DeepLinkTests.swift +31 −1
@@ -19,7 +19,19 @@ struct DeepLinkTests {
19 @Test 19 @Test
20 func parsesRepositoryLink() { 20 func parsesRepositoryLink() {
21 let link = DeepLink(url: HutchDeepLinkURL.repositoryGit) 21 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"))
23 } 35 }
24 36
25 @Test 37 @Test
@@ -34,6 +46,18 @@ struct DeepLinkTests {
34 #expect(link == .build(jobId: 12345)) 46 #expect(link == .build(jobId: 12345))
35 } 47 }
36 48
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
37 @Test 61 @Test
38 func parsesBuildsTabLink() { 62 func parsesBuildsTabLink() {
39 let link = DeepLink(url: HutchDeepLinkURL.builds) 63 let link = DeepLink(url: HutchDeepLinkURL.builds)
@@ -64,6 +88,12 @@ struct DeepLinkTests {
64 #expect(link == .lookup) 88 #expect(link == .lookup)
65 } 89 }
66 90
91 @Test
92 func parsesUserProfileLink() {
93 let link = DeepLink(url: HutchDeepLinkURL.userProfile)
94 #expect(link == .userProfile(owner: "~owner"))
95 }
96
67 @Test 97 @Test
68 func rejectsNonHutchScheme() { 98 func rejectsNonHutchScheme() {
69 let link = DeepLink(url: URL(string: "https://example.com/home")!) 99 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 {
5 static let home = URL(string: "hutch://home")! 5 static let home = URL(string: "hutch://home")!
6 static let emptyHost = URL(string: "hutch://")! 6 static let emptyHost = URL(string: "hutch://")!
7 static let repositoryGit = URL(string: "hutch://git/~user/repo")! 7 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/")!
8 static let ticket = URL(string: "hutch://todo/~owner/tracker/42")! 10 static let ticket = URL(string: "hutch://todo/~owner/tracker/42")!
9 static let buildJob = URL(string: "hutch://builds/12345")! 11 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")!
10 static let trackers = URL(string: "hutch://trackers")! 14 static let trackers = URL(string: "hutch://trackers")!
11 static let builds = URL(string: "hutch://builds")! 15 static let builds = URL(string: "hutch://builds")!
12 static let repositories = URL(string: "hutch://repositories")! 16 static let repositories = URL(string: "hutch://repositories")!
13 static let status = URL(string: "hutch://status")! 17 static let status = URL(string: "hutch://status")!
14 static let lookup = URL(string: "hutch://lookup")! 18 static let lookup = URL(string: "hutch://lookup")!
19 static let userProfile = URL(string: "hutch://lookup/~owner")!
15 static let unknown = URL(string: "hutch://unknown")! 20 static let unknown = URL(string: "hutch://unknown")!
16 static let invalidTicketId = URL(string: "hutch://todo/~owner/tracker/abc")! 21 static let invalidTicketId = URL(string: "hutch://todo/~owner/tracker/abc")!
17 static let invalidBuildId = URL(string: "hutch://builds/abc")! 22 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}