From ba33e1c74c3a136e319fb668af80352ba64e3ea9 Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 16:31:52 +0200 Subject: [PATCH 1/4] Optionally download platforms after installing an Xcode Add Settings > Downloads > Platforms: "Download all platforms" or a checkbox per platform (iOS, watchOS, tvOS, visionOS). After Xcodes installs an Xcode, it downloads the selected simulator runtimes that match that Xcode's SDKs and this Mac's architecture variant, one at a time, skipping platforms that are already installed or installing. Runtimes are installed the same way as the Install buttons in the info pane (xcodebuild -downloadPlatform for cryptex runtimes, which uses the selected Xcode). Co-Authored-By: Claude Opus 5.5 --- Xcodes/Backend/AppState+Install.swift | 1 + Xcodes/Backend/AppState+Runtimes.swift | 114 ++++++++++++++++++ .../Preferences/DownloadPreferencePane.swift | 53 ++++++++ Xcodes/Resources/Localizable.xcstrings | 30 +++++ XcodesTests/AppStateUpdateTests.swift | 61 ++++++++++ 5 files changed, 259 insertions(+) diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index 02850d70..0e729a61 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -246,6 +246,7 @@ extension AppState { self.presentedAlert = .generic(title: localizeString("Alert.InstallArchive.Error.Title"), message: error.legibleLocalizedDescription) } resetDockProgressTracking() + autoDownloadPlatforms(for: availableXcode) return installedXcode } diff --git a/Xcodes/Backend/AppState+Runtimes.swift b/Xcodes/Backend/AppState+Runtimes.swift index 78cf486e..80256360 100644 --- a/Xcodes/Backend/AppState+Runtimes.swift +++ b/Xcodes/Backend/AppState+Runtimes.swift @@ -319,3 +319,117 @@ 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, .tvOS, .visionOS] + 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 { $0.platform.order < $1.platform.order } + } + + /// Downloads the platforms chosen in Settings for a newly installed Xcode, one at a time. + 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: ", "))") + + Task { @MainActor [weak self] in + for runtime in runtimes { + guard let self, !Task.isCancelled else { return } + self.downloadRuntime(runtime: runtime) + // Downloads run one at a time; xcodebuild doesn't handle concurrent platform downloads well. + await self.runtimeTasks[runtime.identifier]?.value + } + } + } +} + +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..08b23b0b 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" : "After Xcodes installs an Xcode, it also downloads the selected simulator platforms for that Xcode, one at a time, for this Mac's architecture. Platforms that are already installed are skipped." + } + } + } + }, "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) From cd866ee3d777da015e60f79d5e66fe048e5b2f2b Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 16:46:24 +0200 Subject: [PATCH 2/4] Download selected platforms alongside the Xcode install Start the automatic platform downloads when an Xcode install starts instead of after it finishes. Runtimes don't depend on the new Xcode being installed, so this saves waiting for the Xcode download and unarchive first. Platforms still download one at a time. Co-Authored-By: Claude Opus 5.5 --- Xcodes/Backend/AppState+Install.swift | 8 ++++++-- Xcodes/Backend/AppState+Runtimes.swift | 3 ++- Xcodes/Resources/Localizable.xcstrings | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index 0e729a61..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 @@ -246,7 +251,6 @@ extension AppState { self.presentedAlert = .generic(title: localizeString("Alert.InstallArchive.Error.Title"), message: error.legibleLocalizedDescription) } resetDockProgressTracking() - autoDownloadPlatforms(for: availableXcode) return installedXcode } diff --git a/Xcodes/Backend/AppState+Runtimes.swift b/Xcodes/Backend/AppState+Runtimes.swift index 80256360..2ca653cb 100644 --- a/Xcodes/Backend/AppState+Runtimes.swift +++ b/Xcodes/Backend/AppState+Runtimes.swift @@ -395,7 +395,8 @@ extension AppState { .sorted { $0.platform.order < $1.platform.order } } - /// Downloads the platforms chosen in Settings for a newly installed Xcode, one at a time. + /// Downloads the platforms chosen in Settings for an Xcode being installed, one at a time, + /// alongside the Xcode download. 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 } diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 08b23b0b..0cd110e0 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -5434,7 +5434,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "After Xcodes installs an Xcode, it also downloads the selected simulator platforms for that Xcode, one at a time, for this Mac's architecture. Platforms that are already installed are skipped." + "value" : "When Xcodes installs an Xcode, it also downloads the selected simulator platforms for that Xcode alongside it, one platform at a time, for this Mac's architecture. Platforms that are already installed are skipped." } } } From 8c67f8730a3a8124e4e66838f70f6c227e9241a3 Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 16:50:12 +0200 Subject: [PATCH 3/4] Download selected platforms in parallel Start every selected platform at once, like pressing several Install buttons in the info pane, instead of one after another. Mention in the setting's description that iOS includes the iPhone Duo simulator, which is a device type in the iOS 27.1 runtime rather than its own platform. Co-Authored-By: Claude Opus 5.5 --- Xcodes/Backend/AppState+Runtimes.swift | 13 +++---------- Xcodes/Resources/Localizable.xcstrings | 2 +- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/Xcodes/Backend/AppState+Runtimes.swift b/Xcodes/Backend/AppState+Runtimes.swift index 2ca653cb..e006d6f5 100644 --- a/Xcodes/Backend/AppState+Runtimes.swift +++ b/Xcodes/Backend/AppState+Runtimes.swift @@ -395,8 +395,8 @@ extension AppState { .sorted { $0.platform.order < $1.platform.order } } - /// Downloads the platforms chosen in Settings for an Xcode being installed, one at a time, - /// alongside the Xcode download. + /// 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 } @@ -412,14 +412,7 @@ extension AppState { guard !runtimes.isEmpty else { return } Logger.appState.info("Automatically downloading platforms: \(runtimes.map(\.name).joined(separator: ", "))") - Task { @MainActor [weak self] in - for runtime in runtimes { - guard let self, !Task.isCancelled else { return } - self.downloadRuntime(runtime: runtime) - // Downloads run one at a time; xcodebuild doesn't handle concurrent platform downloads well. - await self.runtimeTasks[runtime.identifier]?.value - } - } + runtimes.forEach(downloadRuntime(runtime:)) } } diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 0cd110e0..649f78d7 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -5434,7 +5434,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "When Xcodes installs an Xcode, it also downloads the selected simulator platforms for that Xcode alongside it, one platform at a time, for this Mac's architecture. Platforms that are already installed are skipped." + "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." } } } From 50e9cb91db9dd2fed5b1a149810660a1b01906d2 Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 17:09:33 +0200 Subject: [PATCH 4/4] Order platform checkboxes as iOS, watchOS, visionOS, tvOS Co-Authored-By: Claude Opus 5.5 --- Xcodes/Backend/AppState+Runtimes.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Xcodes/Backend/AppState+Runtimes.swift b/Xcodes/Backend/AppState+Runtimes.swift index e006d6f5..8e450960 100644 --- a/Xcodes/Backend/AppState+Runtimes.swift +++ b/Xcodes/Backend/AppState+Runtimes.swift @@ -325,7 +325,7 @@ extension AppState { /// 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, .tvOS, .visionOS] + static let choosablePlatforms: [DownloadableRuntime.Platform] = [.iOS, .watchOS, .visionOS, .tvOS] private static let allToken = "all" var isAll: Bool @@ -392,7 +392,11 @@ extension AppState { if case .installing = runtime.installState { return false } return !isInstalled(runtime) } - .sorted { $0.platform.order < $1.platform.order } + .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