Skip to content

Commit a0722be

Browse files
committed
Add architecture filters for Xcodes and runtimes
Adapted from XcodesOrg/xcodes#470 by wmehanna.
1 parent db97f3e commit a0722be

6 files changed

Lines changed: 200 additions & 21 deletions

File tree

Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,13 @@ public struct InstalledRuntime: Decodable, Sendable {
178178
public let supportedArchitectures: [Architecture]?
179179
}
180180

181+
public extension Array where Element == DownloadableRuntime {
182+
func matchingArchitectures(_ architectures: [Architecture]) -> [DownloadableRuntime] {
183+
guard !architectures.isEmpty else { return self }
184+
return filter { $0.architectures?.containsAny(architectures) == true }
185+
}
186+
}
187+
181188
extension InstalledRuntime {
182189
public enum Kind: String, Decodable, Sendable {
183190
case bundled = "Bundled with Xcode"

Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,8 @@ extension Array where Element == Architecture {
6868
public var isUniversal: Bool {
6969
self.contains([.arm64, .x86_64])
7070
}
71+
72+
public func containsAny(_ architectures: [Architecture]) -> Bool {
73+
!Set(self).isDisjoint(with: architectures)
74+
}
7175
}

Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcode.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,39 @@ public extension Array where Element == AvailableXcode {
9090
func first(withVersion version: Version) -> AvailableXcode? {
9191
XcodeVersionMatcher.find(version: version, in: self, versionKeyPath: \AvailableXcode.version)
9292
}
93+
94+
func matchingArchitectures(_ architectures: [Architecture]) -> [AvailableXcode] {
95+
guard !architectures.isEmpty else { return self }
96+
return filter { $0.architectures?.containsAny(architectures) == true }
97+
}
98+
99+
/// Returns the best compatible Xcode for the given version and host architecture.
100+
/// Adapted from XcodesOrg/xcodes#470 by wmehanna.
101+
func firstCompatible(withVersion version: Version, hostArchitecture: Architecture) -> AvailableXcode? {
102+
let matches = all(withVersion: version)
103+
guard !matches.isEmpty else { return nil }
104+
105+
if let universal = matches.first(where: { $0.architectures?.isUniversal == true }) {
106+
return universal
107+
}
108+
109+
if let matching = matches.first(where: { $0.architectures?.contains(hostArchitecture) == true }) {
110+
return matching
111+
}
112+
113+
return matches.first
114+
}
115+
116+
private func all(withVersion version: Version) -> [AvailableXcode] {
117+
let equivalentMatches = filter { $0.version.isEquivalent(to: version) }
118+
if !equivalentMatches.isEmpty {
119+
return equivalentMatches
120+
}
121+
122+
if version.prereleaseIdentifiers.isEmpty && version.buildMetadataIdentifiers.isEmpty {
123+
return filter { $0.version.isEqualWithoutAllIdentifiers(to: version) }
124+
}
125+
126+
return []
127+
}
93128
}

Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListPresentationService.swift

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,26 +44,29 @@ public struct RuntimeListPresentationService: Sendable {
4444
public func rows(
4545
downloadableRuntimes: DownloadableRuntimesResponse,
4646
installedRuntimes: [InstalledRuntime],
47-
includeBetas: Bool
47+
includeBetas: Bool,
48+
architectures: [Architecture] = []
4849
) -> [(platform: DownloadableRuntime.Platform, runtimes: [RuntimeRow])] {
4950
rows(
5051
downloadableRuntimes: downloadableRuntimes.downloadablesWithSDKBuildUpdates(),
5152
installedRuntimes: installedRuntimes,
5253
includeBetas: includeBetas,
53-
sdkToSeedMappings: downloadableRuntimes.sdkToSeedMappings
54+
sdkToSeedMappings: downloadableRuntimes.sdkToSeedMappings,
55+
architectures: architectures
5456
)
5557
}
5658

5759
public func rows(
5860
downloadableRuntimes: [DownloadableRuntime],
5961
installedRuntimes: [InstalledRuntime],
6062
includeBetas: Bool,
61-
sdkToSeedMappings: [SDKToSeedMapping] = []
63+
sdkToSeedMappings: [SDKToSeedMapping] = [],
64+
architectures: [Architecture] = []
6265
) -> [(platform: DownloadableRuntime.Platform, runtimes: [RuntimeRow])] {
6366
var unmatchedInstalledRuntimes = installedRuntimes
6467
var rows: [RuntimeRow] = []
6568

66-
downloadableRuntimes.forEach { downloadable in
69+
downloadableRuntimes.matchingArchitectures(architectures).forEach { downloadable in
6770
let matchingInstalledRuntimes = unmatchedInstalledRuntimes.removeAll {
6871
$0.build == downloadable.simulatorVersion.buildUpdate
6972
}
@@ -77,18 +80,20 @@ public struct RuntimeListPresentationService: Sendable {
7780
}
7881
}
7982

80-
unmatchedInstalledRuntimes.forEach { installedRuntime in
81-
let betaNumber = sdkToSeedMappings.first {
82-
$0.buildUpdate == installedRuntime.build
83-
}?.seedNumber
84-
var row = RuntimeRow(installedRuntime, betaNumber: betaNumber)
83+
if architectures.isEmpty {
84+
unmatchedInstalledRuntimes.forEach { installedRuntime in
85+
let betaNumber = sdkToSeedMappings.first {
86+
$0.buildUpdate == installedRuntime.build
87+
}?.seedNumber
88+
var row = RuntimeRow(installedRuntime, betaNumber: betaNumber)
8589

86-
rows.indices.filter { row.visibleIdentifier == rows[$0].visibleIdentifier }.forEach { index in
87-
row.hasDuplicateVersion = true
88-
rows[index].hasDuplicateVersion = true
89-
}
90+
rows.indices.filter { row.visibleIdentifier == rows[$0].visibleIdentifier }.forEach { index in
91+
row.hasDuplicateVersion = true
92+
rows[index].hasDuplicateVersion = true
93+
}
9094

91-
rows.append(row)
95+
rows.append(row)
96+
}
9297
}
9398

9499
return Dictionary(grouping: rows, by: \.platform)
@@ -161,7 +166,7 @@ private extension RuntimeListPresentationService.RuntimeRow {
161166
version: runtime.version,
162167
build: runtime.build,
163168
kind: runtime.kind,
164-
architectures: nil
169+
architectures: runtime.supportedArchitectures
165170
)
166171
}
167172
}

Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListPresentationService.swift

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,25 +23,31 @@ public struct XcodeListPresentationService: Sendable {
2323
availableXcodes: [AvailableXcode],
2424
installedXcodes: [InstalledXcode],
2525
selectedXcodePath: String?,
26-
dataSource: XcodeListDataSource
26+
dataSource: XcodeListDataSource,
27+
architectures: [Architecture] = []
2728
) -> [AvailableRow] {
2829
struct ReleasedVersion {
2930
let version: Version
3031
let releaseDate: Date?
3132
}
3233

33-
let adjustedAvailableXcodes = dataSource == .apple
34+
let adjustedAvailableXcodes = (dataSource == .apple
3435
? XcodeListComposer.adjustingAvailableXcodesForInstalledBuildMetadata(
3536
availableXcodes,
3637
installedXcodes: installedXcodes
3738
)
38-
: availableXcodes
39+
: availableXcodes)
40+
.matchingArchitectures(architectures)
41+
42+
let adjustedInstalledXcodes = architectures.isEmpty
43+
? installedXcodes
44+
: installedXcodes.filter { $0.xcodeID.architectures?.containsAny(architectures) == true }
3945

4046
var releasedVersions = adjustedAvailableXcodes.map {
4147
ReleasedVersion(version: $0.version, releaseDate: $0.releaseDate)
4248
}
4349

44-
for installedXcode in installedXcodes {
50+
for installedXcode in adjustedInstalledXcodes {
4551
if !releasedVersions.contains(where: { $0.version.isEquivalent(to: installedXcode.version) }) {
4652
releasedVersions.append(ReleasedVersion(version: installedXcode.version, releaseDate: nil))
4753
} else if let index = releasedVersions.firstIndex(where: {
@@ -53,7 +59,7 @@ public struct XcodeListPresentationService: Sendable {
5359
}
5460

5561
let selectedInstalledXcode = Self.selectedInstalledXcode(
56-
in: installedXcodes,
62+
in: adjustedInstalledXcodes,
5763
selectedXcodePath: selectedXcodePath
5864
)
5965

@@ -68,7 +74,7 @@ public struct XcodeListPresentationService: Sendable {
6874
return first.version < second.version
6975
}
7076
.map { releasedVersion in
71-
let installedXcode = installedXcodes.first {
77+
let installedXcode = adjustedInstalledXcodes.first {
7278
releasedVersion.version.isEquivalent(to: $0.version)
7379
}
7480
return AvailableRow(
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import XCTest
2+
@preconcurrency import Version
3+
@testable import XcodesKit
4+
5+
final class ArchitectureFilteringTests: XCTestCase {
6+
func testAvailableXcodesCanBeFilteredByArchitecture() throws {
7+
let universal = availableXcode("15.0.0", filename: "Xcode-15.xip", architectures: [.arm64, .x86_64])
8+
let appleSilicon = availableXcode("16.0.0", filename: "Xcode-16-arm64.xip", architectures: [.arm64])
9+
let intel = availableXcode("14.0.0", filename: "Xcode-14-x86_64.xip", architectures: [.x86_64])
10+
let unknown = availableXcode("13.0.0", filename: "Xcode-13.xip")
11+
12+
XCTAssertEqual(
13+
[universal, appleSilicon, intel, unknown].matchingArchitectures([.arm64]),
14+
[universal, appleSilicon]
15+
)
16+
XCTAssertEqual(
17+
[universal, appleSilicon, intel, unknown].matchingArchitectures([.x86_64]),
18+
[universal, intel]
19+
)
20+
XCTAssertEqual(
21+
[universal, appleSilicon, intel, unknown].matchingArchitectures([]),
22+
[universal, appleSilicon, intel, unknown]
23+
)
24+
}
25+
26+
func testAvailableXcodesFirstCompatiblePrefersUniversalThenHostArchitecture() throws {
27+
let universal = availableXcode("26.2.0", filename: "Xcode-26-universal.xip", architectures: [.arm64, .x86_64])
28+
let appleSilicon = availableXcode("26.2.0", filename: "Xcode-26-arm64.xip", architectures: [.arm64])
29+
let intel = availableXcode("26.2.0", filename: "Xcode-26-x86_64.xip", architectures: [.x86_64])
30+
31+
XCTAssertEqual([appleSilicon, universal, intel].firstCompatible(withVersion: Version("26.2.0")!, hostArchitecture: .arm64), universal)
32+
XCTAssertEqual([appleSilicon, intel].firstCompatible(withVersion: Version("26.2.0")!, hostArchitecture: .x86_64), intel)
33+
}
34+
35+
func testXcodeListPresentationServiceFiltersAvailableRowsByArchitecture() throws {
36+
let universal = availableXcode("15.0.0", filename: "Xcode-15.xip", architectures: [.arm64, .x86_64])
37+
let appleSilicon = availableXcode("16.0.0", filename: "Xcode-16-arm64.xip", architectures: [.arm64])
38+
let intel = availableXcode("14.0.0", filename: "Xcode-14-x86_64.xip", architectures: [.x86_64])
39+
40+
let rows = XcodeListPresentationService().availableRows(
41+
availableXcodes: [universal, appleSilicon, intel],
42+
installedXcodes: [],
43+
selectedXcodePath: nil,
44+
dataSource: .xcodeReleases,
45+
architectures: [.arm64]
46+
)
47+
48+
XCTAssertEqual(rows.map(\.version), [universal.version, appleSilicon.version])
49+
}
50+
51+
func testRuntimeListPresentationServiceFiltersRowsByArchitecture() {
52+
let response = DownloadableRuntimesResponse(
53+
sdkToSimulatorMappings: [],
54+
sdkToSeedMappings: [],
55+
refreshInterval: 3600,
56+
downloadables: [
57+
downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg", architectures: [.arm64, .x86_64]),
58+
downloadableRuntime(
59+
source: "https://example.com/iOS_17_Runtime.dmg",
60+
architectures: [.arm64],
61+
simulatorVersion: .init(buildUpdate: "21A1", version: "17.0"),
62+
identifier: "com.apple.CoreSimulator.SimRuntime.iOS-17-0",
63+
name: "iOS 17.0"
64+
),
65+
downloadableRuntime(
66+
source: "https://example.com/iOS_15_Runtime.dmg",
67+
architectures: [.x86_64],
68+
simulatorVersion: .init(buildUpdate: "19A1", version: "15.0"),
69+
identifier: "com.apple.CoreSimulator.SimRuntime.iOS-15-0",
70+
name: "iOS 15.0"
71+
)
72+
],
73+
version: "2"
74+
)
75+
76+
let rows = RuntimeListPresentationService().rows(
77+
downloadableRuntimes: response,
78+
installedRuntimes: [],
79+
includeBetas: false,
80+
architectures: [.arm64]
81+
)
82+
83+
XCTAssertEqual(rows.first?.runtimes.map(\.visibleIdentifier), [
84+
"iOS 16.0 arm64|x86_64",
85+
"iOS 17.0 arm64"
86+
])
87+
}
88+
89+
private func availableXcode(_ version: String, filename: String, architectures: [Architecture]? = nil) -> AvailableXcode {
90+
AvailableXcode(
91+
version: Version(version)!,
92+
url: URL(fileURLWithPath: "/" + filename),
93+
filename: filename,
94+
releaseDate: nil,
95+
architectures: architectures
96+
)
97+
}
98+
99+
private func downloadableRuntime(
100+
source: String?,
101+
architectures: [Architecture]? = nil,
102+
simulatorVersion: DownloadableRuntime.SimulatorVersion = .init(buildUpdate: "20A360", version: "16.0"),
103+
identifier: String = "com.apple.CoreSimulator.SimRuntime.iOS-16-0",
104+
name: String = "iOS 16.0"
105+
) -> DownloadableRuntime {
106+
DownloadableRuntime(
107+
category: .simulator,
108+
simulatorVersion: simulatorVersion,
109+
source: source,
110+
architectures: architectures,
111+
dictionaryVersion: 1,
112+
contentType: .diskImage,
113+
platform: .iOS,
114+
identifier: identifier,
115+
version: simulatorVersion.version,
116+
fileSize: 42,
117+
hostRequirements: nil,
118+
name: name,
119+
authentication: nil
120+
)
121+
}
122+
}

0 commit comments

Comments
 (0)