From 3a2573f11fa8b123a7ce8a0b52179a0c6ce06ba5 Mon Sep 17 00:00:00 2001 From: beforeold Date: Tue, 30 Jun 2026 12:31:52 +0800 Subject: [PATCH] fix: collapse duplicate installed runtime rows in Platforms preferences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Platforms preferences list mapped each installed simulator runtime to every downloadable record sharing its build update. Apple's downloadable runtime index ships multiple records per build (e.g. a Universal and an Apple Silicon-only variant with the same name and buildUpdate but different identifier and architectures), so a single installed runtime was rendered as multiple rows — most visibly "iOS 26.4 Simulator Runtime" appearing twice with different sizes. Drive the list from the installed runtimes instead, collapsing to one row per installed build and preferring the downloadable variant whose architectures match what is actually installed. Co-Authored-By: Claude Opus 4.8 --- Xcodes.xcodeproj/project.pbxproj | 4 + .../Preferences/PlatformsListView.swift | 51 +++++- XcodesTests/PlatformsListViewTests.swift | 151 ++++++++++++++++++ 3 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 XcodesTests/PlatformsListViewTests.swift 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") + } +}