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/Xcodes/Frontend/Preferences/PlatformsListView.swift b/Xcodes/Frontend/Preferences/PlatformsListView.swift index d789e8a5..28969516 100644 --- a/Xcodes/Frontend/Preferences/PlatformsListView.swift +++ b/Xcodes/Frontend/Preferences/PlatformsListView.swift @@ -55,15 +55,19 @@ struct PlatformsListView: View { .task { loadRuntimes() } - .onChange(of: appState.installedRuntimes) { _ in + .onChange(of: appState.installedRuntimes) { + loadRuntimes() + } + .onChange(of: appState.downloadableRuntimes) { 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 }) } 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)