From 9765a2568c29e3672ef653b52f1a908283a4ff13 Mon Sep 17 00:00:00 2001 From: hstdt Date: Mon, 21 Sep 2026 17:41:24 +0800 Subject: [PATCH] Fix Xcode not showing as installed after successful installation Record completed installations in the installed Xcode cache before updating the row, and discard directory scans started before the cache changed. This prevents list recomposition from reverting a newly installed Xcode to not installed. Extend the existing Apple and Xcode Releases installation tests to verify that installed state survives available-list and selection updates. --- Xcodes/Backend/AppState+Install.swift | 1 + Xcodes/Backend/AppState.swift | 11 +++++++++++ XcodesTests/AppStateTests.swift | 13 +++++++++++++ 3 files changed, 25 insertions(+) diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index 02850d70..2ac53218 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -51,6 +51,7 @@ extension AppState { let (xcode, url) = try await getXcodeArchiveAsync(installationType, downloader: downloader) try Task.checkCancellation() let installedXcode = try await installArchivedXcodeAsync(xcode, at: url) + recordInstalledXcode(installedXcode) guard let index = allXcodes.firstIndex(where: { $0.version.isEquivalent(to: installedXcode.version) }) else { return installedXcode diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 6ac509bb..bebd5aa8 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -80,6 +80,7 @@ class AppState: ObservableObject { } } private var installedXcodes: [InstalledXcode] = [] + private var installedXcodesRevision = UUID() @Published var updateTask: Task? var updateTaskID: UUID? var isUpdating: Bool { updateTask != nil } @@ -934,14 +935,24 @@ class AppState: ObservableObject { } } + func recordInstalledXcode(_ xcode: InstalledXcode) { + // Invalidate scans started before this installation completed. + installedXcodesRevision = UUID() + installedXcodes.removeAll { $0.path == xcode.path } + installedXcodes.append(xcode) + } + @discardableResult func updateInstalledXcodesAsync(recomposeAllXcodes: Bool = true) async -> [InstalledXcode] { + let revision = UUID() + installedXcodesRevision = revision let installDirectory = Path.installDirectory let files = Current.files let installedXcodes = await Task.detached(priority: .userInitiated) { files.installedXcodes(installDirectory) }.value + guard installedXcodesRevision == revision else { return self.installedXcodes } self.installedXcodes = installedXcodes if recomposeAllXcodes { updateAllXcodes( diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index 2945b06c..16462e02 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -814,6 +814,18 @@ class AppStateTests: XCTestCase { [.installed(Path("/Applications/Xcode-0.0.0.app")!), .notInstalled, .notInstalled] ] ) + assertInstalledStateSurvivesRecomposition() + } + + private func assertInstalledStateSurvivesRecomposition() { + subject.availableXcodes = [ + AvailableXcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil) + ] + let path = Path("/Applications/Xcode-0.0.0.app")! + XCTAssertEqual(subject.allXcodes.first?.installState, .installed(path)) + subject.selectedXcodePath = path.string + "/Contents/Developer" + XCTAssertEqual(subject.allXcodes.first?.installState, .installed(path)) + XCTAssertEqual(subject.allXcodes.first?.selected, true) } private static func downloadableRuntime() throws -> DownloadableRuntime { @@ -971,6 +983,7 @@ class AppStateTests: XCTestCase { [.installed(Path("/Applications/Xcode-0.0.0.app")!), .notInstalled, .notInstalled] ] ) + assertInstalledStateSurvivesRecomposition() } func test_Install_NotEnoughFreeSpace() async throws {