commit 377bb8b3366381d4ca000e51a681d824b91473ce
parent f1974d8f4e1461a2fb7547843ed43f95eac1609d
Author: triesap <tyson@radroots.org>
Date: Sat, 8 Aug 2026 22:19:40 +0000
ios: make blossom reconfiguration explicit
Diffstat:
6 files changed, 386 insertions(+), 18 deletions(-)
diff --git a/Radroots/App/AppEntry.swift b/Radroots/App/AppEntry.swift
@@ -33,7 +33,10 @@ struct AppEntry: View {
retry: { Task { await appModel.retry() } },
createIdentity: { Task { await appModel.createIdentity() } },
unlockIdentity: { Task { await appModel.unlockIdentity() } },
- recoverIdentity: { Task { await appModel.recoverIdentity() } }
+ recoverIdentity: { Task { await appModel.recoverIdentity() } },
+ applyConfigurationReconfiguration: {
+ Task { await appModel.applyConfigurationReconfiguration() }
+ }
)
}
}
diff --git a/Radroots/App/RadrootsAppModel.swift b/Radroots/App/RadrootsAppModel.swift
@@ -55,11 +55,15 @@ final class RadrootsAppModel: ObservableObject {
} else {
do {
let runtimeClient = runtimeClient ?? .production()
- self.sessionStore = try .production(runtimeClient: runtimeClient)
+ let sessionStore = try RadrootsSessionStore.production(
+ runtimeClient: runtimeClient
+ )
+ let mediaCoordinator = try Self.productionMediaCoordinator()
+ self.sessionStore = sessionStore
todayStore = RadrootsTodayStore(runtimeClient: runtimeClient)
addStore = RadrootsAddStore(
runtimeClient: runtimeClient,
- media: Self.productionMediaCoordinator()
+ media: mediaCoordinator
)
searchStore = RadrootsSearchStore(runtimeClient: runtimeClient)
meStore = RadrootsMeStore(runtimeClient: runtimeClient)
@@ -118,6 +122,12 @@ final class RadrootsAppModel: ObservableObject {
}
}
+ func applyConfigurationReconfiguration() async {
+ await run(name: "configuration_reconfigure", showsStarting: true) { store in
+ await store.applyConfigurationReconfiguration()
+ }
+ }
+
func stop() async {
stopPresentationWork()
await run(name: "stop") { store in await store.stop() }
@@ -234,17 +244,20 @@ final class RadrootsAppModel: ObservableObject {
case .protectedDataUnavailable: "protected_data_unavailable"
case .recoveryRequired: "recovery_required"
case .corruptIdentity: "corrupt_identity"
+ case .configurationReconfigurationRequired: "configuration_reconfiguration_required"
case .running: "running"
case .stopped: "stopped"
case .failed: "failed"
}
}
- private static func productionMediaCoordinator(bundle: Bundle = .main) -> RadrootsAddMediaCoordinator? {
+ private static func productionMediaCoordinator(
+ bundle: Bundle = .main
+ ) throws -> RadrootsAddMediaCoordinator {
guard let bundleIdentifier = bundle.bundleIdentifier else {
- return nil
+ throw RadrootsConfigurationError.missing("bundle_identifier")
}
- return try? RadrootsAddMediaCoordinator.production(bundleIdentifier: bundleIdentifier)
+ return try RadrootsAddMediaCoordinator.production(bundleIdentifier: bundleIdentifier)
}
}
diff --git a/Radroots/State/RadrootsConfigurationStore.swift b/Radroots/State/RadrootsConfigurationStore.swift
@@ -1,4 +1,5 @@
import Darwin
+import CryptoKit
import Foundation
import RadrootsKit
import Security
@@ -24,6 +25,14 @@ struct RadrootsAppConfiguration: Sendable, Equatable {
let keychainServicePrefix: String
let bundleIdentifier: String
let appMetadata: RadrootsRuntimeAppMetadata
+ let generation: UInt64
+ let activationState: RadrootsConfigurationActivationState
+ let previousBlossomConfigFingerprint: String?
+}
+
+enum RadrootsConfigurationActivationState: String, Codable, Sendable, Equatable {
+ case current
+ case reconfigurationRequired = "reconfiguration_required"
}
struct RadrootsConfigurationBootstrap: Sendable, Equatable {
@@ -67,13 +76,24 @@ extension RadrootsConfigurationError: LocalizedError {
}
actor RadrootsConfigurationStore {
- private struct StoredConfigurationV3: Codable {
+ private struct StoredConfigurationV3: Codable, Equatable {
static let format = "radroots_ios_configuration_v3"
let format: String
let profile: RadrootsAppNetworkProfile
let writableRelays: [String]
let blossom: RadrootsBlossomEndpointConfiguration?
+ let generation: UInt64?
+ let activationState: RadrootsConfigurationActivationState?
+ let bootstrapFingerprint: String?
+ let canonicalBlossomConfigFingerprint: String?
+ let previousBlossomConfigFingerprint: String?
+ }
+
+ private struct BootstrapNetworkIdentity: Codable {
+ let profile: RadrootsAppNetworkProfile
+ let relayURLs: [String]
+ let blossomOrigins: [String]
}
private struct StoredConfigurationV2: Codable {
@@ -119,17 +139,34 @@ actor RadrootsConfigurationStore {
func load() throws -> RadrootsAppConfiguration {
let profile = try Self.profile(for: bootstrap.runtimeMode)
+ let bootstrapFingerprint = try Self.bootstrapFingerprint(bootstrap, profile: profile)
let selected: StoredConfigurationV3
if let stored = try readStoredConfiguration() {
guard stored.format == StoredConfigurationV3.format else {
throw RadrootsConfigurationError.corruptStoredConfiguration
}
- if stored.profile == profile,
- Self.blossomMatchesProfile(stored.blossom, profile: profile)
+ let upgraded = try Self.upgradeStoredConfiguration(
+ stored,
+ bootstrapFingerprint: bootstrapFingerprint
+ )
+ if upgraded.profile == profile,
+ Self.blossomMatchesProfile(upgraded.blossom, profile: profile),
+ upgraded.bootstrapFingerprint == bootstrapFingerprint
{
- selected = stored
+ selected = upgraded
+ if selected != stored {
+ try persist(selected)
+ }
} else {
- selected = Self.bootstrapConfiguration(bootstrap, profile: profile)
+ selected = Self.bootstrapConfiguration(
+ bootstrap,
+ profile: profile,
+ generation: (upgraded.generation ?? 0) + 1,
+ activationState: .reconfigurationRequired,
+ bootstrapFingerprint: bootstrapFingerprint,
+ previousBlossomConfigFingerprint: upgraded.canonicalBlossomConfigFingerprint
+ ?? upgraded.previousBlossomConfigFingerprint
+ )
try persist(selected)
}
} else if let stored = try readV2Configuration() {
@@ -144,10 +181,21 @@ actor RadrootsConfigurationStore {
blossom: Self.blossomConfiguration(
origins: stored.blossomOrigins,
profile: profile
- )
+ ),
+ generation: 1,
+ activationState: .current,
+ bootstrapFingerprint: bootstrapFingerprint,
+ canonicalBlossomConfigFingerprint: nil,
+ previousBlossomConfigFingerprint: nil
)
} else {
- Self.bootstrapConfiguration(bootstrap, profile: profile)
+ Self.bootstrapConfiguration(
+ bootstrap,
+ profile: profile,
+ generation: 1,
+ activationState: .reconfigurationRequired,
+ bootstrapFingerprint: bootstrapFingerprint
+ )
}
try persist(selected)
} else if let legacy = try readLegacyConfiguration() {
@@ -161,11 +209,22 @@ actor RadrootsConfigurationStore {
blossom: Self.blossomConfiguration(
origins: bootstrap.blossomOrigins,
profile: profile
- )
+ ),
+ generation: 1,
+ activationState: .current,
+ bootstrapFingerprint: bootstrapFingerprint,
+ canonicalBlossomConfigFingerprint: nil,
+ previousBlossomConfigFingerprint: nil
)
try persist(selected)
} else {
- selected = Self.bootstrapConfiguration(bootstrap, profile: profile)
+ selected = Self.bootstrapConfiguration(
+ bootstrap,
+ profile: profile,
+ generation: 1,
+ activationState: .current,
+ bootstrapFingerprint: bootstrapFingerprint
+ )
try persist(selected)
}
@@ -184,7 +243,63 @@ actor RadrootsConfigurationStore {
blossom: selected.blossom,
keychainServicePrefix: bootstrap.keychainServicePrefix,
bundleIdentifier: bootstrap.bundleIdentifier,
- appMetadata: bootstrap.appMetadata
+ appMetadata: bootstrap.appMetadata,
+ generation: selected.generation ?? 1,
+ activationState: selected.activationState ?? .current,
+ previousBlossomConfigFingerprint: selected.previousBlossomConfigFingerprint
+ )
+ }
+
+ func confirmCanonicalBlossomConfiguration(
+ _ canonical: RadrootsBlossomConfigurationStatus?,
+ expectedGeneration: UInt64
+ ) throws {
+ guard let stored = try readStoredConfiguration(),
+ stored.format == StoredConfigurationV3.format,
+ stored.generation == expectedGeneration,
+ let bootstrapFingerprint = stored.bootstrapFingerprint
+ else {
+ throw RadrootsConfigurationError.persistenceFailed
+ }
+ let canonicalBlossom: RadrootsBlossomEndpointConfiguration?
+ let canonicalFingerprint: String?
+ switch (stored.blossom, canonical) {
+ case (nil, nil):
+ canonicalBlossom = nil
+ canonicalFingerprint = nil
+ case let (.some(selected), .some(canonical)):
+ guard let hostKind = RadrootsBlossomHostKind(rawValue: canonical.hostKind),
+ let endpointAuthority = RadrootsBlossomEndpointAuthority(
+ rawValue: canonical.endpointAuthority
+ ),
+ hostKind == selected.hostKind,
+ endpointAuthority == selected.endpointAuthority,
+ Self.isFingerprint(canonical.configFingerprint)
+ else {
+ throw RadrootsConfigurationError.invalid("canonical_blossom_configuration")
+ }
+ canonicalBlossom = RadrootsBlossomEndpointConfiguration(
+ hostKind: hostKind,
+ endpointAuthority: endpointAuthority,
+ primaryOrigin: canonical.primaryOrigin,
+ fallbackOrigins: canonical.fallbackOrigins
+ )
+ canonicalFingerprint = canonical.configFingerprint
+ default:
+ throw RadrootsConfigurationError.invalid("canonical_blossom_configuration")
+ }
+ try persist(
+ StoredConfigurationV3(
+ format: StoredConfigurationV3.format,
+ profile: stored.profile,
+ writableRelays: stored.writableRelays,
+ blossom: canonicalBlossom,
+ generation: expectedGeneration,
+ activationState: .current,
+ bootstrapFingerprint: bootstrapFingerprint,
+ canonicalBlossomConfigFingerprint: canonicalFingerprint,
+ previousBlossomConfigFingerprint: nil
+ )
)
}
@@ -280,16 +395,97 @@ actor RadrootsConfigurationStore {
private static func bootstrapConfiguration(
_ bootstrap: RadrootsConfigurationBootstrap,
- profile: RadrootsAppNetworkProfile
+ profile: RadrootsAppNetworkProfile,
+ generation: UInt64,
+ activationState: RadrootsConfigurationActivationState,
+ bootstrapFingerprint: String,
+ previousBlossomConfigFingerprint: String? = nil
) -> StoredConfigurationV3 {
StoredConfigurationV3(
format: StoredConfigurationV3.format,
profile: profile,
writableRelays: bootstrap.relayURLs,
- blossom: blossomConfiguration(origins: bootstrap.blossomOrigins, profile: profile)
+ blossom: blossomConfiguration(origins: bootstrap.blossomOrigins, profile: profile),
+ generation: generation,
+ activationState: activationState,
+ bootstrapFingerprint: bootstrapFingerprint,
+ canonicalBlossomConfigFingerprint: nil,
+ previousBlossomConfigFingerprint: previousBlossomConfigFingerprint
+ )
+ }
+
+ private static func upgradeStoredConfiguration(
+ _ stored: StoredConfigurationV3,
+ bootstrapFingerprint: String
+ ) throws -> StoredConfigurationV3 {
+ if let generation = stored.generation,
+ let activationState = stored.activationState,
+ let persistedBootstrapFingerprint = stored.bootstrapFingerprint
+ {
+ guard generation > 0,
+ isFingerprint(persistedBootstrapFingerprint),
+ stored.canonicalBlossomConfigFingerprint.map(isFingerprint) ?? true,
+ stored.previousBlossomConfigFingerprint.map(isFingerprint) ?? true
+ else {
+ throw RadrootsConfigurationError.corruptStoredConfiguration
+ }
+ return StoredConfigurationV3(
+ format: stored.format,
+ profile: stored.profile,
+ writableRelays: stored.writableRelays,
+ blossom: stored.blossom,
+ generation: generation,
+ activationState: activationState,
+ bootstrapFingerprint: persistedBootstrapFingerprint,
+ canonicalBlossomConfigFingerprint: stored.canonicalBlossomConfigFingerprint,
+ previousBlossomConfigFingerprint: stored.previousBlossomConfigFingerprint
+ )
+ }
+ guard stored.generation == nil,
+ stored.activationState == nil,
+ stored.bootstrapFingerprint == nil,
+ stored.canonicalBlossomConfigFingerprint == nil,
+ stored.previousBlossomConfigFingerprint == nil
+ else {
+ throw RadrootsConfigurationError.corruptStoredConfiguration
+ }
+ return StoredConfigurationV3(
+ format: stored.format,
+ profile: stored.profile,
+ writableRelays: stored.writableRelays,
+ blossom: stored.blossom,
+ generation: 1,
+ activationState: .current,
+ bootstrapFingerprint: bootstrapFingerprint,
+ canonicalBlossomConfigFingerprint: nil,
+ previousBlossomConfigFingerprint: nil
)
}
+ private static func bootstrapFingerprint(
+ _ bootstrap: RadrootsConfigurationBootstrap,
+ profile: RadrootsAppNetworkProfile
+ ) throws -> String {
+ do {
+ let data = try JSONEncoder.radroots.encode(
+ BootstrapNetworkIdentity(
+ profile: profile,
+ relayURLs: bootstrap.relayURLs,
+ blossomOrigins: bootstrap.blossomOrigins
+ )
+ )
+ return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ } catch {
+ throw RadrootsConfigurationError.invalid("bootstrap_network_identity")
+ }
+ }
+
+ private static func isFingerprint(_ value: String) -> Bool {
+ value.count == 64
+ && value == value.lowercased()
+ && value.allSatisfy(\.isHexDigit)
+ }
+
private static func blossomConfiguration(
origins: [String],
profile: RadrootsAppNetworkProfile
diff --git a/Radroots/State/RadrootsSessionStore.swift b/Radroots/State/RadrootsSessionStore.swift
@@ -26,11 +26,17 @@ enum RadrootsSessionPhase: Sendable, Equatable {
case protectedDataUnavailable(RadrootsAppIdentity)
case recoveryRequired(RadrootsAppIdentity)
case corruptIdentity(RadrootsAppIdentity)
+ case configurationReconfigurationRequired(RadrootsConfigurationReconfigurationRequirement)
case running(RadrootsRuntimeSnapshot)
case stopped
case failed(RadrootsRuntimeFailure)
}
+struct RadrootsConfigurationReconfigurationRequirement: Sendable, Equatable {
+ let generation: UInt64
+ let previousBlossomConfigFingerprint: String?
+}
+
actor RadrootsSessionStore {
private let configurationStore: RadrootsConfigurationStore
private let identityStore: RadrootsIdentityStore
@@ -106,6 +112,14 @@ actor RadrootsSessionStore {
}
func start() async -> RadrootsSessionPhase {
+ await start(acceptingReconfiguration: false)
+ }
+
+ func applyConfigurationReconfiguration() async -> RadrootsSessionPhase {
+ await start(acceptingReconfiguration: true)
+ }
+
+ private func start(acceptingReconfiguration: Bool) async -> RadrootsSessionPhase {
generation &+= 1
let requestedGeneration = generation
phase = .starting
@@ -127,6 +141,18 @@ actor RadrootsSessionStore {
phase = .corruptIdentity(identity)
case .unlocked:
let configuration = try await configurationStore.load()
+ if configuration.activationState == .reconfigurationRequired,
+ !acceptingReconfiguration
+ {
+ phase = .configurationReconfigurationRequired(
+ RadrootsConfigurationReconfigurationRequirement(
+ generation: configuration.generation,
+ previousBlossomConfigFingerprint: configuration
+ .previousBlossomConfigFingerprint
+ )
+ )
+ return phase
+ }
phase = try await startRuntime(
configuration: configuration,
identity: identity,
@@ -252,6 +278,19 @@ actor RadrootsSessionStore {
_ = try? await runtimeClient.stop()
throw RadrootsRuntimeClientError.superseded
}
+ do {
+ try await configurationStore.confirmCanonicalBlossomConfiguration(
+ snapshot.blossomConfiguration,
+ expectedGeneration: configuration.generation
+ )
+ } catch {
+ _ = try? await runtimeClient.stop()
+ throw error
+ }
+ guard generation == requestedGeneration else {
+ _ = try? await runtimeClient.stop()
+ throw RadrootsRuntimeClientError.superseded
+ }
return .running(snapshot)
}
diff --git a/Radroots/Views/RuntimeStatusView.swift b/Radroots/Views/RuntimeStatusView.swift
@@ -6,6 +6,7 @@ struct RuntimeStatusView: View {
let createIdentity: () -> Void
let unlockIdentity: () -> Void
let recoverIdentity: () -> Void
+ let applyConfigurationReconfiguration: () -> Void
var body: some View {
NavigationStack {
@@ -33,6 +34,10 @@ struct RuntimeStatusView: View {
Button("Recover identity", action: recoverIdentity)
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("radroots.identity.recover")
+ } else if case .configurationReconfigurationRequired = phase {
+ Button("Apply network configuration", action: applyConfigurationReconfiguration)
+ .buttonStyle(.borderedProminent)
+ .accessibilityIdentifier("radroots.configuration.reconfigure")
} else if case .failed = phase {
Button("Retry", action: retry)
.buttonStyle(.borderedProminent)
@@ -54,6 +59,7 @@ struct RuntimeStatusView: View {
case .protectedDataUnavailable: "lock.iphone"
case .recoveryRequired: "wrench.and.screwdriver"
case .corruptIdentity: "exclamationmark.shield"
+ case .configurationReconfigurationRequired: "arrow.triangle.2.circlepath"
case .running: "checkmark.circle"
case .failed: "exclamationmark.triangle"
case .stopped: "pause.circle"
@@ -63,6 +69,7 @@ struct RuntimeStatusView: View {
private var symbolColor: Color {
switch phase {
case .failed, .corruptIdentity: .red
+ case .configurationReconfigurationRequired: .orange
case .running: .green
default: .accentColor
}
@@ -76,6 +83,7 @@ struct RuntimeStatusView: View {
case .protectedDataUnavailable: "Unlock this device"
case .recoveryRequired: "Recover your identity"
case .corruptIdentity: "Identity data needs repair"
+ case .configurationReconfigurationRequired: "Network configuration changed"
case .running: "Radroots is ready"
case .failed: "Radroots needs attention"
case .stopped: "Radroots is paused"
@@ -96,6 +104,12 @@ struct RuntimeStatusView: View {
identity.recoveryCode ?? "A previous identity operation needs recovery."
case let .corruptIdentity(identity):
identity.recoveryCode ?? "Stored identity state is corrupt; it was not treated as absent."
+ case let .configurationReconfigurationRequired(requirement):
+ if let fingerprint = requirement.previousBlossomConfigFingerprint {
+ "Review and apply the new network configuration. Existing uploads remain bound to configuration \(fingerprint.prefix(12))…."
+ } else {
+ "Review and apply the new network configuration. Existing local identity and drafts are preserved."
+ }
case let .running(snapshot):
"Runtime \(snapshot.crateVersion) is connected to your local data."
case let .failed(failure):
diff --git a/RadrootsTests/RadrootsStateMigrationTests.swift b/RadrootsTests/RadrootsStateMigrationTests.swift
@@ -123,6 +123,21 @@ final class RadrootsStateMigrationTests: XCTestCase {
let blossom = try XCTUnwrap(persisted["blossom"] as? [String: Any])
XCTAssertEqual(blossom["hostKind"] as? String, "simulator")
XCTAssertEqual(blossom["endpointAuthority"] as? String, "loopback_development")
+ XCTAssertEqual(persisted["activationState"] as? String, "current")
+ XCTAssertEqual(persisted["generation"] as? UInt64, 1)
+
+ let fingerprint = String(repeating: "a", count: 64)
+ try await store.confirmCanonicalBlossomConfiguration(
+ canonicalBlossomConfiguration(
+ primaryOrigin: "http://127.0.0.1:3000",
+ fallbackOrigins: ["http://localhost:3001"],
+ fingerprint: fingerprint
+ ),
+ expectedGeneration: first.generation
+ )
+ let confirmed = try configurationObject(fileAccess)
+ XCTAssertEqual(confirmed["canonicalBlossomConfigFingerprint"] as? String, fingerprint)
+ XCTAssertEqual(confirmed["activationState"] as? String, "current")
}
func testStoredProfileDriftRecoversFromCurrentBootstrap() async throws {
@@ -133,6 +148,15 @@ final class RadrootsStateMigrationTests: XCTestCase {
bootstrap: fixture.bootstrap,
roots: fixture.roots
).load()
+ let originalFingerprint = String(repeating: "b", count: 64)
+ let originalStore = RadrootsConfigurationStore(
+ bootstrap: fixture.bootstrap,
+ roots: fixture.roots
+ )
+ try await originalStore.confirmCanonicalBlossomConfiguration(
+ canonicalBlossomConfiguration(fingerprint: originalFingerprint),
+ expectedGeneration: 1
+ )
let production = RadrootsConfigurationBootstrap(
runtimeMode: "production",
relayURLs: ["wss://write.example"],
@@ -146,6 +170,9 @@ final class RadrootsStateMigrationTests: XCTestCase {
let recovered = try await store.load()
XCTAssertEqual(recovered.profile, .publicNetwork)
+ XCTAssertEqual(recovered.activationState, .reconfigurationRequired)
+ XCTAssertEqual(recovered.generation, 2)
+ XCTAssertEqual(recovered.previousBlossomConfigFingerprint, originalFingerprint)
XCTAssertEqual(recovered.writableRelays, ["wss://write.example"])
XCTAssertEqual(
recovered.blossom,
@@ -161,6 +188,67 @@ final class RadrootsStateMigrationTests: XCTestCase {
let persisted = try configurationObject(fileAccess)
XCTAssertEqual(persisted["profile"] as? String, "public")
+ XCTAssertEqual(persisted["activationState"] as? String, "reconfiguration_required")
+ XCTAssertNil(persisted["canonicalBlossomConfigFingerprint"])
+ XCTAssertEqual(
+ persisted["previousBlossomConfigFingerprint"] as? String,
+ originalFingerprint
+ )
+ }
+
+ func testBootstrapInputDriftRequiresExplicitGenerationAwareActivation() async throws {
+ let fixture = try StateFixture()
+ defer { fixture.remove() }
+ let original = RadrootsConfigurationStore(
+ bootstrap: fixture.bootstrap,
+ roots: fixture.roots
+ )
+ let first = try await original.load()
+ let originalFingerprint = String(repeating: "c", count: 64)
+ try await original.confirmCanonicalBlossomConfiguration(
+ canonicalBlossomConfiguration(fingerprint: originalFingerprint),
+ expectedGeneration: first.generation
+ )
+ let changedBootstrap = RadrootsConfigurationBootstrap(
+ runtimeMode: fixture.bootstrap.runtimeMode,
+ relayURLs: ["ws://127.0.0.1:7448"],
+ blossomOrigins: ["http://127.0.0.1:3001"],
+ keychainServicePrefix: fixture.bootstrap.keychainServicePrefix,
+ bundleIdentifier: fixture.bootstrap.bundleIdentifier,
+ appMetadata: fixture.bootstrap.appMetadata
+ )
+ let changed = RadrootsConfigurationStore(
+ bootstrap: changedBootstrap,
+ roots: fixture.roots
+ )
+
+ let pending = try await changed.load()
+
+ XCTAssertEqual(pending.activationState, .reconfigurationRequired)
+ XCTAssertEqual(pending.generation, first.generation + 1)
+ XCTAssertEqual(pending.previousBlossomConfigFingerprint, originalFingerprint)
+ XCTAssertEqual(pending.writableRelays, ["ws://127.0.0.1:7448"])
+ XCTAssertEqual(pending.blossom?.primaryOrigin, "http://127.0.0.1:3001")
+
+ let replacementFingerprint = String(repeating: "d", count: 64)
+ try await changed.confirmCanonicalBlossomConfiguration(
+ canonicalBlossomConfiguration(
+ primaryOrigin: "http://127.0.0.1:3001",
+ fingerprint: replacementFingerprint
+ ),
+ expectedGeneration: pending.generation
+ )
+ let active = try await changed.load()
+ XCTAssertEqual(active.activationState, .current)
+ XCTAssertEqual(active.generation, pending.generation)
+ XCTAssertNil(active.previousBlossomConfigFingerprint)
+ let persisted = try configurationObject(
+ RadrootsAppleFileAccess(roots: fixture.roots)
+ )
+ XCTAssertEqual(
+ persisted["canonicalBlossomConfigFingerprint"] as? String,
+ replacementFingerprint
+ )
}
func testStoredBlossomAuthorityDriftRecoversWithinProfile() async throws {
@@ -213,6 +301,21 @@ final class RadrootsStateMigrationTests: XCTestCase {
return object
}
+ private func canonicalBlossomConfiguration(
+ primaryOrigin: String = "http://127.0.0.1:3000",
+ fallbackOrigins: [String] = [],
+ fingerprint: String
+ ) -> RadrootsBlossomConfigurationStatus {
+ RadrootsBlossomConfigurationStatus(
+ schemaVersion: 1,
+ hostKind: "simulator",
+ endpointAuthority: "loopback_development",
+ primaryOrigin: primaryOrigin,
+ fallbackOrigins: fallbackOrigins,
+ configFingerprint: fingerprint
+ )
+ }
+
func testSourceGenerationAndVisualIdentitySurviveStoreRecreation() async throws {
let fixture = try StateFixture()
defer { fixture.remove() }