From b54aef376315cababa99fffb3c1e811ec73dabd1 Mon Sep 17 00:00:00 2001 From: Anand Hegde Date: Sun, 20 Sep 2026 12:00:11 +0530 Subject: [PATCH] Automatically install simulator runtimes for chosen platforms Pick the platforms you always need in the Platforms window and Xcodes installs the newest runtime for each of them as soon as an Xcode install finishes, instead of making you come back and start every runtime by hand. - `RuntimeAutoInstallService` picks the newest downloadable runtime per selected platform and skips platforms whose newest runtime is already installed, so repeat installs don't re-download anything. - Beta runtimes are only considered for users who already opted into automatically installing Xcode betas. - `downloadRuntime` gained `presentingErrors:` so an automatic install that can't run right now (for example no selected Xcode yet) logs instead of raising an alert. - The chosen platforms are stored under `autoInstallRuntimePlatforms` as a comma separated list of platform identifiers, so they can also be managed by a configuration profile. Fixes #298 --- Xcodes.xcodeproj/project.pbxproj | 8 + Xcodes/Backend/AppState+Install.swift | 8 +- Xcodes/Backend/AppState+Runtimes.swift | 56 ++++- Xcodes/Backend/AppState.swift | 1 + .../Backend/RuntimeAutoInstallService.swift | 88 +++++++ Xcodes/Backend/SDKs+Xcode.swift | 8 +- .../Preferences/PlatformsListView.swift | 30 +++ Xcodes/Resources/Localizable.xcstrings | 22 ++ .../RuntimeAutoInstallServiceTests.swift | 236 ++++++++++++++++++ 9 files changed, 451 insertions(+), 6 deletions(-) create mode 100644 Xcodes/Backend/RuntimeAutoInstallService.swift create mode 100644 XcodesTests/RuntimeAutoInstallServiceTests.swift diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index e8e0ad6a..d60bfecb 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ BDBAB7452B9FF55800694B0B /* TrailingIconLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */; }; CA11E7BA2598476C00D2EE1C /* XcodeCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */; }; CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */; }; + AD0298B11A2E5B0100000002 /* RuntimeAutoInstallServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0298B01A2E5B0100000002 /* RuntimeAutoInstallServiceTests.swift */; }; CA2518EE25A7FF2B00F08414 /* SignInCredentialsViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2518ED25A7FF2B00F08414 /* SignInCredentialsViewTests.swift */; }; CA378F992466567600A58CE0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA378F982466567600A58CE0 /* AppState.swift */; }; CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; }; @@ -122,6 +123,7 @@ E89CBD402D6434E10037ED95 /* SignInFederatedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E89CBD3F2D6434E10037ED95 /* SignInFederatedView.swift */; }; E8B20CBF2A2EDEC20057D816 /* SDKs+Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8B20CBE2A2EDEC20057D816 /* SDKs+Xcode.swift */; }; E8C0EB1C291EF9A10081528A /* AppState+Runtimes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8C0EB1B291EF9A10081528A /* AppState+Runtimes.swift */; }; + AD0298A11A2E5B0100000001 /* RuntimeAutoInstallService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0298A01A2E5B0100000001 /* RuntimeAutoInstallService.swift */; }; E8CBDB8927ADE32300B22292 /* unxip in Copy aria2c */ = {isa = PBXBuildFile; fileRef = E8CBDB8627ADD92000B22292 /* unxip */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; E8CBDB8B27AE02FF00B22292 /* ExperiementsPreferencePane.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8CBDB8A27AE02FF00B22292 /* ExperiementsPreferencePane.swift */; }; E8D0296F284B029800647641 /* BottomStatusBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D0296E284B029800647641 /* BottomStatusBar.swift */; }; @@ -214,6 +216,7 @@ BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailingIconLabelStyle.swift; sourceTree = ""; }; CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeCommands.swift; sourceTree = ""; }; CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateUpdateTests.swift; sourceTree = ""; }; + AD0298B01A2E5B0100000002 /* RuntimeAutoInstallServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeAutoInstallServiceTests.swift; sourceTree = ""; }; CA2518ED25A7FF2B00F08414 /* SignInCredentialsViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInCredentialsViewTests.swift; sourceTree = ""; }; CA378F982466567600A58CE0 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreButtonStyle.swift; sourceTree = ""; }; @@ -315,6 +318,7 @@ E89CBD3F2D6434E10037ED95 /* SignInFederatedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInFederatedView.swift; sourceTree = ""; }; E8B20CBE2A2EDEC20057D816 /* SDKs+Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SDKs+Xcode.swift"; sourceTree = ""; }; E8C0EB1B291EF9A10081528A /* AppState+Runtimes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppState+Runtimes.swift"; sourceTree = ""; }; + AD0298A01A2E5B0100000001 /* RuntimeAutoInstallService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeAutoInstallService.swift; sourceTree = ""; }; E8CBDB8627ADD92000B22292 /* unxip */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = unxip; sourceTree = ""; }; E8CBDB8A27AE02FF00B22292 /* ExperiementsPreferencePane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperiementsPreferencePane.swift; sourceTree = ""; }; E8D0296E284B029800647641 /* BottomStatusBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomStatusBar.swift; sourceTree = ""; }; @@ -480,6 +484,7 @@ children = ( CA378F982466567600A58CE0 /* AppState.swift */, E8C0EB1B291EF9A10081528A /* AppState+Runtimes.swift */, + AD0298A01A2E5B0100000001 /* RuntimeAutoInstallService.swift */, CAE424B3259A764700B8B246 /* AppState+Install.swift */, CABFA9A72592EEE900380FEE /* AppState+Update.swift */, CABFAA2B2592FBFC00380FEE /* Configure.swift */, @@ -602,6 +607,7 @@ CAC281E6259FA45A00B8AB0B /* Environment+Mock.swift */, CAD2E7B72449575100113D76 /* AppStateTests.swift */, CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */, + AD0298B01A2E5B0100000002 /* RuntimeAutoInstallServiceTests.swift */, CA2518ED25A7FF2B00F08414 /* SignInCredentialsViewTests.swift */, 884f01aed2f43048ab4d3323 /* NewVersionNotificationTests.swift */, CAD2E7B92449575100113D76 /* Info.plist */, @@ -964,6 +970,7 @@ CABFAA432593104F00380FEE /* AboutView.swift in Sources */, E8D0296F284B029800647641 /* BottomStatusBar.swift in Sources */, E8C0EB1C291EF9A10081528A /* AppState+Runtimes.swift in Sources */, + AD0298A11A2E5B0100000001 /* RuntimeAutoInstallService.swift in Sources */, E8E98A9025D8631800EC89A0 /* InstallationStepRowView.swift in Sources */, CABFA9CC2592EEEA00380FEE /* Path+.swift in Sources */, CAD2E7A22449574E00113D76 /* XcodesApp.swift in Sources */, @@ -982,6 +989,7 @@ CAC281E7259FA45A00B8AB0B /* Environment+Mock.swift in Sources */, CAC281E2259FA44600B8AB0B /* Bundle+XcodesTests.swift in Sources */, CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */, + AD0298B11A2E5B0100000002 /* RuntimeAutoInstallServiceTests.swift in Sources */, 14d2f5a1273f6c350cad4406 /* NewVersionNotificationTests.swift in Sources */, CAB3AB0E25BCA6C200BF1B04 /* AppStateTests.swift in Sources */, CA2518EE25A7FF2B00F08414 /* SignInCredentialsViewTests.swift in Sources */, diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index 02850d70..9fdad92d 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -52,10 +52,12 @@ extension AppState { try Task.checkCancellation() let installedXcode = try await installArchivedXcodeAsync(xcode, at: url) - guard let index = allXcodes.firstIndex(where: { $0.version.isEquivalent(to: installedXcode.version) }) else { - return installedXcode + if let index = allXcodes.firstIndex(where: { $0.version.isEquivalent(to: installedXcode.version) }) { + allXcodes[index].installState = .installed(installedXcode.path) } - allXcodes[index].installState = .installed(installedXcode.path) + + await autoInstallRuntimesIfNeeded() + return installedXcode }, onAttemptFailed: { @MainActor _ in diff --git a/Xcodes/Backend/AppState+Runtimes.swift b/Xcodes/Backend/AppState+Runtimes.swift index 78cf486e..5bc2aa29 100644 --- a/Xcodes/Backend/AppState+Runtimes.swift +++ b/Xcodes/Backend/AppState+Runtimes.swift @@ -55,7 +55,11 @@ extension AppState { } } - func downloadRuntime(runtime: DownloadableRuntime) { + /// Starts installing a runtime. + /// + /// - Parameter presentingErrors: Whether a runtime that can't be installed right now should + /// raise an alert. Automatic installs only log, so that they never interrupt the user. + func downloadRuntime(runtime: DownloadableRuntime, presentingErrors: Bool = true) { do { let method = try RuntimeInstallPolicy().installMethod( for: runtime, @@ -69,8 +73,56 @@ extension AppState { downloadRuntimeViaXcodeBuild(runtime: runtime, architecture: architecture) } } catch { - presentRuntimeInstallPolicyError(error) + if presentingErrors { + presentRuntimeInstallPolicyError(error) + } else { + Logger.appState.error("Skipping automatic install of \(runtime.visibleIdentifier): \(error.localizedDescription)") + } + } + } + + /// Installs the newest runtime for each platform the user asked to keep up to date. + /// + /// Runs after an Xcode install finishes so the simulators a developer always needs are + /// ready as soon as the Xcode they were installed alongside is. + func autoInstallRuntimesIfNeeded() async { + guard + let rawValue = Current.defaults.string(forKey: PreferenceKey.autoInstallRuntimePlatforms.rawValue), + let preference = AutoInstallRuntimePlatforms(rawValue: rawValue), + !preference.platforms.isEmpty + else { return } + + // The runtime lists are populated lazily, and an install that just finished can have + // made an installed runtime stale, so make sure both are current before deciding. + if downloadableRuntimes.isEmpty { + updateDownloadableRuntimes() + await downloadableRuntimesTask?.value } + updateInstalledRuntimes() + await installedRuntimesTask?.value + + let runtimes = RuntimeAutoInstallService().runtimesToInstall( + platforms: preference.platforms, + downloadableRuntimes: downloadableRuntimes, + installedRuntimes: installedRuntimes, + includingPrereleases: isAutoInstallingXcodeBetas + ) + + guard !runtimes.isEmpty else { + Logger.appState.info("Every platform selected for automatic installation is already up to date") + return + } + + for runtime in runtimes where runtimeTasks[runtime.identifier] == nil { + Logger.appState.info("Automatically installing runtime \(runtime.visibleIdentifier)") + downloadRuntime(runtime: runtime, presentingErrors: false) + } + } + + /// Beta runtimes are only installed automatically for users who already opted into Xcode betas. + private var isAutoInstallingXcodeBetas: Bool { + guard let storageValue = Current.defaults.get(forKey: PreferenceKey.autoInstallation.rawValue) as? Int else { return false } + return AutoInstallationType(rawValue: storageValue) == .newestBeta } private func presentRuntimeInstallPolicyError(_ error: Error) { diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 6ac509bb..e78ddd80 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -31,6 +31,7 @@ enum PreferenceKey: String { case expandedMajorXcodeVersions case expandedMinorXcodeVersions case usePrivilegeHelperForFileOperations + case autoInstallRuntimePlatforms func isManaged() -> Bool { UserDefaults.standard.objectIsForced(forKey: self.rawValue) } } diff --git a/Xcodes/Backend/RuntimeAutoInstallService.swift b/Xcodes/Backend/RuntimeAutoInstallService.swift new file mode 100644 index 00000000..63281099 --- /dev/null +++ b/Xcodes/Backend/RuntimeAutoInstallService.swift @@ -0,0 +1,88 @@ +import Foundation +import XcodesKit +import Version + +/// The platforms whose simulator runtimes should be kept installed automatically. +/// +/// Stored as a comma separated list of platform identifiers so that it round trips +/// through `@AppStorage` and through a managed preference profile. +struct AutoInstallRuntimePlatforms: RawRepresentable, Equatable { + /// The platforms that can be installed automatically. + /// + /// macOS is excluded because there is no downloadable macOS simulator runtime. + static let availablePlatforms: [DownloadableRuntime.Platform] = [.iOS, .watchOS, .tvOS, .visionOS] + + var platforms: Set + + init(platforms: Set = []) { + self.platforms = platforms + } + + init?(rawValue: String) { + self.platforms = Set( + rawValue + .split(separator: ",") + .compactMap { DownloadableRuntime.Platform(rawValue: String($0)) } + ) + } + + var rawValue: String { + platforms.sorted(\.order) + .map(\.rawValue) + .joined(separator: ",") + } + + func contains(_ platform: DownloadableRuntime.Platform) -> Bool { + platforms.contains(platform) + } + + mutating func setContains(_ contains: Bool, for platform: DownloadableRuntime.Platform) { + if contains { + platforms.insert(platform) + } else { + platforms.remove(platform) + } + } +} + +/// Decides which simulator runtimes should be installed automatically once an +/// Xcode installation finishes. +struct RuntimeAutoInstallService { + /// Returns the newest downloadable runtime for each requested platform. + /// + /// A platform is skipped when its newest runtime is already installed, so that + /// repeated Xcode installs don't re-download runtimes that are already on disk. + func runtimesToInstall( + platforms: Set, + downloadableRuntimes: [DownloadableRuntime], + installedRuntimes: [CoreSimulatorImage], + includingPrereleases: Bool + ) -> [DownloadableRuntime] { + guard !platforms.isEmpty else { return [] } + + let installedBuilds = Set(installedRuntimes.map(\.runtimeInfo.build)) + + return platforms.sorted(\.order).compactMap { platform in + let candidates = downloadableRuntimes.filter { runtime in + runtime.platform == platform && (includingPrereleases || runtime.betaNumber == nil) + } + + guard let newest = candidates.max(by: isOlder) else { return nil } + guard !installedBuilds.contains(newest.simulatorVersion.buildUpdate) else { return nil } + + return newest + } + } + + /// Orders two runtimes of the same platform, newest last. + private func isOlder(_ lhs: DownloadableRuntime, _ rhs: DownloadableRuntime) -> Bool { + let lhsVersion = Version(tolerant: lhs.simulatorVersion.version) ?? Version(0, 0, 0) + let rhsVersion = Version(tolerant: rhs.simulatorVersion.version) ?? Version(0, 0, 0) + + guard lhsVersion == rhsVersion else { return lhsVersion < rhsVersion } + + // A release of a version always wins over a beta of the same version, + // otherwise the higher seed number wins. + return (lhs.betaNumber ?? .max) < (rhs.betaNumber ?? .max) + } +} diff --git a/Xcodes/Backend/SDKs+Xcode.swift b/Xcodes/Backend/SDKs+Xcode.swift index f35f69e2..d7317edf 100644 --- a/Xcodes/Backend/SDKs+Xcode.swift +++ b/Xcodes/Backend/SDKs+Xcode.swift @@ -11,7 +11,13 @@ import XcodesKit extension DownloadableRuntime { func icon() -> Image { - switch self.platform { + platform.icon() + } +} + +extension DownloadableRuntime.Platform { + func icon() -> Image { + switch self { case .iOS: return Image(systemName: "iphone") case .macOS: diff --git a/Xcodes/Frontend/Preferences/PlatformsListView.swift b/Xcodes/Frontend/Preferences/PlatformsListView.swift index d789e8a5..134af6dd 100644 --- a/Xcodes/Frontend/Preferences/PlatformsListView.swift +++ b/Xcodes/Frontend/Preferences/PlatformsListView.swift @@ -13,11 +13,34 @@ import OrderedCollections struct PlatformsListView: View { @EnvironmentObject var appState: AppState + @AppStorage(PreferenceKey.autoInstallRuntimePlatforms.rawValue) private var autoInstallPlatforms = AutoInstallRuntimePlatforms() @State private var runtimes: OrderedDictionary = [:] @State private var selectedRuntime: DownloadableRuntime? var body: some View { List(selection: $selectedRuntime) { + Section { + ForEach(AutoInstallRuntimePlatforms.availablePlatforms, id: \.self) { platform in + Toggle(isOn: autoInstallBinding(for: platform)) { + HStack { + platform.icon() + .aspectRatio(contentMode: .fit) + .frame(width: 20) + Text(platform.shortName) + } + } + .disabled(PreferenceKey.autoInstallRuntimePlatforms.isManaged()) + } + } header: { + Text("PlatformsList.AutoInstall.Title") + .font(.headline) + } footer: { + Text("PlatformsList.AutoInstall.Description") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.bottom, 8) + } + Text("PlatformsList.Title") .font(.body) ForEach(runtimes.elements.sorted(\.key.order), id: \.key) { platform, runtimeList in @@ -60,6 +83,13 @@ struct PlatformsListView: View { } } + private func autoInstallBinding(for platform: DownloadableRuntime.Platform) -> Binding { + Binding( + get: { autoInstallPlatforms.contains(platform) }, + set: { autoInstallPlatforms.setContains($0, for: platform) } + ) + } + func loadRuntimes() { let filteredRuntimes = appState.downloadableRuntimes.filter { runtime in appState.installedRuntimes.contains { $0.runtimeInfo.build == runtime.simulatorVersion.buildUpdate diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 6608a149..3fd87321 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -20704,6 +20704,28 @@ } } }, + "PlatformsList.AutoInstall.Description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "After Xcodes finishes installing a version of Xcode it downloads the newest runtime for each platform selected above, unless that runtime is already installed." + } + } + } + }, + "PlatformsList.AutoInstall.Title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Install Automatically" + } + } + } + }, "PlatformsList.Title" : { "extractionState" : "manual", "localizations" : { diff --git a/XcodesTests/RuntimeAutoInstallServiceTests.swift b/XcodesTests/RuntimeAutoInstallServiceTests.swift new file mode 100644 index 00000000..73fb430d --- /dev/null +++ b/XcodesTests/RuntimeAutoInstallServiceTests.swift @@ -0,0 +1,236 @@ +import XcodesKit +@testable import Xcodes +import XCTest + +class RuntimeAutoInstallServiceTests: XCTestCase { + let subject = RuntimeAutoInstallService() + + // MARK: - Choosing runtimes + + func test_noSelectedPlatforms_installsNothing() throws { + let runtimes = subject.runtimesToInstall( + platforms: [], + downloadableRuntimes: [ + try makeRuntime(platform: .iOS, version: "18.5", buildUpdate: "22F77"), + ], + installedRuntimes: [], + includingPrereleases: false + ) + + XCTAssertTrue(runtimes.isEmpty) + } + + func test_installsOnlyTheNewestRuntimeOfEachSelectedPlatform() throws { + let runtimes = subject.runtimesToInstall( + platforms: [.watchOS], + downloadableRuntimes: [ + try makeRuntime(platform: .watchOS, version: "10.5", buildUpdate: "21T576"), + try makeRuntime(platform: .watchOS, version: "11.4", buildUpdate: "22T249"), + try makeRuntime(platform: .watchOS, version: "9.4", buildUpdate: "20T253"), + try makeRuntime(platform: .iOS, version: "18.5", buildUpdate: "22F77"), + ], + installedRuntimes: [], + includingPrereleases: false + ) + + XCTAssertEqual(runtimes.map(\.visibleIdentifier), ["watchOS 11.4"]) + } + + func test_comparesRuntimesByVersionAndNotByString() throws { + let runtimes = subject.runtimesToInstall( + platforms: [.iOS], + downloadableRuntimes: [ + try makeRuntime(platform: .iOS, version: "18.5", buildUpdate: "22F77"), + try makeRuntime(platform: .iOS, version: "26.0", buildUpdate: "23A339"), + ], + installedRuntimes: [], + includingPrereleases: false + ) + + XCTAssertEqual(runtimes.map(\.visibleIdentifier), ["iOS 26.0"]) + } + + func test_returnsOneRuntimePerPlatformInPresentationOrder() throws { + let runtimes = subject.runtimesToInstall( + platforms: [.visionOS, .iOS, .watchOS], + downloadableRuntimes: [ + try makeRuntime(platform: .watchOS, version: "11.4", buildUpdate: "22T249"), + try makeRuntime(platform: .iOS, version: "18.5", buildUpdate: "22F77"), + try makeRuntime(platform: .visionOS, version: "2.4", buildUpdate: "22O233"), + try makeRuntime(platform: .tvOS, version: "18.4", buildUpdate: "22L251"), + ], + installedRuntimes: [], + includingPrereleases: false + ) + + XCTAssertEqual(runtimes.map(\.visibleIdentifier), ["iOS 18.5", "watchOS 11.4", "visionOS 2.4"]) + } + + func test_skipsPlatformsWhoseNewestRuntimeIsAlreadyInstalled() throws { + let runtimes = subject.runtimesToInstall( + platforms: [.iOS, .watchOS], + downloadableRuntimes: [ + try makeRuntime(platform: .iOS, version: "18.5", buildUpdate: "22F77"), + try makeRuntime(platform: .watchOS, version: "11.4", buildUpdate: "22T249"), + ], + installedRuntimes: [makeInstalledRuntime(build: "22F77")], + includingPrereleases: false + ) + + XCTAssertEqual(runtimes.map(\.visibleIdentifier), ["watchOS 11.4"]) + } + + func test_installsANewerRuntimeEvenWhenAnOlderOneIsInstalled() throws { + let runtimes = subject.runtimesToInstall( + platforms: [.iOS], + downloadableRuntimes: [ + try makeRuntime(platform: .iOS, version: "18.4", buildUpdate: "22E238"), + try makeRuntime(platform: .iOS, version: "18.5", buildUpdate: "22F77"), + ], + installedRuntimes: [makeInstalledRuntime(build: "22E238")], + includingPrereleases: false + ) + + XCTAssertEqual(runtimes.map(\.visibleIdentifier), ["iOS 18.5"]) + } + + func test_installsNothingWhenAPlatformHasNoDownloadableRuntimes() throws { + let runtimes = subject.runtimesToInstall( + platforms: [.tvOS], + downloadableRuntimes: [ + try makeRuntime(platform: .iOS, version: "18.5", buildUpdate: "22F77"), + ], + installedRuntimes: [], + includingPrereleases: false + ) + + XCTAssertTrue(runtimes.isEmpty) + } + + // MARK: - Prereleases + + func test_ignoresBetaRuntimesByDefault() throws { + let runtimes = subject.runtimesToInstall( + platforms: [.iOS], + downloadableRuntimes: [ + try makeRuntime(platform: .iOS, version: "18.5", buildUpdate: "22F77"), + try makeRuntime(platform: .iOS, version: "26.0", buildUpdate: "23A5260", seedNumber: 2), + ], + installedRuntimes: [], + includingPrereleases: false + ) + + XCTAssertEqual(runtimes.map(\.visibleIdentifier), ["iOS 18.5"]) + } + + func test_installsBetaRuntimesWhenPrereleasesAreIncluded() throws { + let runtimes = subject.runtimesToInstall( + platforms: [.iOS], + downloadableRuntimes: [ + try makeRuntime(platform: .iOS, version: "18.5", buildUpdate: "22F77"), + try makeRuntime(platform: .iOS, version: "26.0", buildUpdate: "23A5260", seedNumber: 2), + ], + installedRuntimes: [], + includingPrereleases: true + ) + + XCTAssertEqual(runtimes.map(\.visibleIdentifier), ["iOS 26.0-beta2"]) + } + + func test_prefersTheReleaseOverABetaOfTheSameVersion() throws { + let runtimes = subject.runtimesToInstall( + platforms: [.iOS], + downloadableRuntimes: [ + try makeRuntime(platform: .iOS, version: "26.0", buildUpdate: "23A5260", seedNumber: 2), + try makeRuntime(platform: .iOS, version: "26.0", buildUpdate: "23A339"), + try makeRuntime(platform: .iOS, version: "26.0", buildUpdate: "23A5297", seedNumber: 4), + ], + installedRuntimes: [], + includingPrereleases: true + ) + + XCTAssertEqual(runtimes.map(\.visibleIdentifier), ["iOS 26.0"]) + } + + func test_prefersTheHighestSeedWhenOnlyBetasAreAvailable() throws { + let runtimes = subject.runtimesToInstall( + platforms: [.iOS], + downloadableRuntimes: [ + try makeRuntime(platform: .iOS, version: "26.0", buildUpdate: "23A5260", seedNumber: 2), + try makeRuntime(platform: .iOS, version: "26.0", buildUpdate: "23A5297", seedNumber: 4), + ], + installedRuntimes: [], + includingPrereleases: true + ) + + XCTAssertEqual(runtimes.map(\.visibleIdentifier), ["iOS 26.0-beta4"]) + } + + // MARK: - Preference storage + + func test_platformPreferenceRoundTrips() { + let preference = AutoInstallRuntimePlatforms(platforms: [.watchOS, .iOS]) + + XCTAssertEqual(preference.rawValue, "com.apple.platform.iphoneos,com.apple.platform.watchos") + XCTAssertEqual(AutoInstallRuntimePlatforms(rawValue: preference.rawValue), preference) + } + + func test_emptyPlatformPreferenceRoundTrips() { + let preference = AutoInstallRuntimePlatforms() + + XCTAssertEqual(preference.rawValue, "") + XCTAssertEqual(AutoInstallRuntimePlatforms(rawValue: ""), preference) + } + + func test_platformPreferenceIgnoresUnknownIdentifiers() { + let preference = AutoInstallRuntimePlatforms(rawValue: "com.apple.platform.watchos,com.apple.platform.toaster") + + XCTAssertEqual(preference?.platforms, [.watchOS]) + } + + func test_settingAPlatformOnAndOff() { + var preference = AutoInstallRuntimePlatforms() + + preference.setContains(true, for: .tvOS) + XCTAssertTrue(preference.contains(.tvOS)) + + preference.setContains(false, for: .tvOS) + XCTAssertFalse(preference.contains(.tvOS)) + } + + // MARK: - Helpers + + /// `DownloadableRuntime` is only `Decodable`, so build one the way the app does. + private func makeRuntime( + platform: DownloadableRuntime.Platform, + version: String, + buildUpdate: String, + seedNumber: Int? = nil + ) throws -> DownloadableRuntime { + var json: [String: Any] = [ + "category": "simulator", + "simulatorVersion": ["buildUpdate": buildUpdate, "version": version], + "dictionaryVersion": 2, + "contentType": "diskImage", + "platform": platform.rawValue, + "identifier": "com.apple.dmg.\(platform.shortName).\(buildUpdate)", + "version": "\(version) (\(buildUpdate))", + "fileSize": 7_000_000_000, + "name": "\(platform.shortName) \(version)", + ] + if let seedNumber { + json["seedNumber"] = seedNumber + } + + let data = try JSONSerialization.data(withJSONObject: json) + return try JSONDecoder().decode(DownloadableRuntime.self, from: data) + } + + private func makeInstalledRuntime(build: String) -> CoreSimulatorImage { + CoreSimulatorImage( + uuid: build, + path: ["relative": "file:///Library/Developer/CoreSimulator/Images/\(build).dmg"], + runtimeInfo: CoreSimulatorRuntimeInfo(build: build) + ) + } +}