diff --git a/Sources/XcodesKit/Environment.swift b/Sources/XcodesKit/Environment.swift index 3b5a637..03c59c4 100644 --- a/Sources/XcodesKit/Environment.swift +++ b/Sources/XcodesKit/Environment.swift @@ -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 diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index f829d21..d60088c 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -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) @@ -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, @@ -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 @@ -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) }, diff --git a/Sources/xcodes/App.swift b/Sources/xcodes/App.swift index 36ee7e5..3ebfbf7 100644 --- a/Sources/xcodes/App.swift +++ b/Sources/xcodes/App.swift @@ -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 @@ -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) } diff --git a/Tests/XcodesKitTests/Environment+Mock.swift b/Tests/XcodesKitTests/Environment+Mock.swift index 5d38145..e3da1cf 100644 --- a/Tests/XcodesKitTests/Environment+Mock.swift +++ b/Tests/XcodesKitTests/Environment+Mock.swift @@ -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 }, diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 900e15e..d14c1e4 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -115,6 +115,7 @@ final class XcodesKitTests: XCTestCase { dataSource: .xcodeReleases, downloader: .urlSession, destination: Path.root.join("Applications"), + shouldExpandXipInplace: true, emptyTrash: false, noSuperuser: true ) @@ -172,6 +173,7 @@ final class XcodesKitTests: XCTestCase { dataSource: .xcodeReleases, downloader: .urlSession, destination: Path.root.join("Applications"), + shouldExpandXipInplace: true, emptyTrash: false, noSuperuser: true ) @@ -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: "")) @@ -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: "")) @@ -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: [])) @@ -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) } @@ -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)) } @@ -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)) } @@ -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)) } @@ -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) @@ -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) @@ -801,7 +803,7 @@ 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 { @@ -809,7 +811,7 @@ final class XcodesKitTests: XCTestCase { } } - _ = 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)