From dde7f888102e20806e9a93c4af762567d6a6849e Mon Sep 17 00:00:00 2001 From: Anand Biligiri Date: Sat, 29 Aug 2026 13:35:59 -0700 Subject: [PATCH 1/3] Make XcodeUnarchiveService.MoveItem async Allows callers to route the post-unarchive move into place through an async privileged-helper XPC call instead of only synchronous FileManager operations. --- Sources/XcodesKit/Services/XcodeUnarchiveService.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/XcodesKit/Services/XcodeUnarchiveService.swift b/Sources/XcodesKit/Services/XcodeUnarchiveService.swift index 6843ab2..dad6500 100644 --- a/Sources/XcodesKit/Services/XcodeUnarchiveService.swift +++ b/Sources/XcodesKit/Services/XcodeUnarchiveService.swift @@ -13,7 +13,7 @@ public enum XcodeUnarchiveStep: Equatable, Sendable { public struct XcodeUnarchiveService: Sendable { public typealias Unarchive = @Sendable (URL) async throws -> Void public typealias FileExists = @Sendable (String) -> Bool - public typealias MoveItem = @Sendable (URL, URL) throws -> Void + public typealias MoveItem = @Sendable (URL, URL) async throws -> Void public typealias RemoveItem = @Sendable (URL) throws -> Void public typealias StepChanged = @Sendable (XcodeUnarchiveStep) async -> Void @@ -81,9 +81,9 @@ public struct XcodeUnarchiveService: Sendable { let xcodeURL = source.deletingLastPathComponent().appendingPathComponent("Xcode.app") let xcodeBetaURL = source.deletingLastPathComponent().appendingPathComponent("Xcode-beta.app") if fileExists(xcodeURL.path) { - try moveItem(xcodeURL, destination) + try await moveItem(xcodeURL, destination) } else if fileExists(xcodeBetaURL.path) { - try moveItem(xcodeBetaURL, destination) + try await moveItem(xcodeBetaURL, destination) } return destination From 602110b2fb49b3d8506ba4c5b4786d2eb25ba441 Mon Sep 17 00:00:00 2001 From: Anand Biligiri Date: Sat, 29 Aug 2026 15:29:30 -0700 Subject: [PATCH 2/3] Add uninstalling install-state case Lets callers show progress feedback while a privileged-helper delete is in flight instead of leaving the row looking unchanged for the whole operation. XcodeListComposer now preserves .uninstalling across recomposes the same way it already preserves .installing, so a concurrent available-list refresh doesn't prematurely clear the in-progress state. --- Sources/XcodesKit/Models/XcodeInstallState.swift | 12 +++++++++++- Sources/XcodesKit/Services/XcodeListComposer.swift | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Sources/XcodesKit/Models/XcodeInstallState.swift b/Sources/XcodesKit/Models/XcodeInstallState.swift index e8e495a..ab4bfc3 100644 --- a/Sources/XcodesKit/Models/XcodeInstallState.swift +++ b/Sources/XcodesKit/Models/XcodeInstallState.swift @@ -16,6 +16,8 @@ public enum XcodeInstallState: Equatable, Sendable { case installing(XcodeInstallationStep) /// The Xcode is installed at the associated path. case installed(Path) + /// The Xcode at the associated path is being uninstalled. + case uninstalling(Path) /// Whether the state is ``notInstalled``. public var notInstalled: Bool { @@ -38,11 +40,19 @@ public enum XcodeInstallState: Equatable, Sendable { default: return false } } + /// Whether the state is ``uninstalling(_:)``. + public var uninstalling: Bool { + switch self { + case .uninstalling: return true + default: return false + } + } - /// The installed path when the state is ``installed(_:)``. + /// The installed path when the state is ``installed(_:)`` or ``uninstalling(_:)``. public var installedPath: Path? { switch self { case .installed(let path): return path + case .uninstalling(let path): return path default: return nil } } diff --git a/Sources/XcodesKit/Services/XcodeListComposer.swift b/Sources/XcodesKit/Services/XcodeListComposer.swift index e175ed8..2333188 100644 --- a/Sources/XcodesKit/Services/XcodeListComposer.swift +++ b/Sources/XcodesKit/Services/XcodeListComposer.swift @@ -32,7 +32,7 @@ public struct XcodeListComposer: Sendable { } let identicalBuilds = XcodeListService.identicalBuildIDs(for: availableXcode, in: availableXcodes) let existingXcodeInstallState = existingXcodes - .first { $0.id == availableXcode.xcodeID && $0.installState.installing }? + .first { $0.id == availableXcode.xcodeID && ($0.installState.installing || $0.installState.uninstalling) }? .installState let defaultXcodeInstallState: XcodeInstallState = installedXcode.map { .installed($0.path) } ?? .notInstalled From fbee0cd75d4ad6da77b2b2c6cf1a752deb80ff56 Mon Sep 17 00:00:00 2001 From: Anand Biligiri Date: Sat, 29 Aug 2026 15:36:04 -0700 Subject: [PATCH 3/3] Don't preserve .uninstalling state across list recomposes Preserving it (like .installing) meant the row never cleared back to .notInstalled after a real uninstall finished, since AppState's own post-uninstall updateInstalledXcodesAsync() recompute would just keep restoring the stale in-progress marker instead of the freshly-scanned disk state. --- Sources/XcodesKit/Services/XcodeListComposer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/XcodesKit/Services/XcodeListComposer.swift b/Sources/XcodesKit/Services/XcodeListComposer.swift index 2333188..e175ed8 100644 --- a/Sources/XcodesKit/Services/XcodeListComposer.swift +++ b/Sources/XcodesKit/Services/XcodeListComposer.swift @@ -32,7 +32,7 @@ public struct XcodeListComposer: Sendable { } let identicalBuilds = XcodeListService.identicalBuildIDs(for: availableXcode, in: availableXcodes) let existingXcodeInstallState = existingXcodes - .first { $0.id == availableXcode.xcodeID && ($0.installState.installing || $0.installState.uninstalling) }? + .first { $0.id == availableXcode.xcodeID && $0.installState.installing }? .installState let defaultXcodeInstallState: XcodeInstallState = installedXcode.map { .installed($0.path) } ?? .notInstalled