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
11 changes: 11 additions & 0 deletions Sources/XcodesKit/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ public struct Shell {
}
public var isRoot: () -> Bool = { NSUserName() == "root" }

/// Returns the machine architecture (e.g., "arm64", "x86_64")
public var machineArchitecture: () -> String = {
var systemInfo = utsname()
uname(&systemInfo)
return withUnsafePointer(to: &systemInfo.machine) {
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
String(cString: $0)
}
}
}

/// Returns the path of an executable within the directories in the PATH environment variable.
public var findExecutable: (_ executableName: String) -> Path? = { executableName in
guard let path = ProcessInfo.processInfo.environment["PATH"] else { return nil }
Expand Down
66 changes: 54 additions & 12 deletions Sources/XcodesKit/XcodeList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ public final class XcodeList {
}

public func update(dataSource: DataSource) -> Promise<[Xcode]> {
let xcodesPromise: Promise<[Xcode]>

switch dataSource {
case .apple:
return when(fulfilled: releasedXcodes(), prereleaseXcodes())
xcodesPromise = when(fulfilled: releasedXcodes(), prereleaseXcodes())
.map { releasedXcodes, prereleaseXcodes in
// Starting with Xcode 11 beta 6, developer.apple.com/download and developer.apple.com/download/more both list some pre-release versions of Xcode.
// Previously pre-release versions only appeared on developer.apple.com/download.
Expand All @@ -34,19 +36,19 @@ public final class XcodeList {
let xcodes = releasedXcodes.filter { releasedXcode in
prereleaseXcodes.contains { $0.version.isEquivalent(to: releasedXcode.version) } == false
} + prereleaseXcodes
self.availableXcodes = xcodes
self.lastUpdated = Date()
try? self.cacheAvailableXcodes(xcodes)
return xcodes
}
case .xcodeReleases:
return xcodeReleases()
.map { xcodes in
self.availableXcodes = xcodes
self.lastUpdated = Date()
try? self.cacheAvailableXcodes(xcodes)
return xcodes
}
xcodesPromise = xcodeReleases()
}

return xcodesPromise.map { xcodes in
// Apply architecture filtering centrally for all data sources
let filtered = self.filterArchitectureVariants(xcodes)
self.availableXcodes = filtered
self.lastUpdated = Date()
try? self.cacheAvailableXcodes(filtered)
return filtered
}
}
}
Expand Down Expand Up @@ -190,5 +192,45 @@ extension XcodeList {
}
}
return filteredXcodes
}
}

