From bde4fb2b0ad5cb470db8fe6338bf0527e83739da Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 08:04:57 +0200 Subject: [PATCH 1/3] Show platforms for installed Xcodes using the bundle's own SDKs The Platforms section matched downloadable runtimes against the SDK builds from the release metadata. The Apple data source has no SDK metadata, and an installed Xcode that isn't matched to its release (e.g. a renamed beta) has none either, so no platforms were shown. Installed Xcodes now read the ProductBuildVersion of each SDK in the bundle (cached per bundle and build) and prefer those builds, which are the same builds xcodereleases lists. Co-Authored-By: Claude Opus 5.5 --- Xcodes/Backend/AppState.swift | 6 +- Xcodes/Backend/Xcode.swift | 58 +++++++++++++++++++- Xcodes/Frontend/InfoPane/PlatformsView.swift | 6 +- XcodesTests/AppStateUpdateTests.swift | 32 +++++++++++ 4 files changed, 95 insertions(+), 7 deletions(-) diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 6ac509bb..a73e6750 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -930,7 +930,11 @@ class AppState: ObservableObject { ) self.allXcodes = items.map { item in - Xcode(item, icon: item.installedPath.map { NSWorkspace.shared.icon(forFile: $0.string) }) + Xcode( + item, + icon: item.installedPath.map { NSWorkspace.shared.icon(forFile: $0.string) }, + installedSDKBuilds: item.installedPath.map { InstalledSDKBuilds.builds(forXcodeAt: $0, version: item.version) } ?? [] + ) } } diff --git a/Xcodes/Backend/Xcode.swift b/Xcodes/Backend/Xcode.swift index 5d65313b..1123f0b6 100644 --- a/Xcodes/Backend/Xcode.swift +++ b/Xcodes/Backend/Xcode.swift @@ -20,6 +20,8 @@ struct Xcode: Identifiable, CustomStringConvertible { let compilers: Compilers? let downloadFileSize: Int64? let architectures: [Architecture]? + /// SDK build identifiers read from the installed bundle, used when the data source has no SDK metadata + let installedSDKBuilds: [String] let id: XcodeID init( @@ -34,7 +36,8 @@ struct Xcode: Identifiable, CustomStringConvertible { sdks: SDKs? = nil, compilers: Compilers? = nil, downloadFileSize: Int64? = nil, - architectures: [Architecture]? = nil + architectures: [Architecture]? = nil, + installedSDKBuilds: [String] = [] ) { self.identicalBuilds = identicalBuilds self.installState = installState @@ -47,10 +50,11 @@ struct Xcode: Identifiable, CustomStringConvertible { self.compilers = compilers self.downloadFileSize = downloadFileSize self.architectures = architectures + self.installedSDKBuilds = installedSDKBuilds self.id = XcodeID(version: version, architectures: architectures) } - init(_ item: XcodeListItem, icon: NSImage?) { + init(_ item: XcodeListItem, icon: NSImage?, installedSDKBuilds: [String] = []) { self.identicalBuilds = item.identicalBuilds self.installState = item.installState self.selected = item.selected @@ -62,6 +66,7 @@ struct Xcode: Identifiable, CustomStringConvertible { self.compilers = item.compilers self.downloadFileSize = item.downloadFileSize self.architectures = item.architectures + self.installedSDKBuilds = installedSDKBuilds self.id = item.id } @@ -96,5 +101,52 @@ struct Xcode: Identifiable, CustomStringConvertible { var installedPath: Path? { installState.installedPath } - + + /// SDK builds used to find matching platform runtimes. An installed bundle is the source of truth, + /// since the Apple data source has no SDK metadata and a renamed beta may not match its release. + var platformSDKBuilds: [String] { + installedSDKBuilds.isEmpty ? (sdks?.allBuilds ?? []) : installedSDKBuilds + } +} + +enum InstalledSDKBuilds { + private static let lock = NSLock() + nonisolated(unsafe) private static var cache: [String: [String]] = [:] + + /// Reads the ProductBuildVersion of each SDK in an installed Xcode, e.g. iPhoneOS.sdk -> 24A5422a. + static func builds(forXcodeAt path: Path, version: Version) -> [String] { + let key = "\(path.string)|\(version.buildMetadataIdentifiers.joined())" + lock.lock() + if let cached = cache[key] { + lock.unlock() + return cached + } + lock.unlock() + + let fileManager = FileManager.default + let platformsURL = path.url.appending(path: "Contents/Developer/Platforms") + let platformURLs = (try? fileManager.contentsOfDirectory(at: platformsURL, includingPropertiesForKeys: nil)) ?? [] + var builds: [String] = [] + for platformURL in platformURLs where platformURL.pathExtension == "platform" { + let sdksURL = platformURL.appending(path: "Developer/SDKs") + let sdkURLs = (try? fileManager.contentsOfDirectory(at: sdksURL, includingPropertiesForKeys: [.isSymbolicLinkKey])) ?? [] + for sdkURL in sdkURLs where sdkURL.pathExtension == "sdk" { + // Versioned SDK names (e.g. iPhoneOS27.0.sdk) are symlinks to the same SDK + if (try? sdkURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true { continue } + let systemVersionURL = sdkURL.appending(path: "System/Library/CoreServices/SystemVersion.plist") + guard + let data = Current.files.contents(atPath: systemVersionURL.path), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + let build = plist["ProductBuildVersion"] as? String, + !builds.contains(build) + else { continue } + builds.append(build) + } + } + + lock.lock() + cache[key] = builds + lock.unlock() + return builds + } } diff --git a/Xcodes/Frontend/InfoPane/PlatformsView.swift b/Xcodes/Frontend/InfoPane/PlatformsView.swift index 34af471e..1194ab52 100644 --- a/Xcodes/Frontend/InfoPane/PlatformsView.swift +++ b/Xcodes/Frontend/InfoPane/PlatformsView.swift @@ -17,12 +17,12 @@ struct PlatformsView: View { var body: some View { - let builds = xcode.sdks?.allBuilds - let availableRuntimes = (builds?.flatMap { sdkBuild in + let builds = xcode.platformSDKBuilds + let availableRuntimes = builds.flatMap { sdkBuild in appState.downloadableRuntimes.filter { $0.sdkBuildUpdate?.contains(sdkBuild) ?? false } - } ?? []).removingReleaseCandidateDisplayDuplicates(installedRuntimes: appState.installedRuntimes) + }.removingReleaseCandidateDisplayDuplicates(installedRuntimes: appState.installedRuntimes) let availableVariants = ArchitectureVariant.allCases.filter { variant in availableRuntimes.contains { $0.supports(variant) } diff --git a/XcodesTests/AppStateUpdateTests.swift b/XcodesTests/AppStateUpdateTests.swift index 299e4f01..1e71c7d6 100644 --- a/XcodesTests/AppStateUpdateTests.swift +++ b/XcodesTests/AppStateUpdateTests.swift @@ -78,6 +78,38 @@ class AppStateUpdateTests: XCTestCase { XCTAssertEqual(subject.selectedXcodePath, "/Applications/Xcode-Beta.app") } + func test_InstalledSDKBuilds_ReadsEachRealSDKOnce() throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let xcodeURL = root.appending(path: "Xcode.app") + func addSDK(platform: String, sdk: String, build: String) throws { + let coreServices = xcodeURL.appending(path: "Contents/Developer/Platforms/\(platform).platform/Developer/SDKs/\(sdk).sdk/System/Library/CoreServices") + try FileManager.default.createDirectory(at: coreServices, withIntermediateDirectories: true) + let plist = try PropertyListSerialization.data(fromPropertyList: ["ProductBuildVersion": build], format: .xml, options: 0) + try plist.write(to: coreServices.appending(path: "SystemVersion.plist")) + } + try addSDK(platform: "iPhoneOS", sdk: "iPhoneOS", build: "24A5422a") + try addSDK(platform: "iPhoneSimulator", sdk: "iPhoneSimulator", build: "24A5422a") + try addSDK(platform: "XROS", sdk: "XROS", build: "24M5357a") + // Versioned SDK names are symlinks to the real SDK and must be skipped + let sdksURL = xcodeURL.appending(path: "Contents/Developer/Platforms/XROS.platform/Developer/SDKs") + try FileManager.default.createSymbolicLink(at: sdksURL.appending(path: "XROS27.0.sdk"), withDestinationURL: sdksURL.appending(path: "XROS.sdk")) + Current.files.contentsAtPath = { FileManager.default.contents(atPath: $0) } + + let builds = InstalledSDKBuilds.builds(forXcodeAt: Path(url: xcodeURL)!, version: Version("27.0.0-beta.6+27A5252f")!) + + XCTAssertEqual(Set(builds), ["24A5422a", "24M5357a"]) + XCTAssertEqual(builds.count, 2) + } + + func test_PlatformSDKBuilds_PreferInstalledBundleOverReleaseMetadata() { + let xcode = Xcode(version: Version("27.0.0")!, installState: .notInstalled, selected: false, icon: nil, installedSDKBuilds: ["24A5422a"]) + XCTAssertEqual(xcode.platformSDKBuilds, ["24A5422a"]) + + let notInstalled = Xcode(version: Version("27.0.0")!, installState: .notInstalled, selected: false, icon: nil) + XCTAssertEqual(notInstalled.platformSDKBuilds, []) + } + func testDoesNotReplaceInstallState() throws { subject.allXcodes = [ Xcode(version: Version("0.0.0")!, installState: .installing(.unarchiving), selected: false, icon: nil) From 03e4dd3287871350f11603d0db5937daf9ef4815 Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 08:04:57 +0200 Subject: [PATCH 2/3] Fix duplicate and stale entries in the installed platforms window Runtimes were matched to installed simulators by build only, so a build published for both Apple Silicon and Universal was listed twice, and deleting the variant that isn't installed failed with "No simulator found". Use the architecture-aware lookup that deletion already uses. Also reload when the downloadable runtime list changes, not only the installed one, so the window isn't empty if it opens before the list has loaded. Co-Authored-By: Claude Opus 5.5 --- Xcodes/Frontend/Preferences/PlatformsListView.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Xcodes/Frontend/Preferences/PlatformsListView.swift b/Xcodes/Frontend/Preferences/PlatformsListView.swift index d789e8a5..89369559 100644 --- a/Xcodes/Frontend/Preferences/PlatformsListView.swift +++ b/Xcodes/Frontend/Preferences/PlatformsListView.swift @@ -58,12 +58,16 @@ struct PlatformsListView: View { .onChange(of: appState.installedRuntimes) { _ in loadRuntimes() } + .onChange(of: appState.downloadableRuntimes) { _ in + loadRuntimes() + } } func loadRuntimes() { + // Match on architecture too, so a build published as both Apple Silicon and Universal + // only lists the variant that is actually installed (and can therefore be deleted). let filteredRuntimes = appState.downloadableRuntimes.filter { runtime in - appState.installedRuntimes.contains { $0.runtimeInfo.build == runtime.simulatorVersion.buildUpdate - } + appState.coreSimulatorInfo(runtime: runtime) != nil } runtimes = OrderedDictionary(grouping: filteredRuntimes, by: { $0.platform }) } From b89829959d413abb6c46fbdfda92c01be20d25c6 Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 17:05:19 +0200 Subject: [PATCH 3/3] Use the two-parameter onChange(of:) in the platforms window Co-Authored-By: Claude Opus 5.5 --- Xcodes/Frontend/Preferences/PlatformsListView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Xcodes/Frontend/Preferences/PlatformsListView.swift b/Xcodes/Frontend/Preferences/PlatformsListView.swift index 89369559..28969516 100644 --- a/Xcodes/Frontend/Preferences/PlatformsListView.swift +++ b/Xcodes/Frontend/Preferences/PlatformsListView.swift @@ -55,10 +55,10 @@ struct PlatformsListView: View { .task { loadRuntimes() } - .onChange(of: appState.installedRuntimes) { _ in + .onChange(of: appState.installedRuntimes) { loadRuntimes() } - .onChange(of: appState.downloadableRuntimes) { _ in + .onChange(of: appState.downloadableRuntimes) { loadRuntimes() } }