field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit f1974d8f4e1461a2fb7547843ed43f95eac1609d
parent 20d4a9baf3366d0009fa24153a7757356df6c974
Author: triesap <tyson@radroots.org>
Date:   Sat,  8 Aug 2026 21:55:44 +0000

ios: unify blossom configuration authority

Diffstat:
MRadroots/App/RadrootsAppModel.swift | 23++++-------------------
MRadroots/Runtime/RadrootsAddMediaCoordinator.swift | 39++++++---------------------------------
MRadroots/Runtime/RadrootsGeneratedRuntimeBackend.swift | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
MRadroots/Runtime/RadrootsRuntimeClient.swift | 11+++++++++++
MRadroots/Runtime/RadrootsRuntimeModels.swift | 52+++++++++++++++++++++++++++++++++++++++++++++++++---
MRadroots/State/RadrootsAddStore.swift | 39+++++++++++++++++++++++++++++++++------
MRadroots/State/RadrootsConfigurationStore.swift | 183++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
MRadroots/State/RadrootsSessionStore.swift | 2+-
MRadroots/Views/RadrootsSupportingViews.swift | 50+++++++++++++++++++++++++++++++++++++++++++++++++-
MRadrootsTests/RadrootsAddStoreTests.swift | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
MRadrootsTests/RadrootsLifecycleTests.swift | 2++
MRadrootsTests/RadrootsRuntimeClientTests.swift | 9++++++++-
MRadrootsTests/RadrootsStateMigrationTests.swift | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
MRadrootsTests/RadrootsSupportingStoreTests.swift | 9++++++++-
MRadrootsTests/RadrootsTodayStoreTests.swift | 9++++++++-
15 files changed, 574 insertions(+), 152 deletions(-)

