diff --git a/Sources/XcodesKit/Models/Xcodes/InstalledXcodeBundle.swift b/Sources/XcodesKit/Models/Xcodes/InstalledXcodeBundle.swift
index d1d0be5..ab75479 100644
--- a/Sources/XcodesKit/Models/Xcodes/InstalledXcodeBundle.swift
+++ b/Sources/XcodesKit/Models/Xcodes/InstalledXcodeBundle.swift
@@ -24,7 +24,17 @@ public struct XcodeBundleInfo: Equatable, Sendable {
if let filenameVersion = Version(path.basename(dropExtension: true).replacingOccurrences(of: "Xcode-", with: "")) {
prereleaseIdentifiers = filenameVersion.prereleaseIdentifiers
} else if infoPlist.bundleIconName == "XcodeBeta", !prereleaseIdentifiers.contains("beta") {
- prereleaseIdentifiers = ["beta"]
+ // The filename no longer carries the prerelease (e.g. renamed to Xcode.app), so fall back to the
+ // seed number Xcode ships in BetaVersion.plist. Beta 1 is unnumbered to match the release lists.
+ let betaVersionPlistPath = path.join("Contents").join("Resources").join("BetaVersion.plist")
+ let seedNumber = contentsAtPath(betaVersionPlistPath.string)
+ .flatMap { try? PropertyListDecoder().decode(BetaVersionPlist.self, from: $0) }
+ .flatMap { Int($0.seedNumber) }
+ if let seedNumber, seedNumber > 1 {
+ prereleaseIdentifiers = ["beta", String(seedNumber)]
+ } else {
+ prereleaseIdentifiers = ["beta"]
+ }
}
self.path = path
@@ -58,3 +68,7 @@ public struct VersionPlist: Decodable, Sendable {
case productBuildVersion = "ProductBuildVersion"
}
}
+
+public struct BetaVersionPlist: Decodable, Sendable {
+ public let seedNumber: String
+}
diff --git a/Tests/XcodesKitTests/InstalledXcodeTests.swift b/Tests/XcodesKitTests/InstalledXcodeTests.swift
index 40af37a..4ae9797 100644
--- a/Tests/XcodesKitTests/InstalledXcodeTests.swift
+++ b/Tests/XcodesKitTests/InstalledXcodeTests.swift
@@ -153,6 +153,88 @@ final class InstalledXcodeTests: XCTestCase {
])
}
+ func testRenamedBetaReadsSeedNumberFromBetaVersionPlist() throws {
+ let path = try XCTUnwrap(Path("/Applications/Xcode.app"))
+
+ let xcode = try XCTUnwrap(InstalledXcode(
+ path: path,
+ contentsAtPath: Self.betaBundleContents(appPath: path.string, seedNumber: "6"),
+ loadArchitectures: { _ in (0, "arm64\n", "") }
+ ))
+
+ XCTAssertEqual(xcode.version, Version("27.0.0-beta.6+27A5252f"))
+ }
+
+ func testRenamedFirstBetaMatchesUnnumberedBetaConvention() throws {
+ let path = try XCTUnwrap(Path("/Applications/Xcode.app"))
+
+ let xcode = try XCTUnwrap(InstalledXcode(
+ path: path,
+ contentsAtPath: Self.betaBundleContents(appPath: path.string, seedNumber: "1"),
+ loadArchitectures: { _ in (0, "arm64\n", "") }
+ ))
+
+ XCTAssertEqual(xcode.version, Version("27.0.0-beta+27A5252f"))
+ }
+
+ func testRenamedBetaWithoutBetaVersionPlistFallsBackToUnnumberedBeta() throws {
+ let path = try XCTUnwrap(Path("/Applications/Xcode.app"))
+
+ let xcode = try XCTUnwrap(InstalledXcode(
+ path: path,
+ contentsAtPath: Self.betaBundleContents(appPath: path.string, seedNumber: nil),
+ loadArchitectures: { _ in (0, "arm64\n", "") }
+ ))
+
+ XCTAssertEqual(xcode.version, Version("27.0.0-beta+27A5252f"))
+ }
+
+ func testFilenameVersionTakesPrecedenceOverBetaVersionPlist() throws {
+ let path = try XCTUnwrap(Path("/Applications/Xcode-27.0.0-Beta.5.app"))
+
+ let xcode = try XCTUnwrap(InstalledXcode(
+ path: path,
+ contentsAtPath: Self.betaBundleContents(appPath: path.string, seedNumber: "6"),
+ loadArchitectures: { _ in (0, "arm64\n", "") }
+ ))
+
+ XCTAssertEqual(xcode.version, Version("27.0.0-Beta.5+27A5252f"))
+ }
+
+ private static func betaBundleContents(appPath: String, seedNumber: String?) -> InstalledXcode.ContentsAtPath {
+ { requestedPath in
+ switch requestedPath {
+ case "\(appPath)/Contents/Info.plist":
+ return plistData("""
+
+ CFBundleIdentifier
+ com.apple.dt.Xcode
+ CFBundleShortVersionString
+ 27.0
+ CFBundleIconName
+ XcodeBeta
+
+ """)
+ case "\(appPath)/Contents/version.plist":
+ return plistData("""
+
+ ProductBuildVersion
+ 27A5252f
+
+ """)
+ case "\(appPath)/Contents/Resources/BetaVersion.plist":
+ return seedNumber.map { plistData("""
+
+ seedNumber
+ \($0)
+
+ """) }
+ default:
+ return nil
+ }
+ }
+ }
+
private static func plistData(_ body: String) -> Data {
Data("""
diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift
index f66818e..eb7369d 100644
--- a/Tests/XcodesKitTests/XcodesKitTests.swift
+++ b/Tests/XcodesKitTests/XcodesKitTests.swift
@@ -1278,6 +1278,34 @@ final class XcodesKitTests: XCTestCase {
XCTAssertEqual(items.first?.selected, true)
}
+ func testXcodeListComposerMatchesRenamedBetaToItsSeedForAppleDataSource() throws {
+ let composer = XcodeListComposer()
+ let installedPath = try XCTUnwrap(Path("/Applications/Xcode.app"))
+ let url = try XCTUnwrap(URL(string: "https://apple.com/xcode.xip"))
+
+ let items = composer.compose(
+ availableXcodes: [
+ AvailableXcode(version: try XCTUnwrap(Version("27.0.0-beta.6")), url: url, filename: "mock.xip", releaseDate: nil),
+ AvailableXcode(version: try XCTUnwrap(Version("27.0.0-beta.5")), url: url, filename: "mock.xip", releaseDate: nil),
+ AvailableXcode(version: try XCTUnwrap(Version("27.0.0-beta")), url: url, filename: "mock.xip", releaseDate: nil),
+ ],
+ installedXcodes: [
+ InstalledXcode(path: installedPath, version: try XCTUnwrap(Version("27.0.0-beta.6+27A5252f")))
+ ],
+ selectedXcodePath: "\(installedPath.string)/Contents/Developer",
+ existingXcodes: [],
+ dataSource: .apple
+ )
+
+ XCTAssertEqual(items.map(\.version), [
+ try XCTUnwrap(Version("27.0.0-beta.6+27A5252f")),
+ try XCTUnwrap(Version("27.0.0-beta.5")),
+ try XCTUnwrap(Version("27.0.0-beta")),
+ ])
+ XCTAssertEqual(items.map(\.installState), [.installed(installedPath), .notInstalled, .notInstalled])
+ XCTAssertEqual(items.map(\.selected), [true, false, false])
+ }
+
func testXcodeListPresentationServiceBuildsAvailableRows() throws {
let selectedPath = try XCTUnwrap(Path("/Applications/Xcode-15.0.app"))
let installedPath = try XCTUnwrap(Path("/Applications/Xcode-14.0.app"))