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 @@ -43,6 +43,19 @@ public struct Shell {
public var touchInstallCheck: (String, String, String) -> Promise<ProcessOutput> = { Process.run(Path.root.usr.bin/"touch", "\($0)com.apple.dt.Xcode.InstallCheckCache_\($1)_\($2)") }
public var installedRuntimes: () -> Promise<ProcessOutput> = { Process.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "list", "-j") }

/// Returns the current host architecture: "arm64" for Apple Silicon, "x86_64" for Intel
public var currentHostArchitecture: () -> String = {
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/uname")
task.arguments = ["-m"]
let pipe = Pipe()
task.standardOutput = pipe
try? task.run()
task.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "x86_64"
}

public var validateSudoAuthentication: () -> Promise<ProcessOutput> = { Process.run(Path.root.usr.bin.sudo, "-nv") }
public var authenticateSudoerIfNecessary: (@escaping () -> Promise<String>) -> Promise<String?> = { passwordInput in
firstly { () -> Promise<String?> in
Expand Down
39 changes: 39 additions & 0 deletions Sources/XcodesKit/Models+FirstWithVersion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,45 @@ public extension Array where Element == Xcode {
func first(withVersion version: Version) -> Xcode? {
findXcode(version: version, in: self, versionKeyPath: \.version)
}

/// Returns the best compatible Xcode for the given version and host architecture.
///
/// Selection priority:
/// 1. Universal build (contains both arm64 and x86_64)
/// 2. Architecture-specific build matching host (arm64 for Apple Silicon, x86_64 for Intel)
/// 3. First match (fallback)
func firstCompatible(withVersion version: Version, hostArchitecture: String) -> Xcode? {
// First try to find using the standard version matching
let matches = findAllXcodes(version: version, in: self, versionKeyPath: \.version)
guard !matches.isEmpty else { return nil }

// Priority 1: Universal build (contains both architectures)
if let universal = matches.first(where: { ($0.architectures ?? []).contains("arm64") && $0.architectures!.contains("x86_64") }) {
return universal
}

// Priority 2: Architecture-specific build matching host
if let matching = matches.first(where: { ($0.architectures ?? []).contains(hostArchitecture) }) {
return matching
}

// Priority 3: Fall back to first match
return matches.first
}

/// Returns all Xcodes with the same version (helper for architecture-aware selection)
private func findAllXcodes<XcodeType>(version: Version, in xcodes: [XcodeType], versionKeyPath: KeyPath<XcodeType, Version>) -> [XcodeType] {
// Look for equivalent matches
let equivalentMatches = xcodes.filter { $0[keyPath: versionKeyPath].isEquivalent(to: version) }
if !equivalentMatches.isEmpty {
return equivalentMatches
}
// If version without prerelease/build identifiers, find matches without all identifiers
if version.prereleaseIdentifiers.isEmpty && version.buildMetadataIdentifiers.isEmpty {
return xcodes.filter { $0[keyPath: versionKeyPath].isEqualWithoutAllIdentifiers(to: version) }
}
return []
}
}

public extension Array where Element == InstalledXcode {
Expand Down
6 changes: 4 additions & 2 deletions Sources/XcodesKit/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,18 @@ public struct Xcode: Codable, Equatable {
public let url: URL
public let filename: String
public let releaseDate: Date?
public let architectures: [String]?

public var downloadPath: String {
return url.path
}
public init(version: Version, url: URL, filename: String, releaseDate: Date?) {

public init(version: Version, url: URL, filename: String, releaseDate: Date?, architectures: [String]? = nil) {
self.version = version
self.url = url
self.filename = filename
self.releaseDate = releaseDate
self.architectures = architectures
}
}

Expand Down
3 changes: 2 additions & 1 deletion Sources/XcodesKit/XcodeInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,8 @@ public final class XcodeInstaller {
}
}
.then { () -> Promise<Xcode> in
guard let xcode = self.xcodeList.availableXcodes.first(withVersion: version) else {
let hostArch = Current.shell.currentHostArchitecture()
guard let xcode = self.xcodeList.availableXcodes.firstCompatible(withVersion: version, hostArchitecture: hostArch) else {
throw Error.unavailableVersion(version)
}

Expand Down
52 changes: 48 additions & 4 deletions Sources/XcodesKit/XcodeList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,31 +136,75 @@ extension XcodeList {

extension XcodeList {
// MARK: - XcodeReleases


// Local type that captures architectures from data.json (XCModel.Link doesn't have architectures field)
private struct XcodeReleaseEntry: Codable {
let name: String
let version: VersionInfo
let date: DateInfo
let links: Links

struct VersionInfo: Codable {
let number: String?
let build: String?
}

struct DateInfo: Codable {
let year: Int
let month: Int
let day: Int
}

struct Links: Codable {
let download: DownloadLink?
}

struct DownloadLink: Codable {
let url: URL
let architectures: [String]?
}
}

private func xcodeReleases() -> Promise<[Xcode]> {
return firstly { () -> Promise<(data: Data, response: URLResponse)> in
Current.network.dataTask(with: URLRequest(url: URL(string: "https://xcodereleases.com/data.json")!))
}
.map { (data, response) in
let decoder = JSONDecoder()
let xcReleasesXcodes = try decoder.decode([XCModel.Xcode].self, from: data)
let entries = try decoder.decode([XcodeReleaseEntry].self, from: data)

// Build a map of version -> architectures from entries
var architectureMap: [String: [String]?] = [:]
for entry in entries {
let versionKey = "\(entry.version.number ?? "")-\(entry.version.build ?? "")"
if architectureMap[versionKey] == nil {
architectureMap[versionKey] = entry.links.download?.architectures
}
}

let xcodes = xcReleasesXcodes.compactMap { xcReleasesXcode -> Xcode? in
guard
let downloadURL = xcReleasesXcode.links?.download?.url,
let version = Version(xcReleasesXcode: xcReleasesXcode)
else { return nil }

let releaseDate = Calendar(identifier: .gregorian).date(from: DateComponents(
year: xcReleasesXcode.date.year,
month: xcReleasesXcode.date.month,
day: xcReleasesXcode.date.day
))


// Get architectures from the entry map
let versionKey = "\(xcReleasesXcode.version.number ?? "")-\(xcReleasesXcode.version.build ?? "")"
let architectures = architectureMap[versionKey] ?? nil

return Xcode(
version: version,
url: downloadURL,
filename: String(downloadURL.path.suffix(fromLast: "/")),
releaseDate: releaseDate
releaseDate: releaseDate,
architectures: architectures
)
}
return xcodes
Expand Down
38 changes: 37 additions & 1 deletion Tests/XcodesKitTests/Models+FirstWithVersionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ final class ModelsFirstWithVersionTests: XCTestCase {
nil
)
}

func test_XcodeVersionEqualWithoutAllIdentifiers() {
XCTAssertTrue(Version("12.0.0-beta")!.isEqualWithoutAllIdentifiers(to: Version(xcodeVersion: "12")!))
XCTAssertTrue(Version("12.0.0-beta")!.isEqualWithoutAllIdentifiers(to: Version(xcodeVersion: "12.0")!))
Expand All @@ -142,4 +142,40 @@ final class ModelsFirstWithVersionTests: XCTestCase {
XCTAssertTrue(Version("12.0.0-beta+qwerty")!.isEqualWithoutAllIdentifiers(to: Version(xcodeVersion: "12.0")!))
XCTAssertTrue(Version("12.0.0-beta+qwerty")!.isEqualWithoutAllIdentifiers(to: Version(xcodeVersion: "12.0.0")!))
}

func test_firstCompatibleXcode_prefersUniversal() {
// Test data with Universal, arm64-only, and x86_64-only builds
let universalXcode = Xcode(version: Version("26.2.0")!, url: URL(string: "https://example.com/Universal.xip")!, filename: "Universal.xip", releaseDate: nil, architectures: ["arm64", "x86_64"])
let arm64Xcode = Xcode(version: Version("26.2.0")!, url: URL(string: "https://example.com/arm64.xip")!, filename: "arm64.xip", releaseDate: nil, architectures: ["arm64"])
let x86Xcode = Xcode(version: Version("26.2.0")!, url: URL(string: "https://example.com/x86.xip")!, filename: "x86.xip", releaseDate: nil, architectures: ["x86_64"])

// On arm64 host, should prefer Universal over arm64-only
let arm64Host = [universalXcode, arm64Xcode, x86Xcode]
XCTAssertEqual(arm64Host.firstCompatible(withVersion: Version("26.2.0")!, hostArchitecture: "arm64")?.filename, "Universal.xip")

// On x86_64 host, should prefer Universal over x86_64-only
let x86Host = [arm64Xcode, universalXcode, x86Xcode] // different order
XCTAssertEqual(x86Host.firstCompatible(withVersion: Version("26.2.0")!, hostArchitecture: "x86_64")?.filename, "Universal.xip")
}

func test_firstCompatibleXcode_fallsBackToMatchingArch() {
let arm64Xcode = Xcode(version: Version("26.2.0")!, url: URL(string: "https://example.com/arm64.xip")!, filename: "arm64.xip", releaseDate: nil, architectures: ["arm64"])
let x86Xcode = Xcode(version: Version("26.2.0")!, url: URL(string: "https://example.com/x86.xip")!, filename: "x86.xip", releaseDate: nil, architectures: ["x86_64"])

// On arm64 host without Universal, should get arm64
let arm64Only = [x86Xcode, arm64Xcode]
XCTAssertEqual(arm64Only.firstCompatible(withVersion: Version("26.2.0")!, hostArchitecture: "arm64")?.filename, "arm64.xip")

// On x86_64 host without Universal, should get x86_64
XCTAssertEqual(arm64Only.firstCompatible(withVersion: Version("26.2.0")!, hostArchitecture: "x86_64")?.filename, "x86.xip")
}

func test_firstCompatibleXcode_noArchitecturesFallsBackToFirst() {
let noArchXcode = Xcode(version: Version("26.2.0")!, url: URL(string: "https://example.com/noarch.xip")!, filename: "noarch.xip", releaseDate: nil, architectures: nil)
let arm64Xcode = Xcode(version: Version("26.2.0")!, url: URL(string: "https://example.com/arm64.xip")!, filename: "arm64.xip", releaseDate: nil, architectures: ["arm64"])

// When entry has no architectures, falls back to first match
let mixed = [arm64Xcode, noArchXcode]
XCTAssertEqual(mixed.firstCompatible(withVersion: Version("26.2.0")!, hostArchitecture: "x86_64")?.filename, "arm64.xip")
}
}