Skip to content

Commit 3ddb27c

Browse files
authored
Merge pull request #211 from juanjonol/empty-trash
Added support to immediately delete Xcode and its XIPs
2 parents f943fcb + c49767b commit 3ddb27c

File tree

3 files changed

+55
-30
lines changed

3 files changed

+55
-30
lines changed

Sources/XcodesKit/XcodeInstaller.swift

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ public final class XcodeInstaller {
8282
case downloading(version: String, progress: String?, willInstall: Bool)
8383
case unarchiving(experimentalUnxip: Bool)
8484
case moving(destination: String)
85-
case trashingArchive(archiveName: String)
85+
case cleaningArchive(archiveName: String, shouldDelete: Bool)
8686
case checkingSecurity
8787
case finishing
8888

@@ -114,7 +114,10 @@ public final class XcodeInstaller {
114114
"""
115115
case .moving(let destination):
116116
return "Moving Xcode to \(destination)"
117-
case .trashingArchive(let archiveName):
117+
case .cleaningArchive(let archiveName, let shouldDelete):
118+
if shouldDelete {
119+
return "Deleting Xcode archive \(archiveName)"
120+
}
118121
return "Moving Xcode archive \(archiveName) to the Trash"
119122
case .checkingSecurity:
120123
return "Checking security assessment and code signing"
@@ -128,7 +131,7 @@ public final class XcodeInstaller {
128131
case .downloading: return 1
129132
case .unarchiving: return 2
130133
case .moving: return 3
131-
case .trashingArchive: return 4
134+
case .cleaningArchive: return 4
132135
case .checkingSecurity: return 5
133136
case .finishing: return 6
134137
}
@@ -163,22 +166,22 @@ public final class XcodeInstaller {
163166
case aria2(Path)
164167
}
165168

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

176-
private func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, attemptNumber: Int, experimentalUnxip: Bool, noSuperuser: Bool) -> Promise<InstalledXcode> {
179+
private func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, attemptNumber: Int, experimentalUnxip: Bool, emptyTrash: Bool, noSuperuser: Bool) -> Promise<InstalledXcode> {
177180
return firstly { () -> Promise<(Xcode, URL)> in
178181
return self.getXcodeArchive(installationType, dataSource: dataSource, downloader: downloader, destination: destination, willInstall: true)
179182
}
180183
.then { xcode, url -> Promise<InstalledXcode> in
181-
return self.installArchivedXcode(xcode, at: url, to: destination, experimentalUnxip: experimentalUnxip, noSuperuser: noSuperuser)
184+
return self.installArchivedXcode(xcode, at: url, to: destination, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
182185
}
183186
.recover { error -> Promise<InstalledXcode> in
184187
switch error {
@@ -195,7 +198,7 @@ public final class XcodeInstaller {
195198
Current.logging.log(error.legibleLocalizedDescription.red)
196199
Current.logging.log("Removing damaged XIP and re-attempting installation.\n")
197200
try Current.files.removeItem(at: damagedXIPURL)
198-
return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, attemptNumber: attemptNumber + 1, experimentalUnxip: experimentalUnxip, noSuperuser: noSuperuser)
201+
return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, attemptNumber: attemptNumber + 1, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
199202
}
200203
}
201204
default:
@@ -528,7 +531,7 @@ public final class XcodeInstaller {
528531
}
529532
}
530533

531-
public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL, to destination: Path, experimentalUnxip: Bool = false, noSuperuser: Bool) -> Promise<InstalledXcode> {
534+
public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL, to destination: Path, experimentalUnxip: Bool = false, emptyTrash: Bool, noSuperuser: Bool) -> Promise<InstalledXcode> {
532535
return firstly { () -> Promise<InstalledXcode> in
533536
let destinationURL = destination.join("Xcode-\(xcode.version.descriptionWithoutBuildMetadata).app").url
534537
switch archiveURL.pathExtension {
@@ -548,8 +551,13 @@ public final class XcodeInstaller {
548551
}
549552
}
550553
.then { xcode -> Promise<InstalledXcode> in
551-
Current.logging.log(InstallationStep.trashingArchive(archiveName: archiveURL.lastPathComponent).description)
552-
try Current.files.trashItem(at: archiveURL)
554+
Current.logging.log(InstallationStep.cleaningArchive(archiveName: archiveURL.lastPathComponent, shouldDelete: emptyTrash).description)
555+
if emptyTrash {
556+
try Current.files.removeItem(at: archiveURL)
557+
}
558+
else {
559+
try Current.files.trashItem(at: archiveURL)
560+
}
553561
Current.logging.log(InstallationStep.checkingSecurity.description)
554562

555563
return when(fulfilled: self.verifySecurityAssessment(of: xcode),
@@ -587,7 +595,7 @@ public final class XcodeInstaller {
587595
}
588596
}
589597

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

602610
return Promise.value(installedXcode)
603611
}
604-
.map { ($0, try Current.files.trashItem(at: $0.path.url)) }
605-
.then { (installedXcode, trashURL) -> Promise<(InstalledXcode, URL)> in
612+
.map { installedXcode -> (InstalledXcode, URL?) in
613+
if emptyTrash {
614+
try Current.files.removeItem(at: installedXcode.path.url)
615+
return (installedXcode, nil)
616+
}
617+
return (installedXcode, try Current.files.trashItem(at: installedXcode.path.url))
618+
}
619+
.then { (installedXcode, trashURL) -> Promise<(InstalledXcode, URL?)> in
606620
// If we just uninstalled the selected Xcode, try to select the latest installed version so things don't accidentally break
607621
Current.shell.xcodeSelectPrintPath()
608-
.then { output -> Promise<(InstalledXcode, URL)> in
622+
.then { output -> Promise<(InstalledXcode, URL?)> in
609623
if output.out.hasPrefix(installedXcode.path.string),
610624
let latestInstalledXcode = Current.files.installedXcodes(directory).sorted(by: { $0.version < $1.version }).last {
611625
return selectXcodeAtPath(latestInstalledXcode.path.string)
@@ -620,7 +634,12 @@ public final class XcodeInstaller {
620634
}
621635
}
622636
.done { (installedXcode, trashURL) in
623-
Current.logging.log("Xcode \(installedXcode.version.appleDescription) moved to Trash: \(trashURL.path)".green)
637+
if let trashURL = trashURL {
638+
Current.logging.log("Xcode \(installedXcode.version.appleDescription) moved to Trash: \(trashURL.path)".green)
639+
}
640+
else {
641+
Current.logging.log("Xcode \(installedXcode.version.appleDescription) deleted".green)
642+
}
624643
Current.shell.exit(0)
625644
}
626645
}

Sources/xcodes/main.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,9 @@ struct Xcodes: ParsableCommand {
189189
@Flag(help: "Don't ask for superuser (root) permission. Some optional steps of the installation will be skipped.")
190190
var noSuperuser: Bool = false
191191

192+
@Flag(help: "Completely delete Xcode .xip after installation, instead of keeping it on the user's Trash.")
193+
var emptyTrash: Bool = false
194+
192195
@Option(help: "The directory to install Xcode into. Defaults to /Applications.",
193196
completion: .directory)
194197
var directory: String?
@@ -224,7 +227,7 @@ struct Xcodes: ParsableCommand {
224227

225228
let destination = getDirectory(possibleDirectory: directory)
226229

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

354+
@Flag(help: "Completely delete Xcode, instead of keeping it on the user's Trash.")
355+
var emptyTrash: Bool = false
356+
351357
@OptionGroup
352358
var globalDirectory: GlobalDirectoryOption
353359

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

360366
let directory = getDirectory(possibleDirectory: globalDirectory.directory)
361367

362-
installer.uninstallXcode(version.joined(separator: " "), directory: directory)
368+
installer.uninstallXcode(version.joined(separator: " "), directory: directory, emptyTrash: emptyTrash)
363369
.done { Uninstall.exit() }
364370
.catch { error in Uninstall.exit(withLegibleError: error) }
365371

Tests/XcodesKitTests/XcodesKitTests.swift

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -86,23 +86,23 @@ final class XcodesKitTests: XCTestCase {
8686

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

721-
installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), noSuperuser: false)
721+
installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
722722
.ensure {
723723
let url = Bundle.module.url(forResource: "LogOutput-DamagedXIP", withExtension: "txt", subdirectory: "Fixtures")!
724724
let expectedText = try! String(contentsOf: url).replacingOccurrences(of: "/Users/brandon", with: Path.home.string)
@@ -778,7 +778,7 @@ final class XcodesKitTests: XCTestCase {
778778
return Promise.value((status: 0, out: "", err: ""))
779779
}
780780

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

826-
installer.uninstallXcode("999", directory: Path.root.join("Applications"))
826+
installer.uninstallXcode("999", directory: Path.root.join("Applications"), emptyTrash: false)
827827
.ensure {
828828
XCTAssertEqual(trashedItemAtURL, installedXcodes[0].path.url)
829829
}

0 commit comments

Comments
 (0)