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
4 changes: 4 additions & 0 deletions Xcodes.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
CAA858C425A2BE4E00ACF8C0 /* Downloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA858C325A2BE4E00ACF8C0 /* Downloader.swift */; };
CAA858DB25A3E11F00ACF8C0 /* aria2-release-1.35.0.tar.gz in Resources */ = {isa = PBXBuildFile; fileRef = CAA858DA25A3E11F00ACF8C0 /* aria2-release-1.35.0.tar.gz */; };
CAB3AB0E25BCA6C200BF1B04 /* AppStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD2E7B72449575100113D76 /* AppStateTests.swift */; };
9B90B7153E09F8599E323DA3 /* PlatformsListViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35BDE2F9D65A9B4A63CEB3BD /* PlatformsListViewTests.swift */; };
CABFA9BD2592EEEA00380FEE /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9A92592EEE900380FEE /* Environment.swift */; };
CABFA9C52592EEEA00380FEE /* FileManager+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B82592EEEA00380FEE /* FileManager+.swift */; };
CABFA9CA2592EEEA00380FEE /* AppState+Update.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9A72592EEE900380FEE /* AppState+Update.swift */; };
Expand Down Expand Up @@ -276,6 +277,7 @@
CAD2E7AE2449575000113D76 /* Xcodes.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Xcodes.entitlements; sourceTree = "<group>"; };
CAD2E7B32449575100113D76 /* XcodesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = XcodesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
CAD2E7B72449575100113D76 /* AppStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTests.swift; sourceTree = "<group>"; };
35BDE2F9D65A9B4A63CEB3BD /* PlatformsListViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformsListViewTests.swift; sourceTree = "<group>"; };
CAD2E7B92449575100113D76 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
CAE4247E259A666100B8B246 /* MainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = "<group>"; };
CAE42486259A68A300B8B246 /* XcodeListCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeListCategory.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -594,6 +596,7 @@
CAC281E6259FA45A00B8AB0B /* Environment+Mock.swift */,
CAD2E7B72449575100113D76 /* AppStateTests.swift */,
CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */,
35BDE2F9D65A9B4A63CEB3BD /* PlatformsListViewTests.swift */,
CAD2E7B92449575100113D76 /* Info.plist */,
);
path = XcodesTests;
Expand Down Expand Up @@ -971,6 +974,7 @@
CAC281E2259FA44600B8AB0B /* Bundle+XcodesTests.swift in Sources */,
CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */,
CAB3AB0E25BCA6C200BF1B04 /* AppStateTests.swift in Sources */,
9B90B7153E09F8599E323DA3 /* PlatformsListViewTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
51 changes: 47 additions & 4 deletions Xcodes/Frontend/Preferences/PlatformsListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,54 @@ struct PlatformsListView: View {
}

func loadRuntimes() {
let filteredRuntimes = appState.downloadableRuntimes.filter { runtime in
appState.installedRuntimes.contains { $0.runtimeInfo.build == runtime.simulatorVersion.buildUpdate
}
runtimes = Self.installedRuntimeRows(
downloadableRuntimes: appState.downloadableRuntimes,
installedRuntimes: appState.installedRuntimes
)
}

/// Builds the grouped list of installed simulator runtimes for display.
///
/// Apple's downloadable runtime index can list several records for the same
/// installed build (e.g. a Universal and an Apple Silicon-only download share
/// the same `simulatorVersion.buildUpdate` but differ in `identifier` and
/// `architectures`). A single installed runtime must therefore collapse to a
/// single row, otherwise the same platform appears multiple times.
nonisolated static func installedRuntimeRows(
downloadableRuntimes: [DownloadableRuntime],
installedRuntimes: [CoreSimulatorImage]
) -> OrderedDictionary<DownloadableRuntime.Platform, [DownloadableRuntime]> {
var rows: [DownloadableRuntime] = []
var seenBuilds = Set<String>()

for installed in installedRuntimes {
let build = installed.runtimeInfo.build
guard !seenBuilds.contains(build) else { continue }

let candidates = downloadableRuntimes.filter { $0.simulatorVersion.buildUpdate == build }
guard let row = bestMatch(for: installed, among: candidates) else { continue }

seenBuilds.insert(build)
rows.append(row)
}
runtimes = OrderedDictionary(grouping: filteredRuntimes, by: { $0.platform })

return OrderedDictionary(grouping: rows, by: { $0.platform })
}

/// Picks the downloadable record that best represents an installed runtime,
/// preferring the variant whose architectures match what is installed.
private nonisolated static func bestMatch(
for installed: CoreSimulatorImage,
among candidates: [DownloadableRuntime]
) -> DownloadableRuntime? {
guard !candidates.isEmpty else { return nil }

if let installedArchitectures = installed.runtimeInfo.supportedArchitectures,
let exactMatch = candidates.first(where: { ($0.architectures ?? []) == installedArchitectures }) {
return exactMatch
}

return candidates.first
}

func deleteRuntime(runtime: DownloadableRuntime) {
Expand Down
151 changes: 151 additions & 0 deletions XcodesTests/PlatformsListViewTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import XCTest
import XcodesKit
import OrderedCollections

@testable import Xcodes

final class PlatformsListViewTests: XCTestCase {

private func downloadableRuntime(
name: String,
identifier: String,
platform: String = "com.apple.platform.iphoneos",
version: String,
buildUpdate: String,
fileSize: Int,
architectures: [String]?
) throws -> DownloadableRuntime {
let architecturesJSON: String
if let architectures {
architecturesJSON = "[" + architectures.map { "\"\($0)\"" }.joined(separator: ",") + "]"
} else {
architecturesJSON = "null"
}
let json = """
{
"category": "simulator",
"simulatorVersion": {
"buildUpdate": "\(buildUpdate)",
"version": "\(version)"
},
"source": "https://example.com/\(identifier).dmg",
"architectures": \(architecturesJSON),
"dictionaryVersion": 1,
"contentType": "diskImage",
"platform": "\(platform)",
"identifier": "\(identifier)",
"version": "\(version)",
"fileSize": \(fileSize),
"hostRequirements": null,
"name": "\(name)",
"authentication": null
}
"""
return try JSONDecoder().decode(DownloadableRuntime.self, from: Data(json.utf8))
}

/// Apple ships a Universal and an arm64-only download for the same installed
/// build (same name, same buildUpdate, different identifier/architectures).
/// A single installed runtime must collapse to a single row.
func test_InstalledRuntimeRows_CollapsesUniversalAndArm64VariantsOfSameInstall() throws {
let universal = try downloadableRuntime(
name: "iOS 26.4 Simulator Runtime",
identifier: "com.apple.dmg.iPhoneSimulatorSDK26_4",
version: "26.4",
buildUpdate: "23E244",
fileSize: 10_603_482_987,
architectures: ["arm64", "x86_64"]
)
let arm64Only = try downloadableRuntime(
name: "iOS 26.4 Simulator Runtime",
identifier: "com.apple.dmg.iPhoneSimulatorSDK26_4_arm64",
version: "26.4",
buildUpdate: "23E244",
fileSize: 8_455_792_717,
architectures: ["arm64"]
)

let installed = CoreSimulatorImage(
uuid: "D6068E04-6529-4EC4-8EF8-A6050AB3EB7F",
path: ["relative": "file:///some/path/iOS_26_4.dmg"],
runtimeInfo: CoreSimulatorRuntimeInfo(build: "23E244", supportedArchitectures: [.arm64])
)

let grouped = PlatformsListView.installedRuntimeRows(
downloadableRuntimes: [universal, arm64Only],
installedRuntimes: [installed]
)

let iosRows = grouped[.iOS] ?? []
XCTAssertEqual(
iosRows.count,
1,
"A single installed 26.4 must produce one row, not one per architecture variant"
)
// The installed image is arm64-only, so the arm64 download is the better match.
XCTAssertEqual(iosRows.first?.identifier, "com.apple.dmg.iPhoneSimulatorSDK26_4_arm64")
}

/// Distinct installed builds must each get their own row.
func test_InstalledRuntimeRows_KeepsDistinctInstalledBuilds() throws {
let ios186 = try downloadableRuntime(
name: "iOS 18.6 Simulator Runtime",
identifier: "com.apple.dmg.iPhoneSimulatorSDK18_6",
version: "18.6",
buildUpdate: "22G86",
fileSize: 9_000_000_000,
architectures: nil
)
let ios264 = try downloadableRuntime(
name: "iOS 26.4 Simulator Runtime",
identifier: "com.apple.dmg.iPhoneSimulatorSDK26_4_arm64",
version: "26.4",
buildUpdate: "23E244",
fileSize: 8_455_792_717,
architectures: ["arm64"]
)

let installed186 = CoreSimulatorImage(
uuid: "11111111-1111-1111-1111-111111111111",
path: ["relative": "file:///some/path/iOS_18_6.dmg"],
runtimeInfo: CoreSimulatorRuntimeInfo(build: "22G86")
)
let installed264 = CoreSimulatorImage(
uuid: "22222222-2222-2222-2222-222222222222",
path: ["relative": "file:///some/path/iOS_26_4.dmg"],
runtimeInfo: CoreSimulatorRuntimeInfo(build: "23E244", supportedArchitectures: [.arm64])
)

let grouped = PlatformsListView.installedRuntimeRows(
downloadableRuntimes: [ios186, ios264],
installedRuntimes: [installed186, installed264]
)

XCTAssertEqual((grouped[.iOS] ?? []).count, 2, "Two distinct installed builds must produce two rows")
}

/// Downloadable runtimes that are not installed locally must not appear.
func test_InstalledRuntimeRows_ExcludesNotInstalledRuntimes() throws {
let notInstalled = try downloadableRuntime(
name: "iOS 27.0 Simulator Runtime",
identifier: "com.apple.dmg.iPhoneSimulatorSDK27_0",
version: "27.0",
buildUpdate: "25A000",
fileSize: 9_000_000_000,
architectures: ["arm64"]
)

let installed = CoreSimulatorImage(
uuid: "33333333-3333-3333-3333-333333333333",
path: ["relative": "file:///some/path/iOS_26_4.dmg"],
runtimeInfo: CoreSimulatorRuntimeInfo(build: "23E244", supportedArchitectures: [.arm64])
)

let grouped = PlatformsListView.installedRuntimeRows(
downloadableRuntimes: [notInstalled],
installedRuntimes: [installed]
)

XCTAssertTrue(grouped.isEmpty, "Runtimes that aren't installed locally must not be listed")
}
}