/// Filters architecture variants to select the appropriate one for the current machine.
/// When multiple architecture variants exist for the same version (e.g., Apple_silicon and Universal),
/// selects the one that matches the current machine's architecture.
private func filterArchitectureVariants(_ xcodes: [Xcode]) -> [Xcode] {
let machine = Current.shell.machineArchitecture()
let isAppleSilicon = machine == "arm64"

// Group by version to find duplicates
var versionGroups: [Version: [Xcode]] = [:]
for xcode in xcodes {
versionGroups[xcode.version, default: []].append(xcode)
}

var filtered: [Xcode] = []
for (_, variants) in versionGroups {
if variants.count == 1 {
// No architecture variants, keep as-is
filtered.append(variants[0])
} else {
// Multiple variants - select based on architecture
let selected: Xcode
if isAppleSilicon {
// Prefer Apple_silicon, fallback to Universal
selected = variants.first { $0.url.absoluteString.contains("Apple_silicon") }
?? variants.first { $0.url.absoluteString.contains("Universal") }
?? variants[0]
} else {
// On Intel, prefer Universal, avoid Apple_silicon
selected = variants.first { $0.url.absoluteString.contains("Universal") }
?? variants.first { !$0.url.absoluteString.contains("Apple_silicon") }
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Datasource does not include an arch parameter, so the only way to distinguish is by using the file name

?? variants[0]
}

filtered.append(selected)
}
}

return filtered
}
}
1 change: 1 addition & 0 deletions Tests/XcodesKitTests/Environment+Mock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ extension Shell {
xcodeSelectPrintPath: { return Promise.value(Shell.processOutputMock) },
xcodeSelectSwitch: { _, _ in return Promise.value(Shell.processOutputMock) },
isRoot: { true },
machineArchitecture: { "arm64" },
readLine: { _ in return nil },
readSecureLine: { _, _ in return nil },
env: { _ in nil },
Expand Down
189 changes: 189 additions & 0 deletions Tests/XcodesKitTests/XcodesKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1479,4 +1479,193 @@ final class XcodesKitTests: XCTestCase {
XCTAssertEqual(cookies[2].path, "/")
XCTAssertEqual(cookies[2].isSecure, true)
}

func test_XcodeList_Update_FiltersArchitectureVariants_ARM64SelectsAppleSilicon() throws {
XcodesKit.Current.shell.machineArchitecture = { "arm64" }

let testDate = Date(timeIntervalSince1970: 0)
let downloads = Downloads(downloads: [
Download(name: "Xcode 16.2", files: [
Download.File(remotePath: "Developer_Tools/Xcode_16.2/Xcode_16.2_Apple_silicon.xip")
], dateModified: testDate),
Download(name: "Xcode 16.2", files: [
Download.File(remotePath: "Developer_Tools/Xcode_16.2/Xcode_16.2_Universal.xip")
], dateModified: testDate)
])

let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .formatted(.downloadsDateModified)
let downloadsData = try encoder.encode(downloads)

XcodesKit.Current.network.dataTask = { url in
do {
let requestURL = try XCTUnwrap(url.pmkRequest.url)
let downloadsURL = try XCTUnwrap(URLRequest.downloads.url)
let response = try XCTUnwrap(HTTPURLResponse(url: requestURL, statusCode: 200, httpVersion: nil, headerFields: nil))

if requestURL == downloadsURL {
return Promise.value((data: downloadsData, response: response))
}
// Return empty data for prerelease endpoint
return Promise.value((data: Data(), response: response))
} catch {
return Promise(error: error)
}
}

let expectation = self.expectation(description: "update completes")
let xcodesList = XcodeList()

xcodesList.update(dataSource: .apple)
.done { xcodes in
XCTAssertEqual(xcodes.count, 1)
XCTAssertEqual(xcodes[0].url.absoluteString, "https://download.developer.apple.com/Developer_Tools/Xcode_16.2/Xcode_16.2_Apple_silicon.xip")
expectation.fulfill()
}
.catch { error in
XCTFail("Update failed: \(error)")
}

waitForExpectations(timeout: 1.0)
}

func test_XcodeList_Update_FiltersArchitectureVariants_ARM64FallsBackToUniversal() throws {
XcodesKit.Current.shell.machineArchitecture = { "arm64" }

let testDate = Date(timeIntervalSince1970: 0)
let downloads = Downloads(downloads: [
Download(name: "Xcode 15.0", files: [
Download.File(remotePath: "Developer_Tools/Xcode_15.0/Xcode_15.0_Universal.xip")
], dateModified: testDate)
])

let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .formatted(.downloadsDateModified)
let downloadsData = try encoder.encode(downloads)

XcodesKit.Current.network.dataTask = { url in
do {
let requestURL = try XCTUnwrap(url.pmkRequest.url)
let downloadsURL = try XCTUnwrap(URLRequest.downloads.url)
let response = try XCTUnwrap(HTTPURLResponse(url: requestURL, statusCode: 200, httpVersion: nil, headerFields: nil))

if requestURL == downloadsURL {
return Promise.value((data: downloadsData, response: response))
}
return Promise.value((data: Data(), response: response))
} catch {
return Promise(error: error)
}
}

let expectation = self.expectation(description: "update completes")
let xcodesList = XcodeList()

xcodesList.update(dataSource: .apple)
.done { xcodes in
XCTAssertEqual(xcodes.count, 1)
XCTAssertEqual(xcodes[0].url.absoluteString, "https://download.developer.apple.com/Developer_Tools/Xcode_15.0/Xcode_15.0_Universal.xip")
expectation.fulfill()
}
.catch { error in
XCTFail("Update failed: \(error)")
}

waitForExpectations(timeout: 1.0)
}

func test_XcodeList_Update_FiltersArchitectureVariants_IntelSelectsUniversal() throws {
XcodesKit.Current.shell.machineArchitecture = { "x86_64" }

let testDate = Date(timeIntervalSince1970: 0)
let downloads = Downloads(downloads: [
Download(name: "Xcode 16.2", files: [
Download.File(remotePath: "Developer_Tools/Xcode_16.2/Xcode_16.2_Apple_silicon.xip")
], dateModified: testDate),
Download(name: "Xcode 16.2", files: [
Download.File(remotePath: "Developer_Tools/Xcode_16.2/Xcode_16.2_Universal.xip")
], dateModified: testDate)
])

let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .formatted(.downloadsDateModified)
let downloadsData = try encoder.encode(downloads)

XcodesKit.Current.network.dataTask = { url in
do {
let requestURL = try XCTUnwrap(url.pmkRequest.url)
let downloadsURL = try XCTUnwrap(URLRequest.downloads.url)
let response = try XCTUnwrap(HTTPURLResponse(url: requestURL, statusCode: 200, httpVersion: nil, headerFields: nil))

if requestURL == downloadsURL {
return Promise.value((data: downloadsData, response: response))
}
return Promise.value((data: Data(), response: response))
} catch {
return Promise(error: error)
}
}

let expectation = self.expectation(description: "update completes")
let xcodesList = XcodeList()

xcodesList.update(dataSource: .apple)
.done { xcodes in
XCTAssertEqual(xcodes.count, 1)
XCTAssertEqual(xcodes[0].url.absoluteString, "https://download.developer.apple.com/Developer_Tools/Xcode_16.2/Xcode_16.2_Universal.xip")
expectation.fulfill()
}
.catch { error in
XCTFail("Update failed: \(error)")
}

waitForExpectations(timeout: 1.0)
}

func test_XcodeList_Update_FiltersArchitectureVariants_IntelAvoidsAppleSilicon() throws {
XcodesKit.Current.shell.machineArchitecture = { "x86_64" }

let testDate = Date(timeIntervalSince1970: 0)
let downloads = Downloads(downloads: [
Download(name: "Xcode 16.2", files: [
Download.File(remotePath: "Developer_Tools/Xcode_16.2/Xcode_16.2_Apple_silicon.xip")
], dateModified: testDate)
])

let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .formatted(.downloadsDateModified)
let downloadsData = try encoder.encode(downloads)

XcodesKit.Current.network.dataTask = { url in
do {
let requestURL = try XCTUnwrap(url.pmkRequest.url)
let downloadsURL = try XCTUnwrap(URLRequest.downloads.url)
let response = try XCTUnwrap(HTTPURLResponse(url: requestURL, statusCode: 200, httpVersion: nil, headerFields: nil))

if requestURL == downloadsURL {
return Promise.value((data: downloadsData, response: response))
}
return Promise.value((data: Data(), response: response))
} catch {
return Promise(error: error)
}
}

let expectation = self.expectation(description: "update completes")
let xcodesList = XcodeList()

xcodesList.update(dataSource: .apple)
.done { xcodes in
// Intel should not get Apple_silicon only builds
// In this case it falls back to the Apple_silicon as last resort
XCTAssertEqual(xcodes.count, 1)
XCTAssertEqual(xcodes[0].url.absoluteString, "https://download.developer.apple.com/Developer_Tools/Xcode_16.2/Xcode_16.2_Apple_silicon.xip")
expectation.fulfill()
}
.catch { error in
XCTFail("Update failed: \(error)")
}

waitForExpectations(timeout: 1.0)
}
}