diff --git a/Sources/XcodesKit/Environment.swift b/Sources/XcodesKit/Environment.swift index 393e19c..1091ee2 100644 --- a/Sources/XcodesKit/Environment.swift +++ b/Sources/XcodesKit/Environment.swift @@ -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 } diff --git a/Sources/XcodesKit/XcodeList.swift b/Sources/XcodesKit/XcodeList.swift index b46c970..3e44371 100644 --- a/Sources/XcodesKit/XcodeList.swift +++ b/Sources/XcodesKit/XcodeList.swift @@ -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. @@ -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 } } } @@ -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") } + ?? variants[0] + } + + filtered.append(selected) + } + } + + return filtered + } } diff --git a/Tests/XcodesKitTests/Environment+Mock.swift b/Tests/XcodesKitTests/Environment+Mock.swift index 067f9ce..6677e3a 100644 --- a/Tests/XcodesKitTests/Environment+Mock.swift +++ b/Tests/XcodesKitTests/Environment+Mock.swift @@ -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 }, diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 1282d92..83c6d1e 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -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) + } }