diff --git a/HelperXPCShared/HelperXPCShared.swift b/HelperXPCShared/HelperXPCShared.swift index 80f89b86..d72d7be9 100644 --- a/HelperXPCShared/HelperXPCShared.swift +++ b/HelperXPCShared/HelperXPCShared.swift @@ -5,7 +5,7 @@ let clientBundleID = "com.xcodesorg.xcodesapp" let subjectOrganizationalUnit = Bundle.main.infoDictionary!["CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT"] as! String @objc(HelperXPCProtocol) -protocol HelperXPCProtocol { +protocol HelperXPCProtocol: Sendable { func getVersion(completion: @escaping (String) -> Void) func xcodeSelect(absolutePath: String, completion: @escaping (Error?) -> Void) func devToolsSecurityEnable(completion: @escaping (Error?) -> Void) diff --git a/Scripts/fix_libfido2_framework.sh b/Scripts/fix_libfido2_framework.sh index d9111062..805dd72b 100755 --- a/Scripts/fix_libfido2_framework.sh +++ b/Scripts/fix_libfido2_framework.sh @@ -1,26 +1,34 @@ #!/bin/sh -# Fix libfido2.framework structure for macOS validation -# If this script is not run, the build will fail because xcodebuild is expecting the library in a specific structure -FRAMEWORK_PATH="${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Frameworks/libfido2.framework" +set -e -if [ -d "$FRAMEWORK_PATH" ] && [ -f "$FRAMEWORK_PATH/Info.plist" ] && [ ! -d "$FRAMEWORK_PATH/Versions" ]; then +FRAMEWORKS_DIR="${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Frameworks" +LIBFIDO_FRAMEWORK_PATH="${FRAMEWORKS_DIR}/libfido2.framework" + +if [ -d "$LIBFIDO_FRAMEWORK_PATH" ] && [ -f "$LIBFIDO_FRAMEWORK_PATH/Info.plist" ] && [ ! -d "$LIBFIDO_FRAMEWORK_PATH/Versions" ]; then echo "Fixing libfido2.framework bundle structure..." - # Create proper bundle structure - mkdir -p "$FRAMEWORK_PATH/Versions/A/Resources" + mkdir -p "$LIBFIDO_FRAMEWORK_PATH/Versions/A/Resources" - # Move files to proper locations - mv "$FRAMEWORK_PATH/Info.plist" "$FRAMEWORK_PATH/Versions/A/Resources/" - mv "$FRAMEWORK_PATH/libfido2" "$FRAMEWORK_PATH/Versions/A/" - if [ -f "$FRAMEWORK_PATH/LICENSE" ]; then - mv "$FRAMEWORK_PATH/LICENSE" "$FRAMEWORK_PATH/Versions/A/" + mv "$LIBFIDO_FRAMEWORK_PATH/Info.plist" "$LIBFIDO_FRAMEWORK_PATH/Versions/A/Resources/" + mv "$LIBFIDO_FRAMEWORK_PATH/libfido2" "$LIBFIDO_FRAMEWORK_PATH/Versions/A/" + if [ -f "$LIBFIDO_FRAMEWORK_PATH/LICENSE" ]; then + mv "$LIBFIDO_FRAMEWORK_PATH/LICENSE" "$LIBFIDO_FRAMEWORK_PATH/Versions/A/" fi - # Create symbolic links - ln -sf A "$FRAMEWORK_PATH/Versions/Current" - ln -sf Versions/Current/libfido2 "$FRAMEWORK_PATH/libfido2" - ln -sf Versions/Current/Resources "$FRAMEWORK_PATH/Resources" - - echo "libfido2.framework structure fixed" + ln -sf A "$LIBFIDO_FRAMEWORK_PATH/Versions/Current" + ln -sf Versions/Current/libfido2 "$LIBFIDO_FRAMEWORK_PATH/libfido2" + ln -sf Versions/Current/Resources "$LIBFIDO_FRAMEWORK_PATH/Resources" fi + +SIGN_IDENTITY="${EXPANDED_CODE_SIGN_IDENTITY:--}" +for item in \ + "$BUILT_PRODUCTS_DIR/libcrypto.3.dylib" \ + "$BUILT_PRODUCTS_DIR/libcbor.0.11.0.dylib" \ + "$FRAMEWORKS_DIR/libcrypto.3.dylib" \ + "$FRAMEWORKS_DIR/libcbor.0.11.0.dylib" \ + "$LIBFIDO_FRAMEWORK_PATH"; do + if [ -e "$item" ]; then + codesign --force --sign "$SIGN_IDENTITY" "$item" + fi +done diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 7aeb76cf..e0763bb3 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 15F5B8902CCF09B900705E2F /* CryptoKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15F5B88F2CCF09B900705E2F /* CryptoKit.framework */; }; - 33027E342CA8C18800CB387C /* LibFido2Swift in Frameworks */ = {isa = PBXBuildFile; productRef = 334A932B2CA885A400A5E079 /* LibFido2Swift */; }; 3328073F2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3328073E2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift */; }; 332807412CA5EA820036F691 /* SignInSecurityKeyTouchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332807402CA5EA820036F691 /* SignInSecurityKeyTouchView.swift */; }; 36741BFD291E4FDB00A85AAE /* DownloadPreferencePane.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36741BFC291E4FDB00A85AAE /* DownloadPreferencePane.swift */; }; @@ -32,7 +31,6 @@ BDBAB7452B9FF55800694B0B /* TrailingIconLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */; }; CA11E7BA2598476C00D2EE1C /* XcodeCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */; }; CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */; }; - CA25192A25A9644800F08414 /* XcodeInstallState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA25192925A9644800F08414 /* XcodeInstallState.swift */; }; CA378F992466567600A58CE0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA378F982466567600A58CE0 /* AppState.swift */; }; CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; }; CA42DD7325AEB04300BC0B0C /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA42DD7225AEB04300BC0B0C /* Logger.swift */; }; @@ -48,10 +46,7 @@ CA9FF84E2595079F00E47BAF /* ScrollingTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF84D2595079F00E47BAF /* ScrollingTextView.swift */; }; CA9FF8522595080100E47BAF /* AcknowledgementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8512595080100E47BAF /* AcknowledgementsView.swift */; }; CA9FF8662595130600E47BAF /* View+IsHidden.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8652595130600E47BAF /* View+IsHidden.swift */; }; - CA9FF877259528CC00E47BAF /* Version+XcodeReleases.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF876259528CC00E47BAF /* Version+XcodeReleases.swift */; }; CA9FF87B2595293E00E47BAF /* DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF87A2595293E00E47BAF /* DataSource.swift */; }; - CA9FF88125955C7000E47BAF /* AvailableXcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF88025955C7000E47BAF /* AvailableXcode.swift */; }; - CA9FF8872595607900E47BAF /* InstalledXcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8862595607900E47BAF /* InstalledXcode.swift */; }; CA9FF8B12595967A00E47BAF /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8B02595967A00E47BAF /* main.swift */; }; CA9FF8CF25959A9700E47BAF /* HelperXPCShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8CE25959A9700E47BAF /* HelperXPCShared.swift */; }; CA9FF8D025959A9700E47BAF /* HelperXPCShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8CE25959A9700E47BAF /* HelperXPCShared.swift */; }; @@ -59,31 +54,20 @@ CA9FF8E025959BAA00E47BAF /* ConnectionVerifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8DF25959BAA00E47BAF /* ConnectionVerifier.swift */; }; CA9FF8E625959BB800E47BAF /* AuditTokenHack.m in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8E525959BB800E47BAF /* AuditTokenHack.m */; }; CA9FF9362595B44700E47BAF /* HelperClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF9352595B44700E47BAF /* HelperClient.swift */; }; - CAA1CB2D255A5262003FD669 /* AppleAPI in Frameworks */ = {isa = PBXBuildFile; productRef = CAA1CB2C255A5262003FD669 /* AppleAPI */; }; CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */; }; CAA1CB45255A5B60003FD669 /* SignIn2FAView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */; }; CAA1CB49255A5C97003FD669 /* SignInSMSView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */; }; CAA1CB4D255A5CFD003FD669 /* SignInPhoneListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB4C255A5CFD003FD669 /* SignInPhoneListView.swift */; }; CAA8587C25A2B37900ACF8C0 /* IsTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA8587B25A2B37900ACF8C0 /* IsTesting.swift */; }; CAA8589325A2B77E00ACF8C0 /* aria2c in Copy aria2c */ = {isa = PBXBuildFile; fileRef = CAA8588025A2B63A00ACF8C0 /* aria2c */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; - CAA8589B25A2B83000ACF8C0 /* Aria2CError.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA8589A25A2B83000ACF8C0 /* Aria2CError.swift */; }; + CAA858C025A2BE4E00ACF8C0 /* View+ErrorHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA858BF25A2BE4E00ACF8C0 /* View+ErrorHandling.swift */; }; CAA858C425A2BE4E00ACF8C0 /* Downloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA858C325A2BE4E00ACF8C0 /* Downloader.swift */; }; - CAA858CD25A3D8BC00ACF8C0 /* ErrorHandling in Frameworks */ = {isa = PBXBuildFile; productRef = CAA858CC25A3D8BC00ACF8C0 /* ErrorHandling */; }; CAA858DB25A3E11F00ACF8C0 /* aria2-release-1.35.0.tar.gz in Resources */ = {isa = PBXBuildFile; fileRef = CAA858DA25A3E11F00ACF8C0 /* aria2-release-1.35.0.tar.gz */; }; CAB3AB0E25BCA6C200BF1B04 /* AppStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD2E7B72449575100113D76 /* AppStateTests.swift */; }; - CABFA9BB2592EEEA00380FEE /* DateFormatter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9BA2592EEEA00380FEE /* DateFormatter+.swift */; }; CABFA9BD2592EEEA00380FEE /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9A92592EEE900380FEE /* Environment.swift */; }; - CABFA9BF2592EEEA00380FEE /* URLSession+DownloadTaskPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B32592EEEA00380FEE /* URLSession+DownloadTaskPublisher.swift */; }; - CABFA9C12592EEEA00380FEE /* Version+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9A82592EEE900380FEE /* Version+.swift */; }; - CABFA9C22592EEEA00380FEE /* Publisher+Resumable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B02592EEEA00380FEE /* Publisher+Resumable.swift */; }; - CABFA9C32592EEEA00380FEE /* Downloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B92592EEEA00380FEE /* Downloads.swift */; }; CABFA9C52592EEEA00380FEE /* FileManager+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B82592EEEA00380FEE /* FileManager+.swift */; }; - CABFA9C72592EEEA00380FEE /* Entry+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B22592EEEA00380FEE /* Entry+.swift */; }; - CABFA9C92592EEEA00380FEE /* URLRequest+Apple.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9AB2592EEE900380FEE /* URLRequest+Apple.swift */; }; CABFA9CA2592EEEA00380FEE /* AppState+Update.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9A72592EEE900380FEE /* AppState+Update.swift */; }; CABFA9CC2592EEEA00380FEE /* Path+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9AE2592EEE900380FEE /* Path+.swift */; }; - CABFA9CD2592EEEA00380FEE /* Foundation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9AC2592EEE900380FEE /* Foundation.swift */; }; - CABFA9CE2592EEEA00380FEE /* Version+Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9A62592EEE900380FEE /* Version+Xcode.swift */; }; CABFA9CF2592EEEA00380FEE /* Process.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B42592EEEA00380FEE /* Process.swift */; }; CABFA9E42592F08E00380FEE /* Version in Frameworks */ = {isa = PBXBuildFile; productRef = CABFA9E32592F08E00380FEE /* Version */; }; CABFA9EE2592F0CC00380FEE /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = CABFA9ED2592F0CC00380FEE /* SwiftSoup */; }; @@ -92,7 +76,6 @@ CABFAA2D2592FBFC00380FEE /* Configure.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFAA2B2592FBFC00380FEE /* Configure.swift */; }; CABFAA432593104F00380FEE /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFAA422593104F00380FEE /* AboutView.swift */; }; CABFAA492593162500380FEE /* Bundle+InfoPlistValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFAA482593162500380FEE /* Bundle+InfoPlistValues.swift */; }; - CAC28188259EE27200B8AB0B /* CombineExpectations in Frameworks */ = {isa = PBXBuildFile; productRef = CAC28187259EE27200B8AB0B /* CombineExpectations */; }; CAC281CD259F97FA00B8AB0B /* ObservingProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC281CC259F97FA00B8AB0B /* ObservingProgressIndicator.swift */; }; CAC281E2259FA44600B8AB0B /* Bundle+XcodesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC281E1259FA44600B8AB0B /* Bundle+XcodesTests.swift */; }; CAC281E7259FA45A00B8AB0B /* Environment+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC281E6259FA45A00B8AB0B /* Environment+Mock.swift */; }; @@ -129,6 +112,9 @@ E87DD6EB25D053FA00D86808 /* Progress+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E87DD6EA25D053FA00D86808 /* Progress+.swift */; }; E89342FA25EDCC17007CF557 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E89342F925EDCC17007CF557 /* NotificationManager.swift */; }; E8977EA325C11E1500835F80 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8977EA225C11E1500835F80 /* PreferencesView.swift */; }; + E89CBD382D5FAB950037ED95 /* XcodesLoginKit in Frameworks */ = {isa = PBXBuildFile; productRef = E89CBD372D5FAB950037ED95 /* XcodesLoginKit */; }; + E89CBD3A2D5FB8920037ED95 /* XcodesLoginKitSecurityKey in Frameworks */ = {isa = PBXBuildFile; productRef = E89CBD392D5FB8920037ED95 /* XcodesLoginKitSecurityKey */; }; + E89CBD402D6434E10037ED95 /* SignInFederatedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E89CBD3F2D6434E10037ED95 /* SignInFederatedView.swift */; }; E8B20CBF2A2EDEC20057D816 /* SDKs+Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8B20CBE2A2EDEC20057D816 /* SDKs+Xcode.swift */; }; E8C0EB1A291EF43E0081528A /* XcodesKit in Frameworks */ = {isa = PBXBuildFile; productRef = E8C0EB19291EF43E0081528A /* XcodesKit */; }; E8C0EB1C291EF9A10081528A /* AppState+Runtimes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8C0EB1B291EF9A10081528A /* AppState+Runtimes.swift */; }; @@ -222,7 +208,6 @@ BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailingIconLabelStyle.swift; sourceTree = ""; }; CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeCommands.swift; sourceTree = ""; }; CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateUpdateTests.swift; sourceTree = ""; }; - CA25192925A9644800F08414 /* XcodeInstallState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeInstallState.swift; sourceTree = ""; }; CA378F982466567600A58CE0 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreButtonStyle.swift; sourceTree = ""; }; CA42DD7225AEB04300BC0B0C /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; @@ -230,7 +215,6 @@ CA452BAF259FD9770072DFA4 /* ProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = ""; }; CA452BBF259FDDFE0072DFA4 /* Stub-version.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Stub-version.plist"; sourceTree = ""; }; CA452BEA25A236500072DFA4 /* Stub-0.0.0.Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Stub-0.0.0.Info.plist"; sourceTree = ""; }; - CA538A0C255A4F1A00E64DD7 /* AppleAPI */ = {isa = PBXFileReference; lastKnownFileType = folder; name = AppleAPI; path = Xcodes/AppleAPI; sourceTree = ""; }; CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinCodeTextView.swift; sourceTree = ""; }; CA61A6DF259835580008926E /* Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcode.swift; sourceTree = ""; }; CA735108257BF96D00EA9CF8 /* AttributedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedText.swift; sourceTree = ""; }; @@ -243,10 +227,7 @@ CA9FF84D2595079F00E47BAF /* ScrollingTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingTextView.swift; sourceTree = ""; }; CA9FF8512595080100E47BAF /* AcknowledgementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcknowledgementsView.swift; sourceTree = ""; }; CA9FF8652595130600E47BAF /* View+IsHidden.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+IsHidden.swift"; sourceTree = ""; }; - CA9FF876259528CC00E47BAF /* Version+XcodeReleases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Version+XcodeReleases.swift"; sourceTree = ""; }; CA9FF87A2595293E00E47BAF /* DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSource.swift; sourceTree = ""; }; - CA9FF88025955C7000E47BAF /* AvailableXcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvailableXcode.swift; sourceTree = ""; }; - CA9FF8862595607900E47BAF /* InstalledXcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledXcode.swift; sourceTree = ""; }; CA9FF8AE2595967A00E47BAF /* com.xcodesorg.xcodesapp.Helper */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = com.xcodesorg.xcodesapp.Helper; sourceTree = BUILT_PRODUCTS_DIR; }; CA9FF8B02595967A00E47BAF /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; CA9FF8C22595988B00E47BAF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -267,26 +248,17 @@ CAA8587B25A2B37900ACF8C0 /* IsTesting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IsTesting.swift; sourceTree = ""; }; CAA8588025A2B63A00ACF8C0 /* aria2c */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = aria2c; sourceTree = ""; }; CAA8588A25A2B69300ACF8C0 /* aria2c.LICENSE */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = aria2c.LICENSE; sourceTree = ""; }; - CAA8589A25A2B83000ACF8C0 /* Aria2CError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Aria2CError.swift; sourceTree = ""; }; + CAA858BF25A2BE4E00ACF8C0 /* View+ErrorHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ErrorHandling.swift"; sourceTree = ""; }; CAA858C325A2BE4E00ACF8C0 /* Downloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Downloader.swift; sourceTree = ""; }; CAA858DA25A3E11F00ACF8C0 /* aria2-release-1.35.0.tar.gz */ = {isa = PBXFileReference; lastKnownFileType = archive.gzip; path = "aria2-release-1.35.0.tar.gz"; sourceTree = ""; }; CABFA9A02592EAF500380FEE /* R&PLogo.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "R&PLogo.png"; sourceTree = ""; }; CABFA9A12592EAFB00380FEE /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; CABFA9A32592ED5700380FEE /* Apple.paw */ = {isa = PBXFileReference; lastKnownFileType = file; name = Apple.paw; path = ../xcodes/Apple.paw; sourceTree = ""; }; - CABFA9A62592EEE900380FEE /* Version+Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Version+Xcode.swift"; sourceTree = ""; }; CABFA9A72592EEE900380FEE /* AppState+Update.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppState+Update.swift"; sourceTree = ""; }; - CABFA9A82592EEE900380FEE /* Version+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Version+.swift"; sourceTree = ""; }; CABFA9A92592EEE900380FEE /* Environment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Environment.swift; sourceTree = ""; }; - CABFA9AB2592EEE900380FEE /* URLRequest+Apple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest+Apple.swift"; sourceTree = ""; }; - CABFA9AC2592EEE900380FEE /* Foundation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Foundation.swift; sourceTree = ""; }; CABFA9AE2592EEE900380FEE /* Path+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Path+.swift"; sourceTree = ""; }; - CABFA9B02592EEEA00380FEE /* Publisher+Resumable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+Resumable.swift"; sourceTree = ""; }; - CABFA9B22592EEEA00380FEE /* Entry+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Entry+.swift"; sourceTree = ""; }; - CABFA9B32592EEEA00380FEE /* URLSession+DownloadTaskPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSession+DownloadTaskPublisher.swift"; sourceTree = ""; }; CABFA9B42592EEEA00380FEE /* Process.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Process.swift; sourceTree = ""; }; CABFA9B82592EEEA00380FEE /* FileManager+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+.swift"; sourceTree = ""; }; - CABFA9B92592EEEA00380FEE /* Downloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Downloads.swift; sourceTree = ""; }; - CABFA9BA2592EEEA00380FEE /* DateFormatter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DateFormatter+.swift"; sourceTree = ""; }; CABFA9D42592EF6300380FEE /* DECISIONS.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = DECISIONS.md; sourceTree = ""; }; CABFAA2B2592FBFC00380FEE /* Configure.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Configure.swift; path = Xcodes/Backend/Configure.swift; sourceTree = SOURCE_ROOT; }; CABFAA422593104F00380FEE /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -328,12 +300,13 @@ E84B7D0C2B296A8900DBDA2B /* NavigationSplitViewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationSplitViewWrapper.swift; sourceTree = ""; }; E84E4F512B323A5F003F3959 /* CornerRadiusModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CornerRadiusModifier.swift; sourceTree = ""; }; E84E4F532B333864003F3959 /* PlatformsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformsListView.swift; sourceTree = ""; }; - E856BB73291EDD3D00DC438B /* XcodesKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = XcodesKit; path = Xcodes/XcodesKit; sourceTree = ""; }; E86671262B309D2F0048559A /* PlatformsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformsView.swift; sourceTree = ""; }; E87AB3C42939B65E00D72F43 /* Hardware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hardware.swift; sourceTree = ""; }; E87DD6EA25D053FA00D86808 /* Progress+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Progress+.swift"; sourceTree = ""; }; E89342F925EDCC17007CF557 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; E8977EA225C11E1500835F80 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; + E89CBD3B2D5FC0B10037ED95 /* XcodesLoginKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = XcodesLoginKit; path = ../XcodesLoginKit; sourceTree = ""; }; + E89CBD3F2D6434E10037ED95 /* SignInFederatedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInFederatedView.swift; sourceTree = ""; }; E8B20CBE2A2EDEC20057D816 /* SDKs+Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SDKs+Xcode.swift"; sourceTree = ""; }; E8C0EB1B291EF9A10081528A /* AppState+Runtimes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppState+Runtimes.swift"; sourceTree = ""; }; E8CBDB8627ADD92000B22292 /* unxip */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = unxip; sourceTree = ""; }; @@ -358,17 +331,16 @@ buildActionMask = 2147483647; files = ( 15F5B8902CCF09B900705E2F /* CryptoKit.framework in Frameworks */, - 33027E342CA8C18800CB387C /* LibFido2Swift in Frameworks */, CABFA9E42592F08E00380FEE /* Version in Frameworks */, CABFA9FD2592F13300380FEE /* LegibleError in Frameworks */, E689540325BE8C64000EBCEA /* DockProgress in Frameworks */, CABFA9F82592F0F900380FEE /* KeychainAccess in Frameworks */, E83FDC442CBB649100679C6B /* Sparkle in Frameworks */, E862D43B2CC8B26F00BAA376 /* SRP in Frameworks */, - CAA858CD25A3D8BC00ACF8C0 /* ErrorHandling in Frameworks */, + E89CBD3A2D5FB8920037ED95 /* XcodesLoginKitSecurityKey in Frameworks */, + E89CBD382D5FAB950037ED95 /* XcodesLoginKit in Frameworks */, E8C0EB1A291EF43E0081528A /* XcodesKit in Frameworks */, E8FD5727291EE4AC001E004C /* AsyncNetworkService in Frameworks */, - CAA1CB2D255A5262003FD669 /* AppleAPI in Frameworks */, CABFA9EE2592F0CC00380FEE /* SwiftSoup in Frameworks */, E84E4F572B335094003F3959 /* OrderedCollections in Frameworks */, E8F44A1E296B4CD7002D6592 /* Path in Frameworks */, @@ -379,7 +351,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - CAC28188259EE27200B8AB0B /* CombineExpectations in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -397,6 +368,7 @@ 53CBAB2B263DCC9100410495 /* XcodesAlert.swift */, E84B7D0C2B296A8900DBDA2B /* NavigationSplitViewWrapper.swift */, BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */, + CAA858BF25A2BE4E00ACF8C0 /* View+ErrorHandling.swift */, ); path = Common; sourceTree = ""; @@ -472,6 +444,7 @@ 3328073E2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift */, 332807402CA5EA820036F691 /* SignInSecurityKeyTouchView.swift */, CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */, + E89CBD3F2D6434E10037ED95 /* SignInFederatedView.swift */, CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */, CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */, CAA1CB4C255A5CFD003FD669 /* SignInPhoneListView.swift */, @@ -502,37 +475,23 @@ E8C0EB1B291EF9A10081528A /* AppState+Runtimes.swift */, CAE424B3259A764700B8B246 /* AppState+Install.swift */, CABFA9A72592EEE900380FEE /* AppState+Update.swift */, - CAA8589A25A2B83000ACF8C0 /* Aria2CError.swift */, - CA9FF88025955C7000E47BAF /* AvailableXcode.swift */, CABFAA2B2592FBFC00380FEE /* Configure.swift */, CABFAA482593162500380FEE /* Bundle+InfoPlistValues.swift */, CA9FF87A2595293E00E47BAF /* DataSource.swift */, - CABFA9BA2592EEEA00380FEE /* DateFormatter+.swift */, - CABFA9B92592EEEA00380FEE /* Downloads.swift */, CAA858C325A2BE4E00ACF8C0 /* Downloader.swift */, - CABFA9B22592EEEA00380FEE /* Entry+.swift */, CABFA9A92592EEE900380FEE /* Environment.swift */, 36741BFE291E50F500A85AAE /* FileError.swift */, CABFA9B82592EEEA00380FEE /* FileManager+.swift */, CAFBDB942598FE96003DCC5A /* FocusedValues.swift */, - CABFA9AC2592EEE900380FEE /* Foundation.swift */, CA9FF9352595B44700E47BAF /* HelperClient.swift */, CAC9F92C25BCDA4400B4965F /* HelperInstallState.swift */, - CA9FF8862595607900E47BAF /* InstalledXcode.swift */, CAA8587B25A2B37900ACF8C0 /* IsTesting.swift */, E89342F925EDCC17007CF557 /* NotificationManager.swift */, CAE4248B259A68B800B8B246 /* Optional+IsNotNil.swift */, CABFA9AE2592EEE900380FEE /* Path+.swift */, CABFA9B42592EEEA00380FEE /* Process.swift */, - CABFA9B02592EEEA00380FEE /* Publisher+Resumable.swift */, CAFBDB902598FE80003DCC5A /* SelectedXcode.swift */, - CABFA9AB2592EEE900380FEE /* URLRequest+Apple.swift */, - CABFA9B32592EEEA00380FEE /* URLSession+DownloadTaskPublisher.swift */, - CABFA9A82592EEE900380FEE /* Version+.swift */, - CA9FF876259528CC00E47BAF /* Version+XcodeReleases.swift */, - CABFA9A62592EEE900380FEE /* Version+Xcode.swift */, CA61A6DF259835580008926E /* Xcode.swift */, - CA25192925A9644800F08414 /* XcodeInstallState.swift */, CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */, E87DD6EA25D053FA00D86808 /* Progress+.swift */, E81D7E9F2805250100A205FC /* Collection+.swift */, @@ -580,7 +539,7 @@ CAD2E7952449574E00113D76 = { isa = PBXGroup; children = ( - E856BB73291EDD3D00DC438B /* XcodesKit */, + E89CBD3B2D5FC0B10037ED95 /* XcodesLoginKit */, CA8FB5F8256E0F9400469DA5 /* README.md */, CABFA9D42592EF6300380FEE /* DECISIONS.md */, CABFA9A02592EAF500380FEE /* R&PLogo.png */, @@ -589,7 +548,6 @@ CA8FB61C256E115700469DA5 /* .github */, CA9FF9252595A7EB00E47BAF /* Scripts */, CA9FF8242594F10700E47BAF /* AcknowledgementsGenerator */, - CA538A0C255A4F1A00E64DD7 /* AppleAPI */, CAD2E7A02449574E00113D76 /* Xcodes */, CAD2E7B62449575100113D76 /* XcodesTests */, CA9FF8AF2595967A00E47BAF /* com.xcodesorg.xcodesapp.Helper */, @@ -711,6 +669,7 @@ CAD2E79C2449574E00113D76 /* Resources */, CA9FF8BB259596B500E47BAF /* Copy Helper */, CAA8589225A2B76F00ACF8C0 /* Copy aria2c */, + D971F84C2E79102E005F84C9 /* Fix libfido2 structure */, ); buildRules = ( ); @@ -719,20 +678,19 @@ ); name = Xcodes; packageProductDependencies = ( - CAA1CB2C255A5262003FD669 /* AppleAPI */, CABFA9E32592F08E00380FEE /* Version */, CABFA9ED2592F0CC00380FEE /* SwiftSoup */, CABFA9F72592F0F900380FEE /* KeychainAccess */, CABFA9FC2592F13300380FEE /* LegibleError */, - CAA858CC25A3D8BC00ACF8C0 /* ErrorHandling */, E689540225BE8C64000EBCEA /* DockProgress */, E8FD5726291EE4AC001E004C /* AsyncNetworkService */, E8C0EB19291EF43E0081528A /* XcodesKit */, E8F44A1D296B4CD7002D6592 /* Path */, E84E4F562B335094003F3959 /* OrderedCollections */, E83FDC432CBB649100679C6B /* Sparkle */, - 334A932B2CA885A400A5E079 /* LibFido2Swift */, E862D43A2CC8B26F00BAA376 /* SRP */, + E89CBD372D5FAB950037ED95 /* XcodesLoginKit */, + E89CBD392D5FB8920037ED95 /* XcodesLoginKitSecurityKey */, ); productName = XcodesMac; productReference = CAD2E79E2449574E00113D76 /* Xcodes.app */; @@ -753,7 +711,6 @@ ); name = XcodesTests; packageProductDependencies = ( - CAC28187259EE27200B8AB0B /* CombineExpectations */, ); productName = XcodesMacTests; productReference = CAD2E7B32449575100113D76 /* XcodesTests.xctest */; @@ -814,14 +771,12 @@ CABFA9EC2592F0CC00380FEE /* XCRemoteSwiftPackageReference "SwiftSoup" */, CABFA9F62592F0F900380FEE /* XCRemoteSwiftPackageReference "KeychainAccess" */, CABFA9FB2592F13300380FEE /* XCRemoteSwiftPackageReference "LegibleError" */, - CAA858CB25A3D8BC00ACF8C0 /* XCRemoteSwiftPackageReference "ErrorHandling" */, - CAC28186259EE27200B8AB0B /* XCRemoteSwiftPackageReference "CombineExpectations" */, E689540125BE8C64000EBCEA /* XCRemoteSwiftPackageReference "DockProgress" */, E8FD5725291EE4AC001E004C /* XCRemoteSwiftPackageReference "AsyncHTTPNetworkService" */, E8F44A1C296B4CD7002D6592 /* XCRemoteSwiftPackageReference "Path" */, + E856BB74291EDD3D00DC438B /* XCRemoteSwiftPackageReference "XcodesKit" */, E84E4F552B335094003F3959 /* XCRemoteSwiftPackageReference "swift-collections" */, E83FDC422CBB649100679C6B /* XCRemoteSwiftPackageReference "Sparkle" */, - 33027E282CA8BB5800CB387C /* XCRemoteSwiftPackageReference "LibFido2Swift" */, ); productRefGroup = CAD2E79F2449574E00113D76 /* Products */; projectDirPath = ""; @@ -881,6 +836,24 @@ shellPath = /bin/sh; shellScript = "cd \"${SRCROOT}/Xcodes/AcknowledgementsGenerator\"\nxcrun -sdk macosx swift run AcknowledgementsGenerator \\\n -p \"${SRCROOT}/Xcodes.xcodeproj\" \\\n -o \"${SRCROOT}/Xcodes/Resources/Licenses.rtf\"\n"; }; + D971F84C2E79102E005F84C9 /* Fix libfido2 structure */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Fix libfido2 structure"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "sh \"${SRCROOT}/Scripts/fix_libfido2_framework.sh\"\n"; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -905,7 +878,6 @@ CA735109257BF96D00EA9CF8 /* AttributedText.swift in Sources */, CAFBDC4E2599B33D003DCC5A /* MainToolbar.swift in Sources */, CA11E7BA2598476C00D2EE1C /* XcodeCommands.swift in Sources */, - CAA8589B25A2B83000ACF8C0 /* Aria2CError.swift in Sources */, 536CFDD2263C94DE00026CE0 /* SignedInView.swift in Sources */, CABFAA492593162500380FEE /* Bundle+InfoPlistValues.swift in Sources */, CA9FF8662595130600E47BAF /* View+IsHidden.swift in Sources */, @@ -917,22 +889,18 @@ CAA8587C25A2B37900ACF8C0 /* IsTesting.swift in Sources */, CABFA9CA2592EEEA00380FEE /* AppState+Update.swift in Sources */, CA44901F2463AD34003D8213 /* Tag.swift in Sources */, - CABFA9BF2592EEEA00380FEE /* URLSession+DownloadTaskPublisher.swift in Sources */, CAFE4AAC25B7D2C70064FE51 /* GeneralPreferencePane.swift in Sources */, - CABFA9BB2592EEEA00380FEE /* DateFormatter+.swift in Sources */, CAFE4ABC25B7D54B0064FE51 /* UpdatesPreferencePane.swift in Sources */, CABFA9BD2592EEEA00380FEE /* Environment.swift in Sources */, - CABFA9C32592EEEA00380FEE /* Downloads.swift in Sources */, + CAA858C025A2BE4E00ACF8C0 /* View+ErrorHandling.swift in Sources */, E8CBDB8B27AE02FF00B22292 /* ExperiementsPreferencePane.swift in Sources */, E8E98A9625D863D700EC89A0 /* InstallationStepDetailView.swift in Sources */, CA378F992466567600A58CE0 /* AppState.swift in Sources */, CAD2E7A42449574E00113D76 /* XcodeListView.swift in Sources */, CAA1CB45255A5B60003FD669 /* SignIn2FAView.swift in Sources */, CABFA9C52592EEEA00380FEE /* FileManager+.swift in Sources */, - CABFA9CD2592EEEA00380FEE /* Foundation.swift in Sources */, B0403CFC2AD9A6BF00137C09 /* InstalledStateButtons.swift in Sources */, 36741BFF291E50F500A85AAE /* FileError.swift in Sources */, - CA9FF8872595607900E47BAF /* InstalledXcode.swift in Sources */, E84E4F522B323A5F003F3959 /* CornerRadiusModifier.swift in Sources */, B0403CF22AD934B600137C09 /* CompatibilityView.swift in Sources */, B0403CFE2ADA712C00137C09 /* InfoPaneControls.swift in Sources */, @@ -946,22 +914,19 @@ CAFE4AB425B7D3AF0064FE51 /* AdvancedPreferencePane.swift in Sources */, CA9FF84E2595079F00E47BAF /* ScrollingTextView.swift in Sources */, E832EAF82B0FBCF4001B570D /* RuntimeInstallationStepDetailView.swift in Sources */, - CABFA9C12592EEEA00380FEE /* Version+.swift in Sources */, BDBAB7452B9FF55800694B0B /* TrailingIconLabelStyle.swift in Sources */, E8D655C0288DD04700A139C2 /* SelectedActionType.swift in Sources */, 36741BFD291E4FDB00A85AAE /* DownloadPreferencePane.swift in Sources */, E84E4F542B333864003F3959 /* PlatformsListView.swift in Sources */, E86671272B309D2F0048559A /* PlatformsView.swift in Sources */, CA9FF8522595080100E47BAF /* AcknowledgementsView.swift in Sources */, - CABFA9CE2592EEEA00380FEE /* Version+Xcode.swift in Sources */, CAFBDB912598FE80003DCC5A /* SelectedXcode.swift in Sources */, CAA1CB49255A5C97003FD669 /* SignInSMSView.swift in Sources */, - CA25192A25A9644800F08414 /* XcodeInstallState.swift in Sources */, E8DA461125FAF7FB002E85EF /* NotificationsView.swift in Sources */, CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */, + E89CBD402D6434E10037ED95 /* SignInFederatedView.swift in Sources */, E81D7EA02805250100A205FC /* Collection+.swift in Sources */, E8B20CBF2A2EDEC20057D816 /* SDKs+Xcode.swift in Sources */, - CA9FF877259528CC00E47BAF /* Version+XcodeReleases.swift in Sources */, CABFAA2D2592FBFC00380FEE /* Configure.swift in Sources */, E84B7D0D2B296A8900DBDA2B /* NavigationSplitViewWrapper.swift in Sources */, CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */, @@ -970,7 +935,6 @@ CAC9F92D25BCDA4400B4965F /* HelperInstallState.swift in Sources */, E87DD6EB25D053FA00D86808 /* Progress+.swift in Sources */, CAC281CD259F97FA00B8AB0B /* ObservingProgressIndicator.swift in Sources */, - CABFA9C22592EEEA00380FEE /* Publisher+Resumable.swift in Sources */, B0C6AD0B2AD9178E00E64698 /* IdenticalBuildView.swift in Sources */, CAFBDC68259A308B003DCC5A /* InfoPane.swift in Sources */, B0403CF82AD991F800137C09 /* UnselectedView.swift in Sources */, @@ -979,7 +943,6 @@ CAFBDC6C259A3098003DCC5A /* View+Conditional.swift in Sources */, CABFA9CF2592EEEA00380FEE /* Process.swift in Sources */, CAFFFED8259CDA5000903F81 /* XcodeListViewRow.swift in Sources */, - CABFA9C72592EEEA00380FEE /* Entry+.swift in Sources */, B0403CFA2AD9942A00137C09 /* NotInstalledStateButtons.swift in Sources */, CAE424B4259A764700B8B246 /* AppState+Install.swift in Sources */, CAE42487259A68A300B8B246 /* XcodeListCategory.swift in Sources */, @@ -987,7 +950,6 @@ B0403CF62AD9849E00137C09 /* CompilersView.swift in Sources */, E8977EA325C11E1500835F80 /* PreferencesView.swift in Sources */, CA9FF87B2595293E00E47BAF /* DataSource.swift in Sources */, - CABFA9C92592EEEA00380FEE /* URLRequest+Apple.swift in Sources */, CABFAA432593104F00380FEE /* AboutView.swift in Sources */, E8D0296F284B029800647641 /* BottomStatusBar.swift in Sources */, E8C0EB1C291EF9A10081528A /* AppState+Runtimes.swift in Sources */, @@ -999,7 +961,6 @@ E89342FA25EDCC17007CF557 /* NotificationManager.swift in Sources */, CA5D781E257365D6008EDE9D /* PinCodeTextView.swift in Sources */, CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */, - CA9FF88125955C7000E47BAF /* AvailableXcode.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1104,7 +1065,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 34; + CURRENT_PROJECT_VERSION = 35; DEVELOPMENT_ASSET_PATHS = "\"Xcodes/Preview Content\""; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; @@ -1115,12 +1076,12 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 3.0.2; + MACOSX_DEPLOYMENT_TARGET = 14.6; + MARKETING_VERSION = 4.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.xcodesorg.xcodesapp; PRODUCT_NAME = Xcodes; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Test; }; @@ -1143,7 +1104,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.robotsandpencils.XcodesAppTests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Xcodes.app/Contents/MacOS/Xcodes"; }; name = Test; @@ -1169,7 +1130,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_OBJC_BRIDGING_HEADER = "com.xcodesorg.xcodesapp.Helper/com.xcodesorg.xcodesapp.Helper-Bridging-Header.h"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -1196,7 +1157,7 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_OBJC_BRIDGING_HEADER = "com.xcodesorg.xcodesapp.Helper/com.xcodesorg.xcodesapp.Helper-Bridging-Header.h"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Test; }; @@ -1222,7 +1183,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_OBJC_BRIDGING_HEADER = "com.xcodesorg.xcodesapp.Helper/com.xcodesorg.xcodesapp.Helper-Bridging-Header.h"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Release; }; @@ -1357,7 +1318,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 34; + CURRENT_PROJECT_VERSION = 35; DEVELOPMENT_ASSET_PATHS = "\"Xcodes/Preview Content\""; DEVELOPMENT_TEAM = ZU6GR6B2FY; ENABLE_HARDENED_RUNTIME = YES; @@ -1368,11 +1329,11 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 3.0.2; + MACOSX_DEPLOYMENT_TARGET = 14.6; + MARKETING_VERSION = 4.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.xcodesorg.xcodesapp; PRODUCT_NAME = Xcodes; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -1386,7 +1347,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 34; + CURRENT_PROJECT_VERSION = 35; DEVELOPMENT_ASSET_PATHS = "\"Xcodes/Preview Content\""; DEVELOPMENT_TEAM = ZU6GR6B2FY; ENABLE_HARDENED_RUNTIME = YES; @@ -1397,11 +1358,11 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 3.0.2; + MACOSX_DEPLOYMENT_TARGET = 14.6; + MARKETING_VERSION = 4.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.xcodesorg.xcodesapp; PRODUCT_NAME = Xcodes; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Release; }; @@ -1422,7 +1383,7 @@ MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = com.robotsandpencils.XcodesAppTests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Xcodes.app/Contents/MacOS/Xcodes"; }; name = Debug; @@ -1445,7 +1406,7 @@ MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = com.xcodesorg.xcodesapp.XcodesAppTests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Xcodes.app/Contents/MacOS/Xcodes"; }; name = Release; @@ -1496,22 +1457,6 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 33027E282CA8BB5800CB387C /* XCRemoteSwiftPackageReference "LibFido2Swift" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/kinoroy/LibFido2Swift"; - requirement = { - branch = main; - kind = branch; - }; - }; - CAA858CB25A3D8BC00ACF8C0 /* XCRemoteSwiftPackageReference "ErrorHandling" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/RobotsAndPencils/ErrorHandling"; - requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.1.0; - }; - }; CABFA9E22592F08E00380FEE /* XCRemoteSwiftPackageReference "Version" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/mxcl/Version"; @@ -1544,14 +1489,6 @@ minimumVersion = 1.0.1; }; }; - CAC28186259EE27200B8AB0B /* XCRemoteSwiftPackageReference "CombineExpectations" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/groue/CombineExpectations"; - requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.6.0; - }; - }; E689540125BE8C64000EBCEA /* XCRemoteSwiftPackageReference "DockProgress" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/sindresorhus/DockProgress"; @@ -1576,6 +1513,14 @@ minimumVersion = 1.0.5; }; }; + E856BB74291EDD3D00DC438B /* XCRemoteSwiftPackageReference "XcodesKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/XcodesOrg/XcodesKit"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 1.0.3; + }; + }; E8F44A1C296B4CD7002D6592 /* XCRemoteSwiftPackageReference "Path" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/mxcl/Path.swift"; @@ -1586,7 +1531,7 @@ }; E8FD5725291EE4AC001E004C /* XCRemoteSwiftPackageReference "AsyncHTTPNetworkService" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/RobotsAndPencils/AsyncHTTPNetworkService"; + repositoryURL = "https://github.com/XcodesOrg/AsyncHTTPNetworkService"; requirement = { branch = main; kind = branch; @@ -1595,19 +1540,6 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 334A932B2CA885A400A5E079 /* LibFido2Swift */ = { - isa = XCSwiftPackageProductDependency; - productName = LibFido2Swift; - }; - CAA1CB2C255A5262003FD669 /* AppleAPI */ = { - isa = XCSwiftPackageProductDependency; - productName = AppleAPI; - }; - CAA858CC25A3D8BC00ACF8C0 /* ErrorHandling */ = { - isa = XCSwiftPackageProductDependency; - package = CAA858CB25A3D8BC00ACF8C0 /* XCRemoteSwiftPackageReference "ErrorHandling" */; - productName = ErrorHandling; - }; CABFA9E32592F08E00380FEE /* Version */ = { isa = XCSwiftPackageProductDependency; package = CABFA9E22592F08E00380FEE /* XCRemoteSwiftPackageReference "Version" */; @@ -1628,11 +1560,6 @@ package = CABFA9FB2592F13300380FEE /* XCRemoteSwiftPackageReference "LegibleError" */; productName = LegibleError; }; - CAC28187259EE27200B8AB0B /* CombineExpectations */ = { - isa = XCSwiftPackageProductDependency; - package = CAC28186259EE27200B8AB0B /* XCRemoteSwiftPackageReference "CombineExpectations" */; - productName = CombineExpectations; - }; E689540225BE8C64000EBCEA /* DockProgress */ = { isa = XCSwiftPackageProductDependency; package = E689540125BE8C64000EBCEA /* XCRemoteSwiftPackageReference "DockProgress" */; @@ -1652,8 +1579,17 @@ isa = XCSwiftPackageProductDependency; productName = SRP; }; + E89CBD372D5FAB950037ED95 /* XcodesLoginKit */ = { + isa = XCSwiftPackageProductDependency; + productName = XcodesLoginKit; + }; + E89CBD392D5FB8920037ED95 /* XcodesLoginKitSecurityKey */ = { + isa = XCSwiftPackageProductDependency; + productName = XcodesLoginKitSecurityKey; + }; E8C0EB19291EF43E0081528A /* XcodesKit */ = { isa = XCSwiftPackageProductDependency; + package = E856BB74291EDD3D00DC438B /* XCRemoteSwiftPackageReference "XcodesKit" */; productName = XcodesKit; }; E8F44A1D296B4CD7002D6592 /* Path */ = { diff --git a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 02c22651..3c48feea 100644 --- a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -3,10 +3,10 @@ "pins": [ { "package": "AsyncNetworkService", - "repositoryURL": "https://github.com/RobotsAndPencils/AsyncHTTPNetworkService", + "repositoryURL": "https://github.com/XcodesOrg/AsyncHTTPNetworkService", "state": { "branch": "main", - "revision": "97770856c4e429f880d4b4dd68cfaf286dc00c30", + "revision": "12e225af8b5dc25afcfabfcf582a165b0581ab19", "version": null } }, @@ -19,15 +19,6 @@ "version": "2.0.2" } }, - { - "package": "CombineExpectations", - "repositoryURL": "https://github.com/groue/CombineExpectations", - "state": { - "branch": null, - "revision": "989a92221899929ab8347a5878aa2b16db8b81ca", - "version": "0.6.0" - } - }, { "package": "DockProgress", "repositoryURL": "https://github.com/sindresorhus/DockProgress", @@ -37,15 +28,6 @@ "version": "4.3.1" } }, - { - "package": "ErrorHandling", - "repositoryURL": "https://github.com/RobotsAndPencils/ErrorHandling", - "state": { - "branch": null, - "revision": "7be837fcb515447c0776805c3288fb7d5181ec68", - "version": "0.1.0" - } - }, { "package": "KeychainAccess", "repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess", @@ -68,9 +50,9 @@ "package": "LibFido2Swift", "repositoryURL": "https://github.com/kinoroy/LibFido2Swift", "state": { - "branch": "main", + "branch": null, "revision": "b87a93300c5b35307c9f26ae490963196bd927f1", - "version": null + "version": "0.1.5" } }, { @@ -135,6 +117,24 @@ "revision": "087c91fedc110f9f833b14ef4c32745dabca8913", "version": "1.0.3" } + }, + { + "package": "XcodesKit", + "repositoryURL": "https://github.com/XcodesOrg/XcodesKit", + "state": { + "branch": null, + "revision": "5bff14052f7664f75a4837547804e07c3c2dfe47", + "version": "1.0.3" + } + }, + { + "package": "Yams", + "repositoryURL": "https://github.com/jpsim/Yams", + "state": { + "branch": null, + "revision": "0d9ee7ea8c4ebd4a489ad7a73d5c6cad55d6fed3", + "version": "5.0.6" + } } ] }, diff --git a/Xcodes/AppleAPI/.gitignore b/Xcodes/AppleAPI/.gitignore deleted file mode 100644 index 95c43209..00000000 --- a/Xcodes/AppleAPI/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.DS_Store -/.build -/Packages -/*.xcodeproj -xcuserdata/ diff --git a/Xcodes/AppleAPI/Package.swift b/Xcodes/AppleAPI/Package.swift deleted file mode 100644 index f3eb5b75..00000000 --- a/Xcodes/AppleAPI/Package.swift +++ /dev/null @@ -1,28 +0,0 @@ -// swift-tools-version:5.7 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "AppleAPI", - platforms: [.macOS(.v11)], - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "AppleAPI", - targets: ["AppleAPI"]), - ], - dependencies: [ - .package(url: "https://github.com/xcodesOrg/swift-srp", branch: "main") - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "AppleAPI", - dependencies: [.product(name: "SRP", package: "swift-srp")]), - .testTarget( - name: "AppleAPITests", - dependencies: ["AppleAPI"]), - ] -) diff --git a/Xcodes/AppleAPI/README.md b/Xcodes/AppleAPI/README.md deleted file mode 100644 index 158b3f2f..00000000 --- a/Xcodes/AppleAPI/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# AppleAPI - -A description of this package. diff --git a/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift b/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift deleted file mode 100644 index 1e8e4735..00000000 --- a/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift +++ /dev/null @@ -1,606 +0,0 @@ -import Foundation -import Combine -import SRP -import Crypto -import CommonCrypto - - -public class Client { - private static let authTypes = ["sa", "hsa", "non-sa", "hsa2"] - - public init() {} - - // MARK: - Login - - public func srpLogin(accountName: String, password: String) -> AnyPublisher { - var serviceKey: String! - - let client = SRPClient(configuration: SRPConfiguration(.N2048)) - let clientKeys = client.generateKeys() - let a = clientKeys.public - - return Current.network.dataTask(with: URLRequest.itcServiceKey) - .map(\.data) - .decode(type: ServiceKeyResponse.self, decoder: JSONDecoder()) - .flatMap { serviceKeyResponse -> AnyPublisher<(String, String), Swift.Error> in - serviceKey = serviceKeyResponse.authServiceKey - - // Fixes issue https://github.com/RobotsAndPencils/XcodesApp/issues/360 - // On 2023-02-23, Apple added a custom implementation of hashcash to their auth flow - // Without this addition, Apple ID's would get set to locked - return self.loadHashcash(accountName: accountName, serviceKey: serviceKey) - .map { return (serviceKey, $0)} - .eraseToAnyPublisher() - } - .flatMap { (serviceKey, hashcash) -> AnyPublisher<(String, String, ServerSRPInitResponse), Swift.Error> in - - return Current.network.dataTask(with: URLRequest.SRPInit(serviceKey: serviceKey, a: Data(a.bytes).base64EncodedString(), accountName: accountName)) - .map(\.data) - .decode(type: ServerSRPInitResponse.self, decoder: JSONDecoder()) - .map { return (serviceKey, hashcash, $0) } - .eraseToAnyPublisher() - } - .flatMap { (serviceKey, hashcash, srpInit) -> AnyPublisher in - guard let decodedB = Data(base64Encoded: srpInit.b) else { - return Fail(error: AuthenticationError.srpInvalidPublicKey) - .eraseToAnyPublisher() - } - - guard let decodedSalt = Data(base64Encoded: srpInit.salt) else { - return Fail(error: AuthenticationError.srpInvalidPublicKey) - .eraseToAnyPublisher() - } - - let iterations = srpInit.iteration - - do { - guard let encryptedPassword = self.pbkdf2(password: password, saltData: decodedSalt, keyByteCount: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), rounds: iterations, protocol: srpInit.protocol) else { - return Fail(error: AuthenticationError.srpInvalidPublicKey) - .eraseToAnyPublisher() - } - - let sharedSecret = try client.calculateSharedSecret(password: encryptedPassword, salt: [UInt8](decodedSalt), clientKeys: clientKeys, serverPublicKey: .init([UInt8](decodedB))) - - let m1 = client.calculateClientProof(username: accountName, salt: [UInt8](decodedSalt), clientPublicKey: a, serverPublicKey: .init([UInt8](decodedB)), sharedSecret: .init(sharedSecret.bytes)) - let m2 = client.calculateServerProof(clientPublicKey: a, clientProof: m1, sharedSecret: .init([UInt8](sharedSecret.bytes))) - - return Current.network.dataTask(with: URLRequest.SRPComplete(serviceKey: serviceKey, hashcash: hashcash, accountName: accountName, c: srpInit.c, m1: Data(m1).base64EncodedString(), m2: Data(m2).base64EncodedString())) - .mapError { $0 as Swift.Error } - .eraseToAnyPublisher() - } catch { - return Fail(error: AuthenticationError.srpInvalidPublicKey) - .eraseToAnyPublisher() - } - } - .flatMap { result -> AnyPublisher in - let (data, response) = result - return Just(data) - .decode(type: SignInResponse.self, decoder: JSONDecoder()) - .flatMap { responseBody -> AnyPublisher in - let httpResponse = response as! HTTPURLResponse - - switch httpResponse.statusCode { - case 200: - return Current.network.dataTask(with: URLRequest.olympusSession) - .map { _ in AuthenticationState.authenticated } - .mapError { $0 as Swift.Error } - .eraseToAnyPublisher() - case 401: - return Fail(error: AuthenticationError.invalidUsernameOrPassword(username: accountName)) - .eraseToAnyPublisher() - case 403: - let errorMessage = responseBody.serviceErrors?.first?.description.replacingOccurrences(of: "-20209: ", with: "") ?? "" - return Fail(error: AuthenticationError.accountLocked(errorMessage)) - .eraseToAnyPublisher() - case 409: - return self.handleTwoStepOrFactor(data: data, response: response, serviceKey: serviceKey) - case 412 where Client.authTypes.contains(responseBody.authType ?? ""): - return Fail(error: AuthenticationError.appleIDAndPrivacyAcknowledgementRequired) - .eraseToAnyPublisher() - default: - return Fail(error: AuthenticationError.unexpectedSignInResponse(statusCode: httpResponse.statusCode, - message: responseBody.serviceErrors?.map { $0.description }.joined(separator: ", "))) - .eraseToAnyPublisher() - } - } - .eraseToAnyPublisher() - } - .mapError { $0 as Swift.Error } - .eraseToAnyPublisher() - } - - func loadHashcash(accountName: String, serviceKey: String) -> AnyPublisher { - - Result { - try URLRequest.federate(account: accountName, serviceKey: serviceKey) - } - .publisher - .flatMap { request in - Current.network.dataTask(with: request) - .mapError { $0 as Error } - .tryMap { (data, response) throws -> (String) in - guard let urlResponse = response as? HTTPURLResponse else { - throw AuthenticationError.invalidSession - } - switch urlResponse.statusCode { - case 200..<300: - - let httpResponse = response as! HTTPURLResponse - guard let bitsString = httpResponse.allHeaderFields["X-Apple-HC-Bits"] as? String, let bits = UInt(bitsString) else { - throw AuthenticationError.invalidHashcash - } - guard let challenge = httpResponse.allHeaderFields["X-Apple-HC-Challenge"] as? String else { - throw AuthenticationError.invalidHashcash - } - guard let hashcash = Hashcash().mint(resource: challenge, bits: bits) else { - throw AuthenticationError.invalidHashcash - } - return (hashcash) - case 400, 401: - throw AuthenticationError.invalidHashcash - case let code: - throw AuthenticationError.badStatusCode(statusCode: code, data: data, response: urlResponse) - } - } - } - .eraseToAnyPublisher() - - } - - func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> AnyPublisher { - let httpResponse = response as! HTTPURLResponse - let sessionID = (httpResponse.allHeaderFields["X-Apple-ID-Session-Id"] as! String) - let scnt = (httpResponse.allHeaderFields["scnt"] as! String) - - return Current.network.dataTask(with: URLRequest.authOptions(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)) - .map(\.data) - .decode(type: AuthOptionsResponse.self, decoder: JSONDecoder()) - .flatMap { authOptions -> AnyPublisher in - switch authOptions.kind { - case .twoStep: - return Fail(error: AuthenticationError.accountUsesTwoStepAuthentication) - .eraseToAnyPublisher() - case .twoFactor, .securityKey: - return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions) - .eraseToAnyPublisher() - case .unknown: - let possibleResponseString = String(data: data, encoding: .utf8) - return Fail(error: AuthenticationError.accountUsesUnknownAuthenticationKind(possibleResponseString)) - .eraseToAnyPublisher() - } - } - .eraseToAnyPublisher() - } - - func handleTwoFactor(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> AnyPublisher { - let option: TwoFactorOption - - // SMS was sent automatically - if authOptions.smsAutomaticallySent { - option = .smsSent(authOptions.trustedPhoneNumbers!.first!) - // SMS wasn't sent automatically because user needs to choose a phone to send to - } else if authOptions.canFallBackToSMS { - option = .smsPendingChoice - // Code is shown on trusted devices - } else if authOptions.fsaChallenge != nil { - option = .securityKey - // User needs to use a physical security key to respond to the challenge - } else { - option = .codeSent - } - - let sessionData = AppleSessionData(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) - return Just(AuthenticationState.waitingForSecondFactor(option, authOptions, sessionData)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - // MARK: - Continue 2FA - - public func requestSMSSecurityCode(to trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber, authOptions: AuthOptionsResponse, sessionData: AppleSessionData) -> AnyPublisher { - Result { - try URLRequest.requestSecurityCode(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt, trustedPhoneID: trustedPhoneNumber.id) - } - .publisher - .flatMap { request in - Current.network.dataTask(with: request) - .mapError { $0 as Error } - } - .map { _ in AuthenticationState.waitingForSecondFactor(.smsSent(trustedPhoneNumber), authOptions, sessionData) } - .eraseToAnyPublisher() - } - - public func submitSecurityCode(_ code: SecurityCode, sessionData: AppleSessionData) -> AnyPublisher { - Result { - try URLRequest.submitSecurityCode(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt, code: code) - } - .publisher - .flatMap { request in - Current.network.dataTask(with: request) - .mapError { $0 as Error } - .tryMap { (data, response) throws -> (Data, URLResponse) in - guard let urlResponse = response as? HTTPURLResponse else { return (data, response) } - switch urlResponse.statusCode { - case 200..<300: - return (data, urlResponse) - case 400, 401: - throw AuthenticationError.incorrectSecurityCode - case 412: - throw AuthenticationError.appleIDAndPrivacyAcknowledgementRequired - case let code: - throw AuthenticationError.badStatusCode(statusCode: code, data: data, response: urlResponse) - } - } - .flatMap { (data, response) -> AnyPublisher in - self.updateSession(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt) - } - } - .eraseToAnyPublisher() - } - - public func submitChallenge(response: Data, sessionData: AppleSessionData) -> AnyPublisher { - Result { - URLRequest.respondToChallenge(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt, response: response) - } - .publisher - .flatMap { request in - Current.network.dataTask(with: request) - .mapError { $0 as Error } - .tryMap { (data, response) throws -> (Data, URLResponse) in - guard let urlResponse = response as? HTTPURLResponse else { return (data, response) } - switch urlResponse.statusCode { - case 200..<300: - return (data, urlResponse) - case 400, 401: - throw AuthenticationError.incorrectSecurityCode - case 412: - throw AuthenticationError.appleIDAndPrivacyAcknowledgementRequired - case let code: - throw AuthenticationError.badStatusCode(statusCode: code, data: data, response: urlResponse) - } - } - .flatMap { (data, response) -> AnyPublisher in - self.updateSession(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt) - } - }.eraseToAnyPublisher() - } - - // MARK: - Session - - /// Use the olympus session endpoint to see if the existing session is still valid - public func validateSession() -> AnyPublisher { - return Current.network.dataTask(with: URLRequest.olympusSession) - .tryMap { result -> Data in - let httpResponse = result.response as! HTTPURLResponse - if httpResponse.statusCode == 401 { - throw AuthenticationError.notAuthorized - } - - return result.data - } - .decode(type: AppleSession.self, decoder: JSONDecoder()) - .tryMap { session in - // A user that is a non-paid Apple Developer will have a provider == nil - // Those users can still download Xcode. - // Non Apple Developers will get caught in the download as invalid -// if session.provider == nil { -// throw AuthenticationError.notDeveloperAppleId -// } - } - .eraseToAnyPublisher() - } - - func updateSession(serviceKey: String, sessionID: String, scnt: String) -> AnyPublisher { - return Current.network.dataTask(with: URLRequest.trust(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)) - .flatMap { (data, response) in - Current.network.dataTask(with: URLRequest.olympusSession) - .map { _ in AuthenticationState.authenticated } - } - .mapError { $0 as Error } - .eraseToAnyPublisher() - } - - func sha256(data : Data) -> Data { - var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) - data.withUnsafeBytes { - _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash) - } - return Data(hash) - } - - private func pbkdf2(password: String, saltData: Data, keyByteCount: Int, prf: CCPseudoRandomAlgorithm, rounds: Int, protocol srpProtocol: SRPProtocol) -> Data? { - guard let passwordData = password.data(using: .utf8) else { return nil } - let hashedPasswordDataRaw = sha256(data: passwordData) - let hashedPasswordData = switch srpProtocol { - case .s2k: hashedPasswordDataRaw - // the legacy s2k_fo protocol requires hex-encoding the digest before performing PBKDF2. - case .s2k_fo: Data(hashedPasswordDataRaw.hexEncodedString().lowercased().utf8) - } - - var derivedKeyData = Data(repeating: 0, count: keyByteCount) - let derivedCount = derivedKeyData.count - let derivationStatus: Int32 = derivedKeyData.withUnsafeMutableBytes { derivedKeyBytes in - let keyBuffer: UnsafeMutablePointer = - derivedKeyBytes.baseAddress!.assumingMemoryBound(to: UInt8.self) - return saltData.withUnsafeBytes { saltBytes -> Int32 in - let saltBuffer: UnsafePointer = saltBytes.baseAddress!.assumingMemoryBound(to: UInt8.self) - return hashedPasswordData.withUnsafeBytes { hashedPasswordBytes -> Int32 in - let passwordBuffer: UnsafePointer = hashedPasswordBytes.baseAddress!.assumingMemoryBound(to: UInt8.self) - return CCKeyDerivationPBKDF( - CCPBKDFAlgorithm(kCCPBKDF2), - passwordBuffer, - hashedPasswordData.count, - saltBuffer, - saltData.count, - prf, - UInt32(rounds), - keyBuffer, - derivedCount) - } - } - } - return derivationStatus == kCCSuccess ? derivedKeyData : nil - } - -} - -// MARK: - Types - -public enum AuthenticationState: Equatable { - case unauthenticated - case waitingForSecondFactor(TwoFactorOption, AuthOptionsResponse, AppleSessionData) - case authenticated - case notAppleDeveloper -} - -public enum AuthenticationError: Swift.Error, LocalizedError, Equatable { - case invalidSession - case invalidHashcash - case invalidUsernameOrPassword(username: String) - case incorrectSecurityCode - case unexpectedSignInResponse(statusCode: Int, message: String?) - case appleIDAndPrivacyAcknowledgementRequired - case accountUsesTwoStepAuthentication - case accountUsesUnknownAuthenticationKind(String?) - case accountLocked(String) - case badStatusCode(statusCode: Int, data: Data, response: HTTPURLResponse) - case notDeveloperAppleId - case notAuthorized - case invalidResult(resultString: String?) - case srpInvalidPublicKey - - public var errorDescription: String? { - switch self { - case .invalidSession: - return "Your authentication session is invalid. Try signing in again." - case .invalidHashcash: - return "Could not create a hashcash for the session." - case .invalidUsernameOrPassword: - return "Invalid username and password combination." - case .incorrectSecurityCode: - return "The code that was entered is incorrect." - case let .unexpectedSignInResponse(statusCode, message): - return """ - Received an unexpected sign in response. If you continue to have problems, please submit a bug report in the Help menu and include the following information: - - Status code: \(statusCode) - \(message != nil ? ("Message: " + message!) : "") - """ - case .appleIDAndPrivacyAcknowledgementRequired: - return "You must sign in to https://appstoreconnect.apple.com and acknowledge the Apple ID & Privacy agreement." - case .accountUsesTwoStepAuthentication: - return "Received a response from Apple that indicates this account has two-step authentication enabled. xcodes currently only supports the newer two-factor authentication, though. Please consider upgrading to two-factor authentication, or explain why this isn't an option for you by making a new feature request in the Help menu." - case .accountUsesUnknownAuthenticationKind: - return "Received a response from Apple that indicates this account has two-step or two-factor authentication enabled, but xcodes is unsure how to handle this response. If you continue to have problems, please submit a bug report in the Help menu." - case let .accountLocked(message): - return message - case let .badStatusCode(statusCode, _, _): - return "Received an unexpected status code: \(statusCode). If you continue to have problems, please submit a bug report in the Help menu." - case .notDeveloperAppleId: - return "You are not registered as an Apple Developer. Please visit Apple Developer Registration. https://developer.apple.com/register/" - case .notAuthorized: - return "You are not authorized. Please Sign in with your Apple ID first." - case let .invalidResult(resultString): - return resultString ?? "If you continue to have problems, please submit a bug report in the Help menu." - case .srpInvalidPublicKey: - return "Invalid Key" - } - } -} - -public struct AppleSessionData: Equatable, Identifiable { - public let serviceKey: String - public let sessionID: String - public let scnt: String - - public var id: String { sessionID } - - public init(serviceKey: String, sessionID: String, scnt: String) { - self.serviceKey = serviceKey - self.sessionID = sessionID - self.scnt = scnt - } -} - -struct ServiceKeyResponse: Decodable { - let authServiceKey: String -} - -struct SignInResponse: Decodable { - let authType: String? - let serviceErrors: [ServiceError]? - - struct ServiceError: Decodable, CustomStringConvertible { - let code: String - let message: String - - var description: String { - return "\(code): \(message)" - } - } -} - -public enum TwoFactorOption: Equatable { - case smsSent(AuthOptionsResponse.TrustedPhoneNumber) - case codeSent - case smsPendingChoice - case securityKey -} - -public struct FSAChallenge: Equatable, Decodable { - public let challenge: String - public let keyHandles: [String] - public let allowedCredentials: String -} - -public struct AuthOptionsResponse: Equatable, Decodable { - public let trustedPhoneNumbers: [TrustedPhoneNumber]? - public let trustedDevices: [TrustedDevice]? - public let securityCode: SecurityCodeInfo? - public let noTrustedDevices: Bool? - public let serviceErrors: [ServiceError]? - public let fsaChallenge: FSAChallenge? - - public init( - trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]?, - trustedDevices: [AuthOptionsResponse.TrustedDevice]?, - securityCode: AuthOptionsResponse.SecurityCodeInfo, - noTrustedDevices: Bool? = nil, - serviceErrors: [ServiceError]? = nil, - fsaChallenge: FSAChallenge? = nil - ) { - self.trustedPhoneNumbers = trustedPhoneNumbers - self.trustedDevices = trustedDevices - self.securityCode = securityCode - self.noTrustedDevices = noTrustedDevices - self.serviceErrors = serviceErrors - self.fsaChallenge = fsaChallenge - } - - public var kind: Kind { - if trustedDevices != nil { - return .twoStep - } else if trustedPhoneNumbers != nil { - return .twoFactor - } else if fsaChallenge != nil { - return .securityKey - } else { - return .unknown - } - } - - // One time with a new testing account I had a response where noTrustedDevices was nil, but the account didn't have any trusted devices. - // This should have been a situation where an SMS security code was sent automatically. - // This resolved itself either after some time passed, or by signing into appleid.apple.com with the account. - // Not sure if it's worth explicitly handling this case or if it'll be really rare. - public var canFallBackToSMS: Bool { - noTrustedDevices == true - } - - public var smsAutomaticallySent: Bool { - trustedPhoneNumbers?.count == 1 && canFallBackToSMS - } - - public struct TrustedPhoneNumber: Equatable, Decodable, Identifiable { - public let id: Int - public let numberWithDialCode: String - - public init(id: Int, numberWithDialCode: String) { - self.id = id - self.numberWithDialCode = numberWithDialCode - } - } - - public struct TrustedDevice: Equatable, Decodable { - public let id: String - public let name: String - public let modelName: String - - public init(id: String, name: String, modelName: String) { - self.id = id - self.name = name - self.modelName = modelName - } - } - - public struct SecurityCodeInfo: Equatable, Decodable { - public let length: Int - public let tooManyCodesSent: Bool - public let tooManyCodesValidated: Bool - public let securityCodeLocked: Bool - public let securityCodeCooldown: Bool - - public init( - length: Int, - tooManyCodesSent: Bool = false, - tooManyCodesValidated: Bool = false, - securityCodeLocked: Bool = false, - securityCodeCooldown: Bool = false - ) { - self.length = length - self.tooManyCodesSent = tooManyCodesSent - self.tooManyCodesValidated = tooManyCodesValidated - self.securityCodeLocked = securityCodeLocked - self.securityCodeCooldown = securityCodeCooldown - } - } - - public enum Kind: Equatable { - case twoStep, twoFactor, securityKey, unknown - } -} - -public struct ServiceError: Decodable, Equatable { - let code: String - let message: String -} - -public enum SecurityCode { - case device(code: String) - case sms(code: String, phoneNumberId: Int) - - var urlPathComponent: String { - switch self { - case .device: return "trusteddevice" - case .sms: return "phone" - } - } -} - -/// Object returned from olympus/v1/session -/// Used to check Provider, and show name -/// If Provider is nil, we can assume the Apple User is NOT an Apple Developer and can't download Xcode. -public struct AppleSession: Decodable, Equatable { - public let user: AppleUser - public let provider: AppleProvider? -} - -public struct AppleProvider: Decodable, Equatable { - public let providerId: Int - public let name: String -} - -public struct AppleUser: Decodable, Equatable { - public let fullName: String -} - -public struct ServerSRPInitResponse: Decodable { - let iteration: Int - let salt: String - let b: String - let c: String - let `protocol`: SRPProtocol -} - - - -extension String { - func base64ToU8Array() -> Data { - return Data(base64Encoded: self) ?? Data() - } -} -extension Data { - func hexEncodedString() -> String { - return map { String(format: "%02hhx", $0) }.joined() - } -} diff --git a/Xcodes/AppleAPI/Sources/AppleAPI/Environment.swift b/Xcodes/AppleAPI/Sources/AppleAPI/Environment.swift deleted file mode 100644 index 53c5fc50..00000000 --- a/Xcodes/AppleAPI/Sources/AppleAPI/Environment.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation -import Combine - -/** - Lightweight dependency injection using global mutable state :P - - - SeeAlso: https://www.pointfree.co/episodes/ep16-dependency-injection-made-easy - - SeeAlso: https://www.pointfree.co/episodes/ep18-dependency-injection-made-comfortable - - SeeAlso: https://vimeo.com/291588126 - */ -public struct Environment { - public var network = Network() -} - -public var Current = Environment() - -public struct Network { - public var session = URLSession.shared - - public var dataTask: (URLRequest) -> URLSession.DataTaskPublisher = { Current.network.session.dataTaskPublisher(for: $0) } - public func dataTask(with request: URLRequest) -> URLSession.DataTaskPublisher { - dataTask(request) - } -} diff --git a/Xcodes/AppleAPI/Sources/AppleAPI/Hashcash.swift b/Xcodes/AppleAPI/Sources/AppleAPI/Hashcash.swift deleted file mode 100644 index 135f1ea5..00000000 --- a/Xcodes/AppleAPI/Sources/AppleAPI/Hashcash.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// Hashcash.swift -// -// -// Created by Matt Kiazyk on 2023-02-23. -// - -import Foundation -import CryptoKit -import CommonCrypto - -/* -# This App Store Connect hashcash spec was generously donated by... - # - # __ _ - # __ _ _ __ _ __ / _|(_) __ _ _ _ _ __ ___ ___ - # / _` || '_ \ | '_ \ | |_ | | / _` || | | || '__|/ _ \/ __| - # | (_| || |_) || |_) || _|| || (_| || |_| || | | __/\__ \ - # \__,_|| .__/ | .__/ |_| |_| \__, | \__,_||_| \___||___/ - # |_| |_| |___/ - # - # -*/ -public struct Hashcash { - /// A function to returned a minted hash, using a bit and resource string - /// - /** - X-APPLE-HC: 1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373 - ^ ^ ^ ^ ^ - | | | | +-- Counter - | | | +-- Resource - | | +-- Date YYMMDD[hhmm[ss]] - | +-- Bits (number of leading zeros) - +-- Version - - We can't use an off-the-shelf Hashcash because Apple's implementation is not quite the same as the spec/convention. - 1. The spec calls for a nonce called "Rand" to be inserted between the Ext and Counter. They don't do that at all. - 2. The Counter conventionally encoded as base-64 but Apple just uses the decimal number's string representation. - - Iterate from Counter=0 to Counter=N finding an N that makes the SHA1(X-APPLE-HC) lead with Bits leading zero bits - We get the "Resource" from the X-Apple-HC-Challenge header and Bits from X-Apple-HC-Bits - */ - /// - Parameters: - /// - resource: a string to be used for minting - /// - bits: grabbed from `X-Apple-HC-Bits` header - /// - date: Default uses Date() otherwise used for testing to check. - /// - Returns: A String hash to use in `X-Apple-HC` header on /signin - public func mint(resource: String, - bits: UInt = 10, - date: String? = nil) -> String? { - - let ver = "1" - - var ts: String - if let date = date { - ts = date - } else { - let formatter = DateFormatter() - formatter.dateFormat = "yyMMddHHmmss" - ts = formatter.string(from: Date()) - } - - let challenge = "\(ver):\(bits):\(ts):\(resource):" - - var counter = 0 - - while true { - guard let digest = ("\(challenge):\(counter)").sha1 else { - print("ERROR: Can't generate SHA1 digest") - return nil - } - - if digest == bits { - return "\(challenge):\(counter)" - } - counter += 1 - } - } -} - -extension String { - var sha1: Int? { - - let data = Data(self.utf8) - var digest = [UInt8](repeating: 0, count:Int(CC_SHA1_DIGEST_LENGTH)) - data.withUnsafeBytes { - _ = CC_SHA1($0.baseAddress, CC_LONG(data.count), &digest) - } - let bigEndianValue = digest.withUnsafeBufferPointer { - ($0.baseAddress!.withMemoryRebound(to: UInt32.self, capacity: 1) { $0 }) - }.pointee - let value = UInt32(bigEndian: bigEndianValue) - return value.leadingZeroBitCount - } -} - diff --git a/Xcodes/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift b/Xcodes/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift deleted file mode 100644 index 14601503..00000000 --- a/Xcodes/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift +++ /dev/null @@ -1,204 +0,0 @@ -import Foundation - -public extension URL { - static let itcServiceKey = URL(string: "https://appstoreconnect.apple.com/olympus/v1/app/config?hostname=itunesconnect.apple.com")! - static let signIn = URL(string: "https://idmsa.apple.com/appleauth/auth/signin")! - static let authOptions = URL(string: "https://idmsa.apple.com/appleauth/auth")! - static let requestSecurityCode = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/phone")! - static func submitSecurityCode(_ code: SecurityCode) -> URL { URL(string: "https://idmsa.apple.com/appleauth/auth/verify/\(code.urlPathComponent)/securitycode")! } - static let trust = URL(string: "https://idmsa.apple.com/appleauth/auth/2sv/trust")! - static let federate = URL(string: "https://idmsa.apple.com/appleauth/auth/federate")! - static let olympusSession = URL(string: "https://appstoreconnect.apple.com/olympus/v1/session")! - static let keyAuth = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/security/key")! - - static let srpInit = URL(string: "https://idmsa.apple.com/appleauth/auth/signin/init")! - static let srpComplete = URL(string: "https://idmsa.apple.com/appleauth/auth/signin/complete?isRememberMeEnabled=false")! - -} - -public extension URLRequest { - static var itcServiceKey: URLRequest { - return URLRequest(url: .itcServiceKey) - } - - static func signIn(serviceKey: String, accountName: String, password: String, hashcash: String) -> URLRequest { - struct Body: Encodable { - let accountName: String - let password: String - let rememberMe = true - } - - var request = URLRequest(url: .signIn) - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["Content-Type"] = "application/json" - request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest" - request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey - request.allHTTPHeaderFields?["X-Apple-HC"] = hashcash - request.allHTTPHeaderFields?["Accept"] = "application/json, text/javascript" - request.httpMethod = "POST" - request.httpBody = try! JSONEncoder().encode(Body(accountName: accountName, password: password)) - return request - } - - static func authOptions(serviceKey: String, sessionID: String, scnt: String) -> URLRequest { - var request = URLRequest(url: .authOptions) - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID - request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey - request.allHTTPHeaderFields?["scnt"] = scnt - request.allHTTPHeaderFields?["accept"] = "application/json" - return request - } - - static func requestSecurityCode(serviceKey: String, sessionID: String, scnt: String, trustedPhoneID: Int) throws -> URLRequest { - struct Body: Encodable { - let phoneNumber: PhoneNumber - let mode = "sms" - - struct PhoneNumber: Encodable { - let id: Int - } - } - - var request = URLRequest(url: .requestSecurityCode) - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["Content-Type"] = "application/json" - request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID - request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey - request.allHTTPHeaderFields?["scnt"] = scnt - request.allHTTPHeaderFields?["accept"] = "application/json" - request.httpMethod = "PUT" - request.httpBody = try JSONEncoder().encode(Body(phoneNumber: .init(id: trustedPhoneID))) - return request - } - - static func submitSecurityCode(serviceKey: String, sessionID: String, scnt: String, code: SecurityCode) throws -> URLRequest { - struct DeviceSecurityCodeRequest: Encodable { - let securityCode: SecurityCode - - struct SecurityCode: Encodable { - let code: String - } - } - - struct SMSSecurityCodeRequest: Encodable { - let securityCode: SecurityCode - let phoneNumber: PhoneNumber - let mode = "sms" - - struct SecurityCode: Encodable { - let code: String - } - struct PhoneNumber: Encodable { - let id: Int - } - } - - var request = URLRequest(url: .submitSecurityCode(code)) - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID - request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey - request.allHTTPHeaderFields?["scnt"] = scnt - request.allHTTPHeaderFields?["Accept"] = "application/json" - request.allHTTPHeaderFields?["Content-Type"] = "application/json" - request.httpMethod = "POST" - switch code { - case .device(let code): - request.httpBody = try JSONEncoder().encode(DeviceSecurityCodeRequest(securityCode: .init(code: code))) - case .sms(let code, let phoneNumberId): - request.httpBody = try JSONEncoder().encode(SMSSecurityCodeRequest(securityCode: .init(code: code), phoneNumber: .init(id: phoneNumberId))) - } - return request - } - - static func respondToChallenge(serviceKey: String, sessionID: String, scnt: String, response: Data) -> URLRequest { - var request = URLRequest(url: .keyAuth) - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID - request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey - request.allHTTPHeaderFields?["scnt"] = scnt - request.allHTTPHeaderFields?["Accept"] = "application/json" - request.allHTTPHeaderFields?["Content-Type"] = "application/json" - request.httpMethod = "POST" - request.httpBody = response - return request - } - - static func trust(serviceKey: String, sessionID: String, scnt: String) -> URLRequest { - var request = URLRequest(url: .trust) - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID - request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey - request.allHTTPHeaderFields?["scnt"] = scnt - request.allHTTPHeaderFields?["Accept"] = "application/json" - return request - } - - static var olympusSession: URLRequest { - return URLRequest(url: .olympusSession) - } - - static func federate(account: String, serviceKey: String) throws -> URLRequest { - struct FederateRequest: Encodable { - let accountName: String - let rememberMe: Bool - } - var request = URLRequest(url: .signIn) - request.allHTTPHeaderFields?["Accept"] = "application/json" - request.allHTTPHeaderFields?["Content-Type"] = "application/json" - request.httpMethod = "GET" - -// let encoder = JSONEncoder() -// encoder.outputFormatting = .withoutEscapingSlashes -// request.httpBody = try encoder.encode(FederateRequest(accountName: account, rememberMe: true)) - - return request - } - - static func SRPInit(serviceKey: String, a: String, accountName: String) -> URLRequest { - struct ServerSRPInitRequest: Encodable { - public let a: String - public let accountName: String - public let protocols: [SRPProtocol] - } - - var request = URLRequest(url: .srpInit) - request.httpMethod = "POST" - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["Accept"] = "application/json" - request.allHTTPHeaderFields?["Content-Type"] = "application/json" - request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest" - request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey - - request.httpBody = try? JSONEncoder().encode(ServerSRPInitRequest(a: a, accountName: accountName, protocols: [.s2k, .s2k_fo])) - return request - } - - static func SRPComplete(serviceKey: String, hashcash: String, accountName: String, c: String, m1: String, m2: String) -> URLRequest { - struct ServerSRPCompleteRequest: Encodable { - let accountName: String - let c: String - let m1: String - let m2: String - let rememberMe: Bool - } - - var request = URLRequest(url: .srpComplete) - request.httpMethod = "POST" - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["Accept"] = "application/json" - request.allHTTPHeaderFields?["Content-Type"] = "application/json" - request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest" - request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey - request.allHTTPHeaderFields?["X-Apple-HC"] = hashcash - - request.httpBody = try? JSONEncoder().encode(ServerSRPCompleteRequest(accountName: accountName, c: c, m1: m1, m2: m2, rememberMe: false)) - return request - } -} - -public enum SRPProtocol: String, Codable { - case s2k, s2k_fo -} - - diff --git a/Xcodes/AppleAPI/Tests/AppleAPITests/AppleAPITests.swift b/Xcodes/AppleAPI/Tests/AppleAPITests/AppleAPITests.swift deleted file mode 100644 index ecb93e7e..00000000 --- a/Xcodes/AppleAPI/Tests/AppleAPITests/AppleAPITests.swift +++ /dev/null @@ -1,27 +0,0 @@ -import XCTest -@testable import AppleAPI - -final class AppleAPITests: XCTestCase { - - func testValidHashCashMint() { - let bits: UInt = 11 - let resource = "4d74fb15eb23f465f1f6fcbf534e5877" - let testDate = "20230223170600" - - let stamp = Hashcash().mint(resource: resource, bits: bits, date: testDate) - XCTAssertEqual(stamp, "1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373") - } - func testValidHashCashMint2() { - let bits: UInt = 10 - let resource = "bb63edf88d2f9c39f23eb4d6f0281158" - let testDate = "20230224001754" - - let stamp = Hashcash().mint(resource: resource, bits: bits, date: testDate) - XCTAssertEqual(stamp, "1:10:20230224001754:bb63edf88d2f9c39f23eb4d6f0281158::866") - } - - static var allTests = [ - ("testValidHashCashMint", testValidHashCashMint), - ("testValidHashCashMint2", testValidHashCashMint2), - ] -} diff --git a/Xcodes/AppleAPI/Tests/AppleAPITests/XCTestManifests.swift b/Xcodes/AppleAPI/Tests/AppleAPITests/XCTestManifests.swift deleted file mode 100644 index 97d152cf..00000000 --- a/Xcodes/AppleAPI/Tests/AppleAPITests/XCTestManifests.swift +++ /dev/null @@ -1,9 +0,0 @@ -import XCTest - -#if !canImport(ObjectiveC) -public func allTests() -> [XCTestCaseEntry] { - return [ - testCase(AppleAPITests.allTests), - ] -} -#endif diff --git a/Xcodes/AppleAPI/Tests/LinuxMain.swift b/Xcodes/AppleAPI/Tests/LinuxMain.swift deleted file mode 100644 index 9c8128cf..00000000 --- a/Xcodes/AppleAPI/Tests/LinuxMain.swift +++ /dev/null @@ -1,7 +0,0 @@ -import XCTest - -import AppleAPITests - -var tests = [XCTestCaseEntry]() -tests += AppleAPITests.allTests() -XCTMain(tests) diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index 5e2a0742..a116556e 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -1,530 +1,471 @@ -import Combine import Foundation -import Path -import AppleAPI -import Version +@preconcurrency import Path +@preconcurrency import Version import LegibleError import os.log import DockProgress import XcodesKit +import XcodesLoginKit /// Downloads and installs Xcodes extension AppState { - + // check to see if we should auto install for the user public func autoInstallIfNeeded() { guard let storageValue = Current.defaults.get(forKey: "autoInstallation") as? Int, let autoInstallType = AutoInstallationType(rawValue: storageValue) else { return } - if autoInstallType == .none { return } - - // get newest xcode version - guard let newestXcode = allXcodes.first, newestXcode.installState == .notInstalled else { - Logger.appState.info("User has latest Xcode already installed") + let decision = XcodeAutoInstallService().decision( + autoInstallationType: autoInstallType, + xcodes: allXcodes.map(\.listItem) + ) + + switch decision { + case .disabled: return - } - - if autoInstallType == .newestBeta { + case .alreadyInstalled: + Logger.appState.info("User has latest Xcode already installed") + case let .installNewestBeta(id): Logger.appState.info("Auto installing newest Xcode Beta") - // install it, as user doesn't have it installed and it's either latest beta or latest release - checkMinVersionAndInstall(id: newestXcode.id) - } else if autoInstallType == .newestVersion && newestXcode.version.isNotPrerelease { + checkMinVersionAndInstall(id: id) + case let .installNewestVersion(id): Logger.appState.info("Auto installing newest Xcode") - checkMinVersionAndInstall(id: newestXcode.id) - } else { + checkMinVersionAndInstall(id: id) + case .noNewVersion: Logger.appState.info("No new Xcodes version found to auto install") } } - - public func install(_ installationType: InstallationType, downloader: Downloader) -> AnyPublisher { - install(installationType, downloader: downloader, attemptNumber: 0) - .map { _ in Void() } - .eraseToAnyPublisher() - } - - private func install(_ installationType: InstallationType, downloader: Downloader, attemptNumber: Int) -> AnyPublisher { - - Logger.appState.info("Using \(downloader) downloader") - - setupDockProgress() - - return validateSession() - .flatMap { _ in - self.getXcodeArchive(installationType, downloader: downloader) - } - .flatMap { xcode, url -> AnyPublisher in - self.installArchivedXcode(xcode, at: url) - } - .catch { error -> AnyPublisher in - self.resetDockProgressTracking() - - switch error { - case InstallationError.damagedXIP(let damagedXIPURL): - guard attemptNumber < 1 else { return Fail(error: error).eraseToAnyPublisher() } - - switch installationType { - case .version: - // If the XIP was just downloaded, remove it and try to recover. - do { - Logger.appState.error("\(error.legibleLocalizedDescription)") - Logger.appState.info("Removing damaged XIP and re-attempting installation.") - try Current.files.removeItem(at: damagedXIPURL) - return self.install(installationType, downloader: downloader, attemptNumber: attemptNumber + 1) - .eraseToAnyPublisher() - } catch { - return Fail(error: error) - .eraseToAnyPublisher() - } - } - default: - return Fail(error: error) - .eraseToAnyPublisher() + + func installAsync(_ installationType: InstallationType, downloader: Downloader, attemptNumber: Int) async throws -> InstalledXcode { + try await xcodeInstallRetryService.install( + attemptNumber: attemptNumber, + shouldRetryAfterDamagedArchive: installationType.shouldRetryAfterDamagedArchive, + attempt: { @MainActor _ in + Logger.appState.info("Using \(downloader) downloader") + setupDockProgress() + + try await validateSessionAsync() + let (xcode, url) = try await getXcodeArchiveAsync(installationType, downloader: downloader) + try Task.checkCancellation() + let installedXcode = try await installArchivedXcodeAsync(xcode, at: url) + + guard let index = allXcodes.firstIndex(where: { $0.version.isEquivalent(to: installedXcode.version) }) else { + return installedXcode } + allXcodes[index].installState = .installed(installedXcode.path) + return installedXcode + }, + onAttemptFailed: { @MainActor _ in + resetDockProgressTracking() + }, + onRetryDamagedArchive: { error, _ in + Logger.appState.error("\(error.legibleLocalizedDescription)") + Logger.appState.info("Removing damaged XIP and re-attempting installation.") } - .handleEvents(receiveOutput: { installedXcode in - DispatchQueue.main.async { - guard let index = self.allXcodes.firstIndex(where: { $0.version.isEquivalent(to: installedXcode.version) }) else { return } - self.allXcodes[index].installState = .installed(installedXcode.path) - } - }) - .eraseToAnyPublisher() + ) + } + + private var xcodeInstallRetryService: XcodeInstallRetryService { + XcodeInstallRetryService( + damagedArchiveURL: { error in + guard case InstallationError.damagedXIP(let url) = error else { return nil } + return url + }, + removeDamagedArchive: { url in + try Current.files.removeItem(at: url) + } + ) } - - private func getXcodeArchive(_ installationType: InstallationType, downloader: Downloader) -> AnyPublisher<(AvailableXcode, URL), Error> { + + private func getXcodeArchiveAsync(_ installationType: InstallationType, downloader: Downloader) async throws -> (AvailableXcode, URL) { switch installationType { case .version(let availableXcode): - if let installedXcode = Current.files.installedXcodes(Path.installDirectory).first(where: { $0.version.isEquivalent(to: availableXcode.version) }) { - return Fail(error: InstallationError.versionAlreadyInstalled(installedXcode)) - .eraseToAnyPublisher() + let resolution = try mapInstallResolutionError { + try XcodeInstallResolutionService().resolve( + .availableXcode(availableXcode), + availableXcodes: [], + installedXcodes: Current.files.installedXcodes(Path.installDirectory), + willInstall: true + ) } - - return downloadXcode(availableXcode: availableXcode, downloader: downloader) + + return try await archive(for: resolution, downloader: downloader) } } - private func downloadXcode(availableXcode: AvailableXcode, downloader: Downloader) -> AnyPublisher<(AvailableXcode, URL), Error> { - self.downloadOrUseExistingArchive(for: availableXcode, downloader: downloader, progressChanged: { [unowned self] progress in - DispatchQueue.main.async { - self.setInstallationStep(of: availableXcode.version, to: .downloading(progress: progress)) - self.overallProgress.addChild(progress, withPendingUnitCount: AppState.totalProgressUnits - AppState.unxipProgressWeight) - } - }) - .map { return (availableXcode, $0) } - .eraseToAnyPublisher() - } - - public func downloadOrUseExistingArchive(for availableXcode: AvailableXcode, downloader: Downloader, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher { - // Check to see if the archive is in the expected path in case it was downloaded but failed to install - let expectedArchivePath = Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).\(availableXcode.filename.suffix(fromLast: "."))" - // aria2 downloads directly to the destination (instead of into /tmp first) so we need to make sure that the download isn't incomplete - let aria2DownloadMetadataPath = expectedArchivePath.parent/(expectedArchivePath.basename() + ".aria2") - var aria2DownloadIsIncomplete = false - if case .aria2 = downloader, aria2DownloadMetadataPath.exists { - aria2DownloadIsIncomplete = true + private func archive(for resolution: XcodeInstallResolution, downloader: Downloader) async throws -> (AvailableXcode, URL) { + switch resolution { + case let .download(_, .some(availableXcode)): + return try await downloadXcodeAsync(availableXcode: availableXcode, downloader: downloader) + case .download: + throw XcodesKitError("Expected Xcode install resolution to include a selected Xcode") + case let .localArchive(xcode, url): + return (xcode, url) } - if Current.files.fileExistsAtPath(expectedArchivePath.string), aria2DownloadIsIncomplete == false { - Logger.appState.info("Found existing archive that will be used for installation at \(expectedArchivePath).") - return Just(expectedArchivePath.url) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + } + + private func mapInstallResolutionError(_ body: () throws -> T) throws -> T { + do { + return try body() + } catch let error as XcodeInstallResolutionError { + switch error { + case let .invalidVersion(version): + throw InstallationError.invalidVersion(version) + case .noReleaseVersionAvailable: + throw InstallationError.noNonPrereleaseVersionAvailable + case .noPrereleaseVersionAvailable: + throw InstallationError.noPrereleaseVersionAvailable + case let .versionAlreadyInstalled(installedXcode): + throw InstallationError.versionAlreadyInstalled(installedXcode) + } } - else { - let destination = Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).\(availableXcode.filename.suffix(fromLast: "."))" - switch downloader { - case .aria2: - let aria2Path = Path(url: Bundle.main.url(forAuxiliaryExecutable: "aria2c")!)! - return downloadXcodeWithAria2( - availableXcode, - to: destination, - aria2Path: aria2Path, - progressChanged: progressChanged - ) - case .urlSession: - return downloadXcodeWithURLSession( - availableXcode, - to: destination, + } + + private func downloadXcodeAsync(availableXcode: AvailableXcode, downloader: Downloader) async throws -> (AvailableXcode, URL) { + let expectedInstallationTaskID = installationTaskIDs[availableXcode.xcodeID] + let url = try await downloadOrUseExistingArchiveAsync(for: availableXcode, downloader: downloader, progressChanged: { [unowned self] progress in + Task { @MainActor in + if let expectedInstallationTaskID, self.installationTaskIDs[availableXcode.xcodeID] != expectedInstallationTaskID { + return + } + self.setInstallationStep(of: availableXcode.version, to: .downloading(progress: progress)) + self.addDockProgressChildIfNeeded(progress, withPendingUnitCount: AppState.totalProgressUnits - AppState.unxipProgressWeight) + } + }) + + return (availableXcode, url) + } + + public func downloadOrUseExistingArchiveAsync(for availableXcode: AvailableXcode, downloader: Downloader, progressChanged: @escaping @Sendable (Progress) -> Void) async throws -> URL { + let url = try await archiveService().archiveURL( + for: XcodeArchive(availableXcode), + downloader: downloader, + progressChanged: progressChanged + ) + Logger.appState.info("Using Xcode archive at \(url.path).") + return url + } + + private func archiveService() -> XcodeArchiveService { + XcodeArchiveService( + applicationSupportPath: Path.xcodesApplicationSupport, + fileExists: { Current.files.fileExistsAtPath($0.string) }, + download: { archive, destination, downloader, progressChanged in + try await self.archiveDownloadStrategyService.download( + archive: archive, + destination: destination, + downloader: downloader, + applicationSupportPath: Path.xcodesApplicationSupport, progressChanged: progressChanged ) } - } + ) } - - public func downloadXcodeWithAria2(_ availableXcode: AvailableXcode, to destination: Path, aria2Path: Path, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher { - let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies(for: availableXcode.url) ?? [] - - let (progress, publisher) = Current.shell.downloadWithAria2( - aria2Path, - availableXcode.url, - destination, - cookies + + private var archiveDownloadStrategyService: ArchiveDownloadStrategyService { + ArchiveDownloadStrategyService( + archiveDownloadService: archiveDownloadService, + aria2Path: { Path(url: Bundle.main.url(forAuxiliaryExecutable: "aria2c")!)! }, + cookiesForURL: { Current.network.session.configuration.httpCookieStorage?.cookies(for: $0) ?? [] } ) - progressChanged(progress) - - return publisher - .map { _ in destination.url } - .eraseToAnyPublisher() } - public func downloadXcodeWithURLSession(_ availableXcode: AvailableXcode, to destination: Path, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher { - let resumeDataPath = Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).resumedata" - let persistedResumeData = Current.files.contents(atPath: resumeDataPath.string) - - return attemptResumableTask(maximumRetryCount: 3) { resumeData -> AnyPublisher in - let (progress, publisher) = Current.network.downloadTask(with: availableXcode.url, - to: destination.url, - resumingWith: resumeData ?? persistedResumeData) - progressChanged(progress) - - return publisher - .map { $0.saveLocation } - .eraseToAnyPublisher() - } - .handleEvents(receiveCompletion: { completion in - self.persistOrCleanUpResumeData(at: resumeDataPath, for: completion) - }) - .eraseToAnyPublisher() + private var archiveDownloadService: ArchiveDownloadService { + ArchiveDownloadService( + aria2Download: { aria2Path, url, destination, cookies in + Current.shell.downloadWithAria2Async(aria2Path, url, destination, cookies) + }, + urlSessionDownload: { url, destination, resumeData in + Current.network.downloadTaskAsync(with: url, to: destination, resumingWith: resumeData) + }, + contentsAtPath: { path in + Current.files.contents(atPath: path) + }, + createFile: { path, data in + Current.files.createFile(atPath: path, contents: data) + }, + removeItem: { try Current.files.removeItem(at: $0) }, + shouldRetry: { error in + error as? AuthenticationError != .notAuthorized + }, + validateResponse: { response in + try ArchiveDownloadService.validateDeveloperDownloadResponse( + response, + unauthorizedError: { AuthenticationError.notAuthorized } + ) + } + ) } - public func installArchivedXcode(_ availableXcode: AvailableXcode, at archiveURL: URL) -> AnyPublisher { + public func installArchivedXcodeAsync(_ availableXcode: AvailableXcode, at archiveURL: URL) async throws -> InstalledXcode { unxipProgress.completedUnitCount = 0 - overallProgress.addChild(unxipProgress, withPendingUnitCount: AppState.unxipProgressWeight) - + addDockProgressChildIfNeeded(unxipProgress, withPendingUnitCount: AppState.unxipProgressWeight) + + let installedXcode: InstalledXcode do { - let destinationURL = Path.installDirectory.join("Xcode-\(availableXcode.version.descriptionWithoutBuildMetadata).app").url - switch archiveURL.pathExtension { - case "xip": - return unarchiveAndMoveXIP(availableXcode: availableXcode, at: archiveURL, to: destinationURL) - .tryMap { xcodeURL throws -> InstalledXcode in - guard - let path = Path(url: xcodeURL), - Current.files.fileExists(atPath: path.string), - let installedXcode = InstalledXcode(path: path) - else { throw InstallationError.failedToMoveXcodeToApplications } - return installedXcode - } - .flatMap { installedXcode -> AnyPublisher in - do { - self.setInstallationStep(of: availableXcode.version, to: .trashingArchive) - try Current.files.trashItem(at: archiveURL) - self.setInstallationStep(of: availableXcode.version, to: .checkingSecurity) - - return self.verifySecurityAssessment(of: installedXcode) - .combineLatest(self.verifySigningCertificate(of: installedXcode.path.url)) - .map { _ in installedXcode } - .eraseToAnyPublisher() - } catch { - return Fail(error: error) - .eraseToAnyPublisher() - } - } - .flatMap { installedXcode -> AnyPublisher in - self.setInstallationStep(of: availableXcode.version, to: .finishing) - - return self.performPostInstallSteps(for: installedXcode) - .map { installedXcode } - // Show post-install errors but don't fail because of them - .handleEvents(receiveCompletion: { [unowned self] completion in - if case let .failure(error) = completion { - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.InstallArchive.Error.Title"), message: error.legibleLocalizedDescription) - } - resetDockProgressTracking() - }) - .catch { _ in - Just(installedXcode) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - case "dmg": - throw InstallationError.unsupportedFileFormat(extension: "dmg") - default: - throw InstallationError.unsupportedFileFormat(extension: archiveURL.pathExtension) + installedXcode = try await xcodeArchiveInstallService.installArchivedXcode( + availableXcode, + at: archiveURL, + cleanArchive: { try Current.files.trashItem(at: $0) } + ) { step in + switch step { + case .unarchive(.unarchiving): + await self.setInstallationStep(of: availableXcode.version, to: .unarchiving) + case let .unarchive(.moving(destination)): + await self.setInstallationStep(of: availableXcode.version, to: .moving(destination: destination)) + case .cleaningArchive: + await self.setInstallationStep(of: availableXcode.version, to: .trashingArchive) + case .checkingSecurity: + await self.setInstallationStep(of: availableXcode.version, to: .checkingSecurity) + } } } catch { - return Fail(error: error) - .eraseToAnyPublisher() + throw mapXcodeArchiveInstallError(error, availableXcode: availableXcode) + } + + setInstallationStep(of: availableXcode.version, to: .finishing) + do { + try await performPostInstallStepsAsync(for: installedXcode) + } catch { + self.error = error + self.presentedAlert = .generic(title: localizeString("Alert.InstallArchive.Error.Title"), message: error.legibleLocalizedDescription) } + resetDockProgressTracking() + + return installedXcode } - func unarchiveAndMoveXIP(availableXcode: AvailableXcode, at source: URL, to destination: URL) -> AnyPublisher { - self.setInstallationStep(of: availableXcode.version, to: .unarchiving) - - return unxipOrUnxipExperiment(source) - .catch { error -> AnyPublisher in - if let executionError = error as? ProcessExecutionError { - if executionError.standardError.contains("damaged and can’t be expanded") { - return Fail(error: InstallationError.damagedXIP(url: source)) - .eraseToAnyPublisher() - } else if executionError.standardError.contains("can’t be expanded because the selected volume doesn’t have enough free space.") { - return Fail(error: InstallationError.notEnoughFreeSpaceToExpandArchive(archivePath: Path(url: source)!, - version: availableXcode.version)) - .eraseToAnyPublisher() - } - } - return Fail(error: error) - .eraseToAnyPublisher() - } - .tryMap { output -> URL in - self.setInstallationStep(of: availableXcode.version, to: .moving(destination: destination.path)) + private var xcodeUnarchiveService: XcodeUnarchiveService { + XcodeUnarchiveService( + unarchive: { _ = try await self.unxipOrUnxipExperimentAsync($0) }, + fileExists: { path in Current.files.fileExists(atPath: path) }, + moveItem: { source, destination in try Current.files.moveItem(at: source, to: destination) }, + removeItem: { url in try Current.files.removeItem(at: url) } + ) + } - let xcodeURL = source.deletingLastPathComponent().appendingPathComponent("Xcode.app") - let xcodeBetaURL = source.deletingLastPathComponent().appendingPathComponent("Xcode-beta.app") - if Current.files.fileExists(atPath: xcodeURL.path) { - try Current.files.moveItem(at: xcodeURL, to: destination) - } - else if Current.files.fileExists(atPath: xcodeBetaURL.path) { - try Current.files.moveItem(at: xcodeBetaURL, to: destination) + private var xcodeArchiveInstallService: XcodeArchiveInstallService { + XcodeArchiveInstallService( + destinationDirectory: .installDirectory, + unarchiveService: xcodeUnarchiveService, + validationService: xcodeValidationService, + fileExists: { path in Current.files.fileExists(atPath: path) }, + makeInstalledXcode: { path in + InstalledXcode( + path: path, + contentsAtPath: { path in Current.files.contents(atPath: path) }, + loadArchitectures: Current.shell.archs + ) } + ) + } - return destination - } - .handleEvents(receiveCancel: { - if Current.files.fileExists(atPath: source.path) { - try? Current.files.removeItem(source) + private func mapXcodeArchiveInstallError(_ error: Error, availableXcode: AvailableXcode) -> Error { + switch error { + case let error as XcodeArchiveInstallError: + switch error { + case .failedToMoveXcodeToDestination: + return InstallationError.failedToMoveXcodeToApplications + case let .unsupportedFileFormat(fileExtension): + return InstallationError.unsupportedFileFormat(extension: fileExtension) } - if Current.files.fileExists(atPath: destination.path) { - try? Current.files.removeItem(destination) + case let error as XcodeUnarchiveError: + switch error { + case let .damagedXIP(url): + return InstallationError.damagedXIP(url: url) + case let .notEnoughFreeSpaceToExpandArchive(url): + return InstallationError.notEnoughFreeSpaceToExpandArchive( + archivePath: Path(url: url)!, + version: availableXcode.version + ) } - }) - .eraseToAnyPublisher() + case let error as XcodeValidationError: + switch error { + case let .failedSecurityAssessment(xcode, output): + return InstallationError.failedSecurityAssessment(xcode: xcode, output: output) + case let .codesignVerifyFailed(output): + return InstallationError.codesignVerifyFailed(output: output) + case let .unexpectedCodeSigningIdentity(identifier, certificateAuthority): + return InstallationError.unexpectedCodeSigningIdentity( + identifier: identifier, + certificateAuthority: certificateAuthority + ) + } + default: + return error + } } - - func unxipOrUnxipExperiment(_ source: URL) -> AnyPublisher { + + func unxipOrUnxipExperimentAsync(_ source: URL) async throws -> ProcessOutput { if unxipExperiment { // All hard work done by https://github.com/saagarjha/unxip // Compiled to binary with `swiftc -parse-as-library -O unxip.swift` - return Current.shell.unxipExperiment(source) + return try await Current.shell.unxipExperiment(source) } else { - return Current.shell.unxip(source) + return try await Current.shell.unxip(source) } } - public func verifySecurityAssessment(of xcode: InstalledXcode) -> AnyPublisher { - return Current.shell.spctlAssess(xcode.path.url) - .catch { (error: Swift.Error) -> AnyPublisher in - var output = "" - if let executionError = error as? ProcessExecutionError { - output = [executionError.standardOutput, executionError.standardError].joined(separator: "\n") - } - return Fail(error: InstallationError.failedSecurityAssessment(xcode: xcode, output: output)) - .eraseToAnyPublisher() - } - .map { _ in Void() } - .eraseToAnyPublisher() + private var xcodeValidationService: XcodeValidationService { + XcodeValidationService( + assessSecurity: { url in try await Current.shell.spctlAssess(url) }, + verifyCodesign: { url in try await Current.shell.codesignVerify(url) } + ) } - func verifySigningCertificate(of url: URL) -> AnyPublisher { - return Current.shell.codesignVerify(url) - .catch { error -> AnyPublisher in - var output = "" - if let executionError = error as? ProcessExecutionError { - output = [executionError.standardOutput, executionError.standardError].joined(separator: "\n") + // MARK: - Post-Install + + /// Attemps to install the helper once, then performs all post-install steps + public func performPostInstallSteps(for xcode: InstalledXcode) { + postInstallTask?.cancel() + let taskID = UUID() + postInstallTaskID = taskID + postInstallTask = Task { @MainActor in + defer { + if postInstallTaskID == taskID { + postInstallTask = nil + postInstallTaskID = nil } - return Fail(error: InstallationError.codesignVerifyFailed(output: output)) - .eraseToAnyPublisher() - } - .map { output -> CertificateInfo in - // codesign prints to stderr - return self.parseCertificateInfo(output.err) } - .tryMap { cert in - guard - cert.teamIdentifier == XcodeTeamIdentifier, - cert.authority == XcodeCertificateAuthority - else { throw InstallationError.unexpectedCodeSigningIdentity(identifier: cert.teamIdentifier, certificateAuthority: cert.authority) } - - return Void() + do { + try await performPostInstallStepsAsync(for: xcode) + } catch is CancellationError { + } catch { + guard postInstallTaskID == taskID else { return } + self.error = error + self.presentedAlert = .generic(title: localizeString("Alert.PostInstall.Title"), message: error.legibleLocalizedDescription) } - .eraseToAnyPublisher() + } } - public struct CertificateInfo { - public var authority: [String] - public var teamIdentifier: String - public var bundleIdentifier: String + /// Attemps to install the helper once, then performs all post-install steps + public func performPostInstallStepsAsync(for xcode: InstalledXcode) async throws { + do { + if helperInstallState != .installed { + // If the helper isn't installed yet then we need to prepare the user for the install prompt, + // and then actually perform the installation. + try await waitForHelperInstallConsent(version: xcode.version) + } + + try await installHelperIfNecessaryAsync() + try await xcodePostInstallWorkflowService.performPostInstallSteps(for: xcode) + } catch { + Logger.appState.error("Performing post-install steps failed: \(error.legibleLocalizedDescription)") + throw InstallationError.postInstallStepsNotPerformed(version: xcode.version, helperInstallState: helperInstallState) + } } - public func parseCertificateInfo(_ rawInfo: String) -> CertificateInfo { - var info = CertificateInfo(authority: [], teamIdentifier: "", bundleIdentifier: "") + private func waitForHelperInstallConsent(version: Version) async throws { + unxipProgress.completedUnitCount = AppState.totalProgressUnits + resetDockProgressTracking() - for part in rawInfo.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .newlines) { - if part.hasPrefix("Authority") { - info.authority.append(part.components(separatedBy: "=")[1]) - } - if part.hasPrefix("TeamIdentifier") { - info.teamIdentifier = part.components(separatedBy: "=")[1] - } - if part.hasPrefix("Identifier") { - info.bundleIdentifier = part.components(separatedBy: "=")[1] + let helperConsent = OneShotContinuation() + let helperPreparationID = UUID() + try await helperConsent.value(onCancel: { [weak self] in + Task { @MainActor [weak self] in + guard let self, self.helperActionPreparationID == helperPreparationID else { return } + self.helperActionPreparationID = nil + self.isPreparingUserForActionRequiringHelper = nil + if self.presentedAlert?.id == XcodesAlert.privilegedHelper.id { + self.presentedAlert = nil + } } - } + }) { [weak self] in + Task { @MainActor [weak self] in + guard let self else { + helperConsent.resume(throwing: CancellationError()) + return + } - return info - } - - // MARK: - Post-Install - - /// Attemps to install the helper once, then performs all post-install steps - public func performPostInstallSteps(for xcode: InstalledXcode) { - performPostInstallSteps(for: xcode) - .sink( - receiveCompletion: { completion in - if case let .failure(error) = completion { - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.PostInstall.Title"), message: error.legibleLocalizedDescription) + self.prepareForHelperAction(preparationID: helperPreparationID) { [weak self] userConsented in + guard let self else { + helperConsent.resume(throwing: CancellationError()) + return } - }, - receiveValue: {} - ) - .store(in: &cancellables) - } - - /// Attemps to install the helper once, then performs all post-install steps - public func performPostInstallSteps(for xcode: InstalledXcode) -> AnyPublisher { - let postInstallPublisher: AnyPublisher = - Deferred { [unowned self] in - self.installHelperIfNecessary() - } - .flatMap { [unowned self] in - self.enableDeveloperMode() - } - .flatMap { [unowned self] in - self.approveLicense(for: xcode) - } - .flatMap { [unowned self] in - self.installComponents(for: xcode) - } - .mapError { [unowned self] error in - Logger.appState.error("Performing post-install steps failed: \(error.legibleLocalizedDescription)") - return InstallationError.postInstallStepsNotPerformed(version: xcode.version, helperInstallState: self.helperInstallState) - } - .eraseToAnyPublisher() - - guard helperInstallState == .installed else { - // If the helper isn't installed yet then we need to prepare the user for the install prompt, - // and then actually perform the installation, - // and the post-install steps need to wait until that is complete. - // This subject, which completes upon isPreparingUserForActionRequiringHelper being invoked, is used to achieve that. - // This is not the most straightforward code I've ever written... - let helperInstallConsentSubject = PassthroughSubject() - - // Need to dispatch this to avoid duplicate alerts, - // the second of which will crash when force-unwrapping isPreparingUserForActionRequiringHelper - DispatchQueue.main.async { - self.isPreparingUserForActionRequiringHelper = { [unowned self] userConsented in if userConsented { - helperInstallConsentSubject.send() + helperConsent.resume() } else { Logger.appState.info("User did not consent to installing helper during post-install steps.") - - helperInstallConsentSubject.send( - completion: .failure( - InstallationError.postInstallStepsNotPerformed(version: xcode.version, helperInstallState: self.helperInstallState) + helperConsent.resume( + throwing: InstallationError.postInstallStepsNotPerformed( + version: version, + helperInstallState: self.helperInstallState ) ) } } - self.presentedAlert = .privilegedHelper } - - unxipProgress.completedUnitCount = AppState.totalProgressUnits - resetDockProgressTracking() - - return helperInstallConsentSubject - .flatMap { - postInstallPublisher - } - .eraseToAnyPublisher() } - - return postInstallPublisher } - private func enableDeveloperMode() -> AnyPublisher { - Current.helper.devToolsSecurityEnable() - .flatMap { - Current.helper.addStaffToDevelopersGroup() - } - .eraseToAnyPublisher() + private var xcodePostInstallWorkflowService: XcodePostInstallWorkflowService { + XcodePostInstallWorkflowService( + preparationService: xcodePostInstallPreparationService, + postInstallService: xcodePostInstallService + ) } - private func approveLicense(for xcode: InstalledXcode) -> AnyPublisher { - Current.helper.acceptXcodeLicense(xcode.path.string) - .eraseToAnyPublisher() + private var xcodePostInstallService: XcodePostInstallService { + XcodePostInstallService( + runFirstLaunch: { xcode in try await Current.helper.runFirstLaunchAsync(xcode.path.string) }, + getUserCacheDirectory: { try await Current.shell.getUserCacheDir() }, + getMacOSBuildVersion: { try await Current.shell.buildVersion() }, + getXcodeBuildVersion: { xcode in try await Current.shell.xcodeBuildVersion(xcode) }, + touchInstallCheck: { cacheDirectory, macOSBuildVersion, toolsVersion in + try await Current.shell.touchInstallCheck(cacheDirectory, macOSBuildVersion, toolsVersion) + } + ) } - private func installComponents(for xcode: InstalledXcode) -> AnyPublisher { - Current.helper.runFirstLaunch(xcode.path.string) - .flatMap { - Current.shell.getUserCacheDir().map { $0.out } - .combineLatest( - Current.shell.buildVersion().map { $0.out }, - Current.shell.xcodeBuildVersion(xcode).map { $0.out } - ) - } - .flatMap { cacheDirectory, macOSBuildVersion, toolsVersion in - Current.shell.touchInstallCheck(cacheDirectory, macOSBuildVersion, toolsVersion) - } - .map { _ in Void() } - .eraseToAnyPublisher() + private var xcodePostInstallPreparationService: XcodePostInstallPreparationService { + XcodePostInstallPreparationService( + enableDeveloperTools: { try await Current.helper.devToolsSecurityEnableAsync() }, + addStaffToDevelopersGroup: { try await Current.helper.addStaffToDevelopersGroupAsync() }, + acceptLicense: { xcode in try await Current.helper.acceptXcodeLicenseAsync(xcode.path.string) } + ) } - + // MARK: - Dock Progress Tracking - + private func setupDockProgress() { - Task { @MainActor in - DockProgress.progressInstance = nil - DockProgress.style = .bar - - let progress = Progress(totalUnitCount: AppState.totalProgressUnits) - progress.kind = .file - progress.fileOperationKind = .downloading - overallProgress = progress - - DockProgress.progressInstance = overallProgress - } - + DockProgress.progressInstance = nil + DockProgress.style = .bar + + let progress = Progress(totalUnitCount: AppState.totalProgressUnits) + progress.kind = .file + progress.fileOperationKind = .downloading + overallProgress = progress + overallProgressChildIDs = [] + unxipProgress = AppState.makeUnxipProgress() + + DockProgress.progressInstance = overallProgress + } - + + private func addDockProgressChildIfNeeded(_ progress: Progress, withPendingUnitCount pendingUnitCount: Int64) { + let progressID = ObjectIdentifier(progress) + guard overallProgressChildIDs.insert(progressID).inserted else { return } + overallProgress.addChild(progress, withPendingUnitCount: pendingUnitCount) + } + func resetDockProgressTracking() { - Task { @MainActor in - DockProgress.progress = 1 // Only way to completely remove overlay with DockProgress is setting progress to complete - } + DockProgress.progress = 1 // Only way to completely remove overlay with DockProgress is setting progress to complete } - - // MARK: - - + + // MARK: - + func setInstallationStep(of version: Version, to step: XcodeInstallationStep) { - DispatchQueue.main.async { - guard let index = self.allXcodes.firstIndex(where: { $0.version.isEquivalent(to: version) }) else { return } - self.allXcodes[index].installState = .installing(step) - - let xcode = self.allXcodes[index] - Current.notificationManager.scheduleNotification(title: xcode.version.major.description + "." + xcode.version.appleDescription, body: step.description, category: .normal) - } - } - - func setInstallationStep(of runtime: DownloadableRuntime, to step: RuntimeInstallationStep, postNotification: Bool = true) { - DispatchQueue.main.async { - guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return } - self.downloadableRuntimes[index].installState = .installing(step) - if postNotification { - Current.notificationManager.scheduleNotification(title: runtime.name, body: step.description, category: .normal) - } - } + guard let index = allXcodes.firstIndex(where: { $0.version.isEquivalent(to: version) }) else { return } + allXcodes[index].installState = .installing(step) + + let xcode = allXcodes[index] + Current.notificationManager.scheduleNotification(title: xcode.version.major.description + "." + xcode.version.appleDescription, body: step.description, category: .normal) } -} -extension AppState { - func persistOrCleanUpResumeData(at path: Path, for completion: Subscribers.Completion) { - switch completion { - case .finished: - try? Current.files.removeItem(at: path.url) - case .failure(let error): - guard let resumeData = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data else { return } - Current.files.createFile(atPath: path.string, contents: resumeData) + func setInstallationStep(of runtime: DownloadableRuntime, to step: RuntimeInstallationStep, postNotification: Bool = true) { + guard let index = downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return } + downloadableRuntimes[index].installState = .installing(step) + if postNotification { + Current.notificationManager.scheduleNotification(title: runtime.name, body: step.description, category: .normal) } } } @@ -590,34 +531,16 @@ public enum InstallationError: LocalizedError, Equatable { } } -public enum InstallationType { +public enum InstallationType: Sendable { case version(AvailableXcode) -} -public enum AutoInstallationType: Int, Identifiable { - case none = 0 - case newestVersion - case newestBeta - - public var id: Self { self } - - public var isAutoInstalling: Bool { - get { - return self != .none - } - set { - self = newValue ? .newestVersion : .none - } - } - public var isAutoInstallingBeta: Bool { - get { - return self == .newestBeta - } - set { - self = newValue ? .newestBeta : (isAutoInstalling ? .newestVersion : .none) + var shouldRetryAfterDamagedArchive: Bool { + switch self { + case .version: + return true } } } -let XcodeTeamIdentifier = "59GAB85EFG" -let XcodeCertificateAuthority = ["Software Signing", "Apple Code Signing Certification Authority", "Apple Root CA"] +let XcodeTeamIdentifier = XcodeSignatureVerifier.expectedTeamIdentifier +let XcodeCertificateAuthority = XcodeSignatureVerifier.expectedCertificateAuthority diff --git a/Xcodes/Backend/AppState+Runtimes.swift b/Xcodes/Backend/AppState+Runtimes.swift index a0a0ba86..78cf486e 100644 --- a/Xcodes/Backend/AppState+Runtimes.swift +++ b/Xcodes/Backend/AppState+Runtimes.swift @@ -1,356 +1,321 @@ import Foundation import XcodesKit import OSLog -import Combine import Path -import AppleAPI import Version extension AppState { func updateDownloadableRuntimes() { - Task { - do { - - let downloadableRuntimes = try await self.runtimeService.downloadableRuntimes() - let runtimes = downloadableRuntimes.downloadables.map { runtime in - var updatedRuntime = runtime - - // This loops through and matches up the simulatorVersion to the mappings - let simulatorBuildUpdate = downloadableRuntimes.sdkToSimulatorMappings.filter { SDKToSimulatorMapping in - SDKToSimulatorMapping.simulatorBuildUpdate == runtime.simulatorVersion.buildUpdate - } - updatedRuntime.sdkBuildUpdate = simulatorBuildUpdate.map { $0.sdkBuildUpdate } - return updatedRuntime + downloadableRuntimesTask?.cancel() + let taskID = UUID() + downloadableRuntimesTaskID = taskID + downloadableRuntimesTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { + if self.downloadableRuntimesTaskID == taskID { + self.downloadableRuntimesTask = nil + self.downloadableRuntimesTaskID = nil } - - DispatchQueue.main.async { - self.downloadableRuntimes = runtimes - } - try? cacheDownloadableRuntimes(runtimes) + } + do { + var store = self.runtimeListStore + let runtimes = try await store.updateDownloadableRuntimes() + try Task.checkCancellation() + + self.downloadableRuntimes = runtimes + } catch is CancellationError { } catch { Logger.appState.error("Error downloading runtimes: \(error.localizedDescription)") } } } - + func updateInstalledRuntimes() { - Task { + installedRuntimesTask?.cancel() + let taskID = UUID() + installedRuntimesTaskID = taskID + installedRuntimesTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { + if self.installedRuntimesTaskID == taskID { + self.installedRuntimesTask = nil + self.installedRuntimesTaskID = nil + } + } do { Logger.appState.info("Loading Installed runtimes") let runtimes = try await self.runtimeService.localInstalledRuntimes() - - DispatchQueue.main.async { - self.installedRuntimes = runtimes - } + try Task.checkCancellation() + + self.installedRuntimes = runtimes + } catch is CancellationError { } catch { Logger.appState.error("Error loading installed runtimes: \(error.localizedDescription)") } } } - + func downloadRuntime(runtime: DownloadableRuntime) { - guard let selectedXcode = self.allXcodes.first(where: { $0.selected }) else { - Logger.appState.error("No selected Xcode") - DispatchQueue.main.async { - self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: "No selected Xcode. Please make an Xcode active") + do { + let method = try RuntimeInstallPolicy().installMethod( + for: runtime, + selectedXcodeVersion: allXcodes.first(where: { $0.selected })?.version + ) + + switch method { + case .archive: + downloadRuntimeViaArchive(runtime: runtime) + case let .xcodebuild(architecture): + downloadRuntimeViaXcodeBuild(runtime: runtime, architecture: architecture) } - return + } catch { + presentRuntimeInstallPolicyError(error) } - // new runtimes - if runtime.contentType == .cryptexDiskImage { - // only selected xcodes > 16.1 beta 3 can download runtimes via a xcodebuild -downloadPlatform version - // only Runtimes coming from cryptexDiskImage can be downloaded via xcodebuild - if selectedXcode.version > Version(major: 16, minor: 0, patch: 0) { - - if runtime.architectures?.isAppleSilicon ?? false { - // Need Xcode 26 but with some RC/Beta's its simpler to just to greater > 25 - if selectedXcode.version > Version(major: 25, minor: 0, patch: 0) { - downloadRuntimeViaXcodeBuild(runtime: runtime) - } else { - // not supported - Logger.appState.error("Trying to download a runtime we can't download") - DispatchQueue.main.async { - self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: localizeString("Alert.Install.Error.Need.Xcode26")) - } - return - } - - } else { - downloadRuntimeViaXcodeBuild(runtime: runtime) - } - } else { - // not supported - Logger.appState.error("Trying to download a runtime we can't download") - DispatchQueue.main.async { - self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: localizeString("Alert.Install.Error.Need.Xcode16.1")) - } - return + } + + private func presentRuntimeInstallPolicyError(_ error: Error) { + Logger.appState.error("Trying to download a runtime we can't download: \(error.localizedDescription)") + + if let error = error as? RuntimeInstallPolicyError { + switch error { + case .noSelectedXcode: + presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: "No selected Xcode. Please make an Xcode active") + case .xcode16_1OrGreaterRequired: + presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: localizeString("Alert.Install.Error.Need.Xcode16.1")) + case .xcode26OrGreaterRequired: + presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: localizeString("Alert.Install.Error.Need.Xcode26")) } } else { - downloadRuntimeObseleteWay(runtime: runtime) + presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) } } - - func downloadRuntimeViaXcodeBuild(runtime: DownloadableRuntime) { - - let downloadRuntimeTask = Current.shell.downloadRuntime(runtime.platform.shortName, runtime.simulatorVersion.buildUpdate, runtime.architectures?.isAppleSilicon ?? false ? Architecture.arm64.rawValue : nil) - - runtimePublishers[runtime.identifier] = Task { [weak self] in + + func downloadRuntimeViaXcodeBuild(runtime: DownloadableRuntime, architecture: String? = nil) { + runtimeTasks[runtime.identifier]?.cancel() + let runtimeTaskID = UUID() + runtimeTaskIDs[runtime.identifier] = runtimeTaskID + runtimeTasks[runtime.identifier] = Task { @MainActor [weak self] in guard let self = self else { return } + defer { + if self.runtimeTaskIDs[runtime.identifier] == runtimeTaskID { + self.runtimeTasks[runtime.identifier] = nil + self.runtimeTaskIDs[runtime.identifier] = nil + } + } do { - for try await progress in downloadRuntimeTask { - if progress.isIndeterminate { - DispatchQueue.main.async { + try await RuntimeXcodebuildInstallService(download: Current.shell.downloadRuntime).downloadAndInstall( + runtime: runtime, + architecture: architecture + ) { progress in + Task { @MainActor [weak self] in + guard + let self, + self.runtimeTaskIDs[runtime.identifier] == runtimeTaskID + else { return } + + if progress.isIndeterminate { self.setInstallationStep(of: runtime, to: .installing, postNotification: false) - } - } else { - DispatchQueue.main.async { + } else { self.setInstallationStep(of: runtime, to: .downloading(progress: progress), postNotification: false) } } - } + try Task.checkCancellation() Logger.appState.debug("Done downloading runtime - \(runtime.name)") - - DispatchQueue.main.async { - guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return } - self.downloadableRuntimes[index].installState = .installed - self.update() - } - + + guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return } + self.downloadableRuntimes[index].installState = .installed + self.update() + + } catch is CancellationError { } catch { - Logger.appState.error("Error downloading runtime: \(error.localizedDescription)") - DispatchQueue.main.async { - self.error = error - if let error = error as? String { - self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error) - } else { - self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) - } - } + Logger.appState.error("Error downloading runtime: \(error.localizedDescription)") + self.error = error + if let error = error as? XcodesKitError { + self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.message) + } else { + self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) } + } } } - - func downloadRuntimeObseleteWay(runtime: DownloadableRuntime) { - runtimePublishers[runtime.identifier] = Task { - do { - let downloadedURL = try await downloadRunTimeFull(runtime: runtime) - if !Task.isCancelled { - Logger.appState.debug("Installing runtime: \(runtime.name)") - DispatchQueue.main.async { - self.setInstallationStep(of: runtime, to: .installing) - } - switch runtime.contentType { - case .cryptexDiskImage: - // not supported yet (do we need to for old packages?) - throw "Installing via cryptexDiskImage not support - please install manually from \(downloadedURL.description)" - case .package: - // not supported yet (do we need to for old packages?) - throw "Installing via package not support - please install manually from \(downloadedURL.description)" - case .diskImage: - try await self.installFromImage(dmgURL: downloadedURL) - DispatchQueue.main.async { - self.setInstallationStep(of: runtime, to: .trashingArchive) - } - try Current.files.removeItem(at: downloadedURL) - } - - DispatchQueue.main.async { - guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return } - self.downloadableRuntimes[index].installState = .installed - } - updateInstalledRuntimes() + + func downloadRuntimeViaArchive(runtime: DownloadableRuntime) { + runtimeTasks[runtime.identifier]?.cancel() + let runtimeTaskID = UUID() + runtimeTaskIDs[runtime.identifier] = runtimeTaskID + runtimeTasks[runtime.identifier] = Task { @MainActor [weak self] in + guard let self = self else { return } + defer { + if self.runtimeTaskIDs[runtime.identifier] == runtimeTaskID { + self.runtimeTasks[runtime.identifier] = nil + self.runtimeTaskIDs[runtime.identifier] = nil } - } - catch { - Logger.appState.error("Error downloading runtime: \(error.localizedDescription)") - DispatchQueue.main.async { - self.error = error - if let error = error as? String { - self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error) - } else { - self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) + do { + let downloadedURL = try await downloadRuntimeArchive(runtime: runtime, taskID: runtimeTaskID) + try Task.checkCancellation() + Logger.appState.debug("Installing runtime: \(runtime.name)") + try await self.runtimeArchiveInstallService.install( + runtime: runtime, + archiveURL: downloadedURL, + stepChanged: { step in + await self.setInstallationStep(of: runtime, to: step) } + ) + + guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return } + self.downloadableRuntimes[index].installState = .installed + updateInstalledRuntimes() + } catch is CancellationError { + } catch { + Logger.appState.error("Error downloading runtime: \(error.localizedDescription)") + self.error = error + if let error = error as? XcodesKitError { + self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.message) + } else { + self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) } } } } - - func downloadRunTimeFull(runtime: DownloadableRuntime) async throws -> URL { - guard let source = runtime.source else { - throw "Invalid runtime source" - } - - guard let downloadPath = runtime.downloadPath else { - throw "Invalid runtime downloadPath" - } - - // sets a proper cookie for runtimes - try await validateADCSession(path: downloadPath) - + + func downloadRuntimeArchive(runtime: DownloadableRuntime, taskID: UUID? = nil) async throws -> URL { let downloader = Downloader(rawValue: Current.defaults.string(forKey: "downloader") ?? "aria2") ?? .aria2 - - let url = URL(string: source)! - let expectedRuntimePath = Path.xcodesApplicationSupport/"\(url.lastPathComponent)" - // aria2 downloads directly to the destination (instead of into /tmp first) so we need to make sure that the download isn't incomplete - let aria2DownloadMetadataPath = expectedRuntimePath.parent/(expectedRuntimePath.basename() + ".aria2") - var aria2DownloadIsIncomplete = false - if case .aria2 = downloader, aria2DownloadMetadataPath.exists { - aria2DownloadIsIncomplete = true - } - if Current.files.fileExistsAtPath(expectedRuntimePath.string), aria2DownloadIsIncomplete == false { - Logger.appState.info("Found existing runtime that will be used for installation at \(expectedRuntimePath).") - return expectedRuntimePath.url - } - + Logger.appState.info("Downloading \(runtime.visibleIdentifier) with \(downloader)") - switch downloader { - case .aria2: - let aria2Path = Path(url: Bundle.main.url(forAuxiliaryExecutable: "aria2c")!)! - for try await progress in downloadRuntimeWithAria2(runtime, to: expectedRuntimePath, aria2Path: aria2Path) { - DispatchQueue.main.async { - self.setInstallationStep(of: runtime, to: .downloading(progress: progress), postNotification: false) - } + let archiveURL = try await runtimeArchiveService().archiveURL( + for: runtime, + destinationDirectory: .xcodesApplicationSupport, + downloader: downloader + ) { progress in + let expectedTaskID = taskID + Task { @MainActor [weak self] in + if let expectedTaskID, self?.runtimeTaskIDs[runtime.identifier] != expectedTaskID { + return } + self?.setInstallationStep(of: runtime, to: .downloading(progress: progress), postNotification: false) + } + } + Logger.appState.info("Using runtime archive at \(archiveURL.path).") + return archiveURL + } + + private func runtimeArchiveService() -> RuntimeArchiveService { + RuntimeArchiveService( + fileExists: { Current.files.fileExistsAtPath($0.string) }, + download: { runtime, url, destination, downloader, progressChanged in + let archiveURL = try await self.runtimeArchiveDownloadStrategyService.download( + runtime: runtime, + url: url, + destination: destination, + downloader: downloader, + progressChanged: progressChanged + ) Logger.appState.debug("Done downloading runtime") + return archiveURL + } + ) + } - case .urlSession: - throw "Downloading runtimes with URLSession is not supported. Please use aria2" - } - return expectedRuntimePath.url + private var runtimeArchiveDownloadStrategyService: RuntimeArchiveDownloadStrategyService { + RuntimeArchiveDownloadStrategyService( + validateDownloadPath: { path in + // Validating the ADC path sets the session cookie required for runtime downloads. + try await self.validateADCSession(path: path) + }, + aria2Path: { Path(url: Bundle.main.url(forAuxiliaryExecutable: "aria2c")!)! }, + cookiesForURL: { Current.network.session.configuration.httpCookieStorage?.cookies(for: $0) ?? [] } + ) } - public func downloadRuntimeWithAria2(_ runtime: DownloadableRuntime, to destination: Path, aria2Path: Path) -> AsyncThrowingStream { - guard let url = runtime.url else { - return AsyncThrowingStream { continuation in - continuation.finish(throwing: "Invalid or non existant runtime url") + private var runtimeArchiveInstallService: RuntimeArchiveInstallService { + let runtimeService = self.runtimeService + return RuntimeArchiveInstallService( + installDiskImage: { url in + try await runtimeService.installRuntimeImage(dmgURL: url) + }, + removeArchive: { url in + try Current.files.removeItem(at: url) } - } - - let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies(for: url) ?? [] - - return Current.shell.downloadWithAria2Async(aria2Path, url, destination, cookies) + ) } - - + public func installFromImage(dmgURL: URL) async throws { try await self.runtimeService.installRuntimeImage(dmgURL: dmgURL) } - + func cancelRuntimeInstall(runtime: DownloadableRuntime) { - // Cancel the publisher - - runtimePublishers[runtime.identifier]?.cancel() - runtimePublishers[runtime.identifier] = nil - - // If the download is cancelled by the user, clean up the download files that aria2 creates. - guard let source = runtime.source else { - return - } - let url = URL(string: source)! - let expectedRuntimePath = Path.xcodesApplicationSupport/"\(url.lastPathComponent)" - let aria2DownloadMetadataPath = expectedRuntimePath.parent/(expectedRuntimePath.basename() + ".aria2") - - try? Current.files.removeItem(at: expectedRuntimePath.url) - try? Current.files.removeItem(at: aria2DownloadMetadataPath.url) - + runtimeTasks[runtime.identifier]?.cancel() + runtimeTasks[runtime.identifier] = nil + runtimeTaskIDs[runtime.identifier] = nil + + ArchiveCancellationCleanupService( + removeItem: { try Current.files.removeItem(at: $0) } + ).cleanupRuntimeArchive( + for: runtime, + destinationDirectory: .xcodesApplicationSupport + ) + guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return } self.downloadableRuntimes[index].installState = .notInstalled - + updateInstalledRuntimes() } - + func runtimeInstallPath(xcode: Xcode, runtime: DownloadableRuntime) -> Path? { - if let coreSimulatorInfo = coreSimulatorInfo(runtime: runtime) { - let urlString = coreSimulatorInfo.path["relative"]! - // app was not allowed to open up file:// url's so remove - let fileRemovedString = urlString.replacingOccurrences(of: "file://", with: "") - let url = URL(fileURLWithPath: fileRemovedString) - - return Path(url: url)! - } - return nil + RuntimeInstallationLookupService() + .installPath(for: runtime, in: installedRuntimes) } - + func coreSimulatorInfo(runtime: DownloadableRuntime) -> CoreSimulatorImage? { - return installedRuntimes.filter({ - $0.runtimeInfo.build == runtime.simulatorVersion.buildUpdate && - ((runtime.architectures ?? []).isEmpty ? true : - $0.runtimeInfo.supportedArchitectures == runtime.architectures )}).first + RuntimeInstallationLookupService() + .coreSimulatorImage(for: runtime, in: installedRuntimes) } - + func deleteRuntime(runtime: DownloadableRuntime) async throws { if let info = coreSimulatorInfo(runtime: runtime) { try await runtimeService.deleteRuntime(identifier: info.uuid) - + // give it some time to actually finish deleting before updating - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.updateInstalledRuntimes() - } + try await Task.sleep(nanoseconds: 500_000_000) + updateInstalledRuntimes() } else { - throw "No simulator found with \(runtime.identifier)" + throw XcodesKitError("No simulator found with \(runtime.identifier)") } } -} -extension AnyPublisher { - func async() async throws -> Output { - try await withCheckedThrowingContinuation { continuation in - var cancellable: AnyCancellable? - - cancellable = first() - .sink { result in - switch result { - case .finished: - break - case let .failure(error): - continuation.resume(throwing: error) - } - cancellable?.cancel() - } receiveValue: { value in - continuation.resume(with: .success(value)) + func confirmDeleteRuntime(runtime: DownloadableRuntime) { + deleteRuntimeTask?.cancel() + let taskID = UUID() + deleteRuntimeTaskID = taskID + deleteRuntimeTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { + if self.deleteRuntimeTaskID == taskID { + self.deleteRuntimeTask = nil + self.deleteRuntimeTaskID = nil } - } - } -} -extension AnyPublisher where Failure: Error { - struct Subscriber { - fileprivate let send: (Output) -> Void - fileprivate let complete: (Subscribers.Completion) -> Void - - func send(_ value: Output) { self.send(value) } - func send(completion: Subscribers.Completion) { self.complete(completion) } - } - - init(_ closure: (Subscriber) -> AnyCancellable) { - let subject = PassthroughSubject() - - let subscriber = Subscriber( - send: subject.send, - complete: subject.send(completion:) - ) - let cancel = closure(subscriber) + } - self = subject - .handleEvents(receiveCancel: cancel.cancel) - .eraseToAnyPublisher() + do { + try await self.deleteRuntime(runtime: runtime) + } catch is CancellationError { + } catch { + guard self.deleteRuntimeTaskID == taskID else { return } + self.presentedPreferenceAlert = .generic( + title: "Error", + message: self.runtimeDeletionErrorMessage(error) + ) + } + } } -} -extension AnyPublisher where Failure == Error { - init(taskPriority: TaskPriority? = nil, asyncFunc: @escaping () async throws -> Output) { - self.init { subscriber in - let task = Task(priority: taskPriority) { - do { - subscriber.send(try await asyncFunc()) - subscriber.send(completion: .finished) - } catch { - subscriber.send(completion: .failure(error)) - } - } - return AnyCancellable { task.cancel() } + private func runtimeDeletionErrorMessage(_ error: Error) -> String { + if let error = error as? XcodesKitError { + return error.message } + + return error.localizedDescription } } diff --git a/Xcodes/Backend/AppState+Update.swift b/Xcodes/Backend/AppState+Update.swift index 52c280ef..ab9ea3d8 100644 --- a/Xcodes/Backend/AppState+Update.swift +++ b/Xcodes/Backend/AppState+Update.swift @@ -1,34 +1,34 @@ -import Combine import Foundation import Path -import Version -import SwiftSoup -import AppleAPI +import XcodesLoginKit import XcodesKit extension AppState { var isReadyForUpdate: Bool { - guard let lastUpdated = Current.defaults.date(forKey: "lastUpdated"), - // This is bad date math but for this use case it doesn't need to be exact - lastUpdated < Current.date().addingTimeInterval(-60 * 60 * 5) - else { - return false - } - return true + XcodeUpdatePolicy(now: { Current.date() }).shouldUpdate( + cachedXcodes: availableXcodes, + lastUpdated: Current.defaults.date(forKey: "lastUpdated") + ) } func updateIfNeeded() { guard isReadyForUpdate - else { - updatePublisher = updateSelectedXcodePath() - .sink( - receiveCompletion: { _ in - self.updatePublisher = nil - }, - receiveValue: { _ in } - ) + else { + updateTask?.cancel() + let taskID = UUID() + updateTaskID = taskID + let task = Task { @MainActor in + defer { + if updateTaskID == taskID { + updateTask = nil + updateTaskID = nil + } + } + await self.updateSelectedXcodePathAsync() + } + updateTask = task return } update() as Void @@ -38,77 +38,53 @@ extension AppState { guard !isUpdating else { return } updateDownloadableRuntimes() updateInstalledRuntimes() - updatePublisher = updateSelectedXcodePath() - .flatMap { _ in - self.updateAvailableXcodes(from: self.dataSource) - } - .sink( - receiveCompletion: { [unowned self] completion in - switch completion { - case let .failure(error): - // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead - if error as? AuthenticationError != .invalidSession { - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.Update.Error.Title"), message: error.legibleLocalizedDescription) - } - case .finished: - Current.defaults.setDate(Current.date(), forKey: "lastUpdated") - } - self.updatePublisher = nil - }, - receiveValue: { _ in } - ) + let taskID = UUID() + updateTaskID = taskID + let task = Task { @MainActor in + defer { + if updateTaskID == taskID { + updateTask = nil + updateTaskID = nil + } + } + do { + await self.updateSelectedXcodePathAsync() + let xcodes = try await self.updateAvailableXcodes(from: self.dataSource) + try Task.checkCancellation() + self.availableXcodes = xcodes + Current.defaults.setDate(Current.date(), forKey: "lastUpdated") + } catch is CancellationError { + } catch { + // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead + if error as? AuthenticationError != .invalidSession { + self.error = error + self.presentedAlert = .generic(title: localizeString("Alert.Update.Error.Title"), message: error.legibleLocalizedDescription) + } + } + } + updateTask = task } - - func updateSelectedXcodePath() -> AnyPublisher { - Current.shell.xcodeSelectPrintPath() - .handleEvents(receiveOutput: { output in self.selectedXcodePath = output.out }) + + func updateSelectedXcodePathAsync() async { + do { + let output = try await Current.shell.xcodeSelectPrintPath() + selectedXcodePath = output.out + } catch { // Ignore xcode-select failures - .map { _ in Void() } - .catch { _ in Just(()) } - .eraseToAnyPublisher() + } } - private func updateAvailableXcodes(from dataSource: DataSource) -> AnyPublisher<[AvailableXcode], Error> { - switch dataSource { - case .apple: - return signInIfNeeded() - .flatMap { [unowned self] in - // this will check to see if the Apple ID is a valid Apple Developer or not. - // If it's not, we can't use the Apple source to get xcode info. - self.validateSession() - } - .flatMap { [unowned self] in self.releasedXcodes().combineLatest(self.prereleaseXcodes()) } - .receive(on: DispatchQueue.main) - .map { releasedXcodes, prereleaseXcodes in - // Starting with Xcode 11 beta 6, developer.apple.com/download and developer.apple.com/download/more both list some pre-release versions of Xcode. - // Previously pre-release versions only appeared on developer.apple.com/download. - // /download/more doesn't include build numbers, so we trust that if the version number and prerelease identifiers are the same that they're the same build. - // If an Xcode version is listed on both sites then prefer the one on /download because the build metadata is used to compare against installed Xcodes. - let xcodes = releasedXcodes.filter { releasedXcode in - prereleaseXcodes.contains { $0.version.isEquivalent(to: releasedXcode.version) } == false - } + prereleaseXcodes - return xcodes - } - .handleEvents( - receiveOutput: { xcodes in - self.availableXcodes = xcodes - try? self.cacheAvailableXcodes(xcodes) - } - ) - .eraseToAnyPublisher() - case .xcodeReleases: - return xcodeReleases() - .receive(on: DispatchQueue.main) - .handleEvents( - receiveOutput: { xcodes in - self.availableXcodes = xcodes - try? self.cacheAvailableXcodes(xcodes) - } - ) - .eraseToAnyPublisher() + private func updateAvailableXcodes(from dataSource: DataSource) async throws -> [AvailableXcode] { + if dataSource == .apple { + try await signInIfNeededAsync() + // This checks whether the Apple ID is a valid Apple Developer account. + try await validateSessionAsync() } + + let service = XcodeListService(urlSession: Current.network.session) + var store = availableXcodeListStore(service: service) + return try await store.updateAvailableXcodes(from: dataSource) } } @@ -116,128 +92,69 @@ extension AppState { // MARK: - Available Xcode Cache func loadCachedAvailableXcodes() throws { - guard let data = Current.files.contents(atPath: Path.cacheFile.string) else { return } - let xcodes = try JSONDecoder().decode([AvailableXcode].self, from: data) - self.availableXcodes = xcodes + var store = availableXcodeListStore() + try store.loadCachedAvailableXcodes() + availableXcodes = store.availableXcodes } func cacheAvailableXcodes(_ xcodes: [AvailableXcode]) throws { - let data = try JSONEncoder().encode(xcodes) - try FileManager.default.createDirectory(at: Path.cacheFile.url.deletingLastPathComponent(), - withIntermediateDirectories: true) - try data.write(to: Path.cacheFile.url) + try availableXcodeListStore().saveAvailableXcodes(xcodes) + } + + private func availableXcodeListStore(service: XcodeListService = XcodeListService()) -> XcodeListStore { + XcodeListStore( + cache: availableXcodeCache, + service: service, + now: { Current.date() } + ) + } + + private var availableXcodeCache: AvailableXcodeCache { + AvailableXcodeCache( + cacheFile: .cacheFile, + contentsAtPath: { path in Current.files.contents(atPath: path) }, + writeData: { data, url in try Current.files.write(data, to: url) }, + createDirectory: { url, createIntermediates, attributes in + try Current.files.createDirectory( + at: url, + withIntermediateDirectories: createIntermediates, + attributes: attributes + ) + } + ) } // MARK: Runtime Cache func loadCacheDownloadableRuntimes() throws { - guard let data = Current.files.contents(atPath: Path.runtimeCacheFile.string) else { return } - let runtimes = try JSONDecoder().decode([DownloadableRuntime].self, from: data) - self.downloadableRuntimes = runtimes + var store = runtimeListStore + try store.loadCachedDownloadableRuntimes() + downloadableRuntimes = store.downloadableRuntimes } func cacheDownloadableRuntimes(_ runtimes: [DownloadableRuntime]) throws { - let data = try JSONEncoder().encode(runtimes) - try FileManager.default.createDirectory(at: Path.runtimeCacheFile.url.deletingLastPathComponent(), - withIntermediateDirectories: true) - try data.write(to: Path.runtimeCacheFile.url) + try runtimeListStore.saveDownloadableRuntimes(runtimes) } -} -extension AppState { - // MARK: - Apple - - private func releasedXcodes() -> AnyPublisher<[AvailableXcode], Swift.Error> { - Current.network.dataTask(with: URLRequest.downloads) - .map(\.data) - .decode(type: Downloads.self, decoder: configure(JSONDecoder()) { - $0.dateDecodingStrategy = .formatted(.downloadsDateModified) - }) - .tryMap { downloads -> [AvailableXcode] in - if downloads.hasError { - throw AuthenticationError.invalidResult(resultString: downloads.resultsString) - } - guard let downloadList = downloads.downloads else { - throw AuthenticationError.invalidResult(resultString: localizeString("DownloadingError")) - } - let xcodes = downloadList - .filter { $0.name.range(of: "^Xcode [0-9]", options: .regularExpression) != nil } - .compactMap { download -> AvailableXcode? in - let urlPrefix = URL(string: "https://download.developer.apple.com/")! - guard - let xcodeFile = download.files.first(where: { $0.remotePath.hasSuffix("dmg") || $0.remotePath.hasSuffix("xip") }), - let version = Version(xcodeVersion: download.name) - else { return nil } - - let url = urlPrefix.appendingPathComponent(xcodeFile.remotePath) - return AvailableXcode(version: version, url: url, filename: String(xcodeFile.remotePath.suffix(fromLast: "/")), releaseDate: download.dateModified, fileSize: xcodeFile.fileSize) - } - return xcodes - } - .eraseToAnyPublisher() + var runtimeListStore: RuntimeListStore { + RuntimeListStore( + cache: downloadableRuntimeCache, + service: runtimeService + ) } - private func prereleaseXcodes() -> AnyPublisher<[AvailableXcode], Error> { - Current.network.dataTask(with: URLRequest.download) - .tryMap { (data, _) -> [AvailableXcode] in - try self.parsePrereleaseXcodes(from: data) - } - .eraseToAnyPublisher() - } - - private func parsePrereleaseXcodes(from data: Data) throws -> [AvailableXcode] { - let body = String(data: data, encoding: .utf8)! - let document = try SwiftSoup.parse(body) - - guard - let xcodeHeader = try document.select("h2:containsOwn(Xcode)").first(), - let productBuildVersion = try xcodeHeader.parent()?.select("li:contains(Build)").text().replacingOccurrences(of: "Build", with: ""), - let releaseDateString = try xcodeHeader.parent()?.select("li:contains(Released)").text().replacingOccurrences(of: "Released", with: ""), - let version = Version(xcodeVersion: try xcodeHeader.text(), buildMetadataIdentifier: productBuildVersion), - let path = try document.select(".direct-download[href*=xip]").first()?.attr("href"), - let url = URL(string: "https://developer.apple.com" + path) - else { return [] } - - let filename = String(path.suffix(fromLast: "/")) - - return [AvailableXcode(version: version, url: url, filename: filename, releaseDate: DateFormatter.downloadsReleaseDate.date(from: releaseDateString))] - } -} - -extension AppState { - // MARK: - XcodeReleases - - private func xcodeReleases() -> AnyPublisher<[AvailableXcode], Error> { - Current.network.dataTask(with: URLRequest(url: URL(string: "https://xcodereleases.com/data.json")!)) - .map(\.data) - .decode(type: [XcodeRelease].self, decoder: JSONDecoder()) - .map { xcReleasesXcodes in - let xcodes = xcReleasesXcodes.compactMap { xcReleasesXcode -> AvailableXcode? in - guard - let downloadURL = xcReleasesXcode.links?.download?.url, - let version = Version(xcReleasesXcode: xcReleasesXcode) - else { return nil } - - let releaseDate = Calendar(identifier: .gregorian).date(from: DateComponents( - year: xcReleasesXcode.date.year, - month: xcReleasesXcode.date.month, - day: xcReleasesXcode.date.day - )) - - return AvailableXcode( - version: version, - url: downloadURL, - filename: String(downloadURL.path.suffix(fromLast: "/")), - releaseDate: releaseDate, - requiredMacOSVersion: xcReleasesXcode.requires, - releaseNotesURL: xcReleasesXcode.links?.notes?.url, - sdks: xcReleasesXcode.sdks, - compilers: xcReleasesXcode.compilers, - architectures: xcReleasesXcode.architectures - ) - } - return xcodes + private var downloadableRuntimeCache: DownloadableRuntimeCache { + DownloadableRuntimeCache( + cacheFile: .runtimeCacheFile, + contentsAtPath: { path in Current.files.contents(atPath: path) }, + writeData: { data, url in try Current.files.write(data, to: url) }, + createDirectory: { url, createIntermediates, attributes in + try Current.files.createDirectory( + at: url, + withIntermediateDirectories: createIntermediates, + attributes: attributes + ) } - .eraseToAnyPublisher() + ) } } diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 1f6419dc..3d432196 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -1,6 +1,6 @@ import AppKit -import AppleAPI -import Combine +import XcodesLoginKit +import XcodesLoginKitSecurityKey import Path import LegibleError import KeychainAccess @@ -9,7 +9,6 @@ import Version import os.log import DockProgress import XcodesKit -import LibFido2Swift enum PreferenceKey: String { case installPath @@ -27,16 +26,20 @@ enum PreferenceKey: String { case allowedMajorVersions case hideSupportXcodes case xcodeListArchitectures + case enableGroupedXcodeList + case expandedMajorXcodeVersions + case expandedMinorXcodeVersions func isManaged() -> Bool { UserDefaults.standard.objectIsForced(forKey: self.rawValue) } } +@MainActor class AppState: ObservableObject { - private let client = AppleAPI.Client() - internal let runtimeService = RuntimeService() - + private var client: XcodesLoginKit.Client { Current.network.loginClient } + internal var runtimeService: RuntimeService + // MARK: - Published Properties - + @Published var authenticationState: AuthenticationState = .unauthenticated @Published var availableXcodes: [AvailableXcode] = [] { willSet { @@ -44,8 +47,8 @@ class AppState: ObservableObject { Current.notificationManager.scheduleNotification(title: localizeString("Notification.NewXcodeVersion.Title"), body: localizeString("Notification.NewXcodeVersion.Body"), category: .normal) } updateAllXcodes( - availableXcodes: newValue, - installedXcodes: Current.files.installedXcodes(Path.installDirectory), + availableXcodes: newValue, + installedXcodes: Current.files.installedXcodes(Path.installDirectory), selectedXcodePath: selectedXcodePath ) } @@ -63,10 +66,14 @@ class AppState: ObservableObject { ) } } - @Published var updatePublisher: AnyCancellable? - var isUpdating: Bool { updatePublisher != nil } + @Published var updateTask: Task? + var updateTaskID: UUID? + var isUpdating: Bool { updateTask != nil } @Published var presentedSheet: XcodesSheet? = nil @Published var isProcessingAuthRequest = false + private var authenticationRequestID: UUID? + private var authenticationTask: Task? + private var authenticationTaskID: UUID? @Published var xcodeBeingConfirmedForUninstallation: Xcode? @Published var presentedAlert: XcodesAlert? @Published var presentedPreferenceAlert: XcodesPreferencesAlert? @@ -74,19 +81,20 @@ class AppState: ObservableObject { /// Whether the user is being prepared for the helper installation alert with an explanation. /// This closure will be performed after the user chooses whether or not to proceed. @Published var isPreparingUserForActionRequiringHelper: ((Bool) -> Void)? + var helperActionPreparationID: UUID? // MARK: - Errors @Published var error: Error? @Published var authError: Error? - + // MARK: Advanced Preferences @Published var localPath = "" { didSet { Current.defaults.set(localPath, forKey: "localPath") } } - + var disableLocalPathChange: Bool { PreferenceKey.localPath.isManaged() } @Published var installPath = "" { @@ -102,7 +110,7 @@ class AppState: ObservableObject { Current.defaults.set(unxipExperiment, forKey: "unxipExperiment") } } - + var disableUnxipExperiment: Bool { PreferenceKey.unxipExperiment.isManaged() } @Published var createSymLinkOnSelect = false { @@ -110,21 +118,21 @@ class AppState: ObservableObject { Current.defaults.set(createSymLinkOnSelect, forKey: "createSymLinkOnSelect") } } - + var createSymLinkOnSelectDisabled: Bool { return onSelectActionType == .rename || PreferenceKey.createSymLinkOnSelect.isManaged() } - + @Published var onSelectActionType = SelectedActionType.none { didSet { Current.defaults.set(onSelectActionType.rawValue, forKey: "onSelectActionType") - + if onSelectActionType == .rename { createSymLinkOnSelect = false } } } - + var onSelectActionTypeDisabled: Bool { PreferenceKey.onSelectActionType.isManaged() } @Published var showOpenInRosettaOption = false { @@ -132,41 +140,64 @@ class AppState: ObservableObject { Current.defaults.set(showOpenInRosettaOption, forKey: "showOpenInRosettaOption") } } - + @Published var terminateAfterLastWindowClosed = false { didSet { Current.defaults.set(terminateAfterLastWindowClosed, forKey: "terminateAfterLastWindowClosed") } } - + + @Published var enableGroupedXcodeList = true { + didSet { + Current.defaults.set(enableGroupedXcodeList, forKey: PreferenceKey.enableGroupedXcodeList.rawValue) + } + } + // MARK: - Runtimes - + @Published var downloadableRuntimes: [DownloadableRuntime] = [] @Published var installedRuntimes: [CoreSimulatorImage] = [] - // MARK: - Publisher Cancellables - - var cancellables = Set() - private var installationPublishers: [XcodeID: AnyCancellable] = [:] - internal var runtimePublishers: [String: Task<(), any Error>] = [:] - private var selectPublisher: AnyCancellable? - private var uninstallPublisher: AnyCancellable? + // MARK: - Operation State + + var downloadableRuntimesTask: Task? + var downloadableRuntimesTaskID: UUID? + var installedRuntimesTask: Task? + var installedRuntimesTaskID: UUID? + internal var installationTasks: [XcodeID: Task] = [:] + internal var installationTaskIDs: [XcodeID: UUID] = [:] + internal var runtimeTasks: [String: Task] = [:] + internal var runtimeTaskIDs: [String: UUID] = [:] + internal var deleteRuntimeTask: Task? + internal var deleteRuntimeTaskID: UUID? + internal var helperInstallTask: Task? + internal var helperInstallTaskID: UUID? + internal var postInstallTask: Task? + internal var postInstallTaskID: UUID? + private var helperStatusTask: Task? + internal var selectTask: Task? + internal var selectTaskID: UUID? + internal var uninstallTask: Task? + internal var uninstallTaskID: UUID? private var autoInstallTimer: Timer? - + // MARK: - Dock Progress Tracking - + public static let totalProgressUnits = Int64(10) public static let unxipProgressWeight = Int64(1) var overallProgress = Progress() - var unxipProgress = { + var unxipProgress = AppState.makeUnxipProgress() + var overallProgressChildIDs = Set() + + static func makeUnxipProgress() -> Progress { let progress = Progress(totalUnitCount: totalProgressUnits) progress.kind = .file progress.fileOperationKind = .copying return progress - }() - - // MARK: - - + } + + // MARK: - + var dataSource: DataSource { Current.defaults.string(forKey: "dataSource").flatMap(DataSource.init(rawValue:)) ?? .default } @@ -178,29 +209,33 @@ class AppState: ObservableObject { var hasSavedUsername: Bool { savedUsername != nil } - + var bottomStatusBarMessage: String { let formatter = DateFormatter() formatter.dateFormat = "dd/MM/yyyy" let finishDate = formatter.date(from: "11/06/2022") - + if Date().compare(finishDate!) == .orderedAscending { return String(format: localizeString("WWDC.Message"), "2022") } return "" } - + // MARK: - Init - - init() { + + init(runtimeService: RuntimeService = RuntimeService()) { + self.runtimeService = runtimeService guard !isTesting else { return } try? loadCachedAvailableXcodes() try? loadCacheDownloadableRuntimes() - checkIfHelperIsInstalled() + helperStatusTask = Task { @MainActor in + await checkIfHelperIsInstalled() + helperStatusTask = nil + } setupAutoInstallTimer() setupDefaults() } - + func setupDefaults() { localPath = Current.defaults.string(forKey: "localPath") ?? Path.defaultXcodesApplicationSupport.string unxipExperiment = Current.defaults.bool(forKey: "unxipExperiment") ?? false @@ -209,102 +244,78 @@ class AppState: ObservableObject { installPath = Current.defaults.string(forKey: "installPath") ?? Path.defaultInstallDirectory.string showOpenInRosettaOption = Current.defaults.bool(forKey: "showOpenInRosettaOption") ?? false terminateAfterLastWindowClosed = Current.defaults.bool(forKey: "terminateAfterLastWindowClosed") ?? false + enableGroupedXcodeList = Current.defaults.get(forKey: PreferenceKey.enableGroupedXcodeList.rawValue) as? Bool ?? true } - + // MARK: Timer /// Runs a timer every 6 hours when app is open to check if it needs to auto install any xcodes func setupAutoInstallTimer() { guard let storageValue = Current.defaults.get(forKey: "autoInstallation") as? Int, let autoInstallType = AutoInstallationType(rawValue: storageValue) else { return } if autoInstallType == .none { return } - + autoInstallTimer = Timer.scheduledTimer(withTimeInterval: 60*60*6, repeats: true) { [weak self] _ in - self?.updateIfNeeded() + Task { @MainActor in + self?.updateIfNeeded() + } } } // MARK: - Authentication - - func validateADCSession(path: String) -> AnyPublisher { - return Current.network.dataTask(with: URLRequest.downloadADCAuth(path: path)) - .receive(on: DispatchQueue.main) - .tryMap { result -> Void in - let httpResponse = result.response as! HTTPURLResponse - if httpResponse.statusCode == 401 { - throw AuthenticationError.notAuthorized - } - } - .eraseToAnyPublisher() - } - + func validateADCSession(path: String) async throws { - let result = try await Current.network.dataTaskAsync(with: URLRequest.downloadADCAuth(path: path)) - let httpResponse = result.1 as! HTTPURLResponse - if httpResponse.statusCode == 401 { - throw AuthenticationError.notAuthorized - } - } - - func validateSession() -> AnyPublisher { - - return Current.network.validateSession() - .receive(on: DispatchQueue.main) - .handleEvents(receiveCompletion: { completion in - if case .failure = completion { - // this is causing some awkwardness with showing an alert with the error and also popping up the sign in view - // self.authenticationState = .unauthenticated - // self.presentedSheet = .signIn - } - }) - .eraseToAnyPublisher() - } - - func signInIfNeeded() -> AnyPublisher { - validateSession() - .catch { (error) -> AnyPublisher in - guard - let username = self.savedUsername, - let password = try? Current.keychain.getString(username) - else { - return Fail(error: error) - .eraseToAnyPublisher() - } + try await DeveloperPortalSessionService( + loadData: { request in + try await Current.network.dataTaskAsync(with: request) + }, + unauthorizedError: { AuthenticationError.notAuthorized } + ).validateADCSession(path: path) + } + + func validateSessionAsync() async throws { + try await Current.network.validateSessionAsync() + } - return self.signIn(username: username, password: password) - .map { _ in Void() } - .eraseToAnyPublisher() + func signInIfNeededAsync() async throws { + do { + try await validateSessionAsync() + } catch { + guard + let username = savedUsername, + let password = try? Current.keychain.getString(username) + else { + throw error } - .eraseToAnyPublisher() + + _ = try await signInAsync(username: username, password: password) + } } - - func signIn(username: String, password: String) { + + func signIn(username: String, password: String?) { authError = nil - signIn(username: username.lowercased(), password: password) - .sink( - receiveCompletion: { _ in }, - receiveValue: { _ in } - ) - .store(in: &cancellables) + startAuthenticationTask { + _ = try await self.signInAsync(username: username.lowercased(), password: password) + } } - - func signIn(username: String, password: String) -> AnyPublisher { - try? Current.keychain.set(password, key: username) + + func signInAsync(username: String, password: String?) async throws -> AuthenticationState { + if let password, !password.isEmpty { + try? Current.keychain.set(password, key: username) + } Current.defaults.set(username, forKey: "username") - - isProcessingAuthRequest = true - return client.srpLogin(accountName: username, password: password) - .receive(on: DispatchQueue.main) - .handleEvents( - receiveOutput: { authenticationState in - self.authenticationState = authenticationState - }, - receiveCompletion: { completion in - self.handleAuthenticationFlowCompletion(completion) - self.isProcessingAuthRequest = false - } - ) - .eraseToAnyPublisher() + + return try await performAuthenticationRequest { + try await client.authenticationState(accountName: username, password: password) + } } - + + func submitFederatedAuthenticationCallback(_ callbackURLString: String) { + startAuthenticationTask { + _ = try await self.performAuthenticationRequest { + try await self.client.validateFederatedCallbackURLString(callbackURLString) + } + } + } + func handleTwoFactorOption(_ option: TwoFactorOption, authOptions: AuthOptionsResponse, serviceKey: String, sessionID: String, scnt: String) { let sessionData = AppleSessionData(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) @@ -319,25 +330,14 @@ class AppState: ObservableObject { } } - func requestSMS(to trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber, authOptions: AuthOptionsResponse, sessionData: AppleSessionData) { - isProcessingAuthRequest = true - client.requestSMSSecurityCode(to: trustedPhoneNumber, authOptions: authOptions, sessionData: sessionData) - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { completion in - self.handleAuthenticationFlowCompletion(completion) - self.isProcessingAuthRequest = false - }, - receiveValue: { authenticationState in - self.authenticationState = authenticationState - if case let AuthenticationState.waitingForSecondFactor(option, authOptions, sessionData) = authenticationState { - self.handleTwoFactorOption(option, authOptions: authOptions, serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt) - } - } - ) - .store(in: &cancellables) + func requestSMS(to trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber, authOptions: AuthOptionsResponse, sessionData: AppleSessionData) { + startAuthenticationTask { + _ = try await self.performAuthenticationRequest { + try await self.client.requestSMSSecurityCode(to: trustedPhoneNumber, authOptions: authOptions, sessionData: sessionData) + } + } } - + func choosePhoneNumberForSMS(authOptions: AuthOptionsResponse, sessionData: AppleSessionData) { self.presentedSheet = .twoFactor(.init( option: .smsPendingChoice, @@ -345,125 +345,115 @@ class AppState: ObservableObject { sessionData: sessionData )) } - + func submitSecurityCode(_ code: SecurityCode, sessionData: AppleSessionData) { - isProcessingAuthRequest = true - client.submitSecurityCode(code, sessionData: sessionData) - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { completion in - self.handleAuthenticationFlowCompletion(completion) - self.isProcessingAuthRequest = false - }, - receiveValue: { authenticationState in - self.authenticationState = authenticationState - } - ) - .store(in: &cancellables) + startAuthenticationTask { + _ = try await self.performAuthenticationRequest { + try await self.client.submitSecurityCode(code, sessionData: sessionData) + } + } } - - private lazy var fido2 = FIDO2() func createAndSubmitSecurityKeyAssertationWithPinCode(_ pinCode: String?, sessionData: AppleSessionData, authOptions: AuthOptionsResponse) { self.presentedSheet = .securityKeyTouchToConfirm - - guard let fsaChallenge = authOptions.fsaChallenge else { - // This shouldn't happen - // we shouldn't have called this method without setting the fsaChallenge - // so this is an assertionFailure - assertionFailure() - self.authError = "Something went wrong. Please file a bug report" - return - } - - // The challenge is encoded in Base64URL encoding - let challengeUrl = fsaChallenge.challenge - let challenge = FIDO2.base64urlToBase64(base64url: challengeUrl) - let origin = "https://idmsa.apple.com" - let rpId = "apple.com" - // Allowed creds is sent as a comma separated string - let validCreds = fsaChallenge.allowedCredentials.split(separator: ",").map(String.init) - Task { - do { - let response = try fido2.respondToChallenge(args: ChallengeArgs(rpId: rpId, validCredentials: validCreds, devPin: pinCode, challenge: challenge, origin: origin)) - - Task { @MainActor in - self.isProcessingAuthRequest = true - } - - let respData = try JSONEncoder().encode(response) - client.submitChallenge(response: respData, sessionData: AppleSessionData(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt)) - .receive(on: DispatchQueue.main) - .handleEvents( - receiveOutput: { authenticationState in - self.authenticationState = authenticationState - }, - receiveCompletion: { completion in - self.handleAuthenticationFlowCompletion(completion) - self.isProcessingAuthRequest = false - } - ).sink( - receiveCompletion: { _ in }, - receiveValue: { _ in } - ).store(in: &cancellables) - } catch FIDO2Error.canceledByUser { - // User cancelled the auth flow - // we don't have to show an error - // because the sheet will already be dismissed - } catch { - Task { @MainActor in - authError = error - } + startAuthenticationTask { + _ = try await self.performAuthenticationRequest { + try await self.client.submitSecurityKeyPinCode(pinCode, sessionData: sessionData, authOptions: authOptions) } } } func fido2DeviceIsPresent() -> Bool { - fido2.hasDeviceAttached() + client.hasSecurityKeyDeviceAttached() } func fido2DeviceNeedsPin() -> Bool { do { - return try fido2.deviceHasPin() + return try client.securityKeyDeviceNeedsPin() } catch { - Task { @MainActor in - authError = error - } - + authError = error return true } } - + func cancelSecurityKeyAssertationRequest() { - self.fido2.cancel() - } - - private func handleAuthenticationFlowCompletion(_ completion: Subscribers.Completion) { - switch completion { - case let .failure(error): - // remove saved username and any stored keychain password if authentication fails so it doesn't try again. - clearLoginCredentials() - Logger.appState.error("Authentication error: \(error.legibleDescription)") - self.authError = error - case .finished: - switch self.authenticationState { - case .authenticated, .unauthenticated, .notAppleDeveloper: - self.presentedSheet = nil - case let .waitingForSecondFactor(option, authOptions, sessionData): - self.handleTwoFactorOption(option, authOptions: authOptions, serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt) + self.client.cancelSecurityKeyAssertationRequest() + } + + private func handleAuthenticationFlowFailure(_ error: Error) { + // remove saved username and any stored keychain password if authentication fails so it doesn't try again. + clearLoginCredentials() + Logger.appState.error("Authentication error: \(error.legibleDescription)") + self.authError = error + } + + private func handleAuthenticationFlowSuccess() { + switch self.authenticationState { + case .authenticated, .unauthenticated, .notAppleDeveloper: + self.presentedSheet = nil + case .waitingForFederatedAuthentication: + break + case let .waitingForSecondFactor(option, authOptions, sessionData): + self.handleTwoFactorOption(option, authOptions: authOptions, serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt) + } + } + + private func performAuthenticationRequest( + _ operation: () async throws -> AuthenticationState + ) async throws -> AuthenticationState { + let requestID = UUID() + authenticationRequestID = requestID + isProcessingAuthRequest = true + defer { + if authenticationRequestID == requestID { + isProcessingAuthRequest = false + authenticationRequestID = nil } } + + do { + let authenticationState = try await operation() + guard authenticationRequestID == requestID else { return authenticationState } + self.authenticationState = authenticationState + handleAuthenticationFlowSuccess() + return authenticationState + } catch { + if authenticationRequestID == requestID { + handleAuthenticationFlowFailure(error) + } + throw error + } } - + + private func startAuthenticationTask(_ operation: @escaping () async throws -> Void) { + authenticationTask?.cancel() + let taskID = UUID() + authenticationTaskID = taskID + authenticationTask = Task { @MainActor in + defer { + if authenticationTaskID == taskID { + authenticationTask = nil + authenticationTaskID = nil + } + } + do { + try await operation() + } catch is CancellationError { + } catch { + // performAuthenticationRequest owns auth error presentation. + } + } + } + func signOut() { clearLoginCredentials() - AppleAPI.Current.network.session.configuration.httpCookieStorage?.removeCookies(since: .distantPast) + Current.network.signout() authenticationState = .unauthenticated } - + // MARK: - Helper - + /// Install the privileged helper if it isn't already installed. /// /// The way this is done is a little roundabout, because it requires user interaction in an alert before installation should be attempted. @@ -476,66 +466,90 @@ class AppState: ObservableObject { /// - Parameter shouldPrepareUserForHelperInstallation: Whether the user should be presented with an alert preparing them for helper installation. func installHelperIfNecessary(shouldPrepareUserForHelperInstallation: Bool = true) { guard helperInstallState == .installed || shouldPrepareUserForHelperInstallation == false else { - isPreparingUserForActionRequiringHelper = { [unowned self] userConsented in + prepareForHelperAction { [weak self] userConsented in guard userConsented else { return } - self.installHelperIfNecessary(shouldPrepareUserForHelperInstallation: false) + self?.installHelperIfNecessary(shouldPrepareUserForHelperInstallation: false) } - presentedAlert = .privilegedHelper return } - - installHelperIfNecessary() - .sink( - receiveCompletion: { [unowned self] completion in - if case let .failure(error) = completion { - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.PrivilegedHelper.Error.Title"), message: error.legibleLocalizedDescription) - } - }, - receiveValue: {} - ) - .store(in: &cancellables) - } - - func installHelperIfNecessary() -> AnyPublisher { - Result { - if helperInstallState == .notInstalled { - try Current.helper.install() - checkIfHelperIsInstalled() + + helperInstallTask?.cancel() + let taskID = UUID() + helperInstallTaskID = taskID + helperInstallTask = Task { @MainActor in + defer { + if helperInstallTaskID == taskID { + helperInstallTask = nil + helperInstallTaskID = nil + } + } + do { + try await installHelperIfNecessaryAsync() + } catch is CancellationError { + } catch { + self.error = error + self.presentedAlert = .generic(title: localizeString("Alert.PrivilegedHelper.Error.Title"), message: error.legibleLocalizedDescription) } } - .publisher - .subscribe(on: DispatchQueue.main) - .eraseToAnyPublisher() } - - private func checkIfHelperIsInstalled() { + + func installHelperIfNecessaryAsync() async throws { + if helperInstallState == .unknown { + await checkIfHelperIsInstalled() + try Task.checkCancellation() + } + + if helperInstallState == .notInstalled { + try Task.checkCancellation() + try await Current.helper.install() + try Task.checkCancellation() + await checkIfHelperIsInstalled() + } + } + + private func checkIfHelperIsInstalled() async { helperInstallState = .unknown - Current.helper.checkIfLatestHelperIsInstalled() - .receive(on: DispatchQueue.main) - .sink( - receiveValue: { installed in - self.helperInstallState = installed ? .installed : .notInstalled - } - ) - .store(in: &cancellables) + let installed = (try? await Current.helper.checkIfLatestHelperIsInstalledAsync()) ?? false + helperInstallState = installed ? .installed : .notInstalled + } + + @discardableResult + func prepareForHelperAction(preparationID: UUID = UUID(), _ action: @escaping (Bool) -> Void) -> UUID { + helperActionPreparationID = preparationID + var didHandleResponse = false + isPreparingUserForActionRequiringHelper = { [weak self] userConsented in + guard let self else { return } + guard self.helperActionPreparationID == preparationID else { return } + guard didHandleResponse == false else { return } + didHandleResponse = true + helperActionPreparationID = nil + isPreparingUserForActionRequiringHelper = nil + action(userConsented) + } + presentedAlert = .privilegedHelper + return preparationID } - + + func respondToPreparedHelperAction(userConsented: Bool) { + let helperAction = isPreparingUserForActionRequiringHelper + helperAction?(userConsented) + presentedAlert = nil + } + // MARK: - Install - + func checkMinVersionAndInstall(id: XcodeID) { guard let availableXcode = availableXcodes.first(where: { $0.xcodeID == id }) else { return } - - // Check to see if users macOS is supported - if let requiredMacOSVersion = availableXcode.requiredMacOSVersion { - if hasMinSupportedOS(requiredMacOSVersion: requiredMacOSVersion) { - // prompt - self.presentedAlert = .checkMinSupportedVersion(xcode: availableXcode, macOS: ProcessInfo.processInfo.operatingSystemVersion.versionString()) - return - } + + switch compatibilityStatus(for: availableXcode) { + case .supported: + break + case let .unsupported(_, currentMacOSVersion): + self.presentedAlert = .checkMinSupportedVersion(xcode: availableXcode, macOS: currentMacOSVersion) + return } - + switch self.dataSource { case .apple: install(id: id) @@ -543,165 +557,144 @@ class AppState: ObservableObject { install(id: id) } } - + func hasMinSupportedOS(requiredMacOSVersion: String) -> Bool { - let split = requiredMacOSVersion.components(separatedBy: ".").compactMap { Int($0) } - let xcodeMinimumMacOSVersion = OperatingSystemVersion(majorVersion: split[safe: 0] ?? 0, minorVersion: split[safe: 1] ?? 0, patchVersion: split[safe: 2] ?? 0) - - return !ProcessInfo.processInfo.isOperatingSystemAtLeast(xcodeMinimumMacOSVersion) + compatibilityStatus(requiredMacOSVersion: requiredMacOSVersion).isUnsupported + } + + private func compatibilityStatus(for availableXcode: AvailableXcode) -> XcodeCompatibilityStatus { + XcodeCompatibilityService().status( + for: availableXcode, + currentOSVersion: ProcessInfo.processInfo.operatingSystemVersion + ) + } + + private func compatibilityStatus(requiredMacOSVersion: String) -> XcodeCompatibilityStatus { + XcodeCompatibilityService().status( + requiredMacOSVersion: requiredMacOSVersion, + currentOSVersion: ProcessInfo.processInfo.operatingSystemVersion + ) } - + func install(id: XcodeID) { guard let availableXcode = availableXcodes.first(where: { $0.xcodeID == id }) else { return } - installationPublishers[id] = signInIfNeeded() - .handleEvents( - receiveSubscription: { [unowned self] _ in - self.setInstallationStep(of: availableXcode.version, to: .authenticating) + installationTasks[id]?.cancel() + let installationTaskID = UUID() + installationTaskIDs[id] = installationTaskID + installationTasks[id] = Task { @MainActor in + defer { + if installationTaskIDs[id] == installationTaskID { + installationTasks[id] = nil + installationTaskIDs[id] = nil } - ) - .flatMap { [unowned self] in - // signInIfNeeded might finish before the user actually authenticates if UI is involved. - // This publisher will wait for the @Published authentication state to change to authenticated or unauthenticated before finishing, - // indicating that the user finished what they were doing in the UI. - self.$authenticationState - .filter { state in - switch state { - case .authenticated, .unauthenticated, .notAppleDeveloper: return true - case .waitingForSecondFactor: return false - } - } - .prefix(1) - .tryMap { state in - if state == .unauthenticated { - throw AuthenticationError.invalidSession - } - if state == .notAppleDeveloper { - throw AuthenticationError.notDeveloperAppleId - } - return Void() - } } - .flatMap { - // This request would've already been made if the Apple data source were being used. - // That's not the case for the Xcode Releases data source. - // We need the cookies from its response in order to download Xcodes though, - // so perform it here first just to be sure. - Current.network.dataTask(with: URLRequest.downloads) - .map(\.data) - .decode(type: Downloads.self, decoder: configure(JSONDecoder()) { - $0.dateDecodingStrategy = .formatted(.downloadsDateModified) - }) - .tryMap { downloads -> Void in - if downloads.hasError { - throw AuthenticationError.invalidResult(resultString: downloads.resultsString) - } - if downloads.downloads == nil { - throw AuthenticationError.invalidResult(resultString: localizeString("DownloadingError")) - } - } - .mapError { $0 as Error } + do { + setInstallationStep(of: availableXcode.version, to: .authenticating) + try await signInIfNeededAsync() + try await waitForAuthenticationTerminalState() + try await validateDeveloperDownloads() + _ = try await installAsync(.version(availableXcode), downloader: Downloader(rawValue: Current.defaults.string(forKey: "downloader") ?? "aria2") ?? .aria2, attemptNumber: 0) + } catch is CancellationError { + } catch { + handleInstallError(error, id: id) } - .flatMap { [unowned self] in - self.install(.version(availableXcode), downloader: Downloader(rawValue: Current.defaults.string(forKey: "downloader") ?? "aria2") ?? .aria2) + } + } + + private func validateDeveloperDownloads() async throws { + do { + try await XcodeListService { request in + try await Current.network.dataTaskAsync(with: request) + }.validateDeveloperDownloads(missingDownloadsMessage: localizeString("DownloadingError")) + } catch let error as XcodeListService.Error { + switch error { + case let .invalidResult(resultString): + throw AuthenticationError.invalidResult(resultString: resultString) } - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { [unowned self] completion in - self.installationPublishers[id] = nil - if case let .failure(error) = completion { - // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead - if let error = error as? AuthenticationError, case .notAuthorized = error { - self.error = error - self.presentedAlert = .unauthenticated - - } else if error as? AuthenticationError != .invalidSession { - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) - } - if let index = self.allXcodes.firstIndex(where: { $0.id == id }) { - self.allXcodes[index].installState = .notInstalled - } - } - }, - receiveValue: { _ in } - ) + } } - + /// Skips using the username/password to log in to Apple, and simply gets a Auth Cookie used in downloading /// As of Nov 2022 this was returning a 403 forbidden func installWithoutLogin(id: Xcode.ID) { guard let availableXcode = availableXcodes.first(where: { $0.xcodeID == id }) else { return } - - installationPublishers[id] = self.install(.version(availableXcode), downloader: Downloader(rawValue: Current.defaults.string(forKey: "downloader") ?? "aria2") ?? .aria2) - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { [unowned self] completion in - self.installationPublishers[id] = nil - if case let .failure(error) = completion { - // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead - if error as? AuthenticationError != .invalidSession { - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) - } - if let index = self.allXcodes.firstIndex(where: { $0.id == id }) { - self.allXcodes[index].installState = .notInstalled - } - } - }, - receiveValue: { _ in } - ) + + installationTasks[id]?.cancel() + let installationTaskID = UUID() + installationTaskIDs[id] = installationTaskID + installationTasks[id] = Task { @MainActor in + defer { + if installationTaskIDs[id] == installationTaskID { + installationTasks[id] = nil + installationTaskIDs[id] = nil + } + } + do { + _ = try await installAsync(.version(availableXcode), downloader: Downloader(rawValue: Current.defaults.string(forKey: "downloader") ?? "aria2") ?? .aria2, attemptNumber: 0) + } catch is CancellationError { + } catch { + handleInstallError(error, id: id) + } + } } - + func cancelInstall(id: Xcode.ID) { guard let availableXcode = availableXcodes.first(where: { $0.xcodeID == id }) else { return } - // Cancel the publisher - installationPublishers[id] = nil - + installationTasks[id]?.cancel() + installationTasks[id] = nil + installationTaskIDs[id] = nil + resetDockProgressTracking() - + // If the download is cancelled by the user, clean up the download files that aria2 creates. // This isn't done as part of the publisher with handleEvents(receiveCancel:) because it shouldn't happen when e.g. the app quits. - let expectedArchivePath = Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).\(availableXcode.filename.suffix(fromLast: "."))" - let aria2DownloadMetadataPath = expectedArchivePath.parent/(expectedArchivePath.basename() + ".aria2") - try? Current.files.removeItem(at: expectedArchivePath.url) - try? Current.files.removeItem(at: aria2DownloadMetadataPath.url) - - if let index = allXcodes.firstIndex(where: { $0.id == id }) { + ArchiveCancellationCleanupService( + removeItem: { try Current.files.removeItem(at: $0) } + ).cleanupXcodeArchive( + for: availableXcode, + applicationSupportPath: .xcodesApplicationSupport + ) + + if let index = allXcodes.firstIndex(where: { $0.id == id }) { allXcodes[index].installState = .notInstalled } } - + // MARK: - Uninstall func uninstall(xcode: Xcode) { - guard - let installedXcodePath = xcode.installedPath, - uninstallPublisher == nil - else { return } - - uninstallPublisher = uninstallXcode(path: installedXcodePath) - .flatMap { [unowned self] _ in - self.updateSelectedXcodePath() + guard let installedXcodePath = xcode.installedPath else { return } + + uninstallTask?.cancel() + let taskID = UUID() + uninstallTaskID = taskID + uninstallTask = Task { @MainActor in + defer { + if uninstallTaskID == taskID { + uninstallTask = nil + uninstallTaskID = nil + } } - .sink( - receiveCompletion: { [unowned self] completion in - if case let .failure(error) = completion { - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.Uninstall.Error.Title"), message: error.legibleLocalizedDescription) - } - self.uninstallPublisher = nil - }, - receiveValue: { _ in } - ) + do { + try Task.checkCancellation() + try await uninstallXcodeAsync(path: installedXcodePath) + try Task.checkCancellation() + await updateSelectedXcodePathAsync() + } catch is CancellationError { + } catch { + self.error = error + self.presentedAlert = .generic(title: localizeString("Alert.Uninstall.Error.Title"), message: error.legibleLocalizedDescription) + } + } } - + func reveal(_ path: Path?) { // TODO: show error if not guard let path = path else { return } NSWorkspace.shared.activateFileViewerSelecting([path.url]) } - + func reveal(path: String) { let url = URL(fileURLWithPath: path) NSWorkspace.shared.activateFileViewerSelecting([url]) @@ -710,7 +703,7 @@ class AppState: ObservableObject { /// Make an Xcode active, a.k.a select it, in the `xcode-select` sense. /// /// The underlying work is done by the privileged helper, so we need to make sure that it's installed first. - /// The way this is done is a little roundabout, because it requires user interaction in an alert before the `selectPublisher` is subscribed to. + /// The way this is done is a little roundabout, because it requires user interaction in an alert before the selection task is started. /// The first time this method is invoked should be with `shouldPrepareUserForHelperInstallation` set to true. /// If the helper is already installed, the Xcode will be made active immediately. /// If the helper is not already installed, the user will be prepared for installation and this method will return early. @@ -721,47 +714,49 @@ class AppState: ObservableObject { /// - Parameter shouldPrepareUserForHelperInstallation: Whether the user should be presented with an alert preparing them for helper installation before making the Xcode version active. func select(xcode: Xcode, shouldPrepareUserForHelperInstallation: Bool = true) { guard helperInstallState == .installed || shouldPrepareUserForHelperInstallation == false else { - isPreparingUserForActionRequiringHelper = { [unowned self] userConsented in + prepareForHelperAction { [weak self] userConsented in guard userConsented else { return } - self.select(xcode: xcode, shouldPrepareUserForHelperInstallation: false) + self?.select(xcode: xcode, shouldPrepareUserForHelperInstallation: false) } - presentedAlert = .privilegedHelper return } guard - var installedXcodePath = xcode.installedPath, - selectPublisher == nil + var installedXcodePath = xcode.installedPath else { return } - + if onSelectActionType == .rename { guard let newDestinationXcodePath = renameToXcode(xcode: xcode) else { return } installedXcodePath = newDestinationXcodePath } - - selectPublisher = installHelperIfNecessary() - .flatMap { - Current.helper.switchXcodePath(installedXcodePath.string) + + selectTask?.cancel() + let taskID = UUID() + selectTaskID = taskID + selectTask = Task { @MainActor in + defer { + if selectTaskID == taskID { + selectTask = nil + selectTaskID = nil + } } - .flatMap { [unowned self] _ in - self.updateSelectedXcodePath() + do { + try await installHelperIfNecessaryAsync() + try Task.checkCancellation() + try await Current.helper.switchXcodePathAsync(installedXcodePath.string) + try Task.checkCancellation() + await updateSelectedXcodePathAsync() + if createSymLinkOnSelect && onSelectActionType != .rename { + createSymbolicLink(to: installedXcodePath) + } + } catch is CancellationError { + } catch { + self.error = error + self.presentedAlert = .generic(title: localizeString("Alert.Select.Error.Title"), message: error.legibleLocalizedDescription) } - .sink( - receiveCompletion: { [unowned self] completion in - if case let .failure(error) = completion { - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.Select.Error.Title"), message: error.legibleLocalizedDescription) - } else { - if self.createSymLinkOnSelect { - createSymbolicLink(xcode: xcode) - } - } - self.selectPublisher = nil - }, - receiveValue: { _ in } - ) + } } - + func open(xcode: Xcode, openInRosetta: Bool? = false) { switch xcode.installState { case let .installed(path): @@ -776,10 +771,10 @@ class AppState: ObservableObject { return } } - + func copyPath(xcode: Xcode) { guard let installedXcodePath = xcode.installedPath else { return } - + NSPasteboard.general.declareTypes([.URL, .string], owner: nil) NSPasteboard.general.writeObjects([installedXcodePath.url as NSURL]) NSPasteboard.general.setString(installedXcodePath.string, forType: .string) @@ -791,64 +786,51 @@ class AppState: ObservableObject { NSPasteboard.general.writeObjects([url as NSURL]) NSPasteboard.general.setString(url.absoluteString, forType: .string) } - + func createSymbolicLink(xcode: Xcode, isBeta: Bool = false) { guard let installedXcodePath = xcode.installedPath else { return } - - let destinationPath: Path = Path.installDirectory/"Xcode\(isBeta ? "-Beta" : "").app" - - // does an Xcode.app file exist? - if FileManager.default.fileExists(atPath: destinationPath.string) { - do { - // if it's not a symlink, error because we don't want to delete an actual xcode.app file - let attributes: [FileAttributeKey : Any]? = try? FileManager.default.attributesOfItem(atPath: destinationPath.string) - - if attributes?[.type] as? FileAttributeType == FileAttributeType.typeSymbolicLink { - try FileManager.default.removeItem(atPath: destinationPath.string) - Logger.appState.info("Successfully deleted old symlink") - } else { - self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: localizeString("Alert.SymLink.Message")) - return - } - } catch { - self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.localizedDescription) - } - } - + createSymbolicLink(to: installedXcodePath, isBeta: isBeta) + } + + func createSymbolicLink(to installedXcodePath: Path, isBeta: Bool = false) { + let destinationPath = Path.installDirectory/"Xcode\(isBeta ? "-Beta" : "").app" + do { - try FileManager.default.createSymbolicLink(atPath: destinationPath.string, withDestinationPath: installedXcodePath.string) + let service = XcodeSelectionFilesystemService( + installedXcode: { Current.files.installedXcode(destination: $0) } + ) + let result = try service.createSymbolicLink( + to: installedXcodePath, + in: Path.installDirectory, + isBeta: isBeta + ) + if result.replacedExistingSymlink { + Logger.appState.info("Successfully deleted old symlink") + } Logger.appState.info("Successfully created symbolic link with Xcode\(isBeta ? "-Beta": "").app") } catch { Logger.appState.error("Unable to create symbolic Link") self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.legibleLocalizedDescription) + let message = error as? XcodeSelectionFilesystemError == .destinationExistsAndIsNotSymlink(destinationPath) + ? localizeString("Alert.SymLink.Message") + : error.legibleLocalizedDescription + self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: message) } } - + func renameToXcode(xcode: Xcode) -> Path? { guard let installedXcodePath = xcode.installedPath else { return nil } - - let destinationPath: Path = Path.installDirectory/"Xcode.app" - - // rename any old named `Xcode.app` to the Xcodes versioned named files - if FileManager.default.fileExists(atPath: destinationPath.string) { - if let originalXcode = Current.files.installedXcode(destination: destinationPath) { - let newName = "Xcode-\(originalXcode.version.descriptionWithoutBuildMetadata).app" - Logger.appState.debug("Found Xcode.app - renaming back to \(newName)") - do { - try destinationPath.rename(to: newName) - } catch { - Logger.appState.error("Unable to create rename Xcode.app back to original") - self.error = error - // TODO UPDATE MY ERROR STRING - self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.legibleLocalizedDescription) - } - } - } - // rename passed in xcode to xcode.app - Logger.appState.debug("Found Xcode.app - renaming back to Xcode.app") + do { - return try installedXcodePath.rename(to: "Xcode.app") + let service = XcodeSelectionFilesystemService( + installedXcode: { Current.files.installedXcode(destination: $0) } + ) + let renamedPath = try service.renameForSelection( + installedXcodePath: installedXcodePath, + in: Path.installDirectory + ) + Logger.appState.debug("Renamed selected Xcode to Xcode.app") + return renamedPath } catch { Logger.appState.error("Unable to create rename Xcode.app back to original") self.error = error @@ -859,124 +841,73 @@ class AppState: ObservableObject { } func updateAllXcodes(availableXcodes: [AvailableXcode], installedXcodes: [InstalledXcode], selectedXcodePath: String?) { - var adjustedAvailableXcodes = availableXcodes - - // First, adjust all of the available Xcodes so that available and installed versions line up and the second part of this function works properly. - if dataSource == .apple { - for installedXcode in installedXcodes { - // We can trust that build metadata identifiers are unique for each version of Xcode, so if we have it then it's all we need. - // If build metadata matches exactly, replace the available version with the installed version. - // This should handle Apple versions from /downloads/more which don't have build metadata identifiers. - if let index = adjustedAvailableXcodes.map(\.version).firstIndex(where: { $0.buildMetadataIdentifiers == installedXcode.version.buildMetadataIdentifiers }) { - adjustedAvailableXcodes[index].xcodeID = installedXcode.xcodeID - } - // If an installed version is the same as one that's listed online which doesn't have build metadata, replace it with the installed version - // Not all prerelease Apple versions available online include build metadata - else if let index = adjustedAvailableXcodes.firstIndex(where: { availableXcode in - availableXcode.version.isEquivalent(to: installedXcode.version) && - availableXcode.version.buildMetadataIdentifiers.isEmpty - }) { - adjustedAvailableXcodes[index].xcodeID = installedXcode.xcodeID - } - } - } + let existingXcodes = allXcodes.map(\.listItem) + let items = XcodeListComposer().compose( + availableXcodes: availableXcodes, + installedXcodes: installedXcodes, + selectedXcodePath: selectedXcodePath, + existingXcodes: existingXcodes, + dataSource: dataSource + ) - // Map all of the available versions into Xcode values that join available and installed Xcode data for display. - var newAllXcodes = adjustedAvailableXcodes - .filter { availableXcode in - // If we don't have the build identifier, don't attempt to filter prerelease versions with identical build identifiers - guard !availableXcode.version.buildMetadataIdentifiers.isEmpty else { return true } - - let availableXcodesWithIdenticalBuildIdentifiers = availableXcodes - .filter({ $0.version.buildMetadataIdentifiers == availableXcode.version.buildMetadataIdentifiers }) - - // Include this version if there's only one with this build identifier - return availableXcodesWithIdenticalBuildIdentifiers.count == 1 || - // Or if there's more than one with this build identifier and this is the release version - - availableXcodesWithIdenticalBuildIdentifiers.count > 1 && (availableXcode.version.prereleaseIdentifiers.isEmpty || availableXcode.architectures?.count ?? 0 != 0) - } - .map { availableXcode -> Xcode in - let installedXcode = installedXcodes.first(where: { installedXcode in - // if we want to have only specific Xcodes as selected instead of the Architecture Equivalent. - // if availableXcode.architectures == nil { -// return availableXcode.version.isEquivalent(to: installedXcode.version) -// } else { -// return availableXcode.xcodeID == installedXcode.xcodeID -// } - return availableXcode.version.isEquivalent(to: installedXcode.version) - }) - - let identicalBuilds: [XcodeID] - let prereleaseAvailableXcodesWithIdenticalBuildIdentifiers = availableXcodes - .filter { - return $0.version.buildMetadataIdentifiers == availableXcode.version.buildMetadataIdentifiers && - !$0.version.prereleaseIdentifiers.isEmpty && - // If we don't have the build identifier, don't consider this as a potential identical build - !$0.version.buildMetadataIdentifiers.isEmpty - } - // If this is the release version, add the identical builds to it - if !prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.isEmpty, availableXcode.version.prereleaseIdentifiers.isEmpty { - identicalBuilds = [availableXcode.xcodeID] + prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.map(\.xcodeID) - } else { - identicalBuilds = [] - } - - // If the existing install state is "installing", keep it - let existingXcodeInstallState = allXcodes.first { $0.id == availableXcode.xcodeID && $0.installState.installing }?.installState - // Otherwise, determine it from whether there's an installed Xcode - let defaultXcodeInstallState: XcodeInstallState = installedXcode.map { .installed($0.path) } ?? .notInstalled - - return Xcode( - version: availableXcode.version, - identicalBuilds: identicalBuilds, - installState: existingXcodeInstallState ?? defaultXcodeInstallState, - selected: installedXcode != nil && selectedXcodePath?.hasPrefix(installedXcode!.path.string) == true, - icon: (installedXcode?.path.string).map(NSWorkspace.shared.icon(forFile:)), - requiredMacOSVersion: availableXcode.requiredMacOSVersion, - releaseNotesURL: availableXcode.releaseNotesURL, - releaseDate: availableXcode.releaseDate, - sdks: availableXcode.sdks, - compilers: availableXcode.compilers, - downloadFileSize: availableXcode.fileSize, - architectures: availableXcode.architectures - ) - } - - // If an installed version isn't listed in the available versions, add the installed version - // Xcode Releases should have all versions - // Apple didn't used to keep all prerelease versions around but has started to recently - for installedXcode in installedXcodes { - if !newAllXcodes.contains(where: { xcode in xcode.version.isEquivalent(to: installedXcode.version) }) { - newAllXcodes.append( - Xcode( - version: installedXcode.version, - installState: .installed(installedXcode.path), - selected: selectedXcodePath?.hasPrefix(installedXcode.path.string) == true, - icon: NSWorkspace.shared.icon(forFile: installedXcode.path.string) - ) - ) - } + self.allXcodes = items.map { item in + Xcode(item, icon: item.installedPath.map { NSWorkspace.shared.icon(forFile: $0.string) }) } - - self.allXcodes = newAllXcodes.sorted { $0.version > $1.version } } - + // MARK: - Private - - private func uninstallXcode(path: Path) -> AnyPublisher { - return Deferred { - Future { promise in - do { - try Current.files.trashItem(at: path.url) - promise(.success(())) - } catch { - promise(.failure(error)) - } + + private func uninstallXcodeAsync(path: Path) async throws { + let xcode = InstalledXcode( + path: path, + contentsAtPath: { path in Current.files.contents(atPath: path) }, + loadArchitectures: Current.shell.archs + )! + _ = try XcodeUninstallService( + removeItem: { url in try Current.files.removeItem(at: url) }, + trashItem: { url in try Current.files.trashItem(at: url) } + ).uninstall(xcode, emptyTrash: false) + } + + private func waitForAuthenticationTerminalState() async throws { + func validate(_ state: AuthenticationState) throws -> Bool { + switch state { + case .authenticated: + return true + case .unauthenticated: + throw AuthenticationError.invalidSession + case .notAppleDeveloper: + throw AuthenticationError.notDeveloperAppleId + case .waitingForFederatedAuthentication: + return false + case .waitingForSecondFactor: + return false } } - .eraseToAnyPublisher() + + try Task.checkCancellation() + if try validate(authenticationState) { return } + + for await state in $authenticationState.values { + try Task.checkCancellation() + if try validate(state) { return } + } + } + + private func handleInstallError(_ error: Error, id: XcodeID) { + // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead + if let error = error as? AuthenticationError, case .notAuthorized = error { + self.error = error + self.presentedAlert = .unauthenticated + + } else if error as? AuthenticationError != .invalidSession { + self.error = error + self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) + } + if let index = self.allXcodes.firstIndex(where: { $0.id == id }) { + self.allXcodes[index].installState = .notInstalled + } } /// removes saved username and credentials stored in keychain @@ -996,9 +927,3 @@ class AppState: ObservableObject { var id: String { title + message } } } - -extension OperatingSystemVersion { - func versionString() -> String { - return String(majorVersion) + "." + String(minorVersion) + "." + String(patchVersion) - } -} diff --git a/Xcodes/Backend/Aria2CError.swift b/Xcodes/Backend/Aria2CError.swift deleted file mode 100644 index c6526266..00000000 --- a/Xcodes/Backend/Aria2CError.swift +++ /dev/null @@ -1,125 +0,0 @@ -import Foundation - -/// A LocalizedError that represents a non-zero exit code from running aria2c. -struct Aria2CError: LocalizedError { - var code: Code - - init?(exitStatus: Int32) { - guard let code = Code(rawValue: exitStatus) else { return nil } - self.code = code - } - - var errorDescription: String? { - "aria2c error: \(code.description)" - } - - // https://github.com/aria2/aria2/blob/master/src/error_code.h - enum Code: Int32, CustomStringConvertible { - case undefined = -1 - // Ignoring, not an error - // case finished = 0 - case unknownError = 1 - case timeOut - case resourceNotFound - case maxFileNotFound - case tooSlowDownloadSpeed - case networkProblem - case inProgress - case cannotResume - case notEnoughDiskSpace - case pieceLengthChanged - case duplicateDownload - case duplicateInfoHash - case fileAlreadyExists - case fileRenamingFailed - case fileOpenError - case fileCreateError - case fileIoError - case dirCreateError - case nameResolveError - case metalinkParseError - case ftpProtocolError - case httpProtocolError - case httpTooManyRedirects - case httpAuthFailed - case bencodeParseError - case bittorrentParseError - case magnetParseError - case optionError - case httpServiceUnavailable - case jsonParseError - case removed - case checksumError - - var description: String { - switch self { - case .undefined: - return "Undefined" - case .unknownError: - return "Unknown error" - case .timeOut: - return "Timed out" - case .resourceNotFound: - return "Resource not found" - case .maxFileNotFound: - return "Maximum number of file not found errors reached" - case .tooSlowDownloadSpeed: - return "Download speed too slow" - case .networkProblem: - return "Network problem" - case .inProgress: - return "Unfinished downloads in progress" - case .cannotResume: - return "Remote server did not support resume when resume was required to complete download" - case .notEnoughDiskSpace: - return "Not enough disk space available" - case .pieceLengthChanged: - return "Piece length was different from one in .aria2 control file" - case .duplicateDownload: - return "Duplicate download" - case .duplicateInfoHash: - return "Duplicate info hash torrent" - case .fileAlreadyExists: - return "File already exists" - case .fileRenamingFailed: - return "Renaming file failed" - case .fileOpenError: - return "Could not open existing file" - case .fileCreateError: - return "Could not create new file or truncate existing file" - case .fileIoError: - return "File I/O error" - case .dirCreateError: - return "Could not create directory" - case .nameResolveError: - return "Name resolution failed" - case .metalinkParseError: - return "Could not parse Metalink document" - case .ftpProtocolError: - return "FTP command failed" - case .httpProtocolError: - return "HTTP response header was bad or unexpected" - case .httpTooManyRedirects: - return "Too many redirects occurred" - case .httpAuthFailed: - return "HTTP authorization failed" - case .bencodeParseError: - return "Could not parse bencoded file (usually \".torrent\" file)" - case .bittorrentParseError: - return "\".torrent\" file was corrupted or missing information" - case .magnetParseError: - return "Magnet URI was bad" - case .optionError: - return "Bad/unrecognized option was given or unexpected option argument was given" - case .httpServiceUnavailable: - return "HTTP service unavailable" - case .jsonParseError: - return "Could not parse JSON-RPC request" - case .removed: - return "Reserved. Not used." - case .checksumError: - return "Checksum validation failed" - } - } - } -} diff --git a/Xcodes/Backend/AvailableXcode.swift b/Xcodes/Backend/AvailableXcode.swift deleted file mode 100644 index 84151180..00000000 --- a/Xcodes/Backend/AvailableXcode.swift +++ /dev/null @@ -1,47 +0,0 @@ -import Foundation -import Version -import XcodesKit - -/// A version of Xcode that's available for installation -public struct AvailableXcode: Codable { - public var version: Version { - return xcodeID.version - } - public let url: URL - public let filename: String - public let releaseDate: Date? - public let requiredMacOSVersion: String? - public let releaseNotesURL: URL? - public let sdks: SDKs? - public let compilers: Compilers? - public let fileSize: Int64? - public let architectures: [Architecture]? - public var downloadPath: String { - return url.path - } - public var xcodeID: XcodeID - - public init( - version: Version, - url: URL, - filename: String, - releaseDate: Date?, - requiredMacOSVersion: String? = nil, - releaseNotesURL: URL? = nil, - sdks: SDKs? = nil, - compilers: Compilers? = nil, - fileSize: Int64? = nil, - architectures: [Architecture]? = nil - ) { - self.url = url - self.filename = filename - self.releaseDate = releaseDate - self.requiredMacOSVersion = requiredMacOSVersion - self.releaseNotesURL = releaseNotesURL - self.sdks = sdks - self.compilers = compilers - self.fileSize = fileSize - self.architectures = architectures - self.xcodeID = XcodeID(version: version, architectures: architectures) - } -} diff --git a/Xcodes/Backend/DataSource.swift b/Xcodes/Backend/DataSource.swift index 03911848..ef4e7331 100644 --- a/Xcodes/Backend/DataSource.swift +++ b/Xcodes/Backend/DataSource.swift @@ -1,19 +1,7 @@ -import Foundation +import XcodesKit -public enum DataSource: String, CaseIterable, Identifiable, CustomStringConvertible { - case apple - case xcodeReleases - - public var id: Self { self } - - public static var `default` = DataSource.xcodeReleases - - public var description: String { - switch self { - case .apple: return "Apple" - case .xcodeReleases: return "Xcode Releases" - } - } +public typealias DataSource = XcodesKit.DataSource +extension DataSource { var isManaged: Bool { PreferenceKey.dataSource.isManaged() } } diff --git a/Xcodes/Backend/DateFormatter+.swift b/Xcodes/Backend/DateFormatter+.swift deleted file mode 100644 index a0073de1..00000000 --- a/Xcodes/Backend/DateFormatter+.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -extension DateFormatter { - /// Date format used in JSON returned from `URL.downloads` - static let downloadsDateModified: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "MM/dd/yy HH:mm" - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.calendar = .init(identifier: .iso8601) - return formatter - }() - - /// Date format used in HTML returned from `URL.download` - static let downloadsReleaseDate: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "MMMM d, yyyy" - return formatter - }() -} diff --git a/Xcodes/Backend/Downloader.swift b/Xcodes/Backend/Downloader.swift index e155703a..ea048e77 100644 --- a/Xcodes/Backend/Downloader.swift +++ b/Xcodes/Backend/Downloader.swift @@ -1,18 +1,7 @@ -import Foundation -import Path +import XcodesKit -public enum Downloader: String, CaseIterable, Identifiable, CustomStringConvertible { - case aria2 - case urlSession - - public var id: Self { self } - - public var description: String { - switch self { - case .urlSession: return "URLSession" - case .aria2: return "aria2" - } - } +public typealias Downloader = XcodeArchiveDownloader +extension Downloader { var isManaged: Bool { PreferenceKey.downloader.isManaged() } } diff --git a/Xcodes/Backend/Downloads.swift b/Xcodes/Backend/Downloads.swift deleted file mode 100644 index aab97790..00000000 --- a/Xcodes/Backend/Downloads.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation -import Path -import Version - -struct Downloads: Codable { - let resultCode: Int - let resultsString: String? - let downloads: [Download]? - - var hasError: Bool { - return resultCode != 0 - } -} - -// Set to Int64 as ByteCountFormatter uses it. -public typealias ByteCount = Int64 - -public struct Download: Codable { - public let name: String - public let files: [File] - public let dateModified: Date - - public struct File: Codable { - public let remotePath: String - public let fileSize: ByteCount - } -} diff --git a/Xcodes/Backend/Entry+.swift b/Xcodes/Backend/Entry+.swift deleted file mode 100644 index b195fb0b..00000000 --- a/Xcodes/Backend/Entry+.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation -import Path - -extension Path { - static func isAppBundle(path: Path) -> Bool { - path.isDirectory && - path.extension == "app" && - !path.isSymlink - } - static func infoPlist(path: Path) -> InfoPlist? { - let infoPlistPath = path.join("Contents").join("Info.plist") - guard - let infoPlistData = try? Data(contentsOf: infoPlistPath.url), - let infoPlist = try? PropertyListDecoder().decode(InfoPlist.self, from: infoPlistData) - else { return nil } - - return infoPlist - } - - var isAppBundle: Bool { - Path.isAppBundle(path: self) - } - - var infoPlist: InfoPlist? { - Path.infoPlist(path: self) - } -} diff --git a/Xcodes/Backend/Environment.swift b/Xcodes/Backend/Environment.swift index b515e11e..47fb9420 100644 --- a/Xcodes/Backend/Environment.swift +++ b/Xcodes/Backend/Environment.swift @@ -1,9 +1,9 @@ -import Combine import Foundation import Path -import AppleAPI import KeychainAccess import XcodesKit +import XcodesLoginKit +import os /** Lightweight dependency injection using global mutable state :P @@ -11,326 +11,125 @@ import XcodesKit - SeeAlso: https://www.pointfree.co/episodes/ep18-dependency-injection-made-comfortable - SeeAlso: https://vimeo.com/291588126 */ -public struct Environment { +public struct Environment: Sendable { public var shell = Shell() public var files = Files() public var network = Network() public var keychain = Keychain() public var defaults = Defaults() - public var date: () -> Date = Date.init + public var date: @Sendable () -> Date = { Date() } public var helper = Helper() public var notificationManager = NotificationManager() } -public var Current = Environment() +private let currentEnvironment = CurrentEnvironmentStorage(Environment()) -public struct Shell { - public var unxip: (URL) -> AnyPublisher = { Process.run(Path.root.usr.bin.xip, workingDirectory: $0.deletingLastPathComponent(), "--expand", "\($0.path)") } - public var spctlAssess: (URL) -> AnyPublisher = { Process.run(Path.root.usr.sbin.spctl, "--assess", "--verbose", "--type", "execute", "\($0.path)") } - public var codesignVerify: (URL) -> AnyPublisher = { Process.run(Path.root.usr.bin.codesign, "-vv", "-d", "\($0.path)") } - public var buildVersion: () -> AnyPublisher = { Process.run(Path.root.usr.bin.sw_vers, "-buildVersion") } - public var xcodeBuildVersion: (InstalledXcode) -> AnyPublisher = { Process.run(Path.root.usr.libexec.PlistBuddy, "-c", "Print :ProductBuildVersion", "\($0.path.string)/Contents/version.plist") } - public var getUserCacheDir: () -> AnyPublisher = { Process.run(Path.root.usr.bin.getconf, "DARWIN_USER_CACHE_DIR") } - public var touchInstallCheck: (String, String, String) -> AnyPublisher = { Process.run(Path.root.usr.bin/"touch", "\($0)com.apple.dt.Xcode.InstallCheckCache_\($1)_\($2)") } +public var Current: Environment { + get { currentEnvironment.value } + set { currentEnvironment.value = newValue } +} - public var xcodeSelectPrintPath: () -> AnyPublisher = { Process.run(Path.root.usr.bin.join("xcode-select"), "-p") } - - public var downloadWithAria2: (Path, URL, Path, [HTTPCookie]) -> (Progress, AnyPublisher) = { aria2Path, url, destination, cookies in - let process = Process() - process.executableURL = aria2Path.url - process.arguments = [ - "--header=Cookie: \(cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; "))", - "--max-connection-per-server=16", - "--split=16", - "--summary-interval=1", - "--stop-with-process=\(ProcessInfo.processInfo.processIdentifier)", // if xcodes quits, stop aria2 process - "--dir=\(destination.parent.string)", - "--out=\(destination.basename())", - "--human-readable=false", // sets the output to use bytes instead of formatting - url.absoluteString, - ] - let stdOutPipe = Pipe() - process.standardOutput = stdOutPipe - let stdErrPipe = Pipe() - process.standardError = stdErrPipe - - var progress = Progress() - progress.kind = .file - progress.fileOperationKind = .downloading - - let observer = NotificationCenter.default.addObserver( - forName: .NSFileHandleDataAvailable, - object: nil, - queue: OperationQueue.main - ) { note in - guard - // This should always be the case for Notification.Name.NSFileHandleDataAvailable - let handle = note.object as? FileHandle, - handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading - else { return } - - defer { handle.waitForDataInBackgroundAndNotify() } - - let string = String(decoding: handle.availableData, as: UTF8.self) - - progress.updateFromAria2(string: string) - } +private final class CurrentEnvironmentStorage: Sendable { + private let environment: OSAllocatedUnfairLock - stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() - stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() - - do { - try process.run() - } catch { - return (progress, Fail(error: error).eraseToAnyPublisher()) - } + var value: Environment { + get { environment.withLock { $0 } } + set { environment.withLock { $0 = newValue } } + } - let publisher = Deferred { - Future { promise in - DispatchQueue.global(qos: .default).async { - process.waitUntilExit() - - NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) - - guard process.terminationReason == .exit, process.terminationStatus == 0 else { - if let aria2cError = Aria2CError(exitStatus: process.terminationStatus) { - return promise(.failure(aria2cError)) - } else { - return promise(.failure(ProcessExecutionError(process: process, standardOutput: "", standardError: ""))) - } - } - promise(.success(())) - } - } - } - .handleEvents(receiveCancel: { - process.terminate() - NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) - }) - .eraseToAnyPublisher() - - return (progress, publisher) + init(_ environment: Environment) { + self.environment = OSAllocatedUnfairLock(initialState: environment) } +} + +public struct Shell: Sendable { + private static let shared = XcodesShell() + + public var unxip = Shell.shared.unxip + public var spctlAssess = Shell.shared.spctlAssess + public var codesignVerify = Shell.shared.codesignVerify + public var buildVersion = Shell.shared.buildVersion + public var xcodeBuildVersion = Shell.shared.xcodeBuildVersion + public var archs = Shell.shared.archs + public var getUserCacheDir = Shell.shared.getUserCacheDir + public var touchInstallCheck = Shell.shared.touchInstallCheck + + public var xcodeSelectPrintPath = Shell.shared.xcodeSelectPrintPath - public var downloadWithAria2Async: (Path, URL, Path, [HTTPCookie]) -> AsyncThrowingStream = { aria2Path, url, destination, cookies in - return AsyncThrowingStream { continuation in - - Task { - // Assume progress will not have data races, so we manually opt-out isolation checks. - nonisolated(unsafe) var progress = Progress() - progress.kind = .file - progress.fileOperationKind = .downloading - - let process = Process() - process.executableURL = aria2Path.url - process.arguments = [ - "--header=Cookie: \(cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; "))", - "--max-connection-per-server=16", - "--split=16", - "--summary-interval=1", - "--stop-with-process=\(ProcessInfo.processInfo.processIdentifier)", // if xcodes quits, stop aria2 process - "--dir=\(destination.parent.string)", - "--out=\(destination.basename())", - "--human-readable=false", // sets the output to use bytes instead of formatting - url.absoluteString, - ] - let stdOutPipe = Pipe() - process.standardOutput = stdOutPipe - let stdErrPipe = Pipe() - process.standardError = stdErrPipe - - let observer = NotificationCenter.default.addObserver( - forName: .NSFileHandleDataAvailable, - object: nil, - queue: OperationQueue.main - ) { note in - guard - // This should always be the case for Notification.Name.NSFileHandleDataAvailable - let handle = note.object as? FileHandle, - handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading - else { return } - - defer { handle.waitForDataInBackgroundAndNotify() } - - let string = String(decoding: handle.availableData, as: UTF8.self) - // TODO: fix warning. ObservingProgressView is currently tied to an updating progress - progress.updateFromAria2(string: string) - - continuation.yield(progress) - } - - stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() - stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() - - continuation.onTermination = { @Sendable _ in - process.terminate() - NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) - } - - do { - try process.run() - } catch { - continuation.finish(throwing: error) - } - - process.waitUntilExit() - - NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) - - guard process.terminationReason == .exit, process.terminationStatus == 0 else { - if let aria2cError = Aria2CError(exitStatus: process.terminationStatus) { - continuation.finish(throwing: aria2cError) - } else { - continuation.finish(throwing: ProcessExecutionError(process: process, standardOutput: "", standardError: "")) - } - return - } - continuation.finish() - } - } + public var downloadWithAria2Async: @Sendable (Path, URL, Path, [HTTPCookie]) -> AsyncThrowingStream = { aria2Path, url, destination, cookies in + Aria2DownloadService().download(aria2Path: aria2Path, url: url, destination: destination, cookies: cookies) } - public var unxipExperiment: (URL) -> AnyPublisher = { url in + public var unxipExperiment: @Sendable (URL) async throws -> ProcessOutput = { url in let unxipPath = Path(url: Bundle.main.url(forAuxiliaryExecutable: "unxip")!)! - return Process.run(unxipPath.url, workingDirectory: url.deletingLastPathComponent(), ["\(url.path)"]) + return try await Process.runAsync(unxipPath.url, workingDirectory: url.deletingLastPathComponent(), ["\(url.path)"]) } - public var downloadRuntime: (String, String, String?) -> AsyncThrowingStream = { platform, version, architecture in - return AsyncThrowingStream { continuation in - Task { - // Assume progress will not have data races, so we manually opt-out isolation checks. - nonisolated(unsafe) var progress = Progress() - progress.kind = .file - progress.fileOperationKind = .downloading - - var process = Process() - let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild").url - - process.executableURL = xcodeBuildPath - process.arguments = [ - "-downloadPlatform", - "\(platform)", - "-buildVersion", - "\(version)" - ] - - if let architecture { - process.arguments?.append(contentsOf: [ - "-architectureVariant", - "\(architecture)" - ]) - } - - let stdOutPipe = Pipe() - process.standardOutput = stdOutPipe - let stdErrPipe = Pipe() - process.standardError = stdErrPipe - - let observer = NotificationCenter.default.addObserver( - forName: .NSFileHandleDataAvailable, - object: nil, - queue: OperationQueue.main - ) { note in - guard - // This should always be the case for Notification.Name.NSFileHandleDataAvailable - let handle = note.object as? FileHandle, - handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading - else { return } - - defer { handle.waitForDataInBackgroundAndNotify() } - - let string = String(decoding: handle.availableData, as: UTF8.self) - - // TODO: fix warning. ObservingProgressView is currently tied to an updating progress - progress.updateFromXcodebuild(text: string) - - continuation.yield(progress) - } - - stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() - stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() - - continuation.onTermination = { @Sendable _ in - process.terminate() - NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) - } - - do { - try process.run() - } catch { - continuation.finish(throwing: error) - } - - process.waitUntilExit() - - NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) - - guard process.terminationReason == .exit, process.terminationStatus == 0 else { - continuation.finish(throwing: ProcessExecutionError(process: process, standardOutput: "", standardError: "")) - return - } - continuation.finish() - } - } + public var downloadRuntime: @Sendable (String, String, String?) -> AsyncThrowingStream = { platform, version, architecture in + XcodebuildRuntimeDownloadService().download(platform: platform, buildVersion: version, architecture: architecture) } } -public struct Files { - public var fileExistsAtPath: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) } +public struct Files: Sendable { + public var fileExistsAtPath: @Sendable (String) -> Bool = { FileManager.default.fileExists(atPath: $0) } public func fileExists(atPath path: String) -> Bool { return fileExistsAtPath(path) } - public var moveItem: (URL, URL) throws -> Void = { try FileManager.default.moveItem(at: $0, to: $1) } + public var moveItem: @Sendable (URL, URL) throws -> Void = { try FileManager.default.moveItem(at: $0, to: $1) } public func moveItem(at srcURL: URL, to dstURL: URL) throws { try moveItem(srcURL, dstURL) } - public var contentsAtPath: (String) -> Data? = { FileManager.default.contents(atPath: $0) } + public var contentsAtPath: @Sendable (String) -> Data? = { FileManager.default.contents(atPath: $0) } public func contents(atPath path: String) -> Data? { return contentsAtPath(path) } - public var removeItem: (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) } + public var removeItem: @Sendable (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) } public func removeItem(at URL: URL) throws { try removeItem(URL) } - public var trashItem: (URL) throws -> URL = { try FileManager.default.trashItem(at: $0) } + public var trashItem: @Sendable (URL) throws -> URL = { try FileManager.default.trashItem(at: $0) } @discardableResult public func trashItem(at URL: URL) throws -> URL { return try trashItem(URL) } - public var createFile: (String, Data?, [FileAttributeKey: Any]?) -> Bool = { FileManager.default.createFile(atPath: $0, contents: $1, attributes: $2) } + public var createFile: @Sendable (String, Data?, [FileAttributeKey: Any]?) -> Bool = { FileManager.default.createFile(atPath: $0, contents: $1, attributes: $2) } @discardableResult public func createFile(atPath path: String, contents data: Data?, attributes attr: [FileAttributeKey : Any]? = nil) -> Bool { return createFile(path, data, attr) } - public var createDirectory: (URL, Bool, [FileAttributeKey : Any]?) throws -> Void = FileManager.default.createDirectory(at:withIntermediateDirectories:attributes:) + public var createDirectory: @Sendable (URL, Bool, [FileAttributeKey : Any]?) throws -> Void = { url, createIntermediates, attributes in + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: createIntermediates, attributes: attributes) + } public func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool, attributes: [FileAttributeKey : Any]? = nil) throws { try createDirectory(url, createIntermediates, attributes) } - public var installedXcodes = _installedXcodes + public var installedXcodes: @Sendable (Path) -> [InstalledXcode] = { destination in + _installedXcodes(destination: destination) + } public func installedXcode(destination: Path) -> InstalledXcode? { - if Path.isAppBundle(path: destination) && Path.infoPlist(path: destination)?.bundleID == "com.apple.dt.Xcode" { - return InstalledXcode.init(path: destination) - } else { - return nil - } + InstalledXcodeDiscoveryService( + listDirectory: { _ in [] }, + contentsAtPath: contentsAtPath, + loadArchitectures: Current.shell.archs + ).installedXcode(at: destination) } - public var write: (Data, URL) throws -> Void = { try $0.write(to: $1) } + public var write: @Sendable (Data, URL) throws -> Void = { try $0.write(to: $1) } public func write(_ data: Data, to url: URL) throws { try write(data, url) @@ -338,104 +137,152 @@ public struct Files { } private func _installedXcodes(destination: Path) -> [InstalledXcode] { - destination.ls() - .filter { $0.isAppBundle && $0.infoPlist?.bundleID == "com.apple.dt.Xcode" } - .map { $0 } - .compactMap(InstalledXcode.init) + InstalledXcodeDiscoveryService( + listDirectory: { $0.ls() }, + contentsAtPath: { path in FileManager.default.contents(atPath: path) }, + loadArchitectures: Current.shell.archs + ).installedXcodes(in: destination) } -public struct Network { - private static let client = AppleAPI.Client() - - public var dataTask: (URLRequest) -> AnyPublisher = { - AppleAPI.Current.network.session.dataTaskPublisher(for: $0) - .mapError { $0 as Error } - .eraseToAnyPublisher() - } - - public func dataTask(with request: URLRequest) -> AnyPublisher { - dataTask(request) +public struct Network: Sendable { + public private(set) var loginClient: XcodesLoginKit.Client + + public var session: URLSession { + get { loginClient.urlSession } + set { + let loginClient = XcodesLoginKit.Client(urlSession: newValue) + self.loginClient = loginClient + configureDefaultOperations(using: loginClient) + } } - + + public var loadData: @Sendable (URLRequest) async throws -> (Data, URLResponse) + public func dataTaskAsync(with request: URLRequest) async throws -> (Data, URLResponse) { - return try await AppleAPI.Current.network.session.data(for: request) + try await loadData(request) } - public var downloadTask: (URL, URL, Data?) -> (Progress, AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) = { AppleAPI.Current.network.session.downloadTask(with: $0, to: $1, resumingWith: $2) } + public var downloadTaskAsync: @Sendable (URL, URL, Data?) -> (Progress, Task<(saveLocation: URL, response: URLResponse), Error>) - public func downloadTask(with url: URL, to saveLocation: URL, resumingWith resumeData: Data?) -> (progress: Progress, publisher: AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) { - return downloadTask(url, saveLocation, resumeData) + public func downloadTaskAsync(with url: URL, to saveLocation: URL, resumingWith resumeData: Data?) -> (progress: Progress, task: Task<(saveLocation: URL, response: URLResponse), Error>) { + downloadTaskAsync(url, saveLocation, resumeData) } - public var validateSession: () -> AnyPublisher = { - return client.validateSession() + public var validateSessionAsync: @Sendable () async throws -> Void + + public var signout: @Sendable () -> Void + + public init( + session: URLSession? = nil, + loadData: (@Sendable (URLRequest) async throws -> (Data, URLResponse))? = nil, + downloadTaskAsync: (@Sendable (URL, URL, Data?) -> (Progress, Task<(saveLocation: URL, response: URLResponse), Error>))? = nil, + validateSessionAsync: (@Sendable () async throws -> Void)? = nil, + signout: (@Sendable () -> Void)? = nil + ) { + let loginClient: XcodesLoginKit.Client + if let session { + loginClient = XcodesLoginKit.Client(urlSession: session) + } else { + loginClient = XcodesLoginKit.Client() + } + self.loginClient = loginClient + self.loadData = loadData ?? { request in + try await loginClient.urlSession.data(for: request) + } + self.downloadTaskAsync = downloadTaskAsync ?? { url, saveLocation, resumeData in + loginClient.urlSession.downloadTaskAsync(with: url, to: saveLocation, resumingWith: resumeData) + } + self.validateSessionAsync = validateSessionAsync ?? { + _ = try await loginClient.validateSession() + } + self.signout = signout ?? { + loginClient.signout() + } + } + + private mutating func configureDefaultOperations(using loginClient: XcodesLoginKit.Client) { + self.loadData = { request in + try await loginClient.urlSession.data(for: request) + } + self.downloadTaskAsync = { url, saveLocation, resumeData in + loginClient.urlSession.downloadTaskAsync(with: url, to: saveLocation, resumingWith: resumeData) + } + self.validateSessionAsync = { + _ = try await loginClient.validateSession() + } + self.signout = { + loginClient.signout() + } } } -public struct Keychain { - private static let keychain = KeychainAccess.Keychain(service: "com.robotsandpencils.XcodesApp") +public struct Keychain: Sendable { + private static var keychain: KeychainAccess.Keychain { + KeychainAccess.Keychain(service: "com.robotsandpencils.XcodesApp") + } - public var getString: (String) throws -> String? = keychain.getString(_:) + public var getString: @Sendable (String) throws -> String? = { try keychain.getString($0) } public func getString(_ key: String) throws -> String? { try getString(key) } - public var set: (String, String) throws -> Void = keychain.set(_:key:) + public var set: @Sendable (String, String) throws -> Void = { try keychain.set($0, key: $1) } public func set(_ value: String, key: String) throws { try set(value, key) } - public var remove: (String) throws -> Void = keychain.remove(_:) + public var remove: @Sendable (String) throws -> Void = { try keychain.remove($0) } public func remove(_ key: String) throws -> Void { try remove(key) } } -public struct Defaults { - public var string: (String) -> String? = { UserDefaults.standard.string(forKey: $0) } +public struct Defaults: Sendable { + public var string: @Sendable (String) -> String? = { UserDefaults.standard.string(forKey: $0) } public func string(forKey key: String) -> String? { string(key) } - public var date: (String) -> Date? = { Date(timeIntervalSince1970: UserDefaults.standard.double(forKey: $0)) } + public var date: @Sendable (String) -> Date? = { Date(timeIntervalSince1970: UserDefaults.standard.double(forKey: $0)) } public func date(forKey key: String) -> Date? { date(key) } - public var setDate: (Date?, String) -> Void = { UserDefaults.standard.set($0?.timeIntervalSince1970, forKey: $1) } + public var setDate: @Sendable (Date?, String) -> Void = { UserDefaults.standard.set($0?.timeIntervalSince1970, forKey: $1) } public func setDate(_ value: Date?, forKey key: String) { setDate(value, key) } - public var set: (Any?, String) -> Void = { UserDefaults.standard.set($0, forKey: $1) } + public var set: @Sendable (Any?, String) -> Void = { UserDefaults.standard.set($0, forKey: $1) } public func set(_ value: Any?, forKey key: String) { set(value, key) } - public var removeObject: (String) -> Void = { UserDefaults.standard.removeObject(forKey: $0) } + public var removeObject: @Sendable (String) -> Void = { UserDefaults.standard.removeObject(forKey: $0) } public func removeObject(forKey key: String) { removeObject(key) } - public var get: (String) -> Any? = { UserDefaults.standard.value(forKey: $0) } + public var get: @Sendable (String) -> Any? = { UserDefaults.standard.value(forKey: $0) } public func get(forKey key: String) -> Any? { get(key) } - public var bool: (String) -> Bool? = { UserDefaults.standard.bool(forKey: $0) } + public var bool: @Sendable (String) -> Bool? = { UserDefaults.standard.bool(forKey: $0) } public func bool(forKey key: String) -> Bool? { bool(key) } } +@MainActor private let helperClient = HelperClient() -public struct Helper { - var install: () throws -> Void = helperClient.install - var checkIfLatestHelperIsInstalled: () -> AnyPublisher = helperClient.checkIfLatestHelperIsInstalled - var getVersion: () -> AnyPublisher = helperClient.getVersion - var switchXcodePath: (_ absolutePath: String) -> AnyPublisher = helperClient.switchXcodePath - var devToolsSecurityEnable: () -> AnyPublisher = helperClient.devToolsSecurityEnable - var addStaffToDevelopersGroup: () -> AnyPublisher = helperClient.addStaffToDevelopersGroup - var acceptXcodeLicense: (_ absoluteXcodePath: String) -> AnyPublisher = helperClient.acceptXcodeLicense - var runFirstLaunch: (_ absoluteXcodePath: String) -> AnyPublisher = helperClient.runFirstLaunch +public struct Helper: Sendable { + var install: @Sendable () async throws -> Void = { try await helperClient.install() } + var checkIfLatestHelperIsInstalledAsync: @Sendable () async throws -> Bool = { try await helperClient.checkIfLatestHelperIsInstalledAsync() } + var getVersionAsync: @Sendable () async throws -> String = { try await helperClient.getVersionAsync() } + var switchXcodePathAsync: @Sendable (_ absolutePath: String) async throws -> Void = { try await helperClient.switchXcodePathAsync($0) } + var devToolsSecurityEnableAsync: @Sendable () async throws -> Void = { try await helperClient.devToolsSecurityEnableAsync() } + var addStaffToDevelopersGroupAsync: @Sendable () async throws -> Void = { try await helperClient.addStaffToDevelopersGroupAsync() } + var acceptXcodeLicenseAsync: @Sendable (_ absoluteXcodePath: String) async throws -> Void = { try await helperClient.acceptXcodeLicenseAsync(absoluteXcodePath: $0) } + var runFirstLaunchAsync: @Sendable (_ absoluteXcodePath: String) async throws -> Void = { try await helperClient.runFirstLaunchAsync(absoluteXcodePath: $0) } } diff --git a/Xcodes/Backend/FileManager+.swift b/Xcodes/Backend/FileManager+.swift index 72fd1ee6..7ca2d5b9 100644 --- a/Xcodes/Backend/FileManager+.swift +++ b/Xcodes/Backend/FileManager+.swift @@ -1,4 +1,5 @@ import Foundation +import XcodesKit extension FileManager { /** @@ -10,12 +11,10 @@ extension FileManager { */ @discardableResult func trashItem(at url: URL) throws -> URL { - var resultingItemURL: NSURL! if fileExists(atPath: url.path) { - try trashItem(at: url, resultingItemURL: &resultingItemURL) + return try xcodesTrashItem(at: url) } else { throw FileError.fileNotFound(url.lastPathComponent) } - return resultingItemURL as URL } } diff --git a/Xcodes/Backend/Foundation.swift b/Xcodes/Backend/Foundation.swift deleted file mode 100644 index 8ff82aed..00000000 --- a/Xcodes/Backend/Foundation.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -public extension BidirectionalCollection where Element: Equatable { - func suffix(fromLast delimiter: Element) -> Self.SubSequence { - guard - let lastIndex = lastIndex(of: delimiter), - index(after: lastIndex) < endIndex - else { return suffix(0) } - return suffix(from: index(after: lastIndex)) - } -} - -public extension NumberFormatter { - convenience init(numberStyle: NumberFormatter.Style) { - self.init() - self.numberStyle = numberStyle - } - - func string(from number: N) -> String? { - return string(from: number as! NSNumber) - } -} - -extension Sequence { - func sorted(_ keyPath: KeyPath) -> [Element] { - sorted(by: { $0[keyPath: keyPath] < $1[keyPath: keyPath] }) - } -} diff --git a/Xcodes/Backend/Hardware.swift b/Xcodes/Backend/Hardware.swift index 0f7601bb..df4907d2 100644 --- a/Xcodes/Backend/Hardware.swift +++ b/Xcodes/Backend/Hardware.swift @@ -1,28 +1,3 @@ -import Foundation +import XcodesKit - -struct Hardware { - - /// - /// Determines the architecture of the Mac on which we're running. Returns `arm64` for Apple Silicon - /// and `x86_64` for Intel-based Macs or `nil` if the system call fails. - static func getMachineHardwareName() -> String? - { - var sysInfo = utsname() - let retVal = uname(&sysInfo) - var finalString: String? = nil - - if retVal == EXIT_SUCCESS - { - let bytes = Data(bytes: &sysInfo.machine, count: Int(_SYS_NAMELEN)) - finalString = String(data: bytes, encoding: .utf8) - } - - // _SYS_NAMELEN will include a billion null-terminators. Clear those out so string comparisons work as you expect. - return finalString?.trimmingCharacters(in: CharacterSet(charactersIn: "\0")) - } - - static func isAppleSilicon() -> Bool { - return Hardware.getMachineHardwareName() == "arm64" - } -} +typealias Hardware = HostHardware diff --git a/Xcodes/Backend/HelperClient.swift b/Xcodes/Backend/HelperClient.swift index 284d23da..8d2858bb 100644 --- a/Xcodes/Backend/HelperClient.swift +++ b/Xcodes/Backend/HelperClient.swift @@ -1,41 +1,43 @@ -import Combine import Foundation import os.log import ServiceManagement +import XcodesKit +@MainActor final class HelperClient { private var connection: NSXPCConnection? - + private func currentConnection() -> NSXPCConnection? { guard self.connection == nil else { return self.connection } - + let connection = NSXPCConnection(machServiceName: machServiceName, options: .privileged) connection.remoteObjectInterface = NSXPCInterface(with: HelperXPCProtocol.self) - connection.invalidationHandler = { - self.connection?.invalidationHandler = nil - DispatchQueue.main.async { + connection.invalidationHandler = { [weak self, weak connection] in + Task { @MainActor [weak self, weak connection] in + guard let self, let connection, self.connection === connection else { return } + connection.invalidationHandler = nil self.connection = nil } } - + self.connection = connection connection.resume() - + return self.connection } - - private func helper(errorSubject: PassthroughSubject) -> HelperXPCProtocol? { - guard + + private func helper(errorHandler: @escaping @Sendable (Error) -> Void) -> HelperXPCProtocol? { + guard let helper = self.currentConnection()?.remoteObjectProxyWithErrorHandler({ error in - errorSubject.send(completion: .failure(error)) - }) as? HelperXPCProtocol + errorHandler(error) + }) as? HelperXPCProtocol else { return nil } return helper } - - func checkIfLatestHelperIsInstalled() -> AnyPublisher { + + func checkIfLatestHelperIsInstalledAsync() async throws -> Bool { Logger.helperClient.info(#function) let helperURL = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/LaunchServices/" + machServiceName) @@ -43,270 +45,104 @@ final class HelperClient { let helperBundleInfo = CFBundleCopyInfoDictionaryForURL(helperURL as CFURL) as? [String: Any], let bundledHelperVersion = helperBundleInfo["CFBundleShortVersionString"] as? String else { - return Just(false) - .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0))") }) - .eraseToAnyPublisher() + Logger.helperClient.info("\(#function): false") + return false } - - return getVersion() - .map { installedHelperVersion in installedHelperVersion == bundledHelperVersion } - .catch { _ in Just(false) } - // Failure is Never, so don't bother logging completion - .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0), privacy: .public)") }) - .eraseToAnyPublisher() + + let isInstalled = try await getVersionAsync() == bundledHelperVersion + Logger.helperClient.info("\(#function): \(String(describing: isInstalled), privacy: .public)") + return isInstalled } - - func getVersion() -> AnyPublisher { + + func getVersionAsync() async throws -> String { Logger.helperClient.info(#function) - let connectionErrorSubject = PassthroughSubject() - guard - let helper = self.helper(errorSubject: connectionErrorSubject) - else { - return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) - .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) - .eraseToAnyPublisher() - } - - return Deferred { - Future { promise in - helper.getVersion { version in - promise(.success(version)) - } + let version = try await performHelperRequest { helper, finish in + helper.getVersion { version in + finish(.success(version)) } } - // Take values, but fail when connectionErrorSubject fails - .zip( - connectionErrorSubject - .prepend("") - .map { _ in Void() } - ) - .map { $0.0 } - .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0), privacy: .public)") }, - receiveCompletion: { completion in - switch completion { - case .finished: - Logger.helperClient.info("\(#function): finished") - case let .failure(error): - Logger.helperClient.error("\(#function): \(String(describing: error))") - } - }) - .eraseToAnyPublisher() + Logger.helperClient.info("\(#function): \(String(describing: version), privacy: .public)") + return version } - - func switchXcodePath(_ absolutePath: String) -> AnyPublisher { + + func switchXcodePathAsync(_ absolutePath: String) async throws { Logger.helperClient.info("\(#function): \(absolutePath, privacy: .private(mask: .hash))") - let connectionErrorSubject = PassthroughSubject() - guard - let helper = self.helper(errorSubject: connectionErrorSubject) - else { - return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) - .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) - .eraseToAnyPublisher() - } - - return Deferred { - Future { promise in - helper.xcodeSelect(absolutePath: absolutePath, completion: { (possibleError) in - if let error = possibleError { - promise(.failure(error)) - } else { - promise(.success(())) - } - }) + try await performVoidHelperRequest { helper, finish in + helper.xcodeSelect(absolutePath: absolutePath) { possibleError in + finish(possibleError.map(Result.failure) ?? .success(())) } } - // Take values, but fail when connectionErrorSubject fails - .zip( - connectionErrorSubject - .prepend("") - .map { _ in Void() } - ) - .map { $0.0 } - .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0))") }, - receiveCompletion: { completion in - switch completion { - case .finished: - Logger.helperClient.info("\(#function): finished") - case let .failure(error): - Logger.helperClient.error("\(#function): \(String(describing: error))") - } - }) - .eraseToAnyPublisher() + Logger.helperClient.info("\(#function): finished") } - - func devToolsSecurityEnable() -> AnyPublisher { + + func devToolsSecurityEnableAsync() async throws { Logger.helperClient.info(#function) - let connectionErrorSubject = PassthroughSubject() - guard - let helper = self.helper(errorSubject: connectionErrorSubject) - else { - return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) - .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) - .eraseToAnyPublisher() - } - - return Deferred { - Future { promise in - helper.devToolsSecurityEnable(completion: { (possibleError) in - if let error = possibleError { - promise(.failure(error)) - } else { - promise(.success(())) - } - }) + try await performVoidHelperRequest { helper, finish in + helper.devToolsSecurityEnable { possibleError in + finish(possibleError.map(Result.failure) ?? .success(())) } } - // Take values, but fail when connectionErrorSubject fails - .zip( - connectionErrorSubject - .prepend("") - .map { _ in Void() } - ) - .map { $0.0 } - .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0))") }, - receiveCompletion: { completion in - switch completion { - case .finished: - Logger.helperClient.info("\(#function): finished") - case let .failure(error): - Logger.helperClient.error("\(#function): \(String(describing: error))") - } - }) - .eraseToAnyPublisher() + Logger.helperClient.info("\(#function): finished") } - - func addStaffToDevelopersGroup() -> AnyPublisher { + + func addStaffToDevelopersGroupAsync() async throws { Logger.helperClient.info(#function) - let connectionErrorSubject = PassthroughSubject() - guard - let helper = self.helper(errorSubject: connectionErrorSubject) - else { - return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) - .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) - .eraseToAnyPublisher() - } - - return Deferred { - Future { promise in - helper.addStaffToDevelopersGroup(completion: { (possibleError) in - if let error = possibleError { - promise(.failure(error)) - } else { - promise(.success(())) - } - }) + try await performVoidHelperRequest { helper, finish in + helper.addStaffToDevelopersGroup { possibleError in + finish(possibleError.map(Result.failure) ?? .success(())) } } - // Take values, but fail when connectionErrorSubject fails - .zip( - connectionErrorSubject - .prepend("") - .map { _ in Void() } - ) - .map { $0.0 } - .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0))") }, - receiveCompletion: { completion in - switch completion { - case .finished: - Logger.helperClient.info("\(#function): finished") - case let .failure(error): - Logger.helperClient.error("\(#function): \(String(describing: error))") - } - }) - .eraseToAnyPublisher() + Logger.helperClient.info("\(#function): finished") } - - func acceptXcodeLicense(absoluteXcodePath: String) -> AnyPublisher { + + func acceptXcodeLicenseAsync(absoluteXcodePath: String) async throws { Logger.helperClient.info("\(#function): \(absoluteXcodePath, privacy: .private(mask: .hash))") - let connectionErrorSubject = PassthroughSubject() - guard - let helper = self.helper(errorSubject: connectionErrorSubject) - else { - return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) - .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) - .eraseToAnyPublisher() - } - - return Deferred { - Future { promise in - helper.acceptXcodeLicense(absoluteXcodePath: absoluteXcodePath, completion: { (possibleError) in - if let error = possibleError { - promise(.failure(error)) - } else { - promise(.success(())) - } - }) + try await performVoidHelperRequest { helper, finish in + helper.acceptXcodeLicense(absoluteXcodePath: absoluteXcodePath) { possibleError in + finish(possibleError.map(Result.failure) ?? .success(())) } } - // Take values, but fail when connectionErrorSubject fails - .zip( - connectionErrorSubject - .prepend("") - .map { _ in Void() } - ) - .map { $0.0 } - .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0))") }, - receiveCompletion: { completion in - switch completion { - case .finished: - Logger.helperClient.info("\(#function): finished") - case let .failure(error): - Logger.helperClient.error("\(#function): \(String(describing: error))") - } - }) - .eraseToAnyPublisher() + Logger.helperClient.info("\(#function): finished") } - - func runFirstLaunch(absoluteXcodePath: String) -> AnyPublisher { + + func runFirstLaunchAsync(absoluteXcodePath: String) async throws { Logger.helperClient.info("\(#function): \(absoluteXcodePath, privacy: .private(mask: .hash))") - let connectionErrorSubject = PassthroughSubject() - guard - let helper = self.helper(errorSubject: connectionErrorSubject) - else { - return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) - .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) - .eraseToAnyPublisher() + try await performVoidHelperRequest { helper, finish in + helper.runFirstLaunch(absoluteXcodePath: absoluteXcodePath) { possibleError in + finish(possibleError.map(Result.failure) ?? .success(())) + } + } + Logger.helperClient.info("\(#function): finished") + } + + private func performVoidHelperRequest(_ operation: @escaping @Sendable (HelperXPCProtocol, @escaping @Sendable (Result) -> Void) -> Void) async throws { + try await performHelperRequest(operation) + } + + private func performHelperRequest(_ operation: @escaping @Sendable (HelperXPCProtocol, @escaping @Sendable (Result) -> Void) -> Void) async throws -> T { + let request = OneShotContinuation() + guard let helper = helper(errorHandler: { error in + request.resume(throwing: error) + }) else { + throw HelperClientError.failedToCreateRemoteObjectProxy } - - return Deferred { - Future { promise in - helper.runFirstLaunch(absoluteXcodePath: absoluteXcodePath, completion: { (possibleError) in - if let error = possibleError { - promise(.failure(error)) - } else { - promise(.success(())) - } - }) + + return try await request.value { + operation(helper) { result in + request.resume(with: result) } } - // Take values, but fail when connectionErrorSubject fails - .zip( - connectionErrorSubject - .prepend("") - .map { _ in Void() } - ) - .map { $0.0 } - .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0))") }, - receiveCompletion: { completion in - switch completion { - case .finished: - Logger.helperClient.info("\(#function): finished") - case let .failure(error): - Logger.helperClient.error("\(#function): \(String(describing: error))") - } - }) - .eraseToAnyPublisher() - } - + } + // MARK: - Install // From https://github.com/securing/SimpleXPCApp/ - + func install() throws { Logger.helperClient.info(#function) @@ -325,15 +161,15 @@ final class HelperClient { self.connection?.invalidate() self.connection = nil - + Logger.helperClient.info("\(#function): Finished installation") } catch { Logger.helperClient.error("\(#function): \(error.localizedDescription)") - + throw error } } - + private func executeAuthorizationFunction(_ authorizationFunction: () -> (OSStatus) ) throws { let osStatus = authorizationFunction() guard osStatus == errAuthorizationSuccess else { @@ -344,7 +180,7 @@ final class HelperClient { } } } - + func authorizationRef(_ rights: UnsafePointer?, _ environment: UnsafePointer?, _ flags: AuthorizationFlags) throws -> AuthorizationRef? { @@ -357,7 +193,7 @@ final class HelperClient { enum HelperClientError: LocalizedError { case failedToCreateRemoteObjectProxy case message(String) - + var errorDescription: String? { switch self { case .failedToCreateRemoteObjectProxy: diff --git a/Xcodes/Backend/HelperInstallState.swift b/Xcodes/Backend/HelperInstallState.swift index 922ab071..92c20186 100644 --- a/Xcodes/Backend/HelperInstallState.swift +++ b/Xcodes/Backend/HelperInstallState.swift @@ -1,6 +1,6 @@ import Foundation -public enum HelperInstallState: Equatable { +public enum HelperInstallState: Equatable, Sendable { case unknown case notInstalled case installed diff --git a/Xcodes/Backend/InstalledXcode.swift b/Xcodes/Backend/InstalledXcode.swift deleted file mode 100644 index bc2ddd32..00000000 --- a/Xcodes/Backend/InstalledXcode.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Foundation -import Version -import Path -import XcodesKit - -/// A version of Xcode that's already installed -public struct InstalledXcode: Equatable { - public let path: Path - public let xcodeID: XcodeID - - /// Composed of the bundle short version from Info.plist and the product build version from version.plist - public var version: Version { - return xcodeID.version - } - - public init?(path: Path) { - self.path = path - - let infoPlistPath = path.join("Contents").join("Info.plist") - let versionPlistPath = path.join("Contents").join("version.plist") - guard - let infoPlistData = Current.files.contents(atPath: infoPlistPath.string), - let infoPlist = try? PropertyListDecoder().decode(InfoPlist.self, from: infoPlistData), - let bundleShortVersion = infoPlist.bundleShortVersion, - let bundleVersion = Version(tolerant: bundleShortVersion), - - let versionPlistData = Current.files.contents(atPath: versionPlistPath.string), - let versionPlist = try? PropertyListDecoder().decode(VersionPlist.self, from: versionPlistData) - else { return nil } - - // Installed betas don't include the beta number anywhere, so try to parse it from the filename or fall back to simply "beta" - var prereleaseIdentifiers = bundleVersion.prereleaseIdentifiers - if let filenameVersion = Version(path.basename(dropExtension: true).replacingOccurrences(of: "Xcode-", with: "")) { - prereleaseIdentifiers = filenameVersion.prereleaseIdentifiers - } - else if infoPlist.bundleIconName == "XcodeBeta", !prereleaseIdentifiers.contains("beta") { - prereleaseIdentifiers = ["beta"] - } - - let archsString = try? XcodesKit.Current.shell.archs(path.url.appending(path: "Contents/MacOS/Xcode")).out - - let architectures = archsString? - .trimmingCharacters(in: .whitespacesAndNewlines) - .split(separator: " ") - .compactMap { Architecture(rawValue: String($0)) } - - let version = Version(major: bundleVersion.major, - minor: bundleVersion.minor, - patch: bundleVersion.patch, - prereleaseIdentifiers: prereleaseIdentifiers, - buildMetadataIdentifiers: [versionPlist.productBuildVersion].compactMap { $0 }) - - self.xcodeID = XcodeID(version: version, architectures: architectures) - } -} - -public struct InfoPlist: Decodable { - public let bundleID: String? - public let bundleShortVersion: String? - public let bundleIconName: String? - - public enum CodingKeys: String, CodingKey { - case bundleID = "CFBundleIdentifier" - case bundleShortVersion = "CFBundleShortVersionString" - case bundleIconName = "CFBundleIconName" - } -} - -public struct VersionPlist: Decodable { - public let productBuildVersion: String - - public enum CodingKeys: String, CodingKey { - case productBuildVersion = "ProductBuildVersion" - } -} diff --git a/Xcodes/Backend/NotificationManager.swift b/Xcodes/Backend/NotificationManager.swift index 7c2a3af6..4b5fb8c3 100644 --- a/Xcodes/Backend/NotificationManager.swift +++ b/Xcodes/Backend/NotificationManager.swift @@ -29,23 +29,45 @@ public enum XcodesNotificationType: String, Identifiable, CaseIterable, CustomSt } } -public class NotificationManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { +@MainActor +public final class NotificationManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { private let notificationCenter = UNUserNotificationCenter.current() + private var notificationStatusTask: Task? + private var notificationStatusTaskID: UUID? + private var requestAccessTask: Task? + private var requestAccessTaskID: UUID? @Published var notificationStatus = NotificationPermissionPromptStatus.unknown - public override init() { + nonisolated public override init() { super.init() - loadNotificationStatus() - - notificationCenter.delegate = self + Task { @MainActor [weak self] in + guard let self else { return } + loadNotificationStatus() + notificationCenter.delegate = self + } + } + + deinit { + notificationStatusTask?.cancel() + requestAccessTask?.cancel() } public func loadNotificationStatus() { - UNUserNotificationCenter.current().getNotificationSettings(completionHandler: { [weak self] (settings) in + notificationStatusTask?.cancel() + let taskID = UUID() + notificationStatusTaskID = taskID + notificationStatusTask = Task { [weak self] in + let settings = await UNUserNotificationCenter.current().notificationSettings() + guard !Task.isCancelled else { + await self?.clearNotificationStatusTask(id: taskID) + return + } + let status = NotificationManager.systemPromptStatusFromSettings(settings) - self?.notificationStatus = status - }) + await self?.setNotificationStatus(status, ifNotificationStatusTaskID: taskID) + await self?.clearNotificationStatusTask(id: taskID) + } } private class func systemPromptStatusFromSettings(_ settings: UNNotificationSettings) -> NotificationPermissionPromptStatus { @@ -62,18 +84,73 @@ public class NotificationManager: NSObject, UNUserNotificationCenterDelegate, Ob } public func requestAccess() { - - notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, error in - DispatchQueue.main.async { - if let error = error { - // Handle the error here. - Logger.appState.error("Error requesting notification accesss: \(error.legibleLocalizedDescription)") - } else { - Logger.appState.log("User has \(granted ? "Granted" : "NOT GRANTED") notification permission") + notificationStatusTask?.cancel() + notificationStatusTask = nil + notificationStatusTaskID = nil + requestAccessTask?.cancel() + let taskID = UUID() + requestAccessTaskID = taskID + requestAccessTask = Task { [weak self] in + do { + let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) + guard !Task.isCancelled else { + await self?.clearRequestAccessTask(id: taskID) + return + } + + Logger.appState.log("User has \(granted ? "Granted" : "NOT GRANTED") notification permission") + } catch { + guard !Task.isCancelled else { + await self?.clearRequestAccessTask(id: taskID) + return } - self?.loadNotificationStatus() + + Logger.appState.error("Error requesting notification accesss: \(error.legibleLocalizedDescription)") } + + let settings = await UNUserNotificationCenter.current().notificationSettings() + guard !Task.isCancelled else { + await self?.clearRequestAccessTask(id: taskID) + return + } + + let status = NotificationManager.systemPromptStatusFromSettings(settings) + await self?.setNotificationStatus(status, ifRequestAccessTaskID: taskID) + await self?.clearRequestAccessTask(id: taskID) + } + } + + @MainActor + private func setNotificationStatus( + _ status: NotificationPermissionPromptStatus, + ifNotificationStatusTaskID notificationStatusTaskID: UUID? = nil, + ifRequestAccessTaskID requestAccessTaskID: UUID? = nil + ) { + if let notificationStatusTaskID, notificationStatusTaskID != self.notificationStatusTaskID { + return } + + if let requestAccessTaskID, requestAccessTaskID != self.requestAccessTaskID { + return + } + + notificationStatus = status + } + + @MainActor + private func clearNotificationStatusTask(id: UUID) { + guard id == notificationStatusTaskID else { return } + + notificationStatusTask = nil + notificationStatusTaskID = nil + } + + @MainActor + private func clearRequestAccessTask(id: UUID) { + guard id == requestAccessTaskID else { return } + + requestAccessTask = nil + requestAccessTaskID = nil } func scheduleNotification(title: String?, body: String, category: XcodesNotificationCategory) { @@ -93,8 +170,7 @@ public class NotificationManager: NSObject, UNUserNotificationCenterDelegate, Ob // MARK: UNUserNotificationCenterDelegate - public func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + nonisolated public func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { completionHandler( [.banner, .badge, .sound]) } } - diff --git a/Xcodes/Backend/Path+.swift b/Xcodes/Backend/Path+.swift index 06bbe63e..f89b08ea 100644 --- a/Xcodes/Backend/Path+.swift +++ b/Xcodes/Backend/Path+.swift @@ -1,46 +1,33 @@ import Path import Foundation +import XcodesKit extension Path { - static let defaultXcodesApplicationSupport = Path.applicationSupport/"com.robotsandpencils.XcodesApp" + static var defaultXcodesApplicationSupport: Path { + XcodesPathResolver.appDefaultApplicationSupport + } + static var xcodesApplicationSupport: Path { - guard let savedApplicationSupport = Current.defaults.string(forKey: "localPath") else { - return defaultXcodesApplicationSupport - } - guard let path = Path(savedApplicationSupport) else { - return defaultXcodesApplicationSupport - } - return path + XcodesPathResolver.appApplicationSupport(savedPath: Current.defaults.string(forKey: "localPath")) } static var cacheFile: Path { - return xcodesApplicationSupport/"available-xcodes.json" + XcodesPathResolver.availableXcodesCacheFile(in: xcodesApplicationSupport) } - static let defaultInstallDirectory = Path.root/"Applications" + static var defaultInstallDirectory: Path { + XcodesPathResolver.appDefaultInstallDirectory + } static var installDirectory: Path { - guard let savedInstallDirectory = Current.defaults.string(forKey: "installPath") else { - return defaultInstallDirectory - } - guard let path = Path(savedInstallDirectory) else { - return defaultInstallDirectory - } - return path + XcodesPathResolver.appInstallDirectory(savedPath: Current.defaults.string(forKey: "installPath")) } static var runtimeCacheFile: Path { - return xcodesApplicationSupport/"downloadable-runtimes.json" + XcodesPathResolver.downloadableRuntimesCacheFile(in: xcodesApplicationSupport) } static var xcodesCaches: Path { - return caches/"com.xcodesorg.xcodesapp" - } - - @discardableResult - func setCurrentUserAsOwner() -> Path { - let user = ProcessInfo.processInfo.environment["SUDO_USER"] ?? NSUserName() - try? FileManager.default.setAttributes([.ownerAccountName: user], ofItemAtPath: string) - return self + XcodesPathResolver.appCaches() } } diff --git a/Xcodes/Backend/Process.swift b/Xcodes/Backend/Process.swift index b3e5a64f..d922c20f 100644 --- a/Xcodes/Backend/Process.swift +++ b/Xcodes/Backend/Process.swift @@ -1,70 +1,18 @@ -import Combine import Foundation -import os.log import Path import XcodesKit -public typealias ProcessOutput = (status: Int32, out: String, err: String) +public typealias ProcessOutput = XcodesKit.ProcessOutput +public typealias ProcessExecutionError = XcodesKit.ProcessExecutionError extension Process { @discardableResult - static func run(_ executable: any Pathish, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) -> AnyPublisher { - return run(executable.url, workingDirectory: workingDirectory, input: input, arguments) + static func runAsync(_ executable: any Pathish, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) async throws -> ProcessOutput { + try await runAsync(executable.url, workingDirectory: workingDirectory, input: input, arguments) } @discardableResult - static func run(_ executable: URL, workingDirectory: URL? = nil, input: String? = nil, _ arguments: [String]) -> AnyPublisher { - Deferred { - Future { promise in - DispatchQueue.global().async { - let process = Process() - process.currentDirectoryURL = workingDirectory ?? executable.deletingLastPathComponent() - process.executableURL = executable - process.arguments = arguments - - let (stdout, stderr) = (Pipe(), Pipe()) - process.standardOutput = stdout - process.standardError = stderr - - if let input = input { - let inputPipe = Pipe() - process.standardInput = inputPipe.fileHandleForReading - inputPipe.fileHandleForWriting.write(Data(input.utf8)) - inputPipe.fileHandleForWriting.closeFile() - } - - do { - Logger.subprocess.info("Process.run executable: \(executable), input: \(input ?? ""), arguments: \(arguments.joined(separator: ", "))") - - try process.run() - process.waitUntilExit() - - let output = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - let error = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - - Logger.subprocess.info("Process.run output: \(output)") - if !error.isEmpty { - Logger.subprocess.error("Process.run error: \(error)") - } - - guard process.terminationReason == .exit, process.terminationStatus == 0 else { - DispatchQueue.main.async { - promise(.failure(ProcessExecutionError(process: process, standardOutput: output, standardError: error))) - } - return - } - - DispatchQueue.main.async { - promise(.success((process.terminationStatus, output, error))) - } - } catch { - DispatchQueue.main.async { - promise(.failure(error)) - } - } - } - } - } - .eraseToAnyPublisher() + static func runAsync(_ executable: URL, workingDirectory: URL? = nil, input: String? = nil, _ arguments: [String]) async throws -> ProcessOutput { + try await XcodesProcess.run(executable, workingDirectory: workingDirectory, input: input, arguments) } } diff --git a/Xcodes/Backend/Progress+.swift b/Xcodes/Backend/Progress+.swift index ce2ff3be..5b720a22 100644 --- a/Xcodes/Backend/Progress+.swift +++ b/Xcodes/Backend/Progress+.swift @@ -1,107 +1,7 @@ -import os.log import Foundation extension Progress { var xcodesLocalizedDescription: String { return localizedAdditionalDescription.replacingOccurrences(of: " — ", with: "\n") } - func updateFromAria2(string: String) { - - let range = NSRange(location: 0, length: string.utf16.count) - - // MARK: Total Downloaded - let regexTotalDownloaded = try! NSRegularExpression(pattern: #"(?<= )(.*)(?=\/)"#) - - if let match = regexTotalDownloaded.firstMatch(in: string, options: [], range: range), - let matchRange = Range(match.range(at: 0), in: string), - let totalDownloaded = Int(string[matchRange].replacingOccurrences(of: "B", with: "")) { - self.completedUnitCount = Int64(totalDownloaded) - } - - // MARK: Filesize - let regexTotalFileSize = try! NSRegularExpression(pattern: #"(?<=/)(.*)(?=\()"#) - - if let match = regexTotalFileSize.firstMatch(in: string, options: [], range: range), - let matchRange = Range(match.range(at: 0), in: string), - let totalFileSize = Int(string[matchRange].replacingOccurrences(of: "B", with: "")) { - - if totalFileSize > 0 { - self.totalUnitCount = Int64(totalFileSize) - } - } - - // MARK: PERCENT DOWNLOADED - // Since we get fractionCompleted from completedUnitCount + totalUnitCount, no need to process - // let regexPercent = try! NSRegularExpression(pattern: #"((?\d+)%\))"#) - - // MARK: Speed - let regexSpeed = try! NSRegularExpression(pattern: #"(?<=DL:)(.*)(?= )"#) - - if let match = regexSpeed.firstMatch(in: string, options: [], range: range), - let matchRange = Range(match.range(at: 0), in: string), - let speed = Int(string[matchRange].replacingOccurrences(of: "B", with: "")) { - self.throughput = speed - } else { - Logger.appState.debug("Could not parse throughput from aria2 download output") - } - - // MARK: Estimated Time Remaining - let regexETA = try! NSRegularExpression(pattern: #"(?<=ETA:)(?\d*h)?(?\d*m)?(?\d*s)?"#) - - if let match = regexETA.firstMatch(in: string, options: [], range: range) { - var seconds: Int = 0 - - if let matchRange = Range(match.range(withName: "hours"), in: string), - let hours = Int(string[matchRange].replacingOccurrences(of: "h", with: "")) { - seconds += (hours * 60 * 60) - } - - if let matchRange = Range(match.range(withName: "minutes"), in: string), - let minutes = Int(string[matchRange].replacingOccurrences(of: "m", with: "")) { - seconds += (minutes * 60) - } - - if let matchRange = Range(match.range(withName: "seconds"), in: string), - let second = Int(string[matchRange].replacingOccurrences(of: "s", with: "")) { - seconds += (second) - } - - self.estimatedTimeRemaining = TimeInterval(seconds) - } - - } - - func updateFromXcodebuild(text: String) { - self.totalUnitCount = 100 - self.completedUnitCount = 0 - self.localizedAdditionalDescription = "" // to not show the addtional - - do { - - let downloadPattern = #"(\d+\.\d+)% \(([\d.]+ (?:MB|GB)) of ([\d.]+ GB)\)"# - let downloadRegex = try NSRegularExpression(pattern: downloadPattern) - - // Search for matches in the text - if let match = downloadRegex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) { - // Extract the percentage - simpler then trying to extract size MB/GB and convert to bytes. - if let percentRange = Range(match.range(at: 1), in: text), let percentDouble = Double(text[percentRange]) { - let percent = Int64(percentDouble.rounded()) - self.completedUnitCount = percent - } - } - - // "Downloading tvOS 18.1 Simulator (22J5567a): Installing..." or - // "Downloading tvOS 18.1 Simulator (22J5567a): Installing (registering download)..." - if text.range(of: "Installing") != nil { - // sets the progress to indeterminite to show animating progress - self.totalUnitCount = 0 - self.completedUnitCount = 0 - } - - } catch { - Logger.appState.error("Invalid regular expression") - } - - } } - diff --git a/Xcodes/Backend/Publisher+Resumable.swift b/Xcodes/Backend/Publisher+Resumable.swift deleted file mode 100644 index f7eee9b5..00000000 --- a/Xcodes/Backend/Publisher+Resumable.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Combine -import Foundation - -/// Attempt and retry a task that fails with resume data up to `maximumRetryCount` times -func attemptResumableTask( - maximumRetryCount: Int = 3, - delayBeforeRetry: TimeInterval = 2, - _ body: @escaping (Data?) -> AnyPublisher -) -> AnyPublisher { - var attempts = 0 - func attempt(with resumeData: Data? = nil) -> AnyPublisher { - attempts += 1 - return body(resumeData) - .catch { error -> AnyPublisher in - guard - attempts < maximumRetryCount, - let resumeData = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data - else { return Fail(error: error).eraseToAnyPublisher() } - - return attempt(with: resumeData) - .delay(for: .seconds(delayBeforeRetry), scheduler: DispatchQueue.main) - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } - return attempt() -} - -///// Attempt and retry a task up to `maximumRetryCount` times -//func attemptRetryableTask( -// maximumRetryCount: Int = 3, -// delayBeforeRetry: DispatchTimeInterval = .seconds(2), -// _ body: @escaping () -> AnyPublisher -//) -> AnyPublisher { -// var attempts = 0 -// func attempt() -> Promise { -// attempts += 1 -// return body().recover { error -> Promise in -// guard attempts < maximumRetryCount else { throw error } -// return after(delayBeforeRetry).then(on: nil) { attempt() } -// } -// } -// return attempt() -//} diff --git a/Xcodes/Backend/SDKs+Xcode.swift b/Xcodes/Backend/SDKs+Xcode.swift index 716c7fae..f35f69e2 100644 --- a/Xcodes/Backend/SDKs+Xcode.swift +++ b/Xcodes/Backend/SDKs+Xcode.swift @@ -6,34 +6,8 @@ // Copyright © 2023 Robots and Pencils. All rights reserved. // -import Foundation -import XcodesKit import SwiftUI - -extension SDKs { - /// Loops through all SDK's and returns an array of buildNumbers (to be used to correlate runtimes) - func allBuilds() -> [String] { - var buildNumbers: [String] = [] - - if let iOS = self.iOS?.compactMap({ $0.build }) { - buildNumbers += iOS - } - if let tvOS = self.tvOS?.compactMap({ $0.build }) { - buildNumbers += tvOS - } - if let macOS = self.macOS?.compactMap({ $0.build }) { - buildNumbers += macOS - } - if let watchOS = self.watchOS?.compactMap({ $0.build }) { - buildNumbers += watchOS - } - if let visionOS = self.visionOS?.compactMap({ $0.build }) { - buildNumbers += visionOS - } - - return buildNumbers - } -} +import XcodesKit extension DownloadableRuntime { func icon() -> Image { diff --git a/Xcodes/Backend/SelectedActionType.swift b/Xcodes/Backend/SelectedActionType.swift index bce3a883..9471b791 100644 --- a/Xcodes/Backend/SelectedActionType.swift +++ b/Xcodes/Backend/SelectedActionType.swift @@ -1,31 +1,4 @@ -// -// SelectedActionType.swift -// Xcodes -// -// Created by Matt Kiazyk on 2022-07-24. -// Copyright © 2022 Robots and Pencils. All rights reserved. -// +import XcodesKit -import Foundation -public enum SelectedActionType: String, CaseIterable, Identifiable, CustomStringConvertible { - case none - case rename - - public var id: Self { self } - - public static var `default` = SelectedActionType.none - - public var description: String { - switch self { - case .none: return localizeString("OnSelectDoNothing") - case .rename: return localizeString("OnSelectRenameXcode") - } - } - - public var detailedDescription: String { - switch self { - case .none: return localizeString("OnSelectDoNothingDescription") - case .rename: return localizeString("OnSelectRenameXcodeDescription") - } - } -} +public typealias AutoInstallationType = XcodesKit.AutoInstallationType +public typealias SelectedActionType = XcodesKit.SelectedActionType diff --git a/Xcodes/Backend/URLRequest+Apple.swift b/Xcodes/Backend/URLRequest+Apple.swift deleted file mode 100644 index 99e4b6e4..00000000 --- a/Xcodes/Backend/URLRequest+Apple.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation - -extension URL { - static let download = URL(string: "https://developer.apple.com/download")! - static let downloads = URL(string: "https://developer.apple.com/services-account/QH65B2/downloadws/listDownloads.action")! - static let downloadXcode = URL(string: "https://developer.apple.com/devcenter/download.action")! - static let downloadADCAuth = URL(string: "https://developerservices2.apple.com/services/download")! -} - -extension URLRequest { - static var download: URLRequest { - return URLRequest(url: .download) - } - - static var downloads: URLRequest { - var request = URLRequest(url: .downloads) - request.httpMethod = "POST" - return request - } - - static func downloadXcode(path: String) -> URLRequest { - var components = URLComponents(url: .downloadXcode, resolvingAgainstBaseURL: false)! - components.queryItems = [URLQueryItem(name: "path", value: path)] - var request = URLRequest(url: components.url!) - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["Accept"] = "*/*" - return request - } - - static func downloadADCAuth(path: String) -> URLRequest { - var components = URLComponents(url: .downloadADCAuth, resolvingAgainstBaseURL: false)! - components.queryItems = [URLQueryItem(name: "path", value: path)] - var request = URLRequest(url: components.url!) - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["Accept"] = "*/*" - return request - } -} diff --git a/Xcodes/Backend/URLSession+DownloadTaskPublisher.swift b/Xcodes/Backend/URLSession+DownloadTaskPublisher.swift deleted file mode 100644 index d287b4d1..00000000 --- a/Xcodes/Backend/URLSession+DownloadTaskPublisher.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Combine -import Foundation - -extension URLSession { - /** - - Parameter convertible: A URL or URLRequest. - - Parameter saveLocation: A URL to move the downloaded file to after it completes. Apple deletes the temporary file immediately after the underyling completion handler returns. - - Parameter resumeData: Data describing the state of a previously cancelled or failed download task. See the Discussion section for `downloadTask(withResumeData:completionHandler:)` https://developer.apple.com/documentation/foundation/urlsession/1411598-downloadtask# - - - Returns: Tuple containing a Progress object for the task and a publisher of the save location and response. - - - Note: We do not create the destination directory for you, because we move the file with FileManager.moveItem which changes its behavior depending on the directory status of the URL you provide. So create your own directory first! - */ - public func downloadTask( - with url: URL, - to saveLocation: URL, - resumingWith resumeData: Data? - ) -> (progress: Progress, publisher: AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) { - var progress: Progress! - var task: URLSessionDownloadTask! - - // Intentionally not wrapping in Deferred because we need to return the Progress and URLSessionDownloadTask immediately. - // Probably a sign that this should be implemented differently... - let promise = Future<(saveLocation: URL, response: URLResponse), Error> { promise in - let completionHandler = { (temporaryURL: URL?, response: URLResponse?, error: Error?) in - if let error = error { - promise(.failure(error)) - } else if let response = response, let temporaryURL = temporaryURL { - do { - try FileManager.default.moveItem(at: temporaryURL, to: saveLocation) - promise(.success((saveLocation, response))) - } catch { - promise(.failure(error)) - } - } else { - fatalError("Expecting either a temporary URL and a response, or an error, but got neither.") - } - } - - if let resumeData = resumeData { - task = self.downloadTask(withResumeData: resumeData, completionHandler: completionHandler) - } - else { - task = self.downloadTask(with: url, completionHandler: completionHandler) - } - progress = task.progress - task.resume() - } - .handleEvents(receiveCancel: task.cancel) - .eraseToAnyPublisher() - - return (progress, promise) - } -} diff --git a/Xcodes/Backend/Version+.swift b/Xcodes/Backend/Version+.swift deleted file mode 100644 index e86d4a52..00000000 --- a/Xcodes/Backend/Version+.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Version - -public extension Version { - /// Determines if two Xcode versions should be treated equivalently. This is not the same as equality. - /// - /// We need a way to determine if two Xcode versions are the same without always having full information, and supporting different data sources. - /// For example, the Apple data source often doesn't have build metadata identifiers. - func isEquivalent(to other: Version) -> Bool { - // If we don't have build metadata identifiers for both Versions, compare major, minor, patch and prerelease identifiers. - if buildMetadataIdentifiers.isEmpty || other.buildMetadataIdentifiers.isEmpty { - return major == other.major && - minor == other.minor && - patch == other.patch && - prereleaseIdentifiers.map { $0.lowercased() } == other.prereleaseIdentifiers.map { $0.lowercased() } - // If we have build metadata identifiers for both, we can ignore the prerelease identifiers. - } else { - return major == other.major && - minor == other.minor && - patch == other.patch && - buildMetadataIdentifiers.map { $0.lowercased() } == other.buildMetadataIdentifiers.map { $0.lowercased() } - } - } - - var descriptionWithoutBuildMetadata: String { - var base = "\(major).\(minor).\(patch)" - if !prereleaseIdentifiers.isEmpty { - base += "-" + prereleaseIdentifiers.joined(separator: ".") - } - return base - } - - var isPrerelease: Bool { prereleaseIdentifiers.isEmpty == false } - var isNotPrerelease: Bool { prereleaseIdentifiers.isEmpty == true } -} diff --git a/Xcodes/Backend/Version+Xcode.swift b/Xcodes/Backend/Version+Xcode.swift deleted file mode 100644 index 5166fba9..00000000 --- a/Xcodes/Backend/Version+Xcode.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Foundation -import Version - -public extension Version { - /** - E.g.: - Xcode 10.2 Beta 4 - Xcode 10.2 GM - Xcode 10.2 GM seed 2 - Xcode 10.2 - Xcode 10.2.1 - 10.2 Beta 4 - 10.2 GM - 10.2 - 10.2.1 - */ - init?(xcodeVersion: String, buildMetadataIdentifier: String? = nil) { - let nsrange = NSRange(xcodeVersion.startIndex.. String? { - let nsrange = range(withName: name) - guard let range = Range(nsrange, in: string) else { return nil } - return String(string[range]) - } -} diff --git a/Xcodes/Backend/Version+XcodeReleases.swift b/Xcodes/Backend/Version+XcodeReleases.swift deleted file mode 100644 index 7c4dd219..00000000 --- a/Xcodes/Backend/Version+XcodeReleases.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Version -import XcodesKit - -extension Version { - /// Initialize a Version from an XcodeReleases' XCModel.Xcode - /// - /// This is kinda quick-and-dirty, and it would probably be better for us to adopt something closer to XCModel.Xcode under the hood and map the scraped data to it instead. - init?(xcReleasesXcode: XcodeRelease) { - var versionString = xcReleasesXcode.version.number ?? "" - - // Append trailing ".0" in order to get a fully-specified version string - let components = versionString.components(separatedBy: ".") - versionString += Array(repeating: ".0", count: 3 - components.count).joined() - - // Append prerelease identifier - switch xcReleasesXcode.version.release { - case let .beta(beta): - versionString += "-Beta" - if beta > 1 { - versionString += ".\(beta)" - } - case let .dp(dp): - versionString += "-DP" - if dp > 1 { - versionString += ".\(dp)" - } - case .gm: - break - case let .gmSeed(gmSeed): - versionString += "-GM.Seed" - if gmSeed > 1 { - versionString += ".\(gmSeed)" - } - case let .rc(rc): - versionString += "-Release.Candidate" - if rc > 1 { - versionString += ".\(rc)" - } - case .release: - break - } - - // Append build identifier - if let buildNumber = xcReleasesXcode.version.build { - versionString += "+\(buildNumber)" - } - - self.init(versionString) - } - - var buildMetadataIdentifiersDisplay: String { - return !buildMetadataIdentifiers.isEmpty ? "(\(buildMetadataIdentifiers.joined(separator: " ")))" : "" - } -} - diff --git a/Xcodes/Backend/Xcode.swift b/Xcodes/Backend/Xcode.swift index b1721499..5d65313b 100644 --- a/Xcodes/Backend/Xcode.swift +++ b/Xcodes/Backend/Xcode.swift @@ -1,24 +1,9 @@ import AppKit import Foundation -import Version import Path +import Version import XcodesKit -public struct XcodeID: Codable, Hashable, Identifiable { - public let version: Version - public let architectures: [Architecture]? - - public var id: String { - let architectures = architectures?.map { $0.rawValue}.joined() ?? "" - return version.description + architectures - } - - public init(version: Version, architectures: [Architecture]? = nil) { - self.version = version - self.architectures = architectures - } -} - struct Xcode: Identifiable, CustomStringConvertible { var version: Version { return id.version @@ -64,26 +49,52 @@ struct Xcode: Identifiable, CustomStringConvertible { self.architectures = architectures self.id = XcodeID(version: version, architectures: architectures) } + + init(_ item: XcodeListItem, icon: NSImage?) { + self.identicalBuilds = item.identicalBuilds + self.installState = item.installState + self.selected = item.selected + self.icon = icon + self.requiredMacOSVersion = item.requiredMacOSVersion + self.releaseNotesURL = item.releaseNotesURL + self.releaseDate = item.releaseDate + self.sdks = item.sdks + self.compilers = item.compilers + self.downloadFileSize = item.downloadFileSize + self.architectures = item.architectures + self.id = item.id + } + + var listItem: XcodeListItem { + XcodeListItem( + version: version, + identicalBuilds: identicalBuilds, + installState: installState, + selected: selected, + requiredMacOSVersion: requiredMacOSVersion, + releaseNotesURL: releaseNotesURL, + releaseDate: releaseDate, + sdks: sdks, + compilers: compilers, + downloadFileSize: downloadFileSize, + architectures: architectures + ) + } var description: String { version.appleDescription } + + var identicalBuildsForCurrentVariant: [XcodeID] { + identicalBuilds.filter { $0.architectures == architectures } + } var downloadFileSizeString: String? { - if let downloadFileSize = downloadFileSize { - return ByteCountFormatter.string(fromByteCount: downloadFileSize, countStyle: .file) - } else { - return nil - } + listItem.downloadFileSizeString } var installedPath: Path? { - switch installState { - case .installed(let path): - return path - default: - return nil - } + installState.installedPath } } diff --git a/Xcodes/Backend/XcodeCommands.swift b/Xcodes/Backend/XcodeCommands.swift index cd21bf4e..344c7ebd 100644 --- a/Xcodes/Backend/XcodeCommands.swift +++ b/Xcodes/Backend/XcodeCommands.swift @@ -115,7 +115,7 @@ struct OpenButton: View { let xcode: Xcode? var openInRosetta: Bool { - appState.showOpenInRosettaOption && Hardware.isAppleSilicon() + appState.showOpenInRosettaOption && HostHardware.isAppleSilicon() } var body: some View { @@ -347,4 +347,3 @@ struct CreateSymbolicLinkCommand: View { .disabled(selectedXcode.unwrapped?.installState.installed != true) } } - diff --git a/Xcodes/Backend/XcodeInstallState.swift b/Xcodes/Backend/XcodeInstallState.swift deleted file mode 100644 index a289bb59..00000000 --- a/Xcodes/Backend/XcodeInstallState.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation -import Path -import XcodesKit - -enum XcodeInstallState: Equatable { - case notInstalled - case installing(XcodeInstallationStep) - case installed(Path) - - var notInstalled: Bool { - switch self { - case .notInstalled: return true - default: return false - } - } - var installing: Bool { - switch self { - case .installing: return true - default: return false - } - } - var installed: Bool { - switch self { - case .installed: return true - default: return false - } - } -} diff --git a/Xcodes/Frontend/About/AboutView.swift b/Xcodes/Frontend/About/AboutView.swift index b9135397..a8511518 100644 --- a/Xcodes/Frontend/About/AboutView.swift +++ b/Xcodes/Frontend/About/AboutView.swift @@ -1,7 +1,7 @@ import SwiftUI struct AboutView: View { - let showAcknowledgementsWindow: () -> Void + let showAcknowledgementsWindow: @MainActor () -> Void @SwiftUI.Environment(\.openURL) var openURL: OpenURLAction var body: some View { diff --git a/Xcodes/Frontend/Common/ObservingProgressIndicator.swift b/Xcodes/Frontend/Common/ObservingProgressIndicator.swift index 3302f0a3..3c6cdbe2 100644 --- a/Xcodes/Frontend/Common/ObservingProgressIndicator.swift +++ b/Xcodes/Frontend/Common/ObservingProgressIndicator.swift @@ -1,5 +1,5 @@ -import Combine import SwiftUI +import XcodesKit /// A ProgressIndicator that reflects the state of a Progress object. /// This functionality is already built in to ProgressView, @@ -23,17 +23,39 @@ public struct ObservingProgressIndicator: View { self.showsAdditionalDescription = showsAdditionalDescription } + @MainActor class ProgressWrapper: ObservableObject { var progress: Progress - var cancellable: AnyCancellable! + private var observationTask: Task? init(progress: Progress) { self.progress = progress - cancellable = progress.publisher(for: \.fractionCompleted) - .combineLatest(progress.publisher(for: \.localizedAdditionalDescription)) - .combineLatest(progress.publisher(for: \.isIndeterminate)) - .throttle(for: 1.0, scheduler: DispatchQueue.main, latest: true) - .sink { [weak self] _ in self?.objectWillChange.send() } + observationTask = Task { [weak self] in + await self?.observeProgress() + } + } + + deinit { + observationTask?.cancel() + } + + private func observeProgress() async { + for await _ in Self.progressChanges(for: progress) { + objectWillChange.send() + + do { + try await Task.sleep(for: .seconds(1)) + } catch { + return + } + } + } + + private nonisolated static func progressChanges(for progress: Progress) -> AsyncStream { + ProgressObservation.changes( + for: progress, + observing: [.fractionCompleted, .localizedAdditionalDescription, .isIndeterminate] + ) } } diff --git a/Xcodes/Frontend/Common/View+ErrorHandling.swift b/Xcodes/Frontend/Common/View+ErrorHandling.swift new file mode 100644 index 00000000..bd1a8ad5 --- /dev/null +++ b/Xcodes/Frontend/Common/View+ErrorHandling.swift @@ -0,0 +1,172 @@ +import SwiftUI + +enum ErrorCategory: Equatable { + case nonRecoverable + case recoverable(recoveryOption: RecoveryOption) + case requiresSignout +} + +protocol CategorizedError: Error { + var category: ErrorCategory { get } +} + +extension Error { + func resolveCategory() -> ErrorCategory { + guard let categorized = self as? CategorizedError else { + return .nonRecoverable + } + + return categorized.category + } +} + +struct RecoveryOption: Identifiable, Equatable, CustomStringConvertible { + let id: String + let description: String + + init(id: String, description: String) { + self.id = id + self.description = description + } +} + +extension RecoveryOption { + static let retry = RecoveryOption(id: "com.xcodesorg.Xcodes.retry", description: "Retry") +} + +@MainActor +protocol ErrorHandler: Sendable { + func handle( + _ error: Binding, + in view: T, + recoveryHandler: @escaping (RecoveryOption) -> Void, + signOutHandler: @escaping () -> Void + ) -> AnyView +} + +struct AlertErrorHandler: ErrorHandler { + private let id = UUID() + + func handle( + _ error: Binding, + in view: T, + recoveryHandler: @escaping (RecoveryOption) -> Void, + signOutHandler: @escaping () -> Void + ) -> AnyView { + guard error.wrappedValue?.resolveCategory() != .requiresSignout else { + signOutHandler() + return AnyView(view) + } + + let binding = Binding( + get: { + error.wrappedValue.map { + Presentation( + id: id, + error: $0, + recoveryHandler: { recoveryOption in + DispatchQueue.main.async { + recoveryHandler(recoveryOption) + } + } + ) + } + }, + set: { possiblePresentation in + if possiblePresentation == nil { + error.wrappedValue = nil + } + } + ) + + return AnyView(view.alert(item: binding, content: makeAlert)) + } +} + +private extension AlertErrorHandler { + struct Presentation: Identifiable { + let id: UUID + let error: Error + let recoveryHandler: (RecoveryOption) -> Void + } + + func makeAlert(for presentation: Presentation) -> Alert { + let error = presentation.error + + switch error.resolveCategory() { + case let .recoverable(recoveryOption): + return Alert( + title: Text("An error occurred"), + message: Text(error.localizedDescription), + primaryButton: .default(Text("Dismiss")), + secondaryButton: .default( + Text(recoveryOption.description), + action: { presentation.recoveryHandler(recoveryOption) } + ) + ) + case .nonRecoverable: + return Alert( + title: Text("An error occurred"), + message: Text(error.localizedDescription), + dismissButton: .default(Text("Dismiss")) + ) + case .requiresSignout: + assertionFailure("Should have signed out") + return Alert(title: Text("Signing out...")) + } + } +} + +private struct ErrorHandlerEnvironmentKey: EnvironmentKey { + static let defaultValue: any ErrorHandler = AlertErrorHandler() +} + +private struct SignOutHandlerEnvironmentKey: EnvironmentKey { + static let defaultValue: @MainActor @Sendable () -> Void = {} +} + +extension EnvironmentValues { + var errorHandler: any ErrorHandler { + get { self[ErrorHandlerEnvironmentKey.self] } + set { self[ErrorHandlerEnvironmentKey.self] = newValue } + } + + var signOutHandler: @MainActor @Sendable () -> Void { + get { self[SignOutHandlerEnvironmentKey.self] } + set { self[SignOutHandlerEnvironmentKey.self] = newValue } + } +} + +@MainActor +struct ErrorEmittingViewModifier: ViewModifier { + @SwiftUI.Environment(\.errorHandler) private var errorHandler: any ErrorHandler + @SwiftUI.Environment(\.signOutHandler) private var signOutHandler: @MainActor @Sendable () -> Void + + var error: Binding + var recoveryHandler: (RecoveryOption) -> Void + + func body(content: Content) -> some View { + errorHandler.handle( + error, + in: content, + recoveryHandler: recoveryHandler, + signOutHandler: signOutHandler + ) + } +} + +extension View { + func handlingErrors(using handler: any ErrorHandler) -> some View { + environment(\.errorHandler, handler) + } + + func emittingError( + _ error: Binding, + recoveryHandler: @escaping (RecoveryOption) -> Void + ) -> some View { + modifier(ErrorEmittingViewModifier( + error: error, + recoveryHandler: recoveryHandler + )) + } +} diff --git a/Xcodes/Frontend/Common/XcodesSheet.swift b/Xcodes/Frontend/Common/XcodesSheet.swift index 2aa8f2c8..5d60fc94 100644 --- a/Xcodes/Frontend/Common/XcodesSheet.swift +++ b/Xcodes/Frontend/Common/XcodesSheet.swift @@ -1,5 +1,5 @@ import Foundation -import AppleAPI +import XcodesLoginKit enum XcodesSheet: Identifiable { case signIn diff --git a/Xcodes/Frontend/InfoPane/CompatibilityView.swift b/Xcodes/Frontend/InfoPane/CompatibilityView.swift index f5a583b7..7de7c6c0 100644 --- a/Xcodes/Frontend/InfoPane/CompatibilityView.swift +++ b/Xcodes/Frontend/InfoPane/CompatibilityView.swift @@ -36,7 +36,7 @@ struct CompatibilityView: View { } } -#Preview { +#Preview { @MainActor in CompatibilityView(requiredMacOSVersion: "10.15.4") .padding() .environmentObject(AppState()) diff --git a/Xcodes/Frontend/InfoPane/IconView.swift b/Xcodes/Frontend/InfoPane/IconView.swift index ba9aa4f8..0940d4e6 100644 --- a/Xcodes/Frontend/InfoPane/IconView.swift +++ b/Xcodes/Frontend/InfoPane/IconView.swift @@ -9,6 +9,7 @@ import Path import SwiftUI import Version +import XcodesKit struct IconView: View { let xcode: Xcode diff --git a/Xcodes/Frontend/InfoPane/IdenticalBuildView.swift b/Xcodes/Frontend/InfoPane/IdenticalBuildView.swift index 7d2d11a7..6d76d5ed 100644 --- a/Xcodes/Frontend/InfoPane/IdenticalBuildView.swift +++ b/Xcodes/Frontend/InfoPane/IdenticalBuildView.swift @@ -8,9 +8,10 @@ import SwiftUI import Version +import XcodesKit struct IdenticalBuildsView: View { - let builds: [Version] + let builds: [XcodeID] private let isEmpty: Bool private let accessibilityDescription: String @@ -28,8 +29,8 @@ struct IdenticalBuildsView: View { } .font(.headline) - ForEach(builds, id: \.description) { version in - Text(verbatim: "• \(version.appleDescription)") + ForEach(builds) { build in + Text(verbatim: "• \(build.version.appleDescription)") .font(.subheadline) } } @@ -41,19 +42,23 @@ struct IdenticalBuildsView: View { } } - init(builds: [Version]) { + init(builds: [XcodeID]) { self.builds = builds self.isEmpty = builds.isEmpty self.accessibilityDescription = builds - .map(\.appleDescription) + .map(\.version.appleDescription) .joined(separator: ", ") } } -let builds: [Version] = [.init(xcodeVersion: "15.0")!, .init(xcodeVersion: "15.1")!] +@MainActor +private let previewBuilds: [XcodeID] = [ + .init(version: .init(xcodeVersion: "15.0")!), + .init(version: .init(xcodeVersion: "15.1")!) +] #Preview("Has Some Builds") { - IdenticalBuildsView(builds: builds) + IdenticalBuildsView(builds: previewBuilds) .padding() } diff --git a/Xcodes/Frontend/InfoPane/InfoPane.swift b/Xcodes/Frontend/InfoPane/InfoPane.swift index b2171040..b557e748 100644 --- a/Xcodes/Frontend/InfoPane/InfoPane.swift +++ b/Xcodes/Frontend/InfoPane/InfoPane.swift @@ -40,7 +40,7 @@ struct InfoPane: View { VStack(alignment: .leading) { ReleaseDateView(date: xcode.releaseDate, url: xcode.releaseNotesURL) CompatibilityView(requiredMacOSVersion: xcode.requiredMacOSVersion) - IdenticalBuildsView(builds: xcode.identicalBuilds.map { $0.version }) + IdenticalBuildsView(builds: xcode.identicalBuildsForCurrentVariant) SDKandCompilers } .frame(width: 200) @@ -69,6 +69,7 @@ struct InfoPane: View { #Preview(XcodePreviewName.allCases[4].rawValue) { makePreviewContent(for: 4) } #Preview(XcodePreviewName.allCases[5].rawValue) { makePreviewContent(for: 5) } +@MainActor private func makePreviewContent(for index: Int) -> some View { let name = XcodePreviewName.allCases[index] return InfoPane(xcode: xcodeDict[name]!) @@ -90,7 +91,8 @@ enum XcodePreviewName: String, CaseIterable, Identifiable { var id: XcodePreviewName { self } } -var xcodeDict: [XcodePreviewName: Xcode] = [ +@MainActor +let xcodeDict: [XcodePreviewName: Xcode] = [ .Populated_Installed_Selected: .init( version: _versionNoMeta, installState: .installed(Path(_path)!), @@ -156,7 +158,8 @@ var xcodeDict: [XcodePreviewName: Xcode] = [ ), ] -var downloadableRuntimes: [DownloadableRuntime] = { +@MainActor +let downloadableRuntimes: [DownloadableRuntime] = { var runtimes = try! JSONDecoder().decode([DownloadableRuntime].self, from: Current.files.contents(atPath: Path.runtimeCacheFile.string)!) // set iOS to installed let iOSIndex = 0//runtimes.firstIndex { $0.sdkBuildUpdate.contains == "19E239" }! @@ -183,13 +186,16 @@ var downloadableRuntimes: [DownloadableRuntime] = { return runtimes }() -var installedRuntimes: [CoreSimulatorImage] = { +@MainActor +let installedRuntimes: [CoreSimulatorImage] = { [CoreSimulatorImage(uuid: "85B22F5B-048B-4331-B6E2-F4196D8B7475", path: ["relative" : "file:///Library/Developer/CoreSimulator/Images/85B22F5B-048B-4331-B6E2-F4196D8B7475.dmg"], runtimeInfo: CoreSimulatorRuntimeInfo(build: "19E240")), CoreSimulatorImage(uuid: "85B22F5B-048B-4331-B6E2-F4196D8B7473", path: ["relative" : "file:///Library/Developer/CoreSimulator/Images/85B22F5B-048B-4331-B6E2-F4196D8B7475.dmg"], runtimeInfo: CoreSimulatorRuntimeInfo(build: "21N5233f"))] }() +@MainActor private let _versionNoMeta = Version(major: 12, minor: 3, patch: 0) +@MainActor private let _versionWithMeta = Version(major: 12, minor: 3, patch: 1, buildMetadataIdentifiers: ["1234A"]) private let _path = "/Applications/Xcode-12.3.0.app" private let _requiredMacOSVersion = "10.15.4" diff --git a/Xcodes/Frontend/InfoPane/InfoPaneControls.swift b/Xcodes/Frontend/InfoPane/InfoPaneControls.swift index 4e6df41c..de1356ed 100644 --- a/Xcodes/Frontend/InfoPane/InfoPaneControls.swift +++ b/Xcodes/Frontend/InfoPane/InfoPaneControls.swift @@ -42,6 +42,7 @@ struct InfoPaneControls: View { #Preview(XcodePreviewName.allCases[4].rawValue) { makePreviewContent(for: 4) } #Preview(XcodePreviewName.allCases[5].rawValue) { makePreviewContent(for: 5) } +@MainActor private func makePreviewContent(for index: Int) -> some View { let name = XcodePreviewName.allCases[index] diff --git a/Xcodes/Frontend/InfoPane/InstalledStateButtons.swift b/Xcodes/Frontend/InfoPane/InstalledStateButtons.swift index d1068728..0de4924b 100644 --- a/Xcodes/Frontend/InfoPane/InstalledStateButtons.swift +++ b/Xcodes/Frontend/InfoPane/InstalledStateButtons.swift @@ -42,7 +42,7 @@ struct InstalledStateButtons: View { } } -#Preview { +#Preview { @MainActor in InstalledStateButtons(xcode: xcode) .environmentObject(configure(AppState()) { $0.allXcodes = [xcode] diff --git a/Xcodes/Frontend/InfoPane/NotInstalledStateButtons.swift b/Xcodes/Frontend/InfoPane/NotInstalledStateButtons.swift index 9e6bf5cb..5247dcbf 100644 --- a/Xcodes/Frontend/InfoPane/NotInstalledStateButtons.swift +++ b/Xcodes/Frontend/InfoPane/NotInstalledStateButtons.swift @@ -8,6 +8,7 @@ import SwiftUI import Version +import XcodesKit struct NotInstalledStateButtons: View { let downloadFileSizeString: String? @@ -39,7 +40,7 @@ struct NotInstalledStateButtons: View { } } -#Preview { +#Preview { @MainActor in NotInstalledStateButtons( downloadFileSizeString: "1,19 GB", id: XcodeID(version: Version(major: 12, minor: 3, patch: 0), architectures: nil) diff --git a/Xcodes/Frontend/InfoPane/PlatformsView.swift b/Xcodes/Frontend/InfoPane/PlatformsView.swift index ea268c13..8f277999 100644 --- a/Xcodes/Frontend/InfoPane/PlatformsView.swift +++ b/Xcodes/Frontend/InfoPane/PlatformsView.swift @@ -11,14 +11,14 @@ import XcodesKit struct PlatformsView: View { @EnvironmentObject var appState: AppState - @AppStorage("selectedRuntimeArchitecture") private var selectedVariant: ArchitectureVariant = .universal + @AppStorage("selectedRuntimeArchitecture") private var selectedVariant: ArchitectureVariant = .defaultForMachine() let xcode: Xcode var body: some View { - let builds = xcode.sdks?.allBuilds() - let runtimes = builds?.flatMap { sdkBuild in + let builds = xcode.sdks?.allBuilds + let runtimes = (builds?.flatMap { sdkBuild in appState.downloadableRuntimes.filter { $0.sdkBuildUpdate?.contains(sdkBuild) ?? false && ($0.architectures?.isEmpty ?? true || @@ -26,9 +26,9 @@ struct PlatformsView: View { ($0.architectures?.isAppleSilicon ?? false && selectedVariant == .appleSilicon) ) } - } + } ?? []).removingReleaseCandidateDisplayDuplicates(installedRuntimes: appState.installedRuntimes) - let architectures = Set((runtimes ?? []).flatMap { $0.architectures ?? [] }) + let architectures = Set(runtimes.flatMap { $0.architectures ?? [] }) VStack { HStack { @@ -39,7 +39,7 @@ struct PlatformsView: View { Spacer() Picker("Architecture", selection: $selectedVariant) { ForEach(ArchitectureVariant.allCases, id: \.self) { arch in - Label(arch.displayString, systemImage: arch.iconName) + Label(variantLabel(for: arch), systemImage: arch.iconName) .tag(arch) } .labelStyle(.trailingIcon) @@ -52,7 +52,7 @@ struct PlatformsView: View { } } - ForEach(runtimes ?? [], id: \.identifier) { runtime in + ForEach(runtimes, id: \.identifier) { runtime in runtimeView(runtime: runtime) .frame(minWidth: 200) .padding() @@ -64,6 +64,10 @@ struct PlatformsView: View { } + + private func variantLabel(for variant: ArchitectureVariant) -> String { + variant == .defaultForMachine() ? "\(variant.displayString) (\(localizeString("This Mac")))" : variant.displayString + } @ViewBuilder func runtimeView(runtime: DownloadableRuntime) -> some View { @@ -121,8 +125,68 @@ struct PlatformsView: View { } } -#Preview(XcodePreviewName.allCases[0].rawValue) { makePreviewContent(for: 0) } +private struct RuntimeDisplayKey: Hashable { + let platform: DownloadableRuntime.Platform + let version: String + let architectures: [String] + + init(_ runtime: DownloadableRuntime) { + platform = runtime.platform + version = runtime.completeVersion + architectures = (runtime.architectures ?? []).map(\.rawValue).sorted() + } +} + +private extension DownloadableRuntime { + var isReleaseCandidate: Bool { + name.localizedCaseInsensitiveContains("Release Candidate") || + identifier.localizedCaseInsensitiveContains("_rc") + } + + func shouldReplace(_ other: DownloadableRuntime, installedRuntimes: [CoreSimulatorImage]) -> Bool { + let isInstalled = RuntimeInstallationLookupService() + .coreSimulatorImage(for: self, in: installedRuntimes) != nil + let otherIsInstalled = RuntimeInstallationLookupService() + .coreSimulatorImage(for: other, in: installedRuntimes) != nil + + if isInstalled != otherIsInstalled { + return isInstalled + } + + if isReleaseCandidate != other.isReleaseCandidate { + return !isReleaseCandidate + } + + return simulatorVersion.buildUpdate.localizedStandardCompare(other.simulatorVersion.buildUpdate) == .orderedDescending + } +} + +private extension Array where Element == DownloadableRuntime { + func removingReleaseCandidateDisplayDuplicates(installedRuntimes: [CoreSimulatorImage]) -> [DownloadableRuntime] { + var runtimesByKey: [RuntimeDisplayKey: DownloadableRuntime] = [:] + var keys: [RuntimeDisplayKey] = [] + + for runtime in self { + let key = RuntimeDisplayKey(runtime) + + guard let existingRuntime = runtimesByKey[key] else { + runtimesByKey[key] = runtime + keys.append(key) + continue + } + + if runtime.shouldReplace(existingRuntime, installedRuntimes: installedRuntimes) { + runtimesByKey[key] = runtime + } + } + + return keys.compactMap { runtimesByKey[$0] } + } +} + +#Preview(XcodePreviewName.allCases[0].rawValue) { @MainActor in makePreviewContent(for: 0) } +@MainActor private func makePreviewContent(for index: Int) -> some View { let name = XcodePreviewName.allCases[index] let runtimes = downloadableRuntimes diff --git a/Xcodes/Frontend/MainWindow.swift b/Xcodes/Frontend/MainWindow.swift index 3808d63e..0de18b5c 100644 --- a/Xcodes/Frontend/MainWindow.swift +++ b/Xcodes/Frontend/MainWindow.swift @@ -1,6 +1,6 @@ -import ErrorHandling import SwiftUI import XcodesKit +import XcodesLoginKit import Path import Version @@ -15,7 +15,7 @@ struct MainWindow: View { // FB8979533 SceneStorage doesn't restore value after app is quit by user @AppStorage("isShowingInfoPane") private var isShowingInfoPane = false @AppStorage("xcodeListCategory") private var category: XcodeListCategory = .all - @AppStorage("xcodeListArchitecture") private var architecture: XcodeListArchitecture = .universal + @AppStorage("xcodeListArchitecture") private var architecture: XcodeListArchitecture = .defaultForCurrentMachine @AppStorage("isInstalledOnly") private var isInstalledOnly = false var body: some View { @@ -119,7 +119,7 @@ struct MainWindow: View { @ViewBuilder private func signInView() -> some View { - if appState.authenticationState == .authenticated { + if case .authenticated = appState.authenticationState { VStack { SignedInView() .padding(32) @@ -130,6 +130,10 @@ struct MainWindow: View { } } .padding() + } else if case let .waitingForFederatedAuthentication(federationResponse) = appState.authenticationState { + SignInFederatedView(federationResponse: federationResponse) + .environmentObject(appState) + .frame(width: 400) } else { SignInCredentialsView() .frame(width: 400) @@ -155,30 +159,10 @@ struct MainWindow: View { title: Text("Alert.PrivilegedHelper.Title"), message: Text("Alert.PrivilegedHelper.Message"), primaryButton: .default(Text("Install"), action: { - // The isPreparingUserForActionRequiringHelper closure is set to nil by the alert's binding when its dismissed. - // We need to capture it to be invoked after that happens. - let helperAction = appState.isPreparingUserForActionRequiringHelper - DispatchQueue.main.async { - // This really shouldn't be nil, but sometimes this alert is being shown twice and I don't know why. - // There are some DispatchQueue.main.async's scattered around which make this better but in some situations it's still happening. - // When that happens, the second time the user clicks an alert button isPreparingUserForActionRequiringHelper will be nil. - // To at least not crash, we're using ? - helperAction?(true) - appState.presentedAlert = nil - } + appState.respondToPreparedHelperAction(userConsented: true) }), secondaryButton: .cancel { - // The isPreparingUserForActionRequiringHelper closure is set to nil by the alert's binding when its dismissed. - // We need to capture it to be invoked after that happens. - let helperAction = appState.isPreparingUserForActionRequiringHelper - DispatchQueue.main.async { - // This really shouldn't be nil, but sometimes this alert is being shown twice and I don't know why. - // There are some DispatchQueue.main.async's scattered around which make this better but in some situations it's still happening. - // When that happens, the second time the user clicks an alert button isPreparingUserForActionRequiringHelper will be nil. - // To at least not crash, we're using ? - helperAction?(false) - appState.presentedAlert = nil - } + appState.respondToPreparedHelperAction(userConsented: false) } ) case let .generic(title, message): @@ -235,6 +219,7 @@ struct MainWindow: View { } struct MainWindow_Previews: PreviewProvider { + @MainActor static var previews: some View { MainWindow().environmentObject({ () -> AppState in let a = AppState() diff --git a/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift b/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift index 13ab718e..c938bb3e 100644 --- a/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift @@ -1,6 +1,6 @@ -import AppleAPI import SwiftUI import Path +import XcodesKit struct AdvancedPreferencePane: View { @EnvironmentObject var appState: AppState @@ -115,7 +115,7 @@ struct AdvancedPreferencePane: View { } .groupBoxStyle(PreferencesGroupBoxStyle()) - if Hardware.isAppleSilicon() { + if HostHardware.isAppleSilicon() { GroupBox(label: Text("Apple Silicon")) { Toggle("ShowOpenInRosetta", isOn: $appState.showOpenInRosettaOption) .disabled(appState.createSymLinkOnSelectDisabled) @@ -159,6 +159,7 @@ struct AdvancedPreferencePane: View { } struct AdvancedPreferencePane_Previews: PreviewProvider { + @MainActor static var previews: some View { Group { AdvancedPreferencePane() diff --git a/Xcodes/Frontend/Preferences/DownloadPreferencePane.swift b/Xcodes/Frontend/Preferences/DownloadPreferencePane.swift index 854c6159..db082ff2 100644 --- a/Xcodes/Frontend/Preferences/DownloadPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/DownloadPreferencePane.swift @@ -1,4 +1,3 @@ -import AppleAPI import SwiftUI struct DownloadPreferencePane: View { @@ -53,6 +52,7 @@ struct DownloadPreferencePane: View { } struct DownloadPreferencePane_Previews: PreviewProvider { + @MainActor static var previews: some View { Group { DownloadPreferencePane() diff --git a/Xcodes/Frontend/Preferences/ExperiementsPreferencePane.swift b/Xcodes/Frontend/Preferences/ExperiementsPreferencePane.swift index 68dad51e..db137e6c 100644 --- a/Xcodes/Frontend/Preferences/ExperiementsPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/ExperiementsPreferencePane.swift @@ -1,4 +1,3 @@ -import AppleAPI import Path import SwiftUI @@ -27,6 +26,7 @@ struct ExperimentsPreferencePane: View { } struct ExperimentsPreferencePane_Previews: PreviewProvider { + @MainActor static var previews: some View { Group { ExperimentsPreferencePane() diff --git a/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift b/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift index b15f5c6d..2ccfe346 100644 --- a/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift @@ -1,4 +1,3 @@ -import AppleAPI import SwiftUI struct GeneralPreferencePane: View { @@ -7,7 +6,7 @@ struct GeneralPreferencePane: View { var body: some View { VStack(alignment: .leading) { GroupBox(label: Text("AppleID")) { - if appState.authenticationState == .authenticated { + if case .authenticated = appState.authenticationState { SignedInView() } else { Button("SignIn", action: { self.appState.presentedSheet = .signIn }) @@ -24,6 +23,7 @@ struct GeneralPreferencePane: View { GroupBox(label: Text("Misc")) { Toggle("TerminateAfterLastWindowClosed", isOn: $appState.terminateAfterLastWindowClosed) + Toggle("GroupXcodeVersionsInList", isOn: $appState.enableGroupedXcodeList) } .groupBoxStyle(PreferencesGroupBoxStyle()) } @@ -31,6 +31,7 @@ struct GeneralPreferencePane: View { } struct GeneralPreferencePane_Previews: PreviewProvider { + @MainActor static var previews: some View { Group { GeneralPreferencePane() diff --git a/Xcodes/Frontend/Preferences/PlatformsListView.swift b/Xcodes/Frontend/Preferences/PlatformsListView.swift index 855bd7e2..d789e8a5 100644 --- a/Xcodes/Frontend/Preferences/PlatformsListView.swift +++ b/Xcodes/Frontend/Preferences/PlatformsListView.swift @@ -74,7 +74,7 @@ struct PlatformsListView: View { } -#Preview { +#Preview { @MainActor in PlatformsListView() .environmentObject({ () -> AppState in let a = AppState() diff --git a/Xcodes/Frontend/Preferences/UpdatesPreferencePane.swift b/Xcodes/Frontend/Preferences/UpdatesPreferencePane.swift index edad69bd..de9ab1f1 100644 --- a/Xcodes/Frontend/Preferences/UpdatesPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/UpdatesPreferencePane.swift @@ -1,4 +1,3 @@ -import AppleAPI import Sparkle import SwiftUI @@ -74,6 +73,7 @@ struct UpdatesPreferencePane: View { } } +@MainActor class ObservableUpdater: ObservableObject { private let updater: SPUUpdater private let updaterDelegate = UpdaterDelegate() @@ -110,16 +110,22 @@ class ObservableUpdater: ObservableObject { automaticallyChecksForUpdatesObservation = updater.observe( \.automaticallyChecksForUpdates, options: [.initial, .new, .old], - changeHandler: { [unowned self] updater, change in + changeHandler: { [weak self] updater, change in guard change.newValue != change.oldValue else { return } - self.automaticallyChecksForUpdates = updater.automaticallyChecksForUpdates + let automaticallyChecksForUpdates = updater.automaticallyChecksForUpdates + Task { @MainActor [weak self] in + self?.automaticallyChecksForUpdates = automaticallyChecksForUpdates + } } ) lastUpdateCheckDateObservation = updater.observe( \.lastUpdateCheckDate, options: [.initial, .new, .old], - changeHandler: { [unowned self] updater, change in - self.lastUpdateCheckDate = updater.lastUpdateCheckDate + changeHandler: { [weak self] updater, change in + let lastUpdateCheckDate = updater.lastUpdateCheckDate + Task { @MainActor [weak self] in + self?.lastUpdateCheckDate = lastUpdateCheckDate + } } ) includePrereleaseVersions = Current.defaults.bool(forKey: "includePrereleaseVersions") ?? false @@ -149,6 +155,7 @@ extension String { } struct UpdatesPreferencePane_Previews: PreviewProvider { + @MainActor static var previews: some View { Group { UpdatesPreferencePane() diff --git a/Xcodes/Frontend/SignIn/AttributedText.swift b/Xcodes/Frontend/SignIn/AttributedText.swift index e15df805..79a53ee6 100644 --- a/Xcodes/Frontend/SignIn/AttributedText.swift +++ b/Xcodes/Frontend/SignIn/AttributedText.swift @@ -46,7 +46,7 @@ fileprivate struct InnerAttributedStringText: NSViewRepresentable { func updateNSView(_ label: NSTextView, context _: NSViewRepresentableContext) { // This must happen on the next run loop so that we don't update the view hierarchy while already in the middle of an update - DispatchQueue.main.async { + Task { @MainActor in label.textStorage?.setAttributedString(attributedString) // Calculates the height based on the current frame label.layoutManager?.ensureLayout(for: label.textContainer!) diff --git a/Xcodes/Frontend/SignIn/SignIn2FAView.swift b/Xcodes/Frontend/SignIn/SignIn2FAView.swift index a8dfa988..cbc385a7 100644 --- a/Xcodes/Frontend/SignIn/SignIn2FAView.swift +++ b/Xcodes/Frontend/SignIn/SignIn2FAView.swift @@ -1,5 +1,5 @@ import SwiftUI -import AppleAPI +import XcodesLoginKit struct SignIn2FAView: View { @EnvironmentObject var appState: AppState @@ -42,6 +42,7 @@ struct SignIn2FAView: View { } struct SignIn2FAView_Previews: PreviewProvider { + @MainActor static var previews: some View { SignIn2FAView( isPresented: .constant(true), diff --git a/Xcodes/Frontend/SignIn/SignInCredentialsView.swift b/Xcodes/Frontend/SignIn/SignInCredentialsView.swift index b9f527d3..8102e61e 100644 --- a/Xcodes/Frontend/SignIn/SignInCredentialsView.swift +++ b/Xcodes/Frontend/SignIn/SignInCredentialsView.swift @@ -48,12 +48,12 @@ struct SignInCredentialsView: View { .keyboardShortcut(.cancelAction) ProgressButton( isInProgress: appState.isProcessingAuthRequest, - action: { appState.signIn(username: username, password: password) }, + action: { appState.signIn(username: username, password: password.isEmpty ? nil : password) }, label: { Text("Next") } ) - .disabled(username.isEmpty || password.isEmpty) + .disabled(username.isEmpty) .keyboardShortcut(.defaultAction) } .frame(height: 25) @@ -63,6 +63,7 @@ struct SignInCredentialsView: View { } struct SignInCredentialsView_Previews: PreviewProvider { + @MainActor static var previews: some View { SignInCredentialsView() .environmentObject(AppState()) diff --git a/Xcodes/Frontend/SignIn/SignInFederatedView.swift b/Xcodes/Frontend/SignIn/SignInFederatedView.swift new file mode 100644 index 00000000..1c702e2f --- /dev/null +++ b/Xcodes/Frontend/SignIn/SignInFederatedView.swift @@ -0,0 +1,88 @@ +import SwiftUI +import XcodesLoginKit + +struct SignInFederatedView: View { + @EnvironmentObject var appState: AppState + let federationResponse: FederationResponse + @State private var callbackURLString = "" + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("SignInWithApple") + .bold() + .padding(.vertical) + + Text("This Apple ID uses federated authentication via \(organizationName).") + .fixedSize(horizontal: false, vertical: true) + + Button("Open Browser") { + if let idpURL = federationResponse.idpURL { + NSWorkspace.shared.open(idpURL) + } + } + .disabled(federationResponse.idpURL == nil) + + TextField("Paste redirected URL", text: $callbackURLString) + + if appState.authError != nil { + Text(appState.authError?.legibleLocalizedDescription ?? "") + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.red) + } + + HStack { + Spacer() + Button("Cancel") { + appState.authError = nil + appState.presentedSheet = nil + } + .keyboardShortcut(.cancelAction) + + ProgressButton( + isInProgress: appState.isProcessingAuthRequest, + action: { appState.submitFederatedAuthenticationCallback(callbackURLString) }, + label: { + Text("Next") + } + ) + .disabled(callbackURLString.isEmpty) + .keyboardShortcut(.defaultAction) + } + .frame(height: 25) + } + .padding() + } + + private var organizationName: String { + let orgName = federationResponse.federatedAuthIntro?.orgName ?? "your organization" + if let idpName = federationResponse.federatedAuthIntro?.idpName { + return "\(orgName) (\(idpName))" + } + return orgName + } +} + +struct SignInFederatedView_Previews: PreviewProvider { + @MainActor + static var previews: some View { + SignInFederatedView( + federationResponse: FederationResponse( + federated: true, + federatedIdpRequest: FederatedIdpRequest( + idPUrl: "https://login.microsoftonline.com/test-tenant/oauth2/authorize", + requestParams: ["login_hint": "test@example.com"], + httpMethod: "GET" + ), + federatedAuthIntro: FederatedAuthIntro( + orgName: "Test Corp", + idpName: "Microsoft Entra", + idpUrl: nil, + orgType: nil, + accountManagementUrl: nil + ) + ) + ) + .environmentObject(AppState()) + .previewLayout(.sizeThatFits) + } +} diff --git a/Xcodes/Frontend/SignIn/SignInPhoneListView.swift b/Xcodes/Frontend/SignIn/SignInPhoneListView.swift index fc31d97b..f907c6f2 100644 --- a/Xcodes/Frontend/SignIn/SignInPhoneListView.swift +++ b/Xcodes/Frontend/SignIn/SignInPhoneListView.swift @@ -1,4 +1,4 @@ -import AppleAPI +import XcodesLoginKit import SwiftUI struct SignInPhoneListView: View { @@ -48,6 +48,7 @@ struct SignInPhoneListView: View { } struct SignInPhoneListView_Previews: PreviewProvider { + @MainActor static var previews: some View { Group { SignInPhoneListView( diff --git a/Xcodes/Frontend/SignIn/SignInSMSView.swift b/Xcodes/Frontend/SignIn/SignInSMSView.swift index 6d42f692..8a1cd918 100644 --- a/Xcodes/Frontend/SignIn/SignInSMSView.swift +++ b/Xcodes/Frontend/SignIn/SignInSMSView.swift @@ -1,5 +1,5 @@ import SwiftUI -import AppleAPI +import XcodesLoginKit struct SignInSMSView: View { @EnvironmentObject var appState: AppState @@ -41,6 +41,7 @@ struct SignInSMSView: View { } struct SignInSMSView_Previews: PreviewProvider { + @MainActor static var previews: some View { SignInSMSView( isPresented: .constant(true), diff --git a/Xcodes/Frontend/SignIn/SignInSecurityKeyPinView.swift b/Xcodes/Frontend/SignIn/SignInSecurityKeyPinView.swift index d2b16465..6f5b2c2c 100644 --- a/Xcodes/Frontend/SignIn/SignInSecurityKeyPinView.swift +++ b/Xcodes/Frontend/SignIn/SignInSecurityKeyPinView.swift @@ -7,7 +7,7 @@ // import SwiftUI -import AppleAPI +import XcodesLoginKit struct SignInSecurityKeyPinView: View { @EnvironmentObject var appState: AppState @@ -59,7 +59,7 @@ struct SignInSecurityKeyPinView: View { } } -#Preview { +#Preview { @MainActor in SignInSecurityKeyPinView(isPresented: .constant(true), authOptions: AuthOptionsResponse( trustedPhoneNumbers: nil, diff --git a/Xcodes/Frontend/SignIn/SignInSecurityKeyTouchView.swift b/Xcodes/Frontend/SignIn/SignInSecurityKeyTouchView.swift index 362a54fe..1cdd2e64 100644 --- a/Xcodes/Frontend/SignIn/SignInSecurityKeyTouchView.swift +++ b/Xcodes/Frontend/SignIn/SignInSecurityKeyTouchView.swift @@ -7,7 +7,7 @@ // import SwiftUI -import AppleAPI +import XcodesLoginKit struct SignInSecurityKeyTouchView: View { @EnvironmentObject var appState: AppState @@ -48,7 +48,7 @@ struct SignInSecurityKeyTouchView: View { } } -#Preview { +#Preview { @MainActor in SignInSecurityKeyTouchView(isPresented: .constant(true)) .environmentObject(AppState()) } diff --git a/Xcodes/Frontend/XcodeList/BottomStatusBar.swift b/Xcodes/Frontend/XcodeList/BottomStatusBar.swift index 3f6e2e6c..77c298f4 100644 --- a/Xcodes/Frontend/XcodeList/BottomStatusBar.swift +++ b/Xcodes/Frontend/XcodeList/BottomStatusBar.swift @@ -54,6 +54,7 @@ extension View { } struct Previews_BottomStatusBar_Previews: PreviewProvider { + @MainActor static var previews: some View { Group { HStack { diff --git a/Xcodes/Frontend/XcodeList/MainToolbar.swift b/Xcodes/Frontend/XcodeList/MainToolbar.swift index b0ad763e..f2f645f0 100644 --- a/Xcodes/Frontend/XcodeList/MainToolbar.swift +++ b/Xcodes/Frontend/XcodeList/MainToolbar.swift @@ -25,7 +25,7 @@ struct MainToolbarModifier: ViewModifier { Spacer() - let isFiltering = isInstalledOnly || category != .all || architectures != .universal + let isFiltering = isInstalledOnly || category != .all || !architectures.isCurrentMachineDefault Menu("Filter", systemImage: "line.horizontal.3.decrease.circle") { Section { Toggle("Installed Only", systemImage: "arrow.down.app", isOn: $isInstalledOnly) .labelStyle(.titleAndIcon) @@ -47,9 +47,9 @@ struct MainToolbarModifier: ViewModifier { Section { Picker("Architecture", selection: $architectures) { - Label("Universal", systemImage: "cpu.fill") + Label(architecturesLabel(for: .universal), systemImage: "cpu.fill") .tag(XcodeListArchitecture.universal) - Label("Apple Silicon", systemImage: "m4.button.horizontal") + Label(architecturesLabel(for: .appleSilicon), systemImage: "m4.button.horizontal") .foregroundColor(.accentColor) .tag(XcodeListArchitecture.appleSilicon) } @@ -62,6 +62,10 @@ struct MainToolbarModifier: ViewModifier { .symbolVariant(isFiltering ? .fill : .none) } } + + private func architecturesLabel(for architecture: XcodeListArchitecture) -> String { + architecture.menuDescription + } } extension View { diff --git a/Xcodes/Frontend/XcodeList/XcodeListCategory.swift b/Xcodes/Frontend/XcodeList/XcodeListCategory.swift index 1d85da98..f1bf0e4d 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListCategory.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListCategory.swift @@ -17,6 +17,17 @@ enum XcodeListCategory: String, CaseIterable, Identifiable, CustomStringConverti } var isManaged: Bool { PreferenceKey.xcodeListCategory.isManaged() } + + var versionFilter: XcodeListVersionFilter { + switch self { + case .all: + return .all + case .release: + return .release + case .beta: + return .prerelease + } + } } enum XcodeListArchitecture: String, CaseIterable, Identifiable, CustomStringConvertible { @@ -24,6 +35,15 @@ enum XcodeListArchitecture: String, CaseIterable, Identifiable, CustomStringConv case appleSilicon var id: Self { self } + + static var defaultForCurrentMachine: Self { + switch ArchitectureVariant.defaultForMachine() { + case .universal: + return .universal + case .appleSilicon: + return .appleSilicon + } + } var description: String { switch self { @@ -31,6 +51,23 @@ enum XcodeListArchitecture: String, CaseIterable, Identifiable, CustomStringConv case .appleSilicon: return localizeString("Apple Silicon") } } + + var menuDescription: String { + isCurrentMachineDefault ? "\(description) (\(localizeString("This Mac")))" : description + } - var isManaged: Bool { PreferenceKey.xcodeListCategory.isManaged() } + var isCurrentMachineDefault: Bool { + self == Self.defaultForCurrentMachine + } + + var isManaged: Bool { PreferenceKey.xcodeListArchitectures.isManaged() } + + var architectureFilters: [ArchitectureFilter] { + switch self { + case .universal: + return [.variant(.universal)] + case .appleSilicon: + return [.variant(.appleSilicon)] + } + } } diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 3c10e0bc..ce75938e 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -1,6 +1,7 @@ import Path import SwiftUI import Version +import XcodesKit struct XcodeListView: View { @EnvironmentObject var appState: AppState @@ -19,52 +20,43 @@ struct XcodeListView: View { self.architecture = architecture } - var visibleXcodes: [Xcode] { - var xcodes: [Xcode] - switch category { - case .all: - xcodes = appState.allXcodes - case .release: - xcodes = appState.allXcodes.filter { $0.version.isNotPrerelease } - case .beta: - xcodes = appState.allXcodes.filter { $0.version.isPrerelease } - } - - if architecture == .appleSilicon { - xcodes = xcodes.filter { $0.architectures == [.arm64] } - } - - - let latestMajor = xcodes.sorted(\.version) - .filter { $0.version.isNotPrerelease } - .last? - .version - .major - - xcodes = xcodes.filter { - if $0.installState.notInstalled, - let latestMajor = latestMajor, - $0.version.major < (latestMajor - min(latestMajor,allowedMajorVersions)) { - return false - } - - return true - } + private var visibleXcodes: [XcodeListEntry] { + appState.allXcodes + .enumerated() + .map { XcodeListEntry(index: $0.offset, xcode: $0.element) } + .applying(XcodeListFilters( + versionFilter: category.versionFilter, + architectureFilters: architecture.architectureFilters, + allowedMajorVersions: allowedMajorVersions, + searchText: searchText, + installedOnly: isInstalledOnly + ), item: \.listItem) + } - if !searchText.isEmpty { - xcodes = xcodes.filter { $0.description.contains(searchText) } - } - - if isInstalledOnly { - xcodes = xcodes.filter { $0.installState.installed } - } - - return xcodes + private func latestReleaseForSelectedPrerelease(_ xcode: Xcode) -> Xcode? { + appState.allXcodes.latestReleaseForSelectedPrerelease(xcode) } var body: some View { - List(visibleXcodes, selection: $selectedXcodeID) { xcode in - XcodeListViewRow(xcode: xcode, selected: selectedXcodeID == xcode.id, appState: appState) + List(selection: $selectedXcodeID) { + if appState.enableGroupedXcodeList { + GroupedXcodeListContent( + xcodes: visibleXcodes, + allXcodes: appState.allXcodes, + selectedXcodeID: $selectedXcodeID, + appState: appState + ) + } else { + ForEach(visibleXcodes) { entry in + XcodeListViewRow( + xcode: entry.xcode, + selected: selectedXcodeID == entry.xcode.id, + appState: appState, + latestReleaseForSelectedPrerelease: latestReleaseForSelectedPrerelease(entry.xcode) + ) + .tag(entry.xcode.id) + } + } } .listStyle(.sidebar) .safeAreaInset(edge: .bottom, spacing: 0) { @@ -76,6 +68,317 @@ struct XcodeListView: View { } } +private struct XcodeListEntry: Identifiable { + let index: Int + let xcode: Xcode + + var id: Int { + index + } + + var listItem: XcodeListItem { + xcode.listItem + } +} + +private struct GroupedXcodeListContent: View { + let xcodes: [XcodeListEntry] + let allXcodes: [Xcode] + @Binding var selectedXcodeID: Xcode.ID? + let appState: AppState + + @AppStorage(PreferenceKey.expandedMajorXcodeVersions.rawValue) private var expandedMajorVersionStorage = "" + @AppStorage(PreferenceKey.expandedMinorXcodeVersions.rawValue) private var expandedMinorVersionStorage = "" + + private var expandedMajorVersions: Set { + get { + Set(expandedMajorVersionStorage.split(separator: ",").compactMap { Int($0) }) + } + nonmutating set { + expandedMajorVersionStorage = newValue.sorted().map(String.init).joined(separator: ",") + } + } + + private var expandedMinorVersions: Set { + get { + Set(expandedMinorVersionStorage.split(separator: ",").map(String.init)) + } + nonmutating set { + expandedMinorVersionStorage = newValue.sorted().joined(separator: ",") + } + } + + private var majorVersionGroups: [XcodeListElementMajorVersionGroup] { + xcodes.groupedByMajorVersion(item: \.listItem) + } + + private func latestReleaseForSelectedPrerelease(_ xcode: Xcode) -> Xcode? { + allXcodes.latestReleaseForSelectedPrerelease(xcode) + } + + var body: some View { + ForEach(majorVersionGroups) { majorVersionGroup in + let isMajorExpanded = expandedMajorVersions.contains(majorVersionGroup.majorVersion) + let majorVersions = majorVersionGroup.versions.map(\.xcode) + let latestMajorRelease = majorVersions.latestRelease + let latestInstalledMajorVersion = majorVersions.latestInstalledVersion + + XcodeVersionGroupRow( + displayName: majorVersionGroup.displayName, + latestRelease: latestMajorRelease, + latestSelectableRelease: latestMajorRelease, + latestSelectionTarget: latestInstalledMajorVersion, + selectedVersion: majorVersions.first { $0.selected }, + installingVersion: majorVersions.first { $0.installState.installing }, + isExpanded: isMajorExpanded, + indentation: 0, + appState: appState, + onToggleExpanded: { + var updatedExpandedMajorVersions = expandedMajorVersions + var updatedExpandedMinorVersions = expandedMinorVersions + + if isMajorExpanded { + updatedExpandedMajorVersions.remove(majorVersionGroup.majorVersion) + majorVersionGroup.minorVersionGroups.forEach { + updatedExpandedMinorVersions.remove($0.id) + } + } else { + updatedExpandedMajorVersions.insert(majorVersionGroup.majorVersion) + } + + self.expandedMajorVersions = updatedExpandedMajorVersions + self.expandedMinorVersions = updatedExpandedMinorVersions + } + ) + .tag(majorVersions.first { $0.selected }?.id) + + if isMajorExpanded { + ForEach(majorVersionGroup.minorVersionGroups) { minorVersionGroup in + let isMinorExpanded = expandedMinorVersions.contains(minorVersionGroup.id) + let minorVersions = minorVersionGroup.versions.map(\.xcode) + let latestInstalledMinorVersion = minorVersions.latestInstalledVersion + + XcodeVersionGroupRow( + displayName: minorVersionGroup.displayName, + latestRelease: minorVersions.latestRelease, + latestSelectableRelease: latestMajorRelease, + latestSelectionTarget: latestInstalledMinorVersion, + selectedVersion: minorVersions.first { $0.selected }, + installingVersion: minorVersions.first { $0.installState.installing }, + isExpanded: isMinorExpanded, + indentation: 20, + appState: appState, + onToggleExpanded: { + var updatedExpandedMinorVersions = expandedMinorVersions + + if isMinorExpanded { + updatedExpandedMinorVersions.remove(minorVersionGroup.id) + } else { + updatedExpandedMinorVersions.insert(minorVersionGroup.id) + } + + self.expandedMinorVersions = updatedExpandedMinorVersions + } + ) + .tag(minorVersions.first { $0.selected }?.id) + + if isMinorExpanded { + ForEach(minorVersionGroup.versions) { entry in + XcodeListViewRow( + xcode: entry.xcode, + selected: selectedXcodeID == entry.xcode.id, + appState: appState, + latestReleaseForSelectedPrerelease: latestReleaseForSelectedPrerelease(entry.xcode) + ) + .padding(.leading, 40) + .tag(entry.xcode.id) + } + } + } + } + } + } +} + +private struct XcodeVersionGroupRow: View { + let displayName: String + let latestRelease: Xcode? + let latestSelectableRelease: Xcode? + let latestSelectionTarget: Xcode? + let selectedVersion: Xcode? + let installingVersion: Xcode? + let isExpanded: Bool + let indentation: CGFloat + let appState: AppState + let onToggleExpanded: () -> Void + + var body: some View { + HStack { + Button(action: onToggleExpanded) { + HStack(spacing: 8) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundColor(.secondary) + .frame(width: 12, height: 12) + + icon + + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: "Xcode \(displayName)") + .font(.body.weight(indentation == 0 ? .medium : .regular)) + + if let latestRelease { + Text(verbatim: "Latest: \(latestRelease.description)") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + selectControl + .padding(.trailing, 16) + installControl + } + .padding(.leading, indentation) + .padding(.vertical, indentation == 0 ? 8 : 6) + .contentShape(Rectangle()) + } + + @ViewBuilder + private var icon: some View { + if let icon = latestRelease?.icon { + Image(nsImage: icon) + .resizable() + .frame(width: 32, height: 32) + } else { + Image(latestRelease?.version.isPrerelease == true ? "xcode-beta" : "xcode") + .resizable() + .frame(width: 32, height: 32) + .opacity(0.2) + } + } + + @ViewBuilder + private var selectControl: some View { + if let selectedVersion, selectedVersion.selected, let latestSelectableRelease, latestSelectableRelease.id != selectedVersion.id { + switch latestSelectionTarget?.installState { + case .installed: + if let latestSelectionTarget, latestSelectionTarget.id != selectedVersion.id { + Button(action: { appState.select(xcode: latestSelectionTarget) }) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.yellow) + } + .buttonStyle(PlainButtonStyle()) + .help(staleSelectedHelpText(selectedVersion: selectedVersion, latestRelease: latestSelectableRelease, selectionTarget: latestSelectionTarget)) + } else { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.yellow) + .help(staleSelectedHelpText(selectedVersion: selectedVersion, latestRelease: latestSelectableRelease, selectionTarget: latestSelectionTarget)) + } + case .notInstalled: + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.yellow) + .help(staleSelectedHelpText(selectedVersion: selectedVersion, latestRelease: latestSelectableRelease, selectionTarget: latestSelectionTarget)) + case .installing, .none: + EmptyView() + } + } else if selectedVersion?.selected == true { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .help("ActiveVersionDescription") + } else if let latestSelectionTarget { + Button(action: { appState.select(xcode: latestSelectionTarget) }) { + Image(systemName: "checkmark.circle") + .foregroundColor(.secondary) + } + .buttonStyle(PlainButtonStyle()) + .help("MakeActiveVersionDescription") + } + } + + private func staleSelectedHelpText(selectedVersion: Xcode, latestRelease: Xcode, selectionTarget: Xcode?) -> Text { + switch selectionTarget?.installState { + case .installed: + if let selectionTarget, selectionTarget.id != selectedVersion.id { + return Text(verbatim: "\(selectedVersion.description) selected, \(latestRelease.description) available. Click to select \(selectionTarget.description).") + } else { + return Text(verbatim: "\(selectedVersion.description) selected, \(latestRelease.description) available.") + } + case .notInstalled: + if let selectionTarget { + return Text(verbatim: "\(selectedVersion.description) selected, \(latestRelease.description) available. Install \(selectionTarget.description) to select it.") + } else { + return Text(verbatim: "\(selectedVersion.description) selected, \(latestRelease.description) available.") + } + case .installing, .none: + return Text(verbatim: "\(selectedVersion.description) selected, \(latestRelease.description) available.") + } + } + + @ViewBuilder + private var installControl: some View { + if let installingVersion, + case let .installing(installationStep) = installingVersion.installState { + InstallationStepRowView( + installationStep: installationStep, + highlighted: false, + cancel: { appState.presentedAlert = .cancelInstall(xcode: installingVersion) } + ) + } else if let latestRelease { + switch latestRelease.installState { + case .installed: + Button("Open") { appState.open(xcode: latestRelease) } + .textCase(.uppercase) + .buttonStyle(AppStoreButtonStyle(primary: true, highlighted: false)) + .help("OpenDescription") + case .notInstalled: + Button("Install") { + appState.checkMinVersionAndInstall(id: latestRelease.id) + } + .textCase(.uppercase) + .buttonStyle(AppStoreButtonStyle(primary: false, highlighted: false)) + .help("InstallDescription") + case .installing: + EmptyView() + } + } + } +} + +private extension Array where Element == Xcode { + var latestRelease: Xcode? { + filter { $0.version.isNotPrerelease } + .sorted { $0.version < $1.version } + .last + } + + var latestInstalledVersion: Xcode? { + filter(\.installState.installed) + .sorted { $0.version < $1.version } + .last + } + + func latestReleaseForSelectedPrerelease(_ xcode: Xcode) -> Xcode? { + guard xcode.selected, xcode.version.isPrerelease else { return nil } + + return first { candidate in + candidate.id != xcode.id && + candidate.architectures == xcode.architectures && + candidate.version.major == xcode.version.major && + candidate.version.minor == xcode.version.minor && + candidate.version.patch == xcode.version.patch && + candidate.version.isNotPrerelease && + candidate.installState.installing == false + } + } +} + struct PlatformsPocket: View { @SwiftUI.Environment(\.openWindow) private var openWindow @@ -107,10 +410,12 @@ struct PlatformsPocket: View { .font(.body.weight(.medium)) .padding(.horizontal) .padding(.vertical, 12) + .contentShape(Rectangle()) } } struct XcodeListView_Previews: PreviewProvider { + @MainActor static var previews: some View { Group { XcodeListView(selectedXcodeID: .constant(nil), searchText: "", category: .all, isInstalledOnly: false, architecture: .appleSilicon) diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index cc7f311a..166b9d09 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -1,11 +1,20 @@ import Path import SwiftUI import Version +import XcodesKit struct XcodeListViewRow: View { let xcode: Xcode let selected: Bool let appState: AppState + let latestReleaseForSelectedPrerelease: Xcode? + + init(xcode: Xcode, selected: Bool, appState: AppState, latestReleaseForSelectedPrerelease: Xcode? = nil) { + self.xcode = xcode + self.selected = selected + self.appState = appState + self.latestReleaseForSelectedPrerelease = latestReleaseForSelectedPrerelease + } var body: some View { HStack { @@ -16,12 +25,12 @@ struct XcodeListViewRow: View { Text(verbatim: "\(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)") .font(.body) - if !xcode.identicalBuilds.isEmpty { + if !xcode.identicalBuildsForCurrentVariant.isEmpty { Image(systemName: "square.fill.on.square.fill") .font(.subheadline) .foregroundColor(.secondary) .accessibility(label: Text("IdenticalBuilds")) - .accessibility(value: Text(xcode.identicalBuilds.map(\.version.appleDescription).joined(separator: ", "))) + .accessibility(value: Text(xcode.identicalBuildsForCurrentVariant.map(\.version.appleDescription).joined(separator: ", "))) .help("IdenticalBuilds.help") } @@ -69,7 +78,11 @@ struct XcodeListViewRow: View { #if DEBUG Divider() Button("Perform post-install steps") { - appState.performPostInstallSteps(for: InstalledXcode(path: path)!) as Void + appState.performPostInstallSteps(for: InstalledXcode( + path: path, + contentsAtPath: { path in Current.files.contents(atPath: path) }, + loadArchitectures: Current.shell.archs + )!) as Void } #endif } @@ -80,6 +93,8 @@ struct XcodeListViewRow: View { func appIconView(for xcode: Xcode) -> some View { if let icon = xcode.icon { Image(nsImage: icon) + .resizable() + .frame(width: 32, height: 32) } else { Image(xcode.version.isPrerelease ? "xcode-beta" : "xcode") .resizable() @@ -91,7 +106,23 @@ struct XcodeListViewRow: View { @ViewBuilder private func selectControl(for xcode: Xcode) -> some View { if xcode.installState.installed { - if xcode.selected { + if let latestReleaseForSelectedPrerelease, xcode.selected { + switch latestReleaseForSelectedPrerelease.installState { + case .installed: + Button(action: { appState.select(xcode: latestReleaseForSelectedPrerelease) }) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.yellow) + } + .buttonStyle(PlainButtonStyle()) + .help(staleSelectedHelpText) + case .notInstalled: + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.yellow) + .help(staleSelectedHelpText) + case .installing: + EmptyView() + } + } else if xcode.selected { Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) .help("ActiveVersionDescription") @@ -110,6 +141,19 @@ struct XcodeListViewRow: View { @ViewBuilder private func installControl(for xcode: Xcode) -> some View { + if let latestReleaseForSelectedPrerelease, + xcode.selected, + latestReleaseForSelectedPrerelease.installState == .notInstalled { + InstallButton(xcode: latestReleaseForSelectedPrerelease) + .textCase(.uppercase) + .buttonStyle(AppStoreButtonStyle(primary: false, highlighted: false)) + } else { + installStateControl(for: xcode) + } + } + + @ViewBuilder + private func installStateControl(for xcode: Xcode) -> some View { switch xcode.installState { case .installed: Button("Open") { appState.open(xcode: xcode) } @@ -128,6 +172,20 @@ struct XcodeListViewRow: View { ) } } + + private var staleSelectedHelpText: Text { + let selectedVersion = xcode.version.appleDescription + let latestVersion = latestReleaseForSelectedPrerelease?.version.appleDescription ?? "" + + switch latestReleaseForSelectedPrerelease?.installState { + case .installed: + return Text(verbatim: "\(selectedVersion) selected, \(latestVersion) available. Click to select \(latestVersion).") + case .notInstalled: + return Text(verbatim: "\(selectedVersion) selected, \(latestVersion) available. Install \(latestVersion) to select it.") + case .installing, .none: + return Text("ActiveVersionDescription") + } + } } struct XcodeListViewRow_Previews: PreviewProvider { diff --git a/Xcodes/Resources/Licenses.rtf b/Xcodes/Resources/Licenses.rtf index cef9fff5..1687a178 100644 --- a/Xcodes/Resources/Licenses.rtf +++ b/Xcodes/Resources/Licenses.rtf @@ -1,4 +1,4 @@ -{\rtf1\ansi\ansicpg1252\cocoartf2865 +{\rtf1\ansi\ansicpg1252\cocoartf2869 \cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 .SFNS-Regular;} {\colortbl;\red255\green255\blue255;} {\*\expandedcolortbl;;} @@ -85,13 +85,39 @@ SOFTWARE.\ \ \ -\fs34 ErrorHandling\ +\fs34 Yams\ +\ + +\fs26 The MIT License (MIT)\ +\ +Copyright (c) 2016 JP Simard.\ +\ +Permission is hereby granted, free of charge, to any person obtaining a copy\ +of this software and associated documentation files (the "Software"), to deal\ +in the Software without restriction, including without limitation the rights\ +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ +copies of the Software, and to permit persons to whom the Software is\ +furnished to do so, subject to the following conditions:\ +\ +The above copyright notice and this permission notice shall be included in all\ +copies or substantial portions of the Software.\ +\ +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\ +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\ +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\ +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\ +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\ +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\ +SOFTWARE.\ +\ +\ + +\fs34 XcodesKit\ \ \fs26 MIT License\ \ -Copyright (c) 2020 Robots and Pencils\ -Copyright (c) 2020 John Sundell\ +Copyright (c) 2025 XcodesOrg\ \ Permission is hereby granted, free of charge, to any person obtaining a copy\ of this software and associated documentation files (the "Software"), to deal\ @@ -1264,32 +1290,6 @@ For more information, please refer to <>\ \ \ -\fs34 CombineExpectations\ -\ - -\fs26 Copyright (C) 2019 Gwendal Rou\'e9\ -\ -Permission is hereby granted, free of charge, to any person obtaining a\ -copy of this software and associated documentation files (the\ -"Software"), to deal in the Software without restriction, including\ -without limitation the rights to use, copy, modify, merge, publish,\ -distribute, sublicense, and/or sell copies of the Software, and to\ -permit persons to whom the Software is furnished to do so, subject to\ -the following conditions:\ -\ -The above copyright notice and this permission notice shall be included\ -in all copies or substantial portions of the Software.\ -\ -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS\ -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\ -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\ -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\ -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,\ -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE\ -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\ -\ -\ - \fs34 aria2c\ \ diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 2d503460..4cf914c3 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -4669,6 +4669,9 @@ } } } + }, + "An error occurred" : { + }, "Apple Silicon" : { "localizations" : { @@ -4783,7 +4786,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "" + "value" : "Apple芯片" } }, "zh-Hant" : { @@ -5055,7 +5058,14 @@ } }, "Architecture" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "架构" + } + } + } }, "Authenticating" : { "extractionState" : "manual", @@ -5964,7 +5974,14 @@ } }, "Category" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "类别" + } + } + } }, "Change" : { "localizations" : { @@ -7889,6 +7906,9 @@ } } } + }, + "Dismiss" : { + }, "Downloader" : { "localizations" : { @@ -10345,6 +10365,136 @@ } } }, + "GroupXcodeVersionsInList" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + } + } + }, "HelperClient.error" : { "extractionState" : "manual", "localizations" : { @@ -11371,7 +11521,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Install Apple Silicon" + "value" : "安装Apple芯片版本" } }, "zh-Hant" : { @@ -11495,7 +11645,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Install Universal" + "value" : "安装通用版本" } }, "zh-Hant" : { @@ -14127,7 +14277,14 @@ } }, "Installed Only" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仅已安装" + } + } + } }, "InstallHelper" : { "localizations" : { @@ -18660,6 +18817,9 @@ } } } + }, + "Open Browser" : { + }, "Open In Rosetta" : { "localizations" : { @@ -19044,8 +19204,12 @@ } } } + }, + "Paste redirected URL" : { + }, "Perform post-install steps" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -22659,6 +22823,9 @@ } } } + }, + "Signing out..." : { + }, "SignInWithApple" : { "comment" : "SignIn", @@ -23311,6 +23478,9 @@ } } } + }, + "This Apple ID uses federated authentication via %@." : { + }, "TrashingArchive" : { "extractionState" : "manual", @@ -23699,6 +23869,7 @@ } }, "Universal" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -23811,7 +23982,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Universal" + "value" : "通用" } }, "zh-Hant" : { diff --git a/Xcodes/XcodesApp.swift b/Xcodes/XcodesApp.swift index a7835dd6..0d46447a 100644 --- a/Xcodes/XcodesApp.swift +++ b/Xcodes/XcodesApp.swift @@ -1,6 +1,7 @@ import AppKit import Sparkle import SwiftUI +import XcodesKit @main struct XcodesApp: App { @@ -96,20 +97,7 @@ struct XcodesApp: App { primaryButton: .destructive( Text("Alert.DeletePlatform.PrimaryButton"), action: { - Task { - do { - try await self.appState.deleteRuntime(runtime: runtime) - } catch { - var errorString: String - if let error = error as? String { - errorString = error - } else { - errorString = error.localizedDescription - } - self.appState.presentedPreferenceAlert = .generic(title: "Error", message: errorString) - } - - } + self.appState.confirmDeleteRuntime(runtime: runtime) } ), secondaryButton: .cancel(Text("Cancel")) @@ -127,6 +115,7 @@ struct XcodesApp: App { } } +@MainActor class AppDelegate: NSObject, NSApplicationDelegate { private lazy var aboutWindow = configure(NSWindow( contentRect: .zero, diff --git a/Xcodes/XcodesKit/.gitignore b/Xcodes/XcodesKit/.gitignore deleted file mode 100644 index 3b298120..00000000 --- a/Xcodes/XcodesKit/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -.DS_Store -/.build -/Packages -/*.xcodeproj -xcuserdata/ -DerivedData/ -.swiftpm/config/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/Xcodes/XcodesKit/Package.swift b/Xcodes/XcodesKit/Package.swift deleted file mode 100644 index abb461f2..00000000 --- a/Xcodes/XcodesKit/Package.swift +++ /dev/null @@ -1,33 +0,0 @@ -// swift-tools-version: 5.7 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "XcodesKit", - platforms: [.macOS(.v13)], - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "XcodesKit", - targets: ["XcodesKit"]), - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/RobotsAndPencils/AsyncHTTPNetworkService", branch: "main"), - .package(url: "https://github.com/mxcl/Path.swift", from: "1.0.0"), - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "XcodesKit", - dependencies: [ - .product(name: "AsyncNetworkService", package: "AsyncHTTPNetworkService"), - .product(name: "Path", package: "Path.swift") - ]), - .testTarget( - name: "XcodesKitTests", - dependencies: ["XcodesKit"]), - ] -) diff --git a/Xcodes/XcodesKit/README.md b/Xcodes/XcodesKit/README.md deleted file mode 100644 index 5312c492..00000000 --- a/Xcodes/XcodesKit/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# XcodesKit - -A description of this package. diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Foundation.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Foundation.swift deleted file mode 100644 index 02b1e022..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Foundation.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation - -extension NSRegularExpression { - func firstString(in string: String, options: NSRegularExpression.MatchingOptions = []) -> String? { - let range = NSRange(location: 0, length: string.utf16.count) - guard let firstMatch = firstMatch(in: string, options: options, range: range), - let resultRange = Range(firstMatch.range, in: string) else { - return nil - } - return String(string[resultRange]) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Logger.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Logger.swift deleted file mode 100644 index 222e9080..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Logger.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation -import os.log - -extension Logger { - private static var subsystem = Bundle.main.bundleIdentifier! - - static public let appState = Logger(subsystem: subsystem, category: "appState") - static public let helperClient = Logger(subsystem: subsystem, category: "helperClient") - static public let subprocess = Logger(subsystem: subsystem, category: "subprocess") -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/CoreSimulatorImage.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/CoreSimulatorImage.swift deleted file mode 100644 index 3ea12dbf..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/CoreSimulatorImage.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// CoreSimulatorImage.swift -// -// -// Created by Matt Kiazyk on 2023-01-08. -// - -import Foundation - -public struct CoreSimulatorPlist: Decodable { - public let images: [CoreSimulatorImage] - - public init(images: [CoreSimulatorImage]) { - self.images = images - } -} - -public struct CoreSimulatorImage: Decodable, Identifiable, Equatable { - public var id: String { - return uuid - } - - public let uuid: String - public let path: [String: String] - public let runtimeInfo: CoreSimulatorRuntimeInfo - - public init(uuid: String, path: [String : String], runtimeInfo: CoreSimulatorRuntimeInfo) { - self.uuid = uuid - self.path = path - self.runtimeInfo = runtimeInfo - } - - public static func == (lhs: CoreSimulatorImage, rhs: CoreSimulatorImage) -> Bool { - lhs.id == rhs.id - } -} - -public struct CoreSimulatorRuntimeInfo: Decodable { - public let build: String - public let supportedArchitectures: [Architecture]? - - public init(build: String, supportedArchitectures: [Architecture]? = nil) { - self.build = build - self.supportedArchitectures = supportedArchitectures - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallState.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallState.swift deleted file mode 100644 index f50e4a22..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallState.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// RuntimeInstallState.swift -// -// -// Created by Matt Kiazyk on 2023-11-23. -// - -import Foundation -import Path - -public enum RuntimeInstallState: Equatable, Hashable { - case notInstalled - case installing(RuntimeInstallationStep) - case installed - - var notInstalled: Bool { - switch self { - case .notInstalled: return true - default: return false - } - } - var installing: Bool { - switch self { - case .installing: return true - default: return false - } - } - var installed: Bool { - switch self { - case .installed: return true - default: return false - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallationStep.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallationStep.swift deleted file mode 100644 index 231971f7..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallationStep.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// RuntimeInstallationStep.swift -// -// -// Created by Matt Kiazyk on 2023-11-23. -// - -import Foundation - -public enum RuntimeInstallationStep: Equatable, CustomStringConvertible, Hashable { - case downloading(progress: Progress) - case installing - case trashingArchive - - public var description: String { - "(\(stepNumber)/\(stepCount)) \(message)" - } - - public var message: String { - switch self { - case .downloading: - return localizeString("Downloading") - case .installing: - return localizeString("Installing") - case .trashingArchive: - return localizeString("TrashingArchive") - } - } - - public var stepNumber: Int { - switch self { - case .downloading: return 1 - case .installing: return 2 - case .trashingArchive: return 3 - } - } - - public var stepCount: Int { 3 } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift deleted file mode 100644 index 581b6c03..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift +++ /dev/null @@ -1,199 +0,0 @@ -import Foundation - -public struct DownloadableRuntimesResponse: Codable { - public let sdkToSimulatorMappings: [SDKToSimulatorMapping] - public let sdkToSeedMappings: [SDKToSeedMapping] - public let refreshInterval: Int - public let downloadables: [DownloadableRuntime] - public let version: String -} - -public struct DownloadableRuntime: Codable, Identifiable, Hashable { - public let category: Category - public let simulatorVersion: SimulatorVersion - public let source: String? - public let architectures: [Architecture]? - public let dictionaryVersion: Int - public let contentType: ContentType - public let platform: Platform - public let identifier: String - public let version: String - public let fileSize: Int - public let hostRequirements: HostRequirements? - public let name: String - public let authentication: Authentication? - public var url: URL? { - if let source { - return URL(string: source)! - } - return nil - } - public var downloadPath: String? { - url?.path - } - - // dynamically updated - not decoded - public var installState: RuntimeInstallState = .notInstalled - public var sdkBuildUpdate: [String]? - - enum CodingKeys: CodingKey { - case category - case simulatorVersion - case source - case dictionaryVersion - case contentType - case platform - case identifier - case version - case fileSize - case hostRequirements - case name - case authentication - case sdkBuildUpdate - case architectures - } - - var betaNumber: Int? { - enum Regex { static let shared = try! NSRegularExpression(pattern: "b[0-9]+") } - guard var foundString = Regex.shared.firstString(in: identifier) else { return nil } - foundString.removeFirst() - return Int(foundString)! - } - - var completeVersion: String { - makeVersion(for: simulatorVersion.version, betaNumber: betaNumber) - } - - public var visibleIdentifier: String { - return platform.shortName + " " + completeVersion - } - - func makeVersion(for osVersion: String, betaNumber: Int?) -> String { - let betaSuffix = betaNumber.flatMap { "-beta\($0)" } ?? "" - return osVersion + betaSuffix - } - - public var downloadFileSizeString: String { - return ByteCountFormatter.string(fromByteCount: Int64(fileSize), countStyle: .file) - } - - public var id: String { - return visibleIdentifier - } - - public static func == (lhs: DownloadableRuntime, rhs: DownloadableRuntime) -> Bool { - return lhs.identifier == rhs.identifier - } -} - -public struct SDKToSeedMapping: Codable { - public let buildUpdate: String - public let platform: DownloadableRuntime.Platform - public let seedNumber: Int -} - -public struct SDKToSimulatorMapping: Codable { - public let sdkBuildUpdate: String - public let simulatorBuildUpdate: String - public let sdkIdentifier: String - public let downloadableIdentifiers: [String]? -} - -extension DownloadableRuntime { - public struct SimulatorVersion: Codable, Hashable { - public let buildUpdate: String - public let version: String - } - - public struct HostRequirements: Codable, Hashable { - let maxHostVersion: String? - let excludedHostArchitectures: [String]? - let minHostVersion: String? - let minXcodeVersion: String? - } - - public enum Authentication: String, Codable { - case virtual = "virtual" - } - - public enum Category: String, Codable { - case simulator = "simulator" - } - - public enum ContentType: String, Codable { - case diskImage = "diskImage" - case package = "package" - case cryptexDiskImage = "cryptexDiskImage" - } - - public enum Platform: String, Codable { - case iOS = "com.apple.platform.iphoneos" - case macOS = "com.apple.platform.macosx" - case watchOS = "com.apple.platform.watchos" - case tvOS = "com.apple.platform.appletvos" - case visionOS = "com.apple.platform.xros" - - public var order: Int { - switch self { - case .iOS: return 1 - case .macOS: return 2 - case .watchOS: return 3 - case .tvOS: return 4 - case .visionOS: return 5 - } - } - - public var shortName: String { - switch self { - case .iOS: return "iOS" - case .macOS: return "macOS" - case .watchOS: return "watchOS" - case .tvOS: return "tvOS" - case .visionOS: return "visionOS" - } - } - - } -} - -public struct InstalledRuntime: Decodable { - let build: String - let deletable: Bool - let identifier: UUID - let kind: Kind - let lastUsedAt: Date? - let path: String - let platformIdentifier: Platform - let runtimeBundlePath: String - let runtimeIdentifier: String - let signatureState: String - let state: String - let version: String - let sizeBytes: Int? - let supportedArchitectures: [Architecture]? -} - -extension InstalledRuntime { - enum Kind: String, Decodable { - case diskImage = "Disk Image" - case bundled = "Bundled with Xcode" - case legacyDownload = "Legacy Download" - } - - enum Platform: String, Decodable { - case tvOS = "com.apple.platform.appletvsimulator" - case iOS = "com.apple.platform.iphonesimulator" - case watchOS = "com.apple.platform.watchsimulator" - case visionOS = "com.apple.platform.xrsimulator" - - var asPlatformOS: DownloadableRuntime.Platform { - switch self { - case .watchOS: return .watchOS - case .iOS: return .iOS - case .tvOS: return .tvOS - case .visionOS: return .visionOS - } - } - } -} - diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallState.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallState.swift deleted file mode 100644 index 0f824a5a..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallState.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// InstallState.swift -// -// -// Created by Matt Kiazyk on 2023-06-06. -// - -import Foundation -import Path - -public enum XcodeInstallState: Equatable { - case notInstalled - case installing(XcodeInstallationStep) - case installed(Path) - - var notInstalled: Bool { - switch self { - case .notInstalled: return true - default: return false - } - } - var installing: Bool { - switch self { - case .installing: return true - default: return false - } - } - var installed: Bool { - switch self { - case .installed: return true - default: return false - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallationStep.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallationStep.swift deleted file mode 100644 index 9a4349e1..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallationStep.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// InstallationStep.swift -// -// -// Created by Matt Kiazyk on 2023-06-06. -// - -import Foundation - -// A numbered step -public enum XcodeInstallationStep: Equatable, CustomStringConvertible { - case authenticating - case downloading(progress: Progress) - case unarchiving - case moving(destination: String) - case trashingArchive - case checkingSecurity - case finishing - - public var description: String { - "(\(stepNumber)/\(stepCount)) \(message)" - } - - public var message: String { - switch self { - case .authenticating: - return localizeString("Authenticating") - case .downloading: - return localizeString("Downloading") - case .unarchiving: - return localizeString("Unarchiving") - case .moving(let destination): - return String(format: localizeString("Moving"), destination) - case .trashingArchive: - return localizeString("TrashingArchive") - case .checkingSecurity: - return localizeString("CheckingSecurity") - case .finishing: - return localizeString("Finishing") - } - } - - public var stepNumber: Int { - switch self { - case .authenticating: return 1 - case .downloading: return 2 - case .unarchiving: return 3 - case .moving: return 4 - case .trashingArchive: return 5 - case .checkingSecurity: return 6 - case .finishing: return 7 - } - } - - public var stepCount: Int { 7 } -} - -func localizeString(_ key: String, comment: String = "") -> String { - if #available(macOS 12, *) { - return String(localized: String.LocalizationValue(key)) - } else { - return NSLocalizedString(key, comment: comment) - } - -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift deleted file mode 100644 index 607d7f8c..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// Architecture.swift -// XcodesKit -// -// Created by Matt Kiazyk on 2025-08-23. -// - -import Foundation - -/// The name of an Architecture. -public enum Architecture: String, Codable, Equatable, Hashable, Identifiable, CaseIterable { - public var id: Self { self } - - /// The Arm64 architecture (Apple Silicon) - case arm64 = "arm64" - /// The X86\_64 architecture (64-bit Intel) - case x86_64 = "x86_64" - - public var displayString: String { - switch self { - case .arm64: - return "Apple Silicon" - case .x86_64: - return "Intel" - } - } - - public var iconName: String { - switch self { - case .arm64: - return "m4.button.horizontal" - case .x86_64: - return "cpu.fill" - } - } -} - -public enum ArchitectureVariant: String, Codable, Equatable, Hashable, Identifiable, CaseIterable { - public var id: Self { self } - - case universal - case appleSilicon - - public var displayString: String { - switch self { - case .appleSilicon: - return "Apple Silicon" - case .universal: - return "Universal" - } - } - - public var iconName: String { - switch self { - case .appleSilicon: - return "m4.button.horizontal" - case .universal: - return "cpu.fill" - } - } -} - -extension Array where Element == Architecture { - public var isAppleSilicon: Bool { - self == [.arm64] - } - - public var isUniversal: Bool { - self.contains([.arm64, .x86_64]) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Checksums.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Checksums.swift deleted file mode 100644 index b944c698..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Checksums.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// Checksums.swift -// xcodereleases -// -// Created by Xcode Releases on 9/17/20. -// Copyright © 2020 Xcode Releases. All rights reserved. -// - - -import Foundation - -public struct Checksums: Codable { - - public let sha1: String? - - public init(sha1: String? = nil) { - self.sha1 = sha1 - } - -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Compilers.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Compilers.swift deleted file mode 100644 index 3f012f04..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Compilers.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// Compiler.swift -// xcodereleases -// -// Created by Xcode Releases on 4/4/18. -// Copyright © 2018 Xcode Releases. All rights reserved. -// - -import Foundation - -public struct Compilers: Codable { - public let gcc: Array? - public let llvm_gcc: Array? - public let llvm: Array? - public let clang: Array? - public let swift: Array? - - public init(gcc: XcodeVersion? = nil, llvm_gcc: XcodeVersion? = nil, llvm: XcodeVersion? = nil, clang: XcodeVersion? = nil, swift: XcodeVersion? = nil) { - self.gcc = gcc.map { [$0] } - self.llvm_gcc = llvm_gcc.map { [$0] } - self.llvm = llvm.map { [$0] } - self.clang = clang.map { [$0] } - self.swift = swift.map { [$0] } - } - - public init(gcc: Array?, llvm_gcc: Array?, llvm: Array?, clang: Array?, swift: Array?) { - self.gcc = gcc?.isEmpty == true ? nil : gcc - self.llvm_gcc = llvm_gcc?.isEmpty == true ? nil : llvm_gcc - self.llvm = llvm?.isEmpty == true ? nil : llvm - self.clang = clang?.isEmpty == true ? nil : clang - self.swift = swift?.isEmpty == true ? nil : swift - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Link.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Link.swift deleted file mode 100644 index 67e5736b..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Link.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// Link.swift -// xcodereleases -// -// Created by Xcode Releases on 4/5/18. -// Copyright © 2018 Xcode Releases. All rights reserved. -// - -import Foundation - -public struct Link: Codable { - public let url: URL - public let sizeMB: Int? - /// The platforms supported by this link, if applicable. - public var architectures: [Architecture]? - -// public init(_ string: String, _ size: Int? = nil, _ architectures: [Architecture]? = nil) { -// self.url = URL(string: string)! -// self.sizeMB = size -// self.architectures = architectures -// } -} - -public struct Links: Codable { - public let download: Link? - public let notes: Link? - - public init(download: Link? = nil, notes: Link? = nil) { - self.download = download - self.notes = notes - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Release.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Release.swift deleted file mode 100644 index 9c83e62f..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Release.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// Release.swift -// xcodereleases -// -// Created by Xcode Releases on 4/4/18. -// Copyright © 2018 Xcode Releases. All rights reserved. -// - -import Foundation - -public enum Release: Codable { - - public enum CodingKeys: String, CodingKey { - case gm, gmSeed, rc, beta, dp, release - } - - public var isGM: Bool { - guard case .gm = self else { return false } - return true - } - - case gm - case gmSeed(Int) - case rc(Int) - case beta(Int) - case dp(Int) - case release - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - if let _ = try container.decodeIfPresent(Bool.self, forKey: .gm) { - self = .gm - } else if let v = try container.decodeIfPresent(Int.self, forKey: .gmSeed) { - self = .gmSeed(v) - } else if let v = try container.decodeIfPresent(Int.self, forKey: .rc) { - self = .rc(v) - } else if let v = try container.decodeIfPresent(Int.self, forKey: .beta) { - self = .beta(v) - } else if let v = try container.decodeIfPresent(Int.self, forKey: .dp) { - self = .dp(v) - } else if let _ = try container.decodeIfPresent(Bool.self, forKey: .release) { - self = .release - } else { - fatalError("Unreachable") - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .gm: try container.encode(true, forKey: .gm) - case .gmSeed(let v): try container.encode(v, forKey: .gmSeed) - case .rc(let v): try container.encode(v, forKey: .rc) - case .beta(let v): try container.encode(v, forKey: .beta) - case .dp(let v): try container.encode(v, forKey: .dp) - case .release: try container.encode(true, forKey: .release) - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/SDKs.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/SDKs.swift deleted file mode 100644 index 1dcffd6d..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/SDKs.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// SDKs.swift -// xcodereleases -// -// Created by Xcode Releases on 4/4/18. -// Copyright © 2018 Xcode Releases. All rights reserved. -// - -import Foundation - -public struct SDKs: Codable { - public let macOS: Array? - public let iOS: Array? - public let watchOS: Array? - public let tvOS: Array? - public let visionOS: Array? - - public init(macOS: XcodeVersion? = nil, iOS: XcodeVersion? = nil, watchOS: XcodeVersion? = nil, tvOS: XcodeVersion? = nil, visionOS: XcodeVersion? = nil) { - self.macOS = macOS.map { [$0] } - self.iOS = iOS.map { [$0] } - self.watchOS = watchOS.map { [$0] } - self.tvOS = tvOS.map { [$0] } - self.visionOS = visionOS.map { [$0] } - } - - public init(macOS: Array?, iOS: XcodeVersion? = nil, watchOS: XcodeVersion? = nil, tvOS: XcodeVersion? = nil, visionOS: XcodeVersion? = nil) { - self.macOS = macOS?.isEmpty == true ? nil : macOS - self.iOS = iOS.map { [$0] } - self.watchOS = watchOS.map { [$0] } - self.tvOS = tvOS.map { [$0] } - self.visionOS = visionOS.map { [$0] } - } - - public init(macOS: Array?, iOS: Array?, watchOS: XcodeVersion? = nil, tvOS: XcodeVersion? = nil, visionOS: XcodeVersion? = nil) { - self.macOS = macOS?.isEmpty == true ? nil : macOS - self.iOS = iOS?.isEmpty == true ? nil : iOS - self.watchOS = watchOS.map { [$0] } - self.tvOS = tvOS.map { [$0] } - self.visionOS = visionOS.map { [$0] } - } - - public init(macOS: Array?, iOS: Array?, watchOS: Array?, tvOS: XcodeVersion? = nil, visionOS: XcodeVersion? = nil) { - self.macOS = macOS?.isEmpty == true ? nil : macOS - self.iOS = iOS?.isEmpty == true ? nil : iOS - self.watchOS = watchOS?.isEmpty == true ? nil : watchOS - self.tvOS = tvOS.map { [$0] } - self.visionOS = visionOS.map { [$0] } - } - - public init(macOS: Array?, iOS: Array?, watchOS: Array?, tvOS: Array?, visionOS: Array?) { - self.macOS = macOS?.isEmpty == true ? nil : macOS - self.iOS = iOS?.isEmpty == true ? nil : iOS - self.watchOS = watchOS?.isEmpty == true ? nil : watchOS - self.tvOS = tvOS?.isEmpty == true ? nil : tvOS - self.visionOS = visionOS?.isEmpty == true ? nil : visionOS - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeRelease.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeRelease.swift deleted file mode 100644 index 4df4b307..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeRelease.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Xcode.swift -// xcodereleases -// -// Created by Xcode Releases on 4/3/18. -// Copyright © 2018 Xcode Releases. All rights reserved. -// - -import Foundation - -public struct XcodeRelease: Codable { - public let name: String - public let version: XcodeVersion - public let date: YMD - public let requires: String - public let sdks: SDKs? - public let compilers: Compilers? - public let links: Links? - public let checksums: Checksums? - - public var architectures: [Architecture]? { - return links.flatMap { $0.download?.architectures } - } - - public init(name: String = "Xcode", version: XcodeVersion, date: (Int, Int, Int), requires: String, sdks: SDKs? = nil, compilers: Compilers? = nil, links: Links? = nil, checksums: Checksums? = nil) { - self.name = name - self.version = version; - self.date = YMD(date); - self.requires = requires; - self.sdks = sdks; - self.compilers = compilers - self.links = links - self.checksums = checksums - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeVersion.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeVersion.swift deleted file mode 100644 index 09138399..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeVersion.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// Version.swift -// xcodereleases -// -// Created by Xcode Releases on 4/4/18. -// Copyright © 2018 Xcode Releases. All rights reserved. -// - -import Foundation - -public typealias V = XcodeVersion -public struct XcodeVersion: Codable { - public let number: String? - public let build: String? - public let release: Release - - public init(_ build: String, _ number: String? = nil, _ release: Release = .release) { - self.number = number; self.build = build; self.release = release - } - - public init(number: String, _ build: String? = nil, _ release: Release = .release) { - self.number = number; self.build = build; self.release = release - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/YMD.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/YMD.swift deleted file mode 100644 index a97bd4d0..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/YMD.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// YMD.swift -// xcodereleases -// -// Created by Xcode Releases on 4/4/18. -// Copyright © 2018 Xcode Releases. All rights reserved. -// - -import Foundation - -public struct YMD: Codable { - public let year: Int - public let month: Int - public let day: Int - - public init(_ ymd: (Int, Int, Int)) { - self.year = ymd.0; self.month = ymd.1; self.day = ymd.2 - } - - public init(_ year: Int, _ month: Int, _ day: Int) { - self.year = year; self.month = month; self.day = day - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeService.swift deleted file mode 100644 index 1efbc2e2..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeService.swift +++ /dev/null @@ -1,105 +0,0 @@ -import Foundation -import AsyncNetworkService -import Path - -extension URL { - static let downloadableRuntimes = URL(string: "https://devimages-cdn.apple.com/downloads/xcode/simulators/index2.dvtdownloadableindex")! -} - -public struct RuntimeService { - var networkService: AsyncHTTPNetworkService - public enum Error: LocalizedError, Equatable { - case unavailableRuntime(String) - case failedMountingDMG - } - - public init() { - networkService = AsyncHTTPNetworkService() - } - - public func downloadableRuntimes() async throws -> DownloadableRuntimesResponse { - let urlRequest = URLRequest(url: .downloadableRuntimes) - - // Apple gives a plist for download - let (data, _) = try await networkService.requestData(urlRequest, validators: []) - do { - let decodedResponse = try PropertyListDecoder().decode(DownloadableRuntimesResponse.self, from: data) - return decodedResponse - } catch { - print("error: \(error)") - throw error - } - - } - - public func installedRuntimes() async throws -> [InstalledRuntime] { - // This only uses the Selected Xcode, so we don't know what other SDK's have been installed in previous versions - let output = try await Current.shell.installedRuntimes() - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - let outputDictionary = try decoder.decode([String: InstalledRuntime].self, from: output.out.data(using: .utf8)!) - - return outputDictionary.values.sorted { first, second in - return first.identifier.uuidString.compare(second.identifier.uuidString, options: .numeric) == .orderedAscending - } - } - - /// Loops through `/Library/Developer/CoreSimulator/images/images.plist` which contains a list of downloaded Simuator Runtimes - /// This is different then using `simctl` (`installedRuntimes()`) which only returns the installed runtimes for the selected xcode version. - public func localInstalledRuntimes() async throws -> [CoreSimulatorImage] { - guard let path = Path("/Library/Developer/CoreSimulator/images/images.plist") else { throw "Could not find images.plist for CoreSimulators" } - guard let infoPlistData = FileManager.default.contents(atPath: path.string) else { throw "Could not get data from \(path.string)" } - - do { - let infoPlist: CoreSimulatorPlist = try PropertyListDecoder().decode(CoreSimulatorPlist.self, from: infoPlistData) - return infoPlist.images - } catch { - throw error - } - } - - public func installRuntimeImage(dmgURL: URL) async throws { - _ = try await Current.shell.installRuntimeImage(dmgURL) - } - - public func mountDMG(dmgUrl: URL) async throws -> URL { - let resultPlist = try await Current.shell.mountDmg(dmgUrl) - - let dict = try? (PropertyListSerialization.propertyList(from: resultPlist.out.data(using: .utf8)!, format: nil) as? NSDictionary) - let systemEntities = dict?["system-entities"] as? NSArray - guard let path = systemEntities?.compactMap ({ ($0 as? NSDictionary)?["mount-point"] as? String }).first else { - throw Error.failedMountingDMG - } - return URL(fileURLWithPath: path) - } - - public func unmountDMG(mountedURL: URL) async throws { - _ = try await Current.shell.unmountDmg(mountedURL) - } - - public func expand(pkgPath: Path, expandedPkgPath: Path) async throws { - _ = try await Current.shell.expandPkg(pkgPath.url, expandedPkgPath.url) - } - - public func createPkg(pkgPath: Path, expandedPkgPath: Path) async throws { - _ = try await Current.shell.createPkg(pkgPath.url, expandedPkgPath.url) - } - - public func installPkg(pkgPath: Path, expandedPkgPath: Path) async throws { - _ = try await Current.shell.installPkg(pkgPath.url, expandedPkgPath.url.absoluteString) - } - - public func deleteRuntime(identifier: String) async throws { - do { - _ = try await Current.shell.deleteRuntime(identifier) - } catch { - if let executionError = error as? ProcessExecutionError { - throw executionError.standardError - } - throw error - } - } -} - -extension String: Error {} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Shell/Process.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Shell/Process.swift deleted file mode 100644 index daf28e97..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Shell/Process.swift +++ /dev/null @@ -1,70 +0,0 @@ -import Foundation -import Path -import os.log - -public typealias ProcessOutput = (status: Int32, out: String, err: String) - -extension Process { - static func run(_ executable: Path, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) async throws -> ProcessOutput { - return try run(executable.url, workingDirectory: workingDirectory, input: input, arguments) - } - - static func run(_ executable: Path, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) throws -> ProcessOutput { - return try run(executable.url, workingDirectory: workingDirectory, input: input, arguments) - } - - static func run(_ executable: URL, workingDirectory: URL? = nil, input: String? = nil, _ arguments: [String]) throws -> ProcessOutput { - - let process = Process() - process.currentDirectoryURL = workingDirectory ?? executable.deletingLastPathComponent() - process.executableURL = executable - process.arguments = arguments - - let (stdout, stderr) = (Pipe(), Pipe()) - process.standardOutput = stdout - process.standardError = stderr - - if let input = input { - let inputPipe = Pipe() - process.standardInput = inputPipe.fileHandleForReading - inputPipe.fileHandleForWriting.write(Data(input.utf8)) - inputPipe.fileHandleForWriting.closeFile() - } - - do { - Logger.subprocess.info("Process.run executable: \(executable), input: \(input ?? ""), arguments: \(arguments.joined(separator: ", "))") - - try process.run() - process.waitUntilExit() - - let output = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - let error = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - - Logger.subprocess.info("Process.run output: \(output)") - if !error.isEmpty { - Logger.subprocess.error("Process.run error: \(error)") - } - - guard process.terminationReason == .exit, process.terminationStatus == 0 else { - throw ProcessExecutionError(process: process, standardOutput: output, standardError: error) - } - - return (process.terminationStatus, output, error) - } catch { - throw error - } - } - -} - -public struct ProcessExecutionError: Error { - public let process: Process - public let standardOutput: String - public let standardError: String - - public init(process: Process, standardOutput: String, standardError: String) { - self.process = process - self.standardOutput = standardOutput - self.standardError = standardError - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Shell/XcodesShell.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Shell/XcodesShell.swift deleted file mode 100644 index ef091f21..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Shell/XcodesShell.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation -import Path - -public struct XcodesShell { - public var installedRuntimes: () async throws -> ProcessOutput = { - try await Process.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "list", "-j") - } - public var mountDmg: (URL) async throws -> ProcessOutput = { - try await Process.run(Path.root.usr.bin.join("hdiutil"), "attach", "-nobrowse", "-plist", $0.path) - } - public var unmountDmg: (URL) async throws -> ProcessOutput = { - try await Process.run(Path.root.usr.bin.join("hdiutil"), "detach", $0.path) - } - public var expandPkg: (URL, URL) async throws -> ProcessOutput = { - try await Process.run(Path.root.usr.sbin.join("pkgutil"), "--verbose", "--expand", $0.path, $1.path) - } - public var createPkg: (URL, URL) async throws -> ProcessOutput = { - try await Process.run(Path.root.usr.sbin.join("pkgutil"), "--flatten", $0.path, $1.path) - } - public var installPkg: (URL, String) async throws -> ProcessOutput = { - try await Process.run(Path.root.usr.sbin.join("installer"), "-pkg", $0.path, "-target", $1) - } - public var installRuntimeImage: (URL) async throws -> ProcessOutput = { - try await Process.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "add", $0.path) - } - public var deleteRuntime: (String) async throws -> ProcessOutput = { - try await Process.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "delete", $0) - } - - public var archs: (URL) throws -> ProcessOutput = { - try Process.run(Path.root.usr.bin.join("lipo"), "-archs", $0.path) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/XcodesKitEnvironment.swift b/Xcodes/XcodesKit/Sources/XcodesKit/XcodesKitEnvironment.swift deleted file mode 100644 index c6a52435..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/XcodesKitEnvironment.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -public struct XcodesKitEnvironment { - public var shell = XcodesShell() -} - -public var Current = XcodesKitEnvironment() diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesKitTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesKitTests.swift deleted file mode 100644 index 98f0c961..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesKitTests.swift +++ /dev/null @@ -1,11 +0,0 @@ -import XCTest -@testable import XcodesKit - -final class XcodesKitTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(XcodesKit().text, "Hello, World!") - } -} diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index 4be9ca32..88cc48e3 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -1,18 +1,36 @@ -import AppleAPI import Combine -import CombineExpectations -import Path +@preconcurrency import Path import Version import XCTest +import XcodesLoginKit import XcodesKit +import os @testable import Xcodes +private final class TestLockedBox: Sendable { + private let storage: OSAllocatedUnfairLock + + init(_ value: Value) { + self.storage = OSAllocatedUnfairLock(initialState: value) + } + + func read(_ body: @Sendable (Value) -> Result) -> Result { + storage.withLock { body($0) } + } + + func withValue(_ body: @Sendable (inout Value) -> Result) -> Result { + storage.withLock { body(&$0) } + } +} + +@MainActor class AppStateTests: XCTestCase { var subject: AppState! override func setUpWithError() throws { Current = .mock + syncXcodesKitMocks() subject = AppState() } @@ -31,42 +49,494 @@ class AppStateTests: XCTestCase { Sealed Resources version=2 rules=13 files=253327 Internal requirements count=1 size=68 """ - let info = subject.parseCertificateInfo(sampleRawInfo) + let info = XcodeSignatureVerifier().parse(sampleRawInfo) XCTAssertEqual(info.authority, ["Software Signing", "Apple Code Signing Certification Authority", "Apple Root CA"]) XCTAssertEqual(info.teamIdentifier, "59GAB85EFG") XCTAssertEqual(info.bundleIdentifier, "com.apple.dt.Xcode") } - func test_VerifySecurityAssessment_Fails() throws { - Current.shell.spctlAssess = { _ in - Fail(error: ProcessExecutionError(process: Process(), standardOutput: "stdout", standardError: "stderr")) - .eraseToAnyPublisher() + func test_PrepareForHelperAction_OnlyRunsActionOnce() { + var responses = [Bool]() + subject.prepareForHelperAction { responses.append($0) } + + let helperAction = subject.isPreparingUserForActionRequiringHelper + helperAction?(true) + helperAction?(false) + + XCTAssertEqual(responses, [true]) + XCTAssertNil(subject.isPreparingUserForActionRequiringHelper) + } + + func test_SetupDefaults_EnableGroupedXcodeListDefaultsToTrue() { + subject.setupDefaults() + + XCTAssertTrue(subject.enableGroupedXcodeList) + } + + func test_SetupDefaults_EnableGroupedXcodeListUsesStoredValue() { + Current.defaults.get = { key in + key == PreferenceKey.enableGroupedXcodeList.rawValue ? false : nil + } + + subject.setupDefaults() + + XCTAssertFalse(subject.enableGroupedXcodeList) + } + + func test_PrepareForHelperAction_StaleActionDoesNotClearReplacementAction() { + var responses = [Bool]() + subject.prepareForHelperAction { responses.append($0) } + let staleHelperAction = subject.isPreparingUserForActionRequiringHelper + + subject.prepareForHelperAction { responses.append($0) } + let replacementHelperAction = subject.isPreparingUserForActionRequiringHelper + + staleHelperAction?(true) + XCTAssertTrue(responses.isEmpty) + XCTAssertNotNil(subject.isPreparingUserForActionRequiringHelper) + + replacementHelperAction?(false) + XCTAssertEqual(responses, [false]) + XCTAssertNil(subject.isPreparingUserForActionRequiringHelper) + XCTAssertNil(subject.helperActionPreparationID) + } + + func test_RespondToPreparedHelperAction_RunsActionAndClearsAlert() { + var responses = [Bool]() + subject.prepareForHelperAction { responses.append($0) } + + subject.respondToPreparedHelperAction(userConsented: true) + + XCTAssertEqual(responses, [true]) + XCTAssertNil(subject.isPreparingUserForActionRequiringHelper) + XCTAssertNil(subject.helperActionPreparationID) + XCTAssertNil(subject.presentedAlert) + } + + func test_CreateSymbolicLink_UsesProvidedInstalledPath() throws { + let installDirectory = try XCTUnwrap(Path( + NSTemporaryDirectory() + .appending("XcodesAppStateTests-") + .appending(UUID().uuidString) + )) + let installedXcodePath = installDirectory/"Xcode-15.1.app" + let symlinkPath = installDirectory/"Xcode.app" + try FileManager.default.createDirectory(at: installedXcodePath.url, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: installDirectory.url) } + + Current.defaults.string = { key in + key == "installPath" ? installDirectory.string : nil + } + + subject.createSymbolicLink(to: installedXcodePath) + + let destination = try FileManager.default.destinationOfSymbolicLink(atPath: symlinkPath.string) + XCTAssertEqual(destination, installedXcodePath.string) + } + + func test_InstallHelperIfNecessary_OldTaskDoesNotClearReplacementTask() async throws { + subject.helperInstallState = .notInstalled + let continuations = TestLockedBox<[CheckedContinuation]>([]) + Current.helper.install = { } + Current.helper.checkIfLatestHelperIsInstalledAsync = { + try await withCheckedThrowingContinuation { continuation in + continuations.withValue { $0.append(continuation) } + } + } + + subject.installHelperIfNecessary(shouldPrepareUserForHelperInstallation: false) + for _ in 0..<100 where continuations.read({ $0.count }) < 1 { + await Task.yield() + } + let firstTask = try XCTUnwrap(subject.helperInstallTask) + XCTAssertEqual(continuations.read { $0.count }, 1) + + subject.installHelperIfNecessary(shouldPrepareUserForHelperInstallation: false) + for _ in 0..<100 where continuations.read({ $0.count }) < 2 { + await Task.yield() + } + let replacementTask = try XCTUnwrap(subject.helperInstallTask) + XCTAssertEqual(continuations.read { $0.count }, 2) + + continuations.read { $0[0] }.resume(returning: false) + await firstTask.value + XCTAssertNotNil(subject.helperInstallTask) + + continuations.read { $0[1] }.resume(returning: true) + await replacementTask.value + XCTAssertNil(subject.helperInstallTask) + XCTAssertNil(subject.helperInstallTaskID) + XCTAssertEqual(subject.helperInstallState, .installed) + } + + func test_PerformPostInstallSteps_OldTaskDoesNotClearReplacementTask() async throws { + subject.helperInstallState = .installed + let firstXcode = InstalledXcode(path: Path("/Applications/Xcode-1.app")!, version: Version("1.0.0")!) + let secondXcode = InstalledXcode(path: Path("/Applications/Xcode-2.app")!, version: Version("2.0.0")!) + let firstLaunchPaths = TestLockedBox<[String]>([]) + let continuations = TestLockedBox<[CheckedContinuation]>([]) + + Current.helper.runFirstLaunchAsync = { path in + firstLaunchPaths.withValue { $0.append(path) } + try await withCheckedThrowingContinuation { continuation in + continuations.withValue { $0.append(continuation) } + } + } + + subject.performPostInstallSteps(for: firstXcode) + for _ in 0..<100 where continuations.read({ $0.count }) < 1 { + await Task.yield() + } + let firstTask = try XCTUnwrap(subject.postInstallTask) + XCTAssertEqual(firstLaunchPaths.read { $0 }, [firstXcode.path.string]) + + subject.performPostInstallSteps(for: secondXcode) + for _ in 0..<100 where continuations.read({ $0.count }) < 2 { + await Task.yield() + } + let replacementTask = try XCTUnwrap(subject.postInstallTask) + XCTAssertEqual(firstLaunchPaths.read { $0 }, [firstXcode.path.string, secondXcode.path.string]) + + continuations.read { $0[0] }.resume() + await firstTask.value + XCTAssertNotNil(subject.postInstallTask) + + continuations.read { $0[1] }.resume() + await replacementTask.value + XCTAssertNil(subject.postInstallTask) + XCTAssertNil(subject.postInstallTaskID) + } + + func test_Select_OldTaskDoesNotClearReplacementTask() async throws { + subject.helperInstallState = .installed + let firstPath = try XCTUnwrap(Path("/Applications/Xcode-1.app")) + let secondPath = try XCTUnwrap(Path("/Applications/Xcode-2.app")) + let firstXcode = Xcode(version: Version("1.0.0")!, installState: .installed(firstPath), selected: false, icon: nil) + let secondXcode = Xcode(version: Version("2.0.0")!, installState: .installed(secondPath), selected: false, icon: nil) + let selectedPaths = TestLockedBox<[String]>([]) + let continuations = TestLockedBox<[CheckedContinuation]>([]) + + Current.helper.switchXcodePathAsync = { path in + selectedPaths.withValue { $0.append(path) } + try await withCheckedThrowingContinuation { continuation in + continuations.withValue { $0.append(continuation) } + } + } + Current.shell.xcodeSelectPrintPath = { + ProcessOutput(status: 0, out: secondPath.string, err: "") + } + + subject.select(xcode: firstXcode, shouldPrepareUserForHelperInstallation: false) + for _ in 0..<100 where continuations.read({ $0.count }) < 1 { + await Task.yield() + } + let firstTask = try XCTUnwrap(subject.selectTask) + XCTAssertEqual(selectedPaths.read { $0 }, [firstPath.string]) + + subject.select(xcode: secondXcode, shouldPrepareUserForHelperInstallation: false) + for _ in 0..<100 where continuations.read({ $0.count }) < 2 { + await Task.yield() + } + let replacementTask = try XCTUnwrap(subject.selectTask) + XCTAssertEqual(selectedPaths.read { $0 }, [firstPath.string, secondPath.string]) + + continuations.read { $0[0] }.resume() + await firstTask.value + XCTAssertNotNil(subject.selectTask) + + continuations.read { $0[1] }.resume() + await replacementTask.value + XCTAssertNil(subject.selectTask) + XCTAssertNil(subject.selectTaskID) + XCTAssertEqual(subject.selectedXcodePath, secondPath.string) + } + + func test_Signout_RemovesCookiesFromDownloadSession() throws { + let session = URLSession(configuration: .ephemeral) + Current.network.session = session + let cookie = try HTTPCookie.xcodesTestCookie(name: "ADCDownloadAuth") + session.configuration.httpCookieStorage?.setCookie(cookie) + XCTAssertEqual(session.configuration.httpCookieStorage?.cookies?.contains(cookie), true) + + subject.signOut() + + XCTAssertEqual(session.configuration.httpCookieStorage?.cookies?.contains(cookie), false) + } + + func test_Signout_RemovesCookiesAfterDownloadSessionIsReplaced() throws { + let initialSession = URLSession(configuration: .ephemeral) + let replacementSession = URLSession(configuration: .ephemeral) + Current.network.session = initialSession + Current.network.session = replacementSession + let cookie = try HTTPCookie.xcodesTestCookie(name: "FASTLANE_SESSION") + replacementSession.configuration.httpCookieStorage?.setCookie(cookie) + XCTAssertEqual(replacementSession.configuration.httpCookieStorage?.cookies?.contains(cookie), true) + + subject.signOut() + + XCTAssertEqual(initialSession.configuration.httpCookieStorage?.cookies?.contains(cookie), false) + XCTAssertEqual(replacementSession.configuration.httpCookieStorage?.cookies?.contains(cookie), false) + } + + func test_NetworkSessionReplacementUpdatesLoginClientSession() { + let initialSession = URLSession(configuration: .ephemeral) + let replacementSession = URLSession(configuration: .ephemeral) + + Current.network.session = initialSession + XCTAssertTrue(Current.network.loginClient.urlSession === initialSession) + + Current.network.session = replacementSession + XCTAssertTrue(Current.network.loginClient.urlSession === replacementSession) + } + + func test_DownloadRuntimeViaXcodeBuild_ClearsRuntimeTaskWhenComplete() async throws { + let runtime = try Self.downloadableRuntime() + subject.downloadableRuntimes = [runtime] + Current.shell.downloadRuntime = { _, _, _ in + let (stream, continuation) = AsyncThrowingStream.makeStream(of: Progress.self, throwing: Error.self) + continuation.finish() + return stream + } + + subject.downloadRuntimeViaXcodeBuild(runtime: runtime) + let task = try XCTUnwrap(subject.runtimeTasks[runtime.identifier]) + try await task.value + + XCTAssertNil(subject.runtimeTasks[runtime.identifier]) + XCTAssertNil(subject.runtimeTaskIDs[runtime.identifier]) + XCTAssertEqual(subject.downloadableRuntimes.first?.installState, .installed) + } + + func test_DownloadRuntimeViaXcodeBuild_OldTaskDoesNotClearReplacementTask() async throws { + let runtime = try Self.downloadableRuntime() + subject.downloadableRuntimes = [runtime] + let continuations = TestLockedBox<[AsyncThrowingStream.Continuation]>([]) + Current.shell.downloadRuntime = { _, _, _ in + let (stream, continuation) = AsyncThrowingStream.makeStream(of: Progress.self, throwing: Error.self) + continuations.withValue { $0.append(continuation) } + return stream + } + + subject.downloadRuntimeViaXcodeBuild(runtime: runtime) + for _ in 0..<100 where continuations.read({ $0.count }) < 1 { + await Task.yield() + } + let firstTask = try XCTUnwrap(subject.runtimeTasks[runtime.identifier]) + XCTAssertEqual(continuations.read { $0.count }, 1) + + subject.downloadRuntimeViaXcodeBuild(runtime: runtime) + for _ in 0..<100 where continuations.read({ $0.count }) < 2 { + await Task.yield() + } + let replacementTask = try XCTUnwrap(subject.runtimeTasks[runtime.identifier]) + XCTAssertEqual(continuations.read { $0.count }, 2) + + continuations.read { $0[0] }.finish() + try await firstTask.value + XCTAssertNotNil(subject.runtimeTasks[runtime.identifier]) + + continuations.read { $0[1] }.finish() + try await replacementTask.value + XCTAssertNil(subject.runtimeTasks[runtime.identifier]) + XCTAssertNil(subject.runtimeTaskIDs[runtime.identifier]) + } + + func test_ConfirmDeleteRuntime_OldTaskDoesNotClearReplacementTask() async throws { + let runtime = try Self.downloadableRuntime() + let installedRuntime = CoreSimulatorImage( + uuid: "runtime-uuid", + path: ["relative": "/Library/Developer/CoreSimulator/Images/runtime.dmg"], + runtimeInfo: CoreSimulatorRuntimeInfo(build: runtime.simulatorVersion.buildUpdate) + ) + let deletedIdentifiers = TestLockedBox<[String]>([]) + let continuations = TestLockedBox<[CheckedContinuation]>([]) + subject = AppState( + runtimeService: Self.runtimeService { identifier in + deletedIdentifiers.withValue { $0.append(identifier) } + return try await withCheckedThrowingContinuation { continuation in + continuations.withValue { $0.append(continuation) } + } + } + ) + subject.installedRuntimes = [installedRuntime] + + subject.confirmDeleteRuntime(runtime: runtime) + for _ in 0..<100 where continuations.read({ $0.count }) < 1 { + await Task.yield() + } + let firstTask = try XCTUnwrap(subject.deleteRuntimeTask) + XCTAssertEqual(deletedIdentifiers.read { $0 }, [installedRuntime.uuid]) + + subject.confirmDeleteRuntime(runtime: runtime) + for _ in 0..<100 where continuations.read({ $0.count }) < 2 { + await Task.yield() + } + let replacementTask = try XCTUnwrap(subject.deleteRuntimeTask) + XCTAssertEqual(deletedIdentifiers.read { $0 }, [installedRuntime.uuid, installedRuntime.uuid]) + + continuations.read { $0[0] }.resume(returning: ProcessOutput(status: 0, out: "", err: "")) + await firstTask.value + XCTAssertNotNil(subject.deleteRuntimeTask) + + continuations.read { $0[1] }.resume(returning: ProcessOutput(status: 0, out: "", err: "")) + await replacementTask.value + XCTAssertNil(subject.deleteRuntimeTask) + XCTAssertNil(subject.deleteRuntimeTaskID) + } + + func test_ConfirmDeleteRuntime_PresentsPreferenceAlertOnError() async throws { + let runtime = try Self.downloadableRuntime() + + subject.confirmDeleteRuntime(runtime: runtime) + let task = try XCTUnwrap(subject.deleteRuntimeTask) + await task.value + + guard case let .generic(title, message) = subject.presentedPreferenceAlert else { + return XCTFail("Expected generic preference alert") } + XCTAssertEqual(title, "Error") + XCTAssertEqual(message, "No simulator found with \(runtime.identifier)") + XCTAssertNil(subject.deleteRuntimeTask) + XCTAssertNil(subject.deleteRuntimeTaskID) + } - let installedXcode = InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)! - let recorder = subject.verifySecurityAssessment(of: installedXcode).record() - let completion = try wait(for: recorder.completion, timeout: 1, description: "Completion") + func test_InstallWithoutLogin_OldTaskDoesNotClearReplacementTask() async throws { + let version = Version("0.0.0")! + let availableXcode = AvailableXcode( + version: version, + url: URL(string: "https://apple.com/xcode.xip")!, + filename: "mock.xip", + releaseDate: nil + ) + subject.availableXcodes = [availableXcode] + subject.allXcodes = [ + .init(version: version, installState: .notInstalled, selected: false, icon: nil) + ] + subject.helperInstallState = .installed + + Current.defaults.string = { key in + key == "downloader" ? "urlSession" : nil + } + Current.files.fileExistsAtPath = { path in + path != (Path.xcodesApplicationSupport/"Xcode-0.0.0.xip").string + } + Current.shell.codesignVerify = { _ in + ProcessOutput( + status: 0, + out: "", + err: """ + TeamIdentifier=\(XcodeTeamIdentifier) + Authority=\(XcodeCertificateAuthority[0]) + Authority=\(XcodeCertificateAuthority[1]) + Authority=\(XcodeCertificateAuthority[2]) + """ + ) + } + + let continuations = TestLockedBox<[CheckedContinuation<(saveLocation: URL, response: URLResponse), Error>]>([]) + Current.network.downloadTaskAsync = { url, saveLocation, _ in + ( + Progress(), + Task { + try await withCheckedThrowingContinuation { continuation in + continuations.withValue { $0.append(continuation) } + } + } + ) + } - if case let .failure(error as InstallationError) = completion { - XCTAssertEqual(error, InstallationError.failedSecurityAssessment(xcode: installedXcode, output: "stdout\nstderr")) + subject.installWithoutLogin(id: availableXcode.xcodeID) + for _ in 0..<100 where continuations.read({ $0.count }) < 1 { + await Task.yield() } - else { - XCTFail() + let firstTask = try XCTUnwrap(subject.installationTasks[availableXcode.xcodeID]) + XCTAssertEqual(continuations.read { $0.count }, 1) + + subject.installWithoutLogin(id: availableXcode.xcodeID) + for _ in 0..<100 where continuations.read({ $0.count }) < 2 { + await Task.yield() } + let replacementTask = try XCTUnwrap(subject.installationTasks[availableXcode.xcodeID]) + XCTAssertEqual(continuations.read { $0.count }, 2) + + continuations.read { $0[0] }.resume(returning: Self.downloadResult(for: availableXcode)) + await firstTask.value + XCTAssertNotNil(subject.installationTasks[availableXcode.xcodeID]) + + continuations.read { $0[1] }.resume(returning: Self.downloadResult(for: availableXcode)) + await replacementTask.value + XCTAssertNil(subject.installationTasks[availableXcode.xcodeID]) + XCTAssertNil(subject.installationTaskIDs[availableXcode.xcodeID]) } - func test_VerifySecurityAssessment_Succeeds() throws { - Current.shell.spctlAssess = { _ in - Just((0, "", "")).setFailureType(to: Error.self).eraseToAnyPublisher() + func test_Install_RetryingDownloadDoesNotAttachSameProgressTwice() async throws { + let version = Version("0.0.0")! + let availableXcode = AvailableXcode( + version: version, + url: URL(string: "https://apple.com/xcode.xip")!, + filename: "mock.xip", + releaseDate: nil + ) + subject.allXcodes = [ + .init(version: version, installState: .notInstalled, selected: false, icon: nil) + ] + subject.helperInstallState = .installed + + Current.files.fileExistsAtPath = { path in + path != (Path.xcodesApplicationSupport/"Xcode-0.0.0.xip").string } + Current.shell.codesignVerify = { _ in + ProcessOutput( + status: 0, + out: "", + err: """ + TeamIdentifier=\(XcodeTeamIdentifier) + Authority=\(XcodeCertificateAuthority[0]) + Authority=\(XcodeCertificateAuthority[1]) + Authority=\(XcodeCertificateAuthority[2]) + """ + ) + } + + let progress = Progress(totalUnitCount: 100) + let attempts = TestLockedBox(0) + Current.network.downloadTaskAsync = { url, saveLocation, _ in + let attempt = attempts.withValue { + $0 += 1 + return $0 + } + return ( + progress, + Task { + await Task.yield() + if attempt == 1 { + throw NSError( + domain: NSURLErrorDomain, + code: NSURLErrorNetworkConnectionLost, + userInfo: [NSURLSessionDownloadTaskResumeData: Data("resume".utf8)] + ) + } - let installedXcode = InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)! - let recorder = subject.verifySecurityAssessment(of: installedXcode).record() - try wait(for: recorder.finished, timeout: 1, description: "Finished") + return ( + saveLocation: saveLocation, + response: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } + ) + } + + let installedXcode = try await subject.installAsync( + .version(availableXcode), + downloader: .urlSession, + attemptNumber: 0 + ) + + XCTAssertEqual(attempts.read { $0 }, 2) + XCTAssertTrue(installedXcode.version.isEquivalent(to: version)) } - func test_Install_FullHappyPath_Apple() throws { + func test_Install_FullHappyPath_Apple() async throws { // Available xcode doesn't necessarily have build identifier subject.allXcodes = [ .init(version: Version("0.0.0")!, installState: .notInstalled, selected: false, icon: nil), @@ -83,65 +553,46 @@ class AppStateTests: XCTestCase { return true } } - Xcodes.Current.network.validateSession = { - return Just(()) - .setFailureType(to: Error.self).eraseToAnyPublisher() - } - Xcodes.Current.network.dataTask = { urlRequest in - // Don't have a valid session - if urlRequest.url! == URLRequest.olympusSession.url! { - return Fail(error: AuthenticationError.invalidSession) - .eraseToAnyPublisher() - } - // It's an available release version - else if urlRequest.url! == URLRequest.downloads.url! { + Xcodes.Current.network.validateSessionAsync = { } + Xcodes.Current.network.loadData = { urlRequest in + if urlRequest.url! == URLRequest.developerDownloads.url! { let downloads = Downloads(resultCode: 0, resultsString: nil, downloads: [Download(name: "Xcode 0.0.0", files: [Download.File(remotePath: "https://apple.com/xcode.xip", fileSize: 9484444)], dateModified: Date())]) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) let downloadsData = try! encoder.encode(downloads) - return Just( - ( - data: downloadsData, - response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - ) + return ( + data: downloadsData, + response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! ) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() } - return Just( - ( - data: Data(), - response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - ) + return ( + data: Data(), + response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! ) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() } // It downloads and updates progress let progress = Progress(totalUnitCount: 100) - Current.network.downloadTask = { (url, saveLocation, _) -> (Progress, AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) in + Current.network.downloadTaskAsync = { url, saveLocation, _ in return ( progress, - Deferred { - Future { promise in - // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. - DispatchQueue.main.async { - for i in 0...100 { - progress.completedUnitCount = Int64(i) - } - promise(.success((saveLocation: saveLocation, - response: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!))) + Task { + await Task.yield() + await MainActor.run { + for i in 0...100 { + progress.completedUnitCount = Int64(i) } } + return ( + saveLocation: saveLocation, + response: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) } - .eraseToAnyPublisher() ) } // It's a valid .app Current.shell.codesignVerify = { _ in - Just( - ProcessOutput( + ProcessOutput( status: 0, out: "", err: """ @@ -150,23 +601,20 @@ class AppStateTests: XCTestCase { Authority=\(XcodeCertificateAuthority[1]) Authority=\(XcodeCertificateAuthority[2]) """) - ) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() } // Helper is already installed subject.helperInstallState = .installed - let allXcodesRecorder = subject.$allXcodes.record() - let installRecorder = subject.install( - .version(AvailableXcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil)), - downloader: .urlSession - ).record() - try wait(for: installRecorder.finished, timeout: 1, description: "Finished") - - let allXcodesElements = try wait(for: allXcodesRecorder.availableElements, timeout: 1, description: "All Xcodes Elements") + let allXcodeInstallStates = try await recordAllXcodeInstallStates { + _ = try await subject.installAsync( + .version(AvailableXcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil)), + downloader: .urlSession, + attemptNumber: 0 + ) + } + XCTAssertEqual( - allXcodesElements.map { $0.map(\.installState) }, + allXcodeInstallStates, [ [XcodeInstallState.notInstalled, .notInstalled, .notInstalled], [.installing(.downloading(progress: progress)), .notInstalled, .notInstalled], @@ -179,8 +627,74 @@ class AppStateTests: XCTestCase { ] ) } + + private static func downloadableRuntime() throws -> DownloadableRuntime { + let json = """ + { + "category": "simulator", + "simulatorVersion": { + "buildUpdate": "20A360", + "version": "16.0" + }, + "source": "https://example.com/iOS_16_Runtime.dmg", + "architectures": null, + "dictionaryVersion": 1, + "contentType": "diskImage", + "platform": "com.apple.platform.iphoneos", + "identifier": "com.apple.CoreSimulator.SimRuntime.iOS-16-0", + "version": "16.0", + "fileSize": 42, + "hostRequirements": null, + "name": "iOS 16.0", + "authentication": null + } + """ + return try JSONDecoder().decode(DownloadableRuntime.self, from: Data(json.utf8)) + } + + private static func downloadResult(for availableXcode: AvailableXcode) -> (saveLocation: URL, response: URLResponse) { + ( + saveLocation: (Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).xip").url, + response: HTTPURLResponse(url: availableXcode.url, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } + + private static func runtimeService( + deleteRuntimeOutput: @escaping @Sendable (String) async throws -> ProcessOutput + ) -> RuntimeService { + RuntimeService( + loadData: { request in + (Data(), HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + }, + contentsAtPath: { _ in + Data(""" + + + + + images + + + + """.utf8) + }, + installedRuntimesOutput: { + ProcessOutput(status: 0, out: "{}", err: "") + }, + installRuntimeImageOutput: { _ in + ProcessOutput(status: 0, out: "", err: "") + }, + mountDMGOutput: { _ in + ProcessOutput(status: 0, out: "", err: "") + }, + unmountDMGOutput: { _ in + ProcessOutput(status: 0, out: "", err: "") + }, + deleteRuntimeOutput: deleteRuntimeOutput + ) + } - func test_Install_FullHappyPath_XcodeReleases() throws { + func test_Install_FullHappyPath_XcodeReleases() async throws { // Available xcode has build identifier subject.allXcodes = [ .init(version: Version("0.0.0+ABC123")!, installState: .notInstalled, selected: false, icon: nil), @@ -197,61 +711,45 @@ class AppStateTests: XCTestCase { return true } } - Xcodes.Current.network.dataTask = { urlRequest in - // Don't have a valid session - if urlRequest.url! == URLRequest.olympusSession.url! { - return Fail(error: AuthenticationError.invalidSession) - .eraseToAnyPublisher() - } - // It's an available release version - else if urlRequest.url! == URLRequest.downloads.url! { + Xcodes.Current.network.loadData = { urlRequest in + if urlRequest.url! == URLRequest.developerDownloads.url! { let downloads = Downloads(resultCode: 0, resultsString: nil, downloads: [Download(name: "Xcode 0.0.0", files: [Download.File(remotePath: "https://apple.com/xcode.xip", fileSize: 9494944)], dateModified: Date())]) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) let downloadsData = try! encoder.encode(downloads) - return Just( - ( - data: downloadsData, - response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - ) + return ( + data: downloadsData, + response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! ) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() } - return Just( - ( - data: Data(), - response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - ) + return ( + data: Data(), + response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! ) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() } // It downloads and updates progress let progress = Progress(totalUnitCount: 100) - Current.network.downloadTask = { (url, saveLocation, _) -> (Progress, AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) in + Current.network.downloadTaskAsync = { url, saveLocation, _ in return ( progress, - Deferred { - Future { promise in - // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. - DispatchQueue.main.async { - for i in 0...100 { - progress.completedUnitCount = Int64(i) - } - promise(.success((saveLocation: saveLocation, - response: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!))) + Task { + await Task.yield() + await MainActor.run { + for i in 0...100 { + progress.completedUnitCount = Int64(i) } } + return ( + saveLocation: saveLocation, + response: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) } - .eraseToAnyPublisher() ) } // It's a valid .app Current.shell.codesignVerify = { _ in - Just( - ProcessOutput( + ProcessOutput( status: 0, out: "", err: """ @@ -260,23 +758,20 @@ class AppStateTests: XCTestCase { Authority=\(XcodeCertificateAuthority[1]) Authority=\(XcodeCertificateAuthority[2]) """) - ) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() } // Helper is already installed subject.helperInstallState = .installed - let allXcodesRecorder = subject.$allXcodes.record() - let installRecorder = subject.install( - .version(AvailableXcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil)), - downloader: .urlSession - ).record() - try wait(for: installRecorder.finished, timeout: 1, description: "Finished") - - let allXcodesElements = try wait(for: allXcodesRecorder.availableElements, timeout: 1, description: "All Xcodes Elements") + let allXcodeInstallStates = try await recordAllXcodeInstallStates { + _ = try await subject.installAsync( + .version(AvailableXcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil)), + downloader: .urlSession, + attemptNumber: 0 + ) + } + XCTAssertEqual( - allXcodesElements.map { $0.map(\.installState) }, + allXcodeInstallStates, [ [XcodeInstallState.notInstalled, .notInstalled, .notInstalled], [.installing(.downloading(progress: progress)), .notInstalled, .notInstalled], @@ -290,39 +785,60 @@ class AppStateTests: XCTestCase { ) } - func test_Install_NotEnoughFreeSpace() throws { + func test_Install_NotEnoughFreeSpace() async throws { Current.shell.unxip = { _ in - Fail(error: ProcessExecutionError( + throw ProcessExecutionError( process: Process(), standardOutput: "xip: signing certificate was \"Development Update\" (validation not attempted)", standardError: "xip: error: The archive “Xcode-12.4.0-Release.Candidate+12D4e.xip” can’t be expanded because the selected volume doesn’t have enough free space." - )) - .eraseToAnyPublisher() + ) } let archiveURL = URL(fileURLWithPath: "/Users/user/Library/Application Support/Xcode-0.0.0.xip") - let recorder = subject.unarchiveAndMoveXIP( - availableXcode: AvailableXcode( - version: Version("0.0.0")!, - url: URL(string: "https://developer.apple.com")!, - filename: "Xcode-0.0.0.xip", - releaseDate: nil - ), - at: archiveURL, - to: URL(string: "/Applications/Xcode-0.0.0.app")! - ).record() - - let completion = try wait(for: recorder.completion, timeout: 1, description: "Completion") - - if case let .failure(error as InstallationError) = completion { + do { + _ = try await subject.installArchivedXcodeAsync( + AvailableXcode( + version: Version("0.0.0")!, + url: URL(string: "https://developer.apple.com")!, + filename: "Xcode-0.0.0.xip", + releaseDate: nil + ), + at: archiveURL + ) + XCTFail() + } catch let error as InstallationError { XCTAssertEqual( error, InstallationError.notEnoughFreeSpaceToExpandArchive(archivePath: Path(url: archiveURL)!, version: Version("0.0.0")!) ) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + private func recordAllXcodeInstallStates(during operation: () async throws -> Void) async throws -> [[XcodeInstallState]] { + var states: [[XcodeInstallState]] = [] + var cancellable: AnyCancellable? + cancellable = subject.$allXcodes.sink { xcodes in + states.append(xcodes.map(\.installState)) } - else { - XCTFail() - } + defer { cancellable?.cancel() } + + try await operation() + return states + } +} + +private extension HTTPCookie { + static func xcodesTestCookie(name: String) throws -> HTTPCookie { + try XCTUnwrap(HTTPCookie(properties: [ + .domain: "developer.apple.com", + .path: "/", + .name: name, + .value: "test-cookie", + .secure: "TRUE", + .expires: Date.distantFuture + ])) } } diff --git a/XcodesTests/AppStateUpdateTests.swift b/XcodesTests/AppStateUpdateTests.swift index a2f501eb..299e4f01 100644 --- a/XcodesTests/AppStateUpdateTests.swift +++ b/XcodesTests/AppStateUpdateTests.swift @@ -1,86 +1,149 @@ import Path import CryptoKit import Version +import XcodesKit @testable import Xcodes import XCTest import CommonCrypto import BigNum import SRP +import os +private final class AppStateUpdateTestLockedBox: Sendable { + private let storage: OSAllocatedUnfairLock + + init(_ value: Value) { + self.storage = OSAllocatedUnfairLock(initialState: value) + } + + func read(_ body: @Sendable (Value) -> Result) -> Result { + storage.withLock { body($0) } + } + + func withValue(_ body: @Sendable (inout Value) -> Result) -> Result { + storage.withLock { body(&$0) } + } +} + +@MainActor class AppStateUpdateTests: XCTestCase { var subject: AppState! - + override func setUpWithError() throws { Current = .mock + syncXcodesKitMocks() subject = AppState() } + func test_UpdateIfNeeded_OldTaskDoesNotClearReplacementTask() async throws { + subject.availableXcodes = [ + AvailableXcode( + version: Version("0.0.0")!, + url: URL(string: "https://apple.com/xcode.xip")!, + filename: "mock.xip", + releaseDate: nil + ) + ] + Current.defaults.date = { _ in Date.mock() } + + let continuations = AppStateUpdateTestLockedBox<[CheckedContinuation]>([]) + Current.shell.xcodeSelectPrintPath = { + try await withCheckedThrowingContinuation { continuation in + continuations.withValue { $0.append(continuation) } + } + } + + subject.updateIfNeeded() + for _ in 0..<100 where continuations.read({ $0.count }) < 1 { + await Task.yield() + } + let firstTask = try XCTUnwrap(subject.updateTask) + XCTAssertEqual(continuations.read { $0.count }, 1) + + subject.updateIfNeeded() + for _ in 0..<100 where continuations.read({ $0.count }) < 2 { + await Task.yield() + } + let replacementTask = try XCTUnwrap(subject.updateTask) + XCTAssertEqual(continuations.read { $0.count }, 2) + + continuations.read { $0[0] }.resume(returning: (0, "/Applications/Xcode.app", "")) + await firstTask.value + XCTAssertNotNil(subject.updateTask) + + continuations.read { $0[1] }.resume(returning: (0, "/Applications/Xcode-Beta.app", "")) + await replacementTask.value + XCTAssertNil(subject.updateTask) + XCTAssertNil(subject.updateTaskID) + XCTAssertEqual(subject.selectedXcodePath, "/Applications/Xcode-Beta.app") + } + func testDoesNotReplaceInstallState() throws { subject.allXcodes = [ Xcode(version: Version("0.0.0")!, installState: .installing(.unarchiving), selected: false, icon: nil) ] - + subject.updateAllXcodes( availableXcodes: [ AvailableXcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil) - ], + ], installedXcodes: [ - ], + ], selectedXcodePath: nil ) - + XCTAssertEqual(subject.allXcodes[0].installState, .installing(.unarchiving)) } - + func testRemovesUninstalledVersion() throws { subject.allXcodes = [ Xcode(version: Version("0.0.0")!, installState: .installed(Path("/Applications/Xcode-0.0.0.app")!), selected: true, icon: NSImage(systemSymbolName: "app.fill", accessibilityDescription: nil)) ] - + subject.updateAllXcodes( availableXcodes: [ AvailableXcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil) - ], + ], installedXcodes: [ - ], + ], selectedXcodePath: nil ) - + XCTAssertEqual(subject.allXcodes[0].installState, .notInstalled) } - + func testDeterminesIfInstalledByBuildMetadataAlone() throws { Current.defaults.string = { key in if key == "dataSource" { - return "apple" + return "apple" } else { return nil } } - + subject.allXcodes = [ ] - + subject.updateAllXcodes( availableXcodes: [ // Note "GM" prerelease identifier AvailableXcode(version: Version("0.0.0-GM+ABC123")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil) - ], + ], installedXcodes: [ InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)! - ], + ], selectedXcodePath: nil ) - - XCTAssertEqual(subject.allXcodes[0].version, Version("0.0.0+ABC123")!) + + XCTAssertEqual(subject.allXcodes[0].version, Version("0.0.0+ABC123")!) XCTAssertEqual(subject.allXcodes[0].installState, .installed(Path("/Applications/Xcode-0.0.0.app")!)) XCTAssertEqual(subject.allXcodes[0].selected, false) } - + func testAdjustedVersionsAreUsedToLookupAvailableXcode() throws { Current.defaults.string = { key in if key == "dataSource" { - return "apple" + return "apple" } else { return nil } @@ -88,19 +151,19 @@ class AppStateUpdateTests: XCTestCase { subject.allXcodes = [ ] - + subject.updateAllXcodes( availableXcodes: [ // Note "GM" prerelease identifier AvailableXcode(version: Version("0.0.0-GM+ABC123")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil, sdks: .init(iOS: .init("14.3"))) - ], + ], installedXcodes: [ InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)! - ], + ], selectedXcodePath: nil ) - - XCTAssertEqual(subject.allXcodes[0].version, Version("0.0.0+ABC123")!) + + XCTAssertEqual(subject.allXcodes[0].version, Version("0.0.0+ABC123")!) XCTAssertEqual(subject.allXcodes[0].installState, .installed(Path("/Applications/Xcode-0.0.0.app")!)) XCTAssertEqual(subject.allXcodes[0].selected, false) // XCModel types aren't equatable, so just check for non-nil for now @@ -110,26 +173,26 @@ class AppStateUpdateTests: XCTestCase { func testAppendingInstalledVersionThatIsNotAvailable() { subject.allXcodes = [ ] - + subject.updateAllXcodes( availableXcodes: [ AvailableXcode(version: Version("1.2.3")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil, sdks: .init(iOS: .init("14.3"))) - ], + ], installedXcodes: [ // There's a version installed which for some reason isn't listed online InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)! - ], + ], selectedXcodePath: nil ) - - XCTAssertEqual(subject.allXcodes.map(\.version), [Version("1.2.3")!, Version("0.0.0+ABC123")!]) + + XCTAssertEqual(subject.allXcodes.map(\.version), [Version("1.2.3")!, Version("0.0.0+ABC123")!]) } - - + + func testIdenticalBuilds_KeepsReleaseVersion_WithNeitherInstalled() { Current.defaults.string = { key in if key == "dataSource" { - return "xcodeReleases" + return "xcodeReleases" } else { return nil } @@ -137,25 +200,25 @@ class AppStateUpdateTests: XCTestCase { subject.allXcodes = [ ] - + subject.updateAllXcodes( availableXcodes: [ AvailableXcode(version: Version("12.4.0+12D4e")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), AvailableXcode(version: Version("12.4.0-RC+12D4e")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), - ], + ], installedXcodes: [ - ], + ], selectedXcodePath: nil ) - + XCTAssertEqual(subject.allXcodes.map(\.version), [Version("12.4.0+12D4e")!]) XCTAssertEqual(subject.allXcodes.map(\.identicalBuilds), [[XcodeID(version: Version("12.4.0+12D4e")!), XcodeID(version: Version("12.4.0-RC+12D4e")!)]]) } - + func testIdenticalBuilds_DoNotMergeReleaseVersions() { Current.defaults.string = { key in if key == "dataSource" { - return "xcodeReleases" + return "xcodeReleases" } else { return nil } @@ -163,25 +226,25 @@ class AppStateUpdateTests: XCTestCase { subject.allXcodes = [ ] - + subject.updateAllXcodes( availableXcodes: [ AvailableXcode(version: Version("3.2.3+10M2262")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), AvailableXcode(version: Version("3.2.3+10M2262")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), - ], + ], installedXcodes: [ - ], + ], selectedXcodePath: nil ) - + XCTAssertEqual(subject.allXcodes.map(\.version), [Version("3.2.3+10M2262")!, Version("3.2.3+10M2262")!]) XCTAssertEqual(subject.allXcodes.map(\.identicalBuilds), [[], []]) } - + func testIdenticalBuilds_KeepsReleaseVersion_WithPrereleaseInstalled() { Current.defaults.string = { key in if key == "dataSource" { - return "xcodeReleases" + return "xcodeReleases" } else { return nil } @@ -189,7 +252,7 @@ class AppStateUpdateTests: XCTestCase { subject.allXcodes = [ ] - + Current.files.contentsAtPath = { path in if path.contains("Info.plist") { return """ @@ -221,26 +284,26 @@ class AppStateUpdateTests: XCTestCase { return nil } } - + subject.updateAllXcodes( availableXcodes: [ AvailableXcode(version: Version("12.4.0+12D4e")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), AvailableXcode(version: Version("12.4.0-RC+12D4e")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), - ], + ], installedXcodes: [ InstalledXcode(path: Path("/Applications/Xcode-12.4.0-RC.app")!)! - ], + ], selectedXcodePath: nil ) - + XCTAssertEqual(subject.allXcodes.map(\.version), [Version("12.4.0+12D4e")!]) XCTAssertEqual(subject.allXcodes.map(\.identicalBuilds), [[XcodeID(version: Version("12.4.0+12D4e")!), XcodeID(version: Version("12.4.0-RC+12D4e")!)]]) } - + func testIdenticalBuilds_AppleDataSource_DoNotMergeVersionsWithoutBuildIdentifiers() { Current.defaults.string = { key in if key == "dataSource" { - return "apple" + return "apple" } else { return nil } @@ -248,17 +311,17 @@ class AppStateUpdateTests: XCTestCase { subject.allXcodes = [ ] - + subject.updateAllXcodes( availableXcodes: [ AvailableXcode(version: Version("12.4.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), AvailableXcode(version: Version("12.3.0-RC")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), - ], + ], installedXcodes: [ - ], + ], selectedXcodePath: nil ) - + XCTAssertEqual(subject.allXcodes.map(\.version), [Version("12.4.0")!, Version("12.3.0-RC")!]) XCTAssertEqual(subject.allXcodes.map(\.identicalBuilds), [[], []]) } diff --git a/XcodesTests/Environment+Mock.swift b/XcodesTests/Environment+Mock.swift index f030d796..6f351ce1 100644 --- a/XcodesTests/Environment+Mock.swift +++ b/XcodesTests/Environment+Mock.swift @@ -1,113 +1,131 @@ -import Combine import Foundation +import XcodesKit @testable import Xcodes +func syncXcodesKitMocks() { + configureXcodesKitFileContents { Xcodes.Current.files.contents(atPath: $0) } + configureXcodesKitArchs { _ in Shell.processOutputMock } +} + extension Xcodes.Environment { - static var mock = Xcodes.Environment( - shell: .mock, - files: .mock, - network: .mock, - keychain: .mock, - defaults: .mock, - date: Date.mock, - helper: .mock - ) + static var mock: Xcodes.Environment { + Xcodes.Environment( + shell: .mock, + files: .mock, + network: .mock, + keychain: .mock, + defaults: .mock, + date: Date.mock, + helper: .mock + ) + } } extension Shell { - static var processOutputMock: ProcessOutput = (0, "", "") + static let processOutputMock: ProcessOutput = (0, "", "") - static var mock = Shell( - unxip: { _ in return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, - spctlAssess: { _ in return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, - codesignVerify: { _ in return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, - buildVersion: { return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, - xcodeBuildVersion: { _ in return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, - getUserCacheDir: { return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, - touchInstallCheck: { _, _, _ in return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, - xcodeSelectPrintPath: { return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() } - ) + static var mock: Shell { + Shell( + unxip: { _ in Shell.processOutputMock }, + spctlAssess: { _ in Shell.processOutputMock }, + codesignVerify: { _ in Shell.processOutputMock }, + buildVersion: { Shell.processOutputMock }, + xcodeBuildVersion: { _ in Shell.processOutputMock }, + archs: { _ in Shell.processOutputMock }, + getUserCacheDir: { Shell.processOutputMock }, + touchInstallCheck: { _, _, _ in Shell.processOutputMock }, + xcodeSelectPrintPath: { Shell.processOutputMock } + ) + } } extension Files { - static var mock = Files( - fileExistsAtPath: { _ in return true }, - moveItem: { _, _ in return }, - contentsAtPath: { path in - if path.contains("Info.plist") { - let url = Bundle.xcodesTests.url(forResource: "Stub-0.0.0.Info", withExtension: "plist")! - return try? Data(contentsOf: url) - } - else if path.contains("version.plist") { - let url = Bundle.xcodesTests.url(forResource: "Stub-version", withExtension: "plist")! - return try? Data(contentsOf: url) - } - else { - return nil - } - }, - removeItem: { _ in }, - trashItem: { _ in return URL(fileURLWithPath: "\(NSHomeDirectory())/.Trash") }, - createFile: { _, _, _ in return true }, - createDirectory: { _, _, _ in }, - installedXcodes: { _ in [] } - ) + static var mock: Files { + Files( + fileExistsAtPath: { _ in return true }, + moveItem: { _, _ in return }, + contentsAtPath: { path in + if path.contains("Info.plist") { + let url = Bundle.xcodesTests.url(forResource: "Stub-0.0.0.Info", withExtension: "plist")! + return try? Data(contentsOf: url) + } + else if path.contains("version.plist") { + let url = Bundle.xcodesTests.url(forResource: "Stub-version", withExtension: "plist")! + return try? Data(contentsOf: url) + } + else { + return nil + } + }, + removeItem: { _ in }, + trashItem: { _ in return URL(fileURLWithPath: "\(NSHomeDirectory())/.Trash") }, + createFile: { _, _, _ in return true }, + createDirectory: { _, _, _ in }, + installedXcodes: { _ in [] } + ) + } } extension Network { - static var mock = Network( - dataTask: { url in - Just((data: Data(), response: HTTPURLResponse(url: url.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! as URLResponse)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - }, - downloadTask: { url, saveLocation, _ in - return ( - Progress(), - Just((saveLocation, HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - ) - }, - validateSession: { - return Just(()) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - ) + static var mock: Network { + Network( + session: URLSession.shared, + loadData: { request in + (Data(), HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! as URLResponse) + }, + downloadTaskAsync: { url, saveLocation, _ in + ( + Progress(), + Task { + (saveLocation, HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + ) + }, + validateSessionAsync: { }, + signout: { } + ) + } } extension Keychain { - static var mock = Keychain( - getString: { _ in return nil }, - set: { _, _ in }, - remove: { _ in } - ) + static var mock: Keychain { + Keychain( + getString: { _ in return nil }, + set: { _, _ in }, + remove: { _ in } + ) + } } extension Defaults { - static var mock = Defaults( - string: { _ in nil }, - date: { _ in nil }, - setDate: { _, _ in }, - set: { _, _ in }, - removeObject: { _ in } - ) + static var mock: Defaults { + Defaults( + string: { _ in nil }, + date: { _ in nil }, + setDate: { _, _ in }, + set: { _, _ in }, + removeObject: { _ in }, + get: { _ in nil }, + bool: { _ in nil } + ) + } } extension Date { - static var mock = { Date(timeIntervalSince1970: 1609479735) } + static let mock: @Sendable () -> Date = { Date(timeIntervalSince1970: 1609479735) } } extension Helper { - static var mock = Helper( - install: { }, - checkIfLatestHelperIsInstalled: { Just(false).eraseToAnyPublisher() }, - getVersion: { Just("").setFailureType(to: Error.self).eraseToAnyPublisher() }, - switchXcodePath: { _ in Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() }, - devToolsSecurityEnable: { Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() }, - addStaffToDevelopersGroup: { Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() }, - acceptXcodeLicense: { _ in Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() }, - runFirstLaunch: { _ in Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() } - ) + static var mock: Helper { + Helper( + install: { }, + checkIfLatestHelperIsInstalledAsync: { false }, + getVersionAsync: { "" }, + switchXcodePathAsync: { _ in }, + devToolsSecurityEnableAsync: { }, + addStaffToDevelopersGroupAsync: { }, + acceptXcodeLicenseAsync: { _ in }, + runFirstLaunchAsync: { _ in } + ) + } } diff --git a/com.xcodesorg.xcodesapp.Helper/Logger.swift b/com.xcodesorg.xcodesapp.Helper/Logger.swift index 3b39654d..d45482a4 100644 --- a/com.xcodesorg.xcodesapp.Helper/Logger.swift +++ b/com.xcodesorg.xcodesapp.Helper/Logger.swift @@ -2,7 +2,7 @@ import Foundation import os.log extension Logger { - private static var subsystem = Bundle.main.bundleIdentifier! + private static let subsystem = Bundle.main.bundleIdentifier! static let connectionVerifier = Logger(subsystem: subsystem, category: "connectionVerifier") static let xpcDelegate = Logger(subsystem: subsystem, category: "xpcDelegate") diff --git a/com.xcodesorg.xcodesapp.Helper/XPCDelegate.swift b/com.xcodesorg.xcodesapp.Helper/XPCDelegate.swift index 86a33301..eb9fe069 100644 --- a/com.xcodesorg.xcodesapp.Helper/XPCDelegate.swift +++ b/com.xcodesorg.xcodesapp.Helper/XPCDelegate.swift @@ -1,7 +1,7 @@ import Foundation import os.log -class XPCDelegate: NSObject, NSXPCListenerDelegate, HelperXPCProtocol { +final class XPCDelegate: NSObject, NSXPCListenerDelegate, HelperXPCProtocol { // MARK: - NSXPCListenerDelegate