diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index 5e2a074..bda9855 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -250,8 +250,17 @@ extension AppState { func unarchiveAndMoveXIP(availableXcode: AvailableXcode, at source: URL, to destination: URL) -> AnyPublisher { self.setInstallationStep(of: availableXcode.version, to: .unarchiving) - - return unxipOrUnxipExperiment(source) + + // Use a version-specific extraction directory to prevent conflicts when + // multiple Xcode versions are installed concurrently. Without this, all + // XIP files extract to the same parent directory as "Xcode.app", causing + // a race condition where one version’s extracted app gets renamed to + // another version’s destination path. + let extractionDirectory = source.deletingLastPathComponent() + .appendingPathComponent("Xcode-\(availableXcode.version)-extract") + try? Current.files.createDirectory(at: extractionDirectory, withIntermediateDirectories: true, attributes: nil) + + return unxipOrUnxipExperiment(source, extractionDirectory: extractionDirectory) .catch { error -> AnyPublisher in if let executionError = error as? ProcessExecutionError { if executionError.standardError.contains("damaged and can’t be expanded") { @@ -269,8 +278,8 @@ extension AppState { .tryMap { output -> URL in self.setInstallationStep(of: availableXcode.version, to: .moving(destination: destination.path)) - let xcodeURL = source.deletingLastPathComponent().appendingPathComponent("Xcode.app") - let xcodeBetaURL = source.deletingLastPathComponent().appendingPathComponent("Xcode-beta.app") + let xcodeURL = extractionDirectory.appendingPathComponent("Xcode.app") + let xcodeBetaURL = extractionDirectory.appendingPathComponent("Xcode-beta.app") if Current.files.fileExists(atPath: xcodeURL.path) { try Current.files.moveItem(at: xcodeURL, to: destination) } @@ -278,6 +287,7 @@ extension AppState { try Current.files.moveItem(at: xcodeBetaURL, to: destination) } + try? Current.files.removeItem(at: extractionDirectory) return destination } .handleEvents(receiveCancel: { @@ -287,17 +297,20 @@ extension AppState { if Current.files.fileExists(atPath: destination.path) { try? Current.files.removeItem(destination) } + if Current.files.fileExists(atPath: extractionDirectory.path) { + try? Current.files.removeItem(extractionDirectory) + } }) .eraseToAnyPublisher() } - - func unxipOrUnxipExperiment(_ source: URL) -> AnyPublisher { + + func unxipOrUnxipExperiment(_ source: URL, extractionDirectory: URL) -> AnyPublisher { if unxipExperiment { // All hard work done by https://github.com/saagarjha/unxip // Compiled to binary with `swiftc -parse-as-library -O unxip.swift` - return Current.shell.unxipExperiment(source) + return Current.shell.unxipExperiment(source, extractionDirectory) } else { - return Current.shell.unxip(source) + return Current.shell.unxip(source, extractionDirectory) } } diff --git a/Xcodes/Backend/Environment.swift b/Xcodes/Backend/Environment.swift index b515e11..251575c 100644 --- a/Xcodes/Backend/Environment.swift +++ b/Xcodes/Backend/Environment.swift @@ -25,7 +25,7 @@ public struct Environment { public var Current = Environment() public struct Shell { - public var unxip: (URL) -> AnyPublisher = { Process.run(Path.root.usr.bin.xip, workingDirectory: $0.deletingLastPathComponent(), "--expand", "\($0.path)") } + public var unxip: (URL, URL) -> AnyPublisher = { xipURL, workingDirectory in Process.run(Path.root.usr.bin.xip, workingDirectory: workingDirectory, "--expand", "\(xipURL.path)") } public var spctlAssess: (URL) -> AnyPublisher = { Process.run(Path.root.usr.sbin.spctl, "--assess", "--verbose", "--type", "execute", "\($0.path)") } public var codesignVerify: (URL) -> AnyPublisher = { Process.run(Path.root.usr.bin.codesign, "-vv", "-d", "\($0.path)") } public var buildVersion: () -> AnyPublisher = { Process.run(Path.root.usr.bin.sw_vers, "-buildVersion") } @@ -191,9 +191,9 @@ public struct Shell { } - public var unxipExperiment: (URL) -> AnyPublisher = { url in + public var unxipExperiment: (URL, URL) -> AnyPublisher = { xipURL, workingDirectory in let unxipPath = Path(url: Bundle.main.url(forAuxiliaryExecutable: "unxip")!)! - return Process.run(unxipPath.url, workingDirectory: url.deletingLastPathComponent(), ["\(url.path)"]) + return Process.run(unxipPath.url, workingDirectory: workingDirectory, ["\(xipURL.path)"]) } public var downloadRuntime: (String, String, String?) -> AsyncThrowingStream = { platform, version, architecture in diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index 4be9ca3..9421e58 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -291,7 +291,7 @@ class AppStateTests: XCTestCase { } func test_Install_NotEnoughFreeSpace() throws { - Current.shell.unxip = { _ in + Current.shell.unxip = { _, _ in Fail(error: ProcessExecutionError( process: Process(), standardOutput: "xip: signing certificate was \"Development Update\" (validation not attempted)", diff --git a/XcodesTests/Environment+Mock.swift b/XcodesTests/Environment+Mock.swift index f030d79..8846e46 100644 --- a/XcodesTests/Environment+Mock.swift +++ b/XcodesTests/Environment+Mock.swift @@ -18,7 +18,7 @@ extension Shell { static var processOutputMock: ProcessOutput = (0, "", "") static var mock = Shell( - unxip: { _ in return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, + unxip: { _, _ in return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, spctlAssess: { _ in return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, codesignVerify: { _ in return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, buildVersion: { return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() },