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) + ) + } +}