Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 35 additions & 16 deletions Sources/XcodesKit/XcodeInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ public final class XcodeInstaller {
case downloading(version: String, progress: String?, willInstall: Bool)
case unarchiving(experimentalUnxip: Bool)
case moving(destination: String)
case trashingArchive(archiveName: String)
case cleaningArchive(archiveName: String, shouldDelete: Bool)
case checkingSecurity
case finishing

Expand Down Expand Up @@ -114,7 +114,10 @@ public final class XcodeInstaller {
"""
case .moving(let destination):
return "Moving Xcode to \(destination)"
case .trashingArchive(let archiveName):
case .cleaningArchive(let archiveName, let shouldDelete):
if shouldDelete {
return "Deleting Xcode archive \(archiveName)"
}
return "Moving Xcode archive \(archiveName) to the Trash"
case .checkingSecurity:
return "Checking security assessment and code signing"
Expand All @@ -128,7 +131,7 @@ public final class XcodeInstaller {
case .downloading: return 1
case .unarchiving: return 2
case .moving: return 3
case .trashingArchive: return 4
case .cleaningArchive: return 4
case .checkingSecurity: return 5
case .finishing: return 6
}
Expand Down Expand Up @@ -163,22 +166,22 @@ public final class XcodeInstaller {
case aria2(Path)
}

public func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, experimentalUnxip: Bool = false, noSuperuser: Bool) -> Promise<Void> {
public func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, experimentalUnxip: Bool = false, emptyTrash: Bool, noSuperuser: Bool) -> Promise<Void> {
return firstly { () -> Promise<InstalledXcode> in
return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, attemptNumber: 0, experimentalUnxip: experimentalUnxip, noSuperuser: noSuperuser)
return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, attemptNumber: 0, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
}
.done { xcode in
Current.logging.log("\nXcode \(xcode.version.descriptionWithoutBuildMetadata) has been installed to \(xcode.path.string)".green)
Current.shell.exit(0)
}
}

private func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, attemptNumber: Int, experimentalUnxip: Bool, noSuperuser: Bool) -> Promise<InstalledXcode> {
private func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, attemptNumber: Int, experimentalUnxip: Bool, emptyTrash: Bool, noSuperuser: Bool) -> Promise<InstalledXcode> {
return firstly { () -> Promise<(Xcode, URL)> in
return self.getXcodeArchive(installationType, dataSource: dataSource, downloader: downloader, destination: destination, willInstall: true)
}
.then { xcode, url -> Promise<InstalledXcode> in
return self.installArchivedXcode(xcode, at: url, to: destination, experimentalUnxip: experimentalUnxip, noSuperuser: noSuperuser)
return self.installArchivedXcode(xcode, at: url, to: destination, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
}
.recover { error -> Promise<InstalledXcode> in
switch error {
Expand All @@ -195,7 +198,7 @@ public final class XcodeInstaller {
Current.logging.log(error.legibleLocalizedDescription.red)
Current.logging.log("Removing damaged XIP and re-attempting installation.\n")
try Current.files.removeItem(at: damagedXIPURL)
return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, attemptNumber: attemptNumber + 1, experimentalUnxip: experimentalUnxip, noSuperuser: noSuperuser)
return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, attemptNumber: attemptNumber + 1, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
}
}
default:
Expand Down Expand Up @@ -528,7 +531,7 @@ public final class XcodeInstaller {
}
}

public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL, to destination: Path, experimentalUnxip: Bool = false, noSuperuser: Bool) -> Promise<InstalledXcode> {
public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL, to destination: Path, experimentalUnxip: Bool = false, emptyTrash: Bool, noSuperuser: Bool) -> Promise<InstalledXcode> {
return firstly { () -> Promise<InstalledXcode> in
let destinationURL = destination.join("Xcode-\(xcode.version.descriptionWithoutBuildMetadata).app").url
switch archiveURL.pathExtension {
Expand All @@ -548,8 +551,13 @@ public final class XcodeInstaller {
}
}
.then { xcode -> Promise<InstalledXcode> in
Current.logging.log(InstallationStep.trashingArchive(archiveName: archiveURL.lastPathComponent).description)
try Current.files.trashItem(at: archiveURL)
Current.logging.log(InstallationStep.cleaningArchive(archiveName: archiveURL.lastPathComponent, shouldDelete: emptyTrash).description)
if emptyTrash {
try Current.files.removeItem(at: archiveURL)
}
else {
try Current.files.trashItem(at: archiveURL)
}
Current.logging.log(InstallationStep.checkingSecurity.description)

return when(fulfilled: self.verifySecurityAssessment(of: xcode),
Expand Down Expand Up @@ -587,7 +595,7 @@ public final class XcodeInstaller {
}
}

public func uninstallXcode(_ versionString: String, directory: Path) -> Promise<Void> {
public func uninstallXcode(_ versionString: String, directory: Path, emptyTrash: Bool) -> Promise<Void> {
return firstly { () -> Promise<InstalledXcode> in
guard let version = Version(xcodeVersion: versionString) else {
Current.logging.log(Error.invalidVersion(versionString).legibleLocalizedDescription)
Expand All @@ -601,11 +609,17 @@ public final class XcodeInstaller {

return Promise.value(installedXcode)
}
.map { ($0, try Current.files.trashItem(at: $0.path.url)) }
.then { (installedXcode, trashURL) -> Promise<(InstalledXcode, URL)> in
.map { installedXcode -> (InstalledXcode, URL?) in
if emptyTrash {
try Current.files.removeItem(at: installedXcode.path.url)
return (installedXcode, nil)
}
return (installedXcode, try Current.files.trashItem(at: installedXcode.path.url))
}
.then { (installedXcode, trashURL) -> Promise<(InstalledXcode, URL?)> in
// If we just uninstalled the selected Xcode, try to select the latest installed version so things don't accidentally break
Current.shell.xcodeSelectPrintPath()
.then { output -> Promise<(InstalledXcode, URL)> in
.then { output -> Promise<(InstalledXcode, URL?)> in
if output.out.hasPrefix(installedXcode.path.string),
let latestInstalledXcode = Current.files.installedXcodes(directory).sorted(by: { $0.version < $1.version }).last {
return selectXcodeAtPath(latestInstalledXcode.path.string)
Expand All @@ -620,7 +634,12 @@ public final class XcodeInstaller {
}
}
.done { (installedXcode, trashURL) in
Current.logging.log("Xcode \(installedXcode.version.appleDescription) moved to Trash: \(trashURL.path)".green)
if let trashURL = trashURL {
Current.logging.log("Xcode \(installedXcode.version.appleDescription) moved to Trash: \(trashURL.path)".green)
}
else {
Current.logging.log("Xcode \(installedXcode.version.appleDescription) deleted".green)
}
Current.shell.exit(0)
}
}
Expand Down
10 changes: 8 additions & 2 deletions Sources/xcodes/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ struct Xcodes: ParsableCommand {
@Flag(help: "Don't ask for superuser (root) permission. Some optional steps of the installation will be skipped.")
var noSuperuser: Bool = false

@Flag(help: "Completely delete Xcode .xip after installation, instead of keeping it on the user's Trash.")
var emptyTrash: Bool = false

@Option(help: "The directory to install Xcode into. Defaults to /Applications.",
completion: .directory)
var directory: String?
Expand Down Expand Up @@ -224,7 +227,7 @@ struct Xcodes: ParsableCommand {

let destination = getDirectory(possibleDirectory: directory)

installer.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip, noSuperuser: noSuperuser)
installer.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
.done { Install.exit() }
.catch { error in
Install.processDownloadOrInstall(error: error)
Expand Down Expand Up @@ -348,6 +351,9 @@ struct Xcodes: ParsableCommand {
completion: .custom { _ in Current.files.installedXcodes(getDirectory(possibleDirectory: nil)).sorted { $0.version < $1.version }.map { $0.version.appleDescription } })
var version: [String] = []

@Flag(help: "Completely delete Xcode, instead of keeping it on the user's Trash.")
var emptyTrash: Bool = false

@OptionGroup
var globalDirectory: GlobalDirectoryOption

Expand All @@ -359,7 +365,7 @@ struct Xcodes: ParsableCommand {

let directory = getDirectory(possibleDirectory: globalDirectory.directory)

installer.uninstallXcode(version.joined(separator: " "), directory: directory)
installer.uninstallXcode(version.joined(separator: " "), directory: directory, emptyTrash: emptyTrash)
.done { Uninstall.exit() }
.catch { error in Uninstall.exit(withLegibleError: error) }

Expand Down
24 changes: 12 additions & 12 deletions Tests/XcodesKitTests/XcodesKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,23 +86,23 @@ final class XcodesKitTests: XCTestCase {

let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock", releaseDate: nil)
let installedXcode = InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)!
installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), noSuperuser: false)
installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
.catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.failedSecurityAssessment(xcode: installedXcode, output: "")) }
}

func test_InstallArchivedXcode_VerifySigningCertificateFails_Throws() {
Current.shell.codesignVerify = { _ in return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: nil)) }

let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock", releaseDate: nil)
installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), noSuperuser: false)
installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
.catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.codesignVerifyFailed(output: "")) }
}

func test_InstallArchivedXcode_VerifySigningCertificateDoesntMatch_Throws() {
Current.shell.codesignVerify = { _ in return Promise.value((0, "", "")) }

let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock", releaseDate: nil)
installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), noSuperuser: false)
installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
.catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.unexpectedCodeSigningIdentity(identifier: "", certificateAuthority: [])) }
}

Expand All @@ -115,7 +115,7 @@ final class XcodesKitTests: XCTestCase {

let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock", releaseDate: nil)
let xipURL = URL(fileURLWithPath: "/Xcode-0.0.0.xip")
installer.installArchivedXcode(xcode, at: xipURL, to: Path.root.join("Applications"), noSuperuser: false)
installer.installArchivedXcode(xcode, at: xipURL, to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
.ensure { XCTAssertEqual(trashedItemAtURL, xipURL) }
.cauterize()
}
Expand Down Expand Up @@ -203,7 +203,7 @@ final class XcodesKitTests: XCTestCase {

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

installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), noSuperuser: false)
installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
.ensure {
let url = Bundle.module.url(forResource: "LogOutput-FullHappyPath", withExtension: "txt", subdirectory: "Fixtures")!
XCTAssertEqual(log, try! String(contentsOf: url))
Expand Down Expand Up @@ -296,7 +296,7 @@ final class XcodesKitTests: XCTestCase {

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

installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), noSuperuser: false)
installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
.ensure {
let url = Bundle.module.url(forResource: "LogOutput-FullHappyPath-NoColor", withExtension: "txt", subdirectory: "Fixtures")!
XCTAssertEqual(log, try! String(contentsOf: url))
Expand Down Expand Up @@ -393,7 +393,7 @@ final class XcodesKitTests: XCTestCase {

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

installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), noSuperuser: false)
installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
.ensure {
let url = Bundle.module.url(forResource: "LogOutput-FullHappyPath-NonInteractiveTerminal", withExtension: "txt", subdirectory: "Fixtures")!
XCTAssertEqual(log, try! String(contentsOf: url))
Expand Down Expand Up @@ -486,7 +486,7 @@ final class XcodesKitTests: XCTestCase {

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

installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.home.join("Xcode"), noSuperuser: false)
installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.home.join("Xcode"), emptyTrash: false, noSuperuser: false)
.ensure {
let url = Bundle.module.url(forResource: "LogOutput-AlternativeDirectory", withExtension: "txt", subdirectory: "Fixtures")!
let expectedText = try! String(contentsOf: url).replacingOccurrences(of: "/Users/brandon", with: Path.home.string)
Expand Down Expand Up @@ -600,7 +600,7 @@ final class XcodesKitTests: XCTestCase {

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

installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), noSuperuser: false)
installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
.ensure {
let url = Bundle.module.url(forResource: "LogOutput-IncorrectSavedPassword", withExtension: "txt", subdirectory: "Fixtures")!
XCTAssertEqual(log, try! String(contentsOf: url))
Expand Down Expand Up @@ -718,7 +718,7 @@ final class XcodesKitTests: XCTestCase {

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

installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), noSuperuser: false)
installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
.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)
Expand Down Expand Up @@ -778,7 +778,7 @@ final class XcodesKitTests: XCTestCase {
return Promise.value((status: 0, out: "", err: ""))
}

installer.uninstallXcode("0.0.0", directory: Path.root.join("Applications"))
installer.uninstallXcode("0.0.0", directory: Path.root.join("Applications"), emptyTrash: false)
.ensure {
XCTAssertEqual(selectedPaths, ["/Applications/Xcode-2.0.1.app"])
XCTAssertEqual(trashedItemAtURL, installedXcodes[0].path.url)
Expand Down Expand Up @@ -823,7 +823,7 @@ final class XcodesKitTests: XCTestCase {
return URL(fileURLWithPath: "\(NSHomeDirectory())/.Trash/\(itemURL.lastPathComponent)")
}

installer.uninstallXcode("999", directory: Path.root.join("Applications"))
installer.uninstallXcode("999", directory: Path.root.join("Applications"), emptyTrash: false)
.ensure {
XCTAssertEqual(trashedItemAtURL, installedXcodes[0].path.url)
}
Expand Down