diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index 02850d70..6cd51348 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -40,7 +40,12 @@ extension AppState { } func installAsync(_ installationType: InstallationType, downloader: Downloader, attemptNumber: Int) async throws -> InstalledXcode { - try await xcodeInstallRetryService.install( + if attemptNumber == 0, case let .version(availableXcode) = installationType { + // Runtimes don't depend on the new Xcode being installed, so download them alongside it. + autoDownloadPlatforms(for: availableXcode) + } + + return try await xcodeInstallRetryService.install( attemptNumber: attemptNumber, shouldRetryAfterDamagedArchive: installationType.shouldRetryAfterDamagedArchive, attempt: { @MainActor _ in diff --git a/Xcodes/Backend/AppState+Runtimes.swift b/Xcodes/Backend/AppState+Runtimes.swift index 78cf486e..8e450960 100644 --- a/Xcodes/Backend/AppState+Runtimes.swift +++ b/Xcodes/Backend/AppState+Runtimes.swift @@ -319,3 +319,115 @@ extension AppState { return error.localizedDescription } } + +// MARK: - Automatic platform downloads + +/// The simulator platforms to download automatically after Xcodes installs an Xcode, as chosen in Settings. +struct AutoDownloadPlatformsSelection: Equatable { + static let defaultsKey = "autoDownloadPlatforms" + static let choosablePlatforms: [DownloadableRuntime.Platform] = [.iOS, .watchOS, .visionOS, .tvOS] + private static let allToken = "all" + + var isAll: Bool + var platforms: Set + + init(isAll: Bool = false, platforms: Set = []) { + self.isAll = isAll + self.platforms = platforms + } + + /// Parses the stored value: "all", or a comma-separated list of platform identifiers. + init(rawValue: String) { + let components = rawValue.split(separator: ",").map(String.init) + self.isAll = components.contains(Self.allToken) + self.platforms = Set(components.compactMap(DownloadableRuntime.Platform.init(rawValue:))) + } + + var rawValue: String { + if isAll { return Self.allToken } + return Self.choosablePlatforms.filter(platforms.contains).map(\.rawValue).joined(separator: ",") + } + + var isEmpty: Bool { + !isAll && platforms.isEmpty + } + + func includes(_ platform: DownloadableRuntime.Platform) -> Bool { + isAll || platforms.contains(platform) + } +} + +extension AppState { + /// Picks one runtime per selected platform that matches the Xcode's SDKs and the architecture variant, + /// skipping platforms that are already installed or installing. + static func runtimesToAutoDownload( + sdkBuilds: [String], + downloadableRuntimes: [DownloadableRuntime], + selection: AutoDownloadPlatformsSelection, + variant: ArchitectureVariant, + isInstalled: (DownloadableRuntime) -> Bool + ) -> [DownloadableRuntime] { + guard !selection.isEmpty else { return [] } + + let candidates = downloadableRuntimes.filter { runtime in + selection.includes(runtime.platform) && + sdkBuilds.contains { runtime.sdkBuildUpdate?.contains($0) ?? false } && + runtime.supports(variant) + } + + var runtimesByPlatform: [DownloadableRuntime.Platform: DownloadableRuntime] = [:] + for runtime in candidates { + guard let existing = runtimesByPlatform[runtime.platform] else { + runtimesByPlatform[runtime.platform] = runtime + continue + } + // Prefer the newest simulator build for the platform + if runtime.simulatorVersion.buildUpdate.localizedStandardCompare(existing.simulatorVersion.buildUpdate) == .orderedDescending { + runtimesByPlatform[runtime.platform] = runtime + } + } + + return runtimesByPlatform.values + .filter { runtime in + if case .installing = runtime.installState { return false } + return !isInstalled(runtime) + } + .sorted { Self.platformOrder($0.platform) < Self.platformOrder($1.platform) } + } + + private static func platformOrder(_ platform: DownloadableRuntime.Platform) -> Int { + AutoDownloadPlatformsSelection.choosablePlatforms.firstIndex(of: platform) ?? .max + } + + /// Downloads the platforms chosen in Settings for an Xcode being installed, in parallel with each + /// other and with the Xcode download, the same as pressing several Install buttons in the info pane. + func autoDownloadPlatforms(for availableXcode: AvailableXcode) { + let selection = AutoDownloadPlatformsSelection(rawValue: Current.defaults.string(forKey: AutoDownloadPlatformsSelection.defaultsKey) ?? "") + guard !selection.isEmpty, let sdkBuilds = availableXcode.sdks?.allBuilds, !sdkBuilds.isEmpty else { return } + + let variant = Current.defaults.string(forKey: "selectedRuntimeArchitecture").flatMap(ArchitectureVariant.init(rawValue:)) ?? .defaultForMachine() + let runtimes = Self.runtimesToAutoDownload( + sdkBuilds: sdkBuilds, + downloadableRuntimes: downloadableRuntimes, + selection: selection, + variant: variant, + isInstalled: { self.coreSimulatorInfo(runtime: $0) != nil } + ) + guard !runtimes.isEmpty else { return } + Logger.appState.info("Automatically downloading platforms: \(runtimes.map(\.name).joined(separator: ", "))") + + runtimes.forEach(downloadRuntime(runtime:)) + } +} + +private extension DownloadableRuntime { + func supports(_ variant: ArchitectureVariant) -> Bool { + guard let architectures, !architectures.isEmpty else { return true } + switch variant { + case .universal: + return architectures.isUniversal + case .appleSilicon: + return architectures.isAppleSilicon + } + } +} diff --git a/Xcodes/Frontend/Preferences/DownloadPreferencePane.swift b/Xcodes/Frontend/Preferences/DownloadPreferencePane.swift index db082ff2..a6cc3e5a 100644 --- a/Xcodes/Frontend/Preferences/DownloadPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/DownloadPreferencePane.swift @@ -1,10 +1,42 @@ import SwiftUI +import XcodesKit struct DownloadPreferencePane: View { @EnvironmentObject var appState: AppState @AppStorage("dataSource") var dataSource: DataSource = .xcodeReleases @AppStorage("downloader") var downloader: Downloader = .aria2 + @AppStorage(AutoDownloadPlatformsSelection.defaultsKey) private var autoDownloadPlatforms = "" + + private var platformSelection: AutoDownloadPlatformsSelection { + AutoDownloadPlatformsSelection(rawValue: autoDownloadPlatforms) + } + + private var downloadsAllPlatforms: Binding { + Binding( + get: { platformSelection.isAll }, + set: { isAll in + var selection = platformSelection + selection.isAll = isAll + autoDownloadPlatforms = selection.rawValue + } + ) + } + + private func downloadsPlatform(_ platform: DownloadableRuntime.Platform) -> Binding { + Binding( + get: { platformSelection.includes(platform) }, + set: { isOn in + var selection = platformSelection + if isOn { + selection.platforms.insert(platform) + } else { + selection.platforms.remove(platform) + } + autoDownloadPlatforms = selection.rawValue + } + ) + } var body: some View { VStack(alignment: .leading) { @@ -46,6 +78,27 @@ struct DownloadPreferencePane: View { } } .groupBoxStyle(PreferencesGroupBoxStyle()) + + GroupBox(label: Text("AutoDownloadPlatforms")) { + VStack(alignment: .leading) { + Toggle("AutoDownloadPlatforms.All", isOn: downloadsAllPlatforms) + + HStack(spacing: 16) { + ForEach(AutoDownloadPlatformsSelection.choosablePlatforms, id: \.self) { platform in + Toggle(isOn: downloadsPlatform(platform)) { + Text(verbatim: platform.shortName) + } + } + } + .disabled(platformSelection.isAll) + + Text("AutoDownloadPlatformsDescription") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .groupBoxStyle(PreferencesGroupBoxStyle()) .disabled(downloader.isManaged) } } diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 6608a149..649f78d7 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -5409,6 +5409,36 @@ } } }, + "AutoDownloadPlatforms" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Platforms" + } + } + } + }, + "AutoDownloadPlatforms.All" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download all platforms" + } + } + } + }, + "AutoDownloadPlatformsDescription" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "When Xcodes installs an Xcode, it also downloads the selected simulator platforms for that Xcode at the same time, for this Mac's architecture. Platforms that are already installed are skipped. iOS includes the iPhone Duo simulator." + } + } + } + }, "AutomaticallyCreateSymbolicLink" : { "localizations" : { "ar" : { diff --git a/XcodesTests/AppStateUpdateTests.swift b/XcodesTests/AppStateUpdateTests.swift index 299e4f01..08b9bc52 100644 --- a/XcodesTests/AppStateUpdateTests.swift +++ b/XcodesTests/AppStateUpdateTests.swift @@ -78,6 +78,67 @@ class AppStateUpdateTests: XCTestCase { XCTAssertEqual(subject.selectedXcodePath, "/Applications/Xcode-Beta.app") } + func test_AutoDownloadPlatformsSelection_RoundTrips() { + XCTAssertTrue(AutoDownloadPlatformsSelection(rawValue: "").isEmpty) + XCTAssertTrue(AutoDownloadPlatformsSelection(rawValue: "all").includes(.visionOS)) + + let selection = AutoDownloadPlatformsSelection(platforms: [.watchOS, .iOS]) + XCTAssertEqual(selection.rawValue, "com.apple.platform.iphoneos,com.apple.platform.watchos") + XCTAssertEqual(AutoDownloadPlatformsSelection(rawValue: selection.rawValue), selection) + XCTAssertFalse(selection.includes(.tvOS)) + } + + func test_RuntimesToAutoDownload_PicksSelectedPlatformsMatchingSDKsAndArchitecture() throws { + let runtimes = try [ + runtime(platform: "iphoneos", identifier: "ios-arm", sdkBuild: "24A5422a", simulatorBuild: "24A5422a", architectures: ["arm64"]), + runtime(platform: "iphoneos", identifier: "ios-universal", sdkBuild: "24A5422a", simulatorBuild: "24A5422a", architectures: ["arm64", "x86_64"]), + runtime(platform: "iphoneos", identifier: "ios-older", sdkBuild: "24A5408c", simulatorBuild: "24A5408c", architectures: ["arm64"]), + runtime(platform: "watchos", identifier: "watch-arm", sdkBuild: "24R5355a", simulatorBuild: "24R5355a", architectures: ["arm64"]), + runtime(platform: "appletvos", identifier: "tv-arm", sdkBuild: "24J5356a", simulatorBuild: "24J5356a", architectures: ["arm64"]), + ] + + let picked = AppState.runtimesToAutoDownload( + sdkBuilds: ["24A5422a", "24R5355a", "24J5356a"], + downloadableRuntimes: runtimes, + selection: AutoDownloadPlatformsSelection(platforms: [.iOS, .watchOS]), + variant: .appleSilicon, + isInstalled: { $0.identifier == "watch-arm" } + ) + + XCTAssertEqual(picked.map(\.identifier), ["ios-arm"]) + } + + func test_RuntimesToAutoDownload_NothingSelectedDownloadsNothing() throws { + let runtimes = try [runtime(platform: "iphoneos", identifier: "ios-arm", sdkBuild: "24A5422a", simulatorBuild: "24A5422a", architectures: ["arm64"])] + + let picked = AppState.runtimesToAutoDownload( + sdkBuilds: ["24A5422a"], + downloadableRuntimes: runtimes, + selection: AutoDownloadPlatformsSelection(), + variant: .appleSilicon, + isInstalled: { _ in false } + ) + + XCTAssertTrue(picked.isEmpty) + } + + private func runtime(platform: String, identifier: String, sdkBuild: String, simulatorBuild: String, architectures: [String]) throws -> DownloadableRuntime { + let json: [String: Any] = [ + "sdkBuildUpdate": [sdkBuild], + "architectures": architectures, + "name": identifier, + "platform": "com.apple.platform.\(platform)", + "simulatorVersion": ["version": "27.0", "buildUpdate": simulatorBuild], + "contentType": "cryptexDiskImage", + "dictionaryVersion": 2, + "version": "27.0.0.1", + "category": "simulator", + "identifier": identifier, + "fileSize": 1, + ] + return try JSONDecoder().decode(DownloadableRuntime.self, from: JSONSerialization.data(withJSONObject: json)) + } + func testDoesNotReplaceInstallState() throws { subject.allXcodes = [ Xcode(version: Version("0.0.0")!, installState: .installing(.unarchiving), selected: false, icon: nil)