Skip to content

Moved XIP expansion to a temporal directory #179

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
15 changes: 14 additions & 1 deletion Sources/XcodesKit/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public struct Environment {
public var Current = Environment()

public struct Shell {
public var unxip: (URL) -> Promise<ProcessOutput> = { Process.run(Path.root.usr.bin.xip, workingDirectory: $0.deletingLastPathComponent(), "--expand", "\($0.path)") }
public var unxip: (URL, URL) -> Promise<ProcessOutput> = { Process.run(Path.root.usr.bin.xip, workingDirectory: $1, "--expand", "\($0.path)") }
public var mountDmg: (URL) -> Promise<ProcessOutput> = { Process.run(Path.root.usr.bin.join("hdiutil"), "attach", "-nobrowse", "-plist", $0.path) }
public var unmountDmg: (URL) -> Promise<ProcessOutput> = { Process.run(Path.root.usr.bin.join("hdiutil"), "detach", $0.path) }
public var expandPkg: (URL, URL) -> Promise<ProcessOutput> = { Process.run(Path.root.usr.sbin.join("pkgutil"), "--expand", $0.path, $1.path) }
Expand Down Expand Up @@ -274,6 +274,19 @@ public struct Files {
try createDirectory(url, createIntermediates, attributes)
}

public var temporalDirectory: (URL) throws -> URL = { try FileManager.default.url(for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: $0, create: true) }

public func temporalDirectory(for URL: URL) throws -> URL {
return try temporalDirectory(URL)
}

public func xcodeExpansionDirectory(archiveURL: URL, xcodeURL: URL, shouldExpandInplace: Bool) -> URL {
if shouldExpandInplace {
return archiveURL.deletingLastPathComponent()
}
return (try? Current.files.temporalDirectory(for: xcodeURL)) ?? archiveURL.deletingLastPathComponent()
}

public var contentsOfDirectory: (URL) throws -> [URL] = { try FileManager.default.contentsOfDirectory(at: $0, includingPropertiesForKeys: nil, options: []) }

public var installedXcodes: (Path) -> [InstalledXcode] = { directory in
Expand Down
26 changes: 13 additions & 13 deletions Sources/XcodesKit/XcodeInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,22 +165,22 @@ public final class XcodeInstaller {
case latestPrerelease
}

public func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, experimentalUnxip: Bool = false, emptyTrash: Bool, noSuperuser: Bool) -> Promise<InstalledXcode> {
public func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, experimentalUnxip: Bool = false, shouldExpandXipInplace: Bool, emptyTrash: Bool, noSuperuser: Bool) -> Promise<InstalledXcode> {
return firstly { () -> Promise<InstalledXcode> in
return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, attemptNumber: 0, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, attemptNumber: 0, experimentalUnxip: experimentalUnxip, shouldExpandXipInplace: shouldExpandXipInplace, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
}
.map { xcode in
Current.logging.log("\nXcode \(xcode.version.descriptionWithoutBuildMetadata) has been installed to \(xcode.path.string)".green)
return xcode
}
}

private func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, attemptNumber: Int, experimentalUnxip: Bool, emptyTrash: Bool, noSuperuser: Bool) -> Promise<InstalledXcode> {
private func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, attemptNumber: Int, experimentalUnxip: Bool, shouldExpandXipInplace: 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, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
return self.installArchivedXcode(xcode, at: url, to: destination, experimentalUnxip: experimentalUnxip, shouldExpandXipInplace: shouldExpandXipInplace, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
}
.recover { error -> Promise<InstalledXcode> in
switch error {
Expand All @@ -197,7 +197,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, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, attemptNumber: attemptNumber + 1, experimentalUnxip: experimentalUnxip, shouldExpandXipInplace: shouldExpandXipInplace, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
}
}
default:
Expand Down Expand Up @@ -377,12 +377,12 @@ public final class XcodeInstaller {
}
}

public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL, to destination: Path, experimentalUnxip: Bool = false, emptyTrash: Bool, noSuperuser: Bool) -> Promise<InstalledXcode> {
public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL, to destination: Path, experimentalUnxip: Bool = false, shouldExpandXipInplace: Bool, emptyTrash: Bool, noSuperuser: Bool) -> Promise<InstalledXcode> {
return firstly { () -> Promise<InstalledXcode> in
let destinationURL = destination.join("Xcode-\(xcode.version.descriptionWithoutBuildMetadata).app").url
switch archiveURL.pathExtension {
case "xip":
return unarchiveAndMoveXIP(at: archiveURL, to: destinationURL, experimentalUnxip: experimentalUnxip).map { xcodeURL in
return unarchiveAndMoveXIP(at: archiveURL, to: destinationURL, experimentalUnxip: experimentalUnxip, shouldExpandXipInplace: shouldExpandXipInplace).map { xcodeURL in
guard
let path = Path(url: xcodeURL),
Current.files.fileExists(atPath: path.string),
Expand Down Expand Up @@ -619,15 +619,15 @@ public final class XcodeInstaller {
}
}

func unarchiveAndMoveXIP(at source: URL, to destination: URL, experimentalUnxip: Bool) -> Promise<URL> {
func unarchiveAndMoveXIP(at source: URL, to destination: URL, experimentalUnxip: Bool, shouldExpandXipInplace: Bool) -> Promise<URL> {
let xcodeExpansionDirectory = Current.files.xcodeExpansionDirectory(archiveURL: source, xcodeURL: destination, shouldExpandInplace: shouldExpandXipInplace)
return firstly { () -> Promise<Void> in
Current.logging.log(InstallationStep.unarchiving(experimentalUnxip: experimentalUnxip).description)

if experimentalUnxip, #available(macOS 11, *) {
return Promise { seal in
Task.detached {
let output = source.deletingLastPathComponent()
let options = UnxipOptions(input: source, output: output)
let options = UnxipOptions(input: source, output: xcodeExpansionDirectory)

do {
try await Unxip(options: options).run()
Expand All @@ -639,7 +639,7 @@ public final class XcodeInstaller {
}
}

return Current.shell.unxip(source)
return Current.shell.unxip(source, xcodeExpansionDirectory)
.recover { (error) throws -> Promise<ProcessOutput> in
if case Process.PMKError.execution(_, _, let standardError) = error,
standardError?.contains("damaged and can’t be expanded") == true {
Expand All @@ -652,8 +652,8 @@ public final class XcodeInstaller {
.map { _ -> URL in
Current.logging.log(InstallationStep.moving(destination: destination.path).description)

let xcodeURL = source.deletingLastPathComponent().appendingPathComponent("Xcode.app")
let xcodeBetaURL = source.deletingLastPathComponent().appendingPathComponent("Xcode-beta.app")
let xcodeURL = xcodeExpansionDirectory.appendingPathComponent("Xcode.app")
let xcodeBetaURL = xcodeExpansionDirectory.appendingPathComponent("Xcode-beta.app")
if Current.files.fileExists(atPath: xcodeURL.path) {
try Current.files.moveItem(at: xcodeURL, to: destination)
}
Expand Down
7 changes: 5 additions & 2 deletions Sources/xcodes/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,9 @@ struct Xcodes: AsyncParsableCommand {
completion: .directory)
var directory: String?

@Flag(help: "Expands (decompress) Xcode .xip on the same directory it's downloaded, instead of using a temporal directory.")
var expandXipInplace: Bool = false

@Flag(help: "Use fastlane spaceship session.")
var useFastlaneAuth: Bool = false

Expand Down Expand Up @@ -284,10 +287,10 @@ struct Xcodes: AsyncParsableCommand {
Current.logging.log("Updating...")
return xcodeList.update(dataSource: globalDataSource.dataSource)
.then { _ -> Promise<InstalledXcode> in
xcodeInstaller.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
xcodeInstaller.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip, shouldExpandXipInplace: expandXipInplace, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
}
} else {
return xcodeInstaller.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
return xcodeInstaller.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip, shouldExpandXipInplace: expandXipInplace, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
}
}
.recover { error -> Promise<InstalledXcode> in
Expand Down
3 changes: 2 additions & 1 deletion Tests/XcodesKitTests/Environment+Mock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ extension Shell {
static var processOutputMock: ProcessOutput = (0, "", "")

static var mock = Shell(
unxip: { _ in return Promise.value(Shell.processOutputMock) },
unxip: { _, _ in return Promise.value(Shell.processOutputMock) },
mountDmg: { _ in return Promise.value(Shell.processOutputMock) },
unmountDmg: { _ in return Promise.value(Shell.processOutputMock) },
expandPkg: { _, _ in return Promise.value(Shell.processOutputMock) },
Expand Down Expand Up @@ -70,6 +70,7 @@ extension Files {
trashItem: { _ in return URL(fileURLWithPath: "\(NSHomeDirectory())/.Trash") },
createFile: { _, _, _ in return true },
createDirectory: { _, _, _ in },
temporalDirectory: { _ in return URL(fileURLWithPath: NSTemporaryDirectory()) },
contentsOfDirectory: { _ in [] },
installedXcodes: { _ in [] }
)
Expand Down
22 changes: 11 additions & 11 deletions Tests/XcodesKitTests/XcodesKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,23 +92,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")!)!
xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), shouldExpandXipInplace: true, 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)
xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), shouldExpandXipInplace: true, 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)
xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), shouldExpandXipInplace: true, emptyTrash: false, noSuperuser: false)
.catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.unexpectedCodeSigningIdentity(identifier: "", certificateAuthority: [])) }
}

Expand All @@ -121,7 +121,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")
xcodeInstaller.installArchivedXcode(xcode, at: xipURL, to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
xcodeInstaller.installArchivedXcode(xcode, at: xipURL, to: Path.root.join("Applications"), shouldExpandXipInplace: true, emptyTrash: false, noSuperuser: false)
.ensure { XCTAssertEqual(trashedItemAtURL, xipURL) }
.cauterize()
}
Expand Down Expand Up @@ -209,7 +209,7 @@ final class XcodesKitTests: XCTestCase {

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

xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), shouldExpandXipInplace: true, 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 @@ -302,7 +302,7 @@ final class XcodesKitTests: XCTestCase {

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

xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), shouldExpandXipInplace: true, 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 @@ -399,7 +399,7 @@ final class XcodesKitTests: XCTestCase {

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

xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), shouldExpandXipInplace: true, 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 @@ -492,7 +492,7 @@ final class XcodesKitTests: XCTestCase {

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

xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.home.join("Xcode"), emptyTrash: false, noSuperuser: false)
xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.home.join("Xcode"), shouldExpandXipInplace: true, 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 @@ -606,7 +606,7 @@ final class XcodesKitTests: XCTestCase {

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

xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), shouldExpandXipInplace: true, 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 @@ -713,7 +713,7 @@ final class XcodesKitTests: XCTestCase {
XcodesKit.Current.logging.log(prompt)
return "asdf"
}
Current.shell.unxip = { _ in
Current.shell.unxip = { _, _ in
unxipCallCount += 1
if unxipCallCount == 1 {
return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: "The file \"Xcode-0.0.0.xip\" is damaged and can’t be expanded."))
Expand All @@ -724,7 +724,7 @@ final class XcodesKitTests: XCTestCase {

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

xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), shouldExpandXipInplace: true, 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
Loading