mirror of
https://github.com/XcodesOrg/XcodesApp.git
synced 2026-03-25 08:55:46 +00:00
Add install functionality
I'm omitting aria2 support for now.
This commit is contained in:
parent
d8f00dbcdf
commit
bfb3fd9ea5
23 changed files with 1080 additions and 54 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
|
@ -180,6 +182,7 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
|
@ -204,11 +207,14 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
|
@ -216,6 +222,7 @@
|
|||
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>"; };
|
||||
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 */
|
||||
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
CA452BE025A2354D0072DFA4 /* Recovered References */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CAFBC421259ACF8000E2A3D8 /* ObservingProgressView.swift */,
|
||||
CAFFFEEE259CEAC400903F81 /* RingProgressViewStyle.swift */,
|
||||
);
|
||||
name = "Recovered References";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
|
|
@ -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 */;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
439
Xcodes/Backend/AppState+Install.swift
Normal file
439
Xcodes/Backend/AppState+Install.swift
Normal 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 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<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"]
|
||||
|
|
@ -9,9 +9,8 @@ import Version
|
|||
|
||||
class AppState: ObservableObject {
|
||||
private let client = AppleAPI.Client()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
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<AnyCancellable>()
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<Bool, Never> = helperClient.checkIfLatestHelperIsInstalled
|
||||
var getVersion: () -> AnyPublisher<String, Error> = helperClient.getVersion
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, Error>) -> HelperXPCProtocol? {
|
||||
private func helper(errorSubject: PassthroughSubject<String, Error>) -> 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<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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ProcessOutput, Error> {
|
||||
return run(executable.url, workingDirectory: workingDirectory, input: input, arguments)
|
||||
|
|
|
|||
44
Xcodes/Backend/Publisher+Resumable.swift
Normal file
44
Xcodes/Backend/Publisher+Resumable.swift
Normal 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()
|
||||
//}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ struct InfoPane: View {
|
|||
}
|
||||
} else {
|
||||
InstallButton(xcode: xcode)
|
||||
.disabled(xcode.installState != .notInstalled)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -396,6 +396,32 @@ For more information, please refer to <<http://unlicense.org/>>\
|
|||
\
|
||||
\
|
||||
|
||||
\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\
|
||||
\
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
283
XcodesTests/AppStateTests.swift
Normal file
283
XcodesTests/AppStateTests.swift
Normal 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]
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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() }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue