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"))