From a68ef08a6023e9a4a28d7769133116913a1e638a Mon Sep 17 00:00:00 2001 From: Max Hoffman Date: Thu, 1 Oct 2026 11:48:45 +0200 Subject: [PATCH] Improve refresh, sign-in guidance and recovery from helper failures --- README.md | 7 + Xcodes.xcodeproj/project.pbxproj | 1 + Xcodes/Backend/AppState+Install.swift | 35 +++-- Xcodes/Backend/AppState+Update.swift | 45 +++++- Xcodes/Backend/AppState.swift | 113 +++++++++++++-- Xcodes/Backend/HelperClient.swift | 52 ++++++- Xcodes/Backend/NotificationManager.swift | 22 +-- Xcodes/Frontend/Common/XcodesAlert.swift | 99 +++++++++++++ Xcodes/Frontend/Common/XcodesSheet.swift | 4 +- Xcodes/Frontend/MainWindow.swift | 26 ++++ .../Preferences/DownloadPreferencePane.swift | 5 + .../Preferences/PreferencesView.swift | 14 +- Xcodes/Frontend/XcodeList/MainToolbar.swift | 2 +- Xcodes/Resources/Localizable.xcstrings | 124 +++++++++++++++++ Xcodes/XcodesApp.swift | 7 +- XcodesTests/AppStateTests.swift | 130 ++++++++++++++++++ XcodesTests/AppStateUpdateTests.swift | 106 ++++++++++++++ 17 files changed, 744 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 55fdbec8..6a7976a1 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,13 @@ Xcodes.app and CLI is updated, maintained with contributors like yourself. Even You'll need macOS 15.6 Ventura and Xcode 26 in order to build and run Xcodes.app. +### Known build warnings + +The following dependency and helper limitations remain: + +- `ld: building for macOS-14.6, but linking with dylib '@rpath/libcbor.0.11.0.dylib' which was built for newer version 15.0`: the prebuilt `libcbor` in LibFido2Swift targets macOS 15 while the app targets 14.6. Resolving it means raising the app's deployment target or rebuilding that dependency for 14.6. +- `SMJobBless` is deprecated in favor of `SMAppService`. The one legacy call is isolated in a marked shim in `HelperClient.swift` until the privileged helper moves to `SMAppService`, which uses a different install and approval model. + `Unxip` and `aria2` must be compiled as a universal binary ``` # compile for Intel diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index e8e0ad6a..e0375350 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -847,6 +847,7 @@ }; D971F84C2E79102E005F84C9 /* Fix libfido2 structure */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index 02850d70..09c71703 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -242,8 +242,7 @@ extension AppState { do { try await performPostInstallStepsAsync(for: installedXcode) } catch { - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.InstallArchive.Error.Title"), message: error.legibleLocalizedDescription) + presentPostInstallRecovery(for: installedXcode) } resetDockProgressTracking() @@ -256,8 +255,14 @@ extension AppState { fileExists: { path in Current.files.fileExists(atPath: path) }, moveItem: { source, destination in if Current.helper.usePrivilegedHelperForFileOperations { - try await self.installHelperIfNecessaryAsync() - try await Current.helper.moveAppAsync(source.path, destination.path) + do { + try await self.installHelperIfNecessaryAsync() + try await Current.helper.moveAppAsync(source.path, destination.path) + } catch is CancellationError { + throw CancellationError() + } catch { + throw HelperFileOperationError(underlyingError: error, command: HelperRecovery.moveCommand(source: source.path, destination: destination.path)) + } } else { try Current.files.moveItem(at: source, to: destination) } @@ -354,8 +359,7 @@ extension AppState { } catch is CancellationError { } catch { guard postInstallTaskID == taskID else { return } - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.PostInstall.Title"), message: error.legibleLocalizedDescription) + presentPostInstallRecovery(for: xcode) } } } @@ -373,7 +377,9 @@ extension AppState { try await xcodePostInstallWorkflowService.performPostInstallSteps(for: xcode) } catch { Logger.appState.error("Performing post-install steps failed: \(error.legibleLocalizedDescription)") - throw InstallationError.postInstallStepsNotPerformed(version: xcode.version, helperInstallState: helperInstallState) + // Keep the underlying reason (unless the user simply declined), so the alert can say why it failed. + let reason: String? = if case InstallationError.postInstallStepsNotPerformed = error { nil } else { error.legibleLocalizedDescription } + throw InstallationError.postInstallStepsNotPerformed(version: xcode.version, helperInstallState: helperInstallState, reason: reason) } } @@ -411,7 +417,8 @@ extension AppState { helperConsent.resume( throwing: InstallationError.postInstallStepsNotPerformed( version: version, - helperInstallState: self.helperInstallState + helperInstallState: self.helperInstallState, + reason: nil ) ) } @@ -511,7 +518,8 @@ public enum InstallationError: LocalizedError, Equatable { case versionAlreadyInstalled(InstalledXcode) case invalidVersion(String) case versionNotInstalled(Version) - case postInstallStepsNotPerformed(version: Version, helperInstallState: HelperInstallState) + /// `reason` describes the underlying failure, if there was one besides the user declining to install the helper + case postInstallStepsNotPerformed(version: Version, helperInstallState: HelperInstallState, reason: String?) public var errorDescription: String? { switch self { @@ -545,13 +553,16 @@ public enum InstallationError: LocalizedError, Equatable { return String(format: localizeString("InstallationError.InvalidVersion"), version) case let .versionNotInstalled(version): return String(format: localizeString("InstallationError.VersionNotInstalled"), version.appleDescription) - case let .postInstallStepsNotPerformed(version, helperInstallState): + case let .postInstallStepsNotPerformed(version, helperInstallState, reason): + let message: String switch helperInstallState { case .installed: - return String(format: localizeString("InstallationError.PostInstallStepsNotPerformed.Installed"), version.appleDescription) + message = String(format: localizeString("InstallationError.PostInstallStepsNotPerformed.Installed"), version.appleDescription) case .notInstalled, .unknown: - return String(format: localizeString("InstallationError.PostInstallStepsNotPerformed.NotInstalled"), version.appleDescription) + message = String(format: localizeString("InstallationError.PostInstallStepsNotPerformed.NotInstalled"), version.appleDescription) } + guard let reason else { return message } + return "\(message)\n\n\(reason)" } } } diff --git a/Xcodes/Backend/AppState+Update.swift b/Xcodes/Backend/AppState+Update.swift index cd2952c9..6aa435c4 100644 --- a/Xcodes/Backend/AppState+Update.swift +++ b/Xcodes/Backend/AppState+Update.swift @@ -13,11 +13,14 @@ extension AppState { } func updateIfNeeded() { + // Never cancel a full refresh just because the window became active again. + if isUpdating, updateTaskIsFullRefresh { return } guard - isReadyForUpdate + isReadyForUpdate || !hasRefreshedAvailableXcodesThisLaunch else { updateTask?.cancel() let taskID = UUID() + updateTaskIsFullRefresh = false updateTaskID = taskID let task = Task { @MainActor in defer { @@ -35,8 +38,19 @@ extension AppState { update() as Void } - func update() { - guard !isUpdating else { return } + /// Refreshes installed and available Xcodes and runtimes. + /// - Parameter restartingInFlightUpdate: Cancel a running refresh and start over, e.g. when the + /// data source or sign-in state changed and the running refresh would return stale results. + func update(restartingInFlightUpdate: Bool = false) { + if isUpdating { + // A quick installed-only rescan is always superseded; a full refresh only when asked. + guard restartingInFlightUpdate || !updateTaskIsFullRefresh else { return } + updateTask?.cancel() + updateTask = nil + updateTaskID = nil + } + hasRefreshedAvailableXcodesThisLaunch = true + updateTaskIsFullRefresh = true updateDownloadableRuntimes() updateInstalledRuntimes() @@ -52,14 +66,22 @@ extension AppState { do { await self.updateInstalledXcodesAsync() await self.updateSelectedXcodePathAsync() + try Task.checkCancellation() let xcodes = try await self.updateAvailableXcodes(from: self.dataSource) try Task.checkCancellation() self.availableXcodes = xcodes Current.defaults.setDate(Current.date(), forKey: "lastUpdated") } catch is CancellationError { } catch { - // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead - if error as? AuthenticationError != .invalidSession { + // A restarted refresh can surface its cancellation as a URLError instead + guard !Task.isCancelled else { return } + + if self.dataSource == .apple, Self.isUnauthorizedInstallError(error) || error as? AuthenticationError == .invalidSession { + // The Apple data source needs a developer session; ask the user to sign in rather than + // showing a raw 401. + self.presentedAlert = .unauthenticatedDataSource + } else if error as? AuthenticationError != .invalidSession { + // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead self.error = error self.presentedAlert = .generic(title: localizeString("Alert.Update.Error.Title"), message: error.legibleLocalizedDescription) } @@ -68,10 +90,21 @@ extension AppState { updateTask = task } + /// Selection can change in Terminal while a full catalog refresh is still running. + func refreshSelectedXcodePath() { + guard selectedXcodeRefreshTask == nil else { return } + selectedXcodeRefreshTask = Task { @MainActor in + defer { selectedXcodeRefreshTask = nil } + await updateSelectedXcodePathAsync() + } + } + func updateSelectedXcodePathAsync() async { do { let output = try await Current.shell.xcodeSelectPrintPath() - selectedXcodePath = output.out + if selectedXcodePath != output.out { + selectedXcodePath = output.out + } } catch { // Ignore xcode-select failures } diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 6ac509bb..33f217f3 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -45,7 +45,10 @@ class AppState: ObservableObject { @Published var authenticationState: AuthenticationState = .unauthenticated @Published var availableXcodes: [AvailableXcode] = [] { willSet { - if !Self.newlyAvailableXcodes(oldXcodes: availableXcodes, newXcodes: newValue).isEmpty { + if isChangingDataSource { + // The sources identify releases differently, so everything would look new. + isChangingDataSource = false + } else if !Self.newlyAvailableXcodes(oldXcodes: availableXcodes, newXcodes: newValue).isEmpty { Current.notificationManager.scheduleNotification(title: localizeString("Notification.NewXcodeVersion.Title"), body: localizeString("Notification.NewXcodeVersion.Body"), category: .normal) } updateAllXcodes( @@ -83,6 +86,13 @@ class AppState: ObservableObject { @Published var updateTask: Task? var updateTaskID: UUID? var isUpdating: Bool { updateTask != nil } + /// Whether `updateTask` fetches available Xcodes, as opposed to only rescanning installed ones. + var updateTaskIsFullRefresh = false + /// The available Xcode list is always refreshed once per launch, regardless of cache age. + var hasRefreshedAvailableXcodesThisLaunch = false + /// Set while refreshing after a data source change, so the new list isn't announced as new versions + var isChangingDataSource = false + @Published var showHelperSettings = false @Published var presentedSheet: XcodesSheet? = nil @Published var isProcessingAuthRequest = false private var authenticationRequestID: UUID? @@ -92,6 +102,8 @@ class AppState: ObservableObject { @Published var presentedAlert: XcodesAlert? @Published var presentedPreferenceAlert: XcodesPreferencesAlert? @Published var helperInstallState: HelperInstallState = .notInstalled + /// Delay between attempts to reach a newly installed helper + var helperInstallRetryDelay: Duration = .milliseconds(500) /// Whether the user is being prepared for the helper installation alert with an explanation. /// This closure will be performed after the user chooses whether or not to proceed. @Published var isPreparingUserForActionRequiringHelper: ((Bool) -> Void)? @@ -194,6 +206,7 @@ class AppState: ObservableObject { internal var helperInstallTaskID: UUID? internal var postInstallTask: Task? internal var postInstallTaskID: UUID? + internal var selectedXcodeRefreshTask: Task? private var helperStatusTask: Task? internal var selectTask: Task? internal var selectTaskID: UUID? @@ -480,8 +493,12 @@ class AppState: ObservableObject { authenticationTaskID = nil } } + let wasAuthenticated = isAuthenticated do { try await operation() + if !wasAuthenticated, isAuthenticated { + refreshAfterSignIn() + } } catch is CancellationError { } catch { // performAuthenticationRequest owns auth error presentation. @@ -489,12 +506,35 @@ class AppState: ObservableObject { } } + private var isAuthenticated: Bool { + if case .authenticated = authenticationState { return true } + return false + } + + /// Signing in unlocks the Apple data source, so restart any refresh that ran without a session. + private func refreshAfterSignIn() { + guard !isTesting else { return } + update(restartingInFlightUpdate: dataSource == .apple) + } + func signOut() { clearLoginCredentials() Current.network.signout() authenticationState = .unauthenticated } + func presentHelperRecovery(title: String, error: Error, command: String, message: String? = nil) { + self.error = nil + presentedAlert = nil + presentedSheet = .helperRecovery(HelperRecovery(title: title, message: message ?? error.legibleLocalizedDescription, command: command)) + } + + func presentPostInstallRecovery(for xcode: InstalledXcode) { + error = nil + presentedSheet = nil + presentedAlert = .postInstallFailed(xcode: xcode) + } + // MARK: - Helper /// Install the privileged helper if it isn't already installed. @@ -546,15 +586,37 @@ class AppState: ObservableObject { try Task.checkCancellation() try await Current.helper.install() try Task.checkCancellation() - await checkIfHelperIsInstalled() + // launchd starts a newly blessed helper asynchronously, so give it a moment to answer. + let connectionError = await checkIfHelperIsInstalled(attempts: 5) + try Task.checkCancellation() + guard helperInstallState == .installed else { + throw HelperClientError.unreachableAfterInstall(underlyingError: connectionError) + } } } - private func checkIfHelperIsInstalled() async { + /// Asks the helper for its version, retrying up to `attempts` times. + /// - Returns: The last connection error, if the helper couldn't be reached. + @discardableResult + private func checkIfHelperIsInstalled(attempts: Int = 1) async -> Error? { helperInstallState = .unknown - let installed = (try? await Current.helper.checkIfLatestHelperIsInstalledAsync()) ?? false - helperInstallState = installed ? .installed : .notInstalled + var lastError: Error? + for attempt in 1...max(1, attempts) { + do { + if try await Current.helper.checkIfLatestHelperIsInstalledAsync() { + helperInstallState = .installed + return nil + } + lastError = nil + } catch { + lastError = error + } + guard attempt < attempts, !Task.isCancelled else { break } + try? await Task.sleep(for: helperInstallRetryDelay) + } + helperInstallState = .notInstalled + return lastError } @discardableResult @@ -734,8 +796,12 @@ class AppState: ObservableObject { if let index = allXcodes.firstIndex(where: { $0.id == xcode.id }) { allXcodes[index].installState = .installed(installedXcodePath) } - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.Uninstall.Error.Title"), message: error.legibleLocalizedDescription) + if Current.helper.usePrivilegedHelperForFileOperations && !(error is FileError) { + presentHelperRecovery(title: localizeString("Alert.Uninstall.Error.Title"), error: error, command: "sudo /bin/rm -rf " + HelperRecovery.quote(installedXcodePath.string)) + } else { + self.error = error + self.presentedAlert = .generic(title: localizeString("Alert.Uninstall.Error.Title"), message: error.legibleLocalizedDescription) + } } } } @@ -786,8 +852,8 @@ class AppState: ObservableObject { selectTaskID = nil } } + var installedXcodePath = installedXcodePath do { - var installedXcodePath = installedXcodePath try await installHelperIfNecessaryAsync() try Task.checkCancellation() @@ -804,8 +870,7 @@ class AppState: ObservableObject { } } catch is CancellationError { } catch { - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.Select.Error.Title"), message: error.legibleLocalizedDescription) + presentHelperRecovery(title: localizeString("Alert.Select.Error.Title"), error: error, command: HelperRecovery.selectCommand(path: installedXcodePath.string)) } } } @@ -825,6 +890,12 @@ class AppState: ObservableObject { } } + func open(xcode: InstalledXcode) { + let configuration = NSWorkspace.OpenConfiguration() + configuration.allowsRunningApplicationSubstitution = false + NSWorkspace.shared.openApplication(at: xcode.path.url, configuration: configuration) + } + func copyPath(xcode: Xcode) { guard let installedXcodePath = xcode.installedPath else { return } @@ -881,7 +952,11 @@ class AppState: ObservableObject { let message = error as? XcodeSelectionFilesystemError == .destinationExistsAndIsNotSymlink(destinationPath) ? localizeString("Alert.SymLink.Message") : error.legibleLocalizedDescription - self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: message) + if Current.helper.usePrivilegedHelperForFileOperations { + presentHelperRecovery(title: localizeString("Alert.SymLink.Title"), error: error, command: HelperRecovery.symlinkCommand(source: installedXcodePath.string, destination: destinationPath.string), message: message) + } else { + self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: message) + } } } @@ -913,8 +988,18 @@ class AppState: ObservableObject { } catch { Logger.appState.error("Unable to create rename Xcode.app back to original") self.error = error - // TODO UPDATE MY ERROR STRING - self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.legibleLocalizedDescription) + if Current.helper.usePrivilegedHelperForFileOperations { + let destination = (Path.installDirectory/"Xcode.app").string + var commands: [String] = [] + if let originalXcode = Current.files.installedXcode(destination: Path.installDirectory/"Xcode.app") { + commands.append(HelperRecovery.moveCommand(source: destination, destination: (Path.installDirectory/"Xcode-\(originalXcode.version.descriptionWithoutBuildMetadata).app").string)) + } + commands.append(HelperRecovery.moveCommand(source: installedXcodePath.string, destination: destination)) + commands.append(HelperRecovery.selectCommand(path: destination)) + presentHelperRecovery(title: localizeString("Alert.Select.Error.Title"), error: error, command: commands.joined(separator: " &&\n")) + } else { + self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.legibleLocalizedDescription) + } } return nil } @@ -1006,6 +1091,8 @@ class AppState: ObservableObject { self.error = error self.presentedAlert = .unauthenticated + } else if let failure = error as? HelperFileOperationError { + presentHelperRecovery(title: localizeString("Alert.Install.Error.Title"), error: failure.underlyingError, command: failure.command) } else if error as? AuthenticationError != .invalidSession { self.error = error self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) diff --git a/Xcodes/Backend/HelperClient.swift b/Xcodes/Backend/HelperClient.swift index 1fb73d8b..66346472 100644 --- a/Xcodes/Backend/HelperClient.swift +++ b/Xcodes/Backend/HelperClient.swift @@ -1,4 +1,5 @@ import Foundation +import LegibleError import os.log import ServiceManagement import XcodesKit @@ -234,8 +235,21 @@ final class HelperClient { do { let authRef = try authorizationRef(&authRights, nil, [.interactionAllowed, .extendRights, .preAuthorize]) var cfError: Unmanaged? - SMJobBless(kSMDomainSystemLaunchd, machServiceName as CFString, authRef, &cfError) - if let error = cfError?.takeRetainedValue() { throw error } + (SMJobBlessInstaller() as PrivilegedHelperBlessing).bless(label: machServiceName, authorization: authRef, error: &cfError) + if let error = cfError?.takeRetainedValue() { + // kSMErrorDomainLaunchd is deprecated, but SMJobBless still reports its errors in that domain + if CFErrorGetDomain(error) as String == "CFErrorDomainLaunchd" { + switch CFErrorGetCode(error) { + case kSMErrorInvalidSignature: + throw HelperClientError.invalidSignature(underlyingError: error) + case kSMErrorAuthorizationFailure: + throw HelperClientError.authorizationFailed(underlyingError: error) + default: + break + } + } + throw error + } self.connection?.invalidate() self.connection = nil @@ -268,9 +282,32 @@ final class HelperClient { } } +/// Installs the privileged helper with SMJobBless, which macOS 13 deprecated in favor of SMAppService. +/// SMAppService uses a different install and approval model (a bundled launch daemon plist, approved in +/// System Settings), so the move is a separate change. Until then the one legacy call lives here, and is +/// reached through a protocol so the known deprecation doesn't warn on every build. +private protocol PrivilegedHelperBlessing { + @discardableResult + func bless(label: String, authorization: AuthorizationRef?, error: inout Unmanaged?) -> Bool +} + +private struct SMJobBlessInstaller: PrivilegedHelperBlessing { + @available(macOS, deprecated: 13.0, message: "Move the privileged helper to SMAppService") + @discardableResult + func bless(label: String, authorization: AuthorizationRef?, error: inout Unmanaged?) -> Bool { + SMJobBless(kSMDomainSystemLaunchd, label as CFString, authorization, &error) + } +} + enum HelperClientError: LocalizedError { case failedToCreateRemoteObjectProxy case message(String) + /// SMJobBless rejected the helper because its signature doesn't satisfy the app's SMPrivilegedExecutables requirement + case invalidSignature(underlyingError: Error) + /// SMJobBless wasn't authorized, e.g. the administrator prompt was cancelled or couldn't be shown + case authorizationFailed(underlyingError: Error) + /// The helper was blessed but doesn't answer, e.g. it rejects this app's signature via SMAuthorizedClients + case unreachableAfterInstall(underlyingError: Error?) var errorDescription: String? { switch self { @@ -278,6 +315,17 @@ enum HelperClientError: LocalizedError { return localizeString("HelperClient.error") case let .message(message): return message + case let .invalidSignature(underlyingError): + return Self.withDetails(localizeString("HelperClient.error.InvalidSignature"), underlyingError) + case let .authorizationFailed(underlyingError): + return Self.withDetails(localizeString("HelperClient.error.AuthorizationFailed"), underlyingError) + case let .unreachableAfterInstall(underlyingError): + return Self.withDetails(localizeString("HelperClient.error.UnreachableAfterInstall"), underlyingError) } } + + private static func withDetails(_ message: String, _ underlyingError: Error?) -> String { + guard let underlyingError else { return message } + return "\(message)\n\n\(underlyingError.legibleLocalizedDescription)" + } } diff --git a/Xcodes/Backend/NotificationManager.swift b/Xcodes/Backend/NotificationManager.swift index 4b5fb8c3..2ac4f17a 100644 --- a/Xcodes/Backend/NotificationManager.swift +++ b/Xcodes/Backend/NotificationManager.swift @@ -60,13 +60,13 @@ public final class NotificationManager: NSObject, UNUserNotificationCenterDelega notificationStatusTask = Task { [weak self] in let settings = await UNUserNotificationCenter.current().notificationSettings() guard !Task.isCancelled else { - await self?.clearNotificationStatusTask(id: taskID) + self?.clearNotificationStatusTask(id: taskID) return } let status = NotificationManager.systemPromptStatusFromSettings(settings) - await self?.setNotificationStatus(status, ifNotificationStatusTaskID: taskID) - await self?.clearNotificationStatusTask(id: taskID) + self?.setNotificationStatus(status, ifNotificationStatusTaskID: taskID) + self?.clearNotificationStatusTask(id: taskID) } } @@ -94,14 +94,14 @@ public final class NotificationManager: NSObject, UNUserNotificationCenterDelega do { let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) guard !Task.isCancelled else { - await self?.clearRequestAccessTask(id: taskID) + self?.clearRequestAccessTask(id: taskID) return } Logger.appState.log("User has \(granted ? "Granted" : "NOT GRANTED") notification permission") } catch { guard !Task.isCancelled else { - await self?.clearRequestAccessTask(id: taskID) + self?.clearRequestAccessTask(id: taskID) return } @@ -110,13 +110,13 @@ public final class NotificationManager: NSObject, UNUserNotificationCenterDelega let settings = await UNUserNotificationCenter.current().notificationSettings() guard !Task.isCancelled else { - await self?.clearRequestAccessTask(id: taskID) + self?.clearRequestAccessTask(id: taskID) return } let status = NotificationManager.systemPromptStatusFromSettings(settings) - await self?.setNotificationStatus(status, ifRequestAccessTaskID: taskID) - await self?.clearRequestAccessTask(id: taskID) + self?.setNotificationStatus(status, ifRequestAccessTaskID: taskID) + self?.clearRequestAccessTask(id: taskID) } } @@ -154,9 +154,11 @@ public final class NotificationManager: NSObject, UNUserNotificationCenterDelega } func scheduleNotification(title: String?, body: String, category: XcodesNotificationCategory) { - let content = UNMutableNotificationContent() - if let title = title { + // Unit tests run inside the app and simulate installing Xcode "0.0"; label those so they aren't mistaken for real installs. + if isTesting { + content.title = ["Test", title].compactMap { $0 }.joined(separator: ": ") + } else if let title = title { content.title = title } content.body = body diff --git a/Xcodes/Frontend/Common/XcodesAlert.swift b/Xcodes/Frontend/Common/XcodesAlert.swift index 710668f9..75d7fc9b 100644 --- a/Xcodes/Frontend/Common/XcodesAlert.swift +++ b/Xcodes/Frontend/Common/XcodesAlert.swift @@ -1,10 +1,13 @@ import Foundation +import SwiftUI import XcodesKit enum XcodesAlert: Identifiable { case cancelInstall(xcode: Xcode) case cancelRuntimeInstall(runtime: DownloadableRuntime) case privilegedHelper + case postInstallFailed(xcode: InstalledXcode) + case unauthenticatedDataSource case generic(title: String, message: String) case checkMinSupportedVersion(xcode: AvailableXcode, macOS: String) case unauthenticated @@ -13,6 +16,8 @@ enum XcodesAlert: Identifiable { switch self { case .cancelInstall: return 1 case .privilegedHelper: return 2 + case .postInstallFailed: return 10 + case .unauthenticatedDataSource: return 7 case .generic: return 3 case .checkMinSupportedVersion: return 4 case .cancelRuntimeInstall: return 5 @@ -34,3 +39,97 @@ enum XcodesPreferencesAlert: Identifiable { } } } + +struct HelperFileOperationError: LocalizedError { + let underlyingError: Error + let command: String + var errorDescription: String? { underlyingError.localizedDescription } +} + +/// The command is displayed and copied only; Xcodes never executes it. +struct HelperRecovery { + let title: String + let message: String + let command: String + + static func quote(_ path: String) -> String { + "'" + path.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } + + static func selectCommand(path: String) -> String { + "sudo /usr/bin/xcode-select --switch " + quote(path) + } + + static func moveCommand(source: String, destination: String) -> String { + // Refuse to overwrite an existing app, including a dangling symlink. + let destination = quote(destination) + return "[ ! -e " + destination + " ] && [ ! -L " + destination + " ] && sudo /bin/mv " + quote(source) + " " + destination + } + + static func symlinkCommand(source: String, destination: String) -> String { + let destination = quote(destination) + return "([ ! -e " + destination + " ] || [ -L " + destination + " ]) && sudo /bin/ln -sfn " + quote(source) + " " + destination + } +} + +struct HelperRecoveryView: View { + @EnvironmentObject var appState: AppState + @SwiftUI.Environment(\.dismiss) private var dismiss + @State private var copied = false + let recovery: HelperRecovery + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(verbatim: recovery.title).font(.headline) + Text(verbatim: recovery.message).textSelection(.enabled) + Text("HelperRecovery.Explanation") + ScrollView { + Text(verbatim: recovery.command) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } + .frame(maxHeight: 180) + .background(Color.secondary.opacity(0.1)) + .cornerRadius(6) + HStack { + if #available(macOS 14, *) { + HelperSettingsButton { + appState.showHelperSettings = true + dismiss() + } + } else { + Button("HelperRecovery.OpenSettings") { + appState.showHelperSettings = true + dismiss() + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + } + } + Button(copied ? "HelperRecovery.Copied" : "HelperRecovery.CopyCommand") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(recovery.command, forType: .string) + copied = true + } + Spacer() + Button("Done") { dismiss() } + .keyboardShortcut(.defaultAction) + } + } + .padding(24) + .frame(width: 620) + } +} + +@available(macOS 14, *) +private struct HelperSettingsButton: View { + @SwiftUI.Environment(\.openSettings) private var openSettings + let prepare: () -> Void + + var body: some View { + Button("HelperRecovery.OpenSettings") { + prepare() + openSettings() + } + } +} diff --git a/Xcodes/Frontend/Common/XcodesSheet.swift b/Xcodes/Frontend/Common/XcodesSheet.swift index 5d60fc94..28196372 100644 --- a/Xcodes/Frontend/Common/XcodesSheet.swift +++ b/Xcodes/Frontend/Common/XcodesSheet.swift @@ -2,6 +2,7 @@ import Foundation import XcodesLoginKit enum XcodesSheet: Identifiable { + case helperRecovery(HelperRecovery) case signIn case twoFactor(SecondFactorData) case securityKeyTouchToConfirm @@ -17,7 +18,7 @@ enum XcodesSheet: Identifiable { extension XcodesSheet { private enum Kind: Hashable { - case signIn, twoFactor(TwoFactorOption), securityKeyTouchToConfirm + case signIn, twoFactor(TwoFactorOption), securityKeyTouchToConfirm, helperRecovery enum TwoFactorOption { case smsSent @@ -28,6 +29,7 @@ extension XcodesSheet { init(_ sheet: XcodesSheet) { switch sheet { + case .helperRecovery: self = .helperRecovery case .signIn: self = .signIn case .twoFactor(let data): switch data.option { diff --git a/Xcodes/Frontend/MainWindow.swift b/Xcodes/Frontend/MainWindow.swift index b108f053..3d27570e 100644 --- a/Xcodes/Frontend/MainWindow.swift +++ b/Xcodes/Frontend/MainWindow.swift @@ -81,6 +81,9 @@ struct MainWindow: View { .emittingError($appState.error, recoveryHandler: { _ in }) .sheet(item: $appState.presentedSheet) { sheet in switch sheet { + case .helperRecovery(let recovery): + HelperRecoveryView(recovery: recovery) + .environmentObject(appState) case .signIn: signInView() .environmentObject(appState) @@ -181,6 +184,15 @@ struct MainWindow: View { appState.respondToPreparedHelperAction(userConsented: false) } ) + case let .postInstallFailed(xcode): + return Alert( + title: Text("Alert.PostInstall.OpenXcode.Title"), + message: Text(verbatim: String(format: localizeString("Alert.PostInstall.OpenXcode.Message"), xcode.version.appleDescription)), + primaryButton: .default(Text("Alert.PostInstall.OpenXcode.Button"), action: { + appState.open(xcode: xcode) + }), + secondaryButton: .cancel(Text("Done")) + ) case let .generic(title, message): return Alert( title: Text(title), @@ -204,6 +216,20 @@ struct MainWindow: View { Text("Cancel") ) ) + case .unauthenticatedDataSource: + return Alert( + title: Text("Alert.Update.AuthError.Title"), + message: Text("Alert.Update.AuthError.Message"), + primaryButton: .default( + Text("Login"), + action: { + appState.presentedSheet = .signIn + } + ), + 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..13143f2f 100644 --- a/Xcodes/Frontend/Preferences/DownloadPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/DownloadPreferencePane.swift @@ -27,6 +27,11 @@ struct DownloadPreferencePane: View { } .groupBoxStyle(PreferencesGroupBoxStyle()) .disabled(dataSource.isManaged) + .onChange(of: dataSource) { + // The cached list belongs to the previous source, so fetch the new one right away. + appState.isChangingDataSource = true + appState.update(restartingInFlightUpdate: true) + } GroupBox(label: Text("Downloader")) { VStack(alignment: .leading) { diff --git a/Xcodes/Frontend/Preferences/PreferencesView.swift b/Xcodes/Frontend/Preferences/PreferencesView.swift index 39509cf5..879ea9d4 100644 --- a/Xcodes/Frontend/Preferences/PreferencesView.swift +++ b/Xcodes/Frontend/Preferences/PreferencesView.swift @@ -2,13 +2,14 @@ import SwiftUI struct PreferencesView: View { private enum Tabs: Hashable { - case general, updates, advanced, experiment + case general, updates, downloads, advanced, experiment } + @State private var selectedTab = Tabs.general @EnvironmentObject var appState: AppState @EnvironmentObject var updater: ObservableUpdater var body: some View { - TabView { + TabView(selection: $selectedTab) { GeneralPreferencePane() .environmentObject(appState) .tabItem { @@ -26,6 +27,7 @@ struct PreferencesView: View { .tabItem { Label("Downloads", systemImage: "icloud.and.arrow.down") } + .tag(Tabs.downloads) AdvancedPreferencePane() .environmentObject(appState) .tabItem { @@ -38,7 +40,15 @@ struct PreferencesView: View { } .tag(Tabs.experiment) } + .onAppear { selectHelperSettingsIfRequested() } + .onChange(of: appState.showHelperSettings) { _, _ in selectHelperSettingsIfRequested() } .padding(20) .frame(width: 600) } + + private func selectHelperSettingsIfRequested() { + guard appState.showHelperSettings else { return } + selectedTab = .advanced + appState.showHelperSettings = false + } } diff --git a/Xcodes/Frontend/XcodeList/MainToolbar.swift b/Xcodes/Frontend/XcodeList/MainToolbar.swift index f2f645f0..56fddc76 100644 --- a/Xcodes/Frontend/XcodeList/MainToolbar.swift +++ b/Xcodes/Frontend/XcodeList/MainToolbar.swift @@ -16,7 +16,7 @@ struct MainToolbarModifier: ViewModifier { ToolbarItemGroup { ProgressButton( isInProgress: appState.isUpdating, - action: appState.update + action: { appState.update() } ) { Label("Refresh", systemImage: "arrow.clockwise") } diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 6608a149..dda7d08c 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -2971,6 +2971,37 @@ } } }, + "Alert.PostInstall.OpenXcode.Button" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Xcode" + } + } + } + }, + "Alert.PostInstall.OpenXcode.Message" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xcode %@ is installed, but some setup steps weren’t completed automatically. Open Xcode to finish setting it up." + } + } + } + }, + "Alert.PostInstall.OpenXcode.Title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finish Setting Up Xcode" + } + } + } + }, "Alert.PostInstall.Title" : { "comment" : "Post install", "extractionState" : "manual", @@ -4414,6 +4445,26 @@ } } }, + "Alert.Update.AuthError.Message" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The Apple data source lists Xcode releases from your Apple Developer account, so it needs you to be signed in. Sign in to refresh the list, or switch the data source to Xcode Releases in Settings." + } + } + } + }, + "Alert.Update.AuthError.Title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in to refresh from Apple" + } + } + } + }, "Alert.Update.Error.Title" : { "comment" : "Update", "extractionState" : "manual", @@ -11157,6 +11208,39 @@ } } }, + "HelperClient.error.AuthorizationFailed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS didn't authorize installing the privileged helper. Try again and enter an administrator's name and password when asked; the helper can also be installed from Settings > Advanced." + } + } + } + }, + "HelperClient.error.InvalidSignature" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS refused to install the privileged helper because its code signature doesn't match what Xcodes expects. The app and helper must be signed by the same developer team." + } + } + } + }, + "HelperClient.error.UnreachableAfterInstall" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The privileged helper was installed, but Xcodes couldn't connect to it. This usually means the helper and Xcodes are signed by different developer teams, for example a local development build running alongside a helper installed by the released app." + } + } + } + }, "HelperInstalled" : { "localizations" : { "ar" : { @@ -11417,6 +11501,46 @@ } } }, + "HelperRecovery.Copied" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copied" + } + } + } + }, + "HelperRecovery.CopyCommand" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy Command" + } + } + } + }, + "HelperRecovery.Explanation" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Advanced Settings to install the privileged helper, then retry. Or copy the command below and paste it into Terminal. Terminal may ask for your administrator password; it will not appear as you type." + } + } + } + }, + "HelperRecovery.OpenSettings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Settings" + } + } + } + }, "IdenticalBuilds" : { "comment" : "Info Pane", "localizations" : { diff --git a/Xcodes/XcodesApp.swift b/Xcodes/XcodesApp.swift index 0d46447a..a977a6a7 100644 --- a/Xcodes/XcodesApp.swift +++ b/Xcodes/XcodesApp.swift @@ -16,11 +16,16 @@ struct XcodesApp: App { MainWindow() .environmentObject(appState) .environmentObject(updater) + // Recheck external selections as soon as the user returns from Terminal. + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + guard !isTesting else { return } + appState.refreshSelectedXcodePath() + } // This is intentionally used on a View, and not on a WindowGroup, // so that it's triggered when an individual window's phase changes instead of all window phases. // When used on a View it's also invoked on launch, which doesn't occur with a WindowGroup. // FB8954581 ScenePhase read from App doesn't return a value on launch - .onChange(of: scenePhase) { newScenePhase in + .onChange(of: scenePhase) { _, newScenePhase in guard !isTesting else { return } if case .active = newScenePhase { appState.updateIfNeeded() diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index 2945b06c..ca6b891f 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -81,6 +81,120 @@ class AppStateTests: XCTestCase { subject = AppState() } + func test_Select_HelperFailureOffersTerminalCommand() async throws { + let path = try XCTUnwrap(Path("/Applications/Xcode 27's Preview.app")) + let xcode = Xcode(version: Version("27.0.0")!, installState: .installed(path), selected: false, icon: nil) + subject.helperInstallState = .installed + subject.onSelectActionType = .none + Current.helper.switchXcodePathAsync = { _ in throw HelperClientError.message("Helper unavailable") } + + subject.select(xcode: xcode, shouldPrepareUserForHelperInstallation: false) + let task = try XCTUnwrap(subject.selectTask) + await task.value + + guard case let .helperRecovery(recovery) = subject.presentedSheet else { + return XCTFail("Expected helper recovery") + } + XCTAssertEqual(recovery.command, "sudo /usr/bin/xcode-select --switch '/Applications/Xcode 27'\"'\"'s Preview.app'") + XCTAssertTrue(recovery.message.contains("Helper unavailable")) + XCTAssertNil(subject.error) + XCTAssertNil(subject.presentedAlert) + XCTAssertNil(subject.selectedXcodePath) + } + + func test_Select_HelperInstallFailureOffersTerminalCommand() async throws { + let path = try XCTUnwrap(Path("/Applications/Xcode.app")) + let xcode = Xcode(version: Version("27.0.0")!, installState: .installed(path), selected: false, icon: nil) + subject.helperInstallState = .notInstalled + Current.helper.install = { throw HelperClientError.message("Installation failed") } + + subject.select(xcode: xcode, shouldPrepareUserForHelperInstallation: false) + let task = try XCTUnwrap(subject.selectTask) + await task.value + + guard case let .helperRecovery(recovery) = subject.presentedSheet else { + return XCTFail("Expected helper recovery") + } + XCTAssertEqual(recovery.command, HelperRecovery.selectCommand(path: path.string)) + XCTAssertTrue(recovery.message.contains("Installation failed")) + XCTAssertNil(subject.presentedAlert) + } + + func test_PostInstall_HelperFailureOffersOpeningInstalledXcode() async throws { + subject.helperInstallState = .installed + let xcode = InstalledXcode(path: Path("/Applications/Xcode Preview.app")!, version: Version("27.0.0")!) + Current.helper.devToolsSecurityEnableAsync = { throw HelperClientError.message("Helper unavailable") } + + subject.performPostInstallSteps(for: xcode) + let task = try XCTUnwrap(subject.postInstallTask) + await task.value + + guard case let .postInstallFailed(installedXcode) = subject.presentedAlert else { + return XCTFail("Expected an option to open Xcode") + } + XCTAssertEqual(installedXcode.path, xcode.path) + XCTAssertEqual(installedXcode.version, xcode.version) + XCTAssertNil(subject.error) + XCTAssertNil(subject.presentedSheet) + } + + func test_HelperRecovery_QuotedPathRoundTripsThroughShell() throws { + let path = "/Applications/Xcode's $(touch /tmp/xcodes-should-not-exist) `whoami` \"Preview\".app" + let process = Process() + let output = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", "printf '%s' " + HelperRecovery.quote(path)] + process.standardOutput = output + try process.run() + let result = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + XCTAssertEqual(process.terminationStatus, 0) + XCTAssertEqual(String(data: result, encoding: .utf8), path) + } + + func test_InstallHelper_RetriesUntilNewlyInstalledHelperAnswers() async throws { + subject.helperInstallState = .notInstalled + subject.helperInstallRetryDelay = .zero + let checks = AppStateTestsCounter() + Current.helper.install = { } + Current.helper.checkIfLatestHelperIsInstalledAsync = { + // launchd hasn't started the helper for the first couple of checks + checks.increment() >= 3 + } + + try await subject.installHelperIfNecessaryAsync() + + XCTAssertEqual(subject.helperInstallState, .installed) + XCTAssertEqual(checks.value, 3) + } + + func test_InstallHelper_ThrowsWithReasonWhenInstalledHelperIsUnreachable() async throws { + subject.helperInstallState = .notInstalled + subject.helperInstallRetryDelay = .zero + Current.helper.install = { } + Current.helper.checkIfLatestHelperIsInstalledAsync = { + throw NSError(domain: NSCocoaErrorDomain, code: NSXPCConnectionInvalid) + } + + do { + try await subject.installHelperIfNecessaryAsync() + XCTFail("Expected an unreachable helper to throw") + } catch let HelperClientError.unreachableAfterInstall(underlyingError) { + XCTAssertEqual((underlyingError as NSError?)?.code, NSXPCConnectionInvalid) + } + XCTAssertEqual(subject.helperInstallState, .notInstalled) + } + + func test_PostInstallStepsError_IncludesUnderlyingReason() { + let error = InstallationError.postInstallStepsNotPerformed( + version: Version("27.2.0")!, + helperInstallState: .notInstalled, + reason: HelperClientError.unreachableAfterInstall(underlyingError: nil).localizedDescription + ) + + XCTAssertTrue(error.errorDescription?.contains(localizeString("HelperClient.error.UnreachableAfterInstall")) == true) + } + func test_InstallError_Network401IsUnauthorized() { let error = NetworkError.non200StatusCode(statusCode: 401, data: Data()) @@ -1046,3 +1160,19 @@ private extension HTTPCookie { ])) } } + +private final class AppStateTestsCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var value: Int { + lock.withLock { count } + } + + func increment() -> Int { + lock.withLock { + count += 1 + return count + } + } +} diff --git a/XcodesTests/AppStateUpdateTests.swift b/XcodesTests/AppStateUpdateTests.swift index 299e4f01..eff3dc4f 100644 --- a/XcodesTests/AppStateUpdateTests.swift +++ b/XcodesTests/AppStateUpdateTests.swift @@ -35,6 +35,111 @@ class AppStateUpdateTests: XCTestCase { subject = AppState() } + func test_SelectionRefresh_UpdatesActiveBadgeDuringFullRefresh() async throws { + let firstPath = Path("/Applications/Xcode-26.app")! + let secondPath = Path("/Applications/Xcode-27.app")! + Current.files.installedXcodes = { _ in [ + InstalledXcode(path: firstPath, version: Version("26.0.0")!), + InstalledXcode(path: secondPath, version: Version("27.0.0")!) + ] } + await subject.updateInstalledXcodesAsync() + subject.selectedXcodePath = firstPath.string + "/Contents/Developer" + XCTAssertTrue(subject.allXcodes.first(where: { $0.installedPath == firstPath })?.selected == true) + + // A catalog refresh must not delay noticing a selection changed outside Xcodes. + subject.updateTaskIsFullRefresh = true + let catalogTask = Task { } + subject.updateTask = catalogTask + Current.shell.xcodeSelectPrintPath = { (0, secondPath.string + "/Contents/Developer", "") } + + subject.refreshSelectedXcodePath() + let task = try XCTUnwrap(subject.selectedXcodeRefreshTask) + await task.value + + XCTAssertEqual(subject.selectedXcodePath, secondPath.string + "/Contents/Developer") + XCTAssertTrue(subject.allXcodes.first(where: { $0.installedPath == secondPath })?.selected == true) + XCTAssertFalse(subject.allXcodes.first(where: { $0.installedPath == firstPath })?.selected == true) + XCTAssertNotNil(subject.updateTask) + XCTAssertFalse(catalogTask.isCancelled) + } + + func test_SelectionRefresh_KeepsSelectionWhenReadFails() async throws { + let path = "/Applications/Xcode.app/Contents/Developer" + subject.selectedXcodePath = path + Current.shell.xcodeSelectPrintPath = { throw HelperClientError.message("Read failed") } + + subject.refreshSelectedXcodePath() + let task = try XCTUnwrap(subject.selectedXcodeRefreshTask) + await task.value + + XCTAssertEqual(subject.selectedXcodePath, path) + XCTAssertNil(subject.error) + XCTAssertNil(subject.selectedXcodeRefreshTask) + } + + func test_UpdateIfNeeded_FirstCallThisLaunch_RefreshesEvenWithFreshCache() async throws { + subject.availableXcodes = [ + AvailableXcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil) + ] + Current.defaults.date = { _ in Date.mock() } + XCTAssertFalse(subject.isReadyForUpdate) + let continuations = blockXcodeSelect() + + subject.updateIfNeeded() + XCTAssertTrue(subject.updateTaskIsFullRefresh) + XCTAssertTrue(subject.hasRefreshedAvailableXcodesThisLaunch) + let fullRefreshTaskID = try XCTUnwrap(subject.updateTaskID) + + // Becoming active again must not cancel the running full refresh + subject.updateIfNeeded() + XCTAssertEqual(subject.updateTaskID, fullRefreshTaskID) + XCTAssertTrue(subject.updateTaskIsFullRefresh) + + await cancelUpdateTask(releasing: continuations) + } + + func test_Update_OnlyRestartsRunningFullRefreshWhenAsked() async throws { + let continuations = blockXcodeSelect() + + subject.update() + let firstTask = try XCTUnwrap(subject.updateTask) + let firstTaskID = try XCTUnwrap(subject.updateTaskID) + + subject.update() + XCTAssertEqual(subject.updateTaskID, firstTaskID) + + subject.update(restartingInFlightUpdate: true) + XCTAssertNotNil(subject.updateTaskID) + XCTAssertNotEqual(subject.updateTaskID, firstTaskID) + + await cancelUpdateTask(releasing: continuations) + await firstTask.value + } + + private func blockXcodeSelect() -> AppStateUpdateTestLockedBox<[CheckedContinuation]> { + let continuations = AppStateUpdateTestLockedBox<[CheckedContinuation]>([]) + Current.shell.xcodeSelectPrintPath = { + try Task.checkCancellation() + return try await withCheckedThrowingContinuation { continuation in + continuations.withValue { $0.append(continuation) } + } + } + return continuations + } + + private func cancelUpdateTask(releasing continuations: AppStateUpdateTestLockedBox<[CheckedContinuation]>) async { + let task = subject.updateTask + task?.cancel() + for _ in 0..<100 where continuations.read({ $0.isEmpty }) { + await Task.yield() + } + continuations.withValue { pending in + pending.forEach { $0.resume(throwing: CancellationError()) } + pending.removeAll() + } + await task?.value + } + func test_UpdateIfNeeded_OldTaskDoesNotClearReplacementTask() async throws { subject.availableXcodes = [ AvailableXcode( @@ -45,6 +150,7 @@ class AppStateUpdateTests: XCTestCase { ) ] Current.defaults.date = { _ in Date.mock() } + subject.hasRefreshedAvailableXcodesThisLaunch = true let continuations = AppStateUpdateTestLockedBox<[CheckedContinuation]>([]) Current.shell.xcodeSelectPrintPath = {