diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index 69c3f11..e2cf106 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -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 @@ -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" @@ -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 } @@ -163,9 +166,9 @@ public final class XcodeInstaller { case aria2(Path) } - public func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, experimentalUnxip: Bool = false, noSuperuser: Bool) -> Promise { + public func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, experimentalUnxip: Bool = false, emptyTrash: Bool, noSuperuser: Bool) -> Promise { return firstly { () -> Promise 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) @@ -173,12 +176,12 @@ public final class XcodeInstaller { } } - private func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, attemptNumber: Int, experimentalUnxip: Bool, noSuperuser: Bool) -> Promise { + private func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, attemptNumber: Int, experimentalUnxip: Bool, emptyTrash: Bool, noSuperuser: Bool) -> Promise { return firstly { () -> Promise<(Xcode, URL)> in return self.getXcodeArchive(installationType, dataSource: dataSource, downloader: downloader, destination: destination, willInstall: true) } .then { xcode, url -> Promise 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 in switch error { @@ -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: @@ -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 { + public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL, to destination: Path, experimentalUnxip: Bool = false, emptyTrash: Bool, noSuperuser: Bool) -> Promise { return firstly { () -> Promise in let destinationURL = destination.join("Xcode-\(xcode.version.descriptionWithoutBuildMetadata).app").url switch archiveURL.pathExtension { @@ -548,8 +551,13 @@ public final class XcodeInstaller { } } .then { xcode -> Promise 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), @@ -587,7 +595,7 @@ public final class XcodeInstaller { } } - public func uninstallXcode(_ versionString: String, directory: Path) -> Promise { + public func uninstallXcode(_ versionString: String, directory: Path, emptyTrash: Bool) -> Promise { return firstly { () -> Promise in guard let version = Version(xcodeVersion: versionString) else { Current.logging.log(Error.invalidVersion(versionString).legibleLocalizedDescription) @@ -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) @@ -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) } } diff --git a/Sources/xcodes/main.swift b/Sources/xcodes/main.swift index d4df002..c98034e 100644 --- a/Sources/xcodes/main.swift +++ b/Sources/xcodes/main.swift @@ -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? @@ -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) @@ -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 @@ -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) } diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 2215b60..91da13c 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -86,7 +86,7 @@ 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: "")) } } @@ -94,7 +94,7 @@ final class XcodesKitTests: XCTestCase { 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: "")) } } @@ -102,7 +102,7 @@ final class XcodesKitTests: XCTestCase { 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: [])) } } @@ -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() } @@ -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)) @@ -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)) @@ -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)) @@ -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) @@ -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)) @@ -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) @@ -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) @@ -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) }