Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion Sources/XcodesKit/Models/Xcodes/InstalledXcodeBundle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -58,3 +68,7 @@ public struct VersionPlist: Decodable, Sendable {
case productBuildVersion = "ProductBuildVersion"
}
}

public struct BetaVersionPlist: Decodable, Sendable {
public let seedNumber: String
}
82 changes: 82 additions & 0 deletions Tests/XcodesKitTests/InstalledXcodeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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("""
<dict>
<key>CFBundleIdentifier</key>
<string>com.apple.dt.Xcode</string>
<key>CFBundleShortVersionString</key>
<string>27.0</string>
<key>CFBundleIconName</key>
<string>XcodeBeta</string>
</dict>
""")
case "\(appPath)/Contents/version.plist":
return plistData("""
<dict>
<key>ProductBuildVersion</key>
<string>27A5252f</string>
</dict>
""")
case "\(appPath)/Contents/Resources/BetaVersion.plist":
return seedNumber.map { plistData("""
<dict>
<key>seedNumber</key>
<string>\($0)</string>
</dict>
""") }
default:
return nil
}
}
}

private static func plistData(_ body: String) -> Data {
Data("""
<?xml version="1.0" encoding="UTF-8"?>
Expand Down
28 changes: 28 additions & 0 deletions Tests/XcodesKitTests/XcodesKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down