diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 0c957789..be12b911 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -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 */; }; @@ -276,6 +277,7 @@ CAD2E7AE2449575000113D76 /* Xcodes.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Xcodes.entitlements; sourceTree = ""; }; 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 = ""; }; + 35BDE2F9D65A9B4A63CEB3BD /* PlatformsListViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformsListViewTests.swift; sourceTree = ""; }; CAD2E7B92449575100113D76 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; CAE4247E259A666100B8B246 /* MainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = ""; }; CAE42486259A68A300B8B246 /* XcodeListCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeListCategory.swift; sourceTree = ""; }; @@ -594,6 +596,7 @@ CAC281E6259FA45A00B8AB0B /* Environment+Mock.swift */, CAD2E7B72449575100113D76 /* AppStateTests.swift */, CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */, + 35BDE2F9D65A9B4A63CEB3BD /* PlatformsListViewTests.swift */, CAD2E7B92449575100113D76 /* Info.plist */, ); path = XcodesTests; @@ -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; }; diff --git a/Xcodes/Frontend/Preferences/PlatformsListView.swift b/Xcodes/Frontend/Preferences/PlatformsListView.swift index d789e8a5..f9c4a684 100644 --- a/Xcodes/Frontend/Preferences/PlatformsListView.swift +++ b/Xcodes/Frontend/Preferences/PlatformsListView.swift @@ -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 { + var rows: [DownloadableRuntime] = [] + var seenBuilds = Set() + + 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) { diff --git a/XcodesTests/PlatformsListViewTests.swift b/XcodesTests/PlatformsListViewTests.swift new file mode 100644 index 00000000..e367fd62 --- /dev/null +++ b/XcodesTests/PlatformsListViewTests.swift @@ -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") + } +}