Skip to content

Commit

Permalink
Merge pull request #110 from interstateone/retry-damaged-xips
Browse files Browse the repository at this point in the history
Retry installation when it fails because of damaged XIP
  • Loading branch information
Brandon Evans authored Oct 20, 2020
2 parents b5b11ba + 2580ee0 commit b9a253d
Show file tree
Hide file tree
Showing 3 changed files with 286 additions and 8 deletions.
60 changes: 52 additions & 8 deletions Sources/XcodesKit/XcodeInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public final class XcodeInstaller {
static let XcodeCertificateAuthority = ["Software Signing", "Apple Code Signing Certification Authority", "Apple Root CA"]

public enum Error: LocalizedError, Equatable {
case damagedXIP(url: URL)
case failedToMoveXcodeToApplications
case failedSecurityAssessment(xcode: InstalledXcode, output: String)
case codesignVerifyFailed(output: String)
Expand All @@ -27,6 +28,8 @@ public final class XcodeInstaller {

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):
Expand Down Expand Up @@ -132,6 +135,47 @@ public final class XcodeInstaller {
}

public func install(_ installationType: InstallationType) -> Promise<Void> {
return firstly { () -> Promise<InstalledXcode> in
return self.install(installationType, attemptNumber: 0)
}
.done { xcode in
Current.logging.log("\nXcode \(xcode.version.descriptionWithoutBuildMetadata) has been installed to \(xcode.path.string)")
Current.shell.exit(0)
}
}

private func install(_ installationType: InstallationType, attemptNumber: Int) -> Promise<InstalledXcode> {
return firstly { () -> Promise<(Xcode, URL)> in
return self.getXcodeArchive(installationType)
}
.then { xcode, url -> Promise<InstalledXcode> in
return self.installArchivedXcode(xcode, at: url)
}
.recover { error -> Promise<InstalledXcode> in
switch error {
case XcodeInstaller.Error.damagedXIP(let damagedXIPURL):
guard attemptNumber < 1 else { throw error }

switch installationType {
case .url:
// If the user provided the URL, don't try to recover and leave it up to them.
throw error
default:
// If the XIP was just downloaded, remove it and try to recover.
return firstly { () -> Promise<InstalledXcode> in
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, attemptNumber: attemptNumber + 1)
}
}
default:
throw error
}
}
}

private func getXcodeArchive(_ installationType: InstallationType) -> Promise<(Xcode, URL)> {
return firstly { () -> Promise<(Xcode, URL)> in
switch installationType {
case .latest:
Expand Down Expand Up @@ -187,13 +231,6 @@ public final class XcodeInstaller {
return self.downloadXcode(version: version)
}
}
.then { xcode, url -> Promise<InstalledXcode> in
return self.installArchivedXcode(xcode, at: url)
}
.done { xcode in
Current.logging.log("\nXcode \(xcode.version.descriptionWithoutBuildMetadata) has been installed to \(xcode.path.string)")
Current.shell.exit(0)
}
}

private func versionFromXcodeVersionFile() -> Version? {
Expand Down Expand Up @@ -322,7 +359,7 @@ public final class XcodeInstaller {
// 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-\(xcode.version).\(xcode.filename.suffix(fromLast: "."))"
if Current.files.fileExistsAtPath(expectedArchivePath.string) {
Current.logging.log("Found existing archive that will be used for installation at \(expectedArchivePath).")
Current.logging.log("(1/6) Found existing archive that will be used for installation at \(expectedArchivePath).")
return Promise.value(expectedArchivePath.url)
}
else {
Expand Down Expand Up @@ -521,6 +558,13 @@ public final class XcodeInstaller {
return firstly { () -> Promise<ProcessOutput> in
Current.logging.log(InstallationStep.unarchiving.description)
return Current.shell.unxip(source)
.recover { (error) throws -> Promise<ProcessOutput> in
if case Process.PMKError.execution(_, _, let standardError) = error,
standardError?.contains("damaged and can’t be expanded") == true {
throw Error.damagedXIP(url: source)
}
throw error
}
}
.map { output -> URL in
Current.logging.log(InstallationStep.moving(destination: destination.path).description)
Expand Down
118 changes: 118 additions & 0 deletions Tests/XcodesKitTests/Fixtures/LogOutput-DamagedXIP.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
Apple ID:
Apple ID Password:

(1/6) Found existing archive that will be used for installation at /Users/brandon/Library/Application Support/com.robotsandpencils.xcodes/Xcode-0.0.0.xip.
(2/6) Unarchiving Xcode (This can take a while)
The archive "Xcode-0.0.0.xip" is damaged and can't be expanded.
Removing damaged XIP and re-attempting installation.


(1/6) Downloading Xcode 0.0.0: 1%
(1/6) Downloading Xcode 0.0.0: 2%
(1/6) Downloading Xcode 0.0.0: 3%
(1/6) Downloading Xcode 0.0.0: 4%
(1/6) Downloading Xcode 0.0.0: 5%
(1/6) Downloading Xcode 0.0.0: 6%
(1/6) Downloading Xcode 0.0.0: 7%
(1/6) Downloading Xcode 0.0.0: 8%
(1/6) Downloading Xcode 0.0.0: 9%
(1/6) Downloading Xcode 0.0.0: 10%
(1/6) Downloading Xcode 0.0.0: 11%
(1/6) Downloading Xcode 0.0.0: 12%
(1/6) Downloading Xcode 0.0.0: 13%
(1/6) Downloading Xcode 0.0.0: 14%
(1/6) Downloading Xcode 0.0.0: 15%
(1/6) Downloading Xcode 0.0.0: 16%
(1/6) Downloading Xcode 0.0.0: 17%
(1/6) Downloading Xcode 0.0.0: 18%
(1/6) Downloading Xcode 0.0.0: 19%
(1/6) Downloading Xcode 0.0.0: 20%
(1/6) Downloading Xcode 0.0.0: 21%
(1/6) Downloading Xcode 0.0.0: 22%
(1/6) Downloading Xcode 0.0.0: 23%
(1/6) Downloading Xcode 0.0.0: 24%
(1/6) Downloading Xcode 0.0.0: 25%
(1/6) Downloading Xcode 0.0.0: 26%
(1/6) Downloading Xcode 0.0.0: 27%
(1/6) Downloading Xcode 0.0.0: 28%
(1/6) Downloading Xcode 0.0.0: 29%
(1/6) Downloading Xcode 0.0.0: 30%
(1/6) Downloading Xcode 0.0.0: 31%
(1/6) Downloading Xcode 0.0.0: 32%
(1/6) Downloading Xcode 0.0.0: 33%
(1/6) Downloading Xcode 0.0.0: 34%
(1/6) Downloading Xcode 0.0.0: 35%
(1/6) Downloading Xcode 0.0.0: 36%
(1/6) Downloading Xcode 0.0.0: 37%
(1/6) Downloading Xcode 0.0.0: 38%
(1/6) Downloading Xcode 0.0.0: 39%
(1/6) Downloading Xcode 0.0.0: 40%
(1/6) Downloading Xcode 0.0.0: 41%
(1/6) Downloading Xcode 0.0.0: 42%
(1/6) Downloading Xcode 0.0.0: 43%
(1/6) Downloading Xcode 0.0.0: 44%
(1/6) Downloading Xcode 0.0.0: 45%
(1/6) Downloading Xcode 0.0.0: 46%
(1/6) Downloading Xcode 0.0.0: 47%
(1/6) Downloading Xcode 0.0.0: 48%
(1/6) Downloading Xcode 0.0.0: 49%
(1/6) Downloading Xcode 0.0.0: 50%
(1/6) Downloading Xcode 0.0.0: 51%
(1/6) Downloading Xcode 0.0.0: 52%
(1/6) Downloading Xcode 0.0.0: 53%
(1/6) Downloading Xcode 0.0.0: 54%
(1/6) Downloading Xcode 0.0.0: 55%
(1/6) Downloading Xcode 0.0.0: 56%
(1/6) Downloading Xcode 0.0.0: 57%
(1/6) Downloading Xcode 0.0.0: 58%
(1/6) Downloading Xcode 0.0.0: 59%
(1/6) Downloading Xcode 0.0.0: 60%
(1/6) Downloading Xcode 0.0.0: 61%
(1/6) Downloading Xcode 0.0.0: 62%
(1/6) Downloading Xcode 0.0.0: 63%
(1/6) Downloading Xcode 0.0.0: 64%
(1/6) Downloading Xcode 0.0.0: 65%
(1/6) Downloading Xcode 0.0.0: 66%
(1/6) Downloading Xcode 0.0.0: 67%
(1/6) Downloading Xcode 0.0.0: 68%
(1/6) Downloading Xcode 0.0.0: 69%
(1/6) Downloading Xcode 0.0.0: 70%
(1/6) Downloading Xcode 0.0.0: 71%
(1/6) Downloading Xcode 0.0.0: 72%
(1/6) Downloading Xcode 0.0.0: 73%
(1/6) Downloading Xcode 0.0.0: 74%
(1/6) Downloading Xcode 0.0.0: 75%
(1/6) Downloading Xcode 0.0.0: 76%
(1/6) Downloading Xcode 0.0.0: 77%
(1/6) Downloading Xcode 0.0.0: 78%
(1/6) Downloading Xcode 0.0.0: 79%
(1/6) Downloading Xcode 0.0.0: 80%
(1/6) Downloading Xcode 0.0.0: 81%
(1/6) Downloading Xcode 0.0.0: 82%
(1/6) Downloading Xcode 0.0.0: 83%
(1/6) Downloading Xcode 0.0.0: 84%
(1/6) Downloading Xcode 0.0.0: 85%
(1/6) Downloading Xcode 0.0.0: 86%
(1/6) Downloading Xcode 0.0.0: 87%
(1/6) Downloading Xcode 0.0.0: 88%
(1/6) Downloading Xcode 0.0.0: 89%
(1/6) Downloading Xcode 0.0.0: 90%
(1/6) Downloading Xcode 0.0.0: 91%
(1/6) Downloading Xcode 0.0.0: 92%
(1/6) Downloading Xcode 0.0.0: 93%
(1/6) Downloading Xcode 0.0.0: 94%
(1/6) Downloading Xcode 0.0.0: 95%
(1/6) Downloading Xcode 0.0.0: 96%
(1/6) Downloading Xcode 0.0.0: 97%
(1/6) Downloading Xcode 0.0.0: 98%
(1/6) Downloading Xcode 0.0.0: 99%
(1/6) Downloading Xcode 0.0.0: 100%
(2/6) Unarchiving Xcode (This can take a while)
(3/6) Moving Xcode to /Applications/Xcode-0.0.0.app
(4/6) Moving Xcode archive Xcode-0.0.0.xip to the Trash
(5/6) Checking security assessment and code signing
(6/6) Finishing installation
xcodes requires superuser privileges in order to finish installation.
macOS User Password:

Xcode 0.0.0 has been installed to /Applications/Xcode-0.0.0.app
116 changes: 116 additions & 0 deletions Tests/XcodesKitTests/XcodesKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,122 @@ final class XcodesKitTests: XCTestCase {
waitForExpectations(timeout: 1.0)
}

func test_InstallLogging_DamagedXIP() {
var log = ""
XcodesKit.Current.logging.log = { log.append($0 + "\n") }

// Don't have a valid session
var validateSessionCallCount = 0
Current.network.validateSession = {
validateSessionCallCount += 1

if validateSessionCallCount == 1 {
return Promise(error: AppleAPI.Client.Error.invalidSession)
} else {
return Promise.value(())
}
}
// It has been downloaded
var unxipCallCount = 0
Current.files.fileExistsAtPath = { path in
if path == (Path.xcodesApplicationSupport/"Xcode-0.0.0.xip").string {
if unxipCallCount == 1 {
return false
} else {
return true
}
}
else {
return true
}
}
// It's an available release version
XcodesKit.Current.network.dataTask = { url in
if url.pmkRequest.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 Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!))
}

return Promise.value((data: Data(), response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!))
}
// It downloads and updates progress
Current.network.downloadTask = { (url, saveLocation, _) -> (Progress, Promise<(saveLocation: URL, response: URLResponse)>) in
let progress = Progress(totalUnitCount: 100)
return (progress,
Promise { resolver 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)
}
resolver.fulfill((saveLocation: saveLocation,
response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!))
}
})
}
// It's a valid .app
Current.shell.codesignVerify = { _ in
return Promise.value(
ProcessOutput(
status: 0,
out: "",
err: """
TeamIdentifier=\(XcodeInstaller.XcodeTeamIdentifier)
Authority=\(XcodeInstaller.XcodeCertificateAuthority[0])
Authority=\(XcodeInstaller.XcodeCertificateAuthority[1])
Authority=\(XcodeInstaller.XcodeCertificateAuthority[2])
"""))
}
// Don't have superuser privileges the first time
var validateSudoAuthenticationCallCount = 0
Current.shell.validateSudoAuthentication = {
validateSudoAuthenticationCallCount += 1

if validateSudoAuthenticationCallCount == 1 {
return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: nil))
}
else {
return Promise.value(Shell.processOutputMock)
}
}
// User enters password
Current.shell.readSecureLine = { prompt, _ in
XcodesKit.Current.logging.log(prompt)
return "password"
}
// User enters something
XcodesKit.Current.shell.readLine = { prompt in
XcodesKit.Current.logging.log(prompt)
return "asdf"
}
Current.shell.unxip = { _ in
unxipCallCount += 1
if unxipCallCount == 1 {
return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: "The file \"Xcode-0.0.0.xip\" is damaged and can’t be expanded."))
} else {
return Promise.value(Shell.processOutputMock)
}
}

let expectation = self.expectation(description: "Finished")

installer.install(.version("0.0.0"))
.ensure {
let url = Bundle.module.url(forResource: "LogOutput-DamagedXIP", withExtension: "txt", subdirectory: "Fixtures")!
let expectedText = try! String(contentsOf: url).replacingOccurrences(of: "/Users/brandon", with: Path.home.string)
XCTAssertEqual(log, expectedText)
expectation.fulfill()
}
.catch {
XCTFail($0.localizedDescription)
}

waitForExpectations(timeout: 1.0)
}

func test_UninstallXcode() {
// There are installed Xcodes
let installedXcodes = [
Expand Down

0 comments on commit b9a253d

Please sign in to comment.