From 4071a4a2720dfc9a98849caa0de1b980cf0aa7df Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Tue, 8 Sep 2026 10:35:07 -0500 Subject: [PATCH] Fix beta numbers for UUID runtime identifiers --- .../XcodesKit/Models/Runtimes/Runtimes.swift | 51 +++++++++- .../RuntimeListPresentationService.swift | 6 +- .../DownloadableRuntimeBetaNumberTests.swift | 94 +++++++++++++++++++ .../RuntimeListStoreTests.swift | 15 ++- 4 files changed, 157 insertions(+), 9 deletions(-) create mode 100644 Tests/XcodesKitTests/DownloadableRuntimeBetaNumberTests.swift diff --git a/Sources/XcodesKit/Models/Runtimes/Runtimes.swift b/Sources/XcodesKit/Models/Runtimes/Runtimes.swift index d1e6821..9c922d1 100644 --- a/Sources/XcodesKit/Models/Runtimes/Runtimes.swift +++ b/Sources/XcodesKit/Models/Runtimes/Runtimes.swift @@ -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 @@ -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. diff --git a/Sources/XcodesKit/Services/RuntimeListPresentationService.swift b/Sources/XcodesKit/Services/RuntimeListPresentationService.swift index d009d3b..b614e42 100644 --- a/Sources/XcodesKit/Services/RuntimeListPresentationService.swift +++ b/Sources/XcodesKit/Services/RuntimeListPresentationService.swift @@ -141,7 +141,7 @@ 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 @@ -149,6 +149,10 @@ public extension DownloadableRuntimesResponse { $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 } } diff --git a/Tests/XcodesKitTests/DownloadableRuntimeBetaNumberTests.swift b/Tests/XcodesKitTests/DownloadableRuntimeBetaNumberTests.swift new file mode 100644 index 0000000..e9237d0 --- /dev/null +++ b/Tests/XcodesKitTests/DownloadableRuntimeBetaNumberTests.swift @@ -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 + ) + } +} diff --git a/Tests/XcodesKitTests/RuntimeListStoreTests.swift b/Tests/XcodesKitTests/RuntimeListStoreTests.swift index 17419bd..3477593 100644 --- a/Tests/XcodesKitTests/RuntimeListStoreTests.swift +++ b/Tests/XcodesKitTests/RuntimeListStoreTests.swift @@ -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], @@ -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() @@ -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) } @@ -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"