Skip to content
Merged
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
51 changes: 46 additions & 5 deletions Sources/XcodesKit/Models/Runtimes/Runtimes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ public struct DownloadableRuntime: Codable, Identifiable, Hashable, Sendable {
public var installState: RuntimeInstallState = .notInstalled
/// SDK build updates that map to this simulator runtime.
public var sdkBuildUpdate: [String]?
/// The beta seed number supplied by Apple's runtime index.
public var seedNumber: Int?

enum CodingKeys: CodingKey {
case category
Expand All @@ -62,15 +64,54 @@ public struct DownloadableRuntime: Codable, Identifiable, Hashable, Sendable {
case name
case authentication
case sdkBuildUpdate
case seedNumber
case architectures
}

/// The beta seed number parsed from the runtime identifier, when present.
/// The beta seed number supplied by Apple, or inferred from legacy runtime metadata.
public var betaNumber: Int? {
enum Regex { static let shared = try! NSRegularExpression(pattern: "b[0-9]+") }
guard var foundString = Regex.shared.firstString(in: identifier) else { return nil }
foundString.removeFirst()
return Int(foundString)!
seedNumber ?? legacyIdentifierBetaNumber ?? nameBetaNumber
}

private var legacyIdentifierBetaNumber: Int? {
// Apple's older runtime identifiers encoded the beta seed as a distinct component,
// for example `com.apple.dmg.iPhoneSimulatorSDK27_0_b3_1`.
//
// Newer runtimes can use UUID identifiers. Keep the match delimiter-bound so a
// random UUID fragment such as `7cb21db4a45b` is not misread as beta 21.
enum Regex {
static let shared = try! NSRegularExpression(
pattern: "(?:^|[_-])b([0-9]+)(?:[_-]|$)",
options: .caseInsensitive
)
}

let searchRange = NSRange(identifier.startIndex..., in: identifier)
guard
let match = Regex.shared.firstMatch(in: identifier, range: searchRange),
let betaRange = Range(match.range(at: 1), in: identifier)
else { return nil }

return Int(identifier[betaRange])
}

private var nameBetaNumber: Int? {
enum Regex {
static let shared = try! NSRegularExpression(
pattern: "\\bbeta(?:\\s+([0-9]+))?\\b",
options: .caseInsensitive
)
}

let searchRange = NSRange(name.startIndex..., in: name)
guard let match = Regex.shared.firstMatch(in: name, range: searchRange) else { return nil }

guard
match.range(at: 1).location != NSNotFound,
let betaRange = Range(match.range(at: 1), in: name)
else { return 1 }

return Int(name[betaRange])
}

/// The OS version plus beta suffix when this is a beta runtime.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,14 +141,18 @@ public struct RuntimeListPresentationService: Sendable {
}

public extension DownloadableRuntimesResponse {
/// Returns downloadable runtimes enriched with SDK build update mappings.
/// Returns downloadable runtimes enriched with SDK build and beta seed mappings.
func downloadablesWithSDKBuildUpdates() -> [DownloadableRuntime] {
downloadables.map { runtime in
var updatedRuntime = runtime
let mappings = sdkToSimulatorMappings.filter {
$0.simulatorBuildUpdate == runtime.simulatorVersion.buildUpdate
}
updatedRuntime.sdkBuildUpdate = mappings.map(\.sdkBuildUpdate)
updatedRuntime.seedNumber = sdkToSeedMappings.first {
$0.buildUpdate == runtime.simulatorVersion.buildUpdate &&
$0.platform == runtime.platform
}?.seedNumber
return updatedRuntime
}
}
Expand Down
94 changes: 94 additions & 0 deletions Tests/XcodesKitTests/DownloadableRuntimeBetaNumberTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import XCTest
@testable import XcodesKit

final class DownloadableRuntimeBetaNumberTests: XCTestCase {
func testUsesSeedMappingForUUIDRuntime() throws {
let response = DownloadableRuntimesResponse(
sdkToSimulatorMappings: [],
sdkToSeedMappings: [
SDKToSeedMapping(buildUpdate: "24A5423a", platform: .iOS, seedNumber: 6)
],
refreshInterval: 3600,
downloadables: [Self.runtime()],
version: "2"
)

let runtime = try XCTUnwrap(response.downloadablesWithSDKBuildUpdates().first)

XCTAssertEqual(runtime.betaNumber, 6)
XCTAssertEqual(runtime.completeVersion, "27.0-beta6")
XCTAssertEqual(runtime.visibleIdentifier, "iOS 27.0-beta6")
}

func testUUIDRuntimeFallsBackToNameWithoutMatchingSeedData() {
let runtime = Self.runtime()

XCTAssertEqual(runtime.betaNumber, 6)
XCTAssertNotEqual(runtime.betaNumber, 21)
}

func testStableUUIDContainingBFollowedByDigitsIsNotABeta() {
let runtime = Self.runtime(name: "iOS 27.0 Simulator Runtime")

XCTAssertNil(runtime.betaNumber)
XCTAssertEqual(runtime.visibleIdentifier, "iOS 27.0")
}

func testLegacyStructuredIdentifierStillProvidesBetaNumber() {
let runtime = Self.runtime(
identifier: "com.apple.dmg.iPhoneSimulatorSDK27_0_b3_1",
name: "iOS 27.0 Simulator Runtime"
)

XCTAssertEqual(runtime.betaNumber, 3)
}

func testUnnumberedBetaNameRepresentsFirstBeta() {
let runtime = Self.runtime(
identifier: "com.apple.dmg.iPhoneSimulatorSDK27_0_b1",
name: "iOS 27.0 beta Simulator Runtime"
)

XCTAssertEqual(runtime.betaNumber, 1)
}

func testSeedNumberSurvivesCacheCodingRoundTrip() throws {
var runtime = Self.runtime()
runtime.seedNumber = 6

let data = try JSONEncoder().encode([runtime])
let decodedRuntime = try XCTUnwrap(JSONDecoder().decode([DownloadableRuntime].self, from: data).first)

XCTAssertEqual(decodedRuntime.seedNumber, 6)
XCTAssertEqual(decodedRuntime.betaNumber, 6)
}

func testCacheWithoutSeedNumberStillDecodesAndUsesMetadataFallback() throws {
let data = try JSONEncoder().encode([Self.runtime()])
let decodedRuntime = try XCTUnwrap(JSONDecoder().decode([DownloadableRuntime].self, from: data).first)

XCTAssertNil(decodedRuntime.seedNumber)
XCTAssertEqual(decodedRuntime.betaNumber, 6)
}

private static func runtime(
identifier: String = "0218a56d-df74-59c7-a193-7cb21db4a45b",
name: String = "iOS 27.0 beta 6 Simulator Runtime"
) -> DownloadableRuntime {
DownloadableRuntime(
category: .simulator,
simulatorVersion: .init(buildUpdate: "24A5423a", version: "27.0"),
source: nil,
architectures: [.arm64],
dictionaryVersion: 2,
contentType: .cryptexDiskImage,
platform: .iOS,
identifier: identifier,
version: "27.0.0.6",
fileSize: 7_986_041_344,
hostRequirements: nil,
name: name,
authentication: DownloadableRuntime.Authentication.none
)
}
}
15 changes: 12 additions & 3 deletions Tests/XcodesKitTests/RuntimeListStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ final class RuntimeListStoreTests: XCTestCase {
XCTAssertEqual(store.downloadableRuntimes, [runtime])
}

func testUpdateFetchesAddsSDKBuildUpdatesAndSavesRuntimes() async throws {
func testUpdateAddsRuntimeMappingsAndSavesRuntimes() async throws {
let runtime = Self.downloadableRuntime(buildUpdate: "20A360")
let response = Self.downloadableResponse(
downloadables: [runtime],
Expand All @@ -31,6 +31,13 @@ final class RuntimeListStoreTests: XCTestCase {
sdkIdentifier: "com.apple.platform.iphonesimulator",
downloadableIdentifiers: nil
)
],
sdkToSeedMappings: [
SDKToSeedMapping(
buildUpdate: "20A360",
platform: .iOS,
seedNumber: 2
)
]
)
let savedRuntimes = RuntimeCacheSaveRecorder()
Expand All @@ -47,6 +54,7 @@ final class RuntimeListStoreTests: XCTestCase {
let runtimes = try await store.updateDownloadableRuntimes()

XCTAssertEqual(runtimes.map(\.sdkBuildUpdate), [["20A361"]])
XCTAssertEqual(runtimes.map(\.seedNumber), [2])
XCTAssertEqual(store.downloadableRuntimes, runtimes)
XCTAssertEqual(savedRuntimes.value, runtimes)
}
Expand All @@ -71,11 +79,12 @@ final class RuntimeListStoreTests: XCTestCase {

private static func downloadableResponse(
downloadables: [DownloadableRuntime],
sdkToSimulatorMappings: [SDKToSimulatorMapping] = []
sdkToSimulatorMappings: [SDKToSimulatorMapping] = [],
sdkToSeedMappings: [SDKToSeedMapping] = []
) -> DownloadableRuntimesResponse {
DownloadableRuntimesResponse(
sdkToSimulatorMappings: sdkToSimulatorMappings,
sdkToSeedMappings: [],
sdkToSeedMappings: sdkToSeedMappings,
refreshInterval: 0,
downloadables: downloadables,
version: "1"
Expand Down
Loading