From fc6cbab35525cd5ff349de96179b49978a234869 Mon Sep 17 00:00:00 2001 From: wmehanna Date: Sun, 26 Apr 2026 17:22:58 -0400 Subject: [PATCH] fix: select architecture-appropriate Xcode build xcodereleases.com/data.json contains multiple builds per version: - Universal (arm64 + x86_64) - Apple Silicon (arm64 only) Previously first(withVersion:) returned first match regardless of architecture, causing Intel Macs to download arm64-only builds. Now firstCompatible(withVersion:hostArchitecture:) prefers: 1. Universal build (both architectures) 2. Architecture-specific build matching host 3. First match (fallback) Fixes #456 --- Sources/XcodesKit/Environment.swift | 13 +++++ .../XcodesKit/Models+FirstWithVersion.swift | 39 ++++++++++++++ Sources/XcodesKit/Models.swift | 6 ++- Sources/XcodesKit/XcodeInstaller.swift | 3 +- Sources/XcodesKit/XcodeList.swift | 52 +++++++++++++++++-- .../Models+FirstWithVersionTests.swift | 38 +++++++++++++- 6 files changed, 143 insertions(+), 8 deletions(-) diff --git a/Sources/XcodesKit/Environment.swift b/Sources/XcodesKit/Environment.swift index 393e19c9..ef114013 100644 --- a/Sources/XcodesKit/Environment.swift +++ b/Sources/XcodesKit/Environment.swift @@ -43,6 +43,19 @@ public struct Shell { public var touchInstallCheck: (String, String, String) -> Promise = { Process.run(Path.root.usr.bin/"touch", "\($0)com.apple.dt.Xcode.InstallCheckCache_\($1)_\($2)") } public var installedRuntimes: () -> Promise = { 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 = { Process.run(Path.root.usr.bin.sudo, "-nv") } public var authenticateSudoerIfNecessary: (@escaping () -> Promise) -> Promise = { passwordInput in firstly { () -> Promise in diff --git a/Sources/XcodesKit/Models+FirstWithVersion.swift b/Sources/XcodesKit/Models+FirstWithVersion.swift index e18bb396..51440c78 100644 --- a/Sources/XcodesKit/Models+FirstWithVersion.swift +++ b/Sources/XcodesKit/Models+FirstWithVersion.swift @@ -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(version: Version, in xcodes: [XcodeType], versionKeyPath: KeyPath) -> [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 { diff --git a/Sources/XcodesKit/Models.swift b/Sources/XcodesKit/Models.swift index 4a388314..b086b7a8 100644 --- a/Sources/XcodesKit/Models.swift +++ b/Sources/XcodesKit/Models.swift @@ -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 } } diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index 44747c6e..82187e05 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -305,7 +305,8 @@ public final class XcodeInstaller { } } .then { () -> Promise 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) } diff --git a/Sources/XcodesKit/XcodeList.swift b/Sources/XcodesKit/XcodeList.swift index b46c970d..832c62fb 100644 --- a/Sources/XcodesKit/XcodeList.swift +++ b/Sources/XcodesKit/XcodeList.swift @@ -136,7 +136,35 @@ 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")!)) @@ -144,23 +172,39 @@ extension XcodeList { .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 diff --git a/Tests/XcodesKitTests/Models+FirstWithVersionTests.swift b/Tests/XcodesKitTests/Models+FirstWithVersionTests.swift index df6d8dd5..18cb1ab5 100644 --- a/Tests/XcodesKitTests/Models+FirstWithVersionTests.swift +++ b/Tests/XcodesKitTests/Models+FirstWithVersionTests.swift @@ -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")!)) @@ -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") + } }