From e1cc7757a1888f980109d367b9b2ea661db5d33d Mon Sep 17 00:00:00 2001 From: Josh Michaels Date: Wed, 5 Aug 2026 12:57:30 -0700 Subject: [PATCH 01/20] Support password manager autofill during Apple ID sign-in --- Xcodes.xcodeproj/project.pbxproj | 4 + Xcodes/Backend/AppState.swift | 5 + Xcodes/Frontend/SignIn/PinCodeTextView.swift | 23 ++-- .../SignIn/SignInCredentialsView.swift | 2 + XcodesTests/AppStateTests.swift | 100 ++++++++++++++++++ XcodesTests/SignInCredentialsViewTests.swift | 28 +++++ 6 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 XcodesTests/SignInCredentialsViewTests.swift diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 6fdb3076..9e424ba0 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ BDBAB7452B9FF55800694B0B /* TrailingIconLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */; }; CA11E7BA2598476C00D2EE1C /* XcodeCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */; }; CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */; }; + CA2518EE25A7FF2B00F08414 /* SignInCredentialsViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2518ED25A7FF2B00F08414 /* SignInCredentialsViewTests.swift */; }; CA378F992466567600A58CE0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA378F982466567600A58CE0 /* AppState.swift */; }; CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; }; CA42DD7325AEB04300BC0B0C /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA42DD7225AEB04300BC0B0C /* Logger.swift */; }; @@ -208,6 +209,7 @@ BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailingIconLabelStyle.swift; sourceTree = ""; }; CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeCommands.swift; sourceTree = ""; }; CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateUpdateTests.swift; sourceTree = ""; }; + CA2518ED25A7FF2B00F08414 /* SignInCredentialsViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInCredentialsViewTests.swift; sourceTree = ""; }; CA378F982466567600A58CE0 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreButtonStyle.swift; sourceTree = ""; }; CA42DD7225AEB04300BC0B0C /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; @@ -594,6 +596,7 @@ CAC281E6259FA45A00B8AB0B /* Environment+Mock.swift */, CAD2E7B72449575100113D76 /* AppStateTests.swift */, CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */, + CA2518ED25A7FF2B00F08414 /* SignInCredentialsViewTests.swift */, CAD2E7B92449575100113D76 /* Info.plist */, ); path = XcodesTests; @@ -971,6 +974,7 @@ CAC281E2259FA44600B8AB0B /* Bundle+XcodesTests.swift in Sources */, CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */, CAB3AB0E25BCA6C200BF1B04 /* AppStateTests.swift in Sources */, + CA2518EE25A7FF2B00F08414 /* SignInCredentialsViewTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 6897bb20..0bb303cf 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -343,6 +343,11 @@ class AppState: ObservableObject { } func choosePhoneNumberForSMS(authOptions: AuthOptionsResponse, sessionData: AppleSessionData) { + if authOptions.trustedPhoneNumbers?.count == 1, let trustedPhoneNumber = authOptions.trustedPhoneNumbers?.first { + requestSMS(to: trustedPhoneNumber, authOptions: authOptions, sessionData: sessionData) + return + } + self.presentedSheet = .twoFactor(.init( option: .smsPendingChoice, authOptions: authOptions, diff --git a/Xcodes/Frontend/SignIn/PinCodeTextView.swift b/Xcodes/Frontend/SignIn/PinCodeTextView.swift index 6140cdd1..1f29a5de 100644 --- a/Xcodes/Frontend/SignIn/PinCodeTextView.swift +++ b/Xcodes/Frontend/SignIn/PinCodeTextView.swift @@ -135,25 +135,25 @@ class PinCodeTextView: NSControl, NSTextFieldDelegate { return true } } - + // Perform default behaviour return false } - + func controlTextDidChange(_ obj: Notification) { guard let field = obj.object as? NSTextField, isEnabled, let fieldIndex = characterViews.firstIndex(where: { $0 === field }) - else { return } - + else { return } + let newFieldText = field.stringValue - + // Handle pasting multiple characters (e.g., pasting "123456" from clipboard) if newFieldText.count > 1 { // Filter to alphanumeric characters only let validCharacters = newFieldText.filter { $0.isLetter || $0.isNumber } - + // Always start from the first field and clear previous content var newCode = Array(repeating: Character?.none, count: numberOfDigits) for (offset, character) in validCharacters.enumerated() { @@ -161,10 +161,10 @@ class PinCodeTextView: NSControl, NSTextFieldDelegate { newCode[offset] = character } } - + // Update all fields at once to avoid triggering didSet multiple times code = newCode - + // Move focus to next empty field or the last field if all are filled let nextEmptyIndex = code.firstIndex(where: { $0 == nil }) ?? numberOfDigits - 1 if nextEmptyIndex < characterViews.count { @@ -172,10 +172,10 @@ class PinCodeTextView: NSControl, NSTextFieldDelegate { } else { resignFirstResponder() } - + return } - + // Handle single character input let lastCharacter: Character? if newFieldText.isEmpty { @@ -185,7 +185,7 @@ class PinCodeTextView: NSControl, NSTextFieldDelegate { } code[fieldIndex] = lastCharacter - + if lastCharacter != nil { if fieldIndex >= characterViews.count - 1 { resignFirstResponder() @@ -227,6 +227,7 @@ class PinCodeCharacterTextField: NSTextField { wantsLayer = true alignment = .center + contentType = .oneTimeCode maximumNumberOfLines = 1 font = .boldSystemFont(ofSize: 48) diff --git a/Xcodes/Frontend/SignIn/SignInCredentialsView.swift b/Xcodes/Frontend/SignIn/SignInCredentialsView.swift index 8102e61e..15f2ac21 100644 --- a/Xcodes/Frontend/SignIn/SignInCredentialsView.swift +++ b/Xcodes/Frontend/SignIn/SignInCredentialsView.swift @@ -21,12 +21,14 @@ struct SignInCredentialsView: View { TextField(text: $username) { Text(verbatim: "example@icloud.com") } + .textContentType(.username) .focused($focusedField, equals: .username) } HStack { Text("Password") .frame(minWidth: 100, alignment: .trailing) SecureField("Required", text: $password) + .textContentType(.password) .focused($focusedField, equals: .password) } if appState.authError != nil { diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index e31ee200..e52ef9c0 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -1,4 +1,5 @@ import Combine +import Cocoa @preconcurrency import Path import Version import XCTest @@ -24,6 +25,51 @@ private final class TestLockedBox: Sendable { } } +private final class MockURLProtocol: URLProtocol, @unchecked Sendable { + typealias Handler = @Sendable (URLRequest) throws -> (Data, HTTPURLResponse) + + private nonisolated(unsafe) static var handler: Handler? + + static func session(handler: @escaping Handler) -> URLSession { + self.handler = handler + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: configuration) + } + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (data, response) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +private extension NSView { + func recursiveSubviews(ofType type: T.Type) -> [T] { + subviews.compactMap { $0 as? T } + subviews.flatMap { $0.recursiveSubviews(ofType: type) } + } +} + @MainActor class AppStateTests: XCTestCase { var subject: AppState! @@ -33,6 +79,60 @@ class AppStateTests: XCTestCase { syncXcodesKitMocks() subject = AppState() } + + func test_PinCodeTextView_MarksDigitFieldsAsOneTimeCode() { + let pinCodeTextView = PinCodeTextView(numberOfDigits: 6, itemSpacing: 10) + + let editableTextFields = pinCodeTextView.recursiveSubviews(ofType: NSTextField.self) + .filter(\.isEditable) + + XCTAssertEqual(editableTextFields.count, 6) + XCTAssertTrue(editableTextFields.allSatisfy { $0.contentType == .oneTimeCode }) + } + + func test_PinCodeTextView_PastedCodeIsDistributedAcrossDigitFields() { + let pinCodeTextView = PinCodeTextView(numberOfDigits: 6, itemSpacing: 10) + var changedCodes: [String] = [] + pinCodeTextView.codeDidChange = { changedCodes.append($0) } + + let inputTextField = pinCodeTextView.recursiveSubviews(ofType: NSTextField.self) + .first { $0.isEditable }! + inputTextField.stringValue = "123 456" + + pinCodeTextView.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: inputTextField)) + + XCTAssertEqual(changedCodes.last, "123456") + } + + func test_ChoosePhoneNumberForSMS_WithOneTrustedPhoneNumberRequestsSMS() async throws { + let trustedPhoneNumber = AuthOptionsResponse.TrustedPhoneNumber(id: 7, numberWithDialCode: "(•••) •••-••90") + let authOptions = AuthOptionsResponse( + trustedPhoneNumbers: [trustedPhoneNumber], + trustedDevices: nil, + securityCode: .init(length: 6) + ) + let sessionData = AppleSessionData(serviceKey: "service-key", sessionID: "session-id", scnt: "scnt") + Current.network = Network(session: MockURLProtocol.session { request in + XCTAssertEqual(request.url?.absoluteString, "https://idmsa.apple.com/appleauth/auth/verify/phone") + XCTAssertEqual(request.httpMethod, "PUT") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Apple-ID-Session-Id"), "session-id") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Apple-Widget-Key"), "service-key") + XCTAssertEqual(request.value(forHTTPHeaderField: "scnt"), "scnt") + return (Data(), HTTPURLResponse(url: request.url!, statusCode: 204, httpVersion: nil, headerFields: nil)!) + }) + + subject.choosePhoneNumberForSMS(authOptions: authOptions, sessionData: sessionData) + for _ in 0..<100 where subject.presentedSheet == nil && subject.authError == nil { + try await Task.sleep(nanoseconds: 10_000_000) + } + + XCTAssertNil(subject.authError) + guard case let .twoFactor(secondFactorData) = subject.presentedSheet else { + XCTFail("Expected the SMS code-entry sheet to be presented") + return + } + XCTAssertEqual(secondFactorData.option, .smsSent(trustedPhoneNumber)) + } func test_ParseCertificateInfo_Succeeds() throws { let sampleRawInfo = """ diff --git a/XcodesTests/SignInCredentialsViewTests.swift b/XcodesTests/SignInCredentialsViewTests.swift new file mode 100644 index 00000000..f7c3b0e2 --- /dev/null +++ b/XcodesTests/SignInCredentialsViewTests.swift @@ -0,0 +1,28 @@ +import Cocoa +import SwiftUI +import XCTest + +@testable import Xcodes + +@MainActor +final class SignInCredentialsViewTests: XCTestCase { + func test_MarksCredentialFieldsForAutofill() { + let appState = AppState() + let hostingView = NSHostingView(rootView: SignInCredentialsView().environmentObject(appState)) + hostingView.frame = NSRect(x: 0, y: 0, width: 420, height: 180) + hostingView.layoutSubtreeIfNeeded() + + let editableTextFields = hostingView.recursiveSubviews(ofType: NSTextField.self) + .filter(\.isEditable) + let contentTypes = Set(editableTextFields.compactMap(\.contentType)) + + XCTAssertTrue(contentTypes.contains(.username)) + XCTAssertTrue(contentTypes.contains(.password)) + } +} + +private extension NSView { + func recursiveSubviews(ofType type: T.Type) -> [T] { + subviews.compactMap { $0 as? T } + subviews.flatMap { $0.recursiveSubviews(ofType: type) } + } +} From c890879fe1b5f7fbd24ecc867d8fb86cde2890d1 Mon Sep 17 00:00:00 2001 From: Yurii Chukhlib Date: Fri, 7 Aug 2026 13:56:54 +0200 Subject: [PATCH 02/20] fix: duplicated major version in install notification title appleDescription already includes the major version, so prepending major.description + "." rendered titles like "27.27.0 Beta 4". Use appleDescription directly via a testable static helper. Co-Authored-By: Claude --- Xcodes/Backend/AppState+Install.swift | 6 +++++- XcodesTests/AppStateTests.swift | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index c0b9e59a..8d8a2f32 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -10,6 +10,10 @@ import XcodesLoginKit /// Downloads and installs Xcodes extension AppState { + static func installNotificationTitle(for version: Version) -> String { + version.appleDescription + } + // check to see if we should auto install for the user public func autoInstallIfNeeded() { guard let storageValue = Current.defaults.get(forKey: "autoInstallation") as? Int, let autoInstallType = AutoInstallationType(rawValue: storageValue) else { return } @@ -471,7 +475,7 @@ extension AppState { let xcode = allXcodes[index] if postNotification { - Current.notificationManager.scheduleNotification(title: xcode.version.major.description + "." + xcode.version.appleDescription, body: step.description, category: .normal) + Current.notificationManager.scheduleNotification(title: AppState.installNotificationTitle(for: xcode.version), body: step.description, category: .normal) } } diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index e31ee200..ff0a668d 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -863,6 +863,22 @@ class AppStateTests: XCTestCase { } } + func test_InstallNotificationTitle_DoesNotDuplicateMajorVersion() { + XCTAssertEqual( + AppState.installNotificationTitle(for: Version(major: 27, minor: 0, patch: 0, prereleaseIdentifiers: ["beta", "4"])), + "27.0 Beta 4" + ) + XCTAssertEqual( + AppState.installNotificationTitle(for: Version(major: 26, minor: 5, patch: 0)), + "26.5" + ) + // Stable release with patch + XCTAssertEqual( + AppState.installNotificationTitle(for: Version(major: 10, minor: 2, patch: 1)), + "10.2.1" + ) + } + private func recordAllXcodeInstallStates(during operation: () async throws -> Void) async throws -> [[XcodeInstallState]] { var states: [[XcodeInstallState]] = [] var cancellable: AnyCancellable? From 52320866c89bd0c8836b5c8c8c15f1e5262c9aeb Mon Sep 17 00:00:00 2001 From: Yurii Chukhlib Date: Sat, 8 Aug 2026 11:43:44 +0200 Subject: [PATCH 03/20] fix: notify on new Xcode version identity, not array-count growth The "New Xcode version available" notification was triggered by an array-count increase rather than by the appearance of a genuinely new version identity (AvailableXcode.xcodeID). This caused two user-visible bugs: - False negative: when a new version is added and an old one removed in the same refresh (count unchanged, or even shrunk), no notification fired, so a genuinely new version landed silently. - False positive: when the array grew without a new identity (a duplicate row, or a data-source switch returning an already-known xcodeID), a spurious "new version" banner appeared. Extract the decision into a pure static helper AppState.newlyAvailableXcodes(oldXcodes:newXcodes:) that computes the xcodeID set difference, and notify iff it is non-empty. The empty-old guard is preserved so the initial cache load (empty -> populated) does not notify; the scheduleNotification arguments are unchanged. Adds XcodesTests/NewVersionNotificationTests.swift with a red-before-green regression suite (the false-negative and false-positive cases fail against a buggy count-based mirror and pass against the identity-based body). Co-Authored-By: Claude --- Xcodes.xcodeproj/project.pbxproj | 4 + Xcodes/Backend/AppState.swift | 13 +- XcodesTests/NewVersionNotificationTests.swift | 145 ++++++++++++++++++ 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 XcodesTests/NewVersionNotificationTests.swift diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 6fdb3076..3ad4c327 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ BDBAB7452B9FF55800694B0B /* TrailingIconLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */; }; CA11E7BA2598476C00D2EE1C /* XcodeCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */; }; CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */; }; + 14d2f5a1273f6c350cad4406 /* NewVersionNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884f01aed2f43048ab4d3323 /* NewVersionNotificationTests.swift */; }; CA378F992466567600A58CE0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA378F982466567600A58CE0 /* AppState.swift */; }; CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; }; CA42DD7325AEB04300BC0B0C /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA42DD7225AEB04300BC0B0C /* Logger.swift */; }; @@ -208,6 +209,7 @@ BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailingIconLabelStyle.swift; sourceTree = ""; }; CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeCommands.swift; sourceTree = ""; }; CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateUpdateTests.swift; sourceTree = ""; }; + 884f01aed2f43048ab4d3323 /* NewVersionNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewVersionNotificationTests.swift; sourceTree = ""; }; CA378F982466567600A58CE0 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreButtonStyle.swift; sourceTree = ""; }; CA42DD7225AEB04300BC0B0C /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; @@ -594,6 +596,7 @@ CAC281E6259FA45A00B8AB0B /* Environment+Mock.swift */, CAD2E7B72449575100113D76 /* AppStateTests.swift */, CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */, + 884f01aed2f43048ab4d3323 /* NewVersionNotificationTests.swift */, CAD2E7B92449575100113D76 /* Info.plist */, ); path = XcodesTests; @@ -970,6 +973,7 @@ CAC281E7259FA45A00B8AB0B /* Environment+Mock.swift in Sources */, CAC281E2259FA44600B8AB0B /* Bundle+XcodesTests.swift in Sources */, CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */, + 14d2f5a1273f6c350cad4406 /* NewVersionNotificationTests.swift in Sources */, CAB3AB0E25BCA6C200BF1B04 /* AppStateTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 6897bb20..ba6f91db 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -43,7 +43,7 @@ class AppState: ObservableObject { @Published var authenticationState: AuthenticationState = .unauthenticated @Published var availableXcodes: [AvailableXcode] = [] { willSet { - if newValue.count > availableXcodes.count && availableXcodes.count != 0 { + if !Self.newlyAvailableXcodes(oldXcodes: availableXcodes, newXcodes: newValue).isEmpty { Current.notificationManager.scheduleNotification(title: localizeString("Notification.NewXcodeVersion.Title"), body: localizeString("Notification.NewXcodeVersion.Body"), category: .normal) } updateAllXcodes( @@ -56,6 +56,17 @@ class AppState: ObservableObject { autoInstallIfNeeded() } } + + /// Returns the `AvailableXcode`s in `newXcodes` whose `xcodeID` was not present in `oldXcodes`. + /// + /// Empty when `oldXcodes` is empty, so the initial load (empty -> populated) is NOT treated + /// as "a new version since you last looked". + static func newlyAvailableXcodes(oldXcodes: [AvailableXcode], newXcodes: [AvailableXcode]) -> [AvailableXcode] { + guard !oldXcodes.isEmpty else { return [] } + let oldIDs = Set(oldXcodes.map(\.xcodeID)) + return newXcodes.filter { !oldIDs.contains($0.xcodeID) } + } + @Published var allXcodes: [Xcode] = [] @Published var selectedXcodePath: String? { willSet { diff --git a/XcodesTests/NewVersionNotificationTests.swift b/XcodesTests/NewVersionNotificationTests.swift new file mode 100644 index 00000000..1057ff29 --- /dev/null +++ b/XcodesTests/NewVersionNotificationTests.swift @@ -0,0 +1,145 @@ +import Version +import XcodesKit +@testable import Xcodes +import XCTest + +/// Regression tests for the "New Xcode version available" notification trigger. +/// +/// The decision to notify is extracted into the pure helper +/// `AppState.newlyAvailableXcodes(oldXcodes:newXcodes:)` and asserted directly here. +/// No notification spying and no `Current = .mock` are required: the helper is the single +/// source of truth that the `availableXcodes.willSet` predicate consults. +@MainActor +final class NewVersionNotificationTests: XCTestCase { + // MARK: - Fixtures + + /// Builds an `AvailableXcode` for `version` with a distinct download URL, mirroring the + /// construction style in `AppStateUpdateTests.swift` (`Version("0.0.0")!`, three components). + /// `architectures` defaults to `nil`. + private func makeAvailableXcode( + version: String, + architectures: [Architecture]? = nil, + urlSuffix: String = "" + ) -> AvailableXcode { + AvailableXcode( + version: Version(version)!, + url: URL(string: "https://example.com/Xcode-\(version)-\(urlSuffix).xip")!, + filename: "Xcode-\(version)-\(urlSuffix).xip", + releaseDate: nil, + architectures: architectures + ) + } + + /// The set of stable identities (`xcodeID`) for the given available Xcodes — an + /// order-independent comparison keyed on version + architecture, not array position. + private func identities(_ xcodes: [AvailableXcode]) -> Set { + Set(xcodes.map(\.xcodeID)) + } + + // MARK: - Initial-load suppression + + func testInitialLoadDoesNotNotify() { + // First population (empty -> populated) is the initial cache load, NOT "a new version + // since you last looked", so the result must be empty even though the array grew. + let old: [AvailableXcode] = [] + let new = [makeAvailableXcode(version: "15.0.0")] + + let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new) + + XCTAssertTrue(result.isEmpty, "Initial population must not be treated as a new version") + } + + // MARK: - True positive — one genuinely new version + + func testGenuinelyNewVersionIsReported() { + let existingA = makeAvailableXcode(version: "15.0.0") + let existingB = makeAvailableXcode(version: "15.1.0") + let added = makeAvailableXcode(version: "16.0.0") + let old = [existingA, existingB] + let new = [existingA, existingB, added] + + let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new) + + XCTAssertEqual(identities(result), [added.xcodeID]) + } + + // MARK: - FALSE NEGATIVE (the bug) — new version added AND old version removed, count unchanged + + func testNewVersionAddedAndOldRemovedIsReported() { + // The OLD count-based predicate saw "no growth" (2 -> 2) here and MISSED version C. + // A data source can drop an obsolete beta row the same refresh it adds the new one. + let existing = makeAvailableXcode(version: "15.1.0") + let removed = makeAvailableXcode(version: "15.0.0") + let added = makeAvailableXcode(version: "16.0.0") + let old = [removed, existing] + let new = [existing, added] + + let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new) + + XCTAssertEqual(identities(result), [added.xcodeID], "A genuinely new version must be reported even when the count is unchanged") + } + + // MARK: - FALSE NEGATIVE variant — a new version appears while the list shrinks + + func testNewVersionReportedEvenWhenListShrinks() { + // A genuinely new version can appear even when the overall count DECREASES: a data source + // prunes older rows the same refresh it surfaces the newest (3 -> 2 here). The OLD + // count-based predicate saw "no growth" and MISSED D; the identity-based helper reports it. + let droppedA = makeAvailableXcode(version: "15.0.0") + let droppedB = makeAvailableXcode(version: "15.1.0") + let kept = makeAvailableXcode(version: "16.0.0") + let added = makeAvailableXcode(version: "17.0.0") + let old = [droppedA, droppedB, kept] + let new = [kept, added] + + let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new) + + XCTAssertEqual(identities(result), [added.xcodeID], "A new version must be reported even when the list shrinks") + } + + // MARK: - FALSE POSITIVE (the bug) — count grows with NO new identity + + func testDuplicateIdentityIsNotNew() { + // The OLD count-based predicate FIRED here (1 -> 2); the fixed helper correctly does not. + // The feed returns a row whose xcodeID was already present (a duplicate, same version). + let present = makeAvailableXcode(version: "15.0.0") + let duplicate = makeAvailableXcode(version: "15.0.0", urlSuffix: "duplicate") // same version -> same xcodeID + let old = [present] + let new = [present, duplicate] + + let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new) + + XCTAssertTrue(result.isEmpty, "A duplicate of an existing xcodeID must not be treated as new") + } + + // MARK: - Identical list + + func testIdenticalListIsNotNew() { + let a = makeAvailableXcode(version: "15.0.0") + let b = makeAvailableXcode(version: "15.1.0") + let old = [a, b] + let new = [a, b] + + let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new) + + XCTAssertTrue(result.isEmpty) + } + + // MARK: - Identity is version + architecture, not version alone + + func testSameVersionDifferentArchitectureIsDistinctIdentity() { + // Two AvailableXcodes with the same version but different architectures have DIFFERENT + // xcodeIDs (XcodeID.id = version.description + architectures), so an Apple-Silicon-only + // release is distinct from a Universal release of the same version. + let universal: [Architecture] = [.arm64, .x86_64] + let appleSilicon: [Architecture] = [.arm64] + let universalRelease = makeAvailableXcode(version: "15.0.0", architectures: universal) + let appleSiliconRelease = makeAvailableXcode(version: "15.0.0", architectures: appleSilicon) + let old = [universalRelease] + let new = [universalRelease, appleSiliconRelease] + + let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new) + + XCTAssertEqual(identities(result), [appleSiliconRelease.xcodeID]) + } +} From a973b232a48635543915f3079a256d9179c3d457 Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Tue, 8 Sep 2026 10:07:15 -0500 Subject: [PATCH 04/20] Fix platform architecture picker visibility --- Xcodes/Frontend/InfoPane/PlatformsView.swift | 32 ++++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/Xcodes/Frontend/InfoPane/PlatformsView.swift b/Xcodes/Frontend/InfoPane/PlatformsView.swift index 8f277999..34af471e 100644 --- a/Xcodes/Frontend/InfoPane/PlatformsView.swift +++ b/Xcodes/Frontend/InfoPane/PlatformsView.swift @@ -18,27 +18,30 @@ struct PlatformsView: View { var body: some View { let builds = xcode.sdks?.allBuilds - let runtimes = (builds?.flatMap { sdkBuild in + let availableRuntimes = (builds?.flatMap { sdkBuild in appState.downloadableRuntimes.filter { - $0.sdkBuildUpdate?.contains(sdkBuild) ?? false && - ($0.architectures?.isEmpty ?? true || - ($0.architectures?.isUniversal ?? false && selectedVariant == .universal) || - ($0.architectures?.isAppleSilicon ?? false && selectedVariant == .appleSilicon) - ) + $0.sdkBuildUpdate?.contains(sdkBuild) ?? false } } ?? []).removingReleaseCandidateDisplayDuplicates(installedRuntimes: appState.installedRuntimes) - - let architectures = Set(runtimes.flatMap { $0.architectures ?? [] }) + + let availableVariants = ArchitectureVariant.allCases.filter { variant in + availableRuntimes.contains { $0.supports(variant) } + } + let displayedVariant = availableVariants.count == 1 ? availableVariants.first : selectedVariant + let runtimes = availableRuntimes.filter { runtime in + guard !(runtime.architectures?.isEmpty ?? true), let displayedVariant else { return true } + return runtime.supports(displayedVariant) + } VStack { HStack { Text("Platforms") .font(.title3) .frame(maxWidth: .infinity, alignment: .leading) - if !architectures.isEmpty { + if availableVariants.count > 1 { Spacer() Picker("Architecture", selection: $selectedVariant) { - ForEach(ArchitectureVariant.allCases, id: \.self) { arch in + ForEach(availableVariants, id: \.self) { arch in Label(variantLabel(for: arch), systemImage: arch.iconName) .tag(arch) } @@ -138,6 +141,15 @@ private struct RuntimeDisplayKey: Hashable { } private extension DownloadableRuntime { + func supports(_ variant: ArchitectureVariant) -> Bool { + switch variant { + case .universal: + return architectures?.isUniversal ?? false + case .appleSilicon: + return architectures?.isAppleSilicon ?? false + } + } + var isReleaseCandidate: Bool { name.localizedCaseInsensitiveContains("Release Candidate") || identifier.localizedCaseInsensitiveContains("_rc") From b6d68e7e7edaf4c3284d3f431a68e933666eb52f Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Fri, 11 Sep 2026 19:57:02 -0500 Subject: [PATCH 05/20] chore: update xcodesKit and LoginKit to latest. minor fixes --- Xcodes.xcodeproj/project.pbxproj | 6 +- .../xcshareddata/swiftpm/Package.resolved | 10 +- Xcodes/Backend/AppState.swift | 25 +++- Xcodes/Backend/Environment.swift | 8 +- .../Frontend/InfoPane/InfoPaneControls.swift | 9 ++ Xcodes/Frontend/MainWindow.swift | 18 ++- Xcodes/Frontend/XcodeList/XcodeListView.swift | 6 +- .../Frontend/XcodeList/XcodeListViewRow.swift | 14 ++- Xcodes/Resources/Licenses.rtf | 29 +---- Xcodes/Resources/Localizable.xcstrings | 118 ++++++++++++++++++ XcodesTests/AppStateTests.swift | 31 ++++- XcodesTests/Environment+Mock.swift | 2 +- 12 files changed, 226 insertions(+), 50 deletions(-) diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 3ad4c327..05f49654 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 14d2f5a1273f6c350cad4406 /* NewVersionNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884f01aed2f43048ab4d3323 /* NewVersionNotificationTests.swift */; }; 15F5B8902CCF09B900705E2F /* CryptoKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15F5B88F2CCF09B900705E2F /* CryptoKit.framework */; }; 3328073F2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3328073E2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift */; }; 332807412CA5EA820036F691 /* SignInSecurityKeyTouchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332807402CA5EA820036F691 /* SignInSecurityKeyTouchView.swift */; }; @@ -31,7 +32,6 @@ BDBAB7452B9FF55800694B0B /* TrailingIconLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */; }; CA11E7BA2598476C00D2EE1C /* XcodeCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */; }; CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */; }; - 14d2f5a1273f6c350cad4406 /* NewVersionNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884f01aed2f43048ab4d3323 /* NewVersionNotificationTests.swift */; }; CA378F992466567600A58CE0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA378F982466567600A58CE0 /* AppState.swift */; }; CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; }; CA42DD7325AEB04300BC0B0C /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA42DD7225AEB04300BC0B0C /* Logger.swift */; }; @@ -194,6 +194,7 @@ 536CFDD3263C9A8000026CE0 /* XcodesSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodesSheet.swift; sourceTree = ""; }; 53CBAB2B263DCC9100410495 /* XcodesAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodesAlert.swift; sourceTree = ""; }; 63EAA4EA259944450046AB8F /* ProgressButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressButton.swift; sourceTree = ""; }; + 884f01aed2f43048ab4d3323 /* NewVersionNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewVersionNotificationTests.swift; sourceTree = ""; }; 9DD4FFCA2B13EC1800C974F1 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; B0403CEF2AD92D7B00137C09 /* ReleaseNotesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReleaseNotesView.swift; sourceTree = ""; }; B0403CF12AD934B600137C09 /* CompatibilityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompatibilityView.swift; sourceTree = ""; }; @@ -209,7 +210,6 @@ BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailingIconLabelStyle.swift; sourceTree = ""; }; CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeCommands.swift; sourceTree = ""; }; CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateUpdateTests.swift; sourceTree = ""; }; - 884f01aed2f43048ab4d3323 /* NewVersionNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewVersionNotificationTests.swift; sourceTree = ""; }; CA378F982466567600A58CE0 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreButtonStyle.swift; sourceTree = ""; }; CA42DD7225AEB04300BC0B0C /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; @@ -1521,7 +1521,7 @@ repositoryURL = "https://github.com/XcodesOrg/XcodesKit"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 1.0.4; + minimumVersion = 1.1.0; }; }; E89CBD3B2D5FC0B10037ED95 /* XCRemoteSwiftPackageReference "XcodesLoginKit" */ = { diff --git a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index fa02b2c6..4f9d80c0 100644 --- a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -51,8 +51,8 @@ "repositoryURL": "https://github.com/kinoroy/LibFido2Swift", "state": { "branch": null, - "revision": "b87a93300c5b35307c9f26ae490963196bd927f1", - "version": "0.1.5" + "revision": "ac8596a852e2b008c5902a521cf4d9c7f0f05fba", + "version": "0.1.6" } }, { @@ -123,8 +123,8 @@ "repositoryURL": "https://github.com/XcodesOrg/XcodesKit", "state": { "branch": null, - "revision": "a9e5d7d701f20f1385071851319cdaecccc9f1e8", - "version": "1.0.4" + "revision": "4d3c6093980978b74c3fa42fc1b90cd1c34ac911", + "version": "1.1.0" } }, { @@ -132,7 +132,7 @@ "repositoryURL": "https://github.com/XcodesOrg/XcodesLoginKit", "state": { "branch": "main", - "revision": "9bece1ada36006b18b84caec62d14dc91b47ae2b", + "revision": "929f9aac3140caf7b64cbb5385f4f645c5f9913d", "version": null } }, diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index ba6f91db..7924e649 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -249,6 +249,9 @@ class AppState: ObservableObject { } setupAutoInstallTimer() setupDefaults() + startAuthenticationTask { + try await self.restoreAuthenticationStateIfNeeded() + } } func setupDefaults() { @@ -286,13 +289,19 @@ class AppState: ObservableObject { ).validateADCSession(path: path) } - func validateSessionAsync() async throws { + @discardableResult + func validateSessionAsync() async throws -> AuthenticationState { try await Current.network.validateSessionAsync() } func signInIfNeededAsync() async throws { do { - try await validateSessionAsync() + let authenticationState = try await validateSessionAsync() + try Task.checkCancellation() + self.authenticationState = authenticationState + handleAuthenticationFlowSuccess() + } catch is CancellationError { + throw CancellationError() } catch { guard let username = savedUsername, @@ -305,6 +314,11 @@ class AppState: ObservableObject { } } + func restoreAuthenticationStateIfNeeded() async throws { + guard hasSavedUsername else { return } + try await signInIfNeededAsync() + } + func signIn(username: String, password: String?) { authError = nil startAuthenticationTask { @@ -681,6 +695,10 @@ class AppState: ObservableObject { func uninstall(xcode: Xcode) { guard let installedXcodePath = xcode.installedPath else { return } + if let index = allXcodes.firstIndex(where: { $0.id == xcode.id }) { + allXcodes[index].installState = .uninstalling(installedXcodePath) + } + uninstallTask?.cancel() let taskID = UUID() uninstallTaskID = taskID @@ -699,6 +717,9 @@ class AppState: ObservableObject { await updateInstalledXcodesAsync() } catch is CancellationError { } catch { + 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) } diff --git a/Xcodes/Backend/Environment.swift b/Xcodes/Backend/Environment.swift index 47fb9420..44a1f92a 100644 --- a/Xcodes/Backend/Environment.swift +++ b/Xcodes/Backend/Environment.swift @@ -168,7 +168,7 @@ public struct Network: Sendable { downloadTaskAsync(url, saveLocation, resumeData) } - public var validateSessionAsync: @Sendable () async throws -> Void + public var validateSessionAsync: @Sendable () async throws -> AuthenticationState public var signout: @Sendable () -> Void @@ -176,7 +176,7 @@ public struct Network: Sendable { session: URLSession? = nil, loadData: (@Sendable (URLRequest) async throws -> (Data, URLResponse))? = nil, downloadTaskAsync: (@Sendable (URL, URL, Data?) -> (Progress, Task<(saveLocation: URL, response: URLResponse), Error>))? = nil, - validateSessionAsync: (@Sendable () async throws -> Void)? = nil, + validateSessionAsync: (@Sendable () async throws -> AuthenticationState)? = nil, signout: (@Sendable () -> Void)? = nil ) { let loginClient: XcodesLoginKit.Client @@ -193,7 +193,7 @@ public struct Network: Sendable { loginClient.urlSession.downloadTaskAsync(with: url, to: saveLocation, resumingWith: resumeData) } self.validateSessionAsync = validateSessionAsync ?? { - _ = try await loginClient.validateSession() + try await loginClient.validateSession() } self.signout = signout ?? { loginClient.signout() @@ -208,7 +208,7 @@ public struct Network: Sendable { loginClient.urlSession.downloadTaskAsync(with: url, to: saveLocation, resumingWith: resumeData) } self.validateSessionAsync = { - _ = try await loginClient.validateSession() + try await loginClient.validateSession() } self.signout = { loginClient.signout() diff --git a/Xcodes/Frontend/InfoPane/InfoPaneControls.swift b/Xcodes/Frontend/InfoPane/InfoPaneControls.swift index de1356ed..b1368141 100644 --- a/Xcodes/Frontend/InfoPane/InfoPaneControls.swift +++ b/Xcodes/Frontend/InfoPane/InfoPaneControls.swift @@ -30,6 +30,15 @@ struct InfoPaneControls: View { } case .installed(_): InstalledStateButtons(xcode: xcode) + case .uninstalling: + HStack { + Spacer() + ProgressView() + .scaleEffect(0.5) + Text("Uninstalling") + .font(.caption) + .foregroundStyle(.secondary) + } } } } diff --git a/Xcodes/Frontend/MainWindow.swift b/Xcodes/Frontend/MainWindow.swift index 0de18b5c..ae313aed 100644 --- a/Xcodes/Frontend/MainWindow.swift +++ b/Xcodes/Frontend/MainWindow.swift @@ -46,7 +46,16 @@ struct MainWindow: View { .toolbar { ToolbarItemGroup { Button(action: { appState.presentedSheet = .signIn }, label: { - Label("Login", systemImage: "person.circle") + Label { + if let authenticatedUserName { + Text(verbatim: authenticatedUserName) + } else { + Text("Login") + } + } icon: { + Image(systemName: "person.circle") + } + .labelStyle(.titleAndIcon) }) .help("LoginDescription") if #available(macOS 14, *) { @@ -95,6 +104,13 @@ struct MainWindow: View { appState.allXcodes.first(where: { $0.id == selectedXcodeID }) } + private var authenticatedUserName: String? { + guard case let .authenticated(appleSession) = appState.authenticationState else { + return nil + } + return appleSession.user.fullName ?? appState.savedUsername + } + private var subtitleText: Text { if let lastUpdated = lastUpdated.map(Date.init(timeIntervalSince1970:)) { return Text("\(localizeString("UpdatedAt")) \(lastUpdated, style: .date) \(lastUpdated, style: .time)") diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index ce75938e..12cfdb05 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -285,7 +285,7 @@ private struct XcodeVersionGroupRow: View { Image(systemName: "checkmark.circle.fill") .foregroundColor(.yellow) .help(staleSelectedHelpText(selectedVersion: selectedVersion, latestRelease: latestSelectableRelease, selectionTarget: latestSelectionTarget)) - case .installing, .none: + case .installing, .uninstalling, .none: EmptyView() } } else if selectedVersion?.selected == true { @@ -316,7 +316,7 @@ private struct XcodeVersionGroupRow: View { } else { return Text(verbatim: "\(selectedVersion.description) selected, \(latestRelease.description) available.") } - case .installing, .none: + case .installing, .uninstalling, .none: return Text(verbatim: "\(selectedVersion.description) selected, \(latestRelease.description) available.") } } @@ -344,7 +344,7 @@ private struct XcodeVersionGroupRow: View { .textCase(.uppercase) .buttonStyle(AppStoreButtonStyle(primary: false, highlighted: false)) .help("InstallDescription") - case .installing: + case .installing, .uninstalling: EmptyView() } } diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index 166b9d09..2d86e634 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -63,6 +63,8 @@ struct XcodeListViewRow: View { InstallButton(xcode: xcode) case .installing: CancelInstallButton(xcode: xcode) + case .uninstalling: + EmptyView() case let .installed(path): SelectButton(xcode: xcode) OpenButton(xcode: xcode) @@ -119,7 +121,7 @@ struct XcodeListViewRow: View { Image(systemName: "checkmark.circle.fill") .foregroundColor(.yellow) .help(staleSelectedHelpText) - case .installing: + case .installing, .uninstalling: EmptyView() } } else if xcode.selected { @@ -170,6 +172,14 @@ struct XcodeListViewRow: View { highlighted: selected, cancel: { appState.presentedAlert = .cancelInstall(xcode: xcode) } ) + case .uninstalling: + HStack(spacing: 4) { + ProgressView() + .scaleEffect(0.5) + Text("Uninstalling") + .font(.caption) + .foregroundStyle(.secondary) + } } } @@ -182,7 +192,7 @@ struct XcodeListViewRow: View { return Text(verbatim: "\(selectedVersion) selected, \(latestVersion) available. Click to select \(latestVersion).") case .notInstalled: return Text(verbatim: "\(selectedVersion) selected, \(latestVersion) available. Install \(latestVersion) to select it.") - case .installing, .none: + case .installing, .uninstalling, .none: return Text("ActiveVersionDescription") } } diff --git a/Xcodes/Resources/Licenses.rtf b/Xcodes/Resources/Licenses.rtf index 1687a178..9cf2e752 100644 --- a/Xcodes/Resources/Licenses.rtf +++ b/Xcodes/Resources/Licenses.rtf @@ -1,4 +1,4 @@ -{\rtf1\ansi\ansicpg1252\cocoartf2869 +{\rtf1\ansi\ansicpg1252\cocoartf2870 \cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 .SFNS-Regular;} {\colortbl;\red255\green255\blue255;} {\*\expandedcolortbl;;} @@ -1090,33 +1090,6 @@ SOFTWARE.\ \ \ -\fs34 data\ -\ - -\fs26 MIT License\ -\ -Copyright (c) 2018 \ -\ -Permission is hereby granted, free of charge, to any person obtaining a copy\ -of this software and associated documentation files (the "Software"), to deal\ -in the Software without restriction, including without limitation the rights\ -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ -copies of the Software, and to permit persons to whom the Software is\ -furnished to do so, subject to the following conditions:\ -\ -The above copyright notice and this permission notice shall be included in all\ -copies or substantial portions of the Software.\ -\ -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\ -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\ -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\ -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\ -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\ -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\ -SOFTWARE.\ -\ -\ - \fs34 Sparkle\ \ diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 0716fee2..93e638da 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -24937,6 +24937,124 @@ } } }, + "Uninstalling" : { + "localizations" : { + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desinstal·lant" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deinstallieren" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Απεγκατάσταση" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uninstalling" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desinstalando" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poistetaan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désinstallation en cours" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अनइंस्टॉल हो रहा है" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disinstallazione in corso" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アンインストール中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제거 중" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bezig met verwijderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odinstalowywanie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desinstalando" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удаление" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kaldırılıyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Видалення" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在卸载" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在解除安裝" + } + } + } + }, "Universal" : { "extractionState" : "stale", "localizations" : { diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index ff0a668d..f1f0000a 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -336,6 +336,35 @@ class AppStateTests: XCTestCase { XCTAssertTrue(Current.network.loginClient.urlSession === replacementSession) } + func test_RestoreAuthenticationStateIfNeeded_UsesPersistedSession() async throws { + let appleSession = try JSONDecoder().decode( + AppleSession.self, + from: Data(#"{"user":{"fullName":"Jane Developer"}}"#.utf8) + ) + let expectedState = AuthenticationState.authenticated(appleSession) + Current.defaults.string = { key in + key == "username" ? "jane@example.com" : nil + } + Current.network.validateSessionAsync = { expectedState } + + try await subject.restoreAuthenticationStateIfNeeded() + + XCTAssertEqual(subject.authenticationState, expectedState) + } + + func test_RestoreAuthenticationStateIfNeeded_SkipsValidationWithoutSavedUsername() async throws { + let didValidate = TestLockedBox(false) + Current.network.validateSessionAsync = { + didValidate.withValue { $0 = true } + return .unauthenticated + } + + try await subject.restoreAuthenticationStateIfNeeded() + + XCTAssertFalse(didValidate.read { $0 }) + XCTAssertEqual(subject.authenticationState, .unauthenticated) + } + func test_DownloadRuntimeViaXcodeBuild_ClearsRuntimeTaskWhenComplete() async throws { let runtime = try Self.downloadableRuntime() subject.downloadableRuntimes = [runtime] @@ -599,7 +628,7 @@ class AppStateTests: XCTestCase { return true } } - Xcodes.Current.network.validateSessionAsync = { } + Xcodes.Current.network.validateSessionAsync = { .unauthenticated } Xcodes.Current.network.loadData = { urlRequest in if urlRequest.url! == URLRequest.developerDownloads.url! { let downloads = Downloads(resultCode: 0, resultsString: nil, downloads: [Download(name: "Xcode 0.0.0", files: [Download.File(remotePath: "https://apple.com/xcode.xip", fileSize: 9484444)], dateModified: Date())]) diff --git a/XcodesTests/Environment+Mock.swift b/XcodesTests/Environment+Mock.swift index 6f351ce1..6bcf95f9 100644 --- a/XcodesTests/Environment+Mock.swift +++ b/XcodesTests/Environment+Mock.swift @@ -81,7 +81,7 @@ extension Network { } ) }, - validateSessionAsync: { }, + validateSessionAsync: { .unauthenticated }, signout: { } ) } From 498efad30dc902617ee5e2c4d881f1bf42a73780 Mon Sep 17 00:00:00 2001 From: Anand Biligiri Date: Sat, 29 Aug 2026 13:21:10 -0700 Subject: [PATCH 06/20] Add privileged file-operation XPC methods to the helper Ports moveApp/createSymbolicLink/rename/remove from developer_id onto current HelperXPCProtocol (now Sendable) and adds FileOperations.swift implementing them. --- HelperXPCShared/FileOperations.swift | 61 +++++++++++++++++++ HelperXPCShared/HelperXPCShared.swift | 50 +++++++++++++++ Xcodes.xcodeproj/project.pbxproj | 6 ++ .../XPCDelegate.swift | 51 ++++++---------- 4 files changed, 137 insertions(+), 31 deletions(-) create mode 100644 HelperXPCShared/FileOperations.swift diff --git a/HelperXPCShared/FileOperations.swift b/HelperXPCShared/FileOperations.swift new file mode 100644 index 00000000..e0937581 --- /dev/null +++ b/HelperXPCShared/FileOperations.swift @@ -0,0 +1,61 @@ +import Foundation +import os.log + +enum FileOperations { + private static let subsystem = Bundle.main.bundleIdentifier! + static let fileOperations = Logger(subsystem: subsystem, category: "fileOperations") + + static func moveApp(at source: String, to destination: String, completion: @escaping ((any Error)?) -> Void) { + do { + guard URL(fileURLWithPath: source).hasDirectoryPath else { throw XPCDelegateError(.invalidSourcePath)} + + guard URL(fileURLWithPath: destination).deletingLastPathComponent().hasDirectoryPath else { throw + XPCDelegateError(.invalidDestinationPath)} + + try FileManager.default.moveItem(at: URL(fileURLWithPath: source), to: URL(fileURLWithPath: destination)) + completion(nil) + } catch { + completion(error) + } + } + + // does an Xcode.app file exist? + static func createSymbolicLink(source: String, destination: String, completion: @escaping ((any Error)?) -> Void) { + do { + if FileManager.default.fileExists(atPath: destination) { + let attributes: [FileAttributeKey : Any]? = try? FileManager.default.attributesOfItem(atPath: destination) + + if attributes?[.type] as? FileAttributeType == FileAttributeType.typeSymbolicLink { + try FileManager.default.removeItem(atPath: destination) + Self.fileOperations.info("Successfully deleted old symlink") + } else { + throw XPCDelegateError(.destinationIsNotASymbolicLink) + } + } + + try FileManager.default.createSymbolicLink(atPath: destination, withDestinationPath: source) + Self.fileOperations.info("Successfully created symbolic link with \(destination)") + completion(nil) + } catch { + completion(error) + } + } + + static func rename(source: String, destination: String, completion: @escaping ((any Error)?) -> Void) { + do { + try FileManager.default.moveItem(at: URL(fileURLWithPath: source), to: URL(fileURLWithPath: destination)) + completion(nil) + } catch { + completion(error) + } + } + + static func remove(path: String, completion: @escaping ((any Error)?) -> Void) { + do { + try FileManager.default.removeItem(atPath: path) + completion(nil) + } catch { + completion(error) + } + } +} diff --git a/HelperXPCShared/HelperXPCShared.swift b/HelperXPCShared/HelperXPCShared.swift index d72d7be9..b5a6613e 100644 --- a/HelperXPCShared/HelperXPCShared.swift +++ b/HelperXPCShared/HelperXPCShared.swift @@ -12,4 +12,54 @@ protocol HelperXPCProtocol: Sendable { func addStaffToDevelopersGroup(completion: @escaping (Error?) -> Void) func acceptXcodeLicense(absoluteXcodePath: String, completion: @escaping (Error?) -> Void) func runFirstLaunch(absoluteXcodePath: String, completion: @escaping (Error?) -> Void) + func moveApp(at source: String, to destination: String, completion: @escaping (Error?) -> Void) + func createSymbolicLink(source: String, destination: String, completion: @escaping (Error?) -> Void) + func rename(source: String, destination: String, completion: @escaping (Error?) -> Void) + func remove(path: String, completion: @escaping (Error?) -> Void) +} + +struct XPCDelegateError: CustomNSError { + enum Code: Int { + case invalidXcodePath + case invalidSourcePath + case invalidDestinationPath + case destinationIsNotASymbolicLink + } + + let code: Code + + init(_ code: Code) { + self.code = code + } + + // MARK: - CustomNSError + + static var errorDomain: String { "XPCDelegateError" } + + var errorCode: Int { code.rawValue } + + var errorUserInfo: [String : Any] { + switch code { + case .invalidXcodePath: + return [ + NSLocalizedDescriptionKey: "Invalid Xcode path.", + NSLocalizedFailureReasonErrorKey: "Xcode path must be absolute." + ] + case .invalidSourcePath: + return [ + NSLocalizedDescriptionKey: "Invalid source path.", + NSLocalizedFailureReasonErrorKey: "Source path must be absolute and must be a directory." + ] + case .invalidDestinationPath: + return [ + NSLocalizedDescriptionKey: "Invalid destination path.", + NSLocalizedFailureReasonErrorKey: "Destination path must be absolute and must be a directory." + ] + case .destinationIsNotASymbolicLink: + return [ + NSLocalizedDescriptionKey: "Invalid destination path.", + NSLocalizedFailureReasonErrorKey: "Destination path must be a symbolic link." + ] + } + } } diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 05f49654..516f31c0 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 14d2f5a1273f6c350cad4406 /* NewVersionNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884f01aed2f43048ab4d3323 /* NewVersionNotificationTests.swift */; }; + 1596C2913043765600178C86 /* FileOperations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1596C2903043765600178C86 /* FileOperations.swift */; }; + 1596C2923043765600178C86 /* FileOperations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1596C2903043765600178C86 /* FileOperations.swift */; }; 15F5B8902CCF09B900705E2F /* CryptoKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15F5B88F2CCF09B900705E2F /* CryptoKit.framework */; }; 3328073F2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3328073E2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift */; }; 332807412CA5EA820036F691 /* SignInSecurityKeyTouchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332807402CA5EA820036F691 /* SignInSecurityKeyTouchView.swift */; }; @@ -185,6 +187,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 1596C2903043765600178C86 /* FileOperations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileOperations.swift; sourceTree = ""; }; 15F5B88F2CCF09B900705E2F /* CryptoKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoKit.framework; path = System/Library/Frameworks/CryptoKit.framework; sourceTree = SDKROOT; }; 3328073E2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInSecurityKeyPinView.swift; sourceTree = ""; }; 332807402CA5EA820036F691 /* SignInSecurityKeyTouchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInSecurityKeyTouchView.swift; sourceTree = ""; }; @@ -432,6 +435,7 @@ isa = PBXGroup; children = ( CA9FF8CE25959A9700E47BAF /* HelperXPCShared.swift */, + 1596C2903043765600178C86 /* FileOperations.swift */, ); path = HelperXPCShared; sourceTree = ""; @@ -863,6 +867,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 1596C2913043765600178C86 /* FileOperations.swift in Sources */, CA9FF8D025959A9700E47BAF /* HelperXPCShared.swift in Sources */, CA42DD7325AEB04300BC0B0C /* Logger.swift in Sources */, CA9FF8DB25959B4000E47BAF /* XPCDelegate.swift in Sources */, @@ -911,6 +916,7 @@ 332807412CA5EA820036F691 /* SignInSecurityKeyTouchView.swift in Sources */, CA61A6E0259835580008926E /* Xcode.swift in Sources */, CAE4247F259A666100B8B246 /* MainWindow.swift in Sources */, + 1596C2923043765600178C86 /* FileOperations.swift in Sources */, CA452BB0259FD9770072DFA4 /* ProgressIndicator.swift in Sources */, B0403CF02AD92D7B00137C09 /* ReleaseNotesView.swift in Sources */, CAFE4AB425B7D3AF0064FE51 /* AdvancedPreferencePane.swift in Sources */, diff --git a/com.xcodesorg.xcodesapp.Helper/XPCDelegate.swift b/com.xcodesorg.xcodesapp.Helper/XPCDelegate.swift index eb9fe069..2e5a4213 100644 --- a/com.xcodesorg.xcodesapp.Helper/XPCDelegate.swift +++ b/com.xcodesorg.xcodesapp.Helper/XPCDelegate.swift @@ -51,6 +51,26 @@ final class XPCDelegate: NSObject, NSXPCListenerDelegate, HelperXPCProtocol { func runFirstLaunch(absoluteXcodePath: String, completion: @escaping (Error?) -> Void) { run(url: URL(fileURLWithPath: absoluteXcodePath + "/Contents/Developer/usr/bin/xcodebuild"), arguments: ["-runFirstLaunch"], completion: completion) } + + func moveApp(at source: String, to destination: String, completion: @escaping (Error?) -> Void) { + Logger.xpcDelegate.info("\(#function)") + FileOperations.moveApp(at: source, to: destination, completion: completion) + } + + func createSymbolicLink(source: String, destination: String, completion: @escaping (Error?) -> Void) { + Logger.xpcDelegate.info("\(#function)") + FileOperations.createSymbolicLink(source: source, destination: destination, completion: completion) + } + + func rename(source: String, destination: String, completion: @escaping (Error?) -> Void) { + Logger.xpcDelegate.info("\(#function)") + FileOperations.rename(source: source, destination: destination, completion: completion) + } + + func remove(path: String, completion: @escaping (Error?) -> Void) { + Logger.xpcDelegate.info("\(#function)") + FileOperations.remove(path: path, completion: completion) + } } // MARK: - Run @@ -69,34 +89,3 @@ private func run(url: URL, arguments: [String], completion: @escaping (Error?) - completion(error) } } - - -// MARK: - Errors - -struct XPCDelegateError: CustomNSError { - enum Code: Int { - case invalidXcodePath - } - - let code: Code - - init(_ code: Code) { - self.code = code - } - - // MARK: - CustomNSError - - static var errorDomain: String { "XPCDelegateError" } - - var errorCode: Int { code.rawValue } - - var errorUserInfo: [String : Any] { - switch code { - case .invalidXcodePath: - return [ - NSLocalizedDescriptionKey: "Invalid Xcode path.", - NSLocalizedFailureReasonErrorKey: "Xcode path must be absolute." - ] - } - } -} From 841dea6bc5a352fb01b242bbf2b5a1179b578eaf Mon Sep 17 00:00:00 2001 From: Anand Biligiri Date: Sat, 29 Aug 2026 13:27:03 -0700 Subject: [PATCH 07/20] Route Xcode select/rename/symlink/uninstall file ops through the privileged helper Adds usePrivilegedHelperForFileOperations preference and wires HelperClient's new moveApp/createSymbolicLink/rename/remove methods into AppState's select, createSymbolicLink, renameToXcode, and uninstall flows, gated behind the toggle. Falls back to direct FileManager operations (via FileOperations/XcodeSelectionFilesystemService) when the toggle is off. --- Xcodes/Backend/AppState.swift | 110 ++++++++++++------ Xcodes/Backend/Environment.swift | 7 ++ Xcodes/Backend/HelperClient.swift | 80 +++++++++++++ .../Preferences/AdvancedPreferencePane.swift | 5 +- 4 files changed, 167 insertions(+), 35 deletions(-) diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 7924e649..f0ee0729 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -29,6 +29,7 @@ enum PreferenceKey: String { case enableGroupedXcodeList case expandedMajorXcodeVersions case expandedMinorXcodeVersions + case usePrivilegeHelperForFileOperations func isManaged() -> Bool { UserDefaults.standard.objectIsForced(forKey: self.rawValue) } } @@ -147,6 +148,12 @@ class AppState: ObservableObject { var onSelectActionTypeDisabled: Bool { PreferenceKey.onSelectActionType.isManaged() } + @Published var usePrivilegedHelperForFileOperations = false { + didSet { + Current.defaults.set(usePrivilegedHelperForFileOperations, forKey: PreferenceKey.usePrivilegeHelperForFileOperations.rawValue) + } + } + @Published var showOpenInRosettaOption = false { didSet { Current.defaults.set(showOpenInRosettaOption, forKey: "showOpenInRosettaOption") @@ -263,6 +270,7 @@ class AppState: ObservableObject { showOpenInRosettaOption = Current.defaults.bool(forKey: "showOpenInRosettaOption") ?? false terminateAfterLastWindowClosed = Current.defaults.bool(forKey: "terminateAfterLastWindowClosed") ?? false enableGroupedXcodeList = Current.defaults.get(forKey: PreferenceKey.enableGroupedXcodeList.rawValue) as? Bool ?? true + usePrivilegedHelperForFileOperations = Current.defaults.bool(forKey: PreferenceKey.usePrivilegeHelperForFileOperations.rawValue) ?? false } // MARK: Timer @@ -759,14 +767,9 @@ class AppState: ObservableObject { } guard - var installedXcodePath = xcode.installedPath + let installedXcodePath = xcode.installedPath else { return } - if onSelectActionType == .rename { - guard let newDestinationXcodePath = renameToXcode(xcode: xcode) else { return } - installedXcodePath = newDestinationXcodePath - } - selectTask?.cancel() let taskID = UUID() selectTaskID = taskID @@ -778,13 +781,20 @@ class AppState: ObservableObject { } } do { + var installedXcodePath = installedXcodePath try await installHelperIfNecessaryAsync() try Task.checkCancellation() + + if onSelectActionType == .rename { + guard let newDestinationXcodePath = await renameToXcode(xcode: xcode) else { return } + installedXcodePath = newDestinationXcodePath + } + try await Current.helper.switchXcodePathAsync(installedXcodePath.string) try Task.checkCancellation() await updateSelectedXcodePathAsync() if createSymLinkOnSelect && onSelectActionType != .rename { - createSymbolicLink(to: installedXcodePath) + await createSymbolicLink(to: installedXcodePath) } } catch is CancellationError { } catch { @@ -826,25 +836,39 @@ class AppState: ObservableObject { func createSymbolicLink(xcode: Xcode, isBeta: Bool = false) { guard let installedXcodePath = xcode.installedPath else { return } - createSymbolicLink(to: installedXcodePath, isBeta: isBeta) + Task { @MainActor in + await createSymbolicLink(to: installedXcodePath, isBeta: isBeta) + } } - func createSymbolicLink(to installedXcodePath: Path, isBeta: Bool = false) { + func createSymbolicLink(to installedXcodePath: Path, isBeta: Bool = false) async { let destinationPath = Path.installDirectory/"Xcode\(isBeta ? "-Beta" : "").app" do { - let service = XcodeSelectionFilesystemService( - installedXcode: { Current.files.installedXcode(destination: $0) } - ) - let result = try service.createSymbolicLink( - to: installedXcodePath, - in: Path.installDirectory, - isBeta: isBeta - ) - if result.replacedExistingSymlink { - Logger.appState.info("Successfully deleted old symlink") + if Current.helper.usePrivilegedHelperForFileOperations { + if Current.files.fileExists(atPath: destinationPath.string) { + let attributes = try FileManager.default.attributesOfItem(atPath: destinationPath.string) + guard attributes[.type] as? FileAttributeType == .typeSymbolicLink else { + throw XcodeSelectionFilesystemError.destinationExistsAndIsNotSymlink(destinationPath) + } + } + // The helper's createSymbolicLink deletes an existing symlink at the destination before creating the new one. + try await Current.helper.createSymbolicLinkAsync(installedXcodePath.string, destinationPath.string) + Logger.appState.info("Successfully created symbolic link with Xcode\(isBeta ? "-Beta": "").app") + } else { + let service = XcodeSelectionFilesystemService( + installedXcode: { Current.files.installedXcode(destination: $0) } + ) + let result = try service.createSymbolicLink( + to: installedXcodePath, + in: Path.installDirectory, + isBeta: isBeta + ) + if result.replacedExistingSymlink { + Logger.appState.info("Successfully deleted old symlink") + } + Logger.appState.info("Successfully created symbolic link with Xcode\(isBeta ? "-Beta": "").app") } - Logger.appState.info("Successfully created symbolic link with Xcode\(isBeta ? "-Beta": "").app") } catch { Logger.appState.error("Unable to create symbolic Link") self.error = error @@ -855,19 +879,31 @@ class AppState: ObservableObject { } } - func renameToXcode(xcode: Xcode) -> Path? { + func renameToXcode(xcode: Xcode) async -> Path? { guard let installedXcodePath = xcode.installedPath else { return nil } do { - let service = XcodeSelectionFilesystemService( - installedXcode: { Current.files.installedXcode(destination: $0) } - ) - let renamedPath = try service.renameForSelection( - installedXcodePath: installedXcodePath, - in: Path.installDirectory - ) - Logger.appState.debug("Renamed selected Xcode to Xcode.app") - return renamedPath + if Current.helper.usePrivilegedHelperForFileOperations { + let destinationPath = Path.installDirectory/"Xcode.app" + if Current.files.fileExists(atPath: destinationPath.string), + let originalXcode = Current.files.installedXcode(destination: destinationPath) { + let newName = "Xcode-\(originalXcode.version.descriptionWithoutBuildMetadata).app" + try await Current.helper.renameAsync(destinationPath.string, "\(Path.installDirectory)/\(newName)") + } + try await Current.helper.renameAsync(installedXcodePath.string, destinationPath.string) + Logger.appState.debug("Renamed selected Xcode to Xcode.app") + return destinationPath + } else { + let service = XcodeSelectionFilesystemService( + installedXcode: { Current.files.installedXcode(destination: $0) } + ) + let renamedPath = try service.renameForSelection( + installedXcodePath: installedXcodePath, + in: Path.installDirectory + ) + Logger.appState.debug("Renamed selected Xcode to Xcode.app") + return renamedPath + } } catch { Logger.appState.error("Unable to create rename Xcode.app back to original") self.error = error @@ -921,10 +957,16 @@ class AppState: ObservableObject { ) else { throw FileError.fileNotFound(path.string) } - _ = try XcodeUninstallService( - removeItem: { url in try Current.files.removeItem(at: url) }, - trashItem: { url in try Current.files.trashItem(at: url) } - ).uninstall(xcode, emptyTrash: false) + + if Current.helper.usePrivilegedHelperForFileOperations { + try await installHelperIfNecessaryAsync() + try await Current.helper.removeAsync(xcode.path.string) + } else { + _ = try XcodeUninstallService( + removeItem: { url in try Current.files.removeItem(at: url) }, + trashItem: { url in try Current.files.trashItem(at: url) } + ).uninstall(xcode, emptyTrash: false) + } } private func waitForAuthenticationTerminalState() async throws { diff --git a/Xcodes/Backend/Environment.swift b/Xcodes/Backend/Environment.swift index 44a1f92a..4373d470 100644 --- a/Xcodes/Backend/Environment.swift +++ b/Xcodes/Backend/Environment.swift @@ -285,4 +285,11 @@ public struct Helper: Sendable { var addStaffToDevelopersGroupAsync: @Sendable () async throws -> Void = { try await helperClient.addStaffToDevelopersGroupAsync() } var acceptXcodeLicenseAsync: @Sendable (_ absoluteXcodePath: String) async throws -> Void = { try await helperClient.acceptXcodeLicenseAsync(absoluteXcodePath: $0) } var runFirstLaunchAsync: @Sendable (_ absoluteXcodePath: String) async throws -> Void = { try await helperClient.runFirstLaunchAsync(absoluteXcodePath: $0) } + var moveAppAsync: @Sendable (_ source: String, _ destination: String) async throws -> Void = { try await helperClient.moveAppAsync(at: $0, to: $1) } + var createSymbolicLinkAsync: @Sendable (_ source: String, _ destination: String) async throws -> Void = { try await helperClient.createSymbolicLinkAsync(source: $0, destination: $1) } + var renameAsync: @Sendable (_ source: String, _ destination: String) async throws -> Void = { try await helperClient.renameAsync(source: $0, destination: $1) } + var removeAsync: @Sendable (_ path: String) async throws -> Void = { try await helperClient.removeAsync(path: $0) } + var usePrivilegedHelperForFileOperations: Bool { + Current.defaults.bool(forKey: PreferenceKey.usePrivilegeHelperForFileOperations.rawValue) ?? false + } } diff --git a/Xcodes/Backend/HelperClient.swift b/Xcodes/Backend/HelperClient.swift index f7f8df2e..1fb73d8b 100644 --- a/Xcodes/Backend/HelperClient.swift +++ b/Xcodes/Backend/HelperClient.swift @@ -119,6 +119,86 @@ final class HelperClient { Logger.helperClient.info("\(#function): finished") } + func moveAppAsync(at source: String, to destination: String) async throws { + Logger.helperClient.info(#function) + + guard Current.helper.usePrivilegedHelperForFileOperations else { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + FileOperations.moveApp(at: source, to: destination) { error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + } + } + return + } + + try await performVoidHelperRequest { helper, finish in + helper.moveApp(at: source, to: destination) { possibleError in + finish(possibleError.map(Result.failure) ?? .success(())) + } + } + Logger.helperClient.info("\(#function): finished") + } + + func createSymbolicLinkAsync(source: String, destination: String) async throws { + Logger.helperClient.info(#function) + + guard Current.helper.usePrivilegedHelperForFileOperations else { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + FileOperations.createSymbolicLink(source: source, destination: destination) { error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + } + } + return + } + + try await performVoidHelperRequest { helper, finish in + helper.createSymbolicLink(source: source, destination: destination) { possibleError in + finish(possibleError.map(Result.failure) ?? .success(())) + } + } + Logger.helperClient.info("\(#function): finished") + } + + func renameAsync(source: String, destination: String) async throws { + Logger.helperClient.info(#function) + + guard Current.helper.usePrivilegedHelperForFileOperations else { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + FileOperations.rename(source: source, destination: destination) { error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + } + } + return + } + + try await performVoidHelperRequest { helper, finish in + helper.rename(source: source, destination: destination) { possibleError in + finish(possibleError.map(Result.failure) ?? .success(())) + } + } + Logger.helperClient.info("\(#function): finished") + } + + func removeAsync(path: String) async throws { + Logger.helperClient.info(#function) + + guard Current.helper.usePrivilegedHelperForFileOperations else { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + FileOperations.remove(path: path) { error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + } + } + return + } + + try await performVoidHelperRequest { helper, finish in + helper.remove(path: path) { possibleError in + finish(possibleError.map(Result.failure) ?? .success(())) + } + } + Logger.helperClient.info("\(#function): finished") + } + private func performVoidHelperRequest(_ operation: @escaping @Sendable (HelperXPCProtocol, @escaping @Sendable (Result) -> Void) -> Void) async throws { try await performHelperRequest(operation) } diff --git a/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift b/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift index c938bb3e..201b1b93 100644 --- a/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift @@ -149,7 +149,10 @@ struct AdvancedPreferencePane: View { .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - + + Toggle("UsePrivilegedHelperForFileOperations", isOn: $appState.usePrivilegedHelperForFileOperations) + .disabled(PreferenceKey.usePrivilegeHelperForFileOperations.isManaged()) + Spacer() } } From b010e58229c709fb9c54c0f440405dbcc8e94896 Mon Sep 17 00:00:00 2001 From: Anand Biligiri Date: Sat, 29 Aug 2026 13:27:35 -0700 Subject: [PATCH 08/20] Add localized label for privileged-helper file operations toggle --- Xcodes/Resources/Localizable.xcstrings | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 93e638da..f2f7b463 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -25567,6 +25567,16 @@ } } }, + "UsePrivilegedHelperForFileOperations" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perform file operations using Privileged Helper" + } + } + } + }, "UseUnxipExperiment" : { "localizations" : { "ar" : { From d3059fafb8d9a0c06604c3451557a2ed18effeb7 Mon Sep 17 00:00:00 2001 From: Anand Biligiri Date: Sat, 29 Aug 2026 13:29:00 -0700 Subject: [PATCH 09/20] Mark createSymbolicLink test async to match new async signature --- XcodesTests/AppStateTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index f1f0000a..712565df 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -114,7 +114,7 @@ class AppStateTests: XCTestCase { XCTAssertNil(subject.presentedAlert) } - func test_CreateSymbolicLink_UsesProvidedInstalledPath() throws { + func test_CreateSymbolicLink_UsesProvidedInstalledPath() async throws { let installDirectory = try XCTUnwrap(Path( NSTemporaryDirectory() .appending("XcodesAppStateTests-") @@ -129,7 +129,7 @@ class AppStateTests: XCTestCase { key == "installPath" ? installDirectory.string : nil } - subject.createSymbolicLink(to: installedXcodePath) + await subject.createSymbolicLink(to: installedXcodePath) let destination = try FileManager.default.destinationOfSymbolicLink(atPath: symlinkPath.string) XCTAssertEqual(destination, installedXcodePath.string) From b09019708049f06641015f72b30b98b606e0cb27 Mon Sep 17 00:00:00 2001 From: Anand Biligiri Date: Sat, 29 Aug 2026 13:36:59 -0700 Subject: [PATCH 10/20] Route install-time Xcode.app move through privileged helper Points the XcodesKit package dependency at abiligiri/XcodesKit async-move-item-for-helper branch, which makes XcodeUnarchiveService.MoveItem async so the post-unarchive move into /Applications can go through Current.helper.moveAppAsync when usePrivilegedHelperForFileOperations is enabled. This is a temporary fork pin; the corresponding XcodesKit change should be upstreamed to XcodesOrg/XcodesKit and this pinned back to a released version once merged. --- Xcodes/Backend/AppState+Install.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index 8d8a2f32..02850d70 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -254,7 +254,14 @@ extension AppState { XcodeUnarchiveService( unarchive: { _ = try await self.unxipOrUnxipExperimentAsync($0) }, fileExists: { path in Current.files.fileExists(atPath: path) }, - moveItem: { source, destination in try Current.files.moveItem(at: source, to: destination) }, + moveItem: { source, destination in + if Current.helper.usePrivilegedHelperForFileOperations { + try await self.installHelperIfNecessaryAsync() + try await Current.helper.moveAppAsync(source.path, destination.path) + } else { + try Current.files.moveItem(at: source, to: destination) + } + }, removeItem: { url in try Current.files.removeItem(at: url) } ) } From 6539d8c8c7bcd4239c9f7cf7b4b409521d87f8da Mon Sep 17 00:00:00 2001 From: Anand Biligiri Date: Sat, 29 Aug 2026 15:37:13 -0700 Subject: [PATCH 11/20] Show a spinner while an Xcode uninstall is in progress Uninstall now permanently deletes files instead of moving to Trash, and the delete can take a noticeable number of seconds with zero visual feedback in the row/detail pane. Sets installState to .uninstalling at the start of AppState.uninstall(xcode:) and resets it on failure; on success the existing post-uninstall updateInstalledXcodesAsync() recompute already clears it back to .notInstalled. Requires the XcodesKit fork's new .uninstalling(Path) case (abiligiri/XcodesKit@fbee0cd). --- .../xcshareddata/swiftpm/Package.resolved | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4f9d80c0..5445e6ff 100644 --- a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,8 +42,8 @@ "repositoryURL": "https://github.com/mxcl/LegibleError", "state": { "branch": null, - "revision": "909e9bab3ded97350b28a5ab41dd745dd8aa9710", - "version": "1.0.4" + "revision": "bc596702d7ff618c3f90ba480eeb48b3e83a2fbe", + "version": "1.0.6" } }, { @@ -60,17 +60,17 @@ "repositoryURL": "https://github.com/mxcl/Path.swift", "state": { "branch": null, - "revision": "8e355c28e9393c42e58b18c54cace2c42c98a616", - "version": "1.4.1" + "revision": "74ec90bbe50a3376e399286fed48b60db9b91bb1", + "version": "1.6.0" } }, { "package": "Sparkle", - "repositoryURL": "https://github.com/sparkle-project/Sparkle/", + "repositoryURL": "https://github.com/sparkle-project/Sparkle", "state": { "branch": null, - "revision": "0ef1ee0220239b3776f433314515fd849025673f", - "version": "2.6.4" + "revision": "ac2def288cbff5cfc7df3ffef6abdf45b72bcb0a", + "version": "2.9.6" } }, { @@ -78,8 +78,8 @@ "repositoryURL": "https://github.com/apple/swift-collections.git", "state": { "branch": null, - "revision": "a902f1823a7ff3c9ab2fba0f992396b948eda307", - "version": "1.0.5" + "revision": "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version": "1.6.0" } }, { From 9335041a1e27acd79365a7de4580fddefa31d9ea Mon Sep 17 00:00:00 2001 From: Anand Biligiri Date: Sat, 29 Aug 2026 18:25:27 -0700 Subject: [PATCH 12/20] Add missing translations for new Uninstalling/UsePrivilegedHelperForFileOperations strings The xcstrings.yml CI check requires every key to have translations for all 18 supported languages (ca, de, el, es, fi, fr, hi, it, ja, ko, nl, pl, pt-BR, ru, tr, uk, zh-Hans, zh-Hant), and these two new keys were English-only. --- Xcodes/Resources/Localizable.xcstrings | 108 +++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index f2f7b463..cad3415a 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -25569,11 +25569,119 @@ }, "UsePrivilegedHelperForFileOperations" : { "localizations" : { + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Realitza operacions de fitxers mitjançant l'ajudant privilegiat" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dateivorgänge über den privilegierten Helfer ausführen" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Εκτέλεση λειτουργιών αρχείων μέσω του προνομιούχου βοηθού" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Perform file operations using Privileged Helper" } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Realizar operaciones de archivos mediante el ayudante con privilegios" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suorita tiedostotoiminnot etuoikeutetun apuohjelman kautta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effectuer les opérations sur les fichiers via l'assistant privilégié" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "विशेषाधिकार प्राप्त हेल्पर का उपयोग करके फ़ाइल संचालन करें" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esegui operazioni sui file tramite l'helper privilegiato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "特権ヘルパーを使用してファイル操作を実行" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "권한이 부여된 헬퍼를 사용하여 파일 작업 수행" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bestandsbewerkingen uitvoeren via de geprivilegieerde helper" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wykonuj operacje na plikach za pomocą uprzywilejowanego pomocnika" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Executar operações de arquivo usando o Auxiliar Privilegiado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выполнять файловые операции с помощью привилегированного помощника" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dosya işlemlerini Yetkili Yardımcı ile gerçekleştir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виконувати файлові операції за допомогою привілейованого помічника" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用特权助手执行文件操作" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用特權輔助程式執行檔案操作" + } } } }, From aaf917f504358f0a3af6d43f2d961ff610fec034 Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Mon, 14 Sep 2026 08:51:53 -0500 Subject: [PATCH 13/20] better 401 error messages --- Xcodes/Backend/AppState.swift | 20 +++++++++++++++++++- Xcodes/Frontend/MainWindow.swift | 2 +- Xcodes/Resources/Licenses.rtf | 2 +- Xcodes/Resources/Localizable.xcstrings | 2 +- XcodesTests/AppStateTests.swift | 13 +++++++++++++ 5 files changed, 35 insertions(+), 4 deletions(-) diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index c5bc0122..6ac509bb 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -1,4 +1,5 @@ import AppKit +import AsyncNetworkService import XcodesLoginKit import XcodesLoginKitSecurityKey import Path @@ -1001,7 +1002,7 @@ class AppState: ObservableObject { private func handleInstallError(_ error: Error, id: XcodeID) { // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead - if let error = error as? AuthenticationError, case .notAuthorized = error { + if Self.isUnauthorizedInstallError(error) { self.error = error self.presentedAlert = .unauthenticated @@ -1014,6 +1015,23 @@ class AppState: ObservableObject { } } + static func isUnauthorizedInstallError(_ error: Error) -> Bool { + if let authenticationError = error as? AuthenticationError { + switch authenticationError { + case .notAuthorized, .badStatusCode(statusCode: 401, data: _, response: _): + return true + default: + break + } + } + + guard let networkError = error as? NetworkError, + case .non200StatusCode(statusCode: 401, data: _) = networkError else { + return false + } + return true + } + /// removes saved username and credentials stored in keychain private func clearLoginCredentials() { if let username = savedUsername { diff --git a/Xcodes/Frontend/MainWindow.swift b/Xcodes/Frontend/MainWindow.swift index ae313aed..b108f053 100644 --- a/Xcodes/Frontend/MainWindow.swift +++ b/Xcodes/Frontend/MainWindow.swift @@ -195,7 +195,7 @@ struct MainWindow: View { title: Text("Alert.Install.Error.Title"), message: Text("Alert.Install.AuthError.Message"), primaryButton: .default( - Text("OK"), + Text("Login"), action: { appState.presentedSheet = .signIn } diff --git a/Xcodes/Resources/Licenses.rtf b/Xcodes/Resources/Licenses.rtf index 9cf2e752..3f86ba20 100644 --- a/Xcodes/Resources/Licenses.rtf +++ b/Xcodes/Resources/Licenses.rtf @@ -1151,7 +1151,7 @@ POSSIBILITY OF SUCH DAMAGE.\ \ --\ \ -sais.c and sais.c, from sais-lite (2010/08/07) :\ +sais.c and sais.h, from sais-lite (2010/08/07) :\ \ The sais-lite copyright is as follows:\ \ diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index cad3415a..6608a149 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -2083,7 +2083,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "You are not authorized. Please Sign in with your Apple ID first." + "value" : "Your Apple ID session may have expired. Try logging in again using the Login button in the top navigation bar." } }, "es" : { diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index 20d0e1e6..2945b06c 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -1,5 +1,6 @@ import Combine import Cocoa +import AsyncNetworkService @preconcurrency import Path import Version import XCTest @@ -80,6 +81,18 @@ class AppStateTests: XCTestCase { subject = AppState() } + func test_InstallError_Network401IsUnauthorized() { + let error = NetworkError.non200StatusCode(statusCode: 401, data: Data()) + + XCTAssertTrue(AppState.isUnauthorizedInstallError(error)) + } + + func test_InstallError_OtherNetworkStatusIsNotUnauthorized() { + let error = NetworkError.non200StatusCode(statusCode: 500, data: Data()) + + XCTAssertFalse(AppState.isUnauthorizedInstallError(error)) + } + func test_PinCodeTextView_MarksDigitFieldsAsOneTimeCode() { let pinCodeTextView = PinCodeTextView(numberOfDigits: 6, itemSpacing: 10) From 7f79cc9a47caaacc0f706ae3448cc83f84d64d54 Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Mon, 14 Sep 2026 08:59:07 -0500 Subject: [PATCH 14/20] v4.1 - v2.1 Helper tool --- Xcodes.xcodeproj/project.pbxproj | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index ccfef6da..0f753273 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -1078,7 +1078,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 40; DEVELOPMENT_ASSET_PATHS = "\"Xcodes/Preview Content\""; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; @@ -1090,7 +1090,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 4.0.4; + MARKETING_VERSION = 4.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.xcodesorg.xcodesapp; PRODUCT_NAME = Xcodes; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1132,7 +1132,7 @@ DEVELOPMENT_TEAM = ZU6GR6B2FY; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = "$(SRCROOT)/$(TARGET_NAME)/Info.plist"; - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.1.0; OTHER_LDFLAGS = ( "-sectcreate", __TEXT, @@ -1158,7 +1158,7 @@ DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; INFOPLIST_FILE = "$(SRCROOT)/$(TARGET_NAME)/Info.plist"; - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.1.0; OTHER_LDFLAGS = ( "-sectcreate", __TEXT, @@ -1185,7 +1185,7 @@ DEVELOPMENT_TEAM = ZU6GR6B2FY; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = "$(SRCROOT)/$(TARGET_NAME)/Info.plist"; - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.1.0; OTHER_LDFLAGS = ( "-sectcreate", __TEXT, @@ -1331,7 +1331,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 40; DEVELOPMENT_ASSET_PATHS = "\"Xcodes/Preview Content\""; DEVELOPMENT_TEAM = ZU6GR6B2FY; ENABLE_HARDENED_RUNTIME = YES; @@ -1343,7 +1343,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 4.0.4; + MARKETING_VERSION = 4.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.xcodesorg.xcodesapp; PRODUCT_NAME = Xcodes; SWIFT_VERSION = 6.0; @@ -1360,7 +1360,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 40; DEVELOPMENT_ASSET_PATHS = "\"Xcodes/Preview Content\""; DEVELOPMENT_TEAM = ZU6GR6B2FY; ENABLE_HARDENED_RUNTIME = YES; @@ -1372,7 +1372,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 4.0.4; + MARKETING_VERSION = 4.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.xcodesorg.xcodesapp; PRODUCT_NAME = Xcodes; SWIFT_VERSION = 6.0; From f9d50b93c7c4fde19ec3033e553471adee762a82 Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Mon, 14 Sep 2026 09:45:14 -0500 Subject: [PATCH 15/20] v 4.1.1b41 --- Xcodes.xcodeproj/project.pbxproj | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 0f753273..e8e0ad6a 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -1078,7 +1078,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 41; DEVELOPMENT_ASSET_PATHS = "\"Xcodes/Preview Content\""; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; @@ -1090,7 +1090,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 4.1.0; + MARKETING_VERSION = 4.1.1; PRODUCT_BUNDLE_IDENTIFIER = com.xcodesorg.xcodesapp; PRODUCT_NAME = Xcodes; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1331,7 +1331,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 41; DEVELOPMENT_ASSET_PATHS = "\"Xcodes/Preview Content\""; DEVELOPMENT_TEAM = ZU6GR6B2FY; ENABLE_HARDENED_RUNTIME = YES; @@ -1343,7 +1343,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 4.1.0; + MARKETING_VERSION = 4.1.1; PRODUCT_BUNDLE_IDENTIFIER = com.xcodesorg.xcodesapp; PRODUCT_NAME = Xcodes; SWIFT_VERSION = 6.0; @@ -1360,7 +1360,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 41; DEVELOPMENT_ASSET_PATHS = "\"Xcodes/Preview Content\""; DEVELOPMENT_TEAM = ZU6GR6B2FY; ENABLE_HARDENED_RUNTIME = YES; @@ -1372,7 +1372,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 4.1.0; + MARKETING_VERSION = 4.1.1; PRODUCT_BUNDLE_IDENTIFIER = com.xcodesorg.xcodesapp; PRODUCT_NAME = Xcodes; SWIFT_VERSION = 6.0; From 54a7a330694c51aeb6ecfe8cc6ca0a7e801cb746 Mon Sep 17 00:00:00 2001 From: Jacob Clayden Date: Fri, 18 Sep 2026 12:02:01 +0100 Subject: [PATCH 16/20] fix: handled wrapped authentication failures - Retried transient authentication status errors within the existing limit. - Preserved unauthorised failures and saved credentials. - Added status-specific regression coverage. --- Xcodes/Backend/AppState.swift | 4 ++++ XcodesTests/AppStateTests.swift | 30 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index e69a3ec0..b890c473 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -113,6 +113,10 @@ struct AuthenticationRequestPolicy: Sendable { case let .non200StatusCode(statusCode, _) = networkError { return [502, 503, 504].contains(statusCode) ? statusCode : nil } + if let authenticationError = error as? AuthenticationError, + case let .badStatusCode(statusCode, _, _) = authenticationError { + return [502, 503, 504].contains(statusCode) ? statusCode : nil + } if let authenticationError = error as? AuthenticationError, case let .serviceKeyResolutionFailed(attempts) = authenticationError { return attempts.compactMap { attempt in diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index b7a6f38c..c44211d0 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -222,6 +222,36 @@ class AppStateTests: XCTestCase { } } + func test_AuthenticationPolicy_HandlesAuthenticationHTTPStatusCodes() async throws { + for statusCode in [502, 503, 504, 401] { + let attempts = TestLockedBox(0) + let response = try XCTUnwrap(HTTPURLResponse( + url: URL(string: "https://idmsa.apple.com/appleauth/auth")!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )) + let failure = AuthenticationError.badStatusCode(statusCode: statusCode, data: nil, response: response) + + do { + let _: String = try await AuthenticationRequestPolicy(delayBeforeRetry: .zero).perform { + attempts.withValue { $0 += 1 } + throw failure + } + XCTFail("Expected authentication HTTP error") + } catch { + if statusCode == 401 { + XCTAssertEqual(error as? AuthenticationError, failure) + XCTAssertEqual(attempts.read { $0 }, 1) + } else { + XCTAssertEqual(error as? AuthenticationRequestError, .serviceTemporarilyUnavailable(statusCode: statusCode)) + XCTAssertEqual(attempts.read { $0 }, 3) + } + XCTAssertFalse(AuthenticationRequestPolicy.shouldClearCredentials(after: error)) + } + } + } + func test_AuthenticationPolicy_RetriesTransientServiceKeyFailureUntilSuccess() async throws { let attempts = TestLockedBox(0) let failure = AuthenticationError.serviceKeyResolutionFailed(attempts: [ From 2b74b2863e3739ebbfa7422eeb1f5b3e4bcd8ec2 Mon Sep 17 00:00:00 2001 From: Jacob Clayden Date: Fri, 18 Sep 2026 12:10:30 +0100 Subject: [PATCH 17/20] fix: improved compact window layouts - Separated platform metadata from titles and actions. - Moved platform navigation below the sidebar list. - Allowed titles to wrap and shortened paths in the middle. --- Xcodes/Frontend/InfoPane/PlatformsView.swift | 38 +++++----- Xcodes/Frontend/XcodeList/XcodeListView.swift | 76 +++++++------------ .../Frontend/XcodeList/XcodeListViewRow.swift | 7 +- 3 files changed, 53 insertions(+), 68 deletions(-) diff --git a/Xcodes/Frontend/InfoPane/PlatformsView.swift b/Xcodes/Frontend/InfoPane/PlatformsView.swift index 34af471e..d169fbcb 100644 --- a/Xcodes/Frontend/InfoPane/PlatformsView.swift +++ b/Xcodes/Frontend/InfoPane/PlatformsView.swift @@ -74,33 +74,33 @@ struct PlatformsView: View { @ViewBuilder func runtimeView(runtime: DownloadableRuntime) -> some View { - VStack(spacing: 10) { - HStack { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { runtime.icon() - Text("\(runtime.visibleIdentifier)") + Text(runtime.visibleIdentifier) .font(.headline) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + pathIfAvailable(xcode: xcode, runtime: runtime) + + if runtime.installState == .notInstalled, + appState.runtimeInstallPath(xcode: xcode, runtime: runtime) == nil { + DownloadRuntimeButton(runtime: runtime) + } + } + + HStack { ForEach(runtime.architectures ?? [], id: \.self) { architecture in TagView(text: architecture.displayString) + .fixedSize() } - - 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) - } - } - } - + Spacer() Text(runtime.downloadFileSizeString) .font(.subheadline) - .frame(width: 70, alignment: .trailing) + .foregroundStyle(.secondary) + .fixedSize() } if case let .installing(installationStep) = runtime.installState { diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 24fef5ae..763ffeac 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -40,32 +40,32 @@ struct XcodeListView: View { } var body: some View { - List(selection: $selectedXcodeID) { - if appState.enableGroupedXcodeList { - GroupedXcodeListContent( - xcodes: visibleXcodes, - allXcodes: appState.allXcodes, - selectedXcodeID: $selectedXcodeID, - appState: appState - ) - } else { - ForEach(visibleXcodes) { entry in - XcodeListViewRow( - xcode: entry.xcode, - selected: selectedXcodeID == entry.xcode.id, - appState: appState, - latestReleaseForSelectedPrerelease: latestReleaseForSelectedPrerelease(entry.xcode) + VStack(spacing: 0) { + List(selection: $selectedXcodeID) { + if appState.enableGroupedXcodeList { + GroupedXcodeListContent( + xcodes: visibleXcodes, + allXcodes: appState.allXcodes, + selectedXcodeID: $selectedXcodeID, + appState: appState ) - .tag(entry.xcode.id) + } else { + ForEach(visibleXcodes) { entry in + XcodeListViewRow( + xcode: entry.xcode, + selected: selectedXcodeID == entry.xcode.id, + appState: appState, + latestReleaseForSelectedPrerelease: latestReleaseForSelectedPrerelease(entry.xcode) + ) + .tag(entry.xcode.id) + } } } - } - .listStyle(.sidebar) - .safeAreaInset(edge: .bottom, spacing: 0) { + .listStyle(.sidebar) + + Divider() PlatformsPocket() - .padding(.horizontal) - .padding(.vertical, 8) - + .padding(10) } } } @@ -385,34 +385,14 @@ struct PlatformsPocket: View { @SwiftUI.Environment(\.openWindow) private var openWindow var body: some View { - Button(action: { + Button { openWindow(id: "platforms") + } label: { + Label("PlatformsDescription", systemImage: "square.3.layers.3d") + .frame(maxWidth: .infinity, alignment: .leading) } - ) { - if #available(macOS 26.0, *) { - platformsLabel - .glassEffect(in: .rect(cornerRadius: 8, style: .continuous)) - } else { - platformsLabel - .background(.quaternary.opacity(0.75)) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - - } - .buttonStyle(.plain) - } - - var platformsLabel: some View { - HStack(spacing: 5) { - Image(systemName: "square.3.layers.3d") - .font(.title3.weight(.medium)) - Text("PlatformsDescription") - Spacer() - } - .font(.body.weight(.medium)) - .padding(.horizontal) - .padding(.vertical, 12) - .contentShape(Rectangle()) + .buttonStyle(.borderless) + .controlSize(.regular) } } diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index e37f8fda..a278d5fc 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -24,6 +24,8 @@ struct XcodeListViewRow: View { HStack { Text(verbatim: "\(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)") .font(.body) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) if !xcode.identicalBuildsForCurrentVariant.isEmpty { Image(systemName: "square.fill.on.square.fill") @@ -47,13 +49,16 @@ struct XcodeListViewRow: View { Text(verbatim: path.string) .font(.caption) .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .help(path.string) } } Spacer() selectControl(for: xcode) - .padding(.trailing, 16) + .padding(.trailing, 4) installControl(for: xcode) } .padding(.vertical, 4) From a8cd778b674d8c342b3975015f3261bccba9fd5e Mon Sep 17 00:00:00 2001 From: Jacob Clayden Date: Fri, 18 Sep 2026 12:10:53 +0100 Subject: [PATCH 18/20] fix: kept grouped sidebar labels readable - Applied flexible label height to version groups. - Reduced spacing reserved beside selection controls. --- Xcodes/Frontend/XcodeList/XcodeListView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 763ffeac..5fec4f9f 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -228,11 +228,13 @@ private struct XcodeVersionGroupRow: View { VStack(alignment: .leading, spacing: 2) { Text(verbatim: "Xcode \(displayName)") .font(.body.weight(indentation == 0 ? .medium : .regular)) + .fixedSize(horizontal: false, vertical: true) if let latestRelease { Text(verbatim: "Latest: \(latestRelease.description)") .font(.caption) .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) } } @@ -244,7 +246,7 @@ private struct XcodeVersionGroupRow: View { .buttonStyle(.plain) selectControl - .padding(.trailing, 16) + .padding(.trailing, 4) installControl } .padding(.leading, indentation) From 1e7d7f8ae0de7aff155c1f58e0ac51702f9db290 Mon Sep 17 00:00:00 2001 From: Jacob Clayden Date: Fri, 18 Sep 2026 12:20:14 +0100 Subject: [PATCH 19/20] fix: clarified retry status inference - Specified the optional status type for older Swift compilers. - Preserved transient authentication retry behaviour. --- Xcodes/Backend/AppState.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index b890c473..63e56408 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -119,7 +119,7 @@ struct AuthenticationRequestPolicy: Sendable { } if let authenticationError = error as? AuthenticationError, case let .serviceKeyResolutionFailed(attempts) = authenticationError { - return attempts.compactMap { attempt in + return attempts.compactMap { attempt -> Int? in guard case let .httpStatus(code, _) = attempt.failure, [502, 503, 504].contains(code) else { return nil } return code From 068e4586db09b76ed47dea135ec1b77e532b9469 Mon Sep 17 00:00:00 2001 From: Jacob Clayden Date: Fri, 18 Sep 2026 12:32:29 +0100 Subject: [PATCH 20/20] test: used real uninstall bundle fixtures - Created isolated app directories for filesystem discovery. - Replaced scheduler polling with explicit helper expectations. - Preserved cancellation and replacement-state assertions. --- XcodesTests/AppStateTests.swift | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index c44211d0..e5312062 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -1399,11 +1399,16 @@ class AppStateTests: XCTestCase { } private func verifyCancelledUninstallState(repeatsSameItem: Bool) async throws { - let path = try XCTUnwrap(Path("/Applications/Xcode-0.0.0.app")) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let path = try XCTUnwrap(Path(url: directory.appendingPathComponent("Xcode-0.0.0.app", isDirectory: true))) + let secondPath = try XCTUnwrap(Path(url: directory.appendingPathComponent("Xcode-0.0.1.app", isDirectory: true))) + try FileManager.default.createDirectory(at: path.url, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: secondPath.url, withIntermediateDirectories: true) let first = Xcode(version: Version("0.0.0")!, installState: .installed(path), selected: false, icon: nil) let second = repeatsSameItem ? first : Xcode( version: Version("0.0.1")!, - installState: .installed(try XCTUnwrap(Path("/Applications/Xcode-0.0.1.app"))), + installState: .installed(secondPath), selected: false, icon: nil ) @@ -1411,19 +1416,23 @@ class AppStateTests: XCTestCase { Current.defaults.bool = { key in key == PreferenceKey.usePrivilegeHelperForFileOperations.rawValue } Current.helper.checkIfLatestHelperIsInstalledAsync = { true } let continuations = TestLockedBox<[CheckedContinuation]>([]) + let firstStarted = expectation(description: "first uninstall reached helper") + let secondStarted = expectation(description: "second uninstall reached helper") Current.helper.removeAsync = { _ in try await withCheckedThrowingContinuation { continuation in - continuations.withValue { $0.append(continuation) } + let count = continuations.withValue { $0.append(continuation); return $0.count } + if count == 1 { firstStarted.fulfill() } + if count == 2 { secondStarted.fulfill() } } } subject.uninstall(xcode: first) let firstTask = try XCTUnwrap(subject.uninstallTask) - for _ in 0..<100 where continuations.read({ $0.count }) < 1 { await Task.yield() } + await fulfillment(of: [firstStarted], timeout: 5) XCTAssertEqual(continuations.read { $0.count }, 1) subject.uninstall(xcode: second) let secondTask = try XCTUnwrap(subject.uninstallTask) - for _ in 0..<100 where continuations.read({ $0.count }) < 2 { await Task.yield() } + await fulfillment(of: [secondStarted], timeout: 5) let pending = continuations.read { $0 } guard pending.count == 2 else { pending.forEach { $0.resume(throwing: CancellationError()) }