Skip to content

Commit

Permalink
Merge pull request #211 from juanjonol/empty-trash
Browse files Browse the repository at this point in the history
Added support to immediately delete Xcode and its XIPs
  • Loading branch information
MattKiazyk committed Sep 18, 2022
2 parents f943fcb + c49767b commit 3ddb27c
Show file tree
Hide file tree
Showing 3 changed files with 55 additions and 30 deletions.
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

0 comments on commit 3ddb27c

Please sign in to comment.