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+Install.swift b/Xcodes/Backend/AppState+Install.swift index 02850d70..6cd51348 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -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 diff --git a/Xcodes/Backend/AppState+Runtimes.swift b/Xcodes/Backend/AppState+Runtimes.swift index 78cf486e..d43ce543 100644 --- a/Xcodes/Backend/AppState+Runtimes.swift +++ b/Xcodes/Backend/AppState+Runtimes.swift @@ -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 { @@ -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,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 @@ -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 + + init(isAll: Bool = false, platforms: Set = []) { + 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 { + 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()) { $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? { + 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 + } + } + } + } +} diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 6ac509bb..a73e6750 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -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) } ?? [] + ) } } diff --git a/Xcodes/Backend/Xcode.swift b/Xcodes/Backend/Xcode.swift index 5d65313b..1123f0b6 100644 --- a/Xcodes/Backend/Xcode.swift +++ b/Xcodes/Backend/Xcode.swift @@ -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( @@ -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 @@ -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 @@ -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 } @@ -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 + } } diff --git a/Xcodes/Frontend/Common/XcodesAlert.swift b/Xcodes/Frontend/Common/XcodesAlert.swift index 710668f9..3c7eb866 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 } } } @@ -24,6 +29,7 @@ enum XcodesAlert: Identifiable { // Splitting out alerts that are shown on the preference screen as by default we are showing on the MainWindow() // and users awkwardly switch screens, sometimes losing the preference screen enum XcodesPreferencesAlert: Identifiable { + case noActiveXcode(runtime: DownloadableRuntime, xcode: Xcode?) case deletePlatform(runtime: DownloadableRuntime) case generic(title: String, message: String) @@ -31,6 +37,31 @@ enum XcodesPreferencesAlert: Identifiable { 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..9e7c5596 100644 --- a/Xcodes/Frontend/InfoPane/PlatformsView.swift +++ b/Xcodes/Frontend/InfoPane/PlatformsView.swift @@ -17,12 +17,12 @@ struct PlatformsView: View { var body: some View { - let builds = xcode.sdks?.allBuilds - let availableRuntimes = (builds?.flatMap { sdkBuild in + let builds = xcode.platformSDKBuilds + let availableRuntimes = builds.flatMap { sdkBuild in appState.downloadableRuntimes.filter { $0.sdkBuildUpdate?.contains(sdkBuild) ?? false } - } ?? []).removingReleaseCandidateDisplayDuplicates(installedRuntimes: appState.installedRuntimes) + }.removingReleaseCandidateDisplayDuplicates(installedRuntimes: appState.installedRuntimes) let availableVariants = ArchitectureVariant.allCases.filter { variant in availableRuntimes.contains { $0.supports(variant) } @@ -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/DownloadPreferencePane.swift b/Xcodes/Frontend/Preferences/DownloadPreferencePane.swift index db082ff2..c60e89f8 100644 --- a/Xcodes/Frontend/Preferences/DownloadPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/DownloadPreferencePane.swift @@ -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 { + Binding( + get: { platformSelection.isAll }, + set: { isAll in + var selection = platformSelection + selection.isAll = isAll + autoDownloadPlatforms = selection.rawValue + } + ) + } + + private func downloadsPlatform(_ platform: DownloadableRuntime.Platform) -> Binding { + 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) { @@ -47,6 +79,27 @@ struct DownloadPreferencePane: View { } .groupBoxStyle(PreferencesGroupBoxStyle()) .disabled(downloader.isManaged) + + 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()) } } } diff --git a/Xcodes/Frontend/Preferences/PlatformsListView.swift b/Xcodes/Frontend/Preferences/PlatformsListView.swift index d789e8a5..bd21f6fc 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 { @@ -55,15 +55,19 @@ struct PlatformsListView: View { .task { loadRuntimes() } - .onChange(of: appState.installedRuntimes) { _ in + .onChange(of: appState.installedRuntimes) { + loadRuntimes() + } + .onChange(of: appState.downloadableRuntimes) { loadRuntimes() } } func loadRuntimes() { + // Match on architecture too, so a build published as both Apple Silicon and Universal + // only lists the variant that is actually installed (and can therefore be deleted). let filteredRuntimes = appState.downloadableRuntimes.filter { runtime in - appState.installedRuntimes.contains { $0.runtimeInfo.build == runtime.simulatorVersion.buildUpdate - } + appState.coreSimulatorInfo(runtime: runtime) != nil } runtimes = OrderedDictionary(grouping: filteredRuntimes, by: { $0.platform }) } diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 6608a149..70fcd76a 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", @@ -5409,6 +5474,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" : { 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..5743d483 100644 --- a/XcodesTests/AppStateUpdateTests.swift +++ b/XcodesTests/AppStateUpdateTests.swift @@ -35,6 +35,198 @@ class AppStateUpdateTests: XCTestCase { subject = AppState() } + 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 test_UninstallWithPlatforms_KeepsPlatformsWhenXcodeRemovalFails() async throws { + let xcode = Xcode(version: Version("27.0.0")!, installState: .installed(Path("/Applications/Xcode-Test.app")!), selected: false, icon: nil) + subject.allXcodes = [xcode] + let platform = try runtime(platform: "iphoneos", identifier: "ios-test", sdkBuild: "24A1", simulatorBuild: "24A1", architectures: ["arm64"]) + Current.files.trashItem = { _ in + throw NSError(domain: "UninstallTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "Xcode removal failed"]) + } + + let task = try XCTUnwrap(subject.uninstall(xcode: xcode, removingRuntimes: [platform])) + await task.value + + // Attempting runtime removal would replace this error with "No simulator found". + XCTAssertEqual(subject.error?.localizedDescription, "Xcode removal failed") + XCTAssertEqual(subject.allXcodes.first?.installedPath, xcode.installedPath) + } + + 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 test_InstalledSDKBuilds_ReadsEachRealSDKOnce() throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let xcodeURL = root.appending(path: "Xcode.app") + func addSDK(platform: String, sdk: String, build: String) throws { + let coreServices = xcodeURL.appending(path: "Contents/Developer/Platforms/\(platform).platform/Developer/SDKs/\(sdk).sdk/System/Library/CoreServices") + try FileManager.default.createDirectory(at: coreServices, withIntermediateDirectories: true) + let plist = try PropertyListSerialization.data(fromPropertyList: ["ProductBuildVersion": build], format: .xml, options: 0) + try plist.write(to: coreServices.appending(path: "SystemVersion.plist")) + } + try addSDK(platform: "iPhoneOS", sdk: "iPhoneOS", build: "24A5422a") + try addSDK(platform: "iPhoneSimulator", sdk: "iPhoneSimulator", build: "24A5422a") + try addSDK(platform: "XROS", sdk: "XROS", build: "24M5357a") + // Versioned SDK names are symlinks to the real SDK and must be skipped + let sdksURL = xcodeURL.appending(path: "Contents/Developer/Platforms/XROS.platform/Developer/SDKs") + try FileManager.default.createSymbolicLink(at: sdksURL.appending(path: "XROS27.0.sdk"), withDestinationURL: sdksURL.appending(path: "XROS.sdk")) + Current.files.contentsAtPath = { FileManager.default.contents(atPath: $0) } + + let builds = InstalledSDKBuilds.builds(forXcodeAt: Path(url: xcodeURL)!, version: Version("27.0.0-beta.6+27A5252f")!) + + XCTAssertEqual(Set(builds), ["24A5422a", "24M5357a"]) + XCTAssertEqual(builds.count, 2) + } + + func test_PlatformSDKBuilds_PreferInstalledBundleOverReleaseMetadata() { + let xcode = Xcode(version: Version("27.0.0")!, installState: .notInstalled, selected: false, icon: nil, installedSDKBuilds: ["24A5422a"]) + XCTAssertEqual(xcode.platformSDKBuilds, ["24A5422a"]) + + let notInstalled = Xcode(version: Version("27.0.0")!, installState: .notInstalled, selected: false, icon: nil) + XCTAssertEqual(notInstalled.platformSDKBuilds, []) + } + func test_UpdateIfNeeded_OldTaskDoesNotClearReplacementTask() async throws { subject.availableXcodes = [ AvailableXcode(