diff --git a/Radroots/App/RadrootsAppModel.swift b/Radroots/App/RadrootsAppModel.swift @@ -241,27 +241,10 @@ final class RadrootsAppModel: ObservableObject { } private static func productionMediaCoordinator(bundle: Bundle = .main) -> RadrootsAddMediaCoordinator? { - guard let bundleIdentifier = bundle.bundleIdentifier, - let origin = configuredValues("RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS", bundle: bundle).first, - let url = URL(string: origin) - else { + guard let bundleIdentifier = bundle.bundleIdentifier else { return nil } - return try? RadrootsAddMediaCoordinator.production( - bundleIdentifier: bundleIdentifier, - blossomOrigin: url - ) - } - - private static func configuredValues(_ key: String, bundle: Bundle) -> [String] { - if let values = bundle.object(forInfoDictionaryKey: key) as? [String] { - return values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - } - guard let value = bundle.object(forInfoDictionaryKey: key) as? String else { return [] } - return value.components(separatedBy: CharacterSet(charactersIn: ",; \n\r\t")) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } + return try? RadrootsAddMediaCoordinator.production(bundleIdentifier: bundleIdentifier) } } @@ -279,6 +262,8 @@ final class RadrootsAppModel: ObservableObject { writeAvailability: "unobserved", relays: [] ), + blossomConfiguration: nil, + blossomEvidence: nil, crateName: "radroots_mobile_ffi", crateVersion: "0.1.0-alpha", isClosed: false diff --git a/Radroots/Runtime/RadrootsAddMediaCoordinator.swift b/Radroots/Runtime/RadrootsAddMediaCoordinator.swift @@ -41,42 +41,24 @@ actor RadrootsAddMediaCoordinator: RadrootsAddMediaHandling { private let roots: RadrootsAppleFileRoots private let picker: any RadrootsMediaPicker private let preparer: RadrootsAppleMediaPreparer - private let blossomOrigin: URL init( roots: RadrootsAppleFileRoots, picker: any RadrootsMediaPicker, - preparer: RadrootsAppleMediaPreparer, - blossomOrigin: URL - ) throws { - guard let scheme = blossomOrigin.scheme?.lowercased(), - ["https", "http"].contains(scheme), - blossomOrigin.user == nil, - blossomOrigin.password == nil, - blossomOrigin.query == nil, - blossomOrigin.fragment == nil, - blossomOrigin.path.isEmpty || blossomOrigin.path == "/" - else { - throw RadrootsRuntimeFailure.local( - operation: "add.media.configure", - code: "ios.add.blossom_origin_invalid", - safeMessage: "The configured media server is invalid." - ) - } + preparer: RadrootsAppleMediaPreparer + ) { self.roots = roots self.picker = picker self.preparer = preparer - self.blossomOrigin = blossomOrigin } - static func production(bundleIdentifier: String, blossomOrigin: URL) throws -> Self { + static func production(bundleIdentifier: String) throws -> Self { let roots = try RadrootsAppleFileRoots.appContainer(appIdentifier: bundleIdentifier) let fileAccess = RadrootsAppleFileAccess(roots: roots) - return try Self( + return Self( roots: roots, picker: RadrootsAppleMediaPicker(fileAccess: fileAccess), - preparer: RadrootsAppleMediaPreparer(roots: roots), - blossomOrigin: blossomOrigin + preparer: RadrootsAppleMediaPreparer(roots: roots) ) } @@ -153,18 +135,9 @@ actor RadrootsAddMediaCoordinator: RadrootsAddMediaHandling { let prepared = try await preparer.prepareImage( RadrootsAppleImagePreparationRequest(source: .file(asset.file)) ) - guard let url = URL( - string: "\(blossomOrigin.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")))/\(prepared.sha256).png" - ) else { - throw RadrootsRuntimeFailure.local( - operation: "add.media.prepare", - code: "ios.add.media_url_invalid", - safeMessage: "The media destination could not be prepared." - ) - } return RadrootsPreparedMedia( opaqueReference: "media:\(prepared.sha256)", - url: url.absoluteString, + remoteURL: nil, sha256: prepared.sha256, mediaType: "image/png", byteSize: UInt64(prepared.file.sizeBytes), diff --git a/Radroots/Runtime/RadrootsGeneratedRuntimeBackend.swift b/Radroots/Runtime/RadrootsGeneratedRuntimeBackend.swift @@ -101,6 +101,8 @@ private final class RadrootsGeneratedRuntimeBackend: RadrootsRuntimeBackend, @un do { let identity = try runtime.identityStatus() let relay = try runtime.sdkRelayStatus() + let blossomConfiguration = try runtime.sdkBlossomConfiguration() + let blossomEvidence = try runtime.sdkBlossomEvidence() let info = runtime.info() return RadrootsRuntimeSnapshot( identity: RadrootsRuntimeIdentity( @@ -127,6 +129,8 @@ private final class RadrootsGeneratedRuntimeBackend: RadrootsRuntimeBackend, @un } ) }, + blossomConfiguration: blossomConfiguration?.appValue, + blossomEvidence: blossomEvidence?.appValue, crateName: info.sdk.crateName, crateVersion: info.sdk.crateVersion, isClosed: info.sdkClosed @@ -322,6 +326,14 @@ private final class RadrootsGeneratedRuntimeBackend: RadrootsRuntimeBackend, @un } } + func probeBlossom() async throws -> RadrootsBlossomEvidence { + do { + return try await runtime.probeBlossom().appValue + } catch { + throw Self.failure(from: error) + } + } + func advanceDraft(id: String, expectedRevision: UInt64) async throws -> RadrootsDraftStatus { do { return try await runtime.phase1AdvanceDraft( @@ -414,19 +426,18 @@ private final class RadrootsGeneratedRuntimeBackend: RadrootsRuntimeBackend, @un switch configuration.networkProfile { case .publicNetwork: try runtime.configurePublicRelays(writableRelays: configuration.writableRelays) - if !configuration.blossomOrigins.isEmpty { - try runtime.configurePublicBlossom(origins: configuration.blossomOrigins) - } case .simulator: try runtime.configureSimulatorRelays(loopbackRelays: configuration.writableRelays) - if !configuration.blossomOrigins.isEmpty { - try runtime.configureSimulatorBlossom(origins: configuration.blossomOrigins) - } case .device: try runtime.configureDeviceRelays(writableRelays: configuration.writableRelays) - if !configuration.blossomOrigins.isEmpty { - try runtime.configureDeviceBlossom(origins: configuration.blossomOrigins) - } + } + if let blossom = configuration.blossom { + try runtime.configureBlossom( + hostKind: blossom.hostKind.generatedValue, + endpointAuthority: blossom.endpointAuthority.generatedValue, + primaryOrigin: blossom.primaryOrigin, + fallbackOrigins: blossom.fallbackOrigins + ) } let backend = RadrootsGeneratedRuntimeBackend(runtime: runtime) @@ -525,6 +536,59 @@ private extension RadrootsProtectedDataState { } } +private extension RadrootsBlossomHostKind { + var generatedValue: FfiBlossomHostKind { + switch self { + case .native: .native + case .simulator: .simulator + case .physicalDevice: .physicalDevice + } + } +} + +private extension RadrootsBlossomEndpointAuthority { + var generatedValue: FfiBlossomEndpointAuthority { + switch self { + case .publicWebPKI: .publicWebPki + case .loopbackDevelopment: .loopbackDevelopment + case .privateNetworkDevelopment: .privateNetworkDevelopment + } + } +} + +private extension FfiBlossomConfigurationRecord { + var appValue: RadrootsBlossomConfigurationStatus { + RadrootsBlossomConfigurationStatus( + schemaVersion: schemaVersion, + hostKind: hostKind, + endpointAuthority: endpointAuthority, + primaryOrigin: primaryOrigin, + fallbackOrigins: fallbackOrigins, + configFingerprint: configFingerprint + ) + } +} + +private extension FfiBlossomEvidenceRecord { + var appValue: RadrootsBlossomEvidence { + RadrootsBlossomEvidence( + schemaVersion: schemaVersion, + origin: origin, + configFingerprint: configFingerprint, + state: state, + lastSuccessfulState: lastSuccessfulState, + transportSecurity: transportSecurity, + observedAtUnixMilliseconds: observedAtUnixMs, + httpStatus: httpStatus, + errorCode: errorCode, + errorPhase: errorPhase, + retryable: retryable, + possibleOrphan: possibleOrphan, + attempts: attempts + ) + } +} + private extension FfiRuntimeChangeKind { var appValue: RadrootsRuntimeChangeKind { switch self { @@ -866,7 +930,6 @@ private extension RadrootsPreparedMediaHandle { schemaVersion: 1, opaqueReference: media.opaqueReference, fileDescriptor: fileDescriptor, - url: media.url, sha256: media.sha256, mediaType: media.mediaType, byteSize: media.byteSize, @@ -882,7 +945,7 @@ private extension FfiDraftFormMediaRecord { var appValue: RadrootsPreparedMedia { RadrootsPreparedMedia( opaqueReference: opaqueReference, - url: url, + remoteURL: url, sha256: sha256, mediaType: mediaType, byteSize: byteSize, diff --git a/Radroots/Runtime/RadrootsRuntimeClient.swift b/Radroots/Runtime/RadrootsRuntimeClient.swift @@ -46,6 +46,7 @@ protocol RadrootsRuntimeBackend: Sendable { ) async throws -> RadrootsDraftStatus func recoverDraftQueue(id: String, recoveredAtUnixMilliseconds: UInt64) async throws -> RadrootsDraftStatus func uploadDraftMedia(input: RadrootsBlossomUploadInput) async throws -> RadrootsDraftStatus + func probeBlossom() async throws -> RadrootsBlossomEvidence func advanceDraft(id: String, expectedRevision: UInt64) async throws -> RadrootsDraftStatus func cancelDraft( id: String, @@ -127,6 +128,10 @@ extension RadrootsRuntimeBackend { throw addUnsupported() } + func probeBlossom() async throws -> RadrootsBlossomEvidence { + throw supportUnsupported() + } + func advanceDraft(id _: String, expectedRevision _: UInt64) async throws -> RadrootsDraftStatus { throw addUnsupported() } @@ -402,6 +407,12 @@ actor RadrootsRuntimeClient { } } + func probeBlossom() async throws -> RadrootsBlossomEvidence { + try await supportOperation("runtime.blossom.probe") { backend in + try await backend.probeBlossom() + } + } + func advanceDraft(id: String, expectedRevision: UInt64) async throws -> RadrootsDraftStatus { try await addOperation("runtime.add.advance") { backend in try await backend.advanceDraft(id: id, expectedRevision: expectedRevision) diff --git a/Radroots/Runtime/RadrootsRuntimeModels.swift b/Radroots/Runtime/RadrootsRuntimeModels.swift @@ -11,6 +11,50 @@ enum RadrootsRuntimeNetworkProfile: Sendable, Equatable { case device } +enum RadrootsBlossomHostKind: String, Codable, Sendable, Equatable { + case native + case simulator + case physicalDevice = "physical_device" +} + +enum RadrootsBlossomEndpointAuthority: String, Codable, Sendable, Equatable { + case publicWebPKI = "public_web_pki" + case loopbackDevelopment = "loopback_development" + case privateNetworkDevelopment = "private_network_development" +} + +struct RadrootsBlossomEndpointConfiguration: Codable, Sendable, Equatable { + let hostKind: RadrootsBlossomHostKind + let endpointAuthority: RadrootsBlossomEndpointAuthority + let primaryOrigin: String + let fallbackOrigins: [String] +} + +struct RadrootsBlossomConfigurationStatus: Sendable, Equatable { + let schemaVersion: UInt16 + let hostKind: String + let endpointAuthority: String + let primaryOrigin: String + let fallbackOrigins: [String] + let configFingerprint: String +} + +struct RadrootsBlossomEvidence: Sendable, Equatable { + let schemaVersion: UInt16 + let origin: String + let configFingerprint: String + let state: String + let lastSuccessfulState: String + let transportSecurity: String + let observedAtUnixMilliseconds: UInt64? + let httpStatus: UInt16? + let errorCode: String? + let errorPhase: String? + let retryable: Bool + let possibleOrphan: Bool + let attempts: UInt8 +} + struct RadrootsRuntimeAppMetadata: Sendable, Equatable { let bundleIdentifier: String let version: String @@ -63,7 +107,7 @@ struct RadrootsRuntimeLaunchConfiguration: Sendable { let protectedData: RadrootsProtectedDataState let networkProfile: RadrootsRuntimeNetworkProfile let writableRelays: [String] - let blossomOrigins: [String] + let blossom: RadrootsBlossomEndpointConfiguration? let app: RadrootsRuntimeAppMetadata let signerGeneration: String let signer: any RadrootsRuntimeSigner @@ -79,7 +123,7 @@ extension RadrootsRuntimeLaunchConfiguration: Equatable { && lhs.protectedData == rhs.protectedData && lhs.networkProfile == rhs.networkProfile && lhs.writableRelays == rhs.writableRelays - && lhs.blossomOrigins == rhs.blossomOrigins + && lhs.blossom == rhs.blossom && lhs.app == rhs.app && lhs.signerGeneration == rhs.signerGeneration } @@ -112,6 +156,8 @@ struct RadrootsRelayStatus: Sendable, Equatable { struct RadrootsRuntimeSnapshot: Sendable, Equatable { let identity: RadrootsRuntimeIdentity let relay: RadrootsRelayStatus? + let blossomConfiguration: RadrootsBlossomConfigurationStatus? + let blossomEvidence: RadrootsBlossomEvidence? let crateName: String let crateVersion: String let isClosed: Bool @@ -539,7 +585,7 @@ enum RadrootsEventTiming: String, Sendable, Equatable, Hashable, CaseIterable, I struct RadrootsPreparedMedia: Sendable, Equatable, Hashable, Identifiable { let opaqueReference: String - let url: String + let remoteURL: String? let sha256: String let mediaType: String let byteSize: UInt64 diff --git a/Radroots/State/RadrootsAddStore.swift b/Radroots/State/RadrootsAddStore.swift @@ -15,6 +15,9 @@ final class RadrootsAddStore: ObservableObject { @Published private(set) var form: RadrootsAddForm @Published private(set) var state: RadrootsAddLoadState = .idle @Published private(set) var mediaSupport: RadrootsAddMediaSupport = .unavailable + @Published private(set) var blossomConfiguration: RadrootsBlossomConfigurationStatus? + @Published private(set) var blossomEvidence: RadrootsBlossomEvidence? + @Published private(set) var isCheckingBlossom = false @Published private(set) var isWorking = false @Published private(set) var message: String? @@ -65,6 +68,8 @@ final class RadrootsAddStore: ObservableObject { writableRelays = snapshot.relay?.relays .filter { $0.access != "read_only" } .map(\.url) ?? [] + blossomConfiguration = snapshot.blossomConfiguration + blossomEvidence = snapshot.blossomEvidence } func start() async { @@ -155,13 +160,21 @@ final class RadrootsAddStore: ObservableObject { } } - func refreshMediaSupport() async { + func checkPhotoService() async { + guard !isCheckingBlossom else { return } + guard blossomConfiguration != nil else { + mediaSupport = .unavailable + message = "No photo service is configured for the current network profile." + return + } + isCheckingBlossom = true + defer { isCheckingBlossom = false } do { + blossomEvidence = try await runtimeClient.probeBlossom() mediaSupport = try await loadMediaSupport() - message = mediaSupport == .unavailable - ? "Photo intake is unavailable for the current configuration." - : "Photo service is ready." + message = "Photo service is reachable." } catch { + await refreshBlossomSnapshot() mediaSupport = .unavailable message = Self.message(for: error) } @@ -333,7 +346,11 @@ final class RadrootsAddStore: ObservableObject { let opened = try await openedMedia(form.media) defer { opened.close() } for mediaStatus in status.media where mediaStatus.stage != .verified { - guard let handle = opened.handles.first(where: { $0.media.url == mediaStatus.url }) else { + guard let persisted = form.media.first(where: { $0.remoteURL == mediaStatus.url }), + let handle = opened.handles.first(where: { + $0.media.opaqueReference == persisted.opaqueReference + }) + else { throw RadrootsRuntimeFailure.local( operation: "add.media.upload", code: "ios.add.media_missing", @@ -358,6 +375,7 @@ final class RadrootsAddStore: ObservableObject { ) ) accept(status) + await refreshBlossomSnapshot() } return status } @@ -399,10 +417,16 @@ final class RadrootsAddStore: ObservableObject { } private func loadMediaSupport() async throws -> RadrootsAddMediaSupport { - guard let media else { return .unavailable } + guard blossomConfiguration != nil, let media else { return .unavailable } return try await media.support() } + private func refreshBlossomSnapshot() async { + guard let snapshot = try? await runtimeClient.snapshot() else { return } + blossomConfiguration = snapshot.blossomConfiguration + blossomEvidence = snapshot.blossomEvidence + } + private func startObservation() { observationTask = Task { [weak self, runtimeClient] in do { @@ -412,6 +436,9 @@ final class RadrootsAddStore: ObservableObject { if change.kind == .drafts || change.kind == .media { await self?.reloadDrafts() } + if change.kind == .media { + await self?.refreshBlossomSnapshot() + } } } catch { // Durable draft operations remain available without observation. diff --git a/Radroots/State/RadrootsConfigurationStore.swift b/Radroots/State/RadrootsConfigurationStore.swift @@ -20,7 +20,7 @@ enum RadrootsAppNetworkProfile: String, Codable, Sendable, Equatable { struct RadrootsAppConfiguration: Sendable, Equatable { let profile: RadrootsAppNetworkProfile let writableRelays: [String] - let blossomOrigins: [String] + let blossom: RadrootsBlossomEndpointConfiguration? let keychainServicePrefix: String let bundleIdentifier: String let appMetadata: RadrootsRuntimeAppMetadata @@ -67,7 +67,16 @@ extension RadrootsConfigurationError: LocalizedError { } actor RadrootsConfigurationStore { - private struct StoredConfiguration: Codable { + private struct StoredConfigurationV3: Codable { + static let format = "radroots_ios_configuration_v3" + + let format: String + let profile: RadrootsAppNetworkProfile + let writableRelays: [String] + let blossom: RadrootsBlossomEndpointConfiguration? + } + + private struct StoredConfigurationV2: Codable { static let format = "radroots_ios_configuration_v2" let format: String @@ -85,6 +94,10 @@ actor RadrootsConfigurationStore { private static let configurationFile = RadrootsFileReference( scope: .data, + relativePath: "settings/radroots_configuration_v3.json" + ) + private static let v2ConfigurationFile = RadrootsFileReference( + scope: .data, relativePath: "settings/radroots_configuration_v2.json" ) private static let legacyRelayFile = RadrootsFileReference( @@ -106,41 +119,60 @@ actor RadrootsConfigurationStore { func load() throws -> RadrootsAppConfiguration { let profile = try Self.profile(for: bootstrap.runtimeMode) - let stored = try readStoredConfiguration() - let selected: StoredConfiguration - if let stored { - guard stored.format == StoredConfiguration.format, stored.profile == profile else { + 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) + { + selected = stored + } else { + selected = Self.bootstrapConfiguration(bootstrap, profile: profile) + try persist(selected) + } + } else if let stored = try readV2Configuration() { + guard stored.format == StoredConfigurationV2.format else { throw RadrootsConfigurationError.corruptStoredConfiguration } - selected = stored + selected = if stored.profile == profile { + StoredConfigurationV3( + format: StoredConfigurationV3.format, + profile: profile, + writableRelays: stored.writableRelays, + blossom: Self.blossomConfiguration( + origins: stored.blossomOrigins, + profile: profile + ) + ) + } else { + Self.bootstrapConfiguration(bootstrap, profile: profile) + } + try persist(selected) } else if let legacy = try readLegacyConfiguration() { guard legacy.format == LegacyRelaySettings.format else { throw RadrootsConfigurationError.corruptStoredConfiguration } - selected = StoredConfiguration( - format: StoredConfiguration.format, + selected = StoredConfigurationV3( + format: StoredConfigurationV3.format, profile: profile, writableRelays: legacy.relays, - blossomOrigins: bootstrap.blossomOrigins + blossom: Self.blossomConfiguration( + origins: bootstrap.blossomOrigins, + profile: profile + ) ) try persist(selected) } else { - selected = StoredConfiguration( - format: StoredConfiguration.format, - profile: profile, - writableRelays: bootstrap.relayURLs, - blossomOrigins: bootstrap.blossomOrigins - ) + selected = Self.bootstrapConfiguration(bootstrap, profile: profile) + try persist(selected) } let relays = try RadrootsNetworkValidator.relays( selected.writableRelays, profile: profile ) - let origins = try RadrootsNetworkValidator.blossomOrigins( - selected.blossomOrigins, - profile: profile - ) guard !bootstrap.keychainServicePrefix.isEmpty, !bootstrap.bundleIdentifier.isEmpty else { @@ -149,7 +181,7 @@ actor RadrootsConfigurationStore { return RadrootsAppConfiguration( profile: profile, writableRelays: relays, - blossomOrigins: origins, + blossom: selected.blossom, keychainServicePrefix: bootstrap.keychainServicePrefix, bundleIdentifier: bootstrap.bundleIdentifier, appMetadata: bootstrap.appMetadata @@ -189,9 +221,17 @@ actor RadrootsConfigurationStore { } } - private func readStoredConfiguration() throws -> StoredConfiguration? { + private func readStoredConfiguration() throws -> StoredConfigurationV3? { guard let data = try read(Self.configurationFile) else { return nil } - guard let stored = try? JSONDecoder().decode(StoredConfiguration.self, from: data) else { + guard let stored = try? JSONDecoder().decode(StoredConfigurationV3.self, from: data) else { + throw RadrootsConfigurationError.corruptStoredConfiguration + } + return stored + } + + private func readV2Configuration() throws -> StoredConfigurationV2? { + guard let data = try read(Self.v2ConfigurationFile) else { return nil } + guard let stored = try? JSONDecoder().decode(StoredConfigurationV2.self, from: data) else { throw RadrootsConfigurationError.corruptStoredConfiguration } return stored @@ -220,7 +260,7 @@ actor RadrootsConfigurationStore { } } - private func persist(_ configuration: StoredConfiguration) throws { + private func persist(_ configuration: StoredConfigurationV3) throws { do { let data = try JSONEncoder.radroots.encode(configuration) try fileAccess.write(.inline(data), to: Self.configurationFile) @@ -237,6 +277,58 @@ actor RadrootsConfigurationStore { default: throw RadrootsConfigurationError.invalid("runtime_mode") } } + + private static func bootstrapConfiguration( + _ bootstrap: RadrootsConfigurationBootstrap, + profile: RadrootsAppNetworkProfile + ) -> StoredConfigurationV3 { + StoredConfigurationV3( + format: StoredConfigurationV3.format, + profile: profile, + writableRelays: bootstrap.relayURLs, + blossom: blossomConfiguration(origins: bootstrap.blossomOrigins, profile: profile) + ) + } + + private static func blossomConfiguration( + origins: [String], + profile: RadrootsAppNetworkProfile + ) -> RadrootsBlossomEndpointConfiguration? { + guard let primary = origins.first else { return nil } + let identity = blossomIdentity(profile: profile) + return RadrootsBlossomEndpointConfiguration( + hostKind: identity.hostKind, + endpointAuthority: identity.endpointAuthority, + primaryOrigin: primary, + fallbackOrigins: Array(origins.dropFirst()) + ) + } + + private static func blossomMatchesProfile( + _ configuration: RadrootsBlossomEndpointConfiguration?, + profile: RadrootsAppNetworkProfile + ) -> Bool { + guard let configuration else { return true } + let identity = blossomIdentity(profile: profile) + return configuration.hostKind == identity.hostKind + && configuration.endpointAuthority == identity.endpointAuthority + } + + private static func blossomIdentity( + profile: RadrootsAppNetworkProfile + ) -> ( + hostKind: RadrootsBlossomHostKind, + endpointAuthority: RadrootsBlossomEndpointAuthority + ) { + switch profile { + case .publicNetwork: + (.physicalDevice, .publicWebPKI) + case .simulator: + (.simulator, .loopbackDevelopment) + case .device: + (.physicalDevice, .privateNetworkDevelopment) + } + } } enum RadrootsNetworkValidator { @@ -268,49 +360,6 @@ enum RadrootsNetworkValidator { return output } - static func blossomOrigins( - _ values: [String], - profile: RadrootsAppNetworkProfile - ) throws -> [String] { - var output: [String] = [] - var seen = Set<String>() - for raw in values { - guard raw == raw.trimmingCharacters(in: .whitespacesAndNewlines), - raw.utf8.allSatisfy({ $0 < 128 }), - let components = URLComponents(string: raw), - components.user == nil, - components.password == nil, - components.query == nil, - components.fragment == nil, - components.path.isEmpty || components.path == "/", - let scheme = components.scheme?.lowercased(), - let host = components.host?.lowercased(), - components.port != 0 - else { - throw RadrootsConfigurationError.invalid("blossom_origin") - } - guard scheme == "https" || scheme == "http" && profile == .simulator, - hostAllowed(host, profile: profile) - else { - throw RadrootsConfigurationError.invalid("blossom_policy") - } - var canonical = "\(scheme)://\(hostForURL(host))" - if let port = components.port, - !((scheme == "https" && port == 443) || (scheme == "http" && port == 80)) - { - canonical += ":\(port)" - } - guard seen.insert(canonical).inserted else { - throw RadrootsConfigurationError.invalid("duplicate_blossom_origin") - } - output.append(canonical) - } - guard output.count <= 8 else { - throw RadrootsConfigurationError.invalid("too_many_blossom_origins") - } - return output - } - private static func relay( _ raw: String, profile: RadrootsAppNetworkProfile diff --git a/Radroots/State/RadrootsSessionStore.swift b/Radroots/State/RadrootsSessionStore.swift @@ -242,7 +242,7 @@ actor RadrootsSessionStore { protectedData: protectedData.isAvailable() ? .available : .unavailable, networkProfile: configuration.profile.runtimeValue, writableRelays: configuration.writableRelays, - blossomOrigins: configuration.blossomOrigins, + blossom: configuration.blossom, app: configuration.appMetadata, signerGeneration: signerGeneration, signer: signer diff --git a/Radroots/Views/RadrootsSupportingViews.swift b/Radroots/Views/RadrootsSupportingViews.swift @@ -334,9 +334,48 @@ struct RadrootsSettingsView: View { .accessibilityIdentifier("radroots.settings.retry.network") } Section("Blossom photos") { + if let configuration = addStore.blossomConfiguration { + LabeledContent("Origin", value: configuration.primaryOrigin) + .accessibilityIdentifier("radroots.settings.blossom.origin") + LabeledContent("Configuration", value: abbreviated(configuration.configFingerprint)) + .accessibilityIdentifier("radroots.settings.blossom.fingerprint") + } else { + Text("No photo service is configured for this network profile.") + .foregroundStyle(.secondary) + } + if let evidence = addStore.blossomEvidence { + LabeledContent("Service state", value: display(evidence.state)) + .accessibilityIdentifier("radroots.settings.blossom.state") + LabeledContent("Connection", value: display(evidence.transportSecurity)) + if evidence.lastSuccessfulState != "none" { + LabeledContent("Last success", value: display(evidence.lastSuccessfulState)) + } + if let status = evidence.httpStatus { + LabeledContent("HTTP status", value: String(status)) + } + if let errorCode = evidence.errorCode { + LabeledContent("Last error", value: display(errorCode)) + } + if evidence.attempts > 0 { + LabeledContent("Attempts", value: String(evidence.attempts)) + } + if evidence.possibleOrphan { + Text("The server may contain an upload whose verification did not complete.") + .foregroundStyle(.orange) + } + } LabeledContent("Photo library", value: addStore.mediaSupport.library ? "Ready" : "Unavailable") LabeledContent("Camera", value: addStore.mediaSupport.camera ? "Ready" : "Unavailable") - Button("Check photo service") { Task { await addStore.refreshMediaSupport() } } + Button { + Task { await addStore.checkPhotoService() } + } label: { + if addStore.isCheckingBlossom { + ProgressView() + } else { + Text("Check photo service") + } + } + .disabled(addStore.isCheckingBlossom || addStore.blossomConfiguration == nil) .accessibilityIdentifier("radroots.settings.retry.blossom") } Section("Runtime") { @@ -372,4 +411,13 @@ struct RadrootsSettingsView: View { guard key.count > 16 else { return key } return "\(key.prefix(8))…\(key.suffix(8))" } + + private func abbreviated(_ value: String) -> String { + guard value.count > 16 else { return value } + return "\(value.prefix(8))…\(value.suffix(8))" + } + + private func display(_ value: String) -> String { + value.replacingOccurrences(of: "_", with: " ") + } } diff --git a/RadrootsTests/RadrootsAddStoreTests.swift b/RadrootsTests/RadrootsAddStoreTests.swift @@ -20,11 +20,18 @@ final class RadrootsAddStoreTests: XCTestCase { if type == .createPhotoUpdate { await store.importPhotos() XCTAssertEqual(store.form.media.count, 1) + XCTAssertNil(store.form.media.first?.remoteURL) } await store.submit() XCTAssertEqual(store.activeDraft?.commandType, type) XCTAssertEqual(store.activeDraft?.state, .complete) XCTAssertEqual(store.activeDraft?.form, store.form) + if type == .createPhotoUpdate { + XCTAssertEqual( + store.form.media.first?.remoteURL, + "http://127.0.0.1:3000/\(String(repeating: "0", count: 64)).png" + ) + } } let frozen = store.form @@ -98,6 +105,22 @@ final class RadrootsAddStoreTests: XCTestCase { } @MainActor + func testPhotoServiceProbeSurfacesCanonicalEvidence() async throws { + let backend = AddBackend() + let client = try await Self.startedClient(backend) + let store = RadrootsAddStore(runtimeClient: client, media: AddMediaHarness()) + await store.configure(snapshot: backend.snapshot()) + + await store.checkPhotoService() + + XCTAssertEqual(store.blossomEvidence?.state, "reachable") + XCTAssertEqual(store.blossomEvidence?.configFingerprint, String(repeating: "f", count: 64)) + XCTAssertEqual(store.mediaSupport, .init(library: true, camera: true)) + XCTAssertEqual(store.message, "Photo service is reachable.") + _ = try await client.stop() + } + + @MainActor func testSuspendAndResumePreserveLateDurableDraftCompletion() async throws { let backend = AddBackend(saveDelayNanoseconds: 50_000_000) let client = try await Self.startedClient(backend) @@ -160,7 +183,12 @@ final class RadrootsAddStoreTests: XCTestCase { protectedData: .available, networkProfile: .simulator, writableRelays: ["ws://127.0.0.1:7447"], - blossomOrigins: ["http://127.0.0.1:3000"], + blossom: RadrootsBlossomEndpointConfiguration( + hostKind: .simulator, + endpointAuthority: .loopbackDevelopment, + primaryOrigin: "http://127.0.0.1:3000", + fallbackOrigins: [] + ), app: RadrootsRuntimeAppMetadata( bundleIdentifier: "org.radroots.add-tests", version: "0.1.0-alpha", @@ -220,7 +248,7 @@ private struct AddSigner: RadrootsRuntimeSigner { private actor AddMediaHarness: RadrootsAddMediaHandling { private let item = RadrootsPreparedMedia( opaqueReference: "media:\(String(repeating: "0", count: 64))", - url: "http://127.0.0.1:3000/\(String(repeating: "0", count: 64)).png", + remoteURL: nil, sha256: String(repeating: "0", count: 64), mediaType: "image/png", byteSize: 4, @@ -285,6 +313,15 @@ private actor AddBackend: RadrootsRuntimeBackend { ), ] ), + blossomConfiguration: RadrootsBlossomConfigurationStatus( + schemaVersion: 1, + hostKind: "simulator", + endpointAuthority: "loopback_development", + primaryOrigin: "http://127.0.0.1:3000", + fallbackOrigins: [], + configFingerprint: String(repeating: "f", count: 64) + ), + blossomEvidence: nil, crateName: "radroots_mobile_ffi", crateVersion: "0.1.0-alpha", isClosed: closed @@ -319,17 +356,32 @@ private actor AddBackend: RadrootsRuntimeBackend { if saveDelayNanoseconds > 0 { try await Task.sleep(nanoseconds: saveDelayNanoseconds) } + let savedMedia = input.form.media.map { + RadrootsPreparedMedia( + opaqueReference: $0.opaqueReference, + remoteURL: "http://127.0.0.1:3000/\($0.sha256).png", + sha256: $0.sha256, + mediaType: $0.mediaType, + byteSize: $0.byteSize, + width: $0.width, + height: $0.height, + alt: $0.alt, + preparedAtUnixSeconds: $0.preparedAtUnixSeconds + ) + } + var storedForm = input.form + storedForm.media = savedMedia let status = makeStatus( id: id, revision: (expectedRevision ?? 0) + 1, kind: .add, commandType: input.form.commandType, - form: input.form, - state: input.form.media.isEmpty ? .draft : .mediaPreparing, + form: storedForm, + state: savedMedia.isEmpty ? .draft : .mediaPreparing, updatedAt: persistedAtUnixMilliseconds, - media: input.form.media.map { + media: savedMedia.map { RadrootsDraftMediaStatus( - url: $0.url, + url: $0.remoteURL!, stage: .pending, uploadAttempts: 0, verifiedAtUnixMilliseconds: nil, @@ -343,6 +395,24 @@ private actor AddBackend: RadrootsRuntimeBackend { return status } + func probeBlossom() -> RadrootsBlossomEvidence { + RadrootsBlossomEvidence( + schemaVersion: 1, + origin: "http://127.0.0.1:3000", + configFingerprint: String(repeating: "f", count: 64), + state: "reachable", + lastSuccessfulState: "probe", + transportSecurity: "loopback_plaintext", + observedAtUnixMilliseconds: 1_800_000_000_000, + httpStatus: 404, + errorCode: nil, + errorPhase: nil, + retryable: false, + possibleOrphan: false, + attempts: 1 + ) + } + func saveRetractionDraft( id: String, input: RadrootsRetractionDraftInput, diff --git a/RadrootsTests/RadrootsLifecycleTests.swift b/RadrootsTests/RadrootsLifecycleTests.swift @@ -142,6 +142,8 @@ final class RadrootsLifecycleTests: XCTestCase { ), ] ), + blossomConfiguration: nil, + blossomEvidence: nil, crateName: "radroots_mobile_ffi", crateVersion: "0.1.0-alpha", isClosed: false diff --git a/RadrootsTests/RadrootsRuntimeClientTests.swift b/RadrootsTests/RadrootsRuntimeClientTests.swift @@ -148,7 +148,12 @@ final class RadrootsRuntimeClientTests: XCTestCase { protectedData: .available, networkProfile: .simulator, writableRelays: ["ws://127.0.0.1:7447"], - blossomOrigins: ["http://127.0.0.1:3000"], + blossom: RadrootsBlossomEndpointConfiguration( + hostKind: .simulator, + endpointAuthority: .loopbackDevelopment, + primaryOrigin: "http://127.0.0.1:3000", + fallbackOrigins: [] + ), app: RadrootsRuntimeAppMetadata( bundleIdentifier: "org.radroots.tests", version: "0.1.0-alpha", @@ -270,6 +275,8 @@ private actor TestRuntimeBackend: RadrootsRuntimeBackend { hostSignerConfigured: true ), relay: nil, + blossomConfiguration: nil, + blossomEvidence: nil, crateName: "radroots_mobile_ffi", crateVersion: "0.1.0-alpha", isClosed: false diff --git a/RadrootsTests/RadrootsStateMigrationTests.swift b/RadrootsTests/RadrootsStateMigrationTests.swift @@ -4,7 +4,7 @@ import RadrootsKit import XCTest final class RadrootsStateMigrationTests: XCTestCase { - func testRelayValidationMatchesRustProfiles() throws { + func testRelayValidationMatchesRuntimeProfiles() throws { XCTAssertEqual( try RadrootsNetworkValidator.relays( ["wss://radroots.org", "WSS://WRITE.EXAMPLE:443/"], @@ -75,7 +75,7 @@ final class RadrootsStateMigrationTests: XCTestCase { .inline(Data("not-json".utf8)), to: RadrootsFileReference( scope: .data, - relativePath: "settings/radroots_configuration_v2.json" + relativePath: "settings/radroots_configuration_v3.json" ) ) do { @@ -86,6 +86,133 @@ final class RadrootsStateMigrationTests: XCTestCase { } } + func testV2ConfigurationMigratesOnceToExplicitV3BlossomAuthority() async throws { + let fixture = try StateFixture() + defer { fixture.remove() } + let fileAccess = RadrootsAppleFileAccess(roots: fixture.roots) + try fileAccess.write( + .inline( + Data( + """ + {"format":"radroots_ios_configuration_v2","profile":"simulator","writableRelays":["ws://127.0.0.1:7447"],"blossomOrigins":["http://127.0.0.1:3000","http://localhost:3001"]} + """.utf8 + ) + ), + to: RadrootsFileReference( + scope: .data, + relativePath: "settings/radroots_configuration_v2.json" + ) + ) + let store = RadrootsConfigurationStore(bootstrap: fixture.bootstrap, roots: fixture.roots) + + let first = try await store.load() + let second = try await store.load() + + XCTAssertEqual(first, second) + XCTAssertEqual( + first.blossom, + RadrootsBlossomEndpointConfiguration( + hostKind: .simulator, + endpointAuthority: .loopbackDevelopment, + primaryOrigin: "http://127.0.0.1:3000", + fallbackOrigins: ["http://localhost:3001"] + ) + ) + let persisted = try configurationObject(fileAccess) + XCTAssertEqual(persisted["format"] as? String, "radroots_ios_configuration_v3") + let blossom = try XCTUnwrap(persisted["blossom"] as? [String: Any]) + XCTAssertEqual(blossom["hostKind"] as? String, "simulator") + XCTAssertEqual(blossom["endpointAuthority"] as? String, "loopback_development") + } + + func testStoredProfileDriftRecoversFromCurrentBootstrap() async throws { + let fixture = try StateFixture() + defer { fixture.remove() } + let fileAccess = RadrootsAppleFileAccess(roots: fixture.roots) + _ = try await RadrootsConfigurationStore( + bootstrap: fixture.bootstrap, + roots: fixture.roots + ).load() + let production = RadrootsConfigurationBootstrap( + runtimeMode: "production", + relayURLs: ["wss://write.example"], + blossomOrigins: ["https://blossom.example"], + keychainServicePrefix: fixture.bootstrap.keychainServicePrefix, + bundleIdentifier: fixture.bootstrap.bundleIdentifier, + appMetadata: fixture.bootstrap.appMetadata + ) + let store = RadrootsConfigurationStore(bootstrap: production, roots: fixture.roots) + + let recovered = try await store.load() + + XCTAssertEqual(recovered.profile, .publicNetwork) + XCTAssertEqual(recovered.writableRelays, ["wss://write.example"]) + XCTAssertEqual( + recovered.blossom, + RadrootsBlossomEndpointConfiguration( + hostKind: .physicalDevice, + endpointAuthority: .publicWebPKI, + primaryOrigin: "https://blossom.example", + fallbackOrigins: [] + ) + ) + let repeated = try await store.load() + XCTAssertEqual(repeated, recovered) + + let persisted = try configurationObject(fileAccess) + XCTAssertEqual(persisted["profile"] as? String, "public") + } + + func testStoredBlossomAuthorityDriftRecoversWithinProfile() async throws { + let fixture = try StateFixture() + defer { fixture.remove() } + let fileAccess = RadrootsAppleFileAccess(roots: fixture.roots) + try fileAccess.write( + .inline( + Data( + """ + {"format":"radroots_ios_configuration_v3","profile":"simulator","writableRelays":["ws://127.0.0.1:7447"],"blossom":{"hostKind":"physical_device","endpointAuthority":"public_web_pki","primaryOrigin":"https://wrong.example","fallbackOrigins":[]}} + """.utf8 + ) + ), + to: RadrootsFileReference( + scope: .data, + relativePath: "settings/radroots_configuration_v3.json" + ) + ) + let store = RadrootsConfigurationStore(bootstrap: fixture.bootstrap, roots: fixture.roots) + + let recovered = try await store.load() + + XCTAssertEqual( + recovered.blossom, + RadrootsBlossomEndpointConfiguration( + hostKind: .simulator, + endpointAuthority: .loopbackDevelopment, + primaryOrigin: "http://127.0.0.1:3000", + fallbackOrigins: [] + ) + ) + } + + private func configurationObject( + _ fileAccess: RadrootsAppleFileAccess + ) throws -> [String: Any] { + let source = try fileAccess.read( + RadrootsFileReference( + scope: .data, + relativePath: "settings/radroots_configuration_v3.json" + ), + mode: .inline + ) + guard case let .inline(data) = source, + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + throw RadrootsConfigurationError.persistenceFailed + } + return object + } + func testSourceGenerationAndVisualIdentitySurviveStoreRecreation() async throws { let fixture = try StateFixture() defer { fixture.remove() } diff --git a/RadrootsTests/RadrootsSupportingStoreTests.swift b/RadrootsTests/RadrootsSupportingStoreTests.swift @@ -88,7 +88,12 @@ final class RadrootsSupportingStoreTests: XCTestCase { protectedData: .available, networkProfile: .simulator, writableRelays: ["ws://127.0.0.1:7447"], - blossomOrigins: ["http://127.0.0.1:3000"], + blossom: RadrootsBlossomEndpointConfiguration( + hostKind: .simulator, + endpointAuthority: .loopbackDevelopment, + primaryOrigin: "http://127.0.0.1:3000", + fallbackOrigins: [] + ), app: RadrootsRuntimeAppMetadata( bundleIdentifier: "org.radroots.supporting-tests", version: "0.1.0-alpha", @@ -144,6 +149,8 @@ private actor SupportingBackend: RadrootsRuntimeBackend { hostSignerConfigured: true ), relay: nil, + blossomConfiguration: nil, + blossomEvidence: nil, crateName: "radroots_mobile_ffi", crateVersion: "0.1.0-alpha", isClosed: closed diff --git a/RadrootsTests/RadrootsTodayStoreTests.swift b/RadrootsTests/RadrootsTodayStoreTests.swift @@ -197,7 +197,12 @@ final class RadrootsTodayStoreTests: XCTestCase { protectedData: .available, networkProfile: .simulator, writableRelays: ["ws://127.0.0.1:7447"], - blossomOrigins: ["http://127.0.0.1:3000"], + blossom: RadrootsBlossomEndpointConfiguration( + hostKind: .simulator, + endpointAuthority: .loopbackDevelopment, + primaryOrigin: "http://127.0.0.1:3000", + fallbackOrigins: [] + ), app: RadrootsRuntimeAppMetadata( bundleIdentifier: "org.radroots.today-tests", version: "0.1.0-alpha", @@ -297,6 +302,8 @@ private actor TodayBackend: RadrootsRuntimeBackend { hostSignerConfigured: true ), relay: nil, + blossomConfiguration: nil, + blossomEvidence: nil, crateName: "radroots_mobile_ffi", crateVersion: "0.1.0-alpha", isClosed: closed