diff --git a/HelperXPCShared/HelperXPCShared.swift b/HelperXPCShared/HelperXPCShared.swift index 465fea4..d3825af 100644 --- a/HelperXPCShared/HelperXPCShared.swift +++ b/HelperXPCShared/HelperXPCShared.swift @@ -8,4 +8,8 @@ let subjectOrganizationalUnit = Bundle.main.infoDictionary!["CODE_SIGNING_SUBJEC protocol HelperXPCProtocol { func getVersion(completion: @escaping (String) -> Void) func xcodeSelect(absolutePath: String, completion: @escaping (Error?) -> Void) + func devToolsSecurityEnable(completion: @escaping (Error?) -> Void) + func addStaffToDevelopersGroup(completion: @escaping (Error?) -> Void) + func acceptXcodeLicense(absoluteXcodePath: String, completion: @escaping (Error?) -> Void) + func runFirstLaunch(absoluteXcodePath: String, completion: @escaping (Error?) -> Void) } diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index d34ceb7..5ef549e 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -13,8 +13,8 @@ CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; }; CA44901F2463AD34003D8213 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA44901E2463AD34003D8213 /* Tag.swift */; }; CA452BB0259FD9770072DFA4 /* ProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA452BAF259FD9770072DFA4 /* ProgressIndicator.swift */; }; - CA452BC0259FDDFE0072DFA4 /* Stub-0.0.0.plist in Resources */ = {isa = PBXBuildFile; fileRef = CA452BBE259FDDFE0072DFA4 /* Stub-0.0.0.plist */; }; CA452BC1259FDDFE0072DFA4 /* Stub-version.plist in Resources */ = {isa = PBXBuildFile; fileRef = CA452BBF259FDDFE0072DFA4 /* Stub-version.plist */; }; + CA452BEB25A236500072DFA4 /* Stub-0.0.0.Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = CA452BEA25A236500072DFA4 /* Stub-0.0.0.Info.plist */; }; CA5D781E257365D6008EDE9D /* PinCodeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */; }; CA61A6E0259835580008926E /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA61A6DF259835580008926E /* Xcode.swift */; }; CA735109257BF96D00EA9CF8 /* AttributedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA735108257BF96D00EA9CF8 /* AttributedText.swift */; }; @@ -47,6 +47,7 @@ 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 */; }; @@ -65,6 +66,7 @@ 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 */; }; CAC281C8259F97E100B8AB0B /* InstallationStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC281C7259F97E100B8AB0B /* InstallationStepView.swift */; }; CAC281CD259F97FA00B8AB0B /* ObservingProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC281CC259F97FA00B8AB0B /* ObservingProgressIndicator.swift */; }; CAC281DA259F985100B8AB0B /* InstallationStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC281D9259F985100B8AB0B /* InstallationStep.swift */; }; @@ -74,10 +76,10 @@ CAD2E7A42449574E00113D76 /* XcodeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD2E7A32449574E00113D76 /* XcodeListView.swift */; }; CAD2E7A62449575000113D76 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAD2E7A52449575000113D76 /* Assets.xcassets */; }; CAD2E7A92449575000113D76 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAD2E7A82449575000113D76 /* Preview Assets.xcassets */; }; - CAD2E7B82449575100113D76 /* XcodesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD2E7B72449575100113D76 /* XcodesTests.swift */; }; CAE4247F259A666100B8B246 /* MainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE4247E259A666100B8B246 /* MainWindow.swift */; }; CAE42487259A68A300B8B246 /* XcodeListCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE42486259A68A300B8B246 /* XcodeListCategory.swift */; }; CAE4248C259A68B800B8B246 /* Optional+IsNotNil.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE4248B259A68B800B8B246 /* Optional+IsNotNil.swift */; }; + CAE424B4259A764700B8B246 /* AppState+Install.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE424B3259A764700B8B246 /* AppState+Install.swift */; }; CAFBDB912598FE80003DCC5A /* SelectedXcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDB902598FE80003DCC5A /* SelectedXcode.swift */; }; CAFBDB952598FE96003DCC5A /* FocusedValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDB942598FE96003DCC5A /* FocusedValues.swift */; }; CAFBDC4E2599B33D003DCC5A /* MainToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDC4D2599B33D003DCC5A /* MainToolbar.swift */; }; @@ -133,8 +135,8 @@ CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreButtonStyle.swift; sourceTree = ""; }; CA44901E2463AD34003D8213 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; CA452BAF259FD9770072DFA4 /* ProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = ""; }; - CA452BBE259FDDFE0072DFA4 /* Stub-0.0.0.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Stub-0.0.0.plist"; 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 = ""; }; @@ -180,6 +182,7 @@ 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 = ""; }; @@ -204,11 +207,14 @@ CAD2E7AD2449575000113D76 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; CAD2E7AE2449575000113D76 /* Xcodes.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Xcodes.entitlements; sourceTree = ""; }; CAD2E7B32449575100113D76 /* XcodesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = XcodesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - CAD2E7B72449575100113D76 /* XcodesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodesTests.swift; sourceTree = ""; }; + CAD2E7B72449575100113D76 /* AppStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTests.swift; sourceTree = ""; }; CAD2E7B92449575100113D76 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; CAE4247E259A666100B8B246 /* MainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = ""; }; CAE42486259A68A300B8B246 /* XcodeListCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeListCategory.swift; sourceTree = ""; }; CAE4248B259A68B800B8B246 /* Optional+IsNotNil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+IsNotNil.swift"; sourceTree = ""; }; + CAE424B3259A764700B8B246 /* AppState+Install.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AppState+Install.swift"; sourceTree = ""; }; + CAFBC3FF259AC17F00E2A3D8 /* InstallationStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallationStepView.swift; sourceTree = ""; }; + CAFBC421259ACF8000E2A3D8 /* ObservingProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservingProgressView.swift; sourceTree = ""; }; CAFBDB902598FE80003DCC5A /* SelectedXcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedXcode.swift; sourceTree = ""; }; CAFBDB942598FE96003DCC5A /* FocusedValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedValues.swift; sourceTree = ""; }; CAFBDBA525990C76003DCC5A /* SimpleXPCApp.LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = SimpleXPCApp.LICENSE; sourceTree = ""; }; @@ -216,6 +222,7 @@ CAFBDC67259A308B003DCC5A /* InfoPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoPane.swift; sourceTree = ""; }; CAFBDC6B259A3098003DCC5A /* View+Conditional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Conditional.swift"; sourceTree = ""; }; CAFFFED7259CDA5000903F81 /* XcodeListViewRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeListViewRow.swift; sourceTree = ""; }; + CAFFFEEE259CEAC400903F81 /* RingProgressViewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RingProgressViewStyle.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -245,6 +252,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + CAC28188259EE27200B8AB0B /* CombineExpectations in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -264,12 +272,21 @@ CA452BBD259FDDBF0072DFA4 /* Fixtures */ = { isa = PBXGroup; children = ( - CA452BBE259FDDFE0072DFA4 /* Stub-0.0.0.plist */, + CA452BEA25A236500072DFA4 /* Stub-0.0.0.Info.plist */, CA452BBF259FDDFE0072DFA4 /* Stub-version.plist */, ); path = Fixtures; sourceTree = ""; }; + CA452BE025A2354D0072DFA4 /* Recovered References */ = { + isa = PBXGroup; + children = ( + CAFBC421259ACF8000E2A3D8 /* ObservingProgressView.swift */, + CAFFFEEE259CEAC400903F81 /* RingProgressViewStyle.swift */, + ); + name = "Recovered References"; + sourceTree = ""; + }; CA538A12255A4F7C00E64DD7 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -331,6 +348,7 @@ children = ( CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */, CAFBDC67259A308B003DCC5A /* InfoPane.swift */, + CAFBC3FF259AC17F00E2A3D8 /* InstallationStepView.swift */, CAFBDC4D2599B33D003DCC5A /* MainToolbar.swift */, CA44901E2463AD34003D8213 /* Tag.swift */, CAE42486259A68A300B8B246 /* XcodeListCategory.swift */, @@ -345,6 +363,7 @@ isa = PBXGroup; children = ( CA378F982466567600A58CE0 /* AppState.swift */, + CAE424B3259A764700B8B246 /* AppState+Install.swift */, CABFA9A72592EEE900380FEE /* AppState+Update.swift */, CA9FF88025955C7000E47BAF /* AvailableXcode.swift */, CABFAA2B2592FBFC00380FEE /* Configure.swift */, @@ -364,6 +383,7 @@ 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 */, @@ -421,6 +441,7 @@ CA9FF8CD25959A7600E47BAF /* HelperXPCShared */, CAD2E79F2449574E00113D76 /* Products */, CA538A12255A4F7C00E64DD7 /* Frameworks */, + CA452BE025A2354D0072DFA4 /* Recovered References */, ); sourceTree = ""; }; @@ -460,7 +481,7 @@ CA452BBD259FDDBF0072DFA4 /* Fixtures */, CAC281E1259FA44600B8AB0B /* Bundle+XcodesTests.swift */, CAC281E6259FA45A00B8AB0B /* Environment+Mock.swift */, - CAD2E7B72449575100113D76 /* XcodesTests.swift */, + CAD2E7B72449575100113D76 /* AppStateTests.swift */, CAD2E7B92449575100113D76 /* Info.plist */, ); path = XcodesTests; @@ -530,6 +551,9 @@ CAD2E7B52449575100113D76 /* PBXTargetDependency */, ); name = XcodesTests; + packageProductDependencies = ( + CAC28187259EE27200B8AB0B /* CombineExpectations */, + ); productName = XcodesMacTests; productReference = CAD2E7B32449575100113D76 /* XcodesTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -573,6 +597,7 @@ CABFA9FB2592F13300380FEE /* XCRemoteSwiftPackageReference "LegibleError" */, CA9FF86B25951C6E00E47BAF /* XCRemoteSwiftPackageReference "data" */, CAA858CB25A3D8BC00ACF8C0 /* XCRemoteSwiftPackageReference "ErrorHandling" */, + CAC28186259EE27200B8AB0B /* XCRemoteSwiftPackageReference "CombineExpectations" */, ); productRefGroup = CAD2E79F2449574E00113D76 /* Products */; projectDirPath = ""; @@ -600,8 +625,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - CA452BC0259FDDFE0072DFA4 /* Stub-0.0.0.plist in Resources */, CA452BC1259FDDFE0072DFA4 /* Stub-version.plist in Resources */, + CA452BEB25A236500072DFA4 /* Stub-0.0.0.Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -686,12 +711,14 @@ CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */, CAFBDB952598FE96003DCC5A /* FocusedValues.swift in Sources */, CAC281CD259F97FA00B8AB0B /* ObservingProgressIndicator.swift in Sources */, + CABFA9C22592EEEA00380FEE /* Publisher+Resumable.swift in Sources */, CAFBDC68259A308B003DCC5A /* InfoPane.swift in Sources */, CAA1CB4D255A5CFD003FD669 /* SignInPhoneListView.swift in Sources */, CAFBDC6C259A3098003DCC5A /* View+Conditional.swift in Sources */, CABFA9CF2592EEEA00380FEE /* Process.swift in Sources */, CAFFFED8259CDA5000903F81 /* XcodeListViewRow.swift in Sources */, CABFA9C72592EEEA00380FEE /* Entry+.swift in Sources */, + CAE424B4259A764700B8B246 /* AppState+Install.swift in Sources */, CAE42487259A68A300B8B246 /* XcodeListCategory.swift in Sources */, CABFAA2C2592FBFC00380FEE /* SettingsView.swift in Sources */, CA9FF87B2595293E00E47BAF /* DataSource.swift in Sources */, @@ -710,7 +737,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - CAD2E7B82449575100113D76 /* XcodesTests.swift in Sources */, CAC281E7259FA45A00B8AB0B /* Environment+Mock.swift in Sources */, CAC281E2259FA44600B8AB0B /* Bundle+XcodesTests.swift in Sources */, ); @@ -1214,6 +1240,14 @@ minimumVersion = 1.0.1; }; }; + CAC28186259EE27200B8AB0B /* XCRemoteSwiftPackageReference "CombineExpectations" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/groue/CombineExpectations"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 0.6.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1256,6 +1290,11 @@ package = CABFA9FB2592F13300380FEE /* XCRemoteSwiftPackageReference "LegibleError" */; productName = LegibleError; }; + CAC28187259EE27200B8AB0B /* CombineExpectations */ = { + isa = XCSwiftPackageProductDependency; + package = CAC28186259EE27200B8AB0B /* XCRemoteSwiftPackageReference "CombineExpectations" */; + productName = CombineExpectations; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = CAD2E7962449574E00113D76 /* Project object */; diff --git a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index fdfa23b..598952c 100644 --- a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { "object": { "pins": [ + { + "package": "CombineExpectations", + "repositoryURL": "https://github.com/groue/CombineExpectations", + "state": { + "branch": null, + "revision": "989a92221899929ab8347a5878aa2b16db8b81ca", + "version": "0.6.0" + } + }, { "package": "XcodeReleases", "repositoryURL": "https://github.com/xcodereleases/data", diff --git a/Xcodes/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift b/Xcodes/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift index c6f33aa..01b98fb 100644 --- a/Xcodes/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift +++ b/Xcodes/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift @@ -1,6 +1,6 @@ import Foundation -extension URL { +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")! @@ -10,7 +10,7 @@ extension URL { static let olympusSession = URL(string: "https://appstoreconnect.apple.com/olympus/v1/session")! } -extension URLRequest { +public extension URLRequest { static var itcServiceKey: URLRequest { return URLRequest(url: .itcServiceKey) } diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift new file mode 100644 index 0000000..d8b934f --- /dev/null +++ b/Xcodes/Backend/AppState+Install.swift @@ -0,0 +1,439 @@ +import Combine +import Foundation +import Path +import AppleAPI +import Version +import LegibleError + +/// Downloads and installs Xcodes +extension AppState { + 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 { + getXcodeArchive(installationType, downloader: downloader) + .flatMap { xcode, url -> AnyPublisher in + self.installArchivedXcode(xcode, at: url) + } + .catch { error -> AnyPublisher in + 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 { + Current.logging.log(error.legibleLocalizedDescription) + Current.logging.log("Removing damaged XIP and re-attempting installation.\n") + 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() + } + } + .handleEvents(receiveOutput: { installedXcode in + DispatchQueue.main.async { + guard let index = self.allXcodes.firstIndex(where: { $0.version == installedXcode.version || $0.version.isEquivalentForDeterminingIfInstalled(toInstalled: installedXcode.version) }) else { return } + self.allXcodes[index].installState = .installed + } + }) + .eraseToAnyPublisher() + } + + private func getXcodeArchive(_ installationType: InstallationType, downloader: Downloader) -> AnyPublisher<(AvailableXcode, URL), Error> { + switch installationType { + case .version(let availableXcode): + if let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: availableXcode.version) }) { + return Fail(error: InstallationError.versionAlreadyInstalled(installedXcode)) + .eraseToAnyPublisher() + } + return downloadXcode(availableXcode: availableXcode, downloader: downloader) + } + } + + private func downloadXcode(availableXcode: AvailableXcode, downloader: Downloader) -> AnyPublisher<(AvailableXcode, URL), Error> { + downloadOrUseExistingArchive(for: availableXcode, downloader: downloader, progressChanged: { [unowned self] progress in + DispatchQueue.main.async { + self.setInstallationStep(of: availableXcode.version, to: .downloading(progress: progress)) + } + }) + .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 +// } + if Current.files.fileExistsAtPath(expectedArchivePath.string) {//}, aria2DownloadIsIncomplete == false { + Current.logging.log("(1/6) Found existing archive that will be used for installation at \(expectedArchivePath).") + return Just(expectedArchivePath.url) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + else { + let destination = Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).\(availableXcode.filename.suffix(fromLast: "."))" + switch downloader { +// case .aria2(let aria2Path): +// return downloadXcodeWithAria2( +// availableXcode, +// to: destination, +// aria2Path: aria2Path, +// progressChanged: progressChanged +// ) + case .urlSession: + return downloadXcodeWithURLSession( + availableXcode, + to: destination, + progressChanged: progressChanged + ) + } + } + } + +// public func downloadXcodeWithAria2(_ availableXcode: AvailableXcode, to destination: Path, aria2Path: Path, progressChanged: @escaping (Progress) -> Void) -> Promise { +// let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies(for: availableXcode.url) ?? [] +// +// return attemptRetryableTask(maximumRetryCount: 3) { +// let (progress, promise) = Current.shell.downloadWithAria2( +// aria2Path, +// availableXcode.url, +// destination, +// cookies +// ) +// progressChanged(progress) +// return promise.map { _ in destination.url } +// } +// } + + 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() + } + + public func installArchivedXcode(_ availableXcode: AvailableXcode, at archiveURL: URL) -> AnyPublisher { + do { + let destinationURL = Path.root.join("Applications").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.enableDeveloperMode() + .map { installedXcode } + .eraseToAnyPublisher() + } + .flatMap { installedXcode -> AnyPublisher in + self.approveLicense(for: installedXcode) + .map { installedXcode } + .eraseToAnyPublisher() + } + .flatMap { installedXcode -> AnyPublisher in + self.installComponents(for: installedXcode) + .map { installedXcode } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + case "dmg": + throw InstallationError.unsupportedFileFormat(extension: "dmg") + default: + throw InstallationError.unsupportedFileFormat(extension: archiveURL.pathExtension) + } + } catch { + return Fail(error: error) + .eraseToAnyPublisher() + } + } + + func unarchiveAndMoveXIP(availableXcode: AvailableXcode, at source: URL, to destination: URL) -> AnyPublisher { + self.setInstallationStep(of: availableXcode.version, to: .unarchiving) + + return Current.shell.unxip(source) + .catch { error -> AnyPublisher in + if let executionError = error as? ProcessExecutionError, + executionError.standardError.contains("damaged and can’t be expanded") { + return Fail(error: InstallationError.damagedXIP(url: source)) + .eraseToAnyPublisher() + } + return Fail(error: error) + .eraseToAnyPublisher() + } + .tryMap { output -> URL in + self.setInstallationStep(of: availableXcode.version, to: .moving(destination: destination.path)) + + 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) + } + + return destination + } + .handleEvents(receiveCancel: { + if Current.files.fileExists(atPath: source.path) { + try? Current.files.removeItem(source) + } + if Current.files.fileExists(atPath: destination.path) { + try? Current.files.removeItem(destination) + } + }) + .eraseToAnyPublisher() + } + + 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() + } + + 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") + } + 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() + } + .eraseToAnyPublisher() + } + + public struct CertificateInfo { + public var authority: [String] + public var teamIdentifier: String + public var bundleIdentifier: String + } + + public func parseCertificateInfo(_ rawInfo: String) -> CertificateInfo { + var info = CertificateInfo(authority: [], teamIdentifier: "", bundleIdentifier: "") + + 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] + } + } + + return info + } + + func enableDeveloperMode() -> AnyPublisher { + if helperInstallState == .notInstalled { + installHelper() + } + + return Current.helper.devToolsSecurityEnable() + .flatMap { + Current.helper.addStaffToDevelopersGroup() + } + .eraseToAnyPublisher() + } + + func approveLicense(for xcode: InstalledXcode) -> AnyPublisher { + if helperInstallState == .notInstalled { + installHelper() + } + + return Current.helper.acceptXcodeLicense(xcode.path.string) + .eraseToAnyPublisher() + } + + func installComponents(for xcode: InstalledXcode) -> AnyPublisher { + if helperInstallState == .notInstalled { + installHelper() + } + + return 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() + } + + func setInstallationStep(of version: Version, to step: InstallationStep) { + DispatchQueue.main.async { + guard let index = self.allXcodes.firstIndex(where: { $0.version.buildMetadataIdentifiers == version.buildMetadataIdentifiers || $0.version.isEquivalentForDeterminingIfInstalled(toInstalled: version) }) else { return } + self.allXcodes[index].installState = .installing(step) + } + } +} + +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) + } + } +} + +public enum InstallationError: LocalizedError, Equatable { + case damagedXIP(url: URL) + case failedToMoveXcodeToApplications + case failedSecurityAssessment(xcode: InstalledXcode, output: String) + case codesignVerifyFailed(output: String) + case unexpectedCodeSigningIdentity(identifier: String, certificateAuthority: [String]) + case unsupportedFileFormat(extension: String) + case missingSudoerPassword + case unavailableVersion(Version) + case noNonPrereleaseVersionAvailable + case noPrereleaseVersionAvailable + case missingUsernameOrPassword + case versionAlreadyInstalled(InstalledXcode) + case invalidVersion(String) + case versionNotInstalled(Version) + + public var errorDescription: String? { + switch self { + case .damagedXIP(let url): + return "The archive \"\(url.lastPathComponent)\" is damaged and can't be expanded." + case .failedToMoveXcodeToApplications: + return "Failed to move Xcode to the /Applications directory." + case .failedSecurityAssessment(let xcode, let output): + return """ + Xcode \(xcode.version) failed its security assessment with the following output: + \(output) + It remains installed at \(xcode.path) if you wish to use it anyways. + """ + case .codesignVerifyFailed(let output): + return """ + The downloaded Xcode failed code signing verification with the following output: + \(output) + """ + case .unexpectedCodeSigningIdentity(let identity, let certificateAuthority): + return """ + The downloaded Xcode doesn't have the expected code signing identity. + Got: + \(identity) + \(certificateAuthority) + Expected: + \(XcodeTeamIdentifier) + \(XcodeCertificateAuthority) + """ + case .unsupportedFileFormat(let fileExtension): + return "xcodes doesn't (yet) support installing Xcode from the \(fileExtension) file format." + case .missingSudoerPassword: + return "Missing password. Please try again." + case let .unavailableVersion(version): + return "Could not find version \(version.xcodeDescription)." + case .noNonPrereleaseVersionAvailable: + return "No non-prerelease versions available." + case .noPrereleaseVersionAvailable: + return "No prerelease versions available." + case .missingUsernameOrPassword: + return "Missing username or a password. Please try again." + case let .versionAlreadyInstalled(installedXcode): + return "\(installedXcode.version.xcodeDescription) is already installed at \(installedXcode.path)" + case let .invalidVersion(version): + return "\(version) is not a valid version number." + case let .versionNotInstalled(version): + return "\(version.xcodeDescription) is not installed." + } + } +} + +public enum InstallationType { + case version(AvailableXcode) +} + +public enum Downloader { + case urlSession + // case aria2(Path) +} + +let XcodeTeamIdentifier = "59GAB85EFG" +let XcodeCertificateAuthority = ["Software Signing", "Apple Code Signing Certification Authority", "Apple Root CA"] diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 61f559e..05c40d1 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -9,9 +9,8 @@ import Version class AppState: ObservableObject { private let client = AppleAPI.Client() - private var cancellables = Set() - private var selectPublisher: AnyCancellable? - private var uninstallPublisher: AnyCancellable? + + // MARK: - Published Properties @Published var authenticationState: AuthenticationState = .unauthenticated @Published var availableXcodes: [AvailableXcode] = [] { @@ -19,7 +18,7 @@ class AppState: ObservableObject { updateAllXcodes(availableXcodes: newValue, selectedXcodePath: selectedXcodePath) } } - var allXcodes: [Xcode] = [] + @Published var allXcodes: [Xcode] = [] @Published var selectedXcodePath: String? { willSet { updateAllXcodes(availableXcodes: availableXcodes, selectedXcodePath: newValue) @@ -38,7 +37,17 @@ class AppState: ObservableObject { @Published var error: Error? @Published var authError: Error? + // MARK: - Publisher Cancellables + + private var cancellables = Set() + private var installationPublishers: [Version: AnyCancellable] = [:] + private var selectPublisher: AnyCancellable? + private var uninstallPublisher: AnyCancellable? + + // MARK: - Init + init() { + guard NSClassFromString("XCTestCase") == nil else { return } try? loadCachedAvailableXcodes() checkIfHelperIsInstalled() } @@ -206,7 +215,59 @@ class AppState: ObservableObject { // MARK: - Install func install(id: Xcode.ID) { - // TODO: + guard let availableXcode = availableXcodes.first(where: { $0.version == id }) else { return } + installationPublishers[id] = signInIfNeeded() + .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: return true + case .waitingForSecondFactor: return false + } + } + .prefix(1) + .tryMap { state in + if state == .unauthenticated { + throw AuthenticationError.invalidSession + } + 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) + .receive(on: DispatchQueue.main) + .map { _ in Void() } + .mapError { $0 as Error } + } + .flatMap { [unowned self] in + self.install(.version(availableXcode), downloader: .urlSession) + } + .sink( + receiveCompletion: { [unowned self] completion in + self.installationPublishers[id] = nil + if case let .failure(error) = completion { + self.error = error + if let index = self.allXcodes.firstIndex(where: { $0.id == id }) { + self.allXcodes[index].installState = .notInstalled + } + } + }, + receiveValue: { _ in } + ) + } + + func cancelInstall(id: Xcode.ID) { + installationPublishers[id] = nil + if let index = allXcodes.firstIndex(where: { $0.id == id }) { + allXcodes[index].installState = .notInstalled + } } // MARK: - Uninstall diff --git a/Xcodes/Backend/Environment.swift b/Xcodes/Backend/Environment.swift index a5b2a1d..0938368 100644 --- a/Xcodes/Backend/Environment.swift +++ b/Xcodes/Backend/Environment.swift @@ -102,7 +102,7 @@ public struct Network { } 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 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) } @@ -164,4 +164,8 @@ public struct Helper { 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 } diff --git a/Xcodes/Backend/HelperClient.swift b/Xcodes/Backend/HelperClient.swift index 5ab5c7b..cc8801f 100644 --- a/Xcodes/Backend/HelperClient.swift +++ b/Xcodes/Backend/HelperClient.swift @@ -4,7 +4,7 @@ import Foundation final class HelperClient { private var connection: NSXPCConnection? - func currentConnection() -> NSXPCConnection? { + private func currentConnection() -> NSXPCConnection? { guard self.connection == nil else { return self.connection } @@ -24,7 +24,7 @@ final class HelperClient { return self.connection } - func helper(errorSubject: PassthroughSubject) -> HelperXPCProtocol? { + private func helper(errorSubject: PassthroughSubject) -> HelperXPCProtocol? { guard let helper = self.currentConnection()?.remoteObjectProxyWithErrorHandler({ error in errorSubject.send(completion: .failure(error)) @@ -103,4 +103,124 @@ final class HelperClient { .map { $0.0 } .eraseToAnyPublisher() } + + func devToolsSecurityEnable() -> AnyPublisher { + let connectionErrorSubject = PassthroughSubject() + guard + let helper = self.helper(errorSubject: connectionErrorSubject) + else { + return Fail(error: NSError()) + .eraseToAnyPublisher() + } + + return Deferred { + Future { promise in + helper.devToolsSecurityEnable(completion: { (possibleError) in + if let error = possibleError { + promise(.failure(error)) + } else { + promise(.success(())) + } + }) + } + } + // Take values, but fail when connectionErrorSubject fails + .zip( + connectionErrorSubject + .prepend("") + .map { _ in Void() } + ) + .map { $0.0 } + .eraseToAnyPublisher() + } + + func addStaffToDevelopersGroup() -> AnyPublisher { + let connectionErrorSubject = PassthroughSubject() + guard + let helper = self.helper(errorSubject: connectionErrorSubject) + else { + return Fail(error: NSError()) + .eraseToAnyPublisher() + } + + return Deferred { + Future { promise in + helper.addStaffToDevelopersGroup(completion: { (possibleError) in + if let error = possibleError { + promise(.failure(error)) + } else { + promise(.success(())) + } + }) + } + } + // Take values, but fail when connectionErrorSubject fails + .zip( + connectionErrorSubject + .prepend("") + .map { _ in Void() } + ) + .map { $0.0 } + .eraseToAnyPublisher() + } + + func acceptXcodeLicense(absoluteXcodePath: String) -> AnyPublisher { + let connectionErrorSubject = PassthroughSubject() + guard + let helper = self.helper(errorSubject: connectionErrorSubject) + else { + return Fail(error: NSError()) + .eraseToAnyPublisher() + } + + return Deferred { + Future { promise in + helper.acceptXcodeLicense(absoluteXcodePath: absoluteXcodePath, completion: { (possibleError) in + if let error = possibleError { + promise(.failure(error)) + } else { + promise(.success(())) + } + }) + } + } + // Take values, but fail when connectionErrorSubject fails + .zip( + connectionErrorSubject + .prepend("") + .map { _ in Void() } + ) + .map { $0.0 } + .eraseToAnyPublisher() + } + + func runFirstLaunch(absoluteXcodePath: String) -> AnyPublisher { + let connectionErrorSubject = PassthroughSubject() + guard + let helper = self.helper(errorSubject: connectionErrorSubject) + else { + return Fail(error: NSError()) + .eraseToAnyPublisher() + } + + return Deferred { + Future { promise in + helper.runFirstLaunch(absoluteXcodePath: absoluteXcodePath, completion: { (possibleError) in + if let error = possibleError { + promise(.failure(error)) + } else { + promise(.success(())) + } + }) + } + } + // Take values, but fail when connectionErrorSubject fails + .zip( + connectionErrorSubject + .prepend("") + .map { _ in Void() } + ) + .map { $0.0 } + .eraseToAnyPublisher() + } } diff --git a/Xcodes/Backend/Process.swift b/Xcodes/Backend/Process.swift index f09bd04..77a2782 100644 --- a/Xcodes/Backend/Process.swift +++ b/Xcodes/Backend/Process.swift @@ -4,7 +4,7 @@ import Path public typealias ProcessOutput = (status: Int32, out: String, err: String) -extension Process { +extension Process { @discardableResult static func run(_ executable: Path, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) -> AnyPublisher { return run(executable.url, workingDirectory: workingDirectory, input: input, arguments) diff --git a/Xcodes/Backend/Publisher+Resumable.swift b/Xcodes/Backend/Publisher+Resumable.swift new file mode 100644 index 0000000..f7eee9b --- /dev/null +++ b/Xcodes/Backend/Publisher+Resumable.swift @@ -0,0 +1,44 @@ +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/URLSession+DownloadTaskPublisher.swift b/Xcodes/Backend/URLSession+DownloadTaskPublisher.swift index d721712..d287b4d 100644 --- a/Xcodes/Backend/URLSession+DownloadTaskPublisher.swift +++ b/Xcodes/Backend/URLSession+DownloadTaskPublisher.swift @@ -17,8 +17,9 @@ extension URLSession { 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! immediately. + // 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 @@ -35,8 +36,7 @@ extension URLSession { fatalError("Expecting either a temporary URL and a response, or an error, but got neither.") } } - - let task: URLSessionDownloadTask + if let resumeData = resumeData { task = self.downloadTask(withResumeData: resumeData, completionHandler: completionHandler) } @@ -46,6 +46,7 @@ extension URLSession { progress = task.progress task.resume() } + .handleEvents(receiveCancel: task.cancel) .eraseToAnyPublisher() return (progress, promise) diff --git a/Xcodes/Backend/Xcode.swift b/Xcodes/Backend/Xcode.swift index aa422a0..0d5baf8 100644 --- a/Xcodes/Backend/Xcode.swift +++ b/Xcodes/Backend/Xcode.swift @@ -6,7 +6,7 @@ import struct XCModel.Compilers struct Xcode: Identifiable, CustomStringConvertible { let version: Version - let installState: XcodeInstallState + var installState: XcodeInstallState let selected: Bool let path: String? let icon: NSImage? diff --git a/Xcodes/Frontend/XcodeList/InfoPane.swift b/Xcodes/Frontend/XcodeList/InfoPane.swift index 0c2f29b..6392d73 100644 --- a/Xcodes/Frontend/XcodeList/InfoPane.swift +++ b/Xcodes/Frontend/XcodeList/InfoPane.swift @@ -43,6 +43,7 @@ struct InfoPane: View { } } else { InstallButton(xcode: xcode) + .disabled(xcode.installState != .notInstalled) } } diff --git a/Xcodes/Frontend/XcodeList/InstallationStepView.swift b/Xcodes/Frontend/XcodeList/InstallationStepView.swift index 0c7ab3c..38cf61b 100644 --- a/Xcodes/Frontend/XcodeList/InstallationStepView.swift +++ b/Xcodes/Frontend/XcodeList/InstallationStepView.swift @@ -25,7 +25,7 @@ struct InstallationStepView: View { Text("Step \(installationStep.stepNumber) of \(installationStep.stepCount): \(installationStep.message)") .font(.footnote) - Button(action: { }) { + Button(action: cancel) { Label("Cancel", systemImage: "xmark.circle.fill") .labelStyle(IconOnlyLabelStyle()) } diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 6293083..b0fba45 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -45,7 +45,7 @@ struct XcodeListView_Previews: PreviewProvider { a.allXcodes = [ Xcode(version: Version("12.3.0")!, installState: .installed, selected: true, path: "/Applications/Xcode-12.3.0.app", icon: nil), Xcode(version: Version("12.2.0")!, installState: .notInstalled, selected: false, path: nil, icon: nil), - Xcode(version: Version("12.1.0")!, installState: .notInstalled, selected: false, path: nil, icon: nil), + Xcode(version: Version("12.1.0")!, installState: .installing(.downloading(progress: configure(Progress(totalUnitCount: 100)) { $0.completedUnitCount = 40 })), selected: false, path: nil, icon: nil), Xcode(version: Version("12.0.0")!, installState: .installed, selected: false, path: "/Applications/Xcode-12.3.0.app", icon: nil), ] return a diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index 8a21574..ce23a09 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -82,7 +82,7 @@ struct XcodeListViewRow: View { .buttonStyle(AppStoreButtonStyle(primary: false, highlighted: selected)) .help("Install this version") case let .installing(installationStep): - InstallationStepView(installationStep: installationStep, highlighted: selected, cancel: {}) + InstallationStepView(installationStep: installationStep, highlighted: selected, cancel: { appState.cancelInstall(id: xcode.id) }) } } } diff --git a/Xcodes/Resources/Licenses.rtf b/Xcodes/Resources/Licenses.rtf index d706ecb..5f04c8d 100644 --- a/Xcodes/Resources/Licenses.rtf +++ b/Xcodes/Resources/Licenses.rtf @@ -396,6 +396,32 @@ 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 spm-licenses\ \ diff --git a/Xcodes/XcodesApp.swift b/Xcodes/XcodesApp.swift index bb4398f..afb6bff 100644 --- a/Xcodes/XcodesApp.swift +++ b/Xcodes/XcodesApp.swift @@ -16,6 +16,7 @@ struct XcodesApp: App { // When used on a View it's also invoked on launch, which doesn't occur with a WindowGroup. // FB8954581 ScenePhase read from App doesn't return a value on launch .onChange(of: scenePhase) { newScenePhase in + guard NSClassFromString("XCTestCase") == nil else { return } if case .active = newScenePhase { appState.updateIfNeeded() } diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift new file mode 100644 index 0000000..ff9598e --- /dev/null +++ b/XcodesTests/AppStateTests.swift @@ -0,0 +1,283 @@ +import AppleAPI +import Combine +import CombineExpectations +import Path +import Version +import XCTest +@testable import Xcodes + +class AppStateTests: XCTestCase { + var subject: AppState! + + override func setUpWithError() throws { + Current = .mock + subject = AppState() + } + + func test_ParseCertificateInfo_Succeeds() throws { + let sampleRawInfo = """ + Executable=/Applications/Xcode-10.1.app/Contents/MacOS/Xcode + Identifier=com.apple.dt.Xcode + Format=app bundle with Mach-O thin (x86_64) + CodeDirectory v=20200 size=434 flags=0x2000(library-validation) hashes=6+5 location=embedded + Signature size=4485 + Authority=Software Signing + Authority=Apple Code Signing Certification Authority + Authority=Apple Root CA + Info.plist entries=39 + TeamIdentifier=59GAB85EFG + Sealed Resources version=2 rules=13 files=253327 + Internal requirements count=1 size=68 + """ + let info = subject.parseCertificateInfo(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() + } + + 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") + + if case let .failure(error as InstallationError) = completion { + XCTAssertEqual(error, InstallationError.failedSecurityAssessment(xcode: installedXcode, output: "stdout\nstderr")) + } + else { + XCTFail() + } + } + + func test_VerifySecurityAssessment_Succeeds() throws { + Current.shell.spctlAssess = { _ in + Just((0, "", "")).setFailureType(to: Error.self).eraseToAnyPublisher() + } + + 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") + } + + func test_Install_FullHappyPath_Apple() throws { + // Available xcode doesn't necessarily have build identifier + subject.allXcodes = [ + .init(version: Version("0.0.0")!, installState: .notInstalled, selected: false, path: nil, icon: nil), + .init(version: Version("0.0.0-Beta.1")!, installState: .notInstalled, selected: false, path: nil, icon: nil), + .init(version: Version("0.0.0-Beta.2")!, installState: .notInstalled, selected: false, path: nil, icon: nil) + ] + + // It hasn't been downloaded + Current.files.fileExistsAtPath = { path in + if path == (Path.xcodesApplicationSupport/"Xcode-0.0.0.xip").string { + return false + } + else { + 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! { + let downloads = Downloads(downloads: [Download(name: "Xcode 0.0.0", files: [Download.File(remotePath: "https://apple.com/xcode.xip")], 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)! + ) + ) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + return Just( + ( + 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 + 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)!))) + } + } + } + .eraseToAnyPublisher() + ) + } + // It's a valid .app + Current.shell.codesignVerify = { _ in + Just( + ProcessOutput( + status: 0, + out: "", + err: """ + TeamIdentifier=\(XcodeTeamIdentifier) + Authority=\(XcodeCertificateAuthority[0]) + Authority=\(XcodeCertificateAuthority[1]) + Authority=\(XcodeCertificateAuthority[2]) + """) + ) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + 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") + XCTAssertEqual( + allXcodesElements.map { $0.map(\.installState) }, + [ + [XcodeInstallState.notInstalled, .notInstalled, .notInstalled], + [.installing(.downloading(progress: progress)), .notInstalled, .notInstalled], + [.installing(.unarchiving), .notInstalled, .notInstalled], + [.installing(.moving(destination: "/Applications/Xcode-0.0.0.app")), .notInstalled, .notInstalled], + [.installing(.trashingArchive), .notInstalled, .notInstalled], + [.installing(.checkingSecurity), .notInstalled, .notInstalled], + [.installing(.finishing), .notInstalled, .notInstalled], + [.installed, .notInstalled, .notInstalled] + ] + ) + } + + func test_Install_FullHappyPath_XcodeReleases() throws { + // Available xcode has build identifier + subject.allXcodes = [ + .init(version: Version("0.0.0+ABC123")!, installState: .notInstalled, selected: false, path: nil, icon: nil), + .init(version: Version("0.0.0-Beta.1+DEF456")!, installState: .notInstalled, selected: false, path: nil, icon: nil), + .init(version: Version("0.0.0-Beta.2+GHI789")!, installState: .notInstalled, selected: false, path: nil, icon: nil) + ] + + // It hasn't been downloaded + Current.files.fileExistsAtPath = { path in + if path == (Path.xcodesApplicationSupport/"Xcode-0.0.0.xip").string { + return false + } + else { + 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! { + let downloads = Downloads(downloads: [Download(name: "Xcode 0.0.0", files: [Download.File(remotePath: "https://apple.com/xcode.xip")], 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)! + ) + ) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + return Just( + ( + 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 + 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)!))) + } + } + } + .eraseToAnyPublisher() + ) + } + // It's a valid .app + Current.shell.codesignVerify = { _ in + Just( + ProcessOutput( + status: 0, + out: "", + err: """ + TeamIdentifier=\(XcodeTeamIdentifier) + Authority=\(XcodeCertificateAuthority[0]) + Authority=\(XcodeCertificateAuthority[1]) + Authority=\(XcodeCertificateAuthority[2]) + """) + ) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + 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") + XCTAssertEqual( + allXcodesElements.map { $0.map(\.installState) }, + [ + [XcodeInstallState.notInstalled, .notInstalled, .notInstalled], + [.installing(.downloading(progress: progress)), .notInstalled, .notInstalled], + [.installing(.unarchiving), .notInstalled, .notInstalled], + [.installing(.moving(destination: "/Applications/Xcode-0.0.0.app")), .notInstalled, .notInstalled], + [.installing(.trashingArchive), .notInstalled, .notInstalled], + [.installing(.checkingSecurity), .notInstalled, .notInstalled], + [.installing(.finishing), .notInstalled, .notInstalled], + [.installed, .notInstalled, .notInstalled] + ] + ) + } + +} diff --git a/XcodesTests/Environment+Mock.swift b/XcodesTests/Environment+Mock.swift index 4227eb7..bf24ce5 100644 --- a/XcodesTests/Environment+Mock.swift +++ b/XcodesTests/Environment+Mock.swift @@ -40,7 +40,7 @@ extension Files { return try? Data(contentsOf: url) } else if path.contains("version.plist") { - let url = Bundle.xcodesTests.url(forResource: "Stub.version", withExtension: "plist")! + let url = Bundle.xcodesTests.url(forResource: "Stub-version", withExtension: "plist")! return try? Data(contentsOf: url) } else { @@ -106,6 +106,10 @@ extension Helper { install: { }, checkIfLatestHelperIsInstalled: { Just(false).eraseToAnyPublisher() }, getVersion: { Just("").setFailureType(to: Error.self).eraseToAnyPublisher() }, - switchXcodePath: { _ in 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() } ) } diff --git a/XcodesTests/Fixtures/Stub-0.0.0.plist b/XcodesTests/Fixtures/Stub-0.0.0.Info.plist similarity index 100% rename from XcodesTests/Fixtures/Stub-0.0.0.plist rename to XcodesTests/Fixtures/Stub-0.0.0.Info.plist diff --git a/XcodesTests/XcodesTests.swift b/XcodesTests/XcodesTests.swift deleted file mode 100644 index 3a640d4..0000000 --- a/XcodesTests/XcodesTests.swift +++ /dev/null @@ -1,26 +0,0 @@ -import XCTest -@testable import Xcodes - -class XcodesTests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - 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. - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - -} diff --git a/com.robotsandpencils.XcodesApp.Helper/XPCDelegate.swift b/com.robotsandpencils.XcodesApp.Helper/XPCDelegate.swift index 6ceab7c..37d7962 100644 --- a/com.robotsandpencils.XcodesApp.Helper/XPCDelegate.swift +++ b/com.robotsandpencils.XcodesApp.Helper/XPCDelegate.swift @@ -34,6 +34,22 @@ class XPCDelegate: NSObject, NSXPCListenerDelegate, HelperXPCProtocol { completion: completion ) } + + func devToolsSecurityEnable(completion: @escaping (Error?) -> Void) { + run(url: URL(fileURLWithPath: "/usr/sbin/DevToolsSecurity"), arguments: ["-enable"], completion: completion) + } + + func addStaffToDevelopersGroup(completion: @escaping (Error?) -> Void) { + run(url: URL(fileURLWithPath: "/usr/sbin/dseditgroup"), arguments: ["-o", "edit", "-t", "group", "-a", "staff", "_developer"], completion: completion) + } + + func acceptXcodeLicense(absoluteXcodePath: String, completion: @escaping (Error?) -> Void) { + run(url: URL(fileURLWithPath: absoluteXcodePath + "/Contents/Developer/usr/bin/xcodebuild"), arguments: ["-license", "accept"], completion: completion) + } + + func runFirstLaunch(absoluteXcodePath: String, completion: @escaping (Error?) -> Void) { + run(url: URL(fileURLWithPath: absoluteXcodePath + "/Contents/Developer/usr/bin/xcodebuild"), arguments: ["-runFirstLaunch"], completion: completion) + } } // MARK: - Run