commit 9df573e320843630930caa90e478b6cf502cc5e7
parent 0e8ebef8087086f93d33e5ac6638c8e86f2033b3
Author: triesap <tyson@radroots.org>
Date: Fri, 28 Aug 2026 06:14:37 +0000
test(ios): qualify local social runtime
Diffstat:
16 files changed, 2203 insertions(+), 1311 deletions(-)
diff --git a/Package.resolved b/Package.resolved
@@ -1,12 +1,12 @@
{
- "originHash" : "d3f53e98235994a331e4e89ecab349c6a16904ee6548c208ff5ab644dc9d6a4c",
+ "originHash" : "2fd3a22c9447cdffbab12bb80ee311003c6a86bbf6a5facc00401faec6447012",
"pins" : [
{
"identity" : "apple_kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/radrootslabs/apple_kit.git",
"state" : {
- "revision" : "86f5548e9e00c2b7ad3c1d6837bff8bc41bd0f24"
+ "revision" : "e61386529422a6413a852f0233a196a88f4ded51"
}
},
{
diff --git a/Package.swift b/Package.swift
@@ -13,7 +13,7 @@ let package = Package(
dependencies: [
.package(
url: "https://github.com/radrootslabs/apple_kit.git",
- revision: "86f5548e9e00c2b7ad3c1d6837bff8bc41bd0f24"
+ revision: "e61386529422a6413a852f0233a196a88f4ded51"
),
],
targets: [
diff --git a/README.md b/README.md
@@ -55,6 +55,23 @@ not installed. `make clean` removes only rebuildable external build output and
the ignored XCFramework; it preserves tracked generated bindings, locks,
snapshots, project sources, and user files.
+The focused local-social scenario starts bounded loopback Nostr and Blossom
+fixtures, then exercises the real app stores and installed Rust FFI on one
+exact simulator. The test saves an unverified photo draft, relaunches, retries
+from the durable draft against the enabled fixture, publishes all five product
+types, and proves Today and the outbox survive another relaunch:
+
+```sh
+RADROOTS_IOS_UI_TEST_RUN_ID=local-social-example \
+cargo extbuild run -- scripts/xcode.sh local-social-ui-test \
+ 'platform=iOS Simulator,id=SIMULATOR-UDID' \
+ local-social-example
+```
+
+This harness is Debug-only, binds only explicit loopback ports, records bounded
+protocol evidence under the extbuild results root, and cannot become a
+production endpoint fallback.
+
## Package surface
`Package.swift` publishes the `RadrootsApp` library used by the generated Xcode
diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj
@@ -651,7 +651,7 @@
repositoryURL = "https://github.com/radrootslabs/apple_kit.git";
requirement = {
kind = revision;
- revision = 86f5548e9e00c2b7ad3c1d6837bff8bc41bd0f24;
+ revision = e61386529422a6413a852f0233a196a88f4ded51;
};
};
/* End XCRemoteSwiftPackageReference section */
diff --git a/Radroots.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Radroots.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,12 +1,12 @@
{
- "originHash" : "71a5f6d2f0d1ef8d7a25e44b7914b4327901c461e679864ca8dc17deff817975",
+ "originHash" : "184b785843a7de169c88e08e1158041b2087a653433ebd5bc29fd201325bf16e",
"pins" : [
{
"identity" : "apple_kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/radrootslabs/apple_kit.git",
"state" : {
- "revision" : "86f5548e9e00c2b7ad3c1d6837bff8bc41bd0f24"
+ "revision" : "e61386529422a6413a852f0233a196a88f4ded51"
}
},
{
diff --git a/Radroots/App/RadrootsRemoteQualification.swift b/Radroots/App/RadrootsRemoteQualification.swift
@@ -3,153 +3,165 @@ import RadrootsKit
import RadrootsKitBindings
struct RadrootsRemoteQualificationEnvironment: Sendable, Equatable {
- static let enabledKey = "RADROOTS_IOS_UI_TEST_REMOTE"
- static let runIDKey = "RADROOTS_IOS_UI_TEST_RUN_ID"
- static let relayURLsKey = "RADROOTS_IOS_UI_TEST_NOSTR_RELAY_URLS"
- static let blossomOriginsKey = "RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS"
- static let mediaRelativePathKey = "RADROOTS_IOS_UI_TEST_MEDIA_RELATIVE_PATH"
+ static let enabledKey = "RADROOTS_IOS_UI_TEST_REMOTE"
+ static let runIDKey = "RADROOTS_IOS_UI_TEST_RUN_ID"
+ static let relayURLsKey = "RADROOTS_IOS_UI_TEST_NOSTR_RELAY_URLS"
+ static let blossomOriginsKey = "RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS"
+ static let mediaRelativePathKey = "RADROOTS_IOS_UI_TEST_MEDIA_RELATIVE_PATH"
+ static let networkProfileKey = "RADROOTS_IOS_UI_TEST_NETWORK_PROFILE"
- let runID: String
- let relayURLs: [String]
- let blossomOrigins: [String]
- let mediaFile: RadrootsFileReference?
+ let runID: String
+ let relayURLs: [String]
+ let blossomOrigins: [String]
+ let mediaFile: RadrootsFileReference?
+ let runtimeMode: String
- var keychainServicePrefix: String {
- "org.radroots.ios.remote-qualification.\(runID)"
- }
+ var keychainServicePrefix: String {
+ "org.radroots.ios.remote-qualification.\(runID)"
+ }
- var identityMetadataKeyPrefix: String {
- "\(keychainServicePrefix).identity"
- }
+ var identityMetadataKeyPrefix: String {
+ "\(keychainServicePrefix).identity"
+ }
- #if DEBUG
- static func current(
- environment: [String: String] = ProcessInfo.processInfo.environment
- ) throws -> Self? {
- guard environment[enabledKey] == "1" else { return nil }
- let runID = try requiredRunID(environment[runIDKey])
- let relays = separatedValues(environment[relayURLsKey])
- let blossoms = separatedValues(environment[blossomOriginsKey])
- guard blossoms.count == 1 else {
- throw RadrootsConfigurationError.invalid("qualification_blossom_origin")
- }
- let mediaFile = try environment[mediaRelativePathKey].map { raw in
- guard raw == "qualification/input.png" else {
- throw RadrootsConfigurationError.invalid("qualification_media_file")
- }
- return RadrootsFileReference(scope: .data, relativePath: raw)
- }
- return Self(
- runID: runID,
- relayURLs: relays,
- blossomOrigins: blossoms,
- mediaFile: mediaFile
- )
+ #if DEBUG
+ static func current(
+ environment: [String: String] = ProcessInfo.processInfo.environment
+ ) throws -> Self? {
+ guard environment[enabledKey] == "1" else { return nil }
+ let runID = try requiredRunID(environment[runIDKey])
+ let relays = separatedValues(environment[relayURLsKey])
+ let blossoms = separatedValues(environment[blossomOriginsKey])
+ let runtimeMode: String
+ switch environment[networkProfileKey] {
+ case "public": runtimeMode = "production"
+ case "simulator": runtimeMode = "simulator"
+ default:
+ throw RadrootsConfigurationError.invalid("qualification_network_profile")
+ }
+ guard blossoms.count == 1 else {
+ throw RadrootsConfigurationError.invalid("qualification_blossom_origin")
+ }
+ let mediaFile = try environment[mediaRelativePathKey].map { raw in
+ guard raw == "qualification/input.png" else {
+ throw RadrootsConfigurationError.invalid("qualification_media_file")
}
+ return RadrootsFileReference(scope: .data, relativePath: raw)
+ }
+ return Self(
+ runID: runID,
+ relayURLs: relays,
+ blossomOrigins: blossoms,
+ mediaFile: mediaFile,
+ runtimeMode: runtimeMode
+ )
+ }
- private static func requiredRunID(_ raw: String?) throws -> String {
- guard let raw,
- (8 ... 64).contains(raw.utf8.count),
- raw == raw.lowercased(),
- raw.unicodeScalars.allSatisfy({
- CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-")
- .contains($0)
- }),
- raw.first != "-",
- raw.last != "-"
- else {
- throw RadrootsConfigurationError.invalid("qualification_run_id")
- }
- return raw
- }
+ private static func requiredRunID(_ raw: String?) throws -> String {
+ guard let raw,
+ (8...64).contains(raw.utf8.count),
+ raw == raw.lowercased(),
+ raw.unicodeScalars.allSatisfy({
+ CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-")
+ .contains($0)
+ }),
+ raw.first != "-",
+ raw.last != "-"
+ else {
+ throw RadrootsConfigurationError.invalid("qualification_run_id")
+ }
+ return raw
+ }
- private static func separatedValues(_ raw: String?) -> [String] {
- guard let raw else { return [] }
- return raw.components(separatedBy: CharacterSet(charactersIn: ",; \n\r\t"))
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
- .filter { !$0.isEmpty }
- }
- #else
- static func current(environment _: [String: String] = [:]) throws -> Self? {
- nil
- }
- #endif
+ private static func separatedValues(_ raw: String?) -> [String] {
+ guard let raw else { return [] }
+ return raw.components(separatedBy: CharacterSet(charactersIn: ",; \n\r\t"))
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ }
+ #else
+ static func current(environment _: [String: String] = [:]) throws -> Self? {
+ nil
+ }
+ #endif
}
#if DEBUG
- private struct RadrootsRemoteQualificationBlossomAuthorization: Codable {
- let schema: String
- let schemaVersion: UInt16
- let id: String
- let pubkey: String
- let createdAt: UInt64
- let kind: UInt32
- let tags: [[String]]
- let content: String
- let signature: String
+ private struct RadrootsRemoteQualificationBlossomAuthorization: Codable {
+ let schema: String
+ let schemaVersion: UInt16
+ let id: String
+ let pubkey: String
+ let createdAt: UInt64
+ let kind: UInt32
+ let tags: [[String]]
+ let content: String
+ let signature: String
- enum CodingKeys: String, CodingKey {
- case schema
- case schemaVersion = "schema_version"
- case id
- case pubkey
- case createdAt = "created_at"
- case kind
- case tags
- case content
- case signature = "sig"
- }
+ enum CodingKeys: String, CodingKey {
+ case schema
+ case schemaVersion = "schema_version"
+ case id
+ case pubkey
+ case createdAt = "created_at"
+ case kind
+ case tags
+ case content
+ case signature = "sig"
}
+ }
- enum RadrootsRemoteQualificationEvidence {
- static func recordBlossomAuthorization(
- request: HostSigningRequest,
- signatureHex: String
- ) throws {
- guard try RadrootsRemoteQualificationEnvironment.current() != nil else { return }
- let evidence = RadrootsRemoteQualificationBlossomAuthorization(
- schema: "radroots-ios-remote-qualification-bud11-v1",
- schemaVersion: 1,
- id: request.expectedEventId,
- pubkey: request.publicKey,
- createdAt: request.createdAtUnixS,
- kind: request.kind,
- tags: request.tags,
- content: request.content,
- signature: signatureHex
- )
- let encoder = JSONEncoder()
- encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
- let data = try encoder.encode(evidence)
- guard let documents = FileManager.default.urls(
- for: .documentDirectory,
- in: .userDomainMask
- ).first else {
- throw CocoaError(.fileNoSuchFile)
- }
- try data.write(
- to: documents.appendingPathComponent(
- "radroots-remote-qualification-bud11.json"
- ),
- options: .atomic
- )
- }
+ enum RadrootsRemoteQualificationEvidence {
+ static func recordBlossomAuthorization(
+ request: HostSigningRequest,
+ signatureHex: String
+ ) throws {
+ guard try RadrootsRemoteQualificationEnvironment.current() != nil else { return }
+ let evidence = RadrootsRemoteQualificationBlossomAuthorization(
+ schema: "radroots-ios-remote-qualification-bud11-v1",
+ schemaVersion: 1,
+ id: request.expectedEventId,
+ pubkey: request.publicKey,
+ createdAt: request.createdAtUnixS,
+ kind: request.kind,
+ tags: request.tags,
+ content: request.content,
+ signature: signatureHex
+ )
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ let data = try encoder.encode(evidence)
+ guard
+ let documents = FileManager.default.urls(
+ for: .documentDirectory,
+ in: .userDomainMask
+ ).first
+ else {
+ throw CocoaError(.fileNoSuchFile)
+ }
+ try data.write(
+ to: documents.appendingPathComponent(
+ "radroots-remote-qualification-bud11.json"
+ ),
+ options: .atomic
+ )
}
+ }
- final class RadrootsRemoteQualificationUserPresence: RadrootsUserPresence, Sendable {
- func currentStatus() async throws -> RadrootsUserPresenceStatus {
- RadrootsUserPresenceStatus(
- support: .deviceCredential,
- biometryKind: .none,
- canEvaluateDeviceCredential: true,
- canEvaluateBiometrics: false
- )
- }
+ final class RadrootsRemoteQualificationUserPresence: RadrootsUserPresence, Sendable {
+ func currentStatus() async throws -> RadrootsUserPresenceStatus {
+ RadrootsUserPresenceStatus(
+ support: .deviceCredential,
+ biometryKind: .none,
+ canEvaluateDeviceCredential: true,
+ canEvaluateBiometrics: false
+ )
+ }
- func verify(
- _ request: RadrootsUserPresenceRequest
- ) async throws -> RadrootsUserPresenceResult {
- RadrootsUserPresenceResult(policy: request.policy, verified: true)
- }
+ func verify(
+ _ request: RadrootsUserPresenceRequest
+ ) async throws -> RadrootsUserPresenceResult {
+ RadrootsUserPresenceResult(policy: request.policy, verified: true)
}
+ }
#endif
diff --git a/Radroots/State/RadrootsSessionStore.swift b/Radroots/State/RadrootsSessionStore.swift
@@ -3,492 +3,491 @@ import RadrootsKit
import UIKit
final class RadrootsProtectedDataMonitor: @unchecked Sendable {
- private let lock = NSLock()
- private var available: Bool
+ private let lock = NSLock()
+ private var available: Bool
- init(available: Bool) {
- self.available = available
- }
+ init(available: Bool) {
+ self.available = available
+ }
- func update(available: Bool) {
- lock.withLock { self.available = available }
- }
+ func update(available: Bool) {
+ lock.withLock { self.available = available }
+ }
- func isAvailable() -> Bool {
- lock.withLock { available }
- }
+ func isAvailable() -> Bool {
+ lock.withLock { available }
+ }
}
enum RadrootsSessionPhase: Sendable, Equatable {
- case starting
- case identityRequired
- case identityLocked(RadrootsAppIdentity)
- case protectedDataUnavailable(RadrootsAppIdentity)
- case recoveryRequired(RadrootsAppIdentity)
- case corruptIdentity(RadrootsAppIdentity)
- case configurationReconfigurationRequired(RadrootsConfigurationReconfigurationRequirement)
- case running(RadrootsRuntimeSnapshot)
- case stopped
- case failed(RadrootsRuntimeFailure)
+ case starting
+ case identityRequired
+ case identityLocked(RadrootsAppIdentity)
+ 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?
+ let generation: UInt64
+ let previousBlossomConfigFingerprint: String?
}
actor RadrootsSessionStore {
- private let configurationStore: RadrootsConfigurationStore
- private let identityStore: RadrootsIdentityStore
- private let runtimeClient: RadrootsRuntimeClient
- private let roots: RadrootsAppleFileRoots
- private let protectedData: RadrootsProtectedDataMonitor
- private let automatesQualificationIdentity: Bool
- private var generation: UInt64 = 0
- private var phase: RadrootsSessionPhase = .starting
+ private let configurationStore: RadrootsConfigurationStore
+ private let identityStore: RadrootsIdentityStore
+ private let runtimeClient: RadrootsRuntimeClient
+ private let roots: RadrootsAppleFileRoots
+ private let protectedData: RadrootsProtectedDataMonitor
+ private let automatesQualificationIdentity: Bool
+ private var generation: UInt64 = 0
+ private var phase: RadrootsSessionPhase = .starting
- init(
- configurationStore: RadrootsConfigurationStore,
- identityStore: RadrootsIdentityStore,
- runtimeClient: RadrootsRuntimeClient,
- roots: RadrootsAppleFileRoots,
- protectedData: RadrootsProtectedDataMonitor,
- automatesQualificationIdentity: Bool = false
- ) {
- self.configurationStore = configurationStore
- self.identityStore = identityStore
- self.runtimeClient = runtimeClient
- self.roots = roots
- self.protectedData = protectedData
- self.automatesQualificationIdentity = automatesQualificationIdentity
- }
+ init(
+ configurationStore: RadrootsConfigurationStore,
+ identityStore: RadrootsIdentityStore,
+ runtimeClient: RadrootsRuntimeClient,
+ roots: RadrootsAppleFileRoots,
+ protectedData: RadrootsProtectedDataMonitor,
+ automatesQualificationIdentity: Bool = false
+ ) {
+ self.configurationStore = configurationStore
+ self.identityStore = identityStore
+ self.runtimeClient = runtimeClient
+ self.roots = roots
+ self.protectedData = protectedData
+ self.automatesQualificationIdentity = automatesQualificationIdentity
+ }
- @MainActor
- static func production(
- bundle: Bundle = .main,
- runtimeClient: RadrootsRuntimeClient = .production()
- ) throws -> RadrootsSessionStore {
- guard let bundleIdentifier = bundle.bundleIdentifier else {
- throw RadrootsConfigurationError.missing("bundle_identifier")
- }
- let qualification = try RadrootsRemoteQualificationEnvironment.current()
- let servicePrefix =
- try qualification?.keychainServicePrefix
- ?? requiredString(
- "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX", bundle: bundle
- )
- let bootstrap = try RadrootsConfigurationBootstrap(
- runtimeMode: qualification == nil
- ? requiredString("RADROOTS_FIELD_IOS_RUNTIME_MODE", bundle: bundle)
- : "production",
- relayURLs: qualification?.relayURLs
- ?? array("RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS", bundle: bundle),
- blossomOrigins: qualification?.blossomOrigins
- ?? array("RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS", bundle: bundle),
- keychainServicePrefix: servicePrefix,
- bundleIdentifier: bundleIdentifier,
- appMetadata: RadrootsRuntimeAppMetadata(
- bundleIdentifier: bundleIdentifier,
- version: bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
- ?? "0",
- buildNumber: bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0",
- buildSHA: normalizedOptional(
- bundle.object(forInfoDictionaryKey: "GIT_SHA") as? String
- )
- )
- )
- let roots = try RadrootsAppleFileRoots.appContainer(appIdentifier: bundleIdentifier)
- let protectedData = RadrootsProtectedDataMonitor(
- available: UIApplication.shared.isProtectedDataAvailable
- )
- return try RadrootsSessionStore(
- configurationStore: RadrootsConfigurationStore(bootstrap: bootstrap, roots: roots),
- identityStore: .production(
- servicePrefix: servicePrefix,
- protectedDataAvailable: { protectedData.isAvailable() },
- qualification: qualification
- ),
- runtimeClient: runtimeClient,
- roots: roots,
- protectedData: protectedData,
- automatesQualificationIdentity: qualification != nil
- )
+ @MainActor
+ static func production(
+ bundle: Bundle = .main,
+ runtimeClient: RadrootsRuntimeClient = .production()
+ ) throws -> RadrootsSessionStore {
+ guard let bundleIdentifier = bundle.bundleIdentifier else {
+ throw RadrootsConfigurationError.missing("bundle_identifier")
}
+ let qualification = try RadrootsRemoteQualificationEnvironment.current()
+ let servicePrefix =
+ try qualification?.keychainServicePrefix
+ ?? requiredString(
+ "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX", bundle: bundle
+ )
+ let bootstrap = RadrootsConfigurationBootstrap(
+ runtimeMode: try qualification?.runtimeMode
+ ?? requiredString("RADROOTS_FIELD_IOS_RUNTIME_MODE", bundle: bundle),
+ relayURLs: qualification?.relayURLs
+ ?? array("RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS", bundle: bundle),
+ blossomOrigins: qualification?.blossomOrigins
+ ?? array("RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS", bundle: bundle),
+ keychainServicePrefix: servicePrefix,
+ bundleIdentifier: bundleIdentifier,
+ appMetadata: RadrootsRuntimeAppMetadata(
+ bundleIdentifier: bundleIdentifier,
+ version: bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
+ ?? "0",
+ buildNumber: bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0",
+ buildSHA: normalizedOptional(
+ bundle.object(forInfoDictionaryKey: "GIT_SHA") as? String
+ )
+ )
+ )
+ let roots = try RadrootsAppleFileRoots.appContainer(appIdentifier: bundleIdentifier)
+ let protectedData = RadrootsProtectedDataMonitor(
+ available: UIApplication.shared.isProtectedDataAvailable
+ )
+ return try RadrootsSessionStore(
+ configurationStore: RadrootsConfigurationStore(bootstrap: bootstrap, roots: roots),
+ identityStore: .production(
+ servicePrefix: servicePrefix,
+ protectedDataAvailable: { protectedData.isAvailable() },
+ qualification: qualification
+ ),
+ runtimeClient: runtimeClient,
+ roots: roots,
+ protectedData: protectedData,
+ automatesQualificationIdentity: qualification != nil
+ )
+ }
- func updateProtectedDataAvailability(_ available: Bool) {
- protectedData.update(available: available)
- }
+ func updateProtectedDataAvailability(_ available: Bool) {
+ protectedData.update(available: available)
+ }
- func currentPhase() -> RadrootsSessionPhase {
- phase
- }
+ func currentPhase() -> RadrootsSessionPhase {
+ phase
+ }
- func start() async -> RadrootsSessionPhase {
- await start(acceptingReconfiguration: false)
- }
+ func start() async -> RadrootsSessionPhase {
+ await start(acceptingReconfiguration: false)
+ }
- func applyConfigurationReconfiguration() async -> RadrootsSessionPhase {
- await start(acceptingReconfiguration: true)
- }
+ func applyConfigurationReconfiguration() async -> RadrootsSessionPhase {
+ await start(acceptingReconfiguration: true)
+ }
- func applySettingsReconfiguration() async -> RadrootsSessionPhase {
- generation &+= 1
- let requestedGeneration = generation
- phase = .starting
- do {
- let identity = try await identityStore.loadAndMigrate()
- guard identity.state == .unlocked else {
- return await start()
- }
- let configuration = try await configurationStore.load()
- phase = try await startRuntime(
- configuration: configuration,
- identity: identity,
- generation: requestedGeneration,
- forceReconfiguration: true,
- adoptBootstrapSettings: false
- )
- } catch {
- guard generation == requestedGeneration else { return phase }
- phase = .failed(
- .local(
- operation: "session.settings_reconfiguration",
- code: "ios.session.settings_reconfiguration_failed",
- safeMessage: "Radroots could not apply the saved settings."
- )
- )
- }
- return phase
+ func applySettingsReconfiguration() async -> RadrootsSessionPhase {
+ generation &+= 1
+ let requestedGeneration = generation
+ phase = .starting
+ do {
+ let identity = try await identityStore.loadAndMigrate()
+ guard identity.state == .unlocked else {
+ return await start()
+ }
+ let configuration = try await configurationStore.load()
+ phase = try await startRuntime(
+ configuration: configuration,
+ identity: identity,
+ generation: requestedGeneration,
+ forceReconfiguration: true,
+ adoptBootstrapSettings: false
+ )
+ } catch {
+ guard generation == requestedGeneration else { return phase }
+ phase = .failed(
+ .local(
+ operation: "session.settings_reconfiguration",
+ code: "ios.session.settings_reconfiguration_failed",
+ safeMessage: "Radroots could not apply the saved settings."
+ )
+ )
}
+ return phase
+ }
- func suspend() async {
- generation &+= 1
- await runtimeClient.suspend()
- if case .starting = phase {
- phase = .stopped
- }
+ func suspend() async {
+ generation &+= 1
+ await runtimeClient.suspend()
+ if case .starting = phase {
+ phase = .stopped
}
+ }
- private func start(acceptingReconfiguration: Bool) async -> RadrootsSessionPhase {
- generation &+= 1
- let requestedGeneration = generation
- phase = .starting
- do {
- let identity = try await identityStore.loadAndMigrate()
- guard generation == requestedGeneration else {
- throw RadrootsRuntimeClientError.superseded
- }
- switch identity.state {
- case .absent:
- phase = .identityRequired
- case .locked:
- #if DEBUG
- if automatesQualificationIdentity {
- return await unlockIdentity()
- }
- #endif
- phase = .identityLocked(identity)
- case .protectedDataUnavailable:
- phase = .protectedDataUnavailable(identity)
- case .recoveryRequired:
- phase = .recoveryRequired(identity)
- case .corrupt:
- 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,
- generation: requestedGeneration,
- forceReconfiguration: configuration.activationState
- == .reconfigurationRequired,
- adoptBootstrapSettings: configuration.activationState
- == .reconfigurationRequired
- )
- }
- } catch RadrootsRuntimeClientError.superseded {
- return phase
- } catch let RadrootsRuntimeClientError.startup(failure) {
- guard generation == requestedGeneration else { return phase }
- phase = .failed(failure)
- } catch let error as LocalizedError {
- guard generation == requestedGeneration else { return phase }
- phase = .failed(
- .local(
- operation: "session.start",
- code: "ios.session.start_failed",
- safeMessage: error.errorDescription ?? "Radroots could not start."
- )
- )
- } catch {
- guard generation == requestedGeneration else { return phase }
- phase = .failed(
- .local(
- operation: "session.start",
- code: "ios.session.start_failed",
- safeMessage: "Radroots could not start."
- )
+ private func start(acceptingReconfiguration: Bool) async -> RadrootsSessionPhase {
+ generation &+= 1
+ let requestedGeneration = generation
+ phase = .starting
+ do {
+ let identity = try await identityStore.loadAndMigrate()
+ guard generation == requestedGeneration else {
+ throw RadrootsRuntimeClientError.superseded
+ }
+ switch identity.state {
+ case .absent:
+ phase = .identityRequired
+ case .locked:
+ #if DEBUG
+ if automatesQualificationIdentity {
+ return await unlockIdentity()
+ }
+ #endif
+ phase = .identityLocked(identity)
+ case .protectedDataUnavailable:
+ phase = .protectedDataUnavailable(identity)
+ case .recoveryRequired:
+ phase = .recoveryRequired(identity)
+ case .corrupt:
+ 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
}
- return phase
+ phase = try await startRuntime(
+ configuration: configuration,
+ identity: identity,
+ generation: requestedGeneration,
+ forceReconfiguration: configuration.activationState
+ == .reconfigurationRequired,
+ adoptBootstrapSettings: configuration.activationState
+ == .reconfigurationRequired
+ )
+ }
+ } catch RadrootsRuntimeClientError.superseded {
+ return phase
+ } catch let RadrootsRuntimeClientError.startup(failure) {
+ guard generation == requestedGeneration else { return phase }
+ phase = .failed(failure)
+ } catch let error as LocalizedError {
+ guard generation == requestedGeneration else { return phase }
+ phase = .failed(
+ .local(
+ operation: "session.start",
+ code: "ios.session.start_failed",
+ safeMessage: error.errorDescription ?? "Radroots could not start."
+ )
+ )
+ } catch {
+ guard generation == requestedGeneration else { return phase }
+ phase = .failed(
+ .local(
+ operation: "session.start",
+ code: "ios.session.start_failed",
+ safeMessage: "Radroots could not start."
+ )
+ )
}
+ return phase
+ }
- func createIdentity(label: String? = nil) async -> RadrootsSessionPhase {
- do {
- _ = try await identityStore.create(label: label)
- } catch {
- return failIdentityOperation(error)
- }
- return await start()
+ func createIdentity(label: String? = nil) async -> RadrootsSessionPhase {
+ do {
+ _ = try await identityStore.create(label: label)
+ } catch {
+ return failIdentityOperation(error)
}
+ return await start()
+ }
- func importIdentity(
- _ material: RadrootsIdentitySecretMaterial,
- label: String? = nil
- ) async -> RadrootsSessionPhase {
- do {
- _ = try await identityStore.importIdentity(material, label: label)
- } catch {
- return failIdentityOperation(error)
- }
- return await start()
+ func importIdentity(
+ _ material: RadrootsIdentitySecretMaterial,
+ label: String? = nil
+ ) async -> RadrootsSessionPhase {
+ do {
+ _ = try await identityStore.importIdentity(material, label: label)
+ } catch {
+ return failIdentityOperation(error)
}
+ return await start()
+ }
- func lockIdentity() async -> RadrootsSessionPhase {
- generation &+= 1
- if case .running = phase,
- let settings = try? await runtimeClient.mobileSettings()
- {
- _ = try? await runtimeClient.applyIdentityCommand(
- expectedRevision: settings.revision,
- command: RadrootsIdentityCommand(
- kind: .lock,
- operationID: nil,
- identityID: nil,
- publicKeyHex: nil
- )
- )
- }
- _ = try? await runtimeClient.stop()
- await identityStore.lock()
- let identity = await identityStore.snapshot()
- phase = .identityLocked(identity)
- return phase
+ func lockIdentity() async -> RadrootsSessionPhase {
+ generation &+= 1
+ if case .running = phase,
+ let settings = try? await runtimeClient.mobileSettings()
+ {
+ _ = try? await runtimeClient.applyIdentityCommand(
+ expectedRevision: settings.revision,
+ command: RadrootsIdentityCommand(
+ kind: .lock,
+ operationID: nil,
+ identityID: nil,
+ publicKeyHex: nil
+ )
+ )
}
+ _ = try? await runtimeClient.stop()
+ await identityStore.lock()
+ let identity = await identityStore.snapshot()
+ phase = .identityLocked(identity)
+ return phase
+ }
- func unlockIdentity() async -> RadrootsSessionPhase {
- do {
- _ = try await identityStore.unlock()
- } catch {
- return failIdentityOperation(error)
- }
- return await start()
+ func unlockIdentity() async -> RadrootsSessionPhase {
+ do {
+ _ = try await identityStore.unlock()
+ } catch {
+ return failIdentityOperation(error)
}
+ return await start()
+ }
- func recoverIdentity() async -> RadrootsSessionPhase {
- do {
- _ = try await identityStore.recover()
- } catch {
- return failIdentityOperation(error)
- }
- return await start()
+ func recoverIdentity() async -> RadrootsSessionPhase {
+ do {
+ _ = try await identityStore.recover()
+ } catch {
+ return failIdentityOperation(error)
}
+ return await start()
+ }
- func stop() async -> RadrootsSessionPhase {
- generation &+= 1
- do {
- _ = try await runtimeClient.stop()
- await identityStore.lock()
- phase = .stopped
- } catch let RadrootsRuntimeClientError.shutdown(failure) {
- phase = .failed(failure)
- } catch {
- phase = .failed(
- .local(
- operation: "session.stop",
- code: "ios.session.stop_failed",
- safeMessage: "Radroots could not finish shutting down."
- )
- )
- }
- return phase
+ func stop() async -> RadrootsSessionPhase {
+ generation &+= 1
+ do {
+ _ = try await runtimeClient.stop()
+ await identityStore.lock()
+ phase = .stopped
+ } catch let RadrootsRuntimeClientError.shutdown(failure) {
+ phase = .failed(failure)
+ } catch {
+ phase = .failed(
+ .local(
+ operation: "session.stop",
+ code: "ios.session.stop_failed",
+ safeMessage: "Radroots could not finish shutting down."
+ )
+ )
}
+ return phase
+ }
- private func startRuntime(
- configuration: RadrootsAppConfiguration,
- identity: RadrootsAppIdentity,
- generation requestedGeneration: UInt64,
- forceReconfiguration: Bool,
- adoptBootstrapSettings: Bool
- ) async throws -> RadrootsSessionPhase {
- guard let publicKeyHex = identity.publicKeyHex,
- let signerGeneration = identity.signerGeneration
- else {
- throw RadrootsIdentityStoreError.unavailable
- }
- let mobileStore = try RadrootsAppleMobileStore.prepare(
- roots: roots,
- publicKeyHex: publicKeyHex,
- protectedDataAvailability: protectedData.isAvailable() ? .available : .unavailable
- )
- let sourceGeneration = try await configurationStore.sourceGeneration()
- let signer = try await identityStore.signer(for: identity)
- let launchConfiguration = RadrootsRuntimeLaunchConfiguration(
- applicationSupportDirectory: mobileStore.applicationSupportDirectory.path,
- publicKeyHex: publicKeyHex,
- sourceGenerationHex: sourceGeneration.generationHex,
- sourceGenerationCreatedAtUnixMilliseconds: sourceGeneration.createdAtUnixMilliseconds,
- protectedData: protectedData.isAvailable() ? .available : .unavailable,
- networkProfile: configuration.profile.runtimeValue,
- writableRelays: configuration.writableRelays,
- blossom: configuration.blossom,
- app: configuration.appMetadata,
- signerGeneration: signerGeneration,
- signer: signer,
- adoptBootstrapSettings: adoptBootstrapSettings
- )
- let snapshot =
- if forceReconfiguration {
- try await runtimeClient.reconfigure(configuration: launchConfiguration)
- } else {
- try await runtimeClient.start(configuration: launchConfiguration)
- }
- guard generation == requestedGeneration else {
- _ = try? await runtimeClient.stop()
- throw RadrootsRuntimeClientError.superseded
- }
- try await reconcileIdentity(identity)
- if configuration.activationState == .reconfigurationRequired,
- adoptBootstrapSettings
- {
- try await configurationStore.confirmBootstrapActivation(
- expectedGeneration: configuration.generation
- )
- }
- guard generation == requestedGeneration else {
- _ = try? await runtimeClient.stop()
- throw RadrootsRuntimeClientError.superseded
- }
- return .running(snapshot)
+ private func startRuntime(
+ configuration: RadrootsAppConfiguration,
+ identity: RadrootsAppIdentity,
+ generation requestedGeneration: UInt64,
+ forceReconfiguration: Bool,
+ adoptBootstrapSettings: Bool
+ ) async throws -> RadrootsSessionPhase {
+ guard let publicKeyHex = identity.publicKeyHex,
+ let signerGeneration = identity.signerGeneration
+ else {
+ throw RadrootsIdentityStoreError.unavailable
}
+ let mobileStore = try RadrootsAppleMobileStore.prepare(
+ roots: roots,
+ publicKeyHex: publicKeyHex,
+ protectedDataAvailability: protectedData.isAvailable() ? .available : .unavailable
+ )
+ let sourceGeneration = try await configurationStore.sourceGeneration()
+ let signer = try await identityStore.signer(for: identity)
+ let launchConfiguration = RadrootsRuntimeLaunchConfiguration(
+ applicationSupportDirectory: mobileStore.applicationSupportDirectory.path,
+ publicKeyHex: publicKeyHex,
+ sourceGenerationHex: sourceGeneration.generationHex,
+ sourceGenerationCreatedAtUnixMilliseconds: sourceGeneration.createdAtUnixMilliseconds,
+ protectedData: protectedData.isAvailable() ? .available : .unavailable,
+ networkProfile: configuration.profile.runtimeValue,
+ writableRelays: configuration.writableRelays,
+ blossom: configuration.blossom,
+ app: configuration.appMetadata,
+ signerGeneration: signerGeneration,
+ signer: signer,
+ adoptBootstrapSettings: adoptBootstrapSettings
+ )
+ let snapshot =
+ if forceReconfiguration {
+ try await runtimeClient.reconfigure(configuration: launchConfiguration)
+ } else {
+ try await runtimeClient.start(configuration: launchConfiguration)
+ }
+ guard generation == requestedGeneration else {
+ _ = try? await runtimeClient.stop()
+ throw RadrootsRuntimeClientError.superseded
+ }
+ try await reconcileIdentity(identity)
+ if configuration.activationState == .reconfigurationRequired,
+ adoptBootstrapSettings
+ {
+ try await configurationStore.confirmBootstrapActivation(
+ expectedGeneration: configuration.generation
+ )
+ }
+ guard generation == requestedGeneration else {
+ _ = try? await runtimeClient.stop()
+ throw RadrootsRuntimeClientError.superseded
+ }
+ return .running(snapshot)
+ }
- private func reconcileIdentity(_ identity: RadrootsAppIdentity) async throws {
- guard let identityID = identity.identityHandle,
- let publicKeyHex = identity.publicKeyHex
- else {
- throw RadrootsIdentityStoreError.unavailable
- }
- var settings = try await runtimeClient.mobileSettings()
- if let pending = settings.identity.pendingImportOperationID {
- settings = try await runtimeClient.applyIdentityCommand(
- expectedRevision: settings.revision,
- command: RadrootsIdentityCommand(
- kind: .cancelImport,
- operationID: pending,
- identityID: nil,
- publicKeyHex: nil
- )
- ).settings
- }
- if let existing = settings.identity.identities.first(where: {
- $0.publicKeyHex == publicKeyHex
- }) {
- if settings.identity.activeIdentityID != existing.id {
- settings = try await runtimeClient.applyIdentityCommand(
- expectedRevision: settings.revision,
- command: RadrootsIdentityCommand(
- kind: .select,
- operationID: nil,
- identityID: existing.id,
- publicKeyHex: nil
- )
- ).settings
- }
- } else {
- let operationID = UUID().uuidString.lowercased()
- settings = try await runtimeClient.applyIdentityCommand(
- expectedRevision: settings.revision,
- command: RadrootsIdentityCommand(
- kind: .beginImport,
- operationID: operationID,
- identityID: nil,
- publicKeyHex: nil
- )
- ).settings
- settings = try await runtimeClient.applyIdentityCommand(
- expectedRevision: settings.revision,
- command: RadrootsIdentityCommand(
- kind: .completeImport,
- operationID: operationID,
- identityID: identityID,
- publicKeyHex: publicKeyHex
- )
- ).settings
- }
- _ = try await runtimeClient.applyIdentityCommand(
- expectedRevision: settings.revision,
- command: RadrootsIdentityCommand(
- kind: .unlock,
- operationID: nil,
- identityID: nil,
- publicKeyHex: nil
- )
+ private func reconcileIdentity(_ identity: RadrootsAppIdentity) async throws {
+ guard let identityID = identity.identityHandle,
+ let publicKeyHex = identity.publicKeyHex
+ else {
+ throw RadrootsIdentityStoreError.unavailable
+ }
+ var settings = try await runtimeClient.mobileSettings()
+ if let pending = settings.identity.pendingImportOperationID {
+ settings = try await runtimeClient.applyIdentityCommand(
+ expectedRevision: settings.revision,
+ command: RadrootsIdentityCommand(
+ kind: .cancelImport,
+ operationID: pending,
+ identityID: nil,
+ publicKeyHex: nil
)
+ ).settings
}
-
- private func failIdentityOperation(_ error: Error) -> RadrootsSessionPhase {
- generation &+= 1
- let message =
- (error as? LocalizedError)?.errorDescription
- ?? "The local identity operation could not be completed."
- phase = .failed(
- .local(
- operation: "identity.operation",
- code: "ios.identity.operation_failed",
- safeMessage: message
- )
+ if let existing = settings.identity.identities.first(where: {
+ $0.publicKeyHex == publicKeyHex
+ }) {
+ if settings.identity.activeIdentityID != existing.id {
+ settings = try await runtimeClient.applyIdentityCommand(
+ expectedRevision: settings.revision,
+ command: RadrootsIdentityCommand(
+ kind: .select,
+ operationID: nil,
+ identityID: existing.id,
+ publicKeyHex: nil
+ )
+ ).settings
+ }
+ } else {
+ let operationID = UUID().uuidString.lowercased()
+ settings = try await runtimeClient.applyIdentityCommand(
+ expectedRevision: settings.revision,
+ command: RadrootsIdentityCommand(
+ kind: .beginImport,
+ operationID: operationID,
+ identityID: nil,
+ publicKeyHex: nil
+ )
+ ).settings
+ settings = try await runtimeClient.applyIdentityCommand(
+ expectedRevision: settings.revision,
+ command: RadrootsIdentityCommand(
+ kind: .completeImport,
+ operationID: operationID,
+ identityID: identityID,
+ publicKeyHex: publicKeyHex
)
- return phase
+ ).settings
}
+ _ = try await runtimeClient.applyIdentityCommand(
+ expectedRevision: settings.revision,
+ command: RadrootsIdentityCommand(
+ kind: .unlock,
+ operationID: nil,
+ identityID: nil,
+ publicKeyHex: nil
+ )
+ )
+ }
- @MainActor
- private static func requiredString(_ key: String, bundle: Bundle) throws -> String {
- guard let value = normalizedOptional(bundle.object(forInfoDictionaryKey: key) as? String) else {
- throw RadrootsConfigurationError.missing(key)
- }
- return value
- }
+ private func failIdentityOperation(_ error: Error) -> RadrootsSessionPhase {
+ generation &+= 1
+ let message =
+ (error as? LocalizedError)?.errorDescription
+ ?? "The local identity operation could not be completed."
+ phase = .failed(
+ .local(
+ operation: "identity.operation",
+ code: "ios.identity.operation_failed",
+ safeMessage: message
+ )
+ )
+ return phase
+ }
- @MainActor
- private static func array(_ 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 raw = normalizedOptional(bundle.object(forInfoDictionaryKey: key) as? String) else {
- return []
- }
- return raw.components(separatedBy: CharacterSet(charactersIn: ",; \n\r\t"))
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
- .filter { !$0.isEmpty }
+ @MainActor
+ private static func requiredString(_ key: String, bundle: Bundle) throws -> String {
+ guard let value = normalizedOptional(bundle.object(forInfoDictionaryKey: key) as? String) else {
+ throw RadrootsConfigurationError.missing(key)
}
+ return value
+ }
- @MainActor
- private static func normalizedOptional(_ value: String?) -> String? {
- guard let value else { return nil }
- let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
- return trimmed.isEmpty || trimmed == "unknown" ? nil : trimmed
+ @MainActor
+ private static func array(_ 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 raw = normalizedOptional(bundle.object(forInfoDictionaryKey: key) as? String) else {
+ return []
+ }
+ return raw.components(separatedBy: CharacterSet(charactersIn: ",; \n\r\t"))
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ }
+
+ @MainActor
+ private static func normalizedOptional(_ value: String?) -> String? {
+ guard let value else { return nil }
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty || trimmed == "unknown" ? nil : trimmed
+ }
}
diff --git a/Radroots/Views/RadrootsAddView.swift b/Radroots/Views/RadrootsAddView.swift
@@ -1,9 +1,20 @@
import SwiftUI
struct RadrootsAddView: View {
+ private enum FocusedField: Hashable {
+ case title
+ case summary
+ case content
+ case location
+ case price
+ case currency
+ case quantity
+ case media(String)
+ }
+
@ObservedObject var store: RadrootsAddStore
@State private var showsDrafts = false
- @FocusState private var isContentEditorFocused: Bool
+ @FocusState private var focusedField: FocusedField?
var body: some View {
Form {
@@ -114,7 +125,7 @@ struct RadrootsAddView: View {
}
ToolbarItemGroup(placement: .keyboard) {
Spacer()
- Button("Done") { isContentEditorFocused = false }
+ Button("Done") { focusedField = nil }
.accessibilityIdentifier("radroots.add.keyboard.done")
}
}
@@ -143,9 +154,11 @@ struct RadrootsAddView: View {
private var eventFields: some View {
Section("Event") {
TextField("Title", text: optional(\.title))
+ .focused($focusedField, equals: .title)
.accessibilityIdentifier("radroots.add.title")
contentEditor(prompt: "Event details (optional)")
TextField("Location (optional)", text: optional(\.location))
+ .focused($focusedField, equals: .location)
.accessibilityIdentifier("radroots.add.location")
Picker("When", selection: optionalTiming) {
ForEach(RadrootsEventTiming.allCases) { timing in
@@ -167,18 +180,25 @@ struct RadrootsAddView: View {
private var foodFields: some View {
Section("Food availability") {
TextField("Food", text: optional(\.title))
+ .focused($focusedField, equals: .title)
.accessibilityIdentifier("radroots.add.title")
TextField("Short summary", text: optional(\.summary))
+ .focused($focusedField, equals: .summary)
+ .accessibilityIdentifier("radroots.add.summary")
contentEditor(prompt: "Details")
TextField("Location", text: optional(\.location))
+ .focused($focusedField, equals: .location)
.accessibilityIdentifier("radroots.add.location")
}
Section("Price and quantity") {
TextField("Price", text: optional(\.priceAmount))
.keyboardType(.decimalPad)
+ .focused($focusedField, equals: .price)
.accessibilityIdentifier("radroots.add.price")
TextField("Currency", text: optional(\.currency))
.textInputAutocapitalization(.characters)
+ .focused($focusedField, equals: .currency)
+ .accessibilityIdentifier("radroots.add.currency")
Picker("Unit", selection: optional(\.unit)) {
Text("Choose a unit").tag("")
ForEach(unitChoices, id: \.self) { unit in
@@ -188,6 +208,7 @@ struct RadrootsAddView: View {
.accessibilityIdentifier("radroots.add.unit")
TextField("Quantity available (optional)", text: optional(\.quantity))
.keyboardType(.decimalPad)
+ .focused($focusedField, equals: .quantity)
}
}
@@ -209,6 +230,7 @@ struct RadrootsAddView: View {
set: { store.updateMediaAlt(id: media.id, alt: $0) }
)
)
+ .focused($focusedField, equals: .media(media.id))
.accessibilityIdentifier("radroots.add.media.alt.\(media.id)")
Button("Remove photo", role: .destructive) { store.removeMedia(id: media.id) }
}
@@ -245,7 +267,7 @@ struct RadrootsAddView: View {
ZStack(alignment: .topLeading) {
TextEditor(text: required(\.content))
.frame(minHeight: 110)
- .focused($isContentEditorFocused)
+ .focused($focusedField, equals: .content)
.accessibilityIdentifier("radroots.add.content")
if store.form.content.isEmpty {
Text(prompt)
diff --git a/Radroots/Views/RadrootsTodayView.swift b/Radroots/Views/RadrootsTodayView.swift
@@ -29,8 +29,8 @@ struct RadrootsTodayView: View {
Text("Pull to refresh or add the first update to this local network.")
} actions: {
Button("Refresh") { Task { await store.reload() } }
+ .accessibilityIdentifier("radroots.today.refresh")
}
- .accessibilityIdentifier("radroots.today.empty")
case .failed(let message) where store.cards.isEmpty:
unavailableView(
title: "Today is unavailable",
@@ -78,7 +78,6 @@ struct RadrootsTodayView: View {
)
}
.task { await store.start() }
- .accessibilityIdentifier("radroots.today.root")
}
private var feed: some View {
diff --git a/RadrootsTests/RadrootsRemoteQualificationTests.swift b/RadrootsTests/RadrootsRemoteQualificationTests.swift
@@ -1,77 +1,95 @@
-@testable import RadrootsApp
import RadrootsKit
import XCTest
-final class RadrootsRemoteQualificationTests: XCTestCase {
- func testQualificationIsOptInAndCarriesOnlyPublicInputs() throws {
- XCTAssertNil(try RadrootsRemoteQualificationEnvironment.current(environment: [:]))
+@testable import RadrootsApp
- let value = try XCTUnwrap(
- RadrootsRemoteQualificationEnvironment.current(
- environment: [
- RadrootsRemoteQualificationEnvironment.enabledKey: "1",
- RadrootsRemoteQualificationEnvironment.runIDKey: "gbrqv1-12345678",
- RadrootsRemoteQualificationEnvironment.relayURLsKey: "wss://write.example",
- RadrootsRemoteQualificationEnvironment.blossomOriginsKey: "https://media.example",
- RadrootsRemoteQualificationEnvironment.mediaRelativePathKey: "qualification/input.png",
- ]
- )
- )
- XCTAssertEqual(value.runID, "gbrqv1-12345678")
- XCTAssertEqual(value.relayURLs, ["wss://write.example"])
- XCTAssertEqual(value.blossomOrigins, ["https://media.example"])
- XCTAssertEqual(
- value.mediaFile,
- RadrootsFileReference(scope: .data, relativePath: "qualification/input.png")
- )
- XCTAssertEqual(
- value.keychainServicePrefix,
- "org.radroots.ios.remote-qualification.gbrqv1-12345678"
- )
- XCTAssertEqual(
- value.identityMetadataKeyPrefix,
- "org.radroots.ios.remote-qualification.gbrqv1-12345678.identity"
- )
- }
+final class RadrootsRemoteQualificationTests: XCTestCase {
+ func testQualificationIsOptInAndCarriesOnlyPublicInputs() throws {
+ XCTAssertNil(try RadrootsRemoteQualificationEnvironment.current(environment: [:]))
- func testQualificationRejectsAmbiguousOrUnsafeHarnessValues() {
- let base = [
- RadrootsRemoteQualificationEnvironment.enabledKey: "1",
- RadrootsRemoteQualificationEnvironment.runIDKey: "gbrqv1-12345678",
- RadrootsRemoteQualificationEnvironment.blossomOriginsKey: "https://media.example",
+ let value = try XCTUnwrap(
+ RadrootsRemoteQualificationEnvironment.current(
+ environment: [
+ RadrootsRemoteQualificationEnvironment.enabledKey: "1",
+ RadrootsRemoteQualificationEnvironment.runIDKey: "gbrqv1-12345678",
+ RadrootsRemoteQualificationEnvironment.relayURLsKey: "wss://write.example",
+ RadrootsRemoteQualificationEnvironment.blossomOriginsKey: "https://media.example",
+ RadrootsRemoteQualificationEnvironment.mediaRelativePathKey: "qualification/input.png",
+ RadrootsRemoteQualificationEnvironment.networkProfileKey: "public",
]
- XCTAssertThrowsError(
- try RadrootsRemoteQualificationEnvironment.current(
- environment: base.merging([
- RadrootsRemoteQualificationEnvironment.runIDKey: "../../bad"
- ]) { _, new in new }
- )
- )
- XCTAssertThrowsError(
- try RadrootsRemoteQualificationEnvironment.current(
- environment: base.merging([
- RadrootsRemoteQualificationEnvironment.blossomOriginsKey:
- "https://one.example,https://two.example"
- ]) { _, new in new }
- )
- )
- XCTAssertThrowsError(
- try RadrootsRemoteQualificationEnvironment.current(
- environment: base.merging([
- RadrootsRemoteQualificationEnvironment.mediaRelativePathKey:
- "../../private-key"
- ]) { _, new in new }
- )
- )
- }
+ )
+ )
+ XCTAssertEqual(value.runID, "gbrqv1-12345678")
+ XCTAssertEqual(value.relayURLs, ["wss://write.example"])
+ XCTAssertEqual(value.blossomOrigins, ["https://media.example"])
+ XCTAssertEqual(value.runtimeMode, "production")
+ XCTAssertEqual(
+ value.mediaFile,
+ RadrootsFileReference(scope: .data, relativePath: "qualification/input.png")
+ )
+ XCTAssertEqual(
+ value.keychainServicePrefix,
+ "org.radroots.ios.remote-qualification.gbrqv1-12345678"
+ )
+ XCTAssertEqual(
+ value.identityMetadataKeyPrefix,
+ "org.radroots.ios.remote-qualification.gbrqv1-12345678.identity"
+ )
+ }
+
+ func testQualificationRejectsAmbiguousOrUnsafeHarnessValues() {
+ let base = [
+ RadrootsRemoteQualificationEnvironment.enabledKey: "1",
+ RadrootsRemoteQualificationEnvironment.runIDKey: "gbrqv1-12345678",
+ RadrootsRemoteQualificationEnvironment.blossomOriginsKey: "https://media.example",
+ RadrootsRemoteQualificationEnvironment.networkProfileKey: "public",
+ ]
+ XCTAssertThrowsError(
+ try RadrootsRemoteQualificationEnvironment.current(
+ environment: base.merging([
+ RadrootsRemoteQualificationEnvironment.runIDKey: "../../bad"
+ ]) { _, new in new }
+ )
+ )
+ XCTAssertThrowsError(
+ try RadrootsRemoteQualificationEnvironment.current(
+ environment: base.merging([
+ RadrootsRemoteQualificationEnvironment.networkProfileKey: "automatic"
+ ]) { _, new in new }
+ )
+ )
+
+ let simulator = try? RadrootsRemoteQualificationEnvironment.current(
+ environment: base.merging([
+ RadrootsRemoteQualificationEnvironment.networkProfileKey: "simulator"
+ ]) { _, new in new }
+ )
+ XCTAssertEqual(simulator?.runtimeMode, "simulator")
+ XCTAssertThrowsError(
+ try RadrootsRemoteQualificationEnvironment.current(
+ environment: base.merging([
+ RadrootsRemoteQualificationEnvironment.blossomOriginsKey:
+ "https://one.example,https://two.example"
+ ]) { _, new in new }
+ )
+ )
+ XCTAssertThrowsError(
+ try RadrootsRemoteQualificationEnvironment.current(
+ environment: base.merging([
+ RadrootsRemoteQualificationEnvironment.mediaRelativePathKey:
+ "../../private-key"
+ ]) { _, new in new }
+ )
+ )
+ }
- func testQualificationUserPresenceIsNoninteractiveAndDebugOnly() async throws {
- let presence = RadrootsRemoteQualificationUserPresence()
- let request = try RadrootsUserPresenceRequest(reason: "Automated remote qualification")
- let result = try await presence.verify(request)
- let status = try await presence.currentStatus()
- XCTAssertTrue(result.verified)
- XCTAssertEqual(result.policy, .deviceOwnerAuthentication)
- XCTAssertFalse(status.canEvaluateBiometrics)
- }
+ func testQualificationUserPresenceIsNoninteractiveAndDebugOnly() async throws {
+ let presence = RadrootsRemoteQualificationUserPresence()
+ let request = try RadrootsUserPresenceRequest(reason: "Automated remote qualification")
+ let result = try await presence.verify(request)
+ let status = try await presence.currentStatus()
+ XCTAssertTrue(result.verified)
+ XCTAssertEqual(result.policy, .deviceOwnerAuthentication)
+ XCTAssertFalse(status.canEvaluateBiometrics)
+ }
}
diff --git a/RadrootsUITests/Info.plist b/RadrootsUITests/Info.plist
@@ -20,5 +20,9 @@
<string>$(RADROOTS_IOS_UI_TEST_NOSTR_RELAY_URLS)</string>
<key>RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS</key>
<string>$(RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS)</string>
+ <key>RADROOTS_IOS_UI_TEST_FIXTURE_CONTROL</key>
+ <string>$(RADROOTS_IOS_UI_TEST_FIXTURE_CONTROL)</string>
+ <key>RADROOTS_IOS_UI_TEST_NETWORK_PROFILE</key>
+ <string>$(RADROOTS_IOS_UI_TEST_NETWORK_PROFILE)</string>
</dict>
</plist>
diff --git a/RadrootsUITests/RadrootsRemoteQualificationUITests.swift b/RadrootsUITests/RadrootsRemoteQualificationUITests.swift
@@ -1,694 +1,950 @@
import XCTest
final class RadrootsRemoteQualificationUITests: XCTestCase {
- private static let receiptName = "radroots-remote-qualification-bootstrap.json"
-
- @MainActor
- func testBootstrapIdentityWithoutInteractiveAuthentication() throws {
- let configuration = try QualificationConfiguration.environment()
- let app = launchToRoot(configuration)
- let publicKey = try readPublicKey(app)
- try writeBootstrapReceipt(configuration: configuration, publicKey: publicKey)
-
- XCUIDevice.shared.press(.home)
- app.activate()
- XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 10))
-
- app.terminate()
- app.launch()
- reachRoot(app)
- XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 20))
- }
-
- @MainActor
- func testStrictPublicEndpointRejectsPrivateAddress() throws {
- let environment = try QualificationConfiguration.environment()
- let configuration = QualificationConfiguration(
- runID: environment.runID,
- relayURLs: [],
- blossomOrigins: ["https://127.0.0.1"]
- )
- let app = XCUIApplication()
- app.launchEnvironment = configuration.launchEnvironment
- app.launch()
-
- let failureCode = app.descendants(matching: .any)["radroots.runtime.failure_code"]
- for _ in 0 ..< 8 where !failureCode.exists {
- for identifier in [
- "radroots.identity.create",
- "radroots.identity.unlock",
- "radroots.configuration.reconfigure",
- "radroots.identity.recover",
- ] {
- let action = app.descendants(matching: .any)[identifier]
- if action.exists, action.isHittable {
- action.tap()
- break
- }
- }
- _ = failureCode.waitForExistence(timeout: 4)
- }
- XCTAssertEqual(
- failureCode.label,
- "Error code runtime_failed",
- "A private Blossom endpoint did not expose the typed fail-closed result; "
- + "failure_code.label=\(failureCode.exists ? failureCode.label : "missing")"
- )
- XCTAssertFalse(app.tabBars.firstMatch.exists)
- }
-
- @MainActor
- func testRemoteBlossomUploadAndRecovery() throws {
- let configuration = try QualificationConfiguration.environment()
- let app = launchToRoot(configuration)
- let marker = "Radroots remote qualification \(configuration.runID)"
-
- guard let submit = preparePhotoUpdate(app, marker: marker),
- let terminalSubmitValue = submitAndWait(app, submit: submit)
- else { return }
- guard !terminalSubmitValue.contains("Error code") else {
- return XCTFail("The Add submission failed locally; submit.value=\(terminalSubmitValue)")
- }
-
- guard openDrafts(app) else {
- return XCTFail("The Drafts sheet did not open after the upload attempt")
- }
- let mediaStatus = app.descendants(matching: .any).matching(
- NSPredicate(format: "identifier BEGINSWITH 'radroots.add.draft.media_status.'")
- ).firstMatch
- guard mediaStatus.waitForExistence(timeout: 20) else {
- app.buttons["Done"].tap()
- let evidence = readBlossomFailureEvidence(app)
- XCTFail("Draft media status was unavailable after upload; \(evidence)")
- return
- }
- let mediaStatusLabel = mediaStatus.label
- guard mediaStatusLabel.contains("1 of 1 photos verified") else {
- app.buttons["Done"].tap()
- let evidence = readBlossomFailureEvidence(app)
- XCTFail(
- "Remote Blossom upload was not verified; media=\(mediaStatusLabel); \(evidence)"
- )
- return
- }
- app.buttons["Done"].tap()
-
- XCUIDevice.shared.press(.home)
- app.activate()
- XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 10))
-
- app.terminate()
- app.launch()
- reachRoot(app)
- guard openAdd(app) != nil else {
- return XCTFail("The Add bottom tab did not recover after relaunch")
- }
- app.buttons["radroots.add.drafts"].tap()
- XCTAssertTrue(
- app.descendants(matching: .any).matching(
- NSPredicate(format: "label CONTAINS '1 of 1 photos verified'")
- ).firstMatch.waitForExistence(timeout: 20)
- )
- app.buttons["Done"].tap()
-
- if !configuration.relayURLs.isEmpty {
- app.tabBars.buttons["Today"].tap()
- let published = app.descendants(matching: .any).matching(
- NSPredicate(format: "label CONTAINS %@", marker)
- ).firstMatch
- XCTAssertTrue(published.waitForExistence(timeout: 30))
- }
+ private static let receiptName = "radroots-remote-qualification-bootstrap.json"
+
+ @MainActor
+ func testBootstrapIdentityWithoutInteractiveAuthentication() throws {
+ let configuration = try QualificationConfiguration.environment()
+ let app = launchToRoot(configuration)
+ let publicKey = try readPublicKey(app)
+ try writeBootstrapReceipt(configuration: configuration, publicKey: publicKey)
+
+ XCUIDevice.shared.press(.home)
+ app.activate()
+ XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 10))
+
+ app.terminate()
+ app.launch()
+ reachRoot(app)
+ XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 20))
+ }
+
+ @MainActor
+ func testStrictPublicEndpointRejectsPrivateAddress() throws {
+ let environment = try QualificationConfiguration.environment()
+ let configuration = QualificationConfiguration(
+ runID: environment.runID,
+ relayURLs: [],
+ blossomOrigins: ["https://127.0.0.1"]
+ )
+ let app = XCUIApplication()
+ app.launchEnvironment = configuration.launchEnvironment
+ app.launch()
+
+ let failureCode = app.descendants(matching: .any)["radroots.runtime.failure_code"]
+ for _ in 0..<8 where !failureCode.exists {
+ for identifier in [
+ "radroots.identity.create",
+ "radroots.identity.unlock",
+ "radroots.configuration.reconfigure",
+ "radroots.identity.recover",
+ ] {
+ let action = app.descendants(matching: .any)[identifier]
+ if action.exists, action.isHittable {
+ action.tap()
+ break
+ }
+ }
+ _ = failureCode.waitForExistence(timeout: 4)
}
-
- @MainActor
- func testRemoteBlossomUnavailablePersistsDraft() throws {
- let configuration = try QualificationConfiguration.environment()
- let app = launchToRoot(configuration)
- let marker = "Radroots unavailable qualification \(configuration.runID)"
-
- guard let submit = preparePhotoUpdate(app, marker: marker),
- let terminalSubmitValue = submitAndWait(app, submit: submit)
- else { return }
- XCTAssertTrue(
- terminalSubmitValue.contains("Error code"),
- "An unavailable Blossom endpoint did not produce a typed failure; "
- + "submit.value=\(terminalSubmitValue)"
- )
- XCTAssertTrue(assertUnverifiedDraft(app))
-
- app.terminate()
- app.launch()
- reachRoot(app)
- guard openAdd(app) != nil else {
- return XCTFail("The Add bottom tab did not recover after unavailable relaunch")
- }
- XCTAssertTrue(assertUnverifiedDraft(app))
+ XCTAssertEqual(
+ failureCode.label,
+ "Error code runtime_failed",
+ "A private Blossom endpoint did not expose the typed fail-closed result; "
+ + "failure_code.label=\(failureCode.exists ? failureCode.label : "missing")"
+ )
+ XCTAssertFalse(app.tabBars.firstMatch.exists)
+ }
+
+ @MainActor
+ func testLocalSocialFiveFlowScenario() throws {
+ let configuration = try QualificationConfiguration.environment()
+ let control = try XCTUnwrap(configuration.fixtureControl)
+ try? FileManager.default.removeItem(atPath: control)
+ var app = launchToRoot(configuration)
+ let marker = "\(configuration.runID)-photo"
+
+ guard preparePhotoUpdate(app, marker: marker) != nil else { return }
+ let save = app.buttons["radroots.add.save"]
+ scrollTo(app, element: save)
+ XCTAssertTrue(save.waitForExistence(timeout: 10))
+ XCTAssertTrue(waitUntilHittable(save, timeout: 10))
+ save.tap()
+ let saved = app.staticTexts.matching(identifier: "radroots.add.status").firstMatch
+ XCTAssertTrue(
+ waitForLabel(saved, label: "Draft saved on this device.", timeout: 20)
+ )
+ XCTAssertTrue(assertUnverifiedDraft(app))
+ app.terminate()
+
+ XCTAssertTrue(FileManager.default.createFile(atPath: control, contents: Data()))
+ app = launchToRoot(configuration)
+ guard openAdd(app) != nil, openDrafts(app) else {
+ return XCTFail("The persisted media draft was unavailable after relaunch")
}
-
- @MainActor
- func testRemoteBlossomRetriesPersistedDraft() throws {
- let configuration = try QualificationConfiguration.environment()
- let app = launchToRoot(configuration)
- guard openAdd(app) != nil else {
- return XCTFail("The Add bottom tab did not present the Add surface")
- }
- guard openDrafts(app) else {
- return XCTFail("The Drafts sheet did not open for retry")
- }
- let unverified = app.descendants(matching: .any).matching(
- NSPredicate(format: "label CONTAINS '0 of 1 photos verified'")
- ).firstMatch
- XCTAssertTrue(unverified.waitForExistence(timeout: 20))
- let reopen = app.buttons["Reopen"].firstMatch
- XCTAssertTrue(reopen.waitForExistence(timeout: 10))
- reopen.tap()
-
- guard let submit = readySubmit(app),
- let terminalSubmitValue = submitAndWait(app, submit: submit)
- else { return }
- guard !terminalSubmitValue.contains("Error code") else {
- return XCTFail("The persisted Blossom retry failed; submit.value=\(terminalSubmitValue)")
- }
- guard openDrafts(app) else {
- return XCTFail("The Drafts sheet did not open after retry")
- }
- XCTAssertTrue(
- app.descendants(matching: .any).matching(
- NSPredicate(format: "label CONTAINS '1 of 1 photos verified'")
- ).firstMatch.waitForExistence(timeout: 20)
- )
- app.buttons["Done"].tap()
-
- app.terminate()
- app.launch()
- reachRoot(app)
- XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 20))
- }
-
- @MainActor
- func testLocalCancellationPersistsAfterVerifiedUpload() throws {
- let configuration = try QualificationConfiguration.environment()
- let app = launchToRoot(configuration)
- guard openAdd(app) != nil else {
- return XCTFail("The Add bottom tab did not present the Add surface")
- }
- guard openDrafts(app) else {
- return XCTFail("The Drafts sheet did not open for cancellation")
- }
-
- let cancel = app.buttons["Cancel"].firstMatch
- XCTAssertTrue(cancel.waitForExistence(timeout: 20))
- cancel.tap()
- let cancelled = app.staticTexts.matching(
- NSPredicate(format: "label == 'Cancelled'")
- ).firstMatch
- XCTAssertTrue(cancelled.waitForExistence(timeout: 20))
- app.buttons["Done"].tap()
-
- app.terminate()
- app.launch()
- reachRoot(app)
- guard openAdd(app) != nil else {
- return XCTFail("The Add bottom tab did not recover after cancellation")
- }
- guard openDrafts(app) else {
- return XCTFail("The Drafts sheet did not reopen after cancellation")
- }
- let persistedCancelled = app.staticTexts.matching(
- NSPredicate(format: "label == 'Cancelled'")
- ).firstMatch
- XCTAssertTrue(persistedCancelled.waitForExistence(timeout: 20))
- app.buttons["Done"].tap()
- }
-
- @MainActor
- func testManualDeviceLockAndProtectedDataRecovery() throws {
- let configuration = try QualificationConfiguration.environment()
- let app = launchToRoot(configuration)
- XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 20))
-
- XCUIDevice.shared.press(.home)
- Thread.sleep(forTimeInterval: 60)
-
- for _ in 0 ..< 24 {
- app.activate()
- if app.tabBars.firstMatch.waitForExistence(timeout: 5) {
- return
- }
- }
- XCTFail("Radroots did not recover after the physical device was unlocked")
- }
-
- @MainActor
- private func launchToRoot(_ configuration: QualificationConfiguration) -> XCUIApplication {
- let app = XCUIApplication()
- app.launchEnvironment = configuration.launchEnvironment
- app.launch()
- reachRoot(app)
- return app
- }
-
- @MainActor
- private func reachRoot(_ app: XCUIApplication) {
- for _ in 0 ..< 6 {
- if app.tabBars.firstMatch.waitForExistence(timeout: 3) {
- return
- }
- for identifier in [
- "radroots.identity.create",
- "radroots.identity.unlock",
- "radroots.configuration.reconfigure",
- "radroots.identity.recover",
- "radroots.runtime.retry",
- ] {
- let action = app.descendants(matching: .any)[identifier]
- if action.exists, action.isHittable {
- action.tap()
- break
- }
- }
- }
- XCTFail("Radroots did not reach the two-tab root without interactive authentication")
+ let reopen = app.buttons["Reopen"].firstMatch
+ XCTAssertTrue(reopen.waitForExistence(timeout: 20))
+ reopen.tap()
+ guard let retrySubmit = readySubmit(app),
+ let retryValue = submitAndWait(app, submit: retrySubmit)
+ else { return }
+ XCTAssertFalse(
+ retryValue.contains("Error code"),
+ "The preserved media draft did not publish after retry; submit.value=\(retryValue)"
+ )
+
+ let markers = [
+ "\(configuration.runID)-update",
+ "\(configuration.runID)-ask",
+ "\(configuration.runID)-event",
+ "\(configuration.runID)-food",
+ ]
+ try publishTextFlow(app, type: "Update", marker: markers[0])
+ try publishTextFlow(app, type: "Ask", marker: markers[1])
+ try publishEvent(app, marker: markers[2])
+ try publishFood(app, marker: markers[3])
+
+ let expected = [marker] + markers
+ assertTodayContains(app, markers: expected)
+ app.terminate()
+ app = launchToRoot(configuration)
+ assertTodayContains(app, markers: expected)
+
+ guard openAdd(app) != nil, openDrafts(app) else {
+ return XCTFail("The durable outbox was unavailable after the final relaunch")
+ }
+ XCTAssertGreaterThanOrEqual(
+ app.buttons.matching(NSPredicate(format: "label == 'View'")).count,
+ 5
+ )
+ app.buttons["Done"].tap()
+ }
+
+ @MainActor
+ func testRemoteBlossomUploadAndRecovery() throws {
+ let configuration = try QualificationConfiguration.environment()
+ let app = launchToRoot(configuration)
+ let marker = "Radroots remote qualification \(configuration.runID)"
+
+ guard let submit = preparePhotoUpdate(app, marker: marker),
+ let terminalSubmitValue = submitAndWait(app, submit: submit)
+ else { return }
+ guard !terminalSubmitValue.contains("Error code") else {
+ return XCTFail("The Add submission failed locally; submit.value=\(terminalSubmitValue)")
}
- @MainActor
- private func readPublicKey(_ app: XCUIApplication) throws -> String {
- app.tabBars.buttons["Today"].tap()
- let account = app.descendants(matching: .any)["radroots.support.account"]
- XCTAssertTrue(account.waitForExistence(timeout: 10))
- account.tap()
-
- let settings = app.descendants(matching: .any)["radroots.support.settings"]
- for _ in 0 ..< 5 where !settings.exists {
- app.swipeUp()
- }
- XCTAssertTrue(settings.waitForExistence(timeout: 20))
- settings.tap()
-
- let publicKey = app.descendants(matching: .any)[
- "radroots.settings.identity.public_key"
- ]
- XCTAssertTrue(publicKey.waitForExistence(timeout: 10))
- guard let value = publicKey.value as? String,
- value.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil
- else {
- throw QualificationError.invalidPublicKey
- }
- return value
+ guard openDrafts(app) else {
+ return XCTFail("The Drafts sheet did not open after the upload attempt")
+ }
+ let mediaStatus = app.descendants(matching: .any).matching(
+ NSPredicate(format: "identifier BEGINSWITH 'radroots.add.draft.media_status.'")
+ ).firstMatch
+ guard mediaStatus.waitForExistence(timeout: 20) else {
+ app.buttons["Done"].tap()
+ let evidence = readBlossomFailureEvidence(app)
+ XCTFail("Draft media status was unavailable after upload; \(evidence)")
+ return
+ }
+ let mediaStatusLabel = mediaStatus.label
+ guard mediaStatusLabel.contains("1 of 1 photos verified") else {
+ app.buttons["Done"].tap()
+ let evidence = readBlossomFailureEvidence(app)
+ XCTFail(
+ "Remote Blossom upload was not verified; media=\(mediaStatusLabel); \(evidence)"
+ )
+ return
}
+ app.buttons["Done"].tap()
- @MainActor
- private func readBlossomFailureEvidence(_ app: XCUIApplication) -> String {
- app.tabBars.buttons["Today"].tap()
- let account = app.descendants(matching: .any)["radroots.support.account"]
- guard account.waitForExistence(timeout: 10) else { return "settings=unavailable" }
- account.tap()
+ XCUIDevice.shared.press(.home)
+ app.activate()
+ XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 10))
- let settings = app.descendants(matching: .any)["radroots.support.settings"]
- for _ in 0 ..< 5 where !settings.exists {
- app.swipeUp()
- }
- guard settings.waitForExistence(timeout: 20) else { return "settings=unavailable" }
- settings.tap()
-
- let httpStatus = app.descendants(matching: .any)[
- "radroots.settings.blossom.http_status"
- ]
- let errorCode = app.descendants(matching: .any)[
- "radroots.settings.blossom.error_code"
- ]
- let serverErrorCode = app.descendants(matching: .any)[
- "radroots.settings.blossom.server_error_code"
- ]
- for _ in 0 ..< 6 where !httpStatus.exists || !errorCode.exists || !serverErrorCode.exists {
- app.swipeUp()
- }
- _ = httpStatus.waitForExistence(timeout: 10)
- _ = errorCode.waitForExistence(timeout: 10)
- _ = serverErrorCode.waitForExistence(timeout: 10)
- return "http=\(httpStatus.exists ? httpStatus.label : "missing"), "
- + "error=\(errorCode.exists ? errorCode.label : "missing"), "
- + "server=\(serverErrorCode.exists ? serverErrorCode.label : "missing")"
- }
-
- @MainActor
- private func openAdd(_ app: XCUIApplication) -> XCUIElement? {
- let add = app.tabBars.buttons["Add"]
- guard add.waitForExistence(timeout: 10) else { return nil }
- let type = app.descendants(matching: .any)["radroots.add.type"]
- for _ in 0 ..< 3 {
- add.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
- if type.waitForExistence(timeout: 10) {
- return type
- }
- }
- return nil
+ app.terminate()
+ app.launch()
+ reachRoot(app)
+ guard openAdd(app) != nil else {
+ return XCTFail("The Add bottom tab did not recover after relaunch")
+ }
+ app.buttons["radroots.add.drafts"].tap()
+ XCTAssertTrue(
+ app.descendants(matching: .any).matching(
+ NSPredicate(format: "label CONTAINS '1 of 1 photos verified'")
+ ).firstMatch.waitForExistence(timeout: 20)
+ )
+ app.buttons["Done"].tap()
+
+ if !configuration.relayURLs.isEmpty {
+ app.tabBars.buttons["Today"].tap()
+ let published = app.descendants(matching: .any).matching(
+ NSPredicate(format: "label CONTAINS %@", marker)
+ ).firstMatch
+ XCTAssertTrue(published.waitForExistence(timeout: 30))
+ }
+ }
+
+ @MainActor
+ func testRemoteBlossomUnavailablePersistsDraft() throws {
+ let configuration = try QualificationConfiguration.environment()
+ let app = launchToRoot(configuration)
+ let marker = "Radroots unavailable qualification \(configuration.runID)"
+
+ guard let submit = preparePhotoUpdate(app, marker: marker),
+ let terminalSubmitValue = submitAndWait(app, submit: submit)
+ else { return }
+ XCTAssertTrue(
+ terminalSubmitValue.contains("Error code"),
+ "An unavailable Blossom endpoint did not produce a typed failure; "
+ + "submit.value=\(terminalSubmitValue)"
+ )
+ XCTAssertTrue(assertUnverifiedDraft(app))
+
+ app.terminate()
+ app.launch()
+ reachRoot(app)
+ guard openAdd(app) != nil else {
+ return XCTFail("The Add bottom tab did not recover after unavailable relaunch")
+ }
+ XCTAssertTrue(assertUnverifiedDraft(app))
+ }
+
+ @MainActor
+ func testRemoteBlossomRetriesPersistedDraft() throws {
+ let configuration = try QualificationConfiguration.environment()
+ let app = launchToRoot(configuration)
+ guard openAdd(app) != nil else {
+ return XCTFail("The Add bottom tab did not present the Add surface")
+ }
+ guard openDrafts(app) else {
+ return XCTFail("The Drafts sheet did not open for retry")
+ }
+ let unverified = app.descendants(matching: .any).matching(
+ NSPredicate(format: "label CONTAINS '0 of 1 photos verified'")
+ ).firstMatch
+ XCTAssertTrue(unverified.waitForExistence(timeout: 20))
+ let reopen = app.buttons["Reopen"].firstMatch
+ XCTAssertTrue(reopen.waitForExistence(timeout: 10))
+ reopen.tap()
+
+ guard let submit = readySubmit(app),
+ let terminalSubmitValue = submitAndWait(app, submit: submit)
+ else { return }
+ guard !terminalSubmitValue.contains("Error code") else {
+ return XCTFail("The persisted Blossom retry failed; submit.value=\(terminalSubmitValue)")
+ }
+ guard openDrafts(app) else {
+ return XCTFail("The Drafts sheet did not open after retry")
+ }
+ XCTAssertTrue(
+ app.descendants(matching: .any).matching(
+ NSPredicate(format: "label CONTAINS '1 of 1 photos verified'")
+ ).firstMatch.waitForExistence(timeout: 20)
+ )
+ app.buttons["Done"].tap()
+
+ app.terminate()
+ app.launch()
+ reachRoot(app)
+ XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 20))
+ }
+
+ @MainActor
+ func testLocalCancellationPersistsAfterVerifiedUpload() throws {
+ let configuration = try QualificationConfiguration.environment()
+ let app = launchToRoot(configuration)
+ guard openAdd(app) != nil else {
+ return XCTFail("The Add bottom tab did not present the Add surface")
+ }
+ guard openDrafts(app) else {
+ return XCTFail("The Drafts sheet did not open for cancellation")
}
- @MainActor
- private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {
- guard let type = openAdd(app) else {
- XCTFail("The Add bottom tab did not present the Add surface")
- return nil
- }
- let newDraft = app.buttons["radroots.add.new"]
- XCTAssertTrue(newDraft.waitForExistence(timeout: 10))
- newDraft.tap()
-
- for _ in 0 ..< 3 where !app.buttons["Photo update"].exists {
- type.tap()
- _ = app.buttons["Photo update"].waitForExistence(timeout: 10)
- }
- let photoUpdate = app.buttons["Photo update"]
- guard photoUpdate.exists else {
- XCTFail("The Add type picker did not present Photo update")
- return nil
- }
- guard waitUntilHittable(photoUpdate, timeout: 10) else {
- XCTFail("Photo update did not become hittable")
- return nil
- }
+ let cancel = app.buttons["Cancel"].firstMatch
+ XCTAssertTrue(cancel.waitForExistence(timeout: 20))
+ cancel.tap()
+ let cancelled = app.staticTexts.matching(
+ NSPredicate(format: "label == 'Cancelled'")
+ ).firstMatch
+ XCTAssertTrue(cancelled.waitForExistence(timeout: 20))
+ app.buttons["Done"].tap()
+
+ app.terminate()
+ app.launch()
+ reachRoot(app)
+ guard openAdd(app) != nil else {
+ return XCTFail("The Add bottom tab did not recover after cancellation")
+ }
+ guard openDrafts(app) else {
+ return XCTFail("The Drafts sheet did not reopen after cancellation")
+ }
+ let persistedCancelled = app.staticTexts.matching(
+ NSPredicate(format: "label == 'Cancelled'")
+ ).firstMatch
+ XCTAssertTrue(persistedCancelled.waitForExistence(timeout: 20))
+ app.buttons["Done"].tap()
+ }
+
+ @MainActor
+ func testManualDeviceLockAndProtectedDataRecovery() throws {
+ let configuration = try QualificationConfiguration.environment()
+ let app = launchToRoot(configuration)
+ XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 20))
+
+ XCUIDevice.shared.press(.home)
+ Thread.sleep(forTimeInterval: 60)
+
+ for _ in 0..<24 {
+ app.activate()
+ if app.tabBars.firstMatch.waitForExistence(timeout: 5) {
+ return
+ }
+ }
+ XCTFail("Radroots did not recover after the physical device was unlocked")
+ }
+
+ @MainActor
+ private func launchToRoot(_ configuration: QualificationConfiguration) -> XCUIApplication {
+ let app = XCUIApplication()
+ app.launchEnvironment = configuration.launchEnvironment
+ app.launch()
+ reachRoot(app)
+ return app
+ }
+
+ @MainActor
+ private func reachRoot(_ app: XCUIApplication) {
+ for _ in 0..<6 {
+ if app.tabBars.firstMatch.waitForExistence(timeout: 3) {
+ return
+ }
+ for identifier in [
+ "radroots.identity.create",
+ "radroots.identity.unlock",
+ "radroots.configuration.reconfigure",
+ "radroots.identity.recover",
+ "radroots.runtime.retry",
+ ] {
+ let action = app.descendants(matching: .any)[identifier]
+ if action.exists, action.isHittable {
+ action.tap()
+ break
+ }
+ }
+ }
+ XCTFail("Radroots did not reach the two-tab root without interactive authentication")
+ }
+
+ @MainActor
+ private func readPublicKey(_ app: XCUIApplication) throws -> String {
+ app.tabBars.buttons["Today"].tap()
+ let account = app.descendants(matching: .any)["radroots.support.account"]
+ XCTAssertTrue(account.waitForExistence(timeout: 10))
+ account.tap()
+
+ let settings = app.descendants(matching: .any)["radroots.support.settings"]
+ for _ in 0..<5 where !settings.exists {
+ app.swipeUp()
+ }
+ XCTAssertTrue(settings.waitForExistence(timeout: 20))
+ settings.tap()
+
+ let publicKey = app.descendants(matching: .any)[
+ "radroots.settings.identity.public_key"
+ ]
+ XCTAssertTrue(publicKey.waitForExistence(timeout: 10))
+ guard let value = publicKey.value as? String,
+ value.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil
+ else {
+ throw QualificationError.invalidPublicKey
+ }
+ return value
+ }
+
+ @MainActor
+ private func readBlossomFailureEvidence(_ app: XCUIApplication) -> String {
+ app.tabBars.buttons["Today"].tap()
+ let account = app.descendants(matching: .any)["radroots.support.account"]
+ guard account.waitForExistence(timeout: 10) else { return "settings=unavailable" }
+ account.tap()
+
+ let settings = app.descendants(matching: .any)["radroots.support.settings"]
+ for _ in 0..<5 where !settings.exists {
+ app.swipeUp()
+ }
+ guard settings.waitForExistence(timeout: 20) else { return "settings=unavailable" }
+ settings.tap()
+
+ let httpStatus = app.descendants(matching: .any)[
+ "radroots.settings.blossom.http_status"
+ ]
+ let errorCode = app.descendants(matching: .any)[
+ "radroots.settings.blossom.error_code"
+ ]
+ let serverErrorCode = app.descendants(matching: .any)[
+ "radroots.settings.blossom.server_error_code"
+ ]
+ for _ in 0..<6 where !httpStatus.exists || !errorCode.exists || !serverErrorCode.exists {
+ app.swipeUp()
+ }
+ _ = httpStatus.waitForExistence(timeout: 10)
+ _ = errorCode.waitForExistence(timeout: 10)
+ _ = serverErrorCode.waitForExistence(timeout: 10)
+ return "http=\(httpStatus.exists ? httpStatus.label : "missing"), "
+ + "error=\(errorCode.exists ? errorCode.label : "missing"), "
+ + "server=\(serverErrorCode.exists ? serverErrorCode.label : "missing")"
+ }
+
+ @MainActor
+ private func openAdd(_ app: XCUIApplication) -> XCUIElement? {
+ let add = app.tabBars.buttons["Add"]
+ guard add.waitForExistence(timeout: 10) else { return nil }
+ let type = app.descendants(matching: .any)["radroots.add.type"]
+ for _ in 0..<3 {
+ add.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
+ if type.waitForExistence(timeout: 10) {
+ return type
+ }
+ }
+ return nil
+ }
+
+ @MainActor
+ private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {
+ guard let type = openAdd(app) else {
+ XCTFail("The Add bottom tab did not present the Add surface")
+ return nil
+ }
+ let newDraft = app.buttons["radroots.add.new"]
+ XCTAssertTrue(newDraft.waitForExistence(timeout: 10))
+ newDraft.tap()
- let content = app.descendants(matching: .any)["radroots.add.content"]
- var selectedPhotoUpdate = false
- for _ in 0 ..< 3 where !selectedPhotoUpdate {
- photoUpdate.tap()
- selectedPhotoUpdate = waitForValue(type, value: "Photo update", timeout: 10)
- if !selectedPhotoUpdate, !photoUpdate.exists {
- type.tap()
- _ = photoUpdate.waitForExistence(timeout: 10)
- }
- }
- guard selectedPhotoUpdate else {
- XCTFail("Photo update selection did not update the Add composer type")
- return nil
- }
- XCTAssertTrue(content.waitForExistence(timeout: 10))
- guard waitUntilHittable(content, timeout: 10) else {
- XCTFail("Photo update content did not become hittable")
- return nil
- }
- content.tap()
- content.typeText(marker)
- let keyboardDone = app.buttons["radroots.add.keyboard.done"]
- XCTAssertTrue(keyboardDone.waitForExistence(timeout: 10))
- keyboardDone.tap()
- XCTAssertFalse(app.keyboards.firstMatch.waitForExistence(timeout: 5))
-
- let library = app.descendants(matching: .any)["radroots.add.media.library"]
- XCTAssertTrue(library.waitForExistence(timeout: 10))
- XCTAssertTrue(library.isEnabled)
- library.tap()
- let prepared = app.descendants(matching: .any).matching(
- NSPredicate(
- format: "identifier MATCHES %@",
- #"radroots\.add\.media\.[0-9a-f]{64}"#
- )
- ).firstMatch
- XCTAssertTrue(prepared.waitForExistence(timeout: 30))
- return readySubmit(app)
- }
-
- @MainActor
- private func readySubmit(_ app: XCUIApplication) -> XCUIElement? {
- let submit = app.descendants(matching: .any)["radroots.add.submit"]
- let addRoot = app.descendants(matching: .any)["radroots.add.root"]
- for _ in 0 ..< 8 {
- if isUnobscured(submit, above: app.tabBars.firstMatch) {
- break
- }
- addRoot.swipeUp()
- }
- guard submit.waitForExistence(timeout: 10),
- submit.isEnabled,
- waitUntilHittable(submit, timeout: 10),
- isUnobscured(submit, above: app.tabBars.firstMatch)
- else {
- XCTFail(
- "The Add submission control did not become unobscured; "
- + submissionDiagnostics(app, submit: submit)
- )
- return nil
- }
- return submit
- }
-
- @MainActor
- private func submitAndWait(_ app: XCUIApplication, submit: XCUIElement) -> String? {
- let priorSubmitValue = submit.value as? String
- guard tapSubmitUntilWorkStarts(
- app,
- submit: submit,
- priorSubmitValue: priorSubmitValue
- ) else {
- XCTFail(
- "The Add submission tap did not start local work; "
- + submissionDiagnostics(app, submit: submit)
- )
- return nil
- }
- guard waitForWorkToFinish(
- app,
- submit: submit,
- priorSubmitValue: priorSubmitValue
- ) else {
- XCTFail(
- "The Add submission did not reach a terminal local state; "
- + submissionDiagnostics(app, submit: submit)
- )
- return nil
- }
- return submit.value as? String ?? "missing"
+ for _ in 0..<3 where !app.buttons["Photo update"].exists {
+ type.tap()
+ _ = app.buttons["Photo update"].waitForExistence(timeout: 10)
+ }
+ let photoUpdate = app.buttons["Photo update"]
+ guard photoUpdate.exists else {
+ XCTFail("The Add type picker did not present Photo update")
+ return nil
+ }
+ guard waitUntilHittable(photoUpdate, timeout: 10) else {
+ XCTFail("Photo update did not become hittable")
+ return nil
}
- @MainActor
- private func assertUnverifiedDraft(_ app: XCUIApplication) -> Bool {
- guard openDrafts(app) else {
- XCTFail("The Drafts sheet did not open after the unavailable attempt")
- return false
- }
- let mediaStatus = app.descendants(matching: .any).matching(
- NSPredicate(format: "label CONTAINS '0 of 1 photos verified'")
- ).firstMatch
- let exists = mediaStatus.waitForExistence(timeout: 20)
- XCTAssertTrue(exists)
- if exists {
- XCTAssertTrue(
- mediaStatus.label == "0 of 1 photos verified"
- || mediaStatus.label == "0 of 1 photos verified; 1 possible orphan",
- "An unavailable upload did not preserve a bounded retry state; "
- + "media_status.label=\(mediaStatus.label)"
- )
- }
- app.buttons["Done"].tap()
- return exists
- }
-
- @MainActor
- private func waitForWorkToFinish(
- _ app: XCUIApplication,
- submit: XCUIElement,
- priorSubmitValue: String?
- ) -> Bool {
- let progress = app.descendants(matching: .any)["radroots.add.progress"]
- let finished = NSPredicate { _, _ in
- let submitValue = submit.value as? String
- return submit.exists
- && !progress.exists
- && submitValue != priorSubmitValue
- && submitValue != "Working"
- }
- let expectation = XCTNSPredicateExpectation(predicate: finished, object: app)
- return XCTWaiter.wait(for: [expectation], timeout: 180) == .completed
- }
-
- @MainActor
- private func tapSubmitUntilWorkStarts(
- _ app: XCUIApplication,
- submit: XCUIElement,
- priorSubmitValue: String?
- ) -> Bool {
- let progress = app.descendants(matching: .any)["radroots.add.progress"]
- let started = NSPredicate { _, _ in
- let submitValue = submit.value as? String
- return progress.exists
- || submitValue != priorSubmitValue
- }
+ let content = app.descendants(matching: .any)["radroots.add.content"]
+ var selectedPhotoUpdate = false
+ for _ in 0..<3 where !selectedPhotoUpdate {
+ photoUpdate.tap()
+ selectedPhotoUpdate = waitForValue(type, value: "Photo update", timeout: 10)
+ if !selectedPhotoUpdate, !photoUpdate.exists {
+ type.tap()
+ _ = photoUpdate.waitForExistence(timeout: 10)
+ }
+ }
+ guard selectedPhotoUpdate else {
+ XCTFail("Photo update selection did not update the Add composer type")
+ return nil
+ }
+ XCTAssertTrue(content.waitForExistence(timeout: 10))
+ guard waitUntilHittable(content, timeout: 10) else {
+ XCTFail("Photo update content did not become hittable")
+ return nil
+ }
+ content.tap()
+ content.typeText(marker)
+ let keyboardDone = app.buttons["radroots.add.keyboard.done"]
+ XCTAssertTrue(keyboardDone.waitForExistence(timeout: 10))
+ keyboardDone.tap()
+ XCTAssertFalse(app.keyboards.firstMatch.waitForExistence(timeout: 5))
+
+ let library = app.descendants(matching: .any)["radroots.add.media.library"]
+ XCTAssertTrue(library.waitForExistence(timeout: 10))
+ XCTAssertTrue(library.isEnabled)
+ library.tap()
+ let prepared = app.descendants(matching: .any).matching(
+ NSPredicate(
+ format: "identifier MATCHES %@",
+ #"radroots\.add\.media\.[0-9a-f]{64}"#
+ )
+ ).firstMatch
+ XCTAssertTrue(prepared.waitForExistence(timeout: 30))
+ return readySubmit(app)
+ }
+
+ @MainActor
+ private func publishTextFlow(
+ _ app: XCUIApplication,
+ type: String,
+ marker: String
+ ) throws {
+ try beginDraft(app, type: type)
+ try enterText(app, identifier: "radroots.add.content", value: marker)
+ try submitSuccessfully(app)
+ }
+
+ @MainActor
+ private func publishEvent(_ app: XCUIApplication, marker: String) throws {
+ try beginDraft(app, type: "Event")
+ try enterText(app, identifier: "radroots.add.title", value: marker)
+ try submitSuccessfully(app)
+ }
+
+ @MainActor
+ private func publishFood(_ app: XCUIApplication, marker: String) throws {
+ try beginDraft(app, type: "Food availability")
+ try enterText(app, identifier: "radroots.add.title", value: marker)
+ try enterText(app, identifier: "radroots.add.summary", value: "Fresh local food")
+ try enterText(app, identifier: "radroots.add.content", value: "Available today")
+ try enterText(app, identifier: "radroots.add.location", value: "Town square")
+ try enterText(app, identifier: "radroots.add.price", value: "3")
+ try enterText(app, identifier: "radroots.add.currency", value: "CAD")
+ let unit = app.descendants(matching: .any)["radroots.add.unit"]
+ scrollTo(app, element: unit)
+ XCTAssertTrue(unit.waitForExistence(timeout: 10))
+ unit.tap()
+ let pounds = app.buttons["lb"]
+ XCTAssertTrue(pounds.waitForExistence(timeout: 10))
+ pounds.tap()
+ try submitSuccessfully(app)
+ }
+
+ @MainActor
+ private func beginDraft(_ app: XCUIApplication, type: String) throws {
+ guard let picker = openAdd(app) else {
+ XCTFail("The Add bottom tab did not present the real Add store")
+ throw QualificationError.missingProductSurface
+ }
+ let newDraft = app.buttons["radroots.add.new"]
+ XCTAssertTrue(newDraft.waitForExistence(timeout: 10))
+ newDraft.tap()
+ if picker.value as? String == type {
+ return
+ }
+ picker.tap()
+ let option = app.buttons[type]
+ XCTAssertTrue(option.waitForExistence(timeout: 10))
+ option.tap()
+ guard waitForValue(picker, value: type, timeout: 10) else {
+ XCTFail("The Add type picker did not select \(type)")
+ throw QualificationError.missingProductSurface
+ }
+ }
+
+ @MainActor
+ private func enterText(
+ _ app: XCUIApplication,
+ identifier: String,
+ value: String
+ ) throws {
+ let field = app.descendants(matching: .any)[identifier]
+ scrollTo(app, element: field)
+ guard field.waitForExistence(timeout: 10), waitUntilHittable(field, timeout: 10) else {
+ XCTFail("The Add field \(identifier) was unavailable")
+ throw QualificationError.missingProductSurface
+ }
+ guard focusForTextInput(field, timeout: 10) else {
+ XCTFail("The Add field \(identifier) did not accept keyboard focus")
+ throw QualificationError.missingProductSurface
+ }
+ field.typeText(value)
+ let done = app.buttons["radroots.add.keyboard.done"]
+ if done.waitForExistence(timeout: 5) {
+ done.tap()
+ XCTAssertFalse(app.keyboards.firstMatch.waitForExistence(timeout: 5))
+ }
+ }
+
+ @MainActor
+ private func submitSuccessfully(_ app: XCUIApplication) throws {
+ guard let submit = readySubmit(app),
+ let value = submitAndWait(app, submit: submit)
+ else {
+ throw QualificationError.missingProductSurface
+ }
+ guard !value.contains("Error code") else {
+ XCTFail("The local-social flow failed: \(value)")
+ throw QualificationError.productSubmissionFailed
+ }
+ }
- for _ in 0 ..< 3 {
- submit.tap()
- let expectation = XCTNSPredicateExpectation(predicate: started, object: app)
- if XCTWaiter.wait(for: [expectation], timeout: 5) == .completed {
- return true
- }
- guard waitUntilHittable(submit, timeout: 5),
- isUnobscured(submit, above: app.tabBars.firstMatch)
- else {
- return false
- }
- }
- return false
+ @MainActor
+ private func scrollTo(_ app: XCUIApplication, element: XCUIElement) {
+ let root = app.descendants(matching: .any)["radroots.add.root"]
+ for _ in 0..<8 where !element.exists || !element.isHittable {
+ root.swipeUp()
+ }
+ }
+
+ @MainActor
+ private func assertTodayContains(_ app: XCUIApplication, markers: [String]) {
+ app.tabBars.buttons["Today"].tap()
+ let refresh = app.buttons["radroots.today.refresh"]
+ if refresh.waitForExistence(timeout: 5) {
+ refresh.tap()
+ }
+ let feed = app.descendants(matching: .any)["radroots.today.feed"].firstMatch
+ guard feed.waitForExistence(timeout: 30) else {
+ XCTFail("The local relay refresh did not materialize the Today feed")
+ return
+ }
+ for marker in markers.reversed() {
+ let card = app.descendants(matching: .any).matching(
+ NSPredicate(format: "label CONTAINS %@", marker)
+ ).firstMatch
+ for _ in 0..<6 where !card.exists {
+ feed.swipeUp()
+ }
+ XCTAssertTrue(card.waitForExistence(timeout: 30), "Missing Today card \(marker)")
+ }
+ }
+
+ @MainActor
+ private func readySubmit(_ app: XCUIApplication) -> XCUIElement? {
+ let submit = app.descendants(matching: .any)["radroots.add.submit"]
+ let addRoot = app.descendants(matching: .any)["radroots.add.root"]
+ for _ in 0..<8 {
+ if isUnobscured(submit, above: app.tabBars.firstMatch) {
+ break
+ }
+ addRoot.swipeUp()
+ }
+ guard submit.waitForExistence(timeout: 10),
+ submit.isEnabled,
+ waitUntilHittable(submit, timeout: 10),
+ isUnobscured(submit, above: app.tabBars.firstMatch)
+ else {
+ XCTFail(
+ "The Add submission control did not become unobscured; "
+ + submissionDiagnostics(app, submit: submit)
+ )
+ return nil
+ }
+ return submit
+ }
+
+ @MainActor
+ private func submitAndWait(_ app: XCUIApplication, submit: XCUIElement) -> String? {
+ let priorSubmitValue = submit.value as? String
+ guard
+ tapSubmitUntilWorkStarts(
+ app,
+ submit: submit,
+ priorSubmitValue: priorSubmitValue
+ )
+ else {
+ XCTFail(
+ "The Add submission tap did not start local work; "
+ + submissionDiagnostics(app, submit: submit)
+ )
+ return nil
+ }
+ guard
+ waitForWorkToFinish(
+ app,
+ submit: submit,
+ priorSubmitValue: priorSubmitValue
+ )
+ else {
+ XCTFail(
+ "The Add submission did not reach a terminal local state; "
+ + submissionDiagnostics(app, submit: submit)
+ )
+ return nil
+ }
+ return submit.value as? String ?? "missing"
+ }
+
+ @MainActor
+ private func assertUnverifiedDraft(_ app: XCUIApplication) -> Bool {
+ guard openDrafts(app) else {
+ XCTFail("The Drafts sheet did not open after the unavailable attempt")
+ return false
+ }
+ let mediaStatus = app.descendants(matching: .any).matching(
+ NSPredicate(format: "label CONTAINS '0 of 1 photos verified'")
+ ).firstMatch
+ let exists = mediaStatus.waitForExistence(timeout: 20)
+ XCTAssertTrue(exists)
+ if exists {
+ XCTAssertTrue(
+ mediaStatus.label == "0 of 1 photos verified"
+ || mediaStatus.label == "0 of 1 photos verified; 1 possible orphan",
+ "An unavailable upload did not preserve a bounded retry state; "
+ + "media_status.label=\(mediaStatus.label)"
+ )
+ }
+ app.buttons["Done"].tap()
+ return exists
+ }
+
+ @MainActor
+ private func waitForWorkToFinish(
+ _ app: XCUIApplication,
+ submit: XCUIElement,
+ priorSubmitValue: String?
+ ) -> Bool {
+ let progress = app.descendants(matching: .any)["radroots.add.progress"]
+ let finished = NSPredicate { _, _ in
+ let submitValue = submit.value as? String
+ return submit.exists
+ && !progress.exists
+ && submitValue != priorSubmitValue
+ && submitValue != "Working"
+ }
+ let expectation = XCTNSPredicateExpectation(predicate: finished, object: app)
+ return XCTWaiter.wait(for: [expectation], timeout: 180) == .completed
+ }
+
+ @MainActor
+ private func tapSubmitUntilWorkStarts(
+ _ app: XCUIApplication,
+ submit: XCUIElement,
+ priorSubmitValue: String?
+ ) -> Bool {
+ let progress = app.descendants(matching: .any)["radroots.add.progress"]
+ let started = NSPredicate { _, _ in
+ let submitValue = submit.value as? String
+ return progress.exists
+ || submitValue != priorSubmitValue
}
- @MainActor
- private func isUnobscured(_ element: XCUIElement, above obstruction: XCUIElement) -> Bool {
- guard element.exists, element.isEnabled, element.isHittable, obstruction.exists else {
- return false
- }
- let frame = element.frame
- return frame.height > 0
- && frame.minY >= 0
- && frame.maxY <= obstruction.frame.minY
- }
-
- @MainActor
- private func submissionDiagnostics(
- _ app: XCUIApplication,
- submit: XCUIElement
- ) -> String {
- let progress = app.descendants(matching: .any)["radroots.add.progress"]
- let status = app.staticTexts.matching(identifier: "radroots.add.status").firstMatch
- let statusLabel = status.exists ? status.label : "missing"
- let tabBar = app.tabBars.firstMatch
- return "submit.exists=\(submit.exists), submit.enabled=\(submit.isEnabled), "
- + "submit.hittable=\(submit.isHittable), submit.value=\(String(describing: submit.value)), "
- + "submit.frame=\(submit.frame), "
- + "tab_bar_top=\(tabBar.exists ? tabBar.frame.minY : -1), "
- + "progress.exists=\(progress.exists), status=\(statusLabel)"
- }
-
- @MainActor
- private func openDrafts(_ app: XCUIApplication) -> Bool {
- let drafts = app.buttons["radroots.add.drafts"]
- guard drafts.waitForExistence(timeout: 10) else { return false }
- let sheet = app.descendants(matching: .any)["radroots.add.drafts.sheet"]
- for _ in 0 ..< 3 {
- drafts.tap()
- if sheet.waitForExistence(timeout: 10) {
- return true
- }
- }
+ for _ in 0..<3 {
+ submit.tap()
+ let expectation = XCTNSPredicateExpectation(predicate: started, object: app)
+ if XCTWaiter.wait(for: [expectation], timeout: 5) == .completed {
+ return true
+ }
+ guard waitUntilHittable(submit, timeout: 5),
+ isUnobscured(submit, above: app.tabBars.firstMatch)
+ else {
return false
+ }
}
+ return false
+ }
- @MainActor
- private func waitUntilHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
- let predicate = NSPredicate(format: "exists == true AND hittable == true")
- let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
- return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
+ @MainActor
+ private func isUnobscured(_ element: XCUIElement, above obstruction: XCUIElement) -> Bool {
+ guard element.exists, element.isEnabled, element.isHittable, obstruction.exists else {
+ return false
}
-
- @MainActor
- private func waitForValue(
- _ element: XCUIElement,
- value: String,
- timeout: TimeInterval
- ) -> Bool {
- let predicate = NSPredicate { object, _ in
- guard let object = object as? XCUIElement else { return false }
- return object.exists && object.value as? String == value
- }
- let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
- return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
- }
-
- private func writeBootstrapReceipt(
- configuration: QualificationConfiguration,
- publicKey: String
- ) throws {
- let receipt = BootstrapReceipt(
- schema: "radroots-ios-remote-qualification-bootstrap-v1",
- schemaVersion: 1,
- runID: configuration.runID,
- publicKey: publicKey,
- relayURLs: configuration.relayURLs,
- blossomOrigins: configuration.blossomOrigins,
- privateKeyPresent: false,
- interactiveAuthenticationRequired: false
- )
- let encoder = JSONEncoder()
- encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
- let data = try encoder.encode(receipt)
- let documents = try XCTUnwrap(
- FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
- )
- try data.write(
- to: documents.appendingPathComponent(Self.receiptName),
- options: .atomic
- )
- let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json")
- attachment.name = Self.receiptName
- attachment.lifetime = .keepAlways
- add(attachment)
+ let frame = element.frame
+ return frame.height > 0
+ && frame.minY >= 0
+ && frame.maxY <= obstruction.frame.minY
+ }
+
+ @MainActor
+ private func submissionDiagnostics(
+ _ app: XCUIApplication,
+ submit: XCUIElement
+ ) -> String {
+ let progress = app.descendants(matching: .any)["radroots.add.progress"]
+ let status = app.staticTexts.matching(identifier: "radroots.add.status").firstMatch
+ let statusLabel = status.exists ? status.label : "missing"
+ let tabBar = app.tabBars.firstMatch
+ return "submit.exists=\(submit.exists), submit.enabled=\(submit.isEnabled), "
+ + "submit.hittable=\(submit.isHittable), submit.value=\(String(describing: submit.value)), "
+ + "submit.frame=\(submit.frame), "
+ + "tab_bar_top=\(tabBar.exists ? tabBar.frame.minY : -1), "
+ + "progress.exists=\(progress.exists), status=\(statusLabel)"
+ }
+
+ @MainActor
+ private func openDrafts(_ app: XCUIApplication) -> Bool {
+ let drafts = app.buttons["radroots.add.drafts"]
+ guard drafts.waitForExistence(timeout: 10) else { return false }
+ let sheet = app.descendants(matching: .any)["radroots.add.drafts.sheet"]
+ for _ in 0..<3 {
+ drafts.tap()
+ if sheet.waitForExistence(timeout: 10) {
+ return true
+ }
}
+ return false
+ }
+
+ @MainActor
+ private func waitUntilHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
+ let predicate = NSPredicate(format: "exists == true AND hittable == true")
+ let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
+ return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
+ }
+
+ @MainActor
+ private func focusForTextInput(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
+ let focused = NSPredicate(format: "hasKeyboardFocus == true")
+ for _ in 0..<3 {
+ element.tap()
+ let expectation = XCTNSPredicateExpectation(predicate: focused, object: element)
+ if XCTWaiter.wait(for: [expectation], timeout: timeout / 3) == .completed {
+ return true
+ }
+ }
+ return false
+ }
+
+ @MainActor
+ private func waitForValue(
+ _ element: XCUIElement,
+ value: String,
+ timeout: TimeInterval
+ ) -> Bool {
+ let predicate = NSPredicate { object, _ in
+ guard let object = object as? XCUIElement else { return false }
+ return object.exists && object.value as? String == value
+ }
+ let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
+ return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
+ }
+
+ @MainActor
+ private func waitForLabel(
+ _ element: XCUIElement,
+ label: String,
+ timeout: TimeInterval
+ ) -> Bool {
+ let predicate = NSPredicate { object, _ in
+ guard let object = object as? XCUIElement else { return false }
+ return object.exists && object.label == label
+ }
+ let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
+ return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
+ }
+
+ private func writeBootstrapReceipt(
+ configuration: QualificationConfiguration,
+ publicKey: String
+ ) throws {
+ let receipt = BootstrapReceipt(
+ schema: "radroots-ios-remote-qualification-bootstrap-v1",
+ schemaVersion: 1,
+ runID: configuration.runID,
+ publicKey: publicKey,
+ relayURLs: configuration.relayURLs,
+ blossomOrigins: configuration.blossomOrigins,
+ privateKeyPresent: false,
+ interactiveAuthenticationRequired: false
+ )
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ let data = try encoder.encode(receipt)
+ let documents = try XCTUnwrap(
+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
+ )
+ try data.write(
+ to: documents.appendingPathComponent(Self.receiptName),
+ options: .atomic
+ )
+ let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json")
+ attachment.name = Self.receiptName
+ attachment.lifetime = .keepAlways
+ add(attachment)
+ }
}
private struct QualificationConfiguration {
- static let enabledKey = "RADROOTS_IOS_UI_TEST_REMOTE"
- static let runIDKey = "RADROOTS_IOS_UI_TEST_RUN_ID"
- static let relayURLsKey = "RADROOTS_IOS_UI_TEST_NOSTR_RELAY_URLS"
- static let blossomOriginsKey = "RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS"
- static let mediaRelativePathKey = "RADROOTS_IOS_UI_TEST_MEDIA_RELATIVE_PATH"
-
- let runID: String
- let relayURLs: [String]
- let blossomOrigins: [String]
-
- var launchEnvironment: [String: String] {
- [
- Self.enabledKey: "1",
- Self.runIDKey: runID,
- Self.relayURLsKey: relayURLs.joined(separator: ","),
- Self.blossomOriginsKey: blossomOrigins.joined(separator: ","),
- Self.mediaRelativePathKey: "qualification/input.png",
- "RUST_BACKTRACE": "1",
- ]
- }
-
- static func environment(
- _ values: [String: String] = ProcessInfo.processInfo.environment
- ) throws -> Self {
- let bundle = Bundle(for: RadrootsRemoteQualificationUITests.self)
- let runIDValue = values[runIDKey]
- ?? bundle.object(forInfoDictionaryKey: runIDKey) as? String
- let relayValue = values[relayURLsKey]
- ?? bundle.object(forInfoDictionaryKey: relayURLsKey) as? String
- let blossomValue = values[blossomOriginsKey]
- ?? bundle.object(forInfoDictionaryKey: blossomOriginsKey) as? String
- if runIDValue?.isEmpty != false, blossomValue?.isEmpty != false {
- throw XCTSkip("remote qualification inputs were not selected")
- }
- guard let runID = runIDValue,
- runID.range(of: "^[a-z0-9][a-z0-9-]{6,62}[a-z0-9]$", options: .regularExpression) != nil,
- let blossom = blossomValue,
- !blossom.isEmpty
- else {
- throw QualificationError.missingEnvironment
- }
- return Self(
- runID: runID,
- relayURLs: separated(relayValue),
- blossomOrigins: separated(blossom)
- )
+ static let enabledKey = "RADROOTS_IOS_UI_TEST_REMOTE"
+ static let runIDKey = "RADROOTS_IOS_UI_TEST_RUN_ID"
+ static let relayURLsKey = "RADROOTS_IOS_UI_TEST_NOSTR_RELAY_URLS"
+ static let blossomOriginsKey = "RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS"
+ static let fixtureControlKey = "RADROOTS_IOS_UI_TEST_FIXTURE_CONTROL"
+ static let mediaRelativePathKey = "RADROOTS_IOS_UI_TEST_MEDIA_RELATIVE_PATH"
+ static let networkProfileKey = "RADROOTS_IOS_UI_TEST_NETWORK_PROFILE"
+
+ let runID: String
+ let relayURLs: [String]
+ let blossomOrigins: [String]
+ let fixtureControl: String?
+ let networkProfile: String
+
+ init(
+ runID: String,
+ relayURLs: [String],
+ blossomOrigins: [String],
+ fixtureControl: String? = nil,
+ networkProfile: String = "public"
+ ) {
+ self.runID = runID
+ self.relayURLs = relayURLs
+ self.blossomOrigins = blossomOrigins
+ self.fixtureControl = fixtureControl
+ self.networkProfile = networkProfile
+ }
+
+ var launchEnvironment: [String: String] {
+ [
+ Self.enabledKey: "1",
+ Self.runIDKey: runID,
+ Self.relayURLsKey: relayURLs.joined(separator: ","),
+ Self.blossomOriginsKey: blossomOrigins.joined(separator: ","),
+ Self.mediaRelativePathKey: "qualification/input.png",
+ Self.networkProfileKey: networkProfile,
+ "RUST_BACKTRACE": "1",
+ ]
+ }
+
+ static func environment(
+ _ values: [String: String] = ProcessInfo.processInfo.environment
+ ) throws -> Self {
+ let bundle = Bundle(for: RadrootsRemoteQualificationUITests.self)
+ let runIDValue =
+ values[runIDKey]
+ ?? bundle.object(forInfoDictionaryKey: runIDKey) as? String
+ let relayValue =
+ values[relayURLsKey]
+ ?? bundle.object(forInfoDictionaryKey: relayURLsKey) as? String
+ let blossomValue =
+ values[blossomOriginsKey]
+ ?? bundle.object(forInfoDictionaryKey: blossomOriginsKey) as? String
+ let fixtureControl =
+ values[fixtureControlKey]
+ ?? bundle.object(forInfoDictionaryKey: fixtureControlKey) as? String
+ let networkProfile =
+ values[networkProfileKey]
+ ?? bundle.object(forInfoDictionaryKey: networkProfileKey) as? String
+ if runIDValue?.isEmpty != false, blossomValue?.isEmpty != false {
+ throw XCTSkip("remote qualification inputs were not selected")
}
-
- private static func separated(_ value: String?) -> [String] {
- (value ?? "").split(separator: ",").map(String.init).filter { !$0.isEmpty }
+ guard let runID = runIDValue,
+ runID.range(of: "^[a-z0-9][a-z0-9-]{6,62}[a-z0-9]$", options: .regularExpression) != nil,
+ let blossom = blossomValue,
+ !blossom.isEmpty,
+ let networkProfile,
+ networkProfile == "public" || networkProfile == "simulator"
+ else {
+ throw QualificationError.missingEnvironment
}
+ return Self(
+ runID: runID,
+ relayURLs: separated(relayValue),
+ blossomOrigins: separated(blossom),
+ fixtureControl: fixtureControl.flatMap { $0.isEmpty ? nil : $0 },
+ networkProfile: networkProfile
+ )
+ }
+
+ private static func separated(_ value: String?) -> [String] {
+ (value ?? "").split(separator: ",").map(String.init).filter { !$0.isEmpty }
+ }
}
private struct BootstrapReceipt: Codable {
- let schema: String
- let schemaVersion: UInt16
- let runID: String
- let publicKey: String
- let relayURLs: [String]
- let blossomOrigins: [String]
- let privateKeyPresent: Bool
- let interactiveAuthenticationRequired: Bool
+ let schema: String
+ let schemaVersion: UInt16
+ let runID: String
+ let publicKey: String
+ let relayURLs: [String]
+ let blossomOrigins: [String]
+ let privateKeyPresent: Bool
+ let interactiveAuthenticationRequired: Bool
}
private enum QualificationError: Error {
- case missingEnvironment
- case invalidPublicKey
+ case missingEnvironment
+ case invalidPublicKey
+ case missingProductSurface
+ case productSubmissionFailed
}
diff --git a/project.yml b/project.yml
@@ -13,7 +13,7 @@ packages:
path: .
RadrootsKit:
url: https://github.com/radrootslabs/apple_kit.git
- revision: 86f5548e9e00c2b7ad3c1d6837bff8bc41bd0f24
+ revision: e61386529422a6413a852f0233a196a88f4ded51
targets:
Radroots:
diff --git a/scripts/local-social-fixture.py b/scripts/local-social-fixture.py
@@ -0,0 +1,464 @@
+#!/usr/bin/env python3
+"""Bounded loopback Nostr and Blossom fixture for iOS simulator tests."""
+
+from __future__ import annotations
+
+import argparse
+import base64
+import hashlib
+import http.server
+import json
+import os
+import signal
+import socket
+import socketserver
+import struct
+import threading
+import time
+from pathlib import Path
+from typing import Any
+
+MAX_HTTP_BODY = 16 * 1024 * 1024
+MAX_WEBSOCKET_MESSAGE = 2 * 1024 * 1024
+MAX_EVENTS = 256
+MAX_BLOBS = 16
+
+
+class FixtureState:
+ def __init__(self, evidence: Path, control: Path, blossom_port: int) -> None:
+ self._evidence = evidence
+ self.control = control
+ self.blossom_port = blossom_port
+ self._lock = threading.Lock()
+ self._events: dict[str, dict[str, Any]] = {}
+ self._blobs: dict[str, tuple[bytes, str]] = {}
+ self._upload_attempts = 0
+ self._accepted_uploads = 0
+ self._retrievals = 0
+ self._subscriptions = 0
+ self._write_evidence()
+
+ def publish(self, event: dict[str, Any]) -> bool:
+ if not valid_nostr_event(event):
+ return False
+ event_id = event["id"]
+ with self._lock:
+ if event_id not in self._events and len(self._events) >= MAX_EVENTS:
+ return False
+ self._events[event_id] = event
+ self._write_evidence_locked()
+ return True
+
+ def query(self, filters: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ with self._lock:
+ self._subscriptions += 1
+ events = list(self._events.values())
+ self._write_evidence_locked()
+ selected = [event for event in events if any(matches(event, item) for item in filters)]
+ selected.sort(key=lambda item: (item.get("created_at", 0), item.get("id", "")))
+ limits = [
+ item["limit"]
+ for item in filters
+ if isinstance(item, dict) and type(item.get("limit")) is int
+ ]
+ count = min(max(max(limits, default=len(selected)), 0), MAX_EVENTS)
+ return selected[-count:] if count else []
+
+ def upload(self, body: bytes, media_type: str, expected_hash: str) -> tuple[bool, dict[str, Any]]:
+ digest = hashlib.sha256(body).hexdigest()
+ with self._lock:
+ self._upload_attempts += 1
+ allowed = self.control.is_file()
+ capacity = digest in self._blobs or len(self._blobs) < MAX_BLOBS
+ if allowed and capacity and digest == expected_hash:
+ self._blobs[digest] = (body, media_type)
+ self._accepted_uploads += 1
+ self._write_evidence_locked()
+ descriptor = {
+ "url": f"http://127.0.0.1:{self.blossom_port}/{digest}.png",
+ "sha256": digest,
+ "size": len(body),
+ "type": media_type,
+ "uploaded": int(time.time()),
+ }
+ return allowed and capacity and digest == expected_hash, descriptor
+
+ def retrieve(self, digest: str) -> tuple[bytes, str] | None:
+ with self._lock:
+ value = self._blobs.get(digest)
+ if value is not None:
+ self._retrievals += 1
+ self._write_evidence_locked()
+ return value
+
+ def _write_evidence(self) -> None:
+ with self._lock:
+ self._write_evidence_locked()
+
+ def _write_evidence_locked(self) -> None:
+ payload = {
+ "schema": "radroots-ios-local-social-fixture-evidence-v1",
+ "schema_version": 1,
+ "accepted_events": len(self._events),
+ "event_kinds": sorted(
+ event["kind"]
+ for event in self._events.values()
+ if isinstance(event.get("kind"), int)
+ ),
+ "subscriptions": self._subscriptions,
+ "upload_attempts": self._upload_attempts,
+ "accepted_uploads": self._accepted_uploads,
+ "retrievals": self._retrievals,
+ }
+ temporary = self._evidence.with_suffix(".tmp")
+ temporary.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8")
+ os.replace(temporary, self._evidence)
+
+
+def matches(event: dict[str, Any], item: dict[str, Any]) -> bool:
+ if not isinstance(item, dict):
+ return False
+ if isinstance(item.get("ids"), list) and event.get("id") not in item["ids"]:
+ return False
+ if isinstance(item.get("authors"), list) and event.get("pubkey") not in item["authors"]:
+ return False
+ if isinstance(item.get("kinds"), list) and event.get("kind") not in item["kinds"]:
+ return False
+ created_at = event.get("created_at")
+ if not isinstance(created_at, int):
+ return False
+ if isinstance(item.get("since"), int) and created_at < item["since"]:
+ return False
+ if isinstance(item.get("until"), int) and created_at > item["until"]:
+ return False
+ tags = event.get("tags")
+ if not isinstance(tags, list):
+ return False
+ for key, expected in item.items():
+ if not key.startswith("#") or not isinstance(expected, list):
+ continue
+ name = key[1:]
+ if not any(
+ isinstance(tag, list)
+ and len(tag) >= 2
+ and tag[0] == name
+ and tag[1] in expected
+ for tag in tags
+ ):
+ return False
+ return True
+
+
+def valid_nostr_event(event: dict[str, Any]) -> bool:
+ if set(event) != {"id", "pubkey", "created_at", "kind", "tags", "content", "sig"}:
+ return False
+ if not lowercase_hex(event["id"], 64) or not lowercase_hex(event["pubkey"], 64):
+ return False
+ if not lowercase_hex(event["sig"], 128):
+ return False
+ if type(event["created_at"]) is not int or event["created_at"] < 0:
+ return False
+ if type(event["kind"]) is not int or not 0 <= event["kind"] <= 0xFFFF_FFFF:
+ return False
+ if not isinstance(event["content"], str) or not isinstance(event["tags"], list):
+ return False
+ if not all(
+ isinstance(tag, list) and all(isinstance(value, str) for value in tag)
+ for tag in event["tags"]
+ ):
+ return False
+ preimage = json.dumps(
+ [
+ 0,
+ event["pubkey"],
+ event["created_at"],
+ event["kind"],
+ event["tags"],
+ event["content"],
+ ],
+ ensure_ascii=False,
+ separators=(",", ":"),
+ ).encode("utf-8")
+ return hashlib.sha256(preimage).hexdigest() == event["id"]
+
+
+def lowercase_hex(value: Any, length: int) -> bool:
+ return (
+ isinstance(value, str)
+ and len(value) == length
+ and all(character in "0123456789abcdef" for character in value)
+ )
+
+
+class ReusableThreadingServer(socketserver.ThreadingTCPServer):
+ allow_reuse_address = True
+ daemon_threads = True
+
+
+class RelayHandler(socketserver.BaseRequestHandler):
+ state: FixtureState
+
+ def handle(self) -> None:
+ self.request.settimeout(15)
+ headers = read_until(self.request, b"\r\n\r\n", 16 * 1024)
+ lines = headers.decode("ascii").split("\r\n")
+ if not lines or lines[0] != "GET / HTTP/1.1":
+ return
+ fields = {}
+ for line in lines[1:]:
+ if ":" in line:
+ key, value = line.split(":", 1)
+ fields[key.lower()] = value.strip()
+ key = fields.get("sec-websocket-key")
+ if not key or fields.get("upgrade", "").lower() != "websocket":
+ return
+ accept = base64.b64encode(
+ hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()
+ ).decode()
+ self.request.sendall(
+ (
+ "HTTP/1.1 101 Switching Protocols\r\n"
+ "Upgrade: websocket\r\n"
+ "Connection: Upgrade\r\n"
+ f"Sec-WebSocket-Accept: {accept}\r\n\r\n"
+ ).encode("ascii")
+ )
+ while True:
+ frame = read_frame(self.request)
+ if frame is None:
+ return
+ opcode, payload = frame
+ if opcode == 0x8:
+ return
+ if opcode == 0x9:
+ send_frame(self.request, 0xA, payload)
+ continue
+ if opcode != 0x1:
+ continue
+ try:
+ message = json.loads(payload)
+ except (UnicodeDecodeError, json.JSONDecodeError):
+ continue
+ if not isinstance(message, list) or not message:
+ continue
+ if message[0] == "EVENT" and len(message) == 2 and isinstance(message[1], dict):
+ event_id = message[1].get("id", "")
+ accepted = self.state.publish(message[1])
+ send_json(self.request, ["OK", event_id, accepted, "" if accepted else "invalid"])
+ elif (
+ message[0] == "REQ"
+ and len(message) >= 3
+ and isinstance(message[1], str)
+ ):
+ subscription = message[1]
+ filters = [value for value in message[2:] if isinstance(value, dict)]
+ for event in self.state.query(filters):
+ send_json(self.request, ["EVENT", subscription, event])
+ send_json(self.request, ["EOSE", subscription])
+
+
+def read_until(stream: socket.socket, marker: bytes, maximum: int) -> bytes:
+ value = bytearray()
+ while marker not in value:
+ chunk = stream.recv(4096)
+ if not chunk:
+ raise ConnectionError("socket closed")
+ value.extend(chunk)
+ if len(value) > maximum:
+ raise ValueError("request too large")
+ return bytes(value)
+
+
+def read_exact(stream: socket.socket, length: int) -> bytes:
+ value = bytearray()
+ while len(value) < length:
+ chunk = stream.recv(length - len(value))
+ if not chunk:
+ raise ConnectionError("socket closed")
+ value.extend(chunk)
+ return bytes(value)
+
+
+def read_frame(stream: socket.socket) -> tuple[int, bytes] | None:
+ try:
+ first, second = read_exact(stream, 2)
+ except (ConnectionError, OSError, TimeoutError):
+ return None
+ if first & 0x80 == 0 or second & 0x80 == 0:
+ return None
+ length = second & 0x7F
+ if length == 126:
+ length = struct.unpack("!H", read_exact(stream, 2))[0]
+ elif length == 127:
+ length = struct.unpack("!Q", read_exact(stream, 8))[0]
+ if length > MAX_WEBSOCKET_MESSAGE:
+ return None
+ mask = read_exact(stream, 4)
+ payload = bytearray(read_exact(stream, length))
+ for index in range(length):
+ payload[index] ^= mask[index % 4]
+ return first & 0x0F, bytes(payload)
+
+
+def send_frame(stream: socket.socket, opcode: int, payload: bytes) -> None:
+ length = len(payload)
+ header = bytearray([0x80 | opcode])
+ if length < 126:
+ header.append(length)
+ elif length <= 0xFFFF:
+ header.append(126)
+ header.extend(struct.pack("!H", length))
+ else:
+ header.append(127)
+ header.extend(struct.pack("!Q", length))
+ stream.sendall(header + payload)
+
+
+def send_json(stream: socket.socket, value: Any) -> None:
+ send_frame(stream, 0x1, json.dumps(value, separators=(",", ":")).encode())
+
+
+class BlossomHandler(http.server.BaseHTTPRequestHandler):
+ state: FixtureState
+ protocol_version = "HTTP/1.1"
+
+ def do_PUT(self) -> None: # noqa: N802
+ component = self.path.removeprefix("/")
+ if (
+ not component.endswith(".png")
+ or len(component) != 68
+ or any(character not in "0123456789abcdef" for character in component[:-4])
+ ):
+ self.send_error(404)
+ return
+ try:
+ length = int(self.headers.get("Content-Length", "-1"))
+ except ValueError:
+ length = -1
+ authorization = self.headers.get("Authorization", "")
+ expected_hash = component[:-4]
+ media_type = self.headers.get("Content-Type", "")
+ if (
+ length < 1
+ or length > MAX_HTTP_BODY
+ or not authorization.startswith("Nostr ")
+ or media_type != "image/png"
+ ):
+ self.send_error(400)
+ return
+ body = self.rfile.read(length)
+ accepted, descriptor = self.state.upload(body, media_type, expected_hash)
+ if not accepted:
+ self.respond(503, b'{"error":"fixture_retry_required"}', "application/json")
+ return
+ self.respond(200, json.dumps(descriptor, separators=(",", ":")).encode(), "application/json")
+
+ def do_GET(self) -> None: # noqa: N802
+ component = self.path.removeprefix("/")
+ if component.endswith(".png") and lowercase_hex(component[:-4], 64):
+ value = self.state.retrieve(component[:-4])
+ if value is not None:
+ self.respond(200, value[0], value[1])
+ return
+ self.respond(404, b'{"error":"not_found"}', "application/json")
+
+ def respond(self, status: int, body: bytes, content_type: str) -> None:
+ self.send_response(status)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(body)))
+ self.send_header("Connection", "close")
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, format: str, *args: Any) -> None:
+ return
+
+
+def serve(arguments: argparse.Namespace) -> int:
+ evidence = Path(arguments.evidence).resolve()
+ ready = Path(arguments.ready).resolve()
+ control = Path(arguments.control).resolve()
+ for path in (evidence, ready, control):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ control.unlink(missing_ok=True)
+ ready.unlink(missing_ok=True)
+ state = FixtureState(evidence, control, arguments.blossom_port)
+ RelayHandler.state = state
+ BlossomHandler.state = state
+ relay = ReusableThreadingServer(("127.0.0.1", arguments.relay_port), RelayHandler)
+ blossom = http.server.ThreadingHTTPServer(
+ ("127.0.0.1", arguments.blossom_port), BlossomHandler
+ )
+ threads = [
+ threading.Thread(target=relay.serve_forever, daemon=True),
+ threading.Thread(target=blossom.serve_forever, daemon=True),
+ ]
+ for thread in threads:
+ thread.start()
+ ready.write_text(
+ json.dumps(
+ {
+ "schema": "radroots-ios-local-social-fixture-ready-v1",
+ "relay": f"ws://127.0.0.1:{arguments.relay_port}",
+ "blossom": f"http://127.0.0.1:{arguments.blossom_port}",
+ },
+ sort_keys=True,
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ stopped = threading.Event()
+
+ def stop(_signum: int, _frame: Any) -> None:
+ stopped.set()
+
+ signal.signal(signal.SIGTERM, stop)
+ signal.signal(signal.SIGINT, stop)
+ stopped.wait()
+ relay.shutdown()
+ blossom.shutdown()
+ relay.server_close()
+ blossom.server_close()
+ for thread in threads:
+ thread.join(timeout=5)
+ return 0
+
+
+def verify(arguments: argparse.Namespace) -> int:
+ payload = json.loads(Path(arguments.evidence).read_text(encoding="utf-8"))
+ if (
+ payload.get("schema") != "radroots-ios-local-social-fixture-evidence-v1"
+ or payload.get("accepted_events", 0) < 5
+ or payload.get("subscriptions", 0) < 1
+ or payload.get("upload_attempts", 0) < 1
+ or payload.get("accepted_uploads", 0) < 1
+ or payload.get("retrievals", 0) < 1
+ ):
+ raise SystemExit("local-social fixture evidence is incomplete")
+ print("local-social fixture evidence verified")
+ return 0
+
+
+def parser() -> argparse.ArgumentParser:
+ root = argparse.ArgumentParser()
+ commands = root.add_subparsers(dest="command", required=True)
+ serve_command = commands.add_parser("serve")
+ serve_command.add_argument("--relay-port", type=int, required=True)
+ serve_command.add_argument("--blossom-port", type=int, required=True)
+ serve_command.add_argument("--evidence", required=True)
+ serve_command.add_argument("--ready", required=True)
+ serve_command.add_argument("--control", required=True)
+ verify_command = commands.add_parser("verify")
+ verify_command.add_argument("--evidence", required=True)
+ return root
+
+
+def main() -> int:
+ arguments = parser().parse_args()
+ if arguments.command == "serve":
+ return serve(arguments)
+ return verify(arguments)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/verify-package-contract.sh b/scripts/verify-package-contract.sh
@@ -44,6 +44,7 @@ grep -Fq "NSPrivacyAccessedAPICategoryUserDefaults" \
grep -Fq "CA92.1" "$repo_root/Radroots/Resources/PrivacyInfo.xcprivacy"
plutil -lint "$repo_root/Radroots/Resources/PrivacyInfo.xcprivacy" >/dev/null
plutil -lint "$repo_root/Radroots/Info.plist" >/dev/null
+plutil -lint "$repo_root/RadrootsUITests/Info.plist" >/dev/null
grep -Fq '<key>NSCameraUsageDescription</key>' "$repo_root/Radroots/Info.plist"
grep -Fq '<key>NSFaceIDUsageDescription</key>' "$repo_root/Radroots/Info.plist"
@@ -75,6 +76,17 @@ grep -Fq 'RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS = wss:$(SLASH)$(SLASH)radroots.org
"$repo_root/Radroots/Config/Base.xcconfig"
grep -Fq 'RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS = https:$(SLASH)$(SLASH)blossom.radroots.org' \
"$repo_root/Radroots/Config/Base.xcconfig"
+grep -Fq '<key>RADROOTS_IOS_UI_TEST_FIXTURE_CONTROL</key>' \
+ "$repo_root/RadrootsUITests/Info.plist"
+grep -Fq '<key>RADROOTS_IOS_UI_TEST_NETWORK_PROFILE</key>' \
+ "$repo_root/RadrootsUITests/Info.plist"
+grep -Fq 'local-social-ui-test)' "$repo_root/scripts/xcode.sh"
+grep -Fq 'RadrootsUITests/RadrootsRemoteQualificationUITests/testLocalSocialFiveFlowScenario' \
+ "$repo_root/scripts/xcode.sh"
+grep -Fq '#if DEBUG' "$repo_root/Radroots/App/RadrootsRemoteQualification.swift"
+grep -Fq 'case "simulator": runtimeMode = "simulator"' \
+ "$repo_root/Radroots/App/RadrootsRemoteQualification.swift"
+test -f "$repo_root/scripts/local-social-fixture.py"
if rg -n \
'AsyncImage|URLSession\.shared|Data\(contentsOf:[[:space:]]*URL' \
diff --git a/scripts/xcode.sh b/scripts/xcode.sh
@@ -132,6 +132,93 @@ case "$operation" in
"CODE_SIGN_IDENTITY=Apple Development" \
build
;;
+ local-social-ui-test)
+ destination=${2:?local-social-ui-test requires a simulator destination}
+ result_name=${3:?local-social-ui-test requires a result name}
+ qualification_run_id=${RADROOTS_IOS_UI_TEST_RUN_ID:?RADROOTS_IOS_UI_TEST_RUN_ID is required}
+ relay_port=${RADROOTS_IOS_LOCAL_SOCIAL_RELAY_PORT:-21000}
+ blossom_port=${RADROOTS_IOS_LOCAL_SOCIAL_BLOSSOM_PORT:-21100}
+ if [[ ! "$destination" =~ ^platform=iOS\ Simulator,id=[A-Fa-f0-9-]+$ ]]; then
+ echo "error: local-social-ui-test destination must be one exact simulator id" >&2
+ exit 64
+ fi
+ if [[ ! "$result_name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]; then
+ echo "error: local-social-ui-test result name is invalid" >&2
+ exit 64
+ fi
+ if [[ ! "$qualification_run_id" =~ ^[a-z0-9][a-z0-9-]{6,62}[a-z0-9]$ ]]; then
+ echo "error: local-social-ui-test run id is invalid" >&2
+ exit 64
+ fi
+ for port in "$relay_port" "$blossom_port"; do
+ if [[ ! "$port" =~ ^[0-9]+$ ]] || ((port < 1024 || port > 65535)); then
+ echo "error: local-social-ui-test port is invalid" >&2
+ exit 64
+ fi
+ done
+ if [[ "$relay_port" == "$blossom_port" ]]; then
+ echo "error: local-social-ui-test ports must be distinct" >&2
+ exit 64
+ fi
+ : "${XCODE_RESULTS:?XCODE_RESULTS is required}"
+ mkdir -p "$XCODE_RESULTS"
+ result_bundle="$XCODE_RESULTS/$result_name.xcresult"
+ evidence="$XCODE_RESULTS/$result_name.fixture.json"
+ ready="$XCODE_RESULTS/$result_name.ready.json"
+ control="$XCODE_RESULTS/$result_name.enable-uploads"
+ if [[ -e "$result_bundle" || -e "$evidence" || -e "$ready" || -e "$control" ]]; then
+ echo "error: local-social-ui-test result already exists: $result_name" >&2
+ exit 1
+ fi
+ python3 scripts/local-social-fixture.py serve \
+ --relay-port "$relay_port" \
+ --blossom-port "$blossom_port" \
+ --evidence "$evidence" \
+ --ready "$ready" \
+ --control "$control" &
+ fixture_pid=$!
+ cleanup_fixture() {
+ kill "$fixture_pid" 2>/dev/null || true
+ wait "$fixture_pid" 2>/dev/null || true
+ }
+ trap cleanup_fixture EXIT INT TERM
+ for _ in {1..100}; do
+ [[ -f "$ready" ]] && break
+ kill -0 "$fixture_pid" 2>/dev/null || {
+ echo "error: local-social fixture exited before readiness" >&2
+ exit 1
+ }
+ sleep 0.1
+ done
+ if [[ ! -f "$ready" ]]; then
+ echo "error: local-social fixture readiness timed out" >&2
+ exit 1
+ fi
+ set +e
+ xcodebuild \
+ -project Radroots.xcodeproj \
+ -scheme Radroots \
+ -configuration Debug \
+ -destination "$destination" \
+ "${output_args[@]}" \
+ "${offline_args[@]}" \
+ -resultBundlePath "$result_bundle" \
+ "-only-testing:RadrootsUITests/RadrootsRemoteQualificationUITests/testLocalSocialFiveFlowScenario" \
+ "RADROOTS_IOS_UI_TEST_RUN_ID=$qualification_run_id" \
+ "RADROOTS_IOS_UI_TEST_NOSTR_RELAY_URLS=ws://127.0.0.1:$relay_port" \
+ "RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS=http://127.0.0.1:$blossom_port" \
+ "RADROOTS_IOS_UI_TEST_FIXTURE_CONTROL=$control" \
+ "RADROOTS_IOS_UI_TEST_NETWORK_PROFILE=simulator" \
+ test
+ test_status=$?
+ set -e
+ cleanup_fixture
+ trap - EXIT INT TERM
+ if ((test_status != 0)); then
+ exit "$test_status"
+ fi
+ python3 scripts/local-social-fixture.py verify --evidence "$evidence"
+ ;;
remote-ui-test)
destination=${2:?remote-ui-test requires a simulator destination}
test_selector=${3:?remote-ui-test requires a RadrootsUITests selector}
@@ -173,6 +260,7 @@ case "$operation" in
"RADROOTS_IOS_UI_TEST_RUN_ID=$qualification_run_id" \
"RADROOTS_IOS_UI_TEST_NOSTR_RELAY_URLS=$relay_urls" \
"RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS=$blossom_origins" \
+ "RADROOTS_IOS_UI_TEST_NETWORK_PROFILE=public" \
test
;;
physical-ui-build|physical-ui-test)
@@ -237,7 +325,8 @@ case "$operation" in
"CODE_SIGN_IDENTITY=Apple Development" \
"RADROOTS_IOS_UI_TEST_RUN_ID=$qualification_run_id" \
"RADROOTS_IOS_UI_TEST_NOSTR_RELAY_URLS=$relay_urls" \
- "RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS=$blossom_origins"
+ "RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS=$blossom_origins" \
+ "RADROOTS_IOS_UI_TEST_NETWORK_PROFILE=public"
)
if [[ "$operation" == "physical-ui-build" ]]; then
exec xcodebuild "${physical_args[@]}" build-for-testing
@@ -258,7 +347,7 @@ case "$operation" in
test-without-building
;;
*)
- echo "usage: $0 {resolve|package-build|package-test|project-build|project-test|physical-app-build|remote-ui-test|physical-ui-build|physical-ui-test}" >&2
+ echo "usage: $0 {resolve|package-build|package-test|project-build|project-test|local-social-ui-test|remote-ui-test|physical-ui-build|physical-ui-test}" >&2
exit 64
;;
esac