Add install functionality

I'm omitting aria2 support for now.
This commit is contained in:
Brandon Evans 2020-12-30 22:02:36 -07:00
parent d8f00dbcdf
commit bfb3fd9ea5
No known key found for this signature in database
GPG key ID: D58A4B8DB64F8E93
23 changed files with 1080 additions and 54 deletions

View file

@ -8,4 +8,8 @@ let subjectOrganizationalUnit = Bundle.main.infoDictionary!["CODE_SIGNING_SUBJEC
protocol HelperXPCProtocol { protocol HelperXPCProtocol {
func getVersion(completion: @escaping (String) -> Void) func getVersion(completion: @escaping (String) -> Void)
func xcodeSelect(absolutePath: String, completion: @escaping (Error?) -> 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)
} }

View file

@ -13,8 +13,8 @@
CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; }; CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; };
CA44901F2463AD34003D8213 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA44901E2463AD34003D8213 /* Tag.swift */; }; CA44901F2463AD34003D8213 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA44901E2463AD34003D8213 /* Tag.swift */; };
CA452BB0259FD9770072DFA4 /* ProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA452BAF259FD9770072DFA4 /* ProgressIndicator.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 */; }; 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 */; }; CA5D781E257365D6008EDE9D /* PinCodeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */; };
CA61A6E0259835580008926E /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA61A6DF259835580008926E /* Xcode.swift */; }; CA61A6E0259835580008926E /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA61A6DF259835580008926E /* Xcode.swift */; };
CA735109257BF96D00EA9CF8 /* AttributedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA735108257BF96D00EA9CF8 /* AttributedText.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 */; }; CABFA9BD2592EEEA00380FEE /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9A92592EEE900380FEE /* Environment.swift */; };
CABFA9BF2592EEEA00380FEE /* URLSession+DownloadTaskPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B32592EEEA00380FEE /* URLSession+DownloadTaskPublisher.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 */; }; 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 */; }; CABFA9C32592EEEA00380FEE /* Downloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B92592EEEA00380FEE /* Downloads.swift */; };
CABFA9C52592EEEA00380FEE /* FileManager+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B82592EEEA00380FEE /* FileManager+.swift */; }; CABFA9C52592EEEA00380FEE /* FileManager+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B82592EEEA00380FEE /* FileManager+.swift */; };
CABFA9C72592EEEA00380FEE /* Entry+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B22592EEEA00380FEE /* Entry+.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 */; }; CABFAA2D2592FBFC00380FEE /* Configure.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFAA2B2592FBFC00380FEE /* Configure.swift */; };
CABFAA432593104F00380FEE /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFAA422593104F00380FEE /* AboutView.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 */; }; 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 */; }; CAC281C8259F97E100B8AB0B /* InstallationStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC281C7259F97E100B8AB0B /* InstallationStepView.swift */; };
CAC281CD259F97FA00B8AB0B /* ObservingProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC281CC259F97FA00B8AB0B /* ObservingProgressIndicator.swift */; }; CAC281CD259F97FA00B8AB0B /* ObservingProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC281CC259F97FA00B8AB0B /* ObservingProgressIndicator.swift */; };
CAC281DA259F985100B8AB0B /* InstallationStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC281D9259F985100B8AB0B /* InstallationStep.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 */; }; CAD2E7A42449574E00113D76 /* XcodeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD2E7A32449574E00113D76 /* XcodeListView.swift */; };
CAD2E7A62449575000113D76 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAD2E7A52449575000113D76 /* Assets.xcassets */; }; CAD2E7A62449575000113D76 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAD2E7A52449575000113D76 /* Assets.xcassets */; };
CAD2E7A92449575000113D76 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAD2E7A82449575000113D76 /* Preview 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 */; }; CAE4247F259A666100B8B246 /* MainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE4247E259A666100B8B246 /* MainWindow.swift */; };
CAE42487259A68A300B8B246 /* XcodeListCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE42486259A68A300B8B246 /* XcodeListCategory.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 */; }; 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 */; }; CAFBDB912598FE80003DCC5A /* SelectedXcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDB902598FE80003DCC5A /* SelectedXcode.swift */; };
CAFBDB952598FE96003DCC5A /* FocusedValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDB942598FE96003DCC5A /* FocusedValues.swift */; }; CAFBDB952598FE96003DCC5A /* FocusedValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDB942598FE96003DCC5A /* FocusedValues.swift */; };
CAFBDC4E2599B33D003DCC5A /* MainToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDC4D2599B33D003DCC5A /* MainToolbar.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 = "<group>"; }; CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreButtonStyle.swift; sourceTree = "<group>"; };
CA44901E2463AD34003D8213 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; }; CA44901E2463AD34003D8213 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
CA452BAF259FD9770072DFA4 /* ProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = "<group>"; }; CA452BAF259FD9770072DFA4 /* ProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = "<group>"; };
CA452BBE259FDDFE0072DFA4 /* Stub-0.0.0.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Stub-0.0.0.plist"; sourceTree = "<group>"; };
CA452BBF259FDDFE0072DFA4 /* Stub-version.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Stub-version.plist"; sourceTree = "<group>"; }; CA452BBF259FDDFE0072DFA4 /* Stub-version.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Stub-version.plist"; sourceTree = "<group>"; };
CA452BEA25A236500072DFA4 /* Stub-0.0.0.Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Stub-0.0.0.Info.plist"; sourceTree = "<group>"; };
CA538A0C255A4F1A00E64DD7 /* AppleAPI */ = {isa = PBXFileReference; lastKnownFileType = folder; name = AppleAPI; path = Xcodes/AppleAPI; sourceTree = "<group>"; }; CA538A0C255A4F1A00E64DD7 /* AppleAPI */ = {isa = PBXFileReference; lastKnownFileType = folder; name = AppleAPI; path = Xcodes/AppleAPI; sourceTree = "<group>"; };
CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinCodeTextView.swift; sourceTree = "<group>"; }; CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinCodeTextView.swift; sourceTree = "<group>"; };
CA61A6DF259835580008926E /* Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcode.swift; sourceTree = "<group>"; }; CA61A6DF259835580008926E /* Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcode.swift; sourceTree = "<group>"; };
@ -180,6 +182,7 @@
CABFA9AB2592EEE900380FEE /* URLRequest+Apple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest+Apple.swift"; sourceTree = "<group>"; }; CABFA9AB2592EEE900380FEE /* URLRequest+Apple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest+Apple.swift"; sourceTree = "<group>"; };
CABFA9AC2592EEE900380FEE /* Foundation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Foundation.swift; sourceTree = "<group>"; }; CABFA9AC2592EEE900380FEE /* Foundation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Foundation.swift; sourceTree = "<group>"; };
CABFA9AE2592EEE900380FEE /* Path+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Path+.swift"; sourceTree = "<group>"; }; CABFA9AE2592EEE900380FEE /* Path+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Path+.swift"; sourceTree = "<group>"; };
CABFA9B02592EEEA00380FEE /* Publisher+Resumable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+Resumable.swift"; sourceTree = "<group>"; };
CABFA9B22592EEEA00380FEE /* Entry+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Entry+.swift"; sourceTree = "<group>"; }; CABFA9B22592EEEA00380FEE /* Entry+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Entry+.swift"; sourceTree = "<group>"; };
CABFA9B32592EEEA00380FEE /* URLSession+DownloadTaskPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSession+DownloadTaskPublisher.swift"; sourceTree = "<group>"; }; CABFA9B32592EEEA00380FEE /* URLSession+DownloadTaskPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSession+DownloadTaskPublisher.swift"; sourceTree = "<group>"; };
CABFA9B42592EEEA00380FEE /* Process.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Process.swift; sourceTree = "<group>"; }; CABFA9B42592EEEA00380FEE /* Process.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Process.swift; sourceTree = "<group>"; };
@ -204,11 +207,14 @@
CAD2E7AD2449575000113D76 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; CAD2E7AD2449575000113D76 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
CAD2E7AE2449575000113D76 /* Xcodes.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Xcodes.entitlements; sourceTree = "<group>"; }; CAD2E7AE2449575000113D76 /* Xcodes.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Xcodes.entitlements; sourceTree = "<group>"; };
CAD2E7B32449575100113D76 /* XcodesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = XcodesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; CAD2E7B72449575100113D76 /* AppStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTests.swift; sourceTree = "<group>"; };
CAD2E7B92449575100113D76 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; CAD2E7B92449575100113D76 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
CAE4247E259A666100B8B246 /* MainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = "<group>"; }; CAE4247E259A666100B8B246 /* MainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = "<group>"; };
CAE42486259A68A300B8B246 /* XcodeListCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeListCategory.swift; sourceTree = "<group>"; }; CAE42486259A68A300B8B246 /* XcodeListCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeListCategory.swift; sourceTree = "<group>"; };
CAE4248B259A68B800B8B246 /* Optional+IsNotNil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+IsNotNil.swift"; sourceTree = "<group>"; }; CAE4248B259A68B800B8B246 /* Optional+IsNotNil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+IsNotNil.swift"; sourceTree = "<group>"; };
CAE424B3259A764700B8B246 /* AppState+Install.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AppState+Install.swift"; sourceTree = "<group>"; };
CAFBC3FF259AC17F00E2A3D8 /* InstallationStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallationStepView.swift; sourceTree = "<group>"; };
CAFBC421259ACF8000E2A3D8 /* ObservingProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservingProgressView.swift; sourceTree = "<group>"; };
CAFBDB902598FE80003DCC5A /* SelectedXcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedXcode.swift; sourceTree = "<group>"; }; CAFBDB902598FE80003DCC5A /* SelectedXcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedXcode.swift; sourceTree = "<group>"; };
CAFBDB942598FE96003DCC5A /* FocusedValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedValues.swift; sourceTree = "<group>"; }; CAFBDB942598FE96003DCC5A /* FocusedValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedValues.swift; sourceTree = "<group>"; };
CAFBDBA525990C76003DCC5A /* SimpleXPCApp.LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = SimpleXPCApp.LICENSE; sourceTree = "<group>"; }; CAFBDBA525990C76003DCC5A /* SimpleXPCApp.LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = SimpleXPCApp.LICENSE; sourceTree = "<group>"; };
@ -216,6 +222,7 @@
CAFBDC67259A308B003DCC5A /* InfoPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoPane.swift; sourceTree = "<group>"; }; CAFBDC67259A308B003DCC5A /* InfoPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoPane.swift; sourceTree = "<group>"; };
CAFBDC6B259A3098003DCC5A /* View+Conditional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Conditional.swift"; sourceTree = "<group>"; }; CAFBDC6B259A3098003DCC5A /* View+Conditional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Conditional.swift"; sourceTree = "<group>"; };
CAFFFED7259CDA5000903F81 /* XcodeListViewRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeListViewRow.swift; sourceTree = "<group>"; }; CAFFFED7259CDA5000903F81 /* XcodeListViewRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeListViewRow.swift; sourceTree = "<group>"; };
CAFFFEEE259CEAC400903F81 /* RingProgressViewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RingProgressViewStyle.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -245,6 +252,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
CAC28188259EE27200B8AB0B /* CombineExpectations in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -264,12 +272,21 @@
CA452BBD259FDDBF0072DFA4 /* Fixtures */ = { CA452BBD259FDDBF0072DFA4 /* Fixtures */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
CA452BBE259FDDFE0072DFA4 /* Stub-0.0.0.plist */, CA452BEA25A236500072DFA4 /* Stub-0.0.0.Info.plist */,
CA452BBF259FDDFE0072DFA4 /* Stub-version.plist */, CA452BBF259FDDFE0072DFA4 /* Stub-version.plist */,
); );
path = Fixtures; path = Fixtures;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
CA452BE025A2354D0072DFA4 /* Recovered References */ = {
isa = PBXGroup;
children = (
CAFBC421259ACF8000E2A3D8 /* ObservingProgressView.swift */,
CAFFFEEE259CEAC400903F81 /* RingProgressViewStyle.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
};
CA538A12255A4F7C00E64DD7 /* Frameworks */ = { CA538A12255A4F7C00E64DD7 /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -331,6 +348,7 @@
children = ( children = (
CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */, CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */,
CAFBDC67259A308B003DCC5A /* InfoPane.swift */, CAFBDC67259A308B003DCC5A /* InfoPane.swift */,
CAFBC3FF259AC17F00E2A3D8 /* InstallationStepView.swift */,
CAFBDC4D2599B33D003DCC5A /* MainToolbar.swift */, CAFBDC4D2599B33D003DCC5A /* MainToolbar.swift */,
CA44901E2463AD34003D8213 /* Tag.swift */, CA44901E2463AD34003D8213 /* Tag.swift */,
CAE42486259A68A300B8B246 /* XcodeListCategory.swift */, CAE42486259A68A300B8B246 /* XcodeListCategory.swift */,
@ -345,6 +363,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
CA378F982466567600A58CE0 /* AppState.swift */, CA378F982466567600A58CE0 /* AppState.swift */,
CAE424B3259A764700B8B246 /* AppState+Install.swift */,
CABFA9A72592EEE900380FEE /* AppState+Update.swift */, CABFA9A72592EEE900380FEE /* AppState+Update.swift */,
CA9FF88025955C7000E47BAF /* AvailableXcode.swift */, CA9FF88025955C7000E47BAF /* AvailableXcode.swift */,
CABFAA2B2592FBFC00380FEE /* Configure.swift */, CABFAA2B2592FBFC00380FEE /* Configure.swift */,
@ -364,6 +383,7 @@
CAE4248B259A68B800B8B246 /* Optional+IsNotNil.swift */, CAE4248B259A68B800B8B246 /* Optional+IsNotNil.swift */,
CABFA9AE2592EEE900380FEE /* Path+.swift */, CABFA9AE2592EEE900380FEE /* Path+.swift */,
CABFA9B42592EEEA00380FEE /* Process.swift */, CABFA9B42592EEEA00380FEE /* Process.swift */,
CABFA9B02592EEEA00380FEE /* Publisher+Resumable.swift */,
CAFBDB902598FE80003DCC5A /* SelectedXcode.swift */, CAFBDB902598FE80003DCC5A /* SelectedXcode.swift */,
CABFA9AB2592EEE900380FEE /* URLRequest+Apple.swift */, CABFA9AB2592EEE900380FEE /* URLRequest+Apple.swift */,
CABFA9B32592EEEA00380FEE /* URLSession+DownloadTaskPublisher.swift */, CABFA9B32592EEEA00380FEE /* URLSession+DownloadTaskPublisher.swift */,
@ -421,6 +441,7 @@
CA9FF8CD25959A7600E47BAF /* HelperXPCShared */, CA9FF8CD25959A7600E47BAF /* HelperXPCShared */,
CAD2E79F2449574E00113D76 /* Products */, CAD2E79F2449574E00113D76 /* Products */,
CA538A12255A4F7C00E64DD7 /* Frameworks */, CA538A12255A4F7C00E64DD7 /* Frameworks */,
CA452BE025A2354D0072DFA4 /* Recovered References */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -460,7 +481,7 @@
CA452BBD259FDDBF0072DFA4 /* Fixtures */, CA452BBD259FDDBF0072DFA4 /* Fixtures */,
CAC281E1259FA44600B8AB0B /* Bundle+XcodesTests.swift */, CAC281E1259FA44600B8AB0B /* Bundle+XcodesTests.swift */,
CAC281E6259FA45A00B8AB0B /* Environment+Mock.swift */, CAC281E6259FA45A00B8AB0B /* Environment+Mock.swift */,
CAD2E7B72449575100113D76 /* XcodesTests.swift */, CAD2E7B72449575100113D76 /* AppStateTests.swift */,
CAD2E7B92449575100113D76 /* Info.plist */, CAD2E7B92449575100113D76 /* Info.plist */,
); );
path = XcodesTests; path = XcodesTests;
@ -530,6 +551,9 @@
CAD2E7B52449575100113D76 /* PBXTargetDependency */, CAD2E7B52449575100113D76 /* PBXTargetDependency */,
); );
name = XcodesTests; name = XcodesTests;
packageProductDependencies = (
CAC28187259EE27200B8AB0B /* CombineExpectations */,
);
productName = XcodesMacTests; productName = XcodesMacTests;
productReference = CAD2E7B32449575100113D76 /* XcodesTests.xctest */; productReference = CAD2E7B32449575100113D76 /* XcodesTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test"; productType = "com.apple.product-type.bundle.unit-test";
@ -573,6 +597,7 @@
CABFA9FB2592F13300380FEE /* XCRemoteSwiftPackageReference "LegibleError" */, CABFA9FB2592F13300380FEE /* XCRemoteSwiftPackageReference "LegibleError" */,
CA9FF86B25951C6E00E47BAF /* XCRemoteSwiftPackageReference "data" */, CA9FF86B25951C6E00E47BAF /* XCRemoteSwiftPackageReference "data" */,
CAA858CB25A3D8BC00ACF8C0 /* XCRemoteSwiftPackageReference "ErrorHandling" */, CAA858CB25A3D8BC00ACF8C0 /* XCRemoteSwiftPackageReference "ErrorHandling" */,
CAC28186259EE27200B8AB0B /* XCRemoteSwiftPackageReference "CombineExpectations" */,
); );
productRefGroup = CAD2E79F2449574E00113D76 /* Products */; productRefGroup = CAD2E79F2449574E00113D76 /* Products */;
projectDirPath = ""; projectDirPath = "";
@ -600,8 +625,8 @@
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
CA452BC0259FDDFE0072DFA4 /* Stub-0.0.0.plist in Resources */,
CA452BC1259FDDFE0072DFA4 /* Stub-version.plist in Resources */, CA452BC1259FDDFE0072DFA4 /* Stub-version.plist in Resources */,
CA452BEB25A236500072DFA4 /* Stub-0.0.0.Info.plist in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -686,12 +711,14 @@
CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */, CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */,
CAFBDB952598FE96003DCC5A /* FocusedValues.swift in Sources */, CAFBDB952598FE96003DCC5A /* FocusedValues.swift in Sources */,
CAC281CD259F97FA00B8AB0B /* ObservingProgressIndicator.swift in Sources */, CAC281CD259F97FA00B8AB0B /* ObservingProgressIndicator.swift in Sources */,
CABFA9C22592EEEA00380FEE /* Publisher+Resumable.swift in Sources */,
CAFBDC68259A308B003DCC5A /* InfoPane.swift in Sources */, CAFBDC68259A308B003DCC5A /* InfoPane.swift in Sources */,
CAA1CB4D255A5CFD003FD669 /* SignInPhoneListView.swift in Sources */, CAA1CB4D255A5CFD003FD669 /* SignInPhoneListView.swift in Sources */,
CAFBDC6C259A3098003DCC5A /* View+Conditional.swift in Sources */, CAFBDC6C259A3098003DCC5A /* View+Conditional.swift in Sources */,
CABFA9CF2592EEEA00380FEE /* Process.swift in Sources */, CABFA9CF2592EEEA00380FEE /* Process.swift in Sources */,
CAFFFED8259CDA5000903F81 /* XcodeListViewRow.swift in Sources */, CAFFFED8259CDA5000903F81 /* XcodeListViewRow.swift in Sources */,
CABFA9C72592EEEA00380FEE /* Entry+.swift in Sources */, CABFA9C72592EEEA00380FEE /* Entry+.swift in Sources */,
CAE424B4259A764700B8B246 /* AppState+Install.swift in Sources */,
CAE42487259A68A300B8B246 /* XcodeListCategory.swift in Sources */, CAE42487259A68A300B8B246 /* XcodeListCategory.swift in Sources */,
CABFAA2C2592FBFC00380FEE /* SettingsView.swift in Sources */, CABFAA2C2592FBFC00380FEE /* SettingsView.swift in Sources */,
CA9FF87B2595293E00E47BAF /* DataSource.swift in Sources */, CA9FF87B2595293E00E47BAF /* DataSource.swift in Sources */,
@ -710,7 +737,6 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
CAD2E7B82449575100113D76 /* XcodesTests.swift in Sources */,
CAC281E7259FA45A00B8AB0B /* Environment+Mock.swift in Sources */, CAC281E7259FA45A00B8AB0B /* Environment+Mock.swift in Sources */,
CAC281E2259FA44600B8AB0B /* Bundle+XcodesTests.swift in Sources */, CAC281E2259FA44600B8AB0B /* Bundle+XcodesTests.swift in Sources */,
); );
@ -1214,6 +1240,14 @@
minimumVersion = 1.0.1; 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 */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
@ -1256,6 +1290,11 @@
package = CABFA9FB2592F13300380FEE /* XCRemoteSwiftPackageReference "LegibleError" */; package = CABFA9FB2592F13300380FEE /* XCRemoteSwiftPackageReference "LegibleError" */;
productName = LegibleError; productName = LegibleError;
}; };
CAC28187259EE27200B8AB0B /* CombineExpectations */ = {
isa = XCSwiftPackageProductDependency;
package = CAC28186259EE27200B8AB0B /* XCRemoteSwiftPackageReference "CombineExpectations" */;
productName = CombineExpectations;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = CAD2E7962449574E00113D76 /* Project object */; rootObject = CAD2E7962449574E00113D76 /* Project object */;

View file

@ -1,6 +1,15 @@
{ {
"object": { "object": {
"pins": [ "pins": [
{
"package": "CombineExpectations",
"repositoryURL": "https://github.com/groue/CombineExpectations",
"state": {
"branch": null,
"revision": "989a92221899929ab8347a5878aa2b16db8b81ca",
"version": "0.6.0"
}
},
{ {
"package": "XcodeReleases", "package": "XcodeReleases",
"repositoryURL": "https://github.com/xcodereleases/data", "repositoryURL": "https://github.com/xcodereleases/data",

View file

@ -1,6 +1,6 @@
import Foundation 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 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 signIn = URL(string: "https://idmsa.apple.com/appleauth/auth/signin")!
static let authOptions = URL(string: "https://idmsa.apple.com/appleauth/auth")! 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")! static let olympusSession = URL(string: "https://appstoreconnect.apple.com/olympus/v1/session")!
} }
extension URLRequest { public extension URLRequest {
static var itcServiceKey: URLRequest { static var itcServiceKey: URLRequest {
return URLRequest(url: .itcServiceKey) return URLRequest(url: .itcServiceKey)
} }

View file

@ -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<Void, Error> {
install(installationType, downloader: downloader, attemptNumber: 0)
.map { _ in Void() }
.eraseToAnyPublisher()
}
private func install(_ installationType: InstallationType, downloader: Downloader, attemptNumber: Int) -> AnyPublisher<InstalledXcode, Error> {
getXcodeArchive(installationType, downloader: downloader)
.flatMap { xcode, url -> AnyPublisher<InstalledXcode, Swift.Error> in
self.installArchivedXcode(xcode, at: url)
}
.catch { error -> AnyPublisher<InstalledXcode, Swift.Error> 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<URL, Error> {
// 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<URL> {
// 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<URL, Error> {
let resumeDataPath = Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).resumedata"
let persistedResumeData = Current.files.contents(atPath: resumeDataPath.string)
return attemptResumableTask(maximumRetryCount: 3) { resumeData -> AnyPublisher<URL, Error> 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<InstalledXcode, Error> {
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<InstalledXcode, Error> 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<InstalledXcode, Error> in
self.setInstallationStep(of: availableXcode.version, to: .finishing)
return self.enableDeveloperMode()
.map { installedXcode }
.eraseToAnyPublisher()
}
.flatMap { installedXcode -> AnyPublisher<InstalledXcode, Error> in
self.approveLicense(for: installedXcode)
.map { installedXcode }
.eraseToAnyPublisher()
}
.flatMap { installedXcode -> AnyPublisher<InstalledXcode, Error> 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<URL, Swift.Error> {
self.setInstallationStep(of: availableXcode.version, to: .unarchiving)
return Current.shell.unxip(source)
.catch { error -> AnyPublisher<ProcessOutput, Swift.Error> in
if let executionError = error as? ProcessExecutionError,
executionError.standardError.contains("damaged and cant 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<Void, Error> {
return Current.shell.spctlAssess(xcode.path.url)
.catch { (error: Swift.Error) -> AnyPublisher<ProcessOutput, Error> 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<Void, Error> {
return Current.shell.codesignVerify(url)
.catch { error -> AnyPublisher<ProcessOutput, Error> 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<Void, Error> {
if helperInstallState == .notInstalled {
installHelper()
}
return Current.helper.devToolsSecurityEnable()
.flatMap {
Current.helper.addStaffToDevelopersGroup()
}
.eraseToAnyPublisher()
}
func approveLicense(for xcode: InstalledXcode) -> AnyPublisher<Void, Error> {
if helperInstallState == .notInstalled {
installHelper()
}
return Current.helper.acceptXcodeLicense(xcode.path.string)
.eraseToAnyPublisher()
}
func installComponents(for xcode: InstalledXcode) -> AnyPublisher<Void, Swift.Error> {
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<T>(at path: Path, for completion: Subscribers.Completion<T>) {
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"]

View file

@ -9,9 +9,8 @@ import Version
class AppState: ObservableObject { class AppState: ObservableObject {
private let client = AppleAPI.Client() private let client = AppleAPI.Client()
private var cancellables = Set<AnyCancellable>()
private var selectPublisher: AnyCancellable? // MARK: - Published Properties
private var uninstallPublisher: AnyCancellable?
@Published var authenticationState: AuthenticationState = .unauthenticated @Published var authenticationState: AuthenticationState = .unauthenticated
@Published var availableXcodes: [AvailableXcode] = [] { @Published var availableXcodes: [AvailableXcode] = [] {
@ -19,7 +18,7 @@ class AppState: ObservableObject {
updateAllXcodes(availableXcodes: newValue, selectedXcodePath: selectedXcodePath) updateAllXcodes(availableXcodes: newValue, selectedXcodePath: selectedXcodePath)
} }
} }
var allXcodes: [Xcode] = [] @Published var allXcodes: [Xcode] = []
@Published var selectedXcodePath: String? { @Published var selectedXcodePath: String? {
willSet { willSet {
updateAllXcodes(availableXcodes: availableXcodes, selectedXcodePath: newValue) updateAllXcodes(availableXcodes: availableXcodes, selectedXcodePath: newValue)
@ -38,7 +37,17 @@ class AppState: ObservableObject {
@Published var error: Error? @Published var error: Error?
@Published var authError: Error? @Published var authError: Error?
// MARK: - Publisher Cancellables
private var cancellables = Set<AnyCancellable>()
private var installationPublishers: [Version: AnyCancellable] = [:]
private var selectPublisher: AnyCancellable?
private var uninstallPublisher: AnyCancellable?
// MARK: - Init
init() { init() {
guard NSClassFromString("XCTestCase") == nil else { return }
try? loadCachedAvailableXcodes() try? loadCachedAvailableXcodes()
checkIfHelperIsInstalled() checkIfHelperIsInstalled()
} }
@ -206,7 +215,59 @@ class AppState: ObservableObject {
// MARK: - Install // MARK: - Install
func install(id: Xcode.ID) { 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 // MARK: - Uninstall

View file

@ -164,4 +164,8 @@ public struct Helper {
var checkIfLatestHelperIsInstalled: () -> AnyPublisher<Bool, Never> = helperClient.checkIfLatestHelperIsInstalled var checkIfLatestHelperIsInstalled: () -> AnyPublisher<Bool, Never> = helperClient.checkIfLatestHelperIsInstalled
var getVersion: () -> AnyPublisher<String, Error> = helperClient.getVersion var getVersion: () -> AnyPublisher<String, Error> = helperClient.getVersion
var switchXcodePath: (_ absolutePath: String) -> AnyPublisher<Void, Error> = helperClient.switchXcodePath var switchXcodePath: (_ absolutePath: String) -> AnyPublisher<Void, Error> = helperClient.switchXcodePath
var devToolsSecurityEnable: () -> AnyPublisher<Void, Error> = helperClient.devToolsSecurityEnable
var addStaffToDevelopersGroup: () -> AnyPublisher<Void, Error> = helperClient.addStaffToDevelopersGroup
var acceptXcodeLicense: (_ absoluteXcodePath: String) -> AnyPublisher<Void, Error> = helperClient.acceptXcodeLicense
var runFirstLaunch: (_ absoluteXcodePath: String) -> AnyPublisher<Void, Error> = helperClient.runFirstLaunch
} }

View file

@ -4,7 +4,7 @@ import Foundation
final class HelperClient { final class HelperClient {
private var connection: NSXPCConnection? private var connection: NSXPCConnection?
func currentConnection() -> NSXPCConnection? { private func currentConnection() -> NSXPCConnection? {
guard self.connection == nil else { guard self.connection == nil else {
return self.connection return self.connection
} }
@ -24,7 +24,7 @@ final class HelperClient {
return self.connection return self.connection
} }
func helper(errorSubject: PassthroughSubject<String, Error>) -> HelperXPCProtocol? { private func helper(errorSubject: PassthroughSubject<String, Error>) -> HelperXPCProtocol? {
guard guard
let helper = self.currentConnection()?.remoteObjectProxyWithErrorHandler({ error in let helper = self.currentConnection()?.remoteObjectProxyWithErrorHandler({ error in
errorSubject.send(completion: .failure(error)) errorSubject.send(completion: .failure(error))
@ -103,4 +103,124 @@ final class HelperClient {
.map { $0.0 } .map { $0.0 }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func devToolsSecurityEnable() -> AnyPublisher<Void, Error> {
let connectionErrorSubject = PassthroughSubject<String, Error>()
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<Void, Error> {
let connectionErrorSubject = PassthroughSubject<String, Error>()
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<Void, Error> {
let connectionErrorSubject = PassthroughSubject<String, Error>()
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<Void, Error> {
let connectionErrorSubject = PassthroughSubject<String, Error>()
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()
}
} }

View file

@ -0,0 +1,44 @@
import Combine
import Foundation
/// Attempt and retry a task that fails with resume data up to `maximumRetryCount` times
func attemptResumableTask<T>(
maximumRetryCount: Int = 3,
delayBeforeRetry: TimeInterval = 2,
_ body: @escaping (Data?) -> AnyPublisher<T, Error>
) -> AnyPublisher<T, Error> {
var attempts = 0
func attempt(with resumeData: Data? = nil) -> AnyPublisher<T, Error> {
attempts += 1
return body(resumeData)
.catch { error -> AnyPublisher<T, Error> 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<T>(
// maximumRetryCount: Int = 3,
// delayBeforeRetry: DispatchTimeInterval = .seconds(2),
// _ body: @escaping () -> AnyPublisher<T, Error>
//) -> AnyPublisher<T, Error> {
// var attempts = 0
// func attempt() -> Promise<T> {
// attempts += 1
// return body().recover { error -> Promise<T> in
// guard attempts < maximumRetryCount else { throw error }
// return after(delayBeforeRetry).then(on: nil) { attempt() }
// }
// }
// return attempt()
//}

View file

@ -17,8 +17,9 @@ extension URLSession {
resumingWith resumeData: Data? resumingWith resumeData: Data?
) -> (progress: Progress, publisher: AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) { ) -> (progress: Progress, publisher: AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) {
var progress: Progress! 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... // Probably a sign that this should be implemented differently...
let promise = Future<(saveLocation: URL, response: URLResponse), Error> { promise in let promise = Future<(saveLocation: URL, response: URLResponse), Error> { promise in
let completionHandler = { (temporaryURL: URL?, response: URLResponse?, error: Error?) in let completionHandler = { (temporaryURL: URL?, response: URLResponse?, error: Error?) in
@ -36,7 +37,6 @@ extension URLSession {
} }
} }
let task: URLSessionDownloadTask
if let resumeData = resumeData { if let resumeData = resumeData {
task = self.downloadTask(withResumeData: resumeData, completionHandler: completionHandler) task = self.downloadTask(withResumeData: resumeData, completionHandler: completionHandler)
} }
@ -46,6 +46,7 @@ extension URLSession {
progress = task.progress progress = task.progress
task.resume() task.resume()
} }
.handleEvents(receiveCancel: task.cancel)
.eraseToAnyPublisher() .eraseToAnyPublisher()
return (progress, promise) return (progress, promise)

View file

@ -6,7 +6,7 @@ import struct XCModel.Compilers
struct Xcode: Identifiable, CustomStringConvertible { struct Xcode: Identifiable, CustomStringConvertible {
let version: Version let version: Version
let installState: XcodeInstallState var installState: XcodeInstallState
let selected: Bool let selected: Bool
let path: String? let path: String?
let icon: NSImage? let icon: NSImage?

View file

@ -43,6 +43,7 @@ struct InfoPane: View {
} }
} else { } else {
InstallButton(xcode: xcode) InstallButton(xcode: xcode)
.disabled(xcode.installState != .notInstalled)
} }
} }

View file

@ -25,7 +25,7 @@ struct InstallationStepView: View {
Text("Step \(installationStep.stepNumber) of \(installationStep.stepCount): \(installationStep.message)") Text("Step \(installationStep.stepNumber) of \(installationStep.stepCount): \(installationStep.message)")
.font(.footnote) .font(.footnote)
Button(action: { }) { Button(action: cancel) {
Label("Cancel", systemImage: "xmark.circle.fill") Label("Cancel", systemImage: "xmark.circle.fill")
.labelStyle(IconOnlyLabelStyle()) .labelStyle(IconOnlyLabelStyle())
} }

View file

@ -45,7 +45,7 @@ struct XcodeListView_Previews: PreviewProvider {
a.allXcodes = [ 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.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.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), Xcode(version: Version("12.0.0")!, installState: .installed, selected: false, path: "/Applications/Xcode-12.3.0.app", icon: nil),
] ]
return a return a

View file

@ -82,7 +82,7 @@ struct XcodeListViewRow: View {
.buttonStyle(AppStoreButtonStyle(primary: false, highlighted: selected)) .buttonStyle(AppStoreButtonStyle(primary: false, highlighted: selected))
.help("Install this version") .help("Install this version")
case let .installing(installationStep): case let .installing(installationStep):
InstallationStepView(installationStep: installationStep, highlighted: selected, cancel: {}) InstallationStepView(installationStep: installationStep, highlighted: selected, cancel: { appState.cancelInstall(id: xcode.id) })
} }
} }
} }

View file

@ -396,6 +396,32 @@ For more information, please refer to &lt;<http://unlicense.org/>&gt;\
\ \
\ \
\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\ \fs34 spm-licenses\
\ \

View file

@ -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. // 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 // FB8954581 ScenePhase read from App doesn't return a value on launch
.onChange(of: scenePhase) { newScenePhase in .onChange(of: scenePhase) { newScenePhase in
guard NSClassFromString("XCTestCase") == nil else { return }
if case .active = newScenePhase { if case .active = newScenePhase {
appState.updateIfNeeded() appState.updateIfNeeded()
} }

View file

@ -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]
]
)
}
}

View file

@ -40,7 +40,7 @@ extension Files {
return try? Data(contentsOf: url) return try? Data(contentsOf: url)
} }
else if path.contains("version.plist") { 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) return try? Data(contentsOf: url)
} }
else { else {
@ -106,6 +106,10 @@ extension Helper {
install: { }, install: { },
checkIfLatestHelperIsInstalled: { Just(false).eraseToAnyPublisher() }, checkIfLatestHelperIsInstalled: { Just(false).eraseToAnyPublisher() },
getVersion: { Just("").setFailureType(to: Error.self).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() }
) )
} }

View file

@ -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.
}
}
}

View file

@ -34,6 +34,22 @@ class XPCDelegate: NSObject, NSXPCListenerDelegate, HelperXPCProtocol {
completion: completion 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 // MARK: - Run