Skip to content
Open
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ XcodesApp is now part of the `XcodesOrg` - [read more here](nextstep.md)
## Platforms/Runtimes

- Xcodes supports downloading the Apple runtimes via the app. Simply click on the Platform, and Xcodes will install automatically for you.
- Platforms are listed in the order iOS, watchOS, visionOS, tvOS, with the newest version first.
- Uninstall an installed platform with the **Uninstall** button next to it in the Xcode's Platforms list.
- When uninstalling an Xcode, choose **Uninstall with Platforms** to also remove the simulator platforms that only that Xcode uses. Platforms that another installed Xcode still uses are never offered for removal.

**Note: iOS 18+, tvOS 18+, watchOS 11+, visionOS 2+ requires that Xcode 16.1 Beta 3+ be installed and active.**

Expand Down
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
223 changes: 217 additions & 6 deletions Xcodes/Backend/AppState+Runtimes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ extension AppState {
for: runtime,
destinationDirectory: .xcodesApplicationSupport,
downloader: downloader
) { progress in
) { [weak self] progress in
let expectedTaskID = taskID
Task { @MainActor [weak self] in
if let expectedTaskID, self?.runtimeTaskIDs[runtime.identifier] != expectedTaskID {
Expand Down Expand Up @@ -285,7 +285,8 @@ extension AppState {
}
}

func confirmDeleteRuntime(runtime: DownloadableRuntime) {
/// - Parameter presentErrorInSettings: Show failures in the Settings window (Platforms list) instead of the main window.
func confirmDeleteRuntime(runtime: DownloadableRuntime, presentErrorInSettings: Bool = true) {
deleteRuntimeTask?.cancel()
let taskID = UUID()
deleteRuntimeTaskID = taskID
Expand All @@ -303,14 +304,54 @@ extension AppState {
} catch is CancellationError {
} catch {
guard self.deleteRuntimeTaskID == taskID else { return }
self.presentedPreferenceAlert = .generic(
title: "Error",
message: self.runtimeDeletionErrorMessage(error)
)
self.presentRuntimeDeletionError(error, runtime: runtime, inSettings: presentErrorInSettings)
}
}
}

/// Shows why a platform couldn't be removed. When it failed because no Xcode is active,
/// offers to make the newest installed release active and try again.
func presentRuntimeDeletionError(_ error: Error, runtime: DownloadableRuntime, inSettings: Bool) {
let message = runtimeDeletionErrorMessage(error)
if Self.isMissingDeveloperToolError(message) {
if inSettings {
presentedPreferenceAlert = .noActiveXcode(runtime: runtime, xcode: latestInstalledReleaseXcode)
} else {
presentedAlert = .noActiveXcode(runtime: runtime, xcode: latestInstalledReleaseXcode)
}
} else if inSettings {
presentedPreferenceAlert = .generic(title: "Error", message: message)
} else {
presentedAlert = .generic(title: "Error", message: message)
}
}

/// `xcrun` reports this when the active developer directory is the Command Line Tools,
/// which don't include `simctl`.
static func isMissingDeveloperToolError(_ message: String) -> Bool {
message.contains("unable to find utility") || message.contains("requires Xcode")
}

/// The newest installed Xcode that isn't a beta or release candidate.
var latestInstalledReleaseXcode: Xcode? {
allXcodes
.filter { $0.installState.installed && $0.version.prereleaseIdentifiers.isEmpty }
.max { $0.version < $1.version }
}

/// Makes the Xcode active, then tries removing the platform again.
func selectXcodeAndDeleteRuntime(xcode: Xcode, runtime: DownloadableRuntime, presentErrorInSettings: Bool) {
select(xcode: xcode)
Task { @MainActor [weak self] in
// select(xcode:) starts selectTask right away unless it first has to ask to install the helper;
// in that case the user can remove the platform again once the Xcode is active.
guard let self, let selectTask = self.selectTask else { return }
await selectTask.value
guard let path = xcode.installedPath?.string, self.selectedXcodePath?.hasPrefix(path) == true else { return }
self.confirmDeleteRuntime(runtime: runtime, presentErrorInSettings: presentErrorInSettings)
}
}

private func runtimeDeletionErrorMessage(_ error: Error) -> String {
if let error = error as? XcodesKitError {
return error.message
Expand All @@ -319,3 +360,173 @@ 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
}
}
}

// MARK: - Platforms of an Xcode

extension DownloadableRuntime.Platform {
/// The order platforms are listed in: iOS, watchOS, visionOS, tvOS.
var displayOrder: Int {
switch self {
case .iOS: return 0
case .watchOS: return 1
case .visionOS: return 2
case .tvOS: return 3
case .macOS: return 4
}
}
}

extension AppState {
/// Installed simulator runtimes that match this Xcode's SDKs and no other installed Xcode's,
/// so they can be offered for removal along with it.
func installedRuntimesUsedOnly(by xcode: Xcode) -> [DownloadableRuntime] {
func runtimes(matching xcode: Xcode) -> Set<String> {
let builds = xcode.platformSDKBuilds
return Set(downloadableRuntimes
.filter { runtime in builds.contains { runtime.sdkBuildUpdate?.contains($0) ?? false } }
.map(\.identifier))
}

let otherInstalledXcodes = allXcodes.filter { $0.id != xcode.id && $0.installState.installed }
let usedElsewhere = otherInstalledXcodes.reduce(into: Set<String>()) { $0.formUnion(runtimes(matching: $1)) }
let candidates = runtimes(matching: xcode).subtracting(usedElsewhere)

return downloadableRuntimes
.filter { candidates.contains($0.identifier) && coreSimulatorInfo(runtime: $0) != nil }
.sorted { ($0.platform.displayOrder, $1.name) < ($1.platform.displayOrder, $0.name) }
}

/// Uninstalls an Xcode, then removes the given simulator runtimes.
@discardableResult
func uninstall(xcode: Xcode, removingRuntimes runtimes: [DownloadableRuntime]) -> Task<Void, Never>? {
guard let installedPath = xcode.installedPath else { return nil }
uninstall(xcode: xcode)
guard !runtimes.isEmpty, let removalTask = uninstallTask else { return uninstallTask }
return Task { @MainActor [weak self] in
await removalTask.value
// Failed or cancelled removal leaves the Xcode in the list. Keep its platforms too.
guard let self, !Task.isCancelled, !removalTask.isCancelled,
!self.allXcodes.contains(where: { $0.installedPath == installedPath }) else { return }
for runtime in runtimes {
do {
try await self.deleteRuntime(runtime: runtime)
} catch {
self.presentRuntimeDeletionError(error, runtime: runtime, inSettings: false)
return
}
}
}
}
}
6 changes: 5 additions & 1 deletion Xcodes/Backend/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -930,7 +930,11 @@ class AppState: ObservableObject {
)

self.allXcodes = items.map { item in
Xcode(item, icon: item.installedPath.map { NSWorkspace.shared.icon(forFile: $0.string) })
Xcode(
item,
icon: item.installedPath.map { NSWorkspace.shared.icon(forFile: $0.string) },
installedSDKBuilds: item.installedPath.map { InstalledSDKBuilds.builds(forXcodeAt: $0, version: item.version) } ?? []
)
}
}

Expand Down
58 changes: 55 additions & 3 deletions Xcodes/Backend/Xcode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ struct Xcode: Identifiable, CustomStringConvertible {
let compilers: Compilers?
let downloadFileSize: Int64?
let architectures: [Architecture]?
/// SDK build identifiers read from the installed bundle, used when the data source has no SDK metadata
let installedSDKBuilds: [String]
let id: XcodeID

init(
Expand All @@ -34,7 +36,8 @@ struct Xcode: Identifiable, CustomStringConvertible {
sdks: SDKs? = nil,
compilers: Compilers? = nil,
downloadFileSize: Int64? = nil,
architectures: [Architecture]? = nil
architectures: [Architecture]? = nil,
installedSDKBuilds: [String] = []
) {
self.identicalBuilds = identicalBuilds
self.installState = installState
Expand All @@ -47,10 +50,11 @@ struct Xcode: Identifiable, CustomStringConvertible {
self.compilers = compilers
self.downloadFileSize = downloadFileSize
self.architectures = architectures
self.installedSDKBuilds = installedSDKBuilds
self.id = XcodeID(version: version, architectures: architectures)
}

init(_ item: XcodeListItem, icon: NSImage?) {
init(_ item: XcodeListItem, icon: NSImage?, installedSDKBuilds: [String] = []) {
self.identicalBuilds = item.identicalBuilds
self.installState = item.installState
self.selected = item.selected
Expand All @@ -62,6 +66,7 @@ struct Xcode: Identifiable, CustomStringConvertible {
self.compilers = item.compilers
self.downloadFileSize = item.downloadFileSize
self.architectures = item.architectures
self.installedSDKBuilds = installedSDKBuilds
self.id = item.id
}

Expand Down Expand Up @@ -96,5 +101,52 @@ struct Xcode: Identifiable, CustomStringConvertible {
var installedPath: Path? {
installState.installedPath
}


/// SDK builds used to find matching platform runtimes. An installed bundle is the source of truth,
/// since the Apple data source has no SDK metadata and a renamed beta may not match its release.
var platformSDKBuilds: [String] {
installedSDKBuilds.isEmpty ? (sdks?.allBuilds ?? []) : installedSDKBuilds
}
}

enum InstalledSDKBuilds {
private static let lock = NSLock()
nonisolated(unsafe) private static var cache: [String: [String]] = [:]

/// Reads the ProductBuildVersion of each SDK in an installed Xcode, e.g. iPhoneOS.sdk -> 24A5422a.
static func builds(forXcodeAt path: Path, version: Version) -> [String] {
let key = "\(path.string)|\(version.buildMetadataIdentifiers.joined())"
lock.lock()
if let cached = cache[key] {
lock.unlock()
return cached
}
lock.unlock()

let fileManager = FileManager.default
let platformsURL = path.url.appending(path: "Contents/Developer/Platforms")
let platformURLs = (try? fileManager.contentsOfDirectory(at: platformsURL, includingPropertiesForKeys: nil)) ?? []
var builds: [String] = []
for platformURL in platformURLs where platformURL.pathExtension == "platform" {
let sdksURL = platformURL.appending(path: "Developer/SDKs")
let sdkURLs = (try? fileManager.contentsOfDirectory(at: sdksURL, includingPropertiesForKeys: [.isSymbolicLinkKey])) ?? []
for sdkURL in sdkURLs where sdkURL.pathExtension == "sdk" {
// Versioned SDK names (e.g. iPhoneOS27.0.sdk) are symlinks to the same SDK
if (try? sdkURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true { continue }
let systemVersionURL = sdkURL.appending(path: "System/Library/CoreServices/SystemVersion.plist")
guard
let data = Current.files.contents(atPath: systemVersionURL.path),
let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any],
let build = plist["ProductBuildVersion"] as? String,
!builds.contains(build)
else { continue }
builds.append(build)
}
}

lock.lock()
cache[key] = builds
lock.unlock()
return builds
}
}
Loading