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.** diff --git a/Xcodes/Backend/AppState+Runtimes.swift b/Xcodes/Backend/AppState+Runtimes.swift index 78cf486e..4fc1ce35 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,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) } } @@ -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 { + 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?.presentRuntimeDeletionError(error, runtime: runtime, inSettings: false) + return + } + } + } + } +} diff --git a/Xcodes/Frontend/Common/XcodesAlert.swift b/Xcodes/Frontend/Common/XcodesAlert.swift index 710668f9..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 { @@ -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 { @@ -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 } } } @@ -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")) + ) } } diff --git a/Xcodes/Frontend/InfoPane/PlatformsView.swift b/Xcodes/Frontend/InfoPane/PlatformsView.swift index 34af471e..5435e646 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 { @@ -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) diff --git a/Xcodes/Frontend/MainWindow.swift b/Xcodes/Frontend/MainWindow.swift index b108f053..b4997da8 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( @@ -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"), 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 { diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 6608a149..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", @@ -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", @@ -4414,6 +4469,16 @@ } } }, + "Alert.Uninstall.WithPlatforms" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uninstall with Platforms" + } + } + } + }, "Alert.Update.Error.Title" : { "comment" : "Update", "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 299e4f01..bed793c3 100644 --- a/XcodesTests/AppStateUpdateTests.swift +++ b/XcodesTests/AppStateUpdateTests.swift @@ -78,6 +78,89 @@ 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"]) + } + + 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], + "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)