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