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
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
105 changes: 100 additions & 5 deletions Xcodes/Backend/AppState+Runtimes.swift
Original file line number Diff line number Diff line change
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,11 +304,51 @@ 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)
}
}

Expand All @@ -319,3 +360,57 @@ extension AppState {
return error.localizedDescription
}
}

// 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.sdks?.allBuilds ?? []
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.
func uninstall(xcode: Xcode, removingRuntimes runtimes: [DownloadableRuntime]) {
uninstall(xcode: xcode)
guard !runtimes.isEmpty else { return }
Task { @MainActor [weak self] in
// Remove the platforms once the Xcode itself is gone, so a failure there doesn't leave it half-removed
await self?.uninstallTask?.value
for runtime in runtimes {
do {
try await self?.deleteRuntime(runtime: runtime)
} catch {
self?.presentRuntimeDeletionError(error, runtime: runtime, inSettings: false)
return
}
}
}
}
}
31 changes: 31 additions & 0 deletions Xcodes/Frontend/Common/XcodesAlert.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import SwiftUI
import XcodesKit

enum XcodesAlert: Identifiable {
Expand All @@ -8,6 +9,8 @@ enum XcodesAlert: Identifiable {
case generic(title: String, message: String)
case checkMinSupportedVersion(xcode: AvailableXcode, macOS: String)
case unauthenticated
case deletePlatform(runtime: DownloadableRuntime)
case noActiveXcode(runtime: DownloadableRuntime, xcode: Xcode?)

var id: Int {
switch self {
Expand All @@ -17,6 +20,8 @@ enum XcodesAlert: Identifiable {
case .checkMinSupportedVersion: return 4
case .cancelRuntimeInstall: return 5
case .unauthenticated: return 6
case .deletePlatform: return 8
case .noActiveXcode: return 9
}
}
}
Expand All @@ -26,11 +31,37 @@ enum XcodesAlert: Identifiable {
enum XcodesPreferencesAlert: Identifiable {
case deletePlatform(runtime: DownloadableRuntime)
case generic(title: String, message: String)
case noActiveXcode(runtime: DownloadableRuntime, xcode: Xcode?)

var id: Int {
switch self {
case .deletePlatform: return 1
case .generic: return 2
case .noActiveXcode: return 3
}
}
}

extension Alert {
/// Removing a platform runs `simctl`, which only comes with Xcode. Offers to make the newest installed release active and retry.
@MainActor
static func noActiveXcode(appState: AppState, runtime: DownloadableRuntime, xcode: Xcode?, inSettings: Bool) -> Alert {
let explanation = String(format: localizeString("Alert.NoActiveXcode.Message"), runtime.name, appState.selectedXcodePath ?? "–")
guard let xcode else {
return Alert(
title: Text("Alert.NoActiveXcode.Title"),
message: Text(verbatim: explanation + "\n\n" + localizeString("Alert.NoActiveXcode.NoneInstalled")),
dismissButton: .default(Text("OK"))
)
}
return Alert(
title: Text("Alert.NoActiveXcode.Title"),
message: Text(verbatim: explanation),
primaryButton: .default(
Text(String(format: localizeString("Alert.NoActiveXcode.PrimaryButton"), xcode.description)),
action: { appState.selectXcodeAndDeleteRuntime(xcode: xcode, runtime: runtime, presentErrorInSettings: inSettings) }
),
secondaryButton: .cancel(Text("Cancel"))
)
}
}
31 changes: 19 additions & 12 deletions Xcodes/Frontend/InfoPane/PlatformsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ struct PlatformsView: View {
guard !(runtime.architectures?.isEmpty ?? true), let displayedVariant else { return true }
return runtime.supports(displayedVariant)
}
// iOS, watchOS, visionOS, tvOS; newest version first within a platform
.sorted { lhs, rhs in
if lhs.platform.displayOrder != rhs.platform.displayOrder {
return lhs.platform.displayOrder < rhs.platform.displayOrder
}
return lhs.simulatorVersion.version.localizedStandardCompare(rhs.simulatorVersion.version) == .orderedDescending
}

VStack {
HStack {
Expand Down Expand Up @@ -84,20 +91,20 @@ struct PlatformsView: View {
}

pathIfAvailable(xcode: xcode, runtime: runtime)

if runtime.installState == .notInstalled {
// TODO: Update the downloadableRuntimes with the appropriate installState so we don't have to check path awkwardly
if appState.runtimeInstallPath(xcode: xcode, runtime: runtime) != nil {
EmptyView()
} else {
HStack {
Spacer()
DownloadRuntimeButton(runtime: runtime)
}

// One spacer, so the action button sits next to the size column on every row
Spacer()

// TODO: Update the downloadableRuntimes with the appropriate installState so we don't have to check path awkwardly
if appState.runtimeInstallPath(xcode: xcode, runtime: runtime) != nil {
Button("Uninstall", role: .destructive) {
appState.presentedAlert = .deletePlatform(runtime: runtime)
}
.help("Alert.DeletePlatform.PrimaryButton")
} else if runtime.installState == .notInstalled {
DownloadRuntimeButton(runtime: runtime)
}

Spacer()

Text(runtime.downloadFileSizeString)
.font(.subheadline)
.frame(width: 70, alignment: .trailing)
Expand Down
36 changes: 31 additions & 5 deletions Xcodes/Frontend/MainWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,26 @@ struct MainWindow: View {
NavigationSplitViewWrapper {
XcodeListView(selectedXcodeID: $selectedXcodeID, searchText: searchText, category: category, isInstalledOnly: isInstalledOnly, architecture: architecture)
.layoutPriority(1)
.alert(item: $appState.xcodeBeingConfirmedForUninstallation) { xcode in
Alert(title: Text(String(format: localizeString("Alert.Uninstall.Title"), xcode.description)),
message: Text("Alert.Uninstall.Message"),
primaryButton: .destructive(Text("Uninstall"), action: { self.appState.uninstall(xcode: xcode) }),
secondaryButton: .cancel(Text("Cancel")))
.alert(
Text(String(format: localizeString("Alert.Uninstall.Title"), appState.xcodeBeingConfirmedForUninstallation?.description ?? "")),
isPresented: $appState.xcodeBeingConfirmedForUninstallation.isNotNil,
presenting: appState.xcodeBeingConfirmedForUninstallation
) { xcode in
let platforms = appState.installedRuntimesUsedOnly(by: xcode)
Button("Uninstall", role: .destructive) { appState.uninstall(xcode: xcode) }
if !platforms.isEmpty {
Button("Alert.Uninstall.WithPlatforms", role: .destructive) {
appState.uninstall(xcode: xcode, removingRuntimes: platforms)
}
}
Button("Cancel", role: .cancel) {}
} message: { xcode in
let platforms = appState.installedRuntimesUsedOnly(by: xcode)
if platforms.isEmpty {
Text("Alert.Uninstall.Message")
} else {
Text(verbatim: localizeString("Alert.Uninstall.Message") + "\n\n" + String(format: localizeString("Alert.Uninstall.PlatformsMessage"), platforms.map(\.visibleIdentifier).joined(separator: ", ")))
}
}
.searchable(text: $searchText, placement: .sidebar)
.mainToolbar(
Expand Down Expand Up @@ -204,6 +219,17 @@ struct MainWindow: View {
Text("Cancel")
)
)
case let .noActiveXcode(runtime, xcode):
return .noActiveXcode(appState: appState, runtime: runtime, xcode: xcode, inSettings: false)
case let .deletePlatform(runtime):
return Alert(
title: Text(String(format: localizeString("Alert.DeletePlatform.Title"), runtime.name)),
primaryButton: .destructive(
Text("Alert.DeletePlatform.PrimaryButton"),
action: { appState.confirmDeleteRuntime(runtime: runtime, presentErrorInSettings: false) }
),
secondaryButton: .cancel(Text("Cancel"))
)
case let .checkMinSupportedVersion(xcode, deviceVersion):
return Alert(
title: Text("Alert.MinSupported.Title"),
Expand Down
2 changes: 1 addition & 1 deletion Xcodes/Frontend/Preferences/PlatformsListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ struct PlatformsListView: View {
List(selection: $selectedRuntime) {
Text("PlatformsList.Title")
.font(.body)
ForEach(runtimes.elements.sorted(\.key.order), id: \.key) { platform, runtimeList in
ForEach(runtimes.elements.sorted(\.key.displayOrder), id: \.key) { platform, runtimeList in
Section {
ForEach(runtimeList, id: \.self) { runtime in
HStack {
Expand Down
65 changes: 65 additions & 0 deletions Xcodes/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -2971,6 +2971,50 @@
}
}
},
"Alert.NoActiveXcode.Message" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Removing %1$@ uses simctl, which comes with Xcode. The active developer directory is %2$@, which doesn't include it."
}
}
}
},
"Alert.NoActiveXcode.NoneInstalled" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Install a release of Xcode, make it active, then try again."
}
}
}
},
"Alert.NoActiveXcode.PrimaryButton" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Make %@ Active and Retry"
}
}
}
},
"Alert.NoActiveXcode.Title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No Xcode Is Active"
}
}
}
},
"Alert.PostInstall.Title" : {
"comment" : "Post install",
"extractionState" : "manual",
Expand Down Expand Up @@ -4282,6 +4326,17 @@
}
}
},
"Alert.Uninstall.PlatformsMessage" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "These simulator platforms are installed for this Xcode and not used by your other installed Xcodes: %@"
}
}
}
},
"Alert.Uninstall.Title" : {
"comment" : "Uninstall",
"extractionState" : "manual",
Expand Down Expand Up @@ -4414,6 +4469,16 @@
}
}
},
"Alert.Uninstall.WithPlatforms" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Uninstall with Platforms"
}
}
}
},
"Alert.Update.Error.Title" : {
"comment" : "Update",
"extractionState" : "manual",
Expand Down
2 changes: 2 additions & 0 deletions Xcodes/XcodesApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ struct XcodesApp: App {

private func alert(for alertType: XcodesPreferencesAlert) -> Alert {
switch alertType {
case let .noActiveXcode(runtime, xcode):
return .noActiveXcode(appState: appState, runtime: runtime, xcode: xcode, inSettings: true)
case let .deletePlatform(runtime):
return Alert(
title: Text(String(format: localizeString("Alert.DeletePlatform.Title"), runtime.name)),
Expand Down
Loading