Commit aeca25dc10
Verified · cmc
gitbay.xcodeproj/project.pbxproj added +461
| @@ -0,0 +1,461 @@ | ||
| 1 | // !$*UTF8*$! | |
| 2 | { | |
| 3 | archiveVersion = 1; | |
| 4 | classes = { | |
| 5 | }; | |
| 6 | objectVersion = 77; | |
| 7 | objects = { | |
| 8 | ||
| 9 | /* Begin PBXContainerItemProxy section */ | |
| 10 | 8B2898A7303FF3E5001BF44A /* PBXContainerItemProxy */ = { | |
| 11 | isa = PBXContainerItemProxy; | |
| 12 | containerPortal = 8B28980A303FF3E5001BF44A /* Project object */; | |
| 13 | proxyType = 1; | |
| 14 | remoteGlobalIDString = 8B289811303FF3E5001BF44A; | |
| 15 | remoteInfo = gitbay; | |
| 16 | }; | |
| 17 | /* End PBXContainerItemProxy section */ | |
| 18 | ||
| 19 | /* Begin PBXFileReference section */ | |
| 20 | 8B289812303FF3E5001BF44A /* gitbay.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = gitbay.app; sourceTree = BUILT_PRODUCTS_DIR; }; | |
| 21 | 8B2898A2303FF3E5001BF44A /* gitbayTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = gitbayTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; | |
| 22 | /* End PBXFileReference section */ | |
| 23 | ||
| 24 | /* Begin PBXFileSystemSynchronizedRootGroup section */ | |
| 25 | 8B289814303FF3E5001BF44A /* gitbay */ = { | |
| 26 | isa = PBXFileSystemSynchronizedRootGroup; | |
| 27 | path = gitbay; | |
| 28 | sourceTree = "<group>"; | |
| 29 | }; | |
| 30 | 8B2898A0303FF3E5001BF44A /* gitbayTests */ = { | |
| 31 | isa = PBXFileSystemSynchronizedRootGroup; | |
| 32 | path = gitbayTests; | |
| 33 | sourceTree = "<group>"; | |
| 34 | }; | |
| 35 | /* End PBXFileSystemSynchronizedRootGroup section */ | |
| 36 | ||
| 37 | /* Begin PBXFrameworksBuildPhase section */ | |
| 38 | 8B28980F303FF3E5001BF44A /* Frameworks */ = { | |
| 39 | isa = PBXFrameworksBuildPhase; | |
| 40 | buildActionMask = 2147483647; | |
| 41 | files = ( | |
| 42 | ); | |
| 43 | runOnlyForDeploymentPostprocessing = 0; | |
| 44 | }; | |
| 45 | 8B2898A4303FF3E5001BF44A /* Frameworks */ = { | |
| 46 | isa = PBXFrameworksBuildPhase; | |
| 47 | buildActionMask = 2147483647; | |
| 48 | files = ( | |
| 49 | ); | |
| 50 | runOnlyForDeploymentPostprocessing = 0; | |
| 51 | }; | |
| 52 | /* End PBXFrameworksBuildPhase section */ | |
| 53 | ||
| 54 | /* Begin PBXGroup section */ | |
| 55 | 8B289809303FF3E5001BF44A = { | |
| 56 | isa = PBXGroup; | |
| 57 | children = ( | |
| 58 | 8B289814303FF3E5001BF44A /* gitbay */, | |
| 59 | 8B2898A0303FF3E5001BF44A /* gitbayTests */, | |
| 60 | 8B289813303FF3E5001BF44A /* Products */, | |
| 61 | ); | |
| 62 | sourceTree = "<group>"; | |
| 63 | }; | |
| 64 | 8B289813303FF3E5001BF44A /* Products */ = { | |
| 65 | isa = PBXGroup; | |
| 66 | children = ( | |
| 67 | 8B289812303FF3E5001BF44A /* gitbay.app */, | |
| 68 | 8B2898A2303FF3E5001BF44A /* gitbayTests.xctest */, | |
| 69 | ); | |
| 70 | name = Products; | |
| 71 | sourceTree = "<group>"; | |
| 72 | }; | |
| 73 | /* End PBXGroup section */ | |
| 74 | ||
| 75 | /* Begin PBXNativeTarget section */ | |
| 76 | 8B289811303FF3E5001BF44A /* gitbay */ = { | |
| 77 | isa = PBXNativeTarget; | |
| 78 | buildConfigurationList = 8B28981D303FF3E6001BF44A /* Build configuration list for PBXNativeTarget "gitbay" */; | |
| 79 | buildPhases = ( | |
| 80 | 8B28980E303FF3E5001BF44A /* Sources */, | |
| 81 | 8B28980F303FF3E5001BF44A /* Frameworks */, | |
| 82 | 8B289810303FF3E5001BF44A /* Resources */, | |
| 83 | ); | |
| 84 | buildRules = ( | |
| 85 | ); | |
| 86 | dependencies = ( | |
| 87 | ); | |
| 88 | fileSystemSynchronizedGroups = ( | |
| 89 | 8B289814303FF3E5001BF44A /* gitbay */, | |
| 90 | ); | |
| 91 | name = gitbay; | |
| 92 | packageProductDependencies = ( | |
| 93 | ); | |
| 94 | productName = gitbay; | |
| 95 | productReference = 8B289812303FF3E5001BF44A /* gitbay.app */; | |
| 96 | productType = "com.apple.product-type.application"; | |
| 97 | }; | |
| 98 | 8B2898A1303FF3E5001BF44A /* gitbayTests */ = { | |
| 99 | isa = PBXNativeTarget; | |
| 100 | buildConfigurationList = 8B2898AA303FF3E5001BF44A /* Build configuration list for PBXNativeTarget "gitbayTests" */; | |
| 101 | buildPhases = ( | |
| 102 | 8B2898A3303FF3E5001BF44A /* Sources */, | |
| 103 | 8B2898A4303FF3E5001BF44A /* Frameworks */, | |
| 104 | 8B2898A5303FF3E5001BF44A /* Resources */, | |
| 105 | ); | |
| 106 | buildRules = ( | |
| 107 | ); | |
| 108 | dependencies = ( | |
| 109 | 8B2898A6303FF3E5001BF44A /* PBXTargetDependency */, | |
| 110 | ); | |
| 111 | fileSystemSynchronizedGroups = ( | |
| 112 | 8B2898A0303FF3E5001BF44A /* gitbayTests */, | |
| 113 | ); | |
| 114 | name = gitbayTests; | |
| 115 | packageProductDependencies = ( | |
| 116 | ); | |
| 117 | productName = gitbayTests; | |
| 118 | productReference = 8B2898A2303FF3E5001BF44A /* gitbayTests.xctest */; | |
| 119 | productType = "com.apple.product-type.bundle.unit-test"; | |
| 120 | }; | |
| 121 | /* End PBXNativeTarget section */ | |
| 122 | ||
| 123 | /* Begin PBXProject section */ | |
| 124 | 8B28980A303FF3E5001BF44A /* Project object */ = { | |
| 125 | isa = PBXProject; | |
| 126 | attributes = { | |
| 127 | BuildIndependentTargetsInParallel = 1; | |
| 128 | LastSwiftUpdateCheck = 2660; | |
| 129 | LastUpgradeCheck = 2660; | |
| 130 | TargetAttributes = { | |
| 131 | 8B289811303FF3E5001BF44A = { | |
| 132 | CreatedOnToolsVersion = 26.6; | |
| 133 | }; | |
| 134 | 8B2898A1303FF3E5001BF44A = { | |
| 135 | CreatedOnToolsVersion = 26.6; | |
| 136 | TestTargetID = 8B289811303FF3E5001BF44A; | |
| 137 | }; | |
| 138 | }; | |
| 139 | }; | |
| 140 | buildConfigurationList = 8B28980D303FF3E5001BF44A /* Build configuration list for PBXProject "gitbay" */; | |
| 141 | developmentRegion = en; | |
| 142 | hasScannedForEncodings = 0; | |
| 143 | knownRegions = ( | |
| 144 | en, | |
| 145 | Base, | |
| 146 | ); | |
| 147 | mainGroup = 8B289809303FF3E5001BF44A; | |
| 148 | minimizedProjectReferenceProxies = 1; | |
| 149 | preferredProjectObjectVersion = 77; | |
| 150 | productRefGroup = 8B289813303FF3E5001BF44A /* Products */; | |
| 151 | projectDirPath = ""; | |
| 152 | projectRoot = ""; | |
| 153 | targets = ( | |
| 154 | 8B289811303FF3E5001BF44A /* gitbay */, | |
| 155 | 8B2898A1303FF3E5001BF44A /* gitbayTests */, | |
| 156 | ); | |
| 157 | }; | |
| 158 | /* End PBXProject section */ | |
| 159 | ||
| 160 | /* Begin PBXResourcesBuildPhase section */ | |
| 161 | 8B289810303FF3E5001BF44A /* Resources */ = { | |
| 162 | isa = PBXResourcesBuildPhase; | |
| 163 | buildActionMask = 2147483647; | |
| 164 | files = ( | |
| 165 | ); | |
| 166 | runOnlyForDeploymentPostprocessing = 0; | |
| 167 | }; | |
| 168 | 8B2898A5303FF3E5001BF44A /* Resources */ = { | |
| 169 | isa = PBXResourcesBuildPhase; | |
| 170 | buildActionMask = 2147483647; | |
| 171 | files = ( | |
| 172 | ); | |
| 173 | runOnlyForDeploymentPostprocessing = 0; | |
| 174 | }; | |
| 175 | /* End PBXResourcesBuildPhase section */ | |
| 176 | ||
| 177 | /* Begin PBXSourcesBuildPhase section */ | |
| 178 | 8B28980E303FF3E5001BF44A /* Sources */ = { | |
| 179 | isa = PBXSourcesBuildPhase; | |
| 180 | buildActionMask = 2147483647; | |
| 181 | files = ( | |
| 182 | ); | |
| 183 | runOnlyForDeploymentPostprocessing = 0; | |
| 184 | }; | |
| 185 | 8B2898A3303FF3E5001BF44A /* Sources */ = { | |
| 186 | isa = PBXSourcesBuildPhase; | |
| 187 | buildActionMask = 2147483647; | |
| 188 | files = ( | |
| 189 | ); | |
| 190 | runOnlyForDeploymentPostprocessing = 0; | |
| 191 | }; | |
| 192 | /* End PBXSourcesBuildPhase section */ | |
| 193 | ||
| 194 | /* Begin PBXTargetDependency section */ | |
| 195 | 8B2898A6303FF3E5001BF44A /* PBXTargetDependency */ = { | |
| 196 | isa = PBXTargetDependency; | |
| 197 | target = 8B289811303FF3E5001BF44A /* gitbay */; | |
| 198 | targetProxy = 8B2898A7303FF3E5001BF44A /* PBXContainerItemProxy */; | |
| 199 | }; | |
| 200 | /* End PBXTargetDependency section */ | |
| 201 | ||
| 202 | /* Begin XCBuildConfiguration section */ | |
| 203 | 8B28981B303FF3E6001BF44A /* Debug */ = { | |
| 204 | isa = XCBuildConfiguration; | |
| 205 | buildSettings = { | |
| 206 | ALWAYS_SEARCH_USER_PATHS = NO; | |
| 207 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; | |
| 208 | CLANG_ANALYZER_NONNULL = YES; | |
| 209 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; | |
| 210 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; | |
| 211 | CLANG_ENABLE_MODULES = YES; | |
| 212 | CLANG_ENABLE_OBJC_ARC = YES; | |
| 213 | CLANG_ENABLE_OBJC_WEAK = YES; | |
| 214 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; | |
| 215 | CLANG_WARN_BOOL_CONVERSION = YES; | |
| 216 | CLANG_WARN_COMMA = YES; | |
| 217 | CLANG_WARN_CONSTANT_CONVERSION = YES; | |
| 218 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; | |
| 219 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; | |
| 220 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; | |
| 221 | CLANG_WARN_EMPTY_BODY = YES; | |
| 222 | CLANG_WARN_ENUM_CONVERSION = YES; | |
| 223 | CLANG_WARN_INFINITE_RECURSION = YES; | |
| 224 | CLANG_WARN_INT_CONVERSION = YES; | |
| 225 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; | |
| 226 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; | |
| 227 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; | |
| 228 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; | |
| 229 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; | |
| 230 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; | |
| 231 | CLANG_WARN_STRICT_PROTOTYPES = YES; | |
| 232 | CLANG_WARN_SUSPICIOUS_MOVE = YES; | |
| 233 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; | |
| 234 | CLANG_WARN_UNREACHABLE_CODE = YES; | |
| 235 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; | |
| 236 | COPY_PHASE_STRIP = NO; | |
| 237 | DEBUG_INFORMATION_FORMAT = dwarf; | |
| 238 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | |
| 239 | ENABLE_STRICT_OBJC_MSGSEND = YES; | |
| 240 | ENABLE_TESTABILITY = YES; | |
| 241 | ENABLE_USER_SCRIPT_SANDBOXING = YES; | |
| 242 | GCC_C_LANGUAGE_STANDARD = gnu17; | |
| 243 | GCC_DYNAMIC_NO_PIC = NO; | |
| 244 | GCC_NO_COMMON_BLOCKS = YES; | |
| 245 | GCC_OPTIMIZATION_LEVEL = 0; | |
| 246 | GCC_PREPROCESSOR_DEFINITIONS = ( | |
| 247 | "DEBUG=1", | |
| 248 | "$(inherited)", | |
| 249 | ); | |
| 250 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; | |
| 251 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; | |
| 252 | GCC_WARN_UNDECLARED_SELECTOR = YES; | |
| 253 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; | |
| 254 | GCC_WARN_UNUSED_FUNCTION = YES; | |
| 255 | GCC_WARN_UNUSED_VARIABLE = YES; | |
| 256 | IPHONEOS_DEPLOYMENT_TARGET = 26.5; | |
| 257 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; | |
| 258 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; | |
| 259 | MTL_FAST_MATH = YES; | |
| 260 | ONLY_ACTIVE_ARCH = YES; | |
| 261 | SDKROOT = iphoneos; | |
| 262 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; | |
| 263 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; | |
| 264 | }; | |
| 265 | name = Debug; | |
| 266 | }; | |
| 267 | 8B28981C303FF3E6001BF44A /* Release */ = { | |
| 268 | isa = XCBuildConfiguration; | |
| 269 | buildSettings = { | |
| 270 | ALWAYS_SEARCH_USER_PATHS = NO; | |
| 271 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; | |
| 272 | CLANG_ANALYZER_NONNULL = YES; | |
| 273 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; | |
| 274 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; | |
| 275 | CLANG_ENABLE_MODULES = YES; | |
| 276 | CLANG_ENABLE_OBJC_ARC = YES; | |
| 277 | CLANG_ENABLE_OBJC_WEAK = YES; | |
| 278 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; | |
| 279 | CLANG_WARN_BOOL_CONVERSION = YES; | |
| 280 | CLANG_WARN_COMMA = YES; | |
| 281 | CLANG_WARN_CONSTANT_CONVERSION = YES; | |
| 282 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; | |
| 283 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; | |
| 284 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; | |
| 285 | CLANG_WARN_EMPTY_BODY = YES; | |
| 286 | CLANG_WARN_ENUM_CONVERSION = YES; | |
| 287 | CLANG_WARN_INFINITE_RECURSION = YES; | |
| 288 | CLANG_WARN_INT_CONVERSION = YES; | |
| 289 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; | |
| 290 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; | |
| 291 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; | |
| 292 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; | |
| 293 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; | |
| 294 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; | |
| 295 | CLANG_WARN_STRICT_PROTOTYPES = YES; | |
| 296 | CLANG_WARN_SUSPICIOUS_MOVE = YES; | |
| 297 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; | |
| 298 | CLANG_WARN_UNREACHABLE_CODE = YES; | |
| 299 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; | |
| 300 | COPY_PHASE_STRIP = NO; | |
| 301 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; | |
| 302 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | |
| 303 | ENABLE_NS_ASSERTIONS = NO; | |
| 304 | ENABLE_STRICT_OBJC_MSGSEND = YES; | |
| 305 | ENABLE_USER_SCRIPT_SANDBOXING = YES; | |
| 306 | GCC_C_LANGUAGE_STANDARD = gnu17; | |
| 307 | GCC_NO_COMMON_BLOCKS = YES; | |
| 308 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; | |
| 309 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; | |
| 310 | GCC_WARN_UNDECLARED_SELECTOR = YES; | |
| 311 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; | |
| 312 | GCC_WARN_UNUSED_FUNCTION = YES; | |
| 313 | GCC_WARN_UNUSED_VARIABLE = YES; | |
| 314 | IPHONEOS_DEPLOYMENT_TARGET = 26.5; | |
| 315 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; | |
| 316 | MTL_ENABLE_DEBUG_INFO = NO; | |
| 317 | MTL_FAST_MATH = YES; | |
| 318 | SDKROOT = iphoneos; | |
| 319 | SWIFT_COMPILATION_MODE = wholemodule; | |
| 320 | VALIDATE_PRODUCT = YES; | |
| 321 | }; | |
| 322 | name = Release; | |
| 323 | }; | |
| 324 | 8B28981E303FF3E6001BF44A /* Debug */ = { | |
| 325 | isa = XCBuildConfiguration; | |
| 326 | buildSettings = { | |
| 327 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | |
| 328 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | |
| 329 | CODE_SIGN_STYLE = Automatic; | |
| 330 | CURRENT_PROJECT_VERSION = 1; | |
| 331 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | |
| 332 | ENABLE_PREVIEWS = YES; | |
| 333 | GENERATE_INFOPLIST_FILE = YES; | |
| 334 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; | |
| 335 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; | |
| 336 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; | |
| 337 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; | |
| 338 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; | |
| 339 | LD_RUNPATH_SEARCH_PATHS = ( | |
| 340 | "$(inherited)", | |
| 341 | "@executable_path/Frameworks", | |
| 342 | ); | |
| 343 | MARKETING_VERSION = 1.0; | |
| 344 | PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbay; | |
| 345 | PRODUCT_NAME = "$(TARGET_NAME)"; | |
| 346 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | |
| 347 | SWIFT_APPROACHABLE_CONCURRENCY = YES; | |
| 348 | SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; | |
| 349 | SWIFT_EMIT_LOC_STRINGS = YES; | |
| 350 | SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; | |
| 351 | SWIFT_VERSION = 6.0; | |
| 352 | TARGETED_DEVICE_FAMILY = "1,2"; | |
| 353 | }; | |
| 354 | name = Debug; | |
| 355 | }; | |
| 356 | 8B28981F303FF3E6001BF44A /* Release */ = { | |
| 357 | isa = XCBuildConfiguration; | |
| 358 | buildSettings = { | |
| 359 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | |
| 360 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | |
| 361 | CODE_SIGN_STYLE = Automatic; | |
| 362 | CURRENT_PROJECT_VERSION = 1; | |
| 363 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | |
| 364 | ENABLE_PREVIEWS = YES; | |
| 365 | GENERATE_INFOPLIST_FILE = YES; | |
| 366 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; | |
| 367 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; | |
| 368 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; | |
| 369 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; | |
| 370 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; | |
| 371 | LD_RUNPATH_SEARCH_PATHS = ( | |
| 372 | "$(inherited)", | |
| 373 | "@executable_path/Frameworks", | |
| 374 | ); | |
| 375 | MARKETING_VERSION = 1.0; | |
| 376 | PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbay; | |
| 377 | PRODUCT_NAME = "$(TARGET_NAME)"; | |
| 378 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | |
| 379 | SWIFT_APPROACHABLE_CONCURRENCY = YES; | |
| 380 | SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; | |
| 381 | SWIFT_EMIT_LOC_STRINGS = YES; | |
| 382 | SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; | |
| 383 | SWIFT_VERSION = 6.0; | |
| 384 | TARGETED_DEVICE_FAMILY = "1,2"; | |
| 385 | }; | |
| 386 | name = Release; | |
| 387 | }; | |
| 388 | 8B2898A8303FF3E5001BF44A /* Debug */ = { | |
| 389 | isa = XCBuildConfiguration; | |
| 390 | buildSettings = { | |
| 391 | BUNDLE_LOADER = "$(TEST_HOST)"; | |
| 392 | CURRENT_PROJECT_VERSION = 1; | |
| 393 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | |
| 394 | GENERATE_INFOPLIST_FILE = YES; | |
| 395 | MARKETING_VERSION = 1.0; | |
| 396 | PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbayTests; | |
| 397 | PRODUCT_NAME = "$(TARGET_NAME)"; | |
| 398 | SWIFT_APPROACHABLE_CONCURRENCY = YES; | |
| 399 | SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; | |
| 400 | SWIFT_EMIT_LOC_STRINGS = NO; | |
| 401 | SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; | |
| 402 | SWIFT_VERSION = 6.0; | |
| 403 | TARGETED_DEVICE_FAMILY = "1,2"; | |
| 404 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/gitbay.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/gitbay"; | |
| 405 | }; | |
| 406 | name = Debug; | |
| 407 | }; | |
| 408 | 8B2898A9303FF3E5001BF44A /* Release */ = { | |
| 409 | isa = XCBuildConfiguration; | |
| 410 | buildSettings = { | |
| 411 | BUNDLE_LOADER = "$(TEST_HOST)"; | |
| 412 | CURRENT_PROJECT_VERSION = 1; | |
| 413 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | |
| 414 | GENERATE_INFOPLIST_FILE = YES; | |
| 415 | MARKETING_VERSION = 1.0; | |
| 416 | PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbayTests; | |
| 417 | PRODUCT_NAME = "$(TARGET_NAME)"; | |
| 418 | SWIFT_APPROACHABLE_CONCURRENCY = YES; | |
| 419 | SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; | |
| 420 | SWIFT_EMIT_LOC_STRINGS = NO; | |
| 421 | SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; | |
| 422 | SWIFT_VERSION = 6.0; | |
| 423 | TARGETED_DEVICE_FAMILY = "1,2"; | |
| 424 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/gitbay.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/gitbay"; | |
| 425 | }; | |
| 426 | name = Release; | |
| 427 | }; | |
| 428 | /* End XCBuildConfiguration section */ | |
| 429 | ||
| 430 | /* Begin XCConfigurationList section */ | |
| 431 | 8B28980D303FF3E5001BF44A /* Build configuration list for PBXProject "gitbay" */ = { | |
| 432 | isa = XCConfigurationList; | |
| 433 | buildConfigurations = ( | |
| 434 | 8B28981B303FF3E6001BF44A /* Debug */, | |
| 435 | 8B28981C303FF3E6001BF44A /* Release */, | |
| 436 | ); | |
| 437 | defaultConfigurationIsVisible = 0; | |
| 438 | defaultConfigurationName = Release; | |
| 439 | }; | |
| 440 | 8B28981D303FF3E6001BF44A /* Build configuration list for PBXNativeTarget "gitbay" */ = { | |
| 441 | isa = XCConfigurationList; | |
| 442 | buildConfigurations = ( | |
| 443 | 8B28981E303FF3E6001BF44A /* Debug */, | |
| 444 | 8B28981F303FF3E6001BF44A /* Release */, | |
| 445 | ); | |
| 446 | defaultConfigurationIsVisible = 0; | |
| 447 | defaultConfigurationName = Release; | |
| 448 | }; | |
| 449 | 8B2898AA303FF3E5001BF44A /* Build configuration list for PBXNativeTarget "gitbayTests" */ = { | |
| 450 | isa = XCConfigurationList; | |
| 451 | buildConfigurations = ( | |
| 452 | 8B2898A8303FF3E5001BF44A /* Debug */, | |
| 453 | 8B2898A9303FF3E5001BF44A /* Release */, | |
| 454 | ); | |
| 455 | defaultConfigurationIsVisible = 0; | |
| 456 | defaultConfigurationName = Release; | |
| 457 | }; | |
| 458 | /* End XCConfigurationList section */ | |
| 459 | }; | |
| 460 | rootObject = 8B28980A303FF3E5001BF44A /* Project object */; | |
| 461 | } | |
gitbay.xcodeproj/project.xcworkspace/contents.xcworkspacedata added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | <?xml version="1.0" encoding="UTF-8"?> | |
| 2 | <Workspace | |
| 3 | version = "1.0"> | |
| 4 | <FileRef | |
| 5 | location = "self:"> | |
| 6 | </FileRef> | |
| 7 | </Workspace> | |
gitbay/Assets.xcassets/AccentColor.colorset/Contents.json added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | { | |
| 2 | "colors" : [ | |
| 3 | { | |
| 4 | "idiom" : "universal" | |
| 5 | } | |
| 6 | ], | |
| 7 | "info" : { | |
| 8 | "author" : "xcode", | |
| 9 | "version" : 1 | |
| 10 | } | |
| 11 | } | |
gitbay/Assets.xcassets/AppIcon.appiconset/Contents.json added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | { | |
| 2 | "images" : [ | |
| 3 | { | |
| 4 | "idiom" : "universal", | |
| 5 | "platform" : "ios", | |
| 6 | "size" : "1024x1024" | |
| 7 | }, | |
| 8 | { | |
| 9 | "appearances" : [ | |
| 10 | { | |
| 11 | "appearance" : "luminosity", | |
| 12 | "value" : "dark" | |
| 13 | } | |
| 14 | ], | |
| 15 | "idiom" : "universal", | |
| 16 | "platform" : "ios", | |
| 17 | "size" : "1024x1024" | |
| 18 | }, | |
| 19 | { | |
| 20 | "appearances" : [ | |
| 21 | { | |
| 22 | "appearance" : "luminosity", | |
| 23 | "value" : "tinted" | |
| 24 | } | |
| 25 | ], | |
| 26 | "idiom" : "universal", | |
| 27 | "platform" : "ios", | |
| 28 | "size" : "1024x1024" | |
| 29 | } | |
| 30 | ], | |
| 31 | "info" : { | |
| 32 | "author" : "xcode", | |
| 33 | "version" : 1 | |
| 34 | } | |
| 35 | } | |
gitbay/Assets.xcassets/Contents.json added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | { | |
| 2 | "info" : { | |
| 3 | "author" : "xcode", | |
| 4 | "version" : 1 | |
| 5 | } | |
| 6 | } | |
gitbay/ContentView.swift added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | // | |
| 2 | // ContentView.swift | |
| 3 | // gitbay | |
| 4 | // | |
| 5 | // Created by cmc on 2026-08-26. | |
| 6 | // | |
| 7 | ||
| 8 | import SwiftUI | |
| 9 | ||
| 10 | struct ContentView: View { | |
| 11 | var body: some View { | |
| 12 | VStack { | |
| 13 | Image(systemName: "globe") | |
| 14 | .imageScale(.large) | |
| 15 | .foregroundStyle(.tint) | |
| 16 | Text("Hello, world!") | |
| 17 | } | |
| 18 | .padding() | |
| 19 | } | |
| 20 | } | |
| 21 | ||
| 22 | #Preview { | |
| 23 | ContentView() | |
| 24 | } | |
gitbay/Networking/ETagStore.swift added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | import Foundation | |
| 2 | import os | |
| 3 | ||
| 4 | /// The validator and body of one cached read. | |
| 5 | nonisolated struct ETagEntry: Sendable, Equatable { | |
| 6 | let etag: String | |
| 7 | let payload: Data | |
| 8 | } | |
| 9 | ||
| 10 | /// In-memory ETag cache for `GET /api/v1/read`. | |
| 11 | /// | |
| 12 | /// The server salts each ETag with the account, so one caller's validator | |
| 13 | /// never matches another's; entries are keyed by account here too, so a | |
| 14 | /// 304 can never be answered out of someone else's body. | |
| 15 | nonisolated final class ETagStore: Sendable { | |
| 16 | ||
| 17 | private struct Slot { | |
| 18 | let entry: ETagEntry | |
| 19 | var usedAt: UInt64 | |
| 20 | } | |
| 21 | ||
| 22 | private let state: OSAllocatedUnfairLock<(slots: [String: Slot], clock: UInt64)> | |
| 23 | private let capacity: Int | |
| 24 | ||
| 25 | init(capacity: Int = 200) { | |
| 26 | self.capacity = capacity | |
| 27 | self.state = OSAllocatedUnfairLock(initialState: (slots: [:], clock: 0)) | |
| 28 | } | |
| 29 | ||
| 30 | static func key(account: String, argv: [String]) -> String { | |
| 31 | // NUL cannot appear in argv, so joining on it cannot collide. | |
| 32 | ([account] + argv).joined(separator: "\u{0}") | |
| 33 | } | |
| 34 | ||
| 35 | func entry(for key: String) -> ETagEntry? { | |
| 36 | state.withLock { state in | |
| 37 | guard var slot = state.slots[key] else { return nil } | |
| 38 | state.clock += 1 | |
| 39 | slot.usedAt = state.clock | |
| 40 | state.slots[key] = slot | |
| 41 | return slot.entry | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | func store(_ entry: ETagEntry, for key: String) { | |
| 46 | state.withLock { state in | |
| 47 | state.clock += 1 | |
| 48 | state.slots[key] = Slot(entry: entry, usedAt: state.clock) | |
| 49 | guard state.slots.count > capacity else { return } | |
| 50 | let oldest = state.slots.min { $0.value.usedAt < $1.value.usedAt } | |
| 51 | if let oldest { state.slots.removeValue(forKey: oldest.key) } | |
| 52 | } | |
| 53 | } | |
| 54 | ||
| 55 | /// Drop everything. Called on sign-out: the entries are account data. | |
| 56 | func clear() { | |
| 57 | state.withLock { $0.slots.removeAll() } | |
| 58 | } | |
| 59 | ||
| 60 | var count: Int { | |
| 61 | state.withLock { $0.slots.count } | |
| 62 | } | |
| 63 | } | |
gitbay/Networking/Envelope.swift added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// Exit codes shared by the CLI, SSH and both HTTP surfaces. | |
| 4 | /// `internal/protocol/protocol.go` is the source of truth. | |
| 5 | nonisolated enum ExitCode: Int, Sendable { | |
| 6 | case ok = 0 | |
| 7 | case failure = 1 | |
| 8 | case usage = 2 | |
| 9 | case notFound = 3 | |
| 10 | case denied = 4 | |
| 11 | case protocolError = 5 | |
| 12 | } | |
| 13 | ||
| 14 | /// Every response from either API surface is the command's own JSON | |
| 15 | /// envelope with `exit_code` added. | |
| 16 | /// | |
| 17 | /// `data` is absent when a command emits an empty result — Go omits a nil | |
| 18 | /// slice — so an empty list arrives as no key at all rather than `[]`. | |
| 19 | /// `output` carries commands that write raw text instead of JSON (`mr | |
| 20 | /// diff`, `help`); the server wraps those. | |
| 21 | nonisolated struct Envelope<Payload: Decodable & Sendable>: Decodable, Sendable { | |
| 22 | let protocolVersion: Int | |
| 23 | let data: Payload? | |
| 24 | let error: String? | |
| 25 | let stderr: String? | |
| 26 | let output: String? | |
| 27 | let exitCode: Int? | |
| 28 | ||
| 29 | enum CodingKeys: String, CodingKey { | |
| 30 | case protocolVersion = "protocol_version" | |
| 31 | case data, error, stderr, output | |
| 32 | case exitCode = "exit_code" | |
| 33 | } | |
| 34 | ||
| 35 | /// The message to put in front of a person. `error` and `stderr` were | |
| 36 | /// both written to be read by one. | |
| 37 | var message: String? { | |
| 38 | let text = error ?? stderr | |
| 39 | guard let text, !text.isEmpty else { return nil } | |
| 40 | return text | |
| 41 | } | |
| 42 | } | |
| 43 | ||
| 44 | /// Placeholder for commands whose `data` we do not decode. It accepts any | |
| 45 | /// JSON, so discarding a payload never fails on its shape. | |
| 46 | nonisolated struct NoPayload: Decodable, Sendable { | |
| 47 | init(from _: any Decoder) throws {} | |
| 48 | } | |
gitbay/Networking/GitbayClient.swift added +312
| @@ -0,0 +1,312 @@ | ||
| 1 | import CryptoKit | |
| 2 | import Foundation | |
| 3 | import os | |
| 4 | ||
| 5 | nonisolated private let logger = Logger(subsystem: "org.gitbay.gitbay", category: "GitbayClient") | |
| 6 | ||
| 7 | /// Speaks the two halves of the gitbay API: `GET /api/v1/read` for the | |
| 8 | /// commands the registry marks read-only, `POST /api/v1/cmd` for the rest. | |
| 9 | /// | |
| 10 | /// Both dispatch the same command registry server-side, so this client adds | |
| 11 | /// no semantics of its own — it carries argv there and an envelope back. | |
| 12 | /// | |
| 13 | /// A client is bound to one account on one instance. Signing out means | |
| 14 | /// dropping the client, not mutating it. | |
| 15 | nonisolated final class GitbayClient: Sendable { | |
| 16 | ||
| 17 | /// How long the client will wait out a 429 by itself. Beyond this the | |
| 18 | /// wait belongs on screen, not inside a request that looks hung. | |
| 19 | static let maximumAutomaticWait: TimeInterval = 3 | |
| 20 | ||
| 21 | let instance: GitbayInstance | |
| 22 | ||
| 23 | private let token: String | |
| 24 | private let session: URLSession | |
| 25 | private let etags: ETagStore | |
| 26 | private let redirectGuard: RedirectGuard | |
| 27 | /// Cache keys are scoped by account without holding the token. | |
| 28 | private let accountKey: String | |
| 29 | ||
| 30 | init( | |
| 31 | instance: GitbayInstance, | |
| 32 | token: String, | |
| 33 | session: URLSession = .shared, | |
| 34 | etags: ETagStore = ETagStore() | |
| 35 | ) { | |
| 36 | self.instance = instance | |
| 37 | self.token = token | |
| 38 | self.session = session | |
| 39 | self.etags = etags | |
| 40 | self.redirectGuard = RedirectGuard(instance: instance) | |
| 41 | let digest = SHA256.hash(data: Data(token.utf8)) | |
| 42 | self.accountKey = digest.prefix(8).map { String(format: "%02x", $0) }.joined() | |
| 43 | } | |
| 44 | ||
| 45 | // MARK: - Reads | |
| 46 | ||
| 47 | /// Run a read-only command, decoding `data` as `Payload`. | |
| 48 | /// | |
| 49 | /// Revalidates with `If-None-Match` when a previous body is cached: a | |
| 50 | /// 304 costs a round trip and no body, which is the point on a phone. | |
| 51 | func read<Payload: Decodable & Sendable>( | |
| 52 | _ argv: [String], | |
| 53 | as _: Payload.Type | |
| 54 | ) async throws -> Payload { | |
| 55 | let envelope: Envelope<Payload> = try await readEnvelope(argv) | |
| 56 | guard let data = envelope.data else { | |
| 57 | throw GitbayError.decoding(MissingData(argv: argv)) | |
| 58 | } | |
| 59 | return data | |
| 60 | } | |
| 61 | ||
| 62 | /// Run a read-only command that returns a list. | |
| 63 | /// | |
| 64 | /// An empty result arrives as no `data` key at all — Go omits a nil | |
| 65 | /// slice — so absent means empty here rather than a decode failure. | |
| 66 | func readList<Element: Decodable & Sendable>( | |
| 67 | _ argv: [String], | |
| 68 | of _: Element.Type | |
| 69 | ) async throws -> [Element] { | |
| 70 | let envelope: Envelope<[Element]> = try await readEnvelope(argv) | |
| 71 | return envelope.data ?? [] | |
| 72 | } | |
| 73 | ||
| 74 | /// Run a read-only command that emits raw text rather than JSON | |
| 75 | /// (`mr diff`, `build log`). The server wraps those as `output`. | |
| 76 | func readText(_ argv: [String]) async throws -> String { | |
| 77 | let envelope: Envelope<NoPayload> = try await readEnvelope(argv) | |
| 78 | return envelope.output ?? "" | |
| 79 | } | |
| 80 | ||
| 81 | private func readEnvelope<Payload: Decodable & Sendable>( | |
| 82 | _ argv: [String], | |
| 83 | revalidate: Bool = true | |
| 84 | ) async throws -> Envelope<Payload> { | |
| 85 | let url = instance.readURL(argv: argv) | |
| 86 | let cacheKey = ETagStore.key(account: accountKey, argv: argv) | |
| 87 | let cached = revalidate ? etags.entry(for: cacheKey) : nil | |
| 88 | ||
| 89 | var request = URLRequest(url: url) | |
| 90 | // URLSession's own cache would answer some of these transparently | |
| 91 | // and the revalidation would never be visible here. The ETag layer | |
| 92 | // in this file is the only one. | |
| 93 | request.cachePolicy = .reloadIgnoringLocalCacheData | |
| 94 | if let cached { | |
| 95 | request.setValue(cached.etag, forHTTPHeaderField: "If-None-Match") | |
| 96 | } | |
| 97 | ||
| 98 | let (body, response) = try await perform(request, argv: argv) | |
| 99 | ||
| 100 | if response.statusCode == 304 { | |
| 101 | guard let cached else { | |
| 102 | // Only reachable if the entry was evicted between the read | |
| 103 | // above and the response. Ask again without a validator, | |
| 104 | // which cannot come back 304. | |
| 105 | return try await readEnvelope(argv, revalidate: false) | |
| 106 | } | |
| 107 | return try decode(cached.payload, status: 200, argv: argv, surface: .read) | |
| 108 | } | |
| 109 | ||
| 110 | if let etag = response.value(forHTTPHeaderField: "ETag"), response.statusCode == 200 { | |
| 111 | etags.store(ETagEntry(etag: etag, payload: body), for: cacheKey) | |
| 112 | } | |
| 113 | return try decode(body, status: response.statusCode, argv: argv, surface: .read) | |
| 114 | } | |
| 115 | ||
| 116 | // MARK: - Writes | |
| 117 | ||
| 118 | /// Run a command that changes state. Long text goes in `stdin`, never | |
| 119 | /// in argv — the same discipline the CLI uses for `--file -`. | |
| 120 | @discardableResult | |
| 121 | func run<Payload: Decodable & Sendable>( | |
| 122 | _ argv: [String], | |
| 123 | stdin: String? = nil, | |
| 124 | as _: Payload.Type | |
| 125 | ) async throws -> Payload? { | |
| 126 | var request = URLRequest(url: instance.cmdURL) | |
| 127 | request.httpMethod = "POST" | |
| 128 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") | |
| 129 | var payload: [String: Any] = ["argv": argv] | |
| 130 | if let stdin { payload["stdin"] = stdin } | |
| 131 | request.httpBody = try JSONSerialization.data(withJSONObject: payload) | |
| 132 | ||
| 133 | let (body, response) = try await perform(request, argv: argv) | |
| 134 | let envelope: Envelope<Payload> = try decode( | |
| 135 | body, status: response.statusCode, argv: argv, surface: .command | |
| 136 | ) | |
| 137 | return envelope.data | |
| 138 | } | |
| 139 | ||
| 140 | /// Run a command whose result carries nothing the caller needs. | |
| 141 | func run(_ argv: [String], stdin: String? = nil) async throws { | |
| 142 | _ = try await run(argv, stdin: stdin, as: NoPayload.self) | |
| 143 | } | |
| 144 | ||
| 145 | // MARK: - Transport | |
| 146 | ||
| 147 | private func perform( | |
| 148 | _ request: URLRequest, | |
| 149 | argv: [String] | |
| 150 | ) async throws -> (Data, HTTPURLResponse) { | |
| 151 | guard let url = request.url, instance.isOwn(url) else { | |
| 152 | throw GitbayError.unexpectedHost(request.url ?? instance.baseURL) | |
| 153 | } | |
| 154 | var request = request | |
| 155 | request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") | |
| 156 | ||
| 157 | // One retry, no more. A phone on a flaky network is exactly the | |
| 158 | // caller that turns a retry loop into a rate-limit spiral. | |
| 159 | for attempt in 0...1 { | |
| 160 | let body: Data | |
| 161 | let response: HTTPURLResponse | |
| 162 | do { | |
| 163 | let (data, urlResponse) = try await session.data(for: request, delegate: redirectGuard) | |
| 164 | guard let http = urlResponse as? HTTPURLResponse else { | |
| 165 | throw GitbayError.transport(URLError(.badServerResponse)) | |
| 166 | } | |
| 167 | (body, response) = (data, http) | |
| 168 | } catch let error as GitbayError { | |
| 169 | throw error | |
| 170 | } catch { | |
| 171 | throw GitbayError.transport(error) | |
| 172 | } | |
| 173 | ||
| 174 | let isLastAttempt = attempt == 1 | |
| 175 | ||
| 176 | if response.statusCode == 429 { | |
| 177 | let wait = retryAfter(response) ?? Self.maximumAutomaticWait | |
| 178 | guard !isLastAttempt, wait <= Self.maximumAutomaticWait else { | |
| 179 | throw GitbayError.rateLimited(retryAfter: wait) | |
| 180 | } | |
| 181 | try await Task.sleep(for: .seconds(wait)) | |
| 182 | continue | |
| 183 | } | |
| 184 | ||
| 185 | if (500...599).contains(response.statusCode), !isLastAttempt { | |
| 186 | try await Task.sleep(for: .milliseconds(500)) | |
| 187 | continue | |
| 188 | } | |
| 189 | return (body, response) | |
| 190 | } | |
| 191 | // The loop returns or throws on every path; this satisfies the | |
| 192 | // compiler rather than describing a reachable state. | |
| 193 | throw GitbayError.failure("") | |
| 194 | } | |
| 195 | ||
| 196 | /// gitbay sends Retry-After as whole seconds. | |
| 197 | private func retryAfter(_ response: HTTPURLResponse) -> TimeInterval? { | |
| 198 | guard let value = response.value(forHTTPHeaderField: "Retry-After"), | |
| 199 | let seconds = TimeInterval(value.trimmingCharacters(in: .whitespaces)), | |
| 200 | seconds >= 0 else { | |
| 201 | return nil | |
| 202 | } | |
| 203 | return seconds | |
| 204 | } | |
| 205 | ||
| 206 | // MARK: - Envelope | |
| 207 | ||
| 208 | private enum Surface { case read, command } | |
| 209 | ||
| 210 | private func decode<Payload: Decodable & Sendable>( | |
| 211 | _ body: Data, | |
| 212 | status: Int, | |
| 213 | argv: [String], | |
| 214 | surface: Surface | |
| 215 | ) throws -> Envelope<Payload> { | |
| 216 | // Probe the envelope before touching the payload, so a failure or | |
| 217 | // a foreign protocol version is recognised whatever shape `data` | |
| 218 | // has — an error envelope never matches the expected payload. | |
| 219 | let probe: Envelope<NoPayload> | |
| 220 | do { | |
| 221 | probe = try JSONDecoder().decode(Envelope<NoPayload>.self, from: body) | |
| 222 | } catch { | |
| 223 | throw GitbayError.decoding(error) | |
| 224 | } | |
| 225 | guard probe.protocolVersion == 1 else { | |
| 226 | throw GitbayError.protocolMismatch(probe.protocolVersion) | |
| 227 | } | |
| 228 | if let error = failure(probe, status: status, argv: argv, surface: surface) { | |
| 229 | throw error | |
| 230 | } | |
| 231 | do { | |
| 232 | return try JSONDecoder().decode(Envelope<Payload>.self, from: body) | |
| 233 | } catch { | |
| 234 | throw GitbayError.decoding(error) | |
| 235 | } | |
| 236 | } | |
| 237 | ||
| 238 | private func failure( | |
| 239 | _ envelope: Envelope<NoPayload>, | |
| 240 | status: Int, | |
| 241 | argv: [String], | |
| 242 | surface: Surface | |
| 243 | ) -> GitbayError? { | |
| 244 | let message = envelope.message ?? "" | |
| 245 | ||
| 246 | // The gate rejections — bad token, unknown command, a write sent to | |
| 247 | // the read surface — never reach a command, so they carry no exit | |
| 248 | // code. Those are read off the status. | |
| 249 | guard let code = envelope.exitCode.flatMap(ExitCode.init(rawValue:)) else { | |
| 250 | switch status { | |
| 251 | case 200: return nil | |
| 252 | case 401: return .unauthorized(message) | |
| 253 | case 403: return .denied(message) | |
| 254 | case 404: return .notFound(message) | |
| 255 | case 429: return .rateLimited(retryAfter: 0) | |
| 256 | case 400 where surface == .read: | |
| 257 | logger.error("read refused: \(message, privacy: .public)") | |
| 258 | return .notReadable(message) | |
| 259 | case 400: | |
| 260 | logger.error("usage error: \(argv, privacy: .private) — \(message, privacy: .public)") | |
| 261 | return .usage(message) | |
| 262 | default: return .failure(message) | |
| 263 | } | |
| 264 | } | |
| 265 | ||
| 266 | switch code { | |
| 267 | case .ok: | |
| 268 | return nil | |
| 269 | case .usage: | |
| 270 | // The app built argv wrong. Log it; never put argv on screen. | |
| 271 | logger.error("usage error: \(argv, privacy: .private) — \(message, privacy: .public)") | |
| 272 | return .usage(message) | |
| 273 | case .notFound: | |
| 274 | return .notFound(message) | |
| 275 | case .denied: | |
| 276 | return .denied(message) | |
| 277 | case .failure, .protocolError: | |
| 278 | return .failure(message) | |
| 279 | } | |
| 280 | } | |
| 281 | ||
| 282 | struct MissingData: LocalizedError, Sendable { | |
| 283 | let argv: [String] | |
| 284 | var errorDescription: String? { "The response carried no data." } | |
| 285 | } | |
| 286 | } | |
| 287 | ||
| 288 | /// Refuses to follow a redirect off the instance. Without this a redirect | |
| 289 | /// would carry the bearer token to whatever host answered. | |
| 290 | private nonisolated final class RedirectGuard: NSObject, URLSessionTaskDelegate, @unchecked Sendable { | |
| 291 | ||
| 292 | private let instance: GitbayInstance | |
| 293 | ||
| 294 | init(instance: GitbayInstance) { | |
| 295 | self.instance = instance | |
| 296 | } | |
| 297 | ||
| 298 | func urlSession( | |
| 299 | _ session: URLSession, | |
| 300 | task: URLSessionTask, | |
| 301 | willPerformHTTPRedirection response: HTTPURLResponse, | |
| 302 | newRequest request: URLRequest, | |
| 303 | completionHandler: @escaping (URLRequest?) -> Void | |
| 304 | ) { | |
| 305 | guard let url = request.url, instance.isOwn(url) else { | |
| 306 | logger.error("refused redirect off \(self.instance.baseURL.absoluteString, privacy: .public)") | |
| 307 | completionHandler(nil) | |
| 308 | return | |
| 309 | } | |
| 310 | completionHandler(request) | |
| 311 | } | |
| 312 | } | |
gitbay/Networking/GitbayError.swift added +65
| @@ -0,0 +1,65 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// A failure from either API surface, already mapped off the exit code so | |
| 4 | /// callers switch on meaning rather than on numbers. | |
| 5 | /// | |
| 6 | /// exit http case | |
| 7 | /// 2 400 usage a bug in the app | |
| 8 | /// 3 404 notFound an empty state, not an error banner | |
| 9 | /// 4 403 denied the message explains the rule; show it verbatim | |
| 10 | /// 1, 5 500 failure retried once by the client, then surfaced | |
| 11 | /// — 401 unauthorized the token is invalid, expired or revoked | |
| 12 | /// — 429 rateLimited Retry-After was longer than the client will wait | |
| 13 | nonisolated enum GitbayError: LocalizedError, Sendable { | |
| 14 | case usage(String) | |
| 15 | case notFound(String) | |
| 16 | case denied(String) | |
| 17 | case failure(String) | |
| 18 | case unauthorized(String) | |
| 19 | case rateLimited(retryAfter: TimeInterval) | |
| 20 | /// The command exists but is not read-only, so it cannot be reached | |
| 21 | /// with GET. Also an app bug: it means we routed a write through `read`. | |
| 22 | case notReadable(String) | |
| 23 | case transport(any Error) | |
| 24 | case decoding(any Error) | |
| 25 | /// A major protocol version the app does not understand. | |
| 26 | case protocolMismatch(Int) | |
| 27 | /// The client refused to send the token somewhere other than the instance. | |
| 28 | case unexpectedHost(URL) | |
| 29 | ||
| 30 | /// Text for a person. `denied` and `notFound` come from the server | |
| 31 | /// verbatim — those messages exist to explain a rule, and rewording | |
| 32 | /// them loses the explanation. | |
| 33 | var userFacingMessage: String { | |
| 34 | switch self { | |
| 35 | case .denied(let message), .notFound(let message): | |
| 36 | message | |
| 37 | case .unauthorized: | |
| 38 | "Your token is no longer valid. Sign in again." | |
| 39 | case .rateLimited(let seconds): | |
| 40 | "Too many requests. Try again in \(Int(seconds.rounded()))s." | |
| 41 | case .failure(let message): | |
| 42 | message.isEmpty ? "The server could not complete that." : message | |
| 43 | case .transport: | |
| 44 | "Check your connection and try again." | |
| 45 | // A usage or protocol failure is ours, not the user's. argv never | |
| 46 | // reaches the screen. | |
| 47 | case .usage, .notReadable, .decoding, .protocolMismatch, .unexpectedHost: | |
| 48 | "Something went wrong. Please try again." | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | var errorDescription: String? { userFacingMessage } | |
| 53 | ||
| 54 | /// True when the right response is an empty screen rather than a banner. | |
| 55 | var isEmptyState: Bool { | |
| 56 | if case .notFound = self { return true } | |
| 57 | return false | |
| 58 | } | |
| 59 | ||
| 60 | /// True when the token needs replacing and the app should sign out. | |
| 61 | var requiresReauthentication: Bool { | |
| 62 | if case .unauthorized = self { return true } | |
| 63 | return false | |
| 64 | } | |
| 65 | } | |
gitbay/Networking/GitbayInstance.swift added +71
| @@ -0,0 +1,71 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// A gitbay deployment. gitbay is self-hosted, so an account is only | |
| 4 | /// meaningful paired with the instance it lives on. | |
| 5 | nonisolated struct GitbayInstance: Sendable, Hashable, Codable { | |
| 6 | ||
| 7 | /// Origin of the instance, e.g. `https://gitbay.org`. Path, query and | |
| 8 | /// fragment are stripped on init so endpoint construction cannot be | |
| 9 | /// steered by whatever the user pasted. | |
| 10 | let baseURL: URL | |
| 11 | ||
| 12 | enum InvalidURL: LocalizedError, Sendable { | |
| 13 | case notAURL(String) | |
| 14 | case insecureScheme(String) | |
| 15 | ||
| 16 | var errorDescription: String? { | |
| 17 | switch self { | |
| 18 | case .notAURL(let text): | |
| 19 | "\(text) is not a server address." | |
| 20 | case .insecureScheme(let scheme): | |
| 21 | "\(scheme):// would send your token in the clear. Use https." | |
| 22 | } | |
| 23 | } | |
| 24 | } | |
| 25 | ||
| 26 | init(url text: String) throws { | |
| 27 | let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 28 | let withScheme = trimmed.contains("://") ? trimmed : "https://\(trimmed)" | |
| 29 | guard var components = URLComponents(string: withScheme), | |
| 30 | let host = components.host, !host.isEmpty else { | |
| 31 | throw InvalidURL.notAURL(trimmed) | |
| 32 | } | |
| 33 | let scheme = (components.scheme ?? "https").lowercased() | |
| 34 | // A bearer token over plaintext is a token given away. localhost is | |
| 35 | // exempt so a self-hoster can point the app at their own machine. | |
| 36 | guard scheme == "https" || (scheme == "http" && Self.isLoopback(host)) else { | |
| 37 | throw InvalidURL.insecureScheme(scheme) | |
| 38 | } | |
| 39 | components.scheme = scheme | |
| 40 | components.path = "" | |
| 41 | components.query = nil | |
| 42 | components.fragment = nil | |
| 43 | guard let url = components.url else { throw InvalidURL.notAURL(trimmed) } | |
| 44 | self.baseURL = url | |
| 45 | } | |
| 46 | ||
| 47 | private static func isLoopback(_ host: String) -> Bool { | |
| 48 | host == "localhost" || host == "127.0.0.1" || host == "::1" | |
| 49 | } | |
| 50 | ||
| 51 | /// `GET /api/v1/read` — argv arrives as repeated query parameters. | |
| 52 | func readURL(argv: [String]) -> URL { | |
| 53 | var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! | |
| 54 | components.path = "/api/v1/read" | |
| 55 | components.queryItems = argv.map { URLQueryItem(name: "argv", value: $0) } | |
| 56 | return components.url! | |
| 57 | } | |
| 58 | ||
| 59 | /// `POST /api/v1/cmd` | |
| 60 | var cmdURL: URL { | |
| 61 | baseURL.appending(path: "/api/v1/cmd") | |
| 62 | } | |
| 63 | ||
| 64 | /// Whether a URL is on this instance. Checked before the Authorization | |
| 65 | /// header goes on a request, and again on every redirect. | |
| 66 | func isOwn(_ url: URL) -> Bool { | |
| 67 | url.scheme?.lowercased() == baseURL.scheme | |
| 68 | && url.host()?.lowercased() == baseURL.host()?.lowercased() | |
| 69 | && url.port == baseURL.port | |
| 70 | } | |
| 71 | } | |
gitbay/gitbayApp.swift added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | // | |
| 2 | // gitbayApp.swift | |
| 3 | // gitbay | |
| 4 | // | |
| 5 | // Created by cmc on 2026-08-26. | |
| 6 | // | |
| 7 | ||
| 8 | import SwiftUI | |
| 9 | ||
| 10 | @main | |
| 11 | struct gitbayApp: App { | |
| 12 | var body: some Scene { | |
| 13 | WindowGroup { | |
| 14 | ContentView() | |
| 15 | } | |
| 16 | } | |
| 17 | } | |
gitbayTests/GitbayClientTests.swift added +380
| @@ -0,0 +1,380 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | /// Recorded envelopes, verbatim from gitbay.org v1.0.0. | |
| 6 | private enum Recorded { | |
| 7 | static let whoami = """ | |
| 8 | {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0} | |
| 9 | """ | |
| 10 | static let repoList = """ | |
| 11 | {"protocol_version":1,"data":[\ | |
| 12 | {"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"},\ | |
| 13 | {"path":"krz/secrets","visibility":"private","archived":true}\ | |
| 14 | ],"exit_code":0} | |
| 15 | """ | |
| 16 | static let emptyList = """ | |
| 17 | {"protocol_version":1,"exit_code":0} | |
| 18 | """ | |
| 19 | static let notFound = """ | |
| 20 | {"protocol_version":1,"error":"no such issue 99 in krz/gitbay","exit_code":3} | |
| 21 | """ | |
| 22 | static let denied = """ | |
| 23 | {"protocol_version":1,"error":"krz/gitbay is archived and read-only","exit_code":4} | |
| 24 | """ | |
| 25 | static let usage = """ | |
| 26 | {"protocol_version":1,"error":"usage: repo tree <owner/name> [<path>] [--ref <ref>]","exit_code":2} | |
| 27 | """ | |
| 28 | static let writeOnReadSurface = """ | |
| 29 | {"protocol_version":1,"error":"repo create changes state; POST it to /api/v1/cmd"} | |
| 30 | """ | |
| 31 | static let badToken = """ | |
| 32 | {"protocol_version":1,"error":"invalid or expired token"} | |
| 33 | """ | |
| 34 | static let diff = """ | |
| 35 | {"protocol_version":1,"output":"diff --git a/main.go b/main.go\\n--- a/main.go\\n+++ b/main.go\\n","exit_code":0} | |
| 36 | """ | |
| 37 | } | |
| 38 | ||
| 39 | nonisolated private struct WhoamiPayload: Decodable, Sendable, Equatable { | |
| 40 | let username: String | |
| 41 | let admin: Bool | |
| 42 | } | |
| 43 | ||
| 44 | nonisolated private struct RepoPayload: Decodable, Sendable, Equatable { | |
| 45 | let path: String | |
| 46 | let visibility: String | |
| 47 | let description: String? | |
| 48 | let archived: Bool? | |
| 49 | } | |
| 50 | ||
| 51 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |
| 52 | let box = StubProtocol.box() | |
| 53 | let client = GitbayClient( | |
| 54 | instance: try GitbayInstance(url: "https://gitbay.org"), | |
| 55 | token: "test-token", | |
| 56 | session: box.session() | |
| 57 | ) | |
| 58 | return (client, box) | |
| 59 | } | |
| 60 | ||
| 61 | struct GitbayClientReadTests { | |
| 62 | ||
| 63 | @Test func readDecodesEnvelopeData() async throws { | |
| 64 | let (client, stub) = try makeClient() | |
| 65 | stub.enqueue(.init(status: 200, json: Recorded.whoami)) | |
| 66 | ||
| 67 | let who = try await client.read(["whoami"], as: WhoamiPayload.self) | |
| 68 | ||
| 69 | #expect(who == WhoamiPayload(username: "cmc", admin: false)) | |
| 70 | let seen = try #require(stub.seen.first) | |
| 71 | #expect(seen.method == "GET") | |
| 72 | #expect(seen.url.path() == "/api/v1/read") | |
| 73 | #expect(seen.url.query() == "argv=whoami") | |
| 74 | #expect(seen.headers["Authorization"] == "Bearer test-token") | |
| 75 | #expect(seen.headers["If-None-Match"] == nil) | |
| 76 | } | |
| 77 | ||
| 78 | @Test func argvArrivesAsRepeatedQueryParameters() async throws { | |
| 79 | let (client, stub) = try makeClient() | |
| 80 | stub.enqueue(.init(status: 200, json: Recorded.emptyList)) | |
| 81 | ||
| 82 | _ = try await client.readList( | |
| 83 | ["repo", "tree", "krz/gitbay", "internal"], of: RepoPayload.self | |
| 84 | ) | |
| 85 | ||
| 86 | let seen = try #require(stub.seen.first) | |
| 87 | #expect(seen.url.query() == "argv=repo&argv=tree&argv=krz/gitbay&argv=internal") | |
| 88 | } | |
| 89 | ||
| 90 | @Test func secondReadRevalidatesAndA304ServesTheCachedBody() async throws { | |
| 91 | let (client, stub) = try makeClient() | |
| 92 | let etag = "\"12e3e2b8914d9abfceec233dac9a7454\"" | |
| 93 | stub.enqueue(.init(status: 200, headers: ["ETag": etag], json: Recorded.repoList)) | |
| 94 | stub.enqueue(.init(status: 304, headers: ["ETag": etag])) | |
| 95 | ||
| 96 | let first = try await client.readList(["repo", "list"], of: RepoPayload.self) | |
| 97 | let second = try await client.readList(["repo", "list"], of: RepoPayload.self) | |
| 98 | ||
| 99 | #expect(first == second) | |
| 100 | #expect(first.count == 2) | |
| 101 | #expect(first[0].path == "krz/gitbay") | |
| 102 | let requests = stub.seen | |
| 103 | #expect(requests.count == 2) | |
| 104 | #expect(requests[0].headers["If-None-Match"] == nil) | |
| 105 | #expect(requests[1].headers["If-None-Match"] == etag) | |
| 106 | } | |
| 107 | ||
| 108 | @Test func changedBodyReplacesTheCachedValidator() async throws { | |
| 109 | let (client, stub) = try makeClient() | |
| 110 | stub.enqueue(.init(status: 200, headers: ["ETag": "\"aa\""], json: Recorded.repoList)) | |
| 111 | stub.enqueue(.init(status: 200, headers: ["ETag": "\"bb\""], json: Recorded.emptyList)) | |
| 112 | stub.enqueue(.init(status: 304)) | |
| 113 | ||
| 114 | _ = try await client.readList(["repo", "list"], of: RepoPayload.self) | |
| 115 | let refreshed = try await client.readList(["repo", "list"], of: RepoPayload.self) | |
| 116 | let revalidated = try await client.readList(["repo", "list"], of: RepoPayload.self) | |
| 117 | ||
| 118 | #expect(refreshed.isEmpty) | |
| 119 | #expect(revalidated.isEmpty) | |
| 120 | #expect(stub.seen[2].headers["If-None-Match"] == "\"bb\"") | |
| 121 | } | |
| 122 | ||
| 123 | @Test func absentDataDecodesAsEmptyList() async throws { | |
| 124 | let (client, stub) = try makeClient() | |
| 125 | stub.enqueue(.init(status: 200, json: Recorded.emptyList)) | |
| 126 | ||
| 127 | let repos = try await client.readList(["repo", "list"], of: RepoPayload.self) | |
| 128 | ||
| 129 | #expect(repos.isEmpty) | |
| 130 | } | |
| 131 | ||
| 132 | @Test func rawTextCommandsComeBackAsOutput() async throws { | |
| 133 | let (client, stub) = try makeClient() | |
| 134 | stub.enqueue(.init(status: 200, json: Recorded.diff)) | |
| 135 | ||
| 136 | let diff = try await client.readText(["mr", "diff", "krz/gitbay", "7"]) | |
| 137 | ||
| 138 | #expect(diff.hasPrefix("diff --git a/main.go")) | |
| 139 | } | |
| 140 | } | |
| 141 | ||
| 142 | struct GitbayClientWriteTests { | |
| 143 | ||
| 144 | @Test func writePostsArgvAndStdinAsJSON() async throws { | |
| 145 | let (client, stub) = try makeClient() | |
| 146 | stub.enqueue(.init(status: 200, json: Recorded.emptyList)) | |
| 147 | ||
| 148 | try await client.run( | |
| 149 | ["issue", "comment", "krz/gitbay", "35"], | |
| 150 | stdin: "looks right to me" | |
| 151 | ) | |
| 152 | ||
| 153 | let seen = try #require(stub.seen.first) | |
| 154 | #expect(seen.method == "POST") | |
| 155 | #expect(seen.url.path() == "/api/v1/cmd") | |
| 156 | let body = try #require( | |
| 157 | try JSONSerialization.jsonObject(with: seen.body) as? [String: Any] | |
| 158 | ) | |
| 159 | #expect(body["argv"] as? [String] == ["issue", "comment", "krz/gitbay", "35"]) | |
| 160 | #expect(body["stdin"] as? String == "looks right to me") | |
| 161 | } | |
| 162 | ||
| 163 | @Test func stdinIsOmittedWhenAbsent() async throws { | |
| 164 | let (client, stub) = try makeClient() | |
| 165 | stub.enqueue(.init(status: 200, json: Recorded.emptyList)) | |
| 166 | ||
| 167 | try await client.run(["mr", "merge", "krz/gitbay", "7"]) | |
| 168 | ||
| 169 | let seen = try #require(stub.seen.first) | |
| 170 | let body = try #require( | |
| 171 | try JSONSerialization.jsonObject(with: seen.body) as? [String: Any] | |
| 172 | ) | |
| 173 | #expect(body["stdin"] == nil) | |
| 174 | } | |
| 175 | } | |
| 176 | ||
| 177 | struct GitbayClientFailureTests { | |
| 178 | ||
| 179 | @Test func exitThreeIsAnEmptyStateNotAnError() async throws { | |
| 180 | let (client, stub) = try makeClient() | |
| 181 | stub.enqueue(.init(status: 404, json: Recorded.notFound)) | |
| 182 | ||
| 183 | await #expect { | |
| 184 | _ = try await client.read(["issue", "show", "krz/gitbay", "99"], as: WhoamiPayload.self) | |
| 185 | } throws: { error in | |
| 186 | guard let error = error as? GitbayError else { return false } | |
| 187 | return error.isEmptyState | |
| 188 | && error.userFacingMessage == "no such issue 99 in krz/gitbay" | |
| 189 | } | |
| 190 | } | |
| 191 | ||
| 192 | @Test func exitFourSurfacesTheServersMessageVerbatim() async throws { | |
| 193 | let (client, stub) = try makeClient() | |
| 194 | stub.enqueue(.init(status: 403, json: Recorded.denied)) | |
| 195 | ||
| 196 | await #expect { | |
| 197 | try await client.run(["issue", "comment", "krz/gitbay", "1"], stdin: "hi") | |
| 198 | } throws: { error in | |
| 199 | guard case .denied(let message)? = error as? GitbayError else { return false } | |
| 200 | return message == "krz/gitbay is archived and read-only" | |
| 201 | } | |
| 202 | } | |
| 203 | ||
| 204 | @Test func exitTwoNeverPutsArgvInTheUserFacingMessage() async throws { | |
| 205 | let (client, stub) = try makeClient() | |
| 206 | stub.enqueue(.init(status: 400, json: Recorded.usage)) | |
| 207 | ||
| 208 | await #expect { | |
| 209 | _ = try await client.read(["repo", "tree"], as: WhoamiPayload.self) | |
| 210 | } throws: { error in | |
| 211 | guard case .usage? = error as? GitbayError else { return false } | |
| 212 | let shown = (error as! GitbayError).userFacingMessage | |
| 213 | return !shown.contains("repo tree") && !shown.contains("usage") | |
| 214 | } | |
| 215 | } | |
| 216 | ||
| 217 | @Test func gateRejectionsWithoutExitCodesMapOffTheStatus() async throws { | |
| 218 | let (client, stub) = try makeClient() | |
| 219 | stub.enqueue(.init(status: 401, json: Recorded.badToken)) | |
| 220 | ||
| 221 | await #expect { | |
| 222 | _ = try await client.read(["whoami"], as: WhoamiPayload.self) | |
| 223 | } throws: { error in | |
| 224 | (error as? GitbayError)?.requiresReauthentication == true | |
| 225 | } | |
| 226 | } | |
| 227 | ||
| 228 | @Test func writeSentToTheReadSurfaceIsAnAppBug() async throws { | |
| 229 | let (client, stub) = try makeClient() | |
| 230 | stub.enqueue(.init(status: 400, json: Recorded.writeOnReadSurface)) | |
| 231 | ||
| 232 | await #expect { | |
| 233 | _ = try await client.read(["repo", "create", "krz/new"], as: WhoamiPayload.self) | |
| 234 | } throws: { error in | |
| 235 | guard case .notReadable? = error as? GitbayError else { return false } | |
| 236 | return true | |
| 237 | } | |
| 238 | } | |
| 239 | ||
| 240 | @Test func serverFailureRetriesOnceThenSurfaces() async throws { | |
| 241 | let (client, stub) = try makeClient() | |
| 242 | stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#)) | |
| 243 | stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#)) | |
| 244 | ||
| 245 | await #expect { | |
| 246 | _ = try await client.read(["repo", "list"], as: WhoamiPayload.self) | |
| 247 | } throws: { error in | |
| 248 | guard case .failure(let message)? = error as? GitbayError else { return false } | |
| 249 | return message == "boom" | |
| 250 | } | |
| 251 | #expect(stub.seen.count == 2) | |
| 252 | } | |
| 253 | ||
| 254 | @Test func serverFailureRecoversWhenTheRetrySucceeds() async throws { | |
| 255 | let (client, stub) = try makeClient() | |
| 256 | stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#)) | |
| 257 | stub.enqueue(.init(status: 200, json: Recorded.whoami)) | |
| 258 | ||
| 259 | let who = try await client.read(["whoami"], as: WhoamiPayload.self) | |
| 260 | ||
| 261 | #expect(who.username == "cmc") | |
| 262 | #expect(stub.seen.count == 2) | |
| 263 | } | |
| 264 | ||
| 265 | @Test func shortRetryAfterIsHonouredThenTheCallSucceeds() async throws { | |
| 266 | let (client, stub) = try makeClient() | |
| 267 | stub.enqueue(.init( | |
| 268 | status: 429, | |
| 269 | headers: ["Retry-After": "1"], | |
| 270 | json: #"{"protocol_version":1,"error":"rate limited; retry in 1s"}"# | |
| 271 | )) | |
| 272 | stub.enqueue(.init(status: 200, json: Recorded.whoami)) | |
| 273 | ||
| 274 | let start = ContinuousClock.now | |
| 275 | let who = try await client.read(["whoami"], as: WhoamiPayload.self) | |
| 276 | ||
| 277 | #expect(who.username == "cmc") | |
| 278 | #expect(ContinuousClock.now - start >= .seconds(1)) | |
| 279 | #expect(stub.seen.count == 2) | |
| 280 | } | |
| 281 | ||
| 282 | @Test func longRetryAfterSurfacesInsteadOfHanging() async throws { | |
| 283 | let (client, stub) = try makeClient() | |
| 284 | stub.enqueue(.init( | |
| 285 | status: 429, | |
| 286 | headers: ["Retry-After": "30"], | |
| 287 | json: #"{"protocol_version":1,"error":"rate limited; retry in 30s"}"# | |
| 288 | )) | |
| 289 | ||
| 290 | await #expect { | |
| 291 | _ = try await client.read(["whoami"], as: WhoamiPayload.self) | |
| 292 | } throws: { error in | |
| 293 | guard case .rateLimited(let wait)? = error as? GitbayError else { return false } | |
| 294 | return wait == 30 | |
| 295 | } | |
| 296 | #expect(stub.seen.count == 1) | |
| 297 | } | |
| 298 | ||
| 299 | @Test func unknownProtocolVersionIsRefused() async throws { | |
| 300 | let (client, stub) = try makeClient() | |
| 301 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":2,"data":{},"exit_code":0}"#)) | |
| 302 | ||
| 303 | await #expect { | |
| 304 | _ = try await client.read(["whoami"], as: WhoamiPayload.self) | |
| 305 | } throws: { error in | |
| 306 | guard case .protocolMismatch(2)? = error as? GitbayError else { return false } | |
| 307 | return true | |
| 308 | } | |
| 309 | } | |
| 310 | } | |
| 311 | ||
| 312 | struct GitbayInstanceTests { | |
| 313 | ||
| 314 | @Test func bareHostGetsHTTPS() throws { | |
| 315 | let instance = try GitbayInstance(url: "gitbay.org") | |
| 316 | #expect(instance.baseURL.absoluteString == "https://gitbay.org") | |
| 317 | } | |
| 318 | ||
| 319 | @Test func pastedPathAndQueryAreStripped() throws { | |
| 320 | let instance = try GitbayInstance(url: "https://gitbay.org/krz/gitbay?tab=readme") | |
| 321 | #expect(instance.baseURL.absoluteString == "https://gitbay.org") | |
| 322 | } | |
| 323 | ||
| 324 | @Test func plainHTTPIsRefusedExceptLoopback() throws { | |
| 325 | #expect(throws: GitbayInstance.InvalidURL.self) { | |
| 326 | _ = try GitbayInstance(url: "http://gitbay.org") | |
| 327 | } | |
| 328 | let local = try GitbayInstance(url: "http://localhost:3000") | |
| 329 | #expect(local.baseURL.absoluteString == "http://localhost:3000") | |
| 330 | } | |
| 331 | ||
| 332 | @Test func readURLEncodesArgvInOrder() throws { | |
| 333 | let instance = try GitbayInstance(url: "https://gitbay.org") | |
| 334 | let url = instance.readURL(argv: ["repo", "cat", "krz/gitbay", "cmd/main.go"]) | |
| 335 | #expect(url.query() == "argv=repo&argv=cat&argv=krz/gitbay&argv=cmd/main.go") | |
| 336 | } | |
| 337 | ||
| 338 | @Test func ownHostCheckCoversSchemeHostAndPort() throws { | |
| 339 | let instance = try GitbayInstance(url: "https://gitbay.org") | |
| 340 | #expect(instance.isOwn(URL(string: "https://gitbay.org/api/v1/read")!)) | |
| 341 | #expect(instance.isOwn(URL(string: "https://GITBAY.ORG/api/v1/cmd")!)) | |
| 342 | #expect(!instance.isOwn(URL(string: "https://evil.example/api/v1/read")!)) | |
| 343 | #expect(!instance.isOwn(URL(string: "http://gitbay.org/api/v1/read")!)) | |
| 344 | #expect(!instance.isOwn(URL(string: "https://gitbay.org.evil.example/")!)) | |
| 345 | } | |
| 346 | } | |
| 347 | ||
| 348 | struct ETagStoreTests { | |
| 349 | ||
| 350 | @Test func keysAreScopedByAccount() { | |
| 351 | let a = ETagStore.key(account: "aaaa", argv: ["repo", "list"]) | |
| 352 | let b = ETagStore.key(account: "bbbb", argv: ["repo", "list"]) | |
| 353 | #expect(a != b) | |
| 354 | } | |
| 355 | ||
| 356 | @Test func joiningCannotCollideAcrossArgvBoundaries() { | |
| 357 | let a = ETagStore.key(account: "x", argv: ["repo list"]) | |
| 358 | let b = ETagStore.key(account: "x", argv: ["repo", "list"]) | |
| 359 | #expect(a != b) | |
| 360 | } | |
| 361 | ||
| 362 | @Test func evictionDropsTheLeastRecentlyUsedEntry() { | |
| 363 | let store = ETagStore(capacity: 2) | |
| 364 | store.store(ETagEntry(etag: "a", payload: Data()), for: "a") | |
| 365 | store.store(ETagEntry(etag: "b", payload: Data()), for: "b") | |
| 366 | _ = store.entry(for: "a") | |
| 367 | store.store(ETagEntry(etag: "c", payload: Data()), for: "c") | |
| 368 | ||
| 369 | #expect(store.entry(for: "a") != nil) | |
| 370 | #expect(store.entry(for: "b") == nil) | |
| 371 | #expect(store.entry(for: "c") != nil) | |
| 372 | } | |
| 373 | ||
| 374 | @Test func clearEmptiesTheStore() { | |
| 375 | let store = ETagStore() | |
| 376 | store.store(ETagEntry(etag: "a", payload: Data()), for: "a") | |
| 377 | store.clear() | |
| 378 | #expect(store.count == 0) | |
| 379 | } | |
| 380 | } | |
gitbayTests/LiveInstanceTests.swift added +87
| @@ -0,0 +1,87 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | /// Exercises a real instance. Skipped unless a token is provided: | |
| 6 | /// | |
| 7 | /// GITBAY_TEST_TOKEN=$(gitbay auth token create --name ci --scope read --ttl 1h) | |
| 8 | /// | |
| 9 | /// Optionally GITBAY_TEST_INSTANCE (defaults to gitbay.org). A read-scoped | |
| 10 | /// token is enough — nothing here writes. | |
| 11 | struct LiveInstanceTests { | |
| 12 | ||
| 13 | nonisolated private static var token: String? { | |
| 14 | ProcessInfo.processInfo.environment["GITBAY_TEST_TOKEN"] | |
| 15 | } | |
| 16 | ||
| 17 | nonisolated private struct Whoami: Decodable, Sendable { | |
| 18 | let username: String | |
| 19 | } | |
| 20 | ||
| 21 | @Test(.enabled(if: token != nil, "GITBAY_TEST_TOKEN is not set")) | |
| 22 | func secondReadOfARealInstanceRevalidatesFor304() async throws { | |
| 23 | let host = ProcessInfo.processInfo.environment["GITBAY_TEST_INSTANCE"] ?? "gitbay.org" | |
| 24 | let instance = try GitbayInstance(url: host) | |
| 25 | ||
| 26 | // A session that counts responses without touching their content, | |
| 27 | // so the 304 is observed rather than inferred. | |
| 28 | let client = GitbayClient( | |
| 29 | instance: instance, | |
| 30 | token: try #require(Self.token), | |
| 31 | session: RecordingProtocol.session() | |
| 32 | ) | |
| 33 | RecordingProtocol.reset() | |
| 34 | ||
| 35 | let first = try await client.read(["whoami"], as: Whoami.self) | |
| 36 | let second = try await client.read(["whoami"], as: Whoami.self) | |
| 37 | ||
| 38 | #expect(first.username == second.username) | |
| 39 | #expect(!first.username.isEmpty) | |
| 40 | let statuses = RecordingProtocol.statuses | |
| 41 | #expect(statuses == [200, 304]) | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | /// Passes requests through to the network, recording each response status. | |
| 46 | nonisolated final class RecordingProtocol: URLProtocol, @unchecked Sendable { | |
| 47 | ||
| 48 | private static let recorded = Mutex<[Int]>([]) | |
| 49 | private static let inner = URLSession(configuration: .ephemeral) | |
| 50 | ||
| 51 | static var statuses: [Int] { | |
| 52 | recorded.withLock { $0 } | |
| 53 | } | |
| 54 | ||
| 55 | static func reset() { | |
| 56 | recorded.withLock { $0.removeAll() } | |
| 57 | } | |
| 58 | ||
| 59 | static func session() -> URLSession { | |
| 60 | let configuration = URLSessionConfiguration.ephemeral | |
| 61 | configuration.protocolClasses = [RecordingProtocol.self] | |
| 62 | return URLSession(configuration: configuration) | |
| 63 | } | |
| 64 | ||
| 65 | override static func canInit(with _: URLRequest) -> Bool { true } | |
| 66 | ||
| 67 | override static func canonicalRequest(for request: URLRequest) -> URLRequest { request } | |
| 68 | ||
| 69 | override func startLoading() { | |
| 70 | let request = self.request | |
| 71 | Task { | |
| 72 | do { | |
| 73 | let (data, response) = try await Self.inner.data(for: request) | |
| 74 | if let http = response as? HTTPURLResponse { | |
| 75 | Self.recorded.withLock { $0.append(http.statusCode) } | |
| 76 | } | |
| 77 | self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) | |
| 78 | self.client?.urlProtocol(self, didLoad: data) | |
| 79 | self.client?.urlProtocolDidFinishLoading(self) | |
| 80 | } catch { | |
| 81 | self.client?.urlProtocol(self, didFailWithError: error) | |
| 82 | } | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | override func stopLoading() {} | |
| 87 | } | |
gitbayTests/StubProtocol.swift added +151
| @@ -0,0 +1,151 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// URLProtocol stub: tests enqueue responses, the client sees a real | |
| 4 | /// URLSession. Recorded envelopes go through here. | |
| 5 | /// | |
| 6 | /// Swift Testing runs suites in parallel, so state is keyed per box: each | |
| 7 | /// `box()` gets its own queue and request log, matched up by a header the | |
| 8 | /// box's session adds to every request. | |
| 9 | nonisolated final class StubProtocol: URLProtocol, @unchecked Sendable { | |
| 10 | ||
| 11 | struct Stub: Sendable { | |
| 12 | let status: Int | |
| 13 | let headers: [String: String] | |
| 14 | let body: Data | |
| 15 | ||
| 16 | init(status: Int, headers: [String: String] = [:], json: String) { | |
| 17 | self.status = status | |
| 18 | var headers = headers | |
| 19 | headers["Content-Type"] = headers["Content-Type"] ?? "application/json" | |
| 20 | self.headers = headers | |
| 21 | self.body = Data(json.utf8) | |
| 22 | } | |
| 23 | ||
| 24 | init(status: Int, headers: [String: String] = [:], body: Data = Data()) { | |
| 25 | self.status = status | |
| 26 | self.headers = headers | |
| 27 | self.body = body | |
| 28 | } | |
| 29 | } | |
| 30 | ||
| 31 | /// A request as the stub saw it, for asserting on method, URL, headers | |
| 32 | /// and body. | |
| 33 | struct Seen: Sendable { | |
| 34 | let url: URL | |
| 35 | let method: String | |
| 36 | let headers: [String: String] | |
| 37 | let body: Data | |
| 38 | } | |
| 39 | ||
| 40 | /// One test's private stub queue and request log. | |
| 41 | final class Box: Sendable { | |
| 42 | fileprivate let id = UUID().uuidString | |
| 43 | private let queue = Mutex<[Stub]>([]) | |
| 44 | private let log = Mutex<[Seen]>([]) | |
| 45 | ||
| 46 | func enqueue(_ stub: Stub) { | |
| 47 | queue.withLock { $0.append(stub) } | |
| 48 | } | |
| 49 | ||
| 50 | var seen: [Seen] { | |
| 51 | log.withLock { $0 } | |
| 52 | } | |
| 53 | ||
| 54 | /// The stubbed session for this box. The marker header is how the | |
| 55 | /// shared protocol class finds its way back here. | |
| 56 | func session() -> URLSession { | |
| 57 | let configuration = URLSessionConfiguration.ephemeral | |
| 58 | configuration.protocolClasses = [StubProtocol.self] | |
| 59 | configuration.httpAdditionalHeaders = [StubProtocol.marker: id] | |
| 60 | return URLSession(configuration: configuration) | |
| 61 | } | |
| 62 | ||
| 63 | fileprivate func next() -> Stub? { | |
| 64 | queue.withLock { $0.isEmpty ? nil : $0.removeFirst() } | |
| 65 | } | |
| 66 | ||
| 67 | fileprivate func record(_ request: Seen) { | |
| 68 | log.withLock { $0.append(request) } | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | private static let marker = "X-Stub-Box" | |
| 73 | private static let boxes = Mutex<[String: Box]>([:]) | |
| 74 | ||
| 75 | static func box() -> Box { | |
| 76 | let box = Box() | |
| 77 | boxes.withLock { $0[box.id] = box } | |
| 78 | return box | |
| 79 | } | |
| 80 | ||
| 81 | // MARK: - URLProtocol | |
| 82 | ||
| 83 | override static func canInit(with _: URLRequest) -> Bool { true } | |
| 84 | ||
| 85 | override static func canonicalRequest(for request: URLRequest) -> URLRequest { request } | |
| 86 | ||
| 87 | override func startLoading() { | |
| 88 | guard let id = request.value(forHTTPHeaderField: Self.marker), | |
| 89 | let box = Self.boxes.withLock({ $0[id] }) else { | |
| 90 | client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable)) | |
| 91 | return | |
| 92 | } | |
| 93 | ||
| 94 | // httpBody is transformed into a stream by URLSession; read it back. | |
| 95 | var body = Data() | |
| 96 | if let stream = request.httpBodyStream { | |
| 97 | stream.open() | |
| 98 | defer { stream.close() } | |
| 99 | let size = 4096 | |
| 100 | let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: size) | |
| 101 | defer { buffer.deallocate() } | |
| 102 | while stream.hasBytesAvailable { | |
| 103 | let read = stream.read(buffer, maxLength: size) | |
| 104 | guard read > 0 else { break } | |
| 105 | body.append(buffer, count: read) | |
| 106 | } | |
| 107 | } | |
| 108 | var headers = request.allHTTPHeaderFields ?? [:] | |
| 109 | headers.removeValue(forKey: Self.marker) | |
| 110 | box.record(Seen( | |
| 111 | url: request.url!, | |
| 112 | method: request.httpMethod ?? "GET", | |
| 113 | headers: headers, | |
| 114 | body: body | |
| 115 | )) | |
| 116 | ||
| 117 | guard let stub = box.next() else { | |
| 118 | client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable)) | |
| 119 | return | |
| 120 | } | |
| 121 | let response = HTTPURLResponse( | |
| 122 | url: request.url!, | |
| 123 | statusCode: stub.status, | |
| 124 | httpVersion: "HTTP/1.1", | |
| 125 | headerFields: stub.headers | |
| 126 | )! | |
| 127 | client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) | |
| 128 | if !stub.body.isEmpty { | |
| 129 | client?.urlProtocol(self, didLoad: stub.body) | |
| 130 | } | |
| 131 | client?.urlProtocolDidFinishLoading(self) | |
| 132 | } | |
| 133 | ||
| 134 | override func stopLoading() {} | |
| 135 | } | |
| 136 | ||
| 137 | /// Minimal lock wrapper; os.OSAllocatedUnfairLock spelled test-side. | |
| 138 | nonisolated final class Mutex<Value>: @unchecked Sendable { | |
| 139 | private var value: Value | |
| 140 | private let lock = NSLock() | |
| 141 | ||
| 142 | init(_ value: Value) { | |
| 143 | self.value = value | |
| 144 | } | |
| 145 | ||
| 146 | func withLock<R>(_ body: (inout Value) -> R) -> R { | |
| 147 | lock.lock() | |
| 148 | defer { lock.unlock() } | |
| 149 | return body(&value) | |
| 150 | } | |
| 151 | } | |