From c57145533bbdf23fefd00958456d013dabc61a2d Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 17:09:06 +0200 Subject: [PATCH 1/6] List platforms as iOS, watchOS, visionOS, tvOS The info pane listed platforms in the order of Apple's runtime index and the Platforms window put tvOS before visionOS. Use one order everywhere, with the newest version first within a platform. Co-Authored-By: Claude Opus 5.5 --- Xcodes/Backend/AppState+Runtimes.swift | 15 +++++++++++++++ Xcodes/Frontend/InfoPane/PlatformsView.swift | 7 +++++++ .../Frontend/Preferences/PlatformsListView.swift | 2 +- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Xcodes/Backend/AppState+Runtimes.swift b/Xcodes/Backend/AppState+Runtimes.swift index 78cf486e..5cf708db 100644 --- a/Xcodes/Backend/AppState+Runtimes.swift +++ b/Xcodes/Backend/AppState+Runtimes.swift @@ -319,3 +319,18 @@ 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 + } + } +} diff --git a/Xcodes/Frontend/InfoPane/PlatformsView.swift b/Xcodes/Frontend/InfoPane/PlatformsView.swift index 34af471e..8a721817 100644 --- a/Xcodes/Frontend/InfoPane/PlatformsView.swift +++ b/Xcodes/Frontend/InfoPane/PlatformsView.swift @@ -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 { diff --git a/Xcodes/Frontend/Preferences/PlatformsListView.swift b/Xcodes/Frontend/Preferences/PlatformsListView.swift index d789e8a5..472eca9b 100644 --- a/Xcodes/Frontend/Preferences/PlatformsListView.swift +++ b/Xcodes/Frontend/Preferences/PlatformsListView.swift @@ -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 { From ca143eaf0ea272c2d56e89498d2dae4f29ed9fc3 Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 17:09:22 +0200 Subject: [PATCH 2/6] Add an Uninstall button for installed platforms in the info pane Installed platforms could only be removed from the Platforms window. Each installed platform in the info pane now has an Uninstall button, confirmed with the same alert the Platforms window uses, with errors shown in the main window. Co-Authored-By: Claude Opus 5.5 --- Xcodes/Backend/AppState+Runtimes.swift | 18 +++++++++++++----- Xcodes/Frontend/Common/XcodesAlert.swift | 2 ++ Xcodes/Frontend/InfoPane/PlatformsView.swift | 8 ++++++++ Xcodes/Frontend/MainWindow.swift | 9 +++++++++ 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/Xcodes/Backend/AppState+Runtimes.swift b/Xcodes/Backend/AppState+Runtimes.swift index 5cf708db..8d6106ed 100644 --- a/Xcodes/Backend/AppState+Runtimes.swift +++ b/Xcodes/Backend/AppState+Runtimes.swift @@ -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 @@ -303,10 +304,17 @@ extension AppState { } catch is CancellationError { } catch { guard self.deleteRuntimeTaskID == taskID else { return } - self.presentedPreferenceAlert = .generic( - title: "Error", - message: self.runtimeDeletionErrorMessage(error) - ) + if presentErrorInSettings { + self.presentedPreferenceAlert = .generic( + title: "Error", + message: self.runtimeDeletionErrorMessage(error) + ) + } else { + self.presentedAlert = .generic( + title: "Error", + message: self.runtimeDeletionErrorMessage(error) + ) + } } } } diff --git a/Xcodes/Frontend/Common/XcodesAlert.swift b/Xcodes/Frontend/Common/XcodesAlert.swift index 710668f9..61805fc3 100644 --- a/Xcodes/Frontend/Common/XcodesAlert.swift +++ b/Xcodes/Frontend/Common/XcodesAlert.swift @@ -8,6 +8,7 @@ enum XcodesAlert: Identifiable { case generic(title: String, message: String) case checkMinSupportedVersion(xcode: AvailableXcode, macOS: String) case unauthenticated + case deletePlatform(runtime: DownloadableRuntime) var id: Int { switch self { @@ -17,6 +18,7 @@ enum XcodesAlert: Identifiable { case .checkMinSupportedVersion: return 4 case .cancelRuntimeInstall: return 5 case .unauthenticated: return 6 + case .deletePlatform: return 8 } } } diff --git a/Xcodes/Frontend/InfoPane/PlatformsView.swift b/Xcodes/Frontend/InfoPane/PlatformsView.swift index 8a721817..983e0329 100644 --- a/Xcodes/Frontend/InfoPane/PlatformsView.swift +++ b/Xcodes/Frontend/InfoPane/PlatformsView.swift @@ -91,6 +91,14 @@ struct PlatformsView: View { } pathIfAvailable(xcode: xcode, runtime: runtime) + + if appState.runtimeInstallPath(xcode: xcode, runtime: runtime) != nil { + Spacer() + Button("Uninstall", role: .destructive) { + appState.presentedAlert = .deletePlatform(runtime: runtime) + } + .help("Alert.DeletePlatform.PrimaryButton") + } if runtime.installState == .notInstalled { // TODO: Update the downloadableRuntimes with the appropriate installState so we don't have to check path awkwardly diff --git a/Xcodes/Frontend/MainWindow.swift b/Xcodes/Frontend/MainWindow.swift index b108f053..58761d36 100644 --- a/Xcodes/Frontend/MainWindow.swift +++ b/Xcodes/Frontend/MainWindow.swift @@ -204,6 +204,15 @@ struct MainWindow: View { Text("Cancel") ) ) + 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"), From de8951f8f5221f233b161703fe2a60d2ea203a69 Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 17:09:22 +0200 Subject: [PATCH 3/6] Offer to remove an Xcode's platforms when uninstalling it The uninstall confirmation gets an "Uninstall with Platforms" button when there are installed simulator platforms that match this Xcode's SDKs and none of the other installed Xcodes, and lists them. Platforms are removed after the Xcode itself; shared platforms are never offered. Co-Authored-By: Claude Opus 5.5 --- Xcodes/Backend/AppState+Runtimes.swift | 38 ++++++++++++++++++++ Xcodes/Frontend/MainWindow.swift | 25 ++++++++++--- Xcodes/Resources/Localizable.xcstrings | 21 +++++++++++ XcodesTests/AppStateUpdateTests.swift | 50 ++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 5 deletions(-) diff --git a/Xcodes/Backend/AppState+Runtimes.swift b/Xcodes/Backend/AppState+Runtimes.swift index 8d6106ed..f19f9fdf 100644 --- a/Xcodes/Backend/AppState+Runtimes.swift +++ b/Xcodes/Backend/AppState+Runtimes.swift @@ -342,3 +342,41 @@ extension DownloadableRuntime.Platform { } } } + +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 { + 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()) { $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?.presentedAlert = .generic(title: "Error", message: error.legibleLocalizedDescription) + } + } + } + } +} diff --git a/Xcodes/Frontend/MainWindow.swift b/Xcodes/Frontend/MainWindow.swift index 58761d36..5e2600c6 100644 --- a/Xcodes/Frontend/MainWindow.swift +++ b/Xcodes/Frontend/MainWindow.swift @@ -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( diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 6608a149..b2a1c42d 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -4282,6 +4282,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", @@ -4414,6 +4425,16 @@ } } }, + "Alert.Uninstall.WithPlatforms" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uninstall with Platforms" + } + } + } + }, "Alert.Update.Error.Title" : { "comment" : "Update", "extractionState" : "manual", diff --git a/XcodesTests/AppStateUpdateTests.swift b/XcodesTests/AppStateUpdateTests.swift index 299e4f01..abe1259a 100644 --- a/XcodesTests/AppStateUpdateTests.swift +++ b/XcodesTests/AppStateUpdateTests.swift @@ -78,6 +78,56 @@ class AppStateUpdateTests: XCTestCase { XCTAssertEqual(subject.selectedXcodePath, "/Applications/Xcode-Beta.app") } + func test_InstalledRuntimesUsedOnlyByXcode_SkipsSharedAndNotInstalledPlatforms() throws { + subject.downloadableRuntimes = try [ + platformRuntime(platform: "iphoneos", identifier: "ios-27.0", sdkBuild: "24A430", simulatorBuild: "24A434"), + platformRuntime(platform: "watchos", identifier: "watch-27.0", sdkBuild: "24R360", simulatorBuild: "24R362"), + platformRuntime(platform: "appletvos", identifier: "tv-27.0", sdkBuild: "24J360", simulatorBuild: "24J360"), + ] + subject.installedRuntimes = [ + CoreSimulatorImage(uuid: "1", path: [:], runtimeInfo: CoreSimulatorRuntimeInfo(build: "24A434", supportedArchitectures: [.arm64])), + CoreSimulatorImage(uuid: "2", path: [:], runtimeInfo: CoreSimulatorRuntimeInfo(build: "24R362", supportedArchitectures: [.arm64])), + ] + let uninstalling = Xcode( + version: Version("27.0.0+27A266a")!, + installState: .installed(Path("/Applications/Xcode-27.0.0.app")!), + selected: false, + icon: nil, + sdks: SDKs(iOS: XcodeVersion("24A430"), watchOS: XcodeVersion("24R360"), tvOS: XcodeVersion("24J360")), + architectures: [.arm64] + ) + // Another installed Xcode still uses the watchOS runtime + let other = Xcode( + version: Version("27.1.0-beta+27A9269")!, + installState: .installed(Path("/Applications/Xcode-27.1.0-Beta.app")!), + selected: false, + icon: nil, + sdks: SDKs(watchOS: XcodeVersion("24R360")), + architectures: [.arm64] + ) + subject.allXcodes = [uninstalling, other] + + // tvOS matches but isn't installed; watchOS is shared + XCTAssertEqual(subject.installedRuntimesUsedOnly(by: uninstalling).map(\.identifier), ["ios-27.0"]) + } + + private func platformRuntime(platform: String, identifier: String, sdkBuild: String, simulatorBuild: String) throws -> DownloadableRuntime { + let json: [String: Any] = [ + "sdkBuildUpdate": [sdkBuild], + "architectures": ["arm64"], + "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 e441bf1f112446aede51e2594f6d4b95d6a74d6b Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 18:42:16 +0200 Subject: [PATCH 4/6] Document platform uninstall and ordering in the README Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 55fdbec8..ad42d05d 100644 --- a/README.md +++ b/README.md @@ -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.** From 2e8f23c4f80e15560284378699f9de205ef0a5d6 Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 19:19:29 +0200 Subject: [PATCH 5/6] Align platform action buttons with the size column Installed and not-installed rows placed their spacers differently, so Uninstall floated in the middle of the row while Install sat next to the size. Use one spacer so both sit next to the size column. Co-Authored-By: Claude Opus 5.5 --- Xcodes/Frontend/InfoPane/PlatformsView.swift | 22 +++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/Xcodes/Frontend/InfoPane/PlatformsView.swift b/Xcodes/Frontend/InfoPane/PlatformsView.swift index 983e0329..5435e646 100644 --- a/Xcodes/Frontend/InfoPane/PlatformsView.swift +++ b/Xcodes/Frontend/InfoPane/PlatformsView.swift @@ -92,27 +92,19 @@ struct PlatformsView: View { pathIfAvailable(xcode: xcode, 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 { - Spacer() Button("Uninstall", role: .destructive) { appState.presentedAlert = .deletePlatform(runtime: runtime) } .help("Alert.DeletePlatform.PrimaryButton") + } else if runtime.installState == .notInstalled { + DownloadRuntimeButton(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) - } - } - } - - Spacer() + Text(runtime.downloadFileSizeString) .font(.subheadline) .frame(width: 70, alignment: .trailing) From 1253ba28be722642f49c0a5d6d2f5e81f94dffe4 Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 19:19:29 +0200 Subject: [PATCH 6/6] Offer to make an Xcode active when a platform can't be removed Removing a platform runs simctl, which only comes with Xcode. With the Command Line Tools as the active developer directory it failed with "unable to find utility simctl". Recognize that error and offer to make the newest installed release Xcode active and retry, from the main window, the Settings platforms list and Uninstall with Platforms. If only betas are installed, explain what to do instead. Co-Authored-By: Claude Opus 5.5 --- Xcodes/Backend/AppState+Runtimes.swift | 58 +++++++++++++++++++----- Xcodes/Frontend/Common/XcodesAlert.swift | 29 ++++++++++++ Xcodes/Frontend/MainWindow.swift | 2 + Xcodes/Resources/Localizable.xcstrings | 44 ++++++++++++++++++ Xcodes/XcodesApp.swift | 2 + XcodesTests/AppStateUpdateTests.swift | 33 ++++++++++++++ 6 files changed, 156 insertions(+), 12 deletions(-) diff --git a/Xcodes/Backend/AppState+Runtimes.swift b/Xcodes/Backend/AppState+Runtimes.swift index f19f9fdf..4fc1ce35 100644 --- a/Xcodes/Backend/AppState+Runtimes.swift +++ b/Xcodes/Backend/AppState+Runtimes.swift @@ -304,18 +304,51 @@ extension AppState { } catch is CancellationError { } catch { guard self.deleteRuntimeTaskID == taskID else { return } - if presentErrorInSettings { - self.presentedPreferenceAlert = .generic( - title: "Error", - message: self.runtimeDeletionErrorMessage(error) - ) - } else { - self.presentedAlert = .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) } } @@ -374,7 +407,8 @@ extension AppState { do { try await self?.deleteRuntime(runtime: runtime) } catch { - self?.presentedAlert = .generic(title: "Error", message: error.legibleLocalizedDescription) + self?.presentRuntimeDeletionError(error, runtime: runtime, inSettings: false) + return } } } diff --git a/Xcodes/Frontend/Common/XcodesAlert.swift b/Xcodes/Frontend/Common/XcodesAlert.swift index 61805fc3..5224b1de 100644 --- a/Xcodes/Frontend/Common/XcodesAlert.swift +++ b/Xcodes/Frontend/Common/XcodesAlert.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI import XcodesKit enum XcodesAlert: Identifiable { @@ -9,6 +10,7 @@ enum XcodesAlert: Identifiable { case checkMinSupportedVersion(xcode: AvailableXcode, macOS: String) case unauthenticated case deletePlatform(runtime: DownloadableRuntime) + case noActiveXcode(runtime: DownloadableRuntime, xcode: Xcode?) var id: Int { switch self { @@ -19,6 +21,7 @@ enum XcodesAlert: Identifiable { case .cancelRuntimeInstall: return 5 case .unauthenticated: return 6 case .deletePlatform: return 8 + case .noActiveXcode: return 9 } } } @@ -28,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")) + ) + } +} diff --git a/Xcodes/Frontend/MainWindow.swift b/Xcodes/Frontend/MainWindow.swift index 5e2600c6..b4997da8 100644 --- a/Xcodes/Frontend/MainWindow.swift +++ b/Xcodes/Frontend/MainWindow.swift @@ -219,6 +219,8 @@ 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)), diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index b2a1c42d..5803a1e7 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -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", diff --git a/Xcodes/XcodesApp.swift b/Xcodes/XcodesApp.swift index 0d46447a..9d25b01f 100644 --- a/Xcodes/XcodesApp.swift +++ b/Xcodes/XcodesApp.swift @@ -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)), diff --git a/XcodesTests/AppStateUpdateTests.swift b/XcodesTests/AppStateUpdateTests.swift index abe1259a..bed793c3 100644 --- a/XcodesTests/AppStateUpdateTests.swift +++ b/XcodesTests/AppStateUpdateTests.swift @@ -111,6 +111,39 @@ class AppStateUpdateTests: XCTestCase { XCTAssertEqual(subject.installedRuntimesUsedOnly(by: uninstalling).map(\.identifier), ["ios-27.0"]) } + func test_LatestInstalledReleaseXcode_SkipsBetasAndNotInstalled() { + func xcode(_ version: String, installed: Bool) -> Xcode { + Xcode( + version: Version(version)!, + installState: installed ? .installed(Path("/Applications/Xcode-\(version).app")!) : .notInstalled, + selected: false, + icon: nil + ) + } + subject.allXcodes = [ + xcode("27.2.0-beta.2+27B5028f", installed: true), + xcode("27.1.0+27B100", installed: false), + xcode("27.0.0+27A266a", installed: true), + xcode("26.6.0+17G1", installed: true), + ] + + XCTAssertEqual(subject.latestInstalledReleaseXcode?.version, Version("27.0.0+27A266a")) + } + + func test_LatestInstalledReleaseXcode_NilWithOnlyBetas() { + subject.allXcodes = [ + Xcode(version: Version("27.2.0-beta.2+27B5028f")!, installState: .installed(Path("/Applications/Xcode-Beta.app")!), selected: false, icon: nil) + ] + + XCTAssertNil(subject.latestInstalledReleaseXcode) + } + + func test_IsMissingDeveloperToolError() { + XCTAssertTrue(AppState.isMissingDeveloperToolError(#"xcrun: error: unable to find utility "simctl", not a developer tool or in PATH"#)) + XCTAssertTrue(AppState.isMissingDeveloperToolError("xcode-select: error: tool 'xcodebuild' requires Xcode, but active developer directory '/Library/Developer/CommandLineTools' is a command line tools instance")) + XCTAssertFalse(AppState.isMissingDeveloperToolError("No simulator found with ios-27.0")) + } + private func platformRuntime(platform: String, identifier: String, sdkBuild: String, simulatorBuild: String) throws -> DownloadableRuntime { let json: [String: Any] = [ "sdkBuildUpdate": [sdkBuild],