Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Xcodes/Backend/AppState+Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions Xcodes/Backend/AppState+Runtimes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<DownloadableRuntime.Platform>

init(isAll: Bool = false, platforms: Set<DownloadableRuntime.Platform> = []) {
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
}
}
}
53 changes: 53 additions & 0 deletions Xcodes/Frontend/Preferences/DownloadPreferencePane.swift
Original file line number Diff line number Diff line change
@@ -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<Bool> {
Binding(
get: { platformSelection.isAll },
set: { isAll in
var selection = platformSelection
selection.isAll = isAll
autoDownloadPlatforms = selection.rawValue
}
)
}

private func downloadsPlatform(_ platform: DownloadableRuntime.Platform) -> Binding<Bool> {
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) {
Expand Down Expand Up @@ -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)
}
}
Expand Down
30 changes: 30 additions & 0 deletions Xcodes/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down
61 changes: 61 additions & 0 deletions XcodesTests/AppStateUpdateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down