Commit 5da0306f24
Unsigned
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 | |||
| 2 | import SwiftUI | 2 | import SwiftUI |
| 3 | import UIKit | 3 | import UIKit |
| 4 | import WebKit | 4 | import WebKit |
| 5 | import os | ||
| 6 | |||
| 7 | private 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 | |||
| 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 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import os | ||
| 3 | |||
| 4 | private 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. |
| 4 | enum DeepLink: Equatable { | 7 | enum 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 | ||
| 76 | private extension URL { | 102 | private 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 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | import os | ||
| 2 | 3 | ||
| 3 | @main | 4 | @main |
| 4 | struct HutchApp: App { | 5 | struct 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 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | import os | ||
| 3 | |||
| 4 | private 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 | ||
| 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 | // 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 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | 2 | ||
| 3 | /// A Sourcehut user from meta.sr.ht. | 3 | /// A Sourcehut user from meta.sr.ht. |
| 4 | struct User: Decodable, Sendable { | 4 | struct 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 @@ | |||
| 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 | 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 @@ | |||
| 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 | struct AppStateTests { | 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 | @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 @@ | |||
| 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 | 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 @@ | |||
| 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 | } | ||