Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Sources/XcodesKit/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,19 @@ public struct Files: Sendable {
try createDirectory(url, createIntermediates, attributes)
}

public var temporalDirectory: @Sendable (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: @Sendable (URL) throws -> [URL] = { try FileManager.default.contentsOfDirectory(at: $0, includingPropertiesForKeys: nil, options: []) }

public var installedXcodes: @Sendable (Path) -> [InstalledXcode] = { directory in
Expand Down
21 changes: 11 additions & 10 deletions Sources/XcodesKit/XcodeInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,12 +182,12 @@ public final class XcodeInstaller: Sendable {
}
}

public func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, experimentalUnxip: Bool = false, emptyTrash: Bool, noSuperuser: Bool) async throws -> InstalledXcode {
public func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, experimentalUnxip: Bool = false, shouldExpandXipInplace: Bool, emptyTrash: Bool, noSuperuser: Bool) async throws -> InstalledXcode {
let xcode = try await xcodeInstallRetryService.install(
shouldRetryAfterDamagedArchive: installationType.shouldRetryAfterDamagedArchive,
attempt: { _ in
let (xcode, url) = try await getXcodeArchive(installationType, dataSource: dataSource, downloader: downloader, destination: destination, willInstall: true)
return try await installArchivedXcode(xcode, at: url, to: destination, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
return try await installArchivedXcode(xcode, at: url, to: destination, experimentalUnxip: experimentalUnxip, shouldExpandXipInplace: shouldExpandXipInplace, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
},
onRetryDamagedArchive: { error, _ in
Current.logging.log(error.legibleLocalizedDescription.red)
Expand Down Expand Up @@ -404,10 +404,10 @@ public final class XcodeInstaller: Sendable {
)
}

public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL, to destination: Path, experimentalUnxip: Bool = false, emptyTrash: Bool, noSuperuser: Bool) async throws -> InstalledXcode {
public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL, to destination: Path, experimentalUnxip: Bool = false, shouldExpandXipInplace: Bool, emptyTrash: Bool, noSuperuser: Bool) async throws -> InstalledXcode {
let installedXcode: InstalledXcode
do {
installedXcode = try await xcodeArchiveInstallService(experimentalUnxip: experimentalUnxip, destination: destination)
installedXcode = try await xcodeArchiveInstallService(experimentalUnxip: experimentalUnxip, shouldExpandXipInplace: shouldExpandXipInplace, destination: destination)
.installArchivedXcode(
xcode,
at: archiveURL,
Expand Down Expand Up @@ -543,10 +543,10 @@ public final class XcodeInstaller: Sendable {
}
}

private func xcodeArchiveInstallService(experimentalUnxip: Bool, destination: Path) -> XcodesKit.XcodeArchiveInstallService {
private func xcodeArchiveInstallService(experimentalUnxip: Bool, shouldExpandXipInplace: Bool, destination: Path) -> XcodesKit.XcodeArchiveInstallService {
XcodesKit.XcodeArchiveInstallService(
destinationDirectory: destination,
unarchiveService: xcodeUnarchiveService(experimentalUnxip: experimentalUnxip),
unarchiveService: xcodeUnarchiveService(experimentalUnxip: experimentalUnxip, shouldExpandXipInplace: shouldExpandXipInplace, destination: destination.url),
validationService: xcodeValidationService,
fileExists: { path in Current.files.fileExists(atPath: path) },
makeInstalledXcode: { path in
Expand Down Expand Up @@ -604,15 +604,16 @@ public final class XcodeInstaller: Sendable {
Current.logging.log(installedXcode.path.string)
}

private func xcodeUnarchiveService(experimentalUnxip: Bool) -> XcodesKit.XcodeUnarchiveService {
// TODO: Remove `shouldExpandXipInplace` in favor of an optional `destination`?
private func xcodeUnarchiveService(experimentalUnxip: Bool, shouldExpandXipInplace: Bool, destination: URL) -> XcodesKit.XcodeUnarchiveService {
XcodesKit.XcodeUnarchiveService(
unarchive: { source in
let xcodeExpansionDirectory = Current.files.xcodeExpansionDirectory(archiveURL: source, xcodeURL: destination, shouldExpandInplace: shouldExpandXipInplace)
if experimentalUnxip, #available(macOS 11, *) {
let output = source.deletingLastPathComponent()
let options = UnxipOptions(input: source, output: output)
let options = UnxipOptions(input: source, output: xcodeExpansionDirectory)
try await Unxip(options: options).run()
} else {
_ = try await Current.shell.unxip(source)
_ = try await Current.shell.unxip(source, xcodeExpansionDirectory)
}
},
fileExists: { path in Current.files.fileExists(atPath: path) },
Expand Down
5 changes: 4 additions & 1 deletion Sources/xcodes/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,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 @@ -310,7 +313,7 @@ struct Xcodes: AsyncParsableCommand {
_ = try await services.xcodeList.updateAvailableXcodes(dataSource: globalDataSource.dataSource)
}

let xcode = try await services.xcodeInstaller.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
let xcode = try await services.xcodeInstaller.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip, shouldExpandXipInplace: expandXipInplace, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
if select {
try await selectXcodeAsync(shouldPrint: print, pathOrVersion: xcode.path.string, directory: destination, fallbackToInteractive: false)
}
Expand Down
2 changes: 1 addition & 1 deletion Tests/XcodesKitTests/Environment+Mock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ extension Shell {

static var mock: Shell {
Shell(
unxip: { _ in Shell.processOutputMock },
unxip: { _, _ in Shell.processOutputMock },
mountDmg: { _ in Shell.processOutputMock },
unmountDmg: { _ in Shell.processOutputMock },
expandPkg: { _, _ in Shell.processOutputMock },
Expand Down
24 changes: 13 additions & 11 deletions Tests/XcodesKitTests/XcodesKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ final class XcodesKitTests: XCTestCase {
dataSource: .xcodeReleases,
downloader: .urlSession,
destination: Path.root.join("Applications"),
shouldExpandXipInplace: true,
emptyTrash: false,
noSuperuser: true
)
Expand Down Expand Up @@ -172,6 +173,7 @@ final class XcodesKitTests: XCTestCase {
dataSource: .xcodeReleases,
downloader: .urlSession,
destination: Path.root.join("Applications"),
shouldExpandXipInplace: true,
emptyTrash: false,
noSuperuser: true
)
Expand Down Expand Up @@ -236,7 +238,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")!)!
do {
_ = try await xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
_ = try await xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), shouldExpandXipInplace: true, emptyTrash: false, noSuperuser: false)
XCTFail("Expected install to fail security assessment")
} catch {
XCTAssertEqual(error as? XcodeInstaller.Error, XcodeInstaller.Error.failedSecurityAssessment(xcode: installedXcode, output: ""))
Expand All @@ -248,7 +250,7 @@ final class XcodesKitTests: XCTestCase {

let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock", releaseDate: nil)
do {
_ = try await xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
_ = try await xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), shouldExpandXipInplace: true, emptyTrash: false, noSuperuser: false)
XCTFail("Expected install to fail code signing verification")
} catch {
XCTAssertEqual(error as? XcodeInstaller.Error, XcodeInstaller.Error.codesignVerifyFailed(output: ""))
Expand All @@ -260,7 +262,7 @@ final class XcodesKitTests: XCTestCase {

let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock", releaseDate: nil)
do {
_ = try await xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
_ = try await xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), shouldExpandXipInplace: true, emptyTrash: false, noSuperuser: false)
XCTFail("Expected install to fail signing identity check")
} catch {
XCTAssertEqual(error as? XcodeInstaller.Error, XcodeInstaller.Error.unexpectedCodeSigningIdentity(identifier: "", certificateAuthority: []))
Expand All @@ -287,7 +289,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")
_ = try await xcodeInstaller.installArchivedXcode(xcode, at: xipURL, to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
_ = try await xcodeInstaller.installArchivedXcode(xcode, at: xipURL, to: Path.root.join("Applications"), shouldExpandXipInplace: true, emptyTrash: false, noSuperuser: false)
XCTAssertEqual(trashedItemAtURL.value, xipURL)
}

Expand Down Expand Up @@ -368,7 +370,7 @@ final class XcodesKitTests: XCTestCase {
return "asdf"
}

_ = try await xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
_ = try await xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), shouldExpandXipInplace: true, emptyTrash: false, noSuperuser: false)
let url = Bundle.module.url(forResource: "LogOutput-FullHappyPath", withExtension: "txt", subdirectory: "Fixtures")!
XCTAssertEqual(log.value, try String(contentsOf: url))
}
Expand Down Expand Up @@ -448,7 +450,7 @@ final class XcodesKitTests: XCTestCase {
return "asdf"
}

_ = try await xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
_ = try await xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), shouldExpandXipInplace: true, emptyTrash: false, noSuperuser: false)
let url = Bundle.module.url(forResource: "LogOutput-FullHappyPath-NoColor", withExtension: "txt", subdirectory: "Fixtures")!
XCTAssertEqual(log.value, try String(contentsOf: url))
}
Expand Down Expand Up @@ -531,7 +533,7 @@ final class XcodesKitTests: XCTestCase {
return "asdf"
}

_ = try await xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
_ = try await xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), shouldExpandXipInplace: true, emptyTrash: false, noSuperuser: false)
let url = Bundle.module.url(forResource: "LogOutput-FullHappyPath-NonInteractiveTerminal", withExtension: "txt", subdirectory: "Fixtures")!
XCTAssertEqual(log.value, try String(contentsOf: url))
}
Expand Down Expand Up @@ -611,7 +613,7 @@ final class XcodesKitTests: XCTestCase {
return "asdf"
}

_ = try await xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.home.join("Xcode"), emptyTrash: false, noSuperuser: false)
_ = try await xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.home.join("Xcode"), shouldExpandXipInplace: true, emptyTrash: false, noSuperuser: false)
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)
XCTAssertEqual(log.value, expectedText)
Expand Down Expand Up @@ -710,7 +712,7 @@ final class XcodesKitTests: XCTestCase {
return "test@example.com"
}

_ = try await xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
_ = try await xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), shouldExpandXipInplace: true, emptyTrash: false, noSuperuser: false)
let url = Bundle.module.url(forResource: "LogOutput-IncorrectSavedPassword", withExtension: "txt", subdirectory: "Fixtures")!
XCTAssertEqual(log.value, try String(contentsOf: url))
XCTAssertEqual(passwordEnvCallCount.value, 2)
Expand Down Expand Up @@ -801,15 +803,15 @@ final class XcodesKitTests: XCTestCase {
XcodesCLIKit.Current.logging.log(prompt)
return "asdf"
}
Current.shell.unxip = { _ in
Current.shell.unxip = { _, _ in
if unxipCallCount.increment() == 1 {
throw ProcessExecutionError(process: Process(), standardOutput: nil, standardError: "The file \"Xcode-0.0.0.xip\" is damaged and can’t be expanded.")
} else {
return Shell.processOutputMock
}
}

_ = try await xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false)
_ = try await xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), shouldExpandXipInplace: true, emptyTrash: false, noSuperuser: false)
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)
XCTAssertEqual(log.value, expectedText)
Expand Down
Loading