Skip to content

Commit 1c7de08

Browse files
committed
Added --delete-xip flag to xcodes install
After Pull Request #60, `xcodes` always moves Xcode's .xip to the Trash after installation. This is problematic for scripts, because on modern macOS versions, [Full Disk Access is required to programatically delete the Trash](https://apple.stackexchange.com/questions/376916/cannot-ls-trash-in-the-terminal-in-catalina-operation-not-permitted). To solve this without reducing security, added a new `--delete-xip` flag that automatically deletes the .xip after a successful installation. - This is done this way to preserve the current behaviour (moving the .xip to the Trash) by default, [as it was originally intended](#56 (comment)). This closes #185.
1 parent c979aa4 commit 1c7de08

File tree

3 files changed

+33
-22
lines changed

3 files changed

+33
-22
lines changed

Sources/XcodesKit/XcodeInstaller.swift

Lines changed: 19 additions & 11 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, deleteXip: 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, deleteXip: deleteXip, 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, deleteXip: 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, deleteXip: deleteXip, 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, deleteXip: deleteXip, 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, deleteXip: 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: deleteXip).description)
555+
if deleteXip {
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),

Sources/xcodes/main.swift

Lines changed: 4 additions & 1 deletion
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 moving it to the user's Trash.")
193+
var deleteXip: 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, deleteXip: deleteXip, noSuperuser: noSuperuser)
228231
.done { Install.exit() }
229232
.catch { error in
230233
Install.processDownloadOrInstall(error: error)

Tests/XcodesKitTests/XcodesKitTests.swift

Lines changed: 10 additions & 10 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"), deleteXip: 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"), deleteXip: 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"), deleteXip: 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"), deleteXip: 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"), deleteXip: 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"), deleteXip: 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"), deleteXip: 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"), deleteXip: 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"), deleteXip: 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"), deleteXip: 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)

0 commit comments

Comments
 (0)