diff --git a/Sources/XcodesKit/Environment.swift b/Sources/XcodesKit/Environment.swift index 9def93b..0f5298c 100644 --- a/Sources/XcodesKit/Environment.swift +++ b/Sources/XcodesKit/Environment.swift @@ -55,6 +55,13 @@ struct Files { func removeItem(at URL: URL) throws { try removeItem(URL) } + + var trashItem: (URL) throws -> URL = { try FileManager.default.trashItem(at: $0) } + + @discardableResult + func trashItem(at URL: URL) throws -> URL { + return try trashItem(URL) + } var createFile: (String, Data?, [FileAttributeKey: Any]?) -> Bool = { FileManager.default.createFile(atPath: $0, contents: $1, attributes: $2) } diff --git a/Sources/XcodesKit/FileManager+.swift b/Sources/XcodesKit/FileManager+.swift new file mode 100644 index 0000000..12c96e1 --- /dev/null +++ b/Sources/XcodesKit/FileManager+.swift @@ -0,0 +1,17 @@ +import Foundation + +extension FileManager { + /** + Moves an item to the trash. + + This implementation exists only to make the existing method more idiomatic by returning the resulting URL instead of setting the value on an inout argument. + + FB6735133: FileManager.trashItem(at:resultingItemURL:) is not an idiomatic Swift API + */ + @discardableResult + func trashItem(at url: URL) throws -> URL { + var resultingItemURL: NSURL! + try trashItem(at: url, resultingItemURL: &resultingItemURL) + return resultingItemURL as URL + } +} \ No newline at end of file diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index cd1bb68..adb1988 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -39,12 +39,12 @@ public final class XcodeInstaller { } } - public func installArchivedXcode(_ xcode: Xcode, at url: URL, passwordInput: @escaping () -> Promise) -> Promise { + public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL, archiveTrashed: @escaping (URL) -> Void, passwordInput: @escaping () -> Promise) -> Promise { return firstly { () -> Promise in let destinationURL = Path.root.join("Applications").join("Xcode-\(xcode.version.descriptionWithoutBuildMetadata).app").url - switch url.pathExtension { + switch archiveURL.pathExtension { case "xip": - return try unarchiveAndMoveXIP(at: url, to: destinationURL).map { xcodeURL in + return try unarchiveAndMoveXIP(at: archiveURL, to: destinationURL).map { xcodeURL in guard let path = Path(url: xcodeURL), Current.files.fileExists(atPath: path.string), @@ -55,11 +55,12 @@ public final class XcodeInstaller { case "dmg": throw Error.unsupportedFileFormat(extension: "dmg") default: - throw Error.unsupportedFileFormat(extension: url.pathExtension) + throw Error.unsupportedFileFormat(extension: archiveURL.pathExtension) } } .then { xcode -> Promise in - try Current.files.removeItem(at: url) + try Current.files.trashItem(at: archiveURL) + archiveTrashed(archiveURL) return when(fulfilled: self.verifySecurityAssessment(of: xcode), self.verifySigningCertificate(of: xcode.path.url)) diff --git a/Sources/xcodes/main.swift b/Sources/xcodes/main.swift index b608ac6..e319248 100644 --- a/Sources/xcodes/main.swift +++ b/Sources/xcodes/main.swift @@ -233,7 +233,9 @@ let install = Command(usage: "install ", flags: [urlFlag]) { flags, arg } } .then { xcode, url -> Promise in - return installer.installArchivedXcode(xcode, at: url, passwordInput: { () -> Promise in + return installer.installArchivedXcode(xcode, at: url, archiveTrashed: { archiveURL in + print("Xcode archive \(url.lastPathComponent) has been moved to the Trash.") + }, passwordInput: { () -> Promise in return Promise { seal in print("xcodes requires superuser privileges in order to setup some parts of Xcode.") guard let password = readSecureLine(prompt: "Password: ") else { seal.reject(XcodesError.missingSudoerPassword); return } diff --git a/Tests/XcodesKitTests/Environment+Mock.swift b/Tests/XcodesKitTests/Environment+Mock.swift index a318061..6c8ab64 100644 --- a/Tests/XcodesKitTests/Environment+Mock.swift +++ b/Tests/XcodesKitTests/Environment+Mock.swift @@ -46,6 +46,7 @@ extension Files { } }, removeItem: { _ in }, + trashItem: { _ in return URL(fileURLWithPath: "\(NSHomeDirectory())/.Trash") }, createFile: { _, _, _ in return true } ) } diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 9d1e3dd..82f9b51 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -46,7 +46,7 @@ final class XcodesKitTests: XCTestCase { let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock") let installedXcode = InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)! - installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), passwordInput: { Promise.value("") }) + installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), archiveTrashed: { _ in }, passwordInput: { Promise.value("") }) .catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.failedSecurityAssessment(xcode: installedXcode, output: "")) } } @@ -54,7 +54,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") - installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), passwordInput: { Promise.value("") }) + installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), archiveTrashed: { _ in }, passwordInput: { Promise.value("") }) .catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.codesignVerifyFailed) } } @@ -62,18 +62,21 @@ 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") - installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), passwordInput: { Promise.value("") }) + installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), archiveTrashed: { _ in }, passwordInput: { Promise.value("") }) .catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.codesignVerifyFailed) } } - func test_InstallArchivedXcode_RemovesXIPWhenFinished() { - var removedItemAtURL: URL? - Current.files.removeItem = { removedItemAtURL = $0 } + func test_InstallArchivedXcode_TrashesXIPWhenFinished() { + var trashedItemAtURL: URL? + Current.files.trashItem = { itemURL in + trashedItemAtURL = itemURL + return URL(fileURLWithPath: "\(NSHomeDirectory())/.Trash/\(itemURL.lastPathComponent)") + } let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock") let xipURL = URL(fileURLWithPath: "/Xcode-0.0.0.xip") - installer.installArchivedXcode(xcode, at: xipURL, passwordInput: { Promise.value("") }) - .ensure { XCTAssertEqual(removedItemAtURL, xipURL) } + installer.installArchivedXcode(xcode, at: xipURL, archiveTrashed: { _ in }, passwordInput: { Promise.value("") }) + .ensure { XCTAssertEqual(trashedItemAtURL, xipURL) } } func test_VerifySecurityAssessment_Fails() {