commit 61a74b5814e2c80169b1ad0608512cc8ee9045f9
parent 4c9510c419ed1f3b23f404589abce08d763daa38
Author: triesap <tyson@radroots.org>
Date: Sat, 8 Aug 2026 00:34:52 +0000
refactor(ios): centralize state and identity migration
- Replace the field app state object with bounded configuration, identity, and session actors.
- Bind the generated host signer to Apple custody without leaking generated models.
- Match Rust relay and Blossom profile validation and preserve corrupt-state classification.
- Prove idempotent migration, stable identity, lifecycle, and Debug and Release builds.
Diffstat:
21 files changed, 1639 insertions(+), 1736 deletions(-)
diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj
@@ -8,7 +8,9 @@
/* Begin PBXBuildFile section */
27F796571E92A6589E0111E3 /* RadrootsKitBindings.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FF8CEF5491290B1218ACFCE7 /* RadrootsKitBindings.framework */; };
+ 41B5CB28ACA51221A1BA59D5 /* RadrootsSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76F84D4706216F743E373F2A /* RadrootsSessionStore.swift */; };
47D9564F81A5E7CEA86B759C /* RadrootsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57C4A7EB045E43F109C4DCA1 /* RadrootsProvider.swift */; };
+ 51FE0A95B71A5D4EDCEB0748 /* RadrootsStateMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C26D512B6CBD1A9BE4780682 /* RadrootsStateMigrationTests.swift */; };
5DAE76BBC71E4A192E029785 /* RadrootsGeneratedRuntimeBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B14CE9958D4C71AFB41D2DD /* RadrootsGeneratedRuntimeBackend.swift */; };
69326B7DA05C115D0055AD13 /* RadrootsKitBindings.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = FF8CEF5491290B1218ACFCE7 /* RadrootsKitBindings.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
74082509C257766A166662FA /* RadrootsAppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343F115F383EB9C8A796AC9C /* RadrootsAppModel.swift */; };
@@ -19,9 +21,11 @@
CA827A410369AC55B0FEE305 /* RuntimeStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9469790A906080D90F8735CB /* RuntimeStatusView.swift */; };
CAD7B38817DC65AAF19576F7 /* RadrootsFFI.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BD7B47A576C4D5CE9318D3E6 /* RadrootsFFI.xcframework */; };
CEED5B97CB1F94445162D662 /* RadrootsRuntimeClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0451E8A8521D320172FFA35 /* RadrootsRuntimeClientTests.swift */; };
+ D03C81CCF4FBC6D7D5634D35 /* RadrootsConfigurationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF9FBA655BDA52E08FB62EED /* RadrootsConfigurationStore.swift */; };
E236CCF8D8F8AC12AF59932E /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 397ED29DF8E9BF73B4BBFDEC /* App.swift */; };
ED326EC0067A550727A1680D /* AppEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0D9C4E6CAE584304521D08 /* AppEntry.swift */; };
F3E40E5A76B4EA19AC7603D2 /* RadrootsKit in Frameworks */ = {isa = PBXBuildFile; productRef = 2DAD90EBF8EB00ACDD7611CD /* RadrootsKit */; };
+ F6F36A06BD77BCFA64FC3541 /* RadrootsIdentityStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0BC5AE5000DE34D9303379 /* RadrootsIdentityStore.swift */; };
F7EEF54776B037D98F389AD6 /* RadrootsRuntimeClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57F5216F7C18E2EE21B8528A /* RadrootsRuntimeClient.swift */; };
/* End PBXBuildFile section */
@@ -58,6 +62,7 @@
/* Begin PBXFileReference section */
298DA81B0A000E307098C840 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
+ 2D0BC5AE5000DE34D9303379 /* RadrootsIdentityStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsIdentityStore.swift; sourceTree = "<group>"; };
30A8A8321A8A261F6D1B480D /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
318804462AADCA05FB04ACD9 /* RadrootsTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = RadrootsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
343F115F383EB9C8A796AC9C /* RadrootsAppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsAppModel.swift; sourceTree = "<group>"; };
@@ -69,11 +74,14 @@
57F5216F7C18E2EE21B8528A /* RadrootsRuntimeClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRuntimeClient.swift; sourceTree = "<group>"; };
5B14CE9958D4C71AFB41D2DD /* RadrootsGeneratedRuntimeBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsGeneratedRuntimeBackend.swift; sourceTree = "<group>"; };
67A31CC210B73EC072BD3542 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
+ 76F84D4706216F743E373F2A /* RadrootsSessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsSessionStore.swift; sourceTree = "<group>"; };
879C89400666972D90996AE5 /* RadrootsKitBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsKitBindings.swift; sourceTree = "<group>"; };
93AA285819DD1269C3EAD80A /* Radroots.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Radroots.app; sourceTree = BUILT_PRODUCTS_DIR; };
9469790A906080D90F8735CB /* RuntimeStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeStatusView.swift; sourceTree = "<group>"; };
BD7B47A576C4D5CE9318D3E6 /* RadrootsFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = RadrootsFFI.xcframework; path = Radroots/Frameworks/RadrootsFFI.xcframework; sourceTree = "<group>"; };
+ C26D512B6CBD1A9BE4780682 /* RadrootsStateMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsStateMigrationTests.swift; sourceTree = "<group>"; };
D0451E8A8521D320172FFA35 /* RadrootsRuntimeClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRuntimeClientTests.swift; sourceTree = "<group>"; };
+ DF9FBA655BDA52E08FB62EED /* RadrootsConfigurationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsConfigurationStore.swift; sourceTree = "<group>"; };
FF8CEF5491290B1218ACFCE7 /* RadrootsKitBindings.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = RadrootsKitBindings.framework; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
@@ -102,6 +110,7 @@
isa = PBXGroup;
children = (
D0451E8A8521D320172FFA35 /* RadrootsRuntimeClientTests.swift */,
+ C26D512B6CBD1A9BE4780682 /* RadrootsStateMigrationTests.swift */,
);
path = RadrootsTests;
sourceTree = "<group>";
@@ -155,6 +164,17 @@
name = Frameworks;
sourceTree = "<group>";
};
+ A2DE5BE1D645F19DA46B3FC5 /* State */ = {
+ isa = PBXGroup;
+ children = (
+ DF9FBA655BDA52E08FB62EED /* RadrootsConfigurationStore.swift */,
+ 2D0BC5AE5000DE34D9303379 /* RadrootsIdentityStore.swift */,
+ 76F84D4706216F743E373F2A /* RadrootsSessionStore.swift */,
+ );
+ name = State;
+ path = Radroots/State;
+ sourceTree = "<group>";
+ };
C22A3ECCB9E9AF9E57B74576 /* App */ = {
isa = PBXGroup;
children = (
@@ -176,6 +196,7 @@
31C88176C5674CFF5F9CFBEA /* RadrootsTests */,
94F94915631E6DC54AAB0B89 /* Resources */,
64C889EFCEB42611797110F7 /* Runtime */,
+ A2DE5BE1D645F19DA46B3FC5 /* State */,
E05061D0ADB560FA929B4D9A /* Views */,
97FA23F0FD7E25C1AF2585FB /* Frameworks */,
6240123423927396E47D6B3E /* Products */,
@@ -353,6 +374,7 @@
buildActionMask = 2147483647;
files = (
CEED5B97CB1F94445162D662 /* RadrootsRuntimeClientTests.swift in Sources */,
+ 51FE0A95B71A5D4EDCEB0748 /* RadrootsStateMigrationTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -363,10 +385,13 @@
E236CCF8D8F8AC12AF59932E /* App.swift in Sources */,
ED326EC0067A550727A1680D /* AppEntry.swift in Sources */,
74082509C257766A166662FA /* RadrootsAppModel.swift in Sources */,
+ D03C81CCF4FBC6D7D5634D35 /* RadrootsConfigurationStore.swift in Sources */,
5DAE76BBC71E4A192E029785 /* RadrootsGeneratedRuntimeBackend.swift in Sources */,
+ F6F36A06BD77BCFA64FC3541 /* RadrootsIdentityStore.swift in Sources */,
47D9564F81A5E7CEA86B759C /* RadrootsProvider.swift in Sources */,
F7EEF54776B037D98F389AD6 /* RadrootsRuntimeClient.swift in Sources */,
B356719A54375706DBBDC118 /* RadrootsRuntimeModels.swift in Sources */,
+ 41B5CB28ACA51221A1BA59D5 /* RadrootsSessionStore.swift in Sources */,
CA827A410369AC55B0FEE305 /* RuntimeStatusView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
diff --git a/Radroots/App/AppEntry.swift b/Radroots/App/AppEntry.swift
@@ -4,9 +4,13 @@ struct AppEntry: View {
@EnvironmentObject private var appModel: RadrootsAppModel
var body: some View {
- RuntimeStatusView(phase: appModel.phase) {
- Task { await appModel.retry() }
- }
+ RuntimeStatusView(
+ phase: appModel.phase,
+ retry: { Task { await appModel.retry() } },
+ createIdentity: { Task { await appModel.createIdentity() } },
+ unlockIdentity: { Task { await appModel.unlockIdentity() } },
+ recoverIdentity: { Task { await appModel.recoverIdentity() } }
+ )
.accessibilityIdentifier("radroots.app_entry")
}
}
diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift
@@ -1,1020 +0,0 @@
-import Foundation
-import RadrootsKit
-
-enum FieldAppRuntimeError: LocalizedError {
- case runtimeNotReady
- case forcedStartupFailure
-
- var errorDescription: String? {
- switch self {
- case .runtimeNotReady:
- "Runtime not ready. Please retry."
- case .forcedStartupFailure:
- "Startup failure requested by field iOS runtime mode."
- }
- }
-}
-
-@MainActor
-public final class AppState: ObservableObject {
- public enum BootstrapPhase: Equatable {
- case idle
- case starting
- case ready
- case failed(String)
- }
-
- public enum RelayLight {
- case red, yellow, green
- }
-
- @Published public private(set) var bootstrapPhase: BootstrapPhase = .idle
- @Published public private(set) var infoJSONString: String = ""
- @Published public private(set) var hasKey: Bool = false
- @Published public private(set) var storedIdentityAvailable: Bool = false
- @Published public private(set) var runtimeIdentityReady: Bool = false
- @Published public private(set) var isLocked: Bool = false
- @Published public private(set) var npub: String?
- @Published public private(set) var identityLabel: String?
- @Published public private(set) var identities: [NostrIdentityRecord] = []
- @Published public private(set) var relayConfigured: Bool = false
- @Published public private(set) var relaySourceAvailable: Bool = false
- @Published public private(set) var relaySinkAvailable: Bool = false
- @Published public private(set) var relayLight: RelayLight = .red
- @Published public private(set) var relayLastError: String?
- @Published public private(set) var configuredRelayURLs: [String] = []
- @Published public private(set) var relaySettingsSourceLabel: String = RelaySettingsSource.buildConfig.displayName
- @Published public private(set) var fileAccessProbeValue: String?
- @Published public private(set) var documentInterchangeProbeValue: String?
- @Published public private(set) var identityPolicyProbeValue: String?
- @Published public private(set) var identityImportFailureProbeValue: String?
- @Published public private(set) var telemetryProbeValue: String?
- @Published public private(set) var backgroundExecutionProbeValue: String?
- @Published public private(set) var externalActionStatus: String?
- @Published public private(set) var userPresenceStatus: String?
- @Published public private(set) var canOpenNostrProfile: Bool = false
- @Published public private(set) var locationCheckInState: FieldLocationCheckInState = .idle(
- RadrootsLocationServicesAvailability(locationServicesEnabled: false, authorization: .unavailable)
- )
- @Published public private(set) var captureIntakeState: FieldCaptureIntakeState = .idle
-
- public var canShowAppContent: Bool {
- bootstrapPhase == .ready && runtimeIdentityReady && !isLocked
- }
-
- public var requiresSetup: Bool {
- bootstrapPhase == .ready && (!storedIdentityAvailable || isLocked || !runtimeIdentityReady)
- }
-
- public var identityDisplayName: String {
- if let label = identityLabel?.trimmingCharacters(in: .whitespacesAndNewlines),
- !label.isEmpty {
- return label
- }
- if let npub {
- return shortNpub(npub)
- }
- return "Local Nostr identity"
- }
-
- public let radroots: Radroots
- private let telemetry: FieldTelemetry
-
- public var runtimeService: FieldRuntimeService? {
- radroots.runtimeService
- }
-
- private let lockKey = "field_ios.identity_locked"
- private var statusTask: Task<Void, Never>?
- private var telemetryProbeTask: Task<Void, Never>?
- private var secureIdentityStore: FieldSecureIdentityStore?
- private var identityMetadataStore: FieldIdentityPublicMetadataStore?
- private var captureIntake: FieldCaptureIntake?
- private var backgroundExecution: FieldBackgroundExecution?
- private let locationCheckIn = FieldLocationCheckIn.configured()
- private let externalActions = FieldExternalActions.configured()
- private let userPresenceGate = FieldUserPresenceGate.configured()
- private var lastTelemetryRelayStatus: FieldTelemetryRelayStatus?
-
- init(radroots: Radroots = Radroots(), telemetry: FieldTelemetry = .shared) {
- self.radroots = radroots
- self.telemetry = telemetry
- self.isLocked = UserDefaults.standard.bool(forKey: lockKey)
- }
-
- deinit {
- statusTask?.cancel()
- telemetryProbeTask?.cancel()
- }
-
- public func start() async throws {
- guard bootstrapPhase == .idle || isFailed else { return }
- telemetry.appStartupBegan()
- bootstrapPhase = .starting
- do {
- try await holdBootstrapSplashForUITestIfRequested()
- if startupFailureWasRequested {
- throw FieldAppRuntimeError.forcedStartupFailure
- }
- let service = try radroots.start(telemetry: telemetry)
- let secureStore = try FieldSecureIdentityStore.configured()
- let metadataStore = try FieldIdentityPublicMetadataStore.configured()
- #if DEBUG
- identityPolicyProbeValue = try FieldIdentityPolicyUITestProbe.value()
- #endif
- let appBundleIdentifier = try bundleIdentifier()
- let resetLocalStateRequested = BuildConfig.bool(.resetLocalState) == true
- let backgroundExecution = try FieldBackgroundExecution.configured(
- bundleIdentifier: appBundleIdentifier,
- telemetry: telemetry
- )
- self.backgroundExecution = backgroundExecution
- await FieldBackgroundURLSessionEvents.shared.attach(backgroundExecution)
- try FieldFileAccessUITestProbe.seedDestructiveResetSentinelIfRequested(
- bundleIdentifier: appBundleIdentifier,
- resetLocalStateRequested: resetLocalStateRequested
- )
- secureIdentityStore = secureStore
- identityMetadataStore = metadataStore
- if resetLocalStateRequested {
- await backgroundExecution.cancelAll()
- try FieldLocalState.resetFileRoots(bundleIdentifier: appBundleIdentifier)
- try RelaySettings.clearUserImportedRelays(bundleIdentifier: appBundleIdentifier)
- try secureStore.deleteSelectedSecret()
- metadataStore.delete()
- try await resetRuntimeIdentityState(using: service)
- applyNoIdentity()
- setLocked(false)
- } else {
- loadStoredIdentityMetadata(metadataStore)
- }
- try refreshRelaySettingsSnapshot(bundleIdentifier: appBundleIdentifier)
- let captureIntake = try FieldCaptureIntake.configured(bundleIdentifier: appBundleIdentifier)
- self.captureIntake = captureIntake
- try await backgroundExecution.start()
- await refreshBackgroundExecutionProbe(using: backgroundExecution)
- await refreshRuntimeState(using: service)
- #if DEBUG
- identityImportFailureProbeValue = await FieldIdentityImportFailureUITestProbe.value(
- secureStore: secureStore,
- service: service
- )
- #endif
- if runtimeIdentityReady && !isLocked {
- startConnectingAndPollingStatus(using: service)
- }
- await refreshNostrProfileExternalActionCapability()
- try refreshFileAccessProbe(
- bundleIdentifier: appBundleIdentifier,
- resetLocalStateRequested: resetLocalStateRequested,
- identityResetObserved: false
- )
- try await refreshDocumentInterchangeProbe(bundleIdentifier: appBundleIdentifier)
- await refreshLocationCheckInStatus()
- await refreshCaptureIntakeState(using: captureIntake)
- bootstrapPhase = .ready
- telemetry.appStartupSucceeded(
- storedIdentityAvailable: storedIdentityAvailable,
- runtimeIdentityReady: runtimeIdentityReady,
- locked: isLocked
- )
- startTelemetryProbeRefreshForUITest()
- } catch {
- statusTask?.cancel()
- statusTask = nil
- telemetryProbeTask?.cancel()
- telemetryProbeTask = nil
- await FieldBackgroundURLSessionEvents.shared.completePendingAfterStartupFailure()
- backgroundExecution = nil
- let message = error.fieldRuntimeMessage
- bootstrapPhase = .failed(message)
- telemetry.appStartupFailed(error)
- startTelemetryProbeRefreshForUITest()
- throw error
- }
- }
-
- public func retryStartup() {
- bootstrapPhase = .idle
- Task {
- try? await start()
- }
- }
-
- public func refresh() {
- Task {
- await refreshRuntimeState()
- }
- }
-
- public func appDidBecomeActive() {
- Task {
- try? await backgroundExecution?.schedulePermittedTasks(reason: "active")
- }
- }
-
- public func appDidEnterBackground() {
- Task {
- _ = try? await backgroundExecution?.schedulePermittedTasks(reason: "background")
- await backgroundExecution?.performMaintenance(reason: "background")
- }
- }
-
- public func shutdown() async {
- statusTask?.cancel()
- statusTask = nil
- telemetryProbeTask?.cancel()
- telemetryProbeTask = nil
- await backgroundExecution?.cancelAll()
- if let service = runtimeService {
- try? await service.nostrIdentityLockHostCustodyRuntime()
- }
- backgroundExecution = nil
- await radroots.shutdown()
- }
-
- public func continueWithLocalIdentity() async throws {
- let service = try requireRuntimeService()
- do {
- try await requireUserPresence(for: .unlockIdentity)
- try await restoreStoredIdentity(using: service)
- setLocked(false)
- await refreshRuntimeState(using: service)
- await refreshNostrProfileExternalActionCapability()
- startConnectingAndPollingStatus(using: service)
- telemetry.identityCustody(action: "unlock", outcome: "success")
- } catch {
- telemetry.identityCustody(action: "unlock", outcome: FieldTelemetry.userPresenceOutcome(for: error))
- throw error
- }
- }
-
- public func createLocalIdentity() async throws {
- let service = try requireRuntimeService()
- do {
- try await requireUserPresence(for: .saveIdentity)
- try await createHostCustodyIdentity(using: service)
- setLocked(false)
- await refreshRuntimeState(using: service)
- await refreshNostrProfileExternalActionCapability()
- startConnectingAndPollingStatus(using: service)
- telemetry.identityCustody(action: "create", outcome: "success")
- } catch {
- telemetry.identityCustody(action: "create", outcome: FieldTelemetry.userPresenceOutcome(for: error))
- throw error
- }
- }
-
- public func importNostrSecret(_ secretKey: String) async throws {
- let trimmed = secretKey.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return }
- let service = try requireRuntimeService()
- do {
- try await requireUserPresence(for: .saveIdentity)
- let record = try await secureIdentityStoreOrConfigured().importSecret(
- trimmed,
- label: "Imported Field Identity",
- using: service
- )
- try persistIdentity(record)
- setLocked(false)
- await refreshRuntimeState(using: service)
- await refreshNostrProfileExternalActionCapability()
- startConnectingAndPollingStatus(using: service)
- telemetry.identityCustody(action: "import", outcome: "success")
- } catch {
- telemetry.identityCustody(action: "import", outcome: FieldTelemetry.userPresenceOutcome(for: error))
- throw error
- }
- }
-
- public func signOut() {
- telemetry.identityCustody(action: "lock", outcome: "success")
- setLocked(true)
- statusTask?.cancel()
- statusTask = nil
- relayConfigured = false
- relaySourceAvailable = false
- relaySinkAvailable = false
- relayLight = .red
- Task {
- await lockRuntimeIdentity()
- }
- }
-
- public func resetLocalIdentity() async throws {
- let service = try requireRuntimeService()
- do {
- try await requireUserPresence(for: .deleteIdentity)
- await backgroundExecution?.updateRuntimeState(service: service, identityUnlocked: false)
- await backgroundExecution?.cancelAll()
- try secureIdentityStoreOrConfigured().deleteSelectedSecret()
- try identityMetadataStoreOrConfigured().delete()
- try await resetRuntimeIdentityState(using: service)
- applyNoIdentity()
- setLocked(false)
- relayConfigured = false
- relaySourceAvailable = false
- relaySinkAvailable = false
- relayLight = .red
- relayLastError = nil
- canOpenNostrProfile = false
- externalActionStatus = nil
- await refreshRuntimeState(using: service)
- try refreshFileAccessProbe(
- bundleIdentifier: try bundleIdentifier(),
- resetLocalStateRequested: false,
- identityResetObserved: true
- )
- statusTask?.cancel()
- statusTask = nil
- telemetry.identityCustody(action: "delete", outcome: "success")
- } catch {
- telemetry.identityCustody(action: "delete", outcome: FieldTelemetry.userPresenceOutcome(for: error))
- throw error
- }
- }
-
- public func requireRuntimeService() throws -> FieldRuntimeService {
- guard let service = runtimeService else {
- throw FieldAppRuntimeError.runtimeNotReady
- }
- return service
- }
-
- public func refreshLocationCheckInStatus() async {
- switch locationCheckInState {
- case .idle:
- break
- case .checking, .checkedIn, .failed:
- return
- }
- let refreshedState = await locationCheckIn.status()
- switch locationCheckInState {
- case .idle:
- locationCheckInState = refreshedState
- case .checking, .checkedIn, .failed:
- return
- }
- }
-
- public func performLocationCheckIn() async {
- let currentState = await locationCheckIn.status()
- if let availability = currentState.availability {
- locationCheckInState = .checking(availability)
- }
- locationCheckInState = await locationCheckIn.checkIn()
- }
-
- public func refreshCaptureIntakeState() async {
- guard let captureIntake else {
- captureIntakeState.lastError = FieldCaptureIntakeError.serviceNotReady.localizedDescription
- captureIntakeState.recoveryAction = nil
- return
- }
- await refreshCaptureIntakeState(using: captureIntake)
- }
-
- public func importPhotoEvidence() async {
- await performCaptureIntakeOperation(.importingPhoto) { captureIntake, records in
- try await captureIntake.importPhoto(records: records)
- }
- }
-
- public func capturePhotoEvidence() async {
- await performCaptureIntakeOperation(.capturingPhoto) { captureIntake, records in
- try await captureIntake.capturePhoto(records: records)
- }
- }
-
- public func scanDocumentEvidence() async {
- await performCaptureIntakeOperation(.scanningDocument) { captureIntake, records in
- try await captureIntake.scanDocument(records: records)
- }
- }
-
- public func refreshNostrProfileExternalActionCapability() async {
- guard let npub else {
- canOpenNostrProfile = false
- return
- }
- canOpenNostrProfile = await externalActions.canOpenPublicNostrProfile(npub: npub)
- }
-
- public func openAppSettingsRecovery() async {
- await requestExternalAction {
- try await externalActions.openAppSettings()
- }
- }
-
- public func openCurrentNostrProfile() async {
- guard let npub else {
- externalActionStatus = "No public Nostr identity is selected."
- canOpenNostrProfile = false
- return
- }
- await requestExternalAction {
- try await externalActions.openPublicNostrProfile(npub: npub)
- }
- }
-
- func prepareDiagnosticsDocumentExport() throws -> RadrootsPreparedExportDocument {
- do {
- let relays = try effectiveRelaySettings().relays
- let document = try documentInterchange().prepareDiagnosticsExport(
- infoJSONString: infoJSONString,
- relays: relays,
- configured: relayConfigured,
- sourceAvailable: relaySourceAvailable,
- sinkAvailable: relaySinkAvailable,
- lastError: relayLastError
- )
- telemetry.documentInterchange(operation: "diagnostics_export", outcome: "success", relayCount: relays.count)
- return document
- } catch {
- telemetry.documentInterchange(
- operation: "diagnostics_export",
- outcome: FieldTelemetry.documentInterchangeOutcome(for: error)
- )
- throw error
- }
- }
-
- func prepareRelayConfigDocumentExport() throws -> RadrootsPreparedExportDocument {
- do {
- let relays = try effectiveRelaySettings().relays
- let document = try documentInterchange().prepareRelayConfigExport(relays: relays)
- telemetry.documentInterchange(operation: "relay_config_export", outcome: "success", relayCount: relays.count)
- return document
- } catch {
- telemetry.documentInterchange(
- operation: "relay_config_export",
- outcome: FieldTelemetry.documentInterchangeOutcome(for: error)
- )
- throw error
- }
- }
-
- func importedRelayConfig(from importedDocument: RadrootsImportedDocument) throws -> [String] {
- do {
- let relays = try documentInterchange().importedRelayConfig(from: importedDocument)
- telemetry.documentInterchange(operation: "relay_config_import", outcome: "success", relayCount: relays.count)
- return relays
- } catch {
- telemetry.documentInterchange(
- operation: "relay_config_import",
- outcome: FieldTelemetry.documentInterchangeOutcome(for: error)
- )
- throw error
- }
- }
-
- func applyImportedRelayConfig(from importedDocument: RadrootsImportedDocument) async throws -> [String] {
- do {
- let relays = try documentInterchange().importedRelayConfig(from: importedDocument)
- let snapshot = try RelaySettings.storeUserImportedRelays(
- relays,
- bundleIdentifier: bundleIdentifier()
- )
- apply(relaySettings: snapshot)
- if let service = runtimeService, runtimeIdentityReady && !isLocked {
- relayConfigured = !snapshot.relays.isEmpty
- relaySourceAvailable = false
- relaySinkAvailable = false
- relayLight = .yellow
- relayLastError = nil
- try await service.nostrSetDefaultRelays(snapshot.relays)
- try await service.nostrConnectIfKeyPresent()
- await refreshRelayStatus(using: service)
- await backgroundExecution?.updateRuntimeState(
- service: service,
- identityUnlocked: true
- )
- }
- telemetry.documentInterchange(operation: "relay_config_import", outcome: "success", relayCount: relays.count)
- return snapshot.relays
- } catch {
- telemetry.documentInterchange(
- operation: "relay_config_import",
- outcome: FieldTelemetry.documentInterchangeOutcome(for: error)
- )
- throw error
- }
- }
-
- func publicPostShareRequest(content: String) throws -> RadrootsShareRequest {
- do {
- let request = try documentInterchange().publicPostShareRequest(content: content)
- telemetry.documentInterchange(operation: "public_share_prepare", outcome: "success")
- return request
- } catch {
- telemetry.documentInterchange(
- operation: "public_share_prepare",
- outcome: FieldTelemetry.documentInterchangeOutcome(for: error)
- )
- throw error
- }
- }
-
- func documentFileAccess() throws -> RadrootsAppleFileAccess {
- try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier())
- }
-
- func releasePreparedDocumentExport(_ preparedExport: RadrootsPreparedExportDocument) {
- try? documentFileAccess().releasePreparedExport(preparedExport)
- }
-
- private func documentInterchange() throws -> FieldDocumentInterchange {
- try FieldDocumentInterchange(bundleIdentifier: bundleIdentifier())
- }
-
- private func refreshRelaySettingsSnapshot(bundleIdentifier: String) throws {
- apply(relaySettings: try RelaySettings.effectiveSnapshot(bundleIdentifier: bundleIdentifier))
- }
-
- private func effectiveRelaySettings() throws -> RelaySettingsSnapshot {
- let snapshot = try RelaySettings.effectiveSnapshot(bundleIdentifier: bundleIdentifier())
- apply(relaySettings: snapshot)
- return snapshot
- }
-
- private func apply(relaySettings snapshot: RelaySettingsSnapshot) {
- configuredRelayURLs = snapshot.relays
- relaySettingsSourceLabel = snapshot.source.displayName
- }
-
- private func refreshCaptureIntakeState(using captureIntake: FieldCaptureIntake) async {
- captureIntakeState.operation = .refreshing
- captureIntakeState.lastError = nil
- captureIntakeState.recoveryAction = nil
- do {
- captureIntakeState.records = try captureIntake.loadRecords()
- captureIntakeState.support = try await captureIntake.support()
- captureIntakeState.operation = .idle
- telemetry.captureSupportRefreshed(
- support: captureIntakeState.support,
- recordCount: captureIntakeState.records.count,
- outcome: "success"
- )
- } catch {
- captureIntakeState.support = .unavailable
- captureIntakeState.operation = .idle
- captureIntakeState.lastError = error.fieldRuntimeMessage
- captureIntakeState.recoveryAction = nil
- telemetry.captureSupportRefreshed(
- support: captureIntakeState.support,
- recordCount: captureIntakeState.records.count,
- outcome: FieldTelemetry.captureOutcome(for: error)
- )
- }
- }
-
- private func performCaptureIntakeOperation(
- _ operation: FieldCaptureIntakeOperation,
- action: (FieldCaptureIntake, [FieldCaptureRecord]) async throws -> [FieldCaptureRecord]
- ) async {
- guard let captureIntake else {
- captureIntakeState.lastError = FieldCaptureIntakeError.serviceNotReady.localizedDescription
- return
- }
- captureIntakeState.operation = operation
- captureIntakeState.lastError = nil
- captureIntakeState.recoveryAction = nil
- do {
- let updatedRecords = try await action(captureIntake, captureIntakeState.records)
- captureIntakeState.records = updatedRecords
- captureIntakeState.support = try await captureIntake.support()
- captureIntakeState.operation = .idle
- captureIntakeState.recoveryAction = nil
- telemetry.captureOperation(
- operation: operation,
- outcome: "success",
- recordCount: captureIntakeState.records.count,
- recoveryAction: nil
- )
- } catch {
- captureIntakeState.operation = .idle
- captureIntakeState.lastError = error.fieldRuntimeMessage
- captureIntakeState.recoveryAction = captureRecoveryAction(for: error)
- telemetry.captureOperation(
- operation: operation,
- outcome: FieldTelemetry.captureOutcome(for: error),
- recordCount: captureIntakeState.records.count,
- recoveryAction: captureIntakeState.recoveryAction
- )
- }
- }
-
- private func captureRecoveryAction(for error: Error) -> FieldExternalActionRecovery? {
- guard let captureError = error as? RadrootsCaptureIntakeError else {
- return nil
- }
- switch captureError {
- case .permissionDenied:
- return .appSettings
- case .invalidRequest, .unavailable, .userCancelled, .transientFailure, .permanentFailure:
- return nil
- }
- }
-
- private var isFailed: Bool {
- if case .failed = bootstrapPhase {
- return true
- }
- return false
- }
-
- private var uiTestWasRequested: Bool {
- #if DEBUG
- return FieldUITestHarness.isRequested
- #else
- return false
- #endif
- }
-
- private var uiTestBootstrapSplashHoldNanoseconds: UInt64? {
- #if DEBUG
- guard uiTestWasRequested else { return nil }
- guard let raw = FieldUITestHarness.string("RADROOTS_FIELD_IOS_UI_TEST_BOOTSTRAP_SPLASH_HOLD_SECONDS"),
- let seconds = Double(raw),
- seconds.isFinite,
- seconds > 0 else {
- return nil
- }
- return UInt64(seconds * 1_000_000_000)
- #else
- return nil
- #endif
- }
-
- private func holdBootstrapSplashForUITestIfRequested() async throws {
- guard let nanoseconds = uiTestBootstrapSplashHoldNanoseconds else { return }
- try await Task.sleep(nanoseconds: nanoseconds)
- }
-
- private var startupFailureWasRequested: Bool {
- #if DEBUG
- guard uiTestWasRequested else {
- return false
- }
- let arguments = ProcessInfo.processInfo.arguments
- if BuildConfig.string(.runtimeMode) == "ui-test-startup-failure" {
- return true
- }
- if FieldUITestHarness.bool("RADROOTS_FIELD_IOS_FORCE_STARTUP_FAILURE", default: false) {
- return true
- }
- return arguments.contains("--radroots-field-ios-force-startup-failure")
- #else
- return false
- #endif
- }
-
- private func configureRelays(using service: FieldRuntimeService) async throws {
- try await service.nostrSetDefaultRelays(try effectiveRelaySettings().relays)
- }
-
- private func connect(using service: FieldRuntimeService) async throws {
- try await configureRelays(using: service)
- try await service.nostrConnectIfKeyPresent()
- await refreshRelayStatus(using: service)
- relayLastError = nil
- }
-
- private func refreshRuntimeState() async {
- guard let service = runtimeService else { return }
- await refreshRuntimeState(using: service)
- }
-
- private func refreshRuntimeState(using service: FieldRuntimeService) async {
- infoJSONString = await service.infoJson()
- do {
- let snapshot = try await service.nostrIdentitySnapshot()
- apply(identity: snapshot)
- } catch {
- relayLastError = error.fieldRuntimeMessage
- }
- await refreshRelayStatus(using: service)
- await backgroundExecution?.updateRuntimeState(
- service: service,
- identityUnlocked: runtimeIdentityReady && !isLocked
- )
- }
-
- private func refreshRelayStatus(using service: FieldRuntimeService) async {
- do {
- let status = try await service.nostrConnectionStatus()
- relayConfigured = status.configured
- relaySourceAvailable = status.sourceAvailable
- relaySinkAvailable = status.sinkAvailable
- relayLastError = status.lastError ?? relayLastError
- switch status.light {
- case .green:
- relayLight = .green
- case .yellow:
- relayLight = .yellow
- case .red:
- relayLight = .red
- }
- } catch {
- relaySourceAvailable = false
- relaySinkAvailable = false
- relayLight = .red
- relayLastError = error.fieldRuntimeMessage
- }
- let telemetryStatus = FieldTelemetryRelayStatus(
- configured: relayConfigured,
- sourceAvailable: relaySourceAvailable,
- sinkAvailable: relaySinkAvailable,
- configuredRelayCount: configuredRelayURLs.count,
- light: relayLight.telemetryValue
- )
- if telemetryStatus != lastTelemetryRelayStatus {
- lastTelemetryRelayStatus = telemetryStatus
- telemetry.relayStatusChanged(
- configured: telemetryStatus.configured,
- sourceAvailable: telemetryStatus.sourceAvailable,
- sinkAvailable: telemetryStatus.sinkAvailable,
- configuredRelayCount: telemetryStatus.configuredRelayCount,
- light: telemetryStatus.light
- )
- }
- }
-
- private func apply(identity snapshot: NostrIdentitySnapshot) {
- runtimeIdentityReady = snapshot.hasSelectedSigningIdentity
- identities = snapshot.identities
- if snapshot.hasSelectedSigningIdentity {
- storedIdentityAvailable = true
- hasKey = true
- npub = snapshot.selectedNpub
- identityLabel = snapshot.identities.first(where: { $0.isSelected })?.label
- } else if storedIdentityAvailable {
- hasKey = true
- } else {
- hasKey = false
- npub = nil
- identityLabel = nil
- canOpenNostrProfile = false
- }
- }
-
- private func lockRuntimeIdentityState(using service: FieldRuntimeService) async throws {
- try await service.nostrIdentityLockHostCustodyRuntime()
- runtimeIdentityReady = false
- identities = []
- }
-
- private func resetRuntimeIdentityState(using service: FieldRuntimeService) async throws {
- try await service.nostrIdentityResetHostCustodyRuntime()
- runtimeIdentityReady = false
- identities = []
- }
-
- private func loadStoredIdentityMetadata(_ metadataStore: FieldIdentityPublicMetadataStore) {
- guard let metadata = metadataStore.load() else {
- applyNoIdentity()
- setLocked(false)
- return
- }
- apply(storedIdentity: metadata)
- setLocked(true)
- }
-
- private func restoreStoredIdentity(using service: FieldRuntimeService) async throws {
- let existingMetadata = try identityMetadataStoreOrConfigured().load()
- let record = try await secureIdentityStoreOrConfigured().restoreStoredIdentity(
- label: existingMetadata?.label ?? "Radroots Field",
- using: service
- )
- try persistIdentity(record)
- }
-
- private func requireUserPresence(for action: FieldUserPresenceAction) async throws {
- do {
- let record = try await userPresenceGate.requirePresence(for: action)
- userPresenceStatus = record.statusText
- telemetry.userPresence(action: action, outcome: "success")
- } catch {
- userPresenceStatus = error.fieldRuntimeMessage
- telemetry.userPresence(action: action, outcome: FieldTelemetry.userPresenceOutcome(for: error))
- throw error
- }
- }
-
- private func createHostCustodyIdentity(using service: FieldRuntimeService) async throws {
- let record = try await secureIdentityStoreOrConfigured().createIdentity(
- label: "Radroots Field",
- using: service
- )
- try persistIdentity(record)
- }
-
- private func persistIdentity(_ record: NostrIdentityRecord) throws {
- let metadata = FieldIdentityPublicMetadata(record: record)
- try identityMetadataStoreOrConfigured().save(metadata)
- apply(storedIdentity: metadata)
- runtimeIdentityReady = true
- hasKey = true
- identities = [record]
- }
-
- private func lockRuntimeIdentity() async {
- guard let service = runtimeService else {
- runtimeIdentityReady = false
- identities = []
- hasKey = storedIdentityAvailable
- return
- }
- do {
- try await lockRuntimeIdentityState(using: service)
- } catch {
- relayLastError = error.fieldRuntimeMessage
- }
- hasKey = storedIdentityAvailable
- await refreshRelayStatus(using: service)
- await backgroundExecution?.updateRuntimeState(service: service, identityUnlocked: false)
- }
-
- private func apply(storedIdentity metadata: FieldIdentityPublicMetadata) {
- storedIdentityAvailable = true
- hasKey = true
- npub = metadata.publicKeyNpub
- identityLabel = metadata.label
- }
-
- private func applyNoIdentity() {
- hasKey = false
- storedIdentityAvailable = false
- runtimeIdentityReady = false
- npub = nil
- identityLabel = nil
- identities = []
- canOpenNostrProfile = false
- }
-
- private func secureIdentityStoreOrConfigured() throws -> FieldSecureIdentityStore {
- if let secureIdentityStore {
- return secureIdentityStore
- }
- let configured = try FieldSecureIdentityStore.configured()
- secureIdentityStore = configured
- return configured
- }
-
- private func identityMetadataStoreOrConfigured() throws -> FieldIdentityPublicMetadataStore {
- if let identityMetadataStore {
- return identityMetadataStore
- }
- let configured = try FieldIdentityPublicMetadataStore.configured()
- identityMetadataStore = configured
- return configured
- }
-
- private func bundleIdentifier() throws -> String {
- guard let bundleIdentifier = Bundle.main.bundleIdentifier,
- !bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
- throw FieldSecureIdentityStoreError.missingBundleIdentifier
- }
- return bundleIdentifier
- }
-
- private func refreshFileAccessProbe(
- bundleIdentifier: String,
- resetLocalStateRequested: Bool,
- identityResetObserved: Bool
- ) throws {
- let loggingSettings = LoggingSettings.load()
- if identityResetObserved {
- fileAccessProbeValue = try FieldFileAccessUITestProbe.identityResetValue(
- bundleIdentifier: bundleIdentifier,
- loggingFileEnabled: loggingSettings.fileEnabled,
- loggingFileName: loggingSettings.fileName
- )
- } else {
- fileAccessProbeValue = try FieldFileAccessUITestProbe.startupValue(
- bundleIdentifier: bundleIdentifier,
- resetLocalStateRequested: resetLocalStateRequested,
- loggingFileEnabled: loggingSettings.fileEnabled,
- loggingFileName: loggingSettings.fileName
- )
- }
- }
-
- private func refreshDocumentInterchangeProbe(bundleIdentifier: String) async throws {
- documentInterchangeProbeValue = try FieldDocumentInterchangeUITestProbe.startupValue(
- bundleIdentifier: bundleIdentifier,
- infoJSONString: infoJSONString,
- relays: effectiveRelaySettings().relays,
- configured: relayConfigured,
- sourceAvailable: relaySourceAvailable,
- sinkAvailable: relaySinkAvailable,
- lastError: relayLastError
- )
- guard FieldDocumentInterchangeUITestProbe.isRequested else {
- return
- }
- let diagnosticsExport = try prepareDiagnosticsDocumentExport()
- releasePreparedDocumentExport(diagnosticsExport)
- let relayConfigExport = try prepareRelayConfigDocumentExport()
- releasePreparedDocumentExport(relayConfigExport)
- if let relayImportDocument = try FieldDocumentInterchangeUITestProbe.relayImportDocument(
- bundleIdentifier: bundleIdentifier
- ) {
- let importedRelays = try await applyImportedRelayConfig(from: relayImportDocument)
- documentInterchangeProbeValue = [
- documentInterchangeProbeValue,
- "relay_import_applied=true",
- "relay_settings_source=\(relaySettingsSourceLabel)",
- "relay_settings_count=\(importedRelays.count)",
- "relay_settings_contains_production=\(importedRelays.contains("wss://radroots.org"))"
- ].compactMap { $0 }.joined(separator: ";")
- }
- _ = try publicPostShareRequest(content: " public field update ")
- }
-
- private func requestExternalAction(
- _ action: () async throws -> FieldExternalActionRequestRecord
- ) async {
- do {
- let record = try await action()
- externalActionStatus = record.statusText
- telemetry.externalAction(operation: "open", kind: record.kind, outcome: "success")
- } catch {
- externalActionStatus = error.fieldRuntimeMessage
- telemetry.externalAction(
- operation: "open",
- kind: nil,
- outcome: FieldTelemetry.externalActionOutcome(for: error)
- )
- }
- }
-
- private func setLocked(_ value: Bool) {
- isLocked = value
- UserDefaults.standard.set(value, forKey: lockKey)
- }
-
- private func startConnectingAndPollingStatus(using service: FieldRuntimeService) {
- statusTask?.cancel()
- statusTask = Task { [weak self] in
- do {
- try await self?.connect(using: service)
- } catch {
- self?.relayLastError = error.fieldRuntimeMessage
- self?.relayLight = .red
- }
- while !Task.isCancelled {
- await self?.refreshRuntimeState(using: service)
- try? await Task.sleep(nanoseconds: 1_000_000_000)
- }
- }
- }
-
- private func startTelemetryProbeRefreshForUITest() {
- guard FieldTelemetryUITestProbe.isRequested else {
- return
- }
- telemetryProbeTask?.cancel()
- telemetryProbeTask = Task { [weak self] in
- while !Task.isCancelled {
- await self?.refreshTelemetryProbeValue()
- try? await Task.sleep(nanoseconds: 250_000_000)
- }
- }
- }
-
- private func refreshTelemetryProbeValue() async {
- telemetryProbeValue = await FieldTelemetryUITestProbe.value(recordedBy: telemetry)
- }
-
- private func refreshBackgroundExecutionProbe(using backgroundExecution: FieldBackgroundExecution) async {
- backgroundExecutionProbeValue = await backgroundExecution.uiTestProbeValue()
- }
-
- private func shortNpub(_ value: String) -> String {
- guard value.count > 18 else { return value }
- return "\(value.prefix(12))...\(value.suffix(6))"
- }
-}
-
-private struct FieldTelemetryRelayStatus: Equatable {
- let configured: Bool
- let sourceAvailable: Bool
- let sinkAvailable: Bool
- let configuredRelayCount: Int
- let light: String
-}
-
-private extension AppState.RelayLight {
- var telemetryValue: String {
- switch self {
- case .red:
- "red"
- case .yellow:
- "yellow"
- case .green:
- "green"
- }
- }
-}
diff --git a/Radroots/App/RadrootsAppModel.swift b/Radroots/App/RadrootsAppModel.swift
@@ -2,66 +2,81 @@ import Foundation
@MainActor
final class RadrootsAppModel: ObservableObject {
- enum Phase: Equatable {
- case starting
- case identityRequired
- case running(RadrootsRuntimeSnapshot)
- case failed(RadrootsRuntimeFailure)
- case stopped
- }
+ typealias Phase = RadrootsSessionPhase
@Published private(set) var phase: Phase = .starting
- private let runtimeClient: RadrootsRuntimeClient
- private let configuration: RadrootsRuntimeLaunchConfiguration?
+ private let sessionStore: RadrootsSessionStore?
+ private let bootstrapFailure: RadrootsRuntimeFailure?
+ private var generation: UInt64 = 0
- init(
- runtimeClient: RadrootsRuntimeClient = .production(),
- configuration: RadrootsRuntimeLaunchConfiguration? = nil
- ) {
- self.runtimeClient = runtimeClient
- self.configuration = configuration
+ init(sessionStore: RadrootsSessionStore? = nil) {
+ if let sessionStore {
+ self.sessionStore = sessionStore
+ bootstrapFailure = nil
+ } else {
+ do {
+ self.sessionStore = try .production()
+ bootstrapFailure = nil
+ } catch let error as LocalizedError {
+ self.sessionStore = nil
+ bootstrapFailure = .local(
+ operation: "app.bootstrap",
+ code: "ios.app.configuration_invalid",
+ safeMessage: error.errorDescription ?? "Radroots configuration is invalid."
+ )
+ } catch {
+ self.sessionStore = nil
+ bootstrapFailure = .local(
+ operation: "app.bootstrap",
+ code: "ios.app.configuration_invalid",
+ safeMessage: "Radroots configuration is invalid."
+ )
+ }
+ }
}
func start() async {
- guard let configuration else {
- phase = .identityRequired
- return
- }
- phase = .starting
- do {
- phase = try await .running(runtimeClient.start(configuration: configuration))
- } catch let RadrootsRuntimeClientError.startup(failure) {
- phase = .failed(failure)
- } catch {
- phase = .failed(
- .local(
- operation: "app.start",
- code: "ios.app.start_failed",
- safeMessage: "Radroots could not start."
- )
- )
- }
+ await run { store in await store.start() }
}
func retry() async {
await start()
}
+ func createIdentity() async {
+ await run { store in await store.createIdentity() }
+ }
+
+ func unlockIdentity() async {
+ await run { store in await store.unlockIdentity() }
+ }
+
+ func recoverIdentity() async {
+ await run { store in await store.recoverIdentity() }
+ }
+
func stop() async {
- do {
- _ = try await runtimeClient.stop()
- phase = .stopped
- } catch let RadrootsRuntimeClientError.shutdown(failure) {
- phase = .failed(failure)
- } catch {
+ await run { store in await store.stop() }
+ }
+
+ private func run(
+ _ operation: @escaping @Sendable (RadrootsSessionStore) async -> Phase
+ ) async {
+ generation &+= 1
+ let requestedGeneration = generation
+ guard let sessionStore else {
phase = .failed(
- .local(
- operation: "app.stop",
- code: "ios.app.stop_failed",
- safeMessage: "Radroots could not finish shutting down."
+ bootstrapFailure ?? .local(
+ operation: "app.bootstrap",
+ code: "ios.app.bootstrap_failed",
+ safeMessage: "Radroots could not start."
)
)
+ return
}
+ let result = await operation(sessionStore)
+ guard generation == requestedGeneration else { return }
+ phase = result
}
}
diff --git a/Radroots/Config/Base.xcconfig b/Radroots/Config/Base.xcconfig
@@ -7,6 +7,7 @@ RADROOTS_FIELD_IOS_LOGGING_FILTER = info
RADROOTS_FIELD_IOS_LOGGING_FILE_ENABLED = true
RADROOTS_FIELD_IOS_LOGGING_FILE_NAME = field-ios.log
RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS = wss:$(SLASH)$(SLASH)radroots.org
+RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS =
RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX = org.radroots.field_ios
RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY = user_presence_local
RADROOTS_FIELD_IOS_RESET_LOCAL_STATE = false
diff --git a/Radroots/Config/Debug.xcconfig b/Radroots/Config/Debug.xcconfig
@@ -7,6 +7,7 @@ RADROOTS_FIELD_IOS_LOGGING_FILTER = debug
RADROOTS_FIELD_IOS_LOGGING_FILE_ENABLED = false
RADROOTS_FIELD_IOS_LOGGING_FILE_NAME = field-ios-debug.log
RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS = ws:$(SLASH)$(SLASH)127.0.0.1:8080
+RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS = http:$(SLASH)$(SLASH)127.0.0.1:3000
RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX = org.radroots.field_ios.local
RADROOTS_FIELD_IOS_RESET_LOCAL_STATE = false
diff --git a/Radroots/Info.plist b/Radroots/Info.plist
@@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
- <string>1.0</string>
+ <string>0.1.0-alpha</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
@@ -96,6 +96,8 @@
<string>$(RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS)</string>
<key>RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX</key>
<string>$(RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX)</string>
+ <key>RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS</key>
+ <string>$(RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS)</string>
<key>RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY</key>
<string>$(RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY)</string>
<key>RADROOTS_FIELD_IOS_RESET_LOCAL_STATE</key>
diff --git a/Radroots/Runtime/BuildConfig.swift b/Radroots/Runtime/BuildConfig.swift
@@ -1,143 +0,0 @@
-import Foundation
-
-enum BuildConfigKey: String {
- case envFile = "RADROOTS_FIELD_IOS_ENV_FILE"
- case runtimeMode = "RADROOTS_FIELD_IOS_RUNTIME_MODE"
- case loggingStdout = "RADROOTS_FIELD_IOS_LOGGING_STDOUT"
- case loggingFilter = "RADROOTS_FIELD_IOS_LOGGING_FILTER"
- case loggingFileEnabled = "RADROOTS_FIELD_IOS_LOGGING_FILE_ENABLED"
- case loggingFileName = "RADROOTS_FIELD_IOS_LOGGING_FILE_NAME"
- case nostrRelayUrls = "RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS"
- case keychainServicePrefix = "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX"
- case keychainAccessPolicy = "RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY"
- case resetLocalState = "RADROOTS_FIELD_IOS_RESET_LOCAL_STATE"
-}
-
-enum BuildConfig {
- static func string(_ key: BuildConfigKey) -> String? {
- debugLaunchOverrideString(key) ?? infoString(key).map { stripOuterQuotes($0) }
- }
-
- static func bool(_ key: BuildConfigKey) -> Bool? {
- if let raw = debugLaunchOverrideString(key),
- let parsed = parseBool(raw) {
- return parsed
- }
- if let v = infoValue(for: key.rawValue) {
- if let b = v as? Bool { return b }
- if let s = v as? String, let parsed = parseBool(s) { return parsed }
- if let n = v as? NSNumber { return n.boolValue }
- }
- return nil
- }
-
- static func array(_ key: BuildConfigKey, splitBy set: CharacterSet = .whitespacesAndNewlines) -> [String]? {
- if let raw = debugLaunchOverrideString(key) {
- return parseArray(raw, splitBy: set)
- }
- if let direct = infoArray(key) {
- return direct
- .map { stripOuterQuotes($0).trimmingCharacters(in: .whitespacesAndNewlines) }
- .filter { !$0.isEmpty }
- }
- guard let raw = infoString(key) else { return nil }
- return parseArray(raw, splitBy: set)
- }
-
- static func effectiveDictionary(keys: [BuildConfigKey]) -> [String: Any] {
- var out: [String: Any] = [:]
- for k in keys {
- switch k {
- case .loggingStdout, .loggingFileEnabled, .resetLocalState:
- if let b = bool(k) {
- out[k.rawValue] = b
- }
- case .nostrRelayUrls:
- if let arr = array(.nostrRelayUrls) {
- out[k.rawValue] = arr
- }
- default:
- if let s = string(k) {
- out[k.rawValue] = s
- }
- }
- }
- return out
- }
-
- private static func parseArray(_ value: String, splitBy set: CharacterSet) -> [String]? {
- var raw = value.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !raw.isEmpty else { return nil }
- if raw.first == "[" {
- if let data = raw.data(using: .utf8),
- let arr = try? JSONSerialization.jsonObject(with: data) as? [String] {
- return arr
- .map { stripOuterQuotes($0).trimmingCharacters(in: .whitespacesAndNewlines) }
- .filter { !$0.isEmpty }
- }
- }
- raw = stripOuterQuotes(raw)
- let separators = set.union(CharacterSet(charactersIn: ",;"))
- raw = raw.replacingOccurrences(of: "\n", with: " ")
- .replacingOccurrences(of: "\r", with: " ")
- return raw
- .components(separatedBy: separators)
- .map { stripOuterQuotes($0).trimmingCharacters(in: .whitespacesAndNewlines) }
- .filter { !$0.isEmpty }
- }
-
- private static func infoString(_ key: BuildConfigKey) -> String? {
- if let v = infoValue(for: key.rawValue) as? String, !v.isEmpty { return v }
- if let n = infoValue(for: key.rawValue) as? NSNumber { return n.stringValue }
- return nil
- }
-
- private static func infoArray(_ key: BuildConfigKey) -> [String]? {
- if let v = infoValue(for: key.rawValue) as? [String] { return v }
- if let nested = Bundle.main.object(forInfoDictionaryKey: "Radroots") as? [String: Any],
- let v = nested[key.rawValue] as? [String] {
- return v
- }
- return nil
- }
-
- private static func infoValue(for key: String) -> Any? {
- if let v = Bundle.main.object(forInfoDictionaryKey: key) {
- return v
- }
- if let nested = Bundle.main.object(forInfoDictionaryKey: "Radroots") as? [String: Any] {
- return nested[key]
- }
- return nil
- }
-
- private static func parseBool(_ s: String) -> Bool? {
- switch s.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
- case "1", "true", "yes": return true
- case "0", "false", "no": return false
- default: return nil
- }
- }
-
- private static func stripOuterQuotes(_ s: String) -> String {
- guard s.count >= 2 else { return s }
- if (s.hasPrefix("\"") && s.hasSuffix("\"")) || (s.hasPrefix("'") && s.hasSuffix("'")) {
- return String(s.dropFirst().dropLast())
- }
- return s
- }
-
- #if DEBUG
- private static func debugLaunchOverrideString(_ key: BuildConfigKey) -> String? {
- guard FieldUITestHarness.isRequested else {
- return nil
- }
- return FieldUITestHarness.string(key.rawValue)
- .flatMap { $0.isEmpty ? nil : stripOuterQuotes($0) }
- }
- #else
- private static func debugLaunchOverrideString(_ key: BuildConfigKey) -> String? {
- nil
- }
- #endif
-}
diff --git a/Radroots/Runtime/FieldIdentityPublicMetadataStore.swift b/Radroots/Runtime/FieldIdentityPublicMetadataStore.swift
@@ -1,50 +0,0 @@
-import Foundation
-
-struct FieldIdentityPublicMetadata: Codable, Equatable, Sendable {
- let selectedIdentityId: String
- let publicKeyHex: String
- let publicKeyNpub: String
- let label: String?
- let updatedAtUnix: UInt64
-
- init(record: NostrIdentityRecord, updatedAtUnix: UInt64 = UInt64(Date().timeIntervalSince1970)) {
- self.selectedIdentityId = record.id
- self.publicKeyHex = record.publicKeyHex
- self.publicKeyNpub = record.publicKeyNpub
- self.label = record.label
- self.updatedAtUnix = updatedAtUnix
- }
-}
-
-struct FieldIdentityPublicMetadataStore {
- private let userDefaults: UserDefaults
- private let key: String
-
- init(servicePrefix: String, userDefaults: UserDefaults = .standard) {
- self.userDefaults = userDefaults
- self.key = "field_ios.identity.public_metadata.\(servicePrefix)"
- }
-
- static func configured() throws -> FieldIdentityPublicMetadataStore {
- guard let servicePrefix = BuildConfig.string(.keychainServicePrefix) else {
- throw FieldSecureIdentityStoreError.missingSecureStoreServicePrefix
- }
- return FieldIdentityPublicMetadataStore(servicePrefix: servicePrefix)
- }
-
- func load() -> FieldIdentityPublicMetadata? {
- guard let data = userDefaults.data(forKey: key) else {
- return nil
- }
- return try? JSONDecoder().decode(FieldIdentityPublicMetadata.self, from: data)
- }
-
- func save(_ metadata: FieldIdentityPublicMetadata) throws {
- let data = try JSONEncoder().encode(metadata)
- userDefaults.set(data, forKey: key)
- }
-
- func delete() {
- userDefaults.removeObject(forKey: key)
- }
-}
diff --git a/Radroots/Runtime/FieldLocalState.swift b/Radroots/Runtime/FieldLocalState.swift
@@ -1,56 +0,0 @@
-import Foundation
-import RadrootsKit
-
-enum FieldLocalStateError: LocalizedError {
- case missingBundleIdentifier
- case invalidLogFileName(String)
-
- var errorDescription: String? {
- switch self {
- case .missingBundleIdentifier:
- "Missing field iOS bundle identifier."
- case .invalidLogFileName(let value):
- "Invalid RADROOTS_FIELD_IOS_LOGGING_FILE_NAME: \(value)."
- }
- }
-}
-
-enum FieldLocalState {
- static func roots(bundleIdentifier: String) throws -> RadrootsAppleFileRoots {
- let appIdentifier = try normalizedBundleIdentifier(bundleIdentifier)
- return try RadrootsAppleFileRoots.appContainer(appIdentifier: appIdentifier)
- }
-
- static func fileAccess(bundleIdentifier: String) throws -> RadrootsAppleFileAccess {
- try RadrootsAppleFileAccess(roots: roots(bundleIdentifier: bundleIdentifier))
- }
-
- static func logFileURL(bundleIdentifier: String, fileName: String) throws -> URL {
- let normalizedFileName = try normalizedLogFileName(fileName)
- let file = RadrootsFileReference(scope: .logs, relativePath: normalizedFileName)
- return try roots(bundleIdentifier: bundleIdentifier).resolvedURL(for: file)
- }
-
- static func resetFileRoots(bundleIdentifier: String) throws {
- try fileAccess(bundleIdentifier: bundleIdentifier).resetFileRoots()
- }
-
- private static func normalizedBundleIdentifier(_ bundleIdentifier: String) throws -> String {
- let trimmed = bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else {
- throw FieldLocalStateError.missingBundleIdentifier
- }
- return trimmed
- }
-
- private static func normalizedLogFileName(_ fileName: String) throws -> String {
- let trimmed = fileName.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty,
- !trimmed.contains("/"),
- !trimmed.contains("\\"),
- !trimmed.contains("\0") else {
- throw FieldLocalStateError.invalidLogFileName(fileName)
- }
- return trimmed
- }
-}
diff --git a/Radroots/Runtime/FieldSecureIdentityStore.swift b/Radroots/Runtime/FieldSecureIdentityStore.swift
@@ -1,273 +0,0 @@
-import Foundation
-import RadrootsKit
-import Security
-
-enum FieldSecureIdentityStoreError: LocalizedError {
- case missingSecureStoreServicePrefix
- case missingBundleIdentifier
- case invalidStoredSecret
- case missingSelectedSecret
- case missingSecureStoreAccessPolicy
- case invalidSecureStoreAccessPolicy(String)
- case randomSecretGenerationFailed(Int32)
- case forcedImportRestoreFailure
-
- var errorDescription: String? {
- switch self {
- case .missingSecureStoreServicePrefix:
- "Missing RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX."
- case .missingBundleIdentifier:
- "Missing field iOS bundle identifier."
- case .invalidStoredSecret:
- "Stored Nostr identity secret is invalid."
- case .missingSelectedSecret:
- "No selected Nostr identity secret is available in secure store."
- case .missingSecureStoreAccessPolicy:
- "Missing RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY."
- case .invalidSecureStoreAccessPolicy(let value):
- "Invalid RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY: \(value)."
- case .randomSecretGenerationFailed(let status):
- "Secure Nostr identity generation failed with status \(status)."
- case .forcedImportRestoreFailure:
- "Forced identity import restore failure."
- }
- }
-}
-
-enum FieldSecureIdentityAccessPolicy: String {
- case userPresenceLocal = "user_presence_local"
- case secureLocal = "secure_local"
-
- var storePolicy: RadrootsSecretAccessPolicy {
- switch self {
- case .userPresenceLocal:
- .userPresenceLocalSecret
- case .secureLocal:
- .secureLocalSecret
- }
- }
-}
-
-struct FieldSecureIdentityStore {
- static let namespace = "nostr_identity"
- static let selectedSecretName = "selected_secret_hex"
-
- let servicePrefix: String
- private let store: any RadrootsSecureStore
-
- init(servicePrefix: String) {
- self.servicePrefix = servicePrefix
- self.store = RadrootsAppleKeychainSecureStore(servicePrefix: servicePrefix)
- }
-
- init(servicePrefix: String, store: any RadrootsSecureStore) {
- self.servicePrefix = servicePrefix
- self.store = store
- }
-
- static func configured() throws -> FieldSecureIdentityStore {
- guard let servicePrefix = BuildConfig.string(.keychainServicePrefix) else {
- throw FieldSecureIdentityStoreError.missingSecureStoreServicePrefix
- }
- return FieldSecureIdentityStore(servicePrefix: servicePrefix)
- }
-
- func loadSelectedSecretHex() throws -> String? {
- guard let data = try store.get(Self.selectedSecretKey) else {
- return nil
- }
- guard let value = String(data: data, encoding: .utf8) else {
- throw FieldSecureIdentityStoreError.invalidStoredSecret
- }
- let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
- return trimmed.isEmpty ? nil : trimmed
- }
-
- func restoreStoredIdentity(
- label: String?,
- using service: FieldRuntimeService
- ) async throws -> NostrIdentityRecord {
- guard let secret = try loadSelectedSecretHex() else {
- throw FieldSecureIdentityStoreError.missingSelectedSecret
- }
- return try await service.nostrIdentityRestoreHostCustodySecret(
- secretKey: secret,
- label: label,
- makeSelected: true
- )
- }
-
- func importSecret(
- _ secret: String,
- label: String?,
- using service: FieldRuntimeService
- ) async throws -> NostrIdentityRecord {
- let trimmed = try normalizedSecret(secret)
- let previousSecret = try loadSelectedSecretHex()
- _ = try await service.nostrIdentityValidateHostCustodySecret(secretKey: trimmed)
- let stagedRecord = try await restoreHostCustodySecret(
- trimmed,
- label: label,
- makeSelected: false,
- using: service
- )
- do {
- try saveSelectedSecret(trimmed)
- } catch {
- await restorePreviousRuntimeIdentity(previousSecret, using: service)
- await removeStagedIdentityIfNeeded(stagedRecord, previousSecret: previousSecret, using: service)
- throw error
- }
- do {
- return try await restoreHostCustodySecret(
- trimmed,
- label: label,
- makeSelected: true,
- using: service
- )
- } catch {
- try? restorePreviousSelectedSecret(previousSecret)
- await restorePreviousRuntimeIdentity(previousSecret, using: service)
- await removeStagedIdentityIfNeeded(stagedRecord, previousSecret: previousSecret, using: service)
- throw error
- }
- }
-
- func createIdentity(
- label: String?,
- using service: FieldRuntimeService
- ) async throws -> NostrIdentityRecord {
- var lastError: Error?
- for _ in 0..<8 {
- let secret = try Self.generateSecretHex()
- do {
- return try await importSecret(secret, label: label, using: service)
- } catch {
- lastError = error
- }
- }
- throw lastError ?? FieldSecureIdentityStoreError.missingSelectedSecret
- }
-
- func deleteSelectedSecret() throws {
- try store.delete(Self.selectedSecretKey)
- }
-
- static func secureStoreServiceName(servicePrefix: String) throws -> String {
- try selectedSecretKey.serviceName(servicePrefix: servicePrefix)
- }
-
- static func configuredAccessPolicy() throws -> FieldSecureIdentityAccessPolicy {
- guard let rawValue = BuildConfig.string(.keychainAccessPolicy) else {
- throw FieldSecureIdentityStoreError.missingSecureStoreAccessPolicy
- }
- guard let policy = FieldSecureIdentityAccessPolicy(rawValue: rawValue) else {
- throw FieldSecureIdentityStoreError.invalidSecureStoreAccessPolicy(rawValue)
- }
- return policy
- }
-
- static func generateSecretHex() throws -> String {
- var bytes = [UInt8](repeating: 0, count: 32)
- let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
- guard status == errSecSuccess else {
- throw FieldSecureIdentityStoreError.randomSecretGenerationFailed(status)
- }
- return bytes.map { String(format: "%02x", $0) }.joined()
- }
-
- private func saveSelectedSecret(_ secret: String) throws {
- let trimmed = try normalizedSecret(secret)
- try store.put(
- Data(trimmed.utf8),
- for: Self.selectedSecretKey,
- policy: try Self.configuredAccessPolicy().storePolicy
- )
- }
-
- private func restorePreviousSelectedSecret(_ previousSecret: String?) throws {
- if let previousSecret {
- try saveSelectedSecret(previousSecret)
- } else {
- try deleteSelectedSecret()
- }
- }
-
- private func restoreHostCustodySecret(
- _ secret: String,
- label: String?,
- makeSelected: Bool,
- using service: FieldRuntimeService
- ) async throws -> NostrIdentityRecord {
- #if DEBUG
- try FieldSecureIdentityImportRestoreFailureUITestHook.throwIfRequested(makeSelected: makeSelected)
- #endif
- return try await service.nostrIdentityRestoreHostCustodySecret(
- secretKey: secret,
- label: label,
- makeSelected: makeSelected
- )
- }
-
- private func restorePreviousRuntimeIdentity(
- _ previousSecret: String?,
- using service: FieldRuntimeService
- ) async {
- guard let previousSecret else {
- return
- }
- _ = try? await service.nostrIdentityRestoreHostCustodySecret(
- secretKey: previousSecret,
- label: nil,
- makeSelected: true
- )
- }
-
- private func removeStagedIdentityIfNeeded(
- _ stagedRecord: NostrIdentityRecord,
- previousSecret: String?,
- using service: FieldRuntimeService
- ) async {
- if let previousSecret,
- let previous = try? await service.nostrIdentityValidateHostCustodySecret(secretKey: previousSecret),
- previous.id == stagedRecord.id {
- return
- }
- try? await service.nostrIdentityRemove(identityId: stagedRecord.id)
- }
-
- private func normalizedSecret(_ secret: String) throws -> String {
- let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else {
- throw FieldSecureIdentityStoreError.missingSelectedSecret
- }
- return trimmed
- }
-
- private static var selectedSecretKey: RadrootsSecureStoreKey {
- RadrootsSecureStoreKey(namespace: namespace, name: selectedSecretName)
- }
-}
-
-#if DEBUG
-private enum FieldSecureIdentityImportRestoreFailureUITestHook {
- private static let phaseKey = "RADROOTS_FIELD_IOS_UI_TEST_IDENTITY_IMPORT_RESTORE_FAILURE_PHASE"
-
- static func throwIfRequested(makeSelected: Bool) throws {
- guard FieldUITestHarness.isRequested,
- let rawPhase = FieldUITestHarness.string(phaseKey)?.lowercased() else {
- return
- }
- switch rawPhase {
- case "any":
- throw FieldSecureIdentityStoreError.forcedImportRestoreFailure
- case "stage" where !makeSelected:
- throw FieldSecureIdentityStoreError.forcedImportRestoreFailure
- case "select" where makeSelected:
- throw FieldSecureIdentityStoreError.forcedImportRestoreFailure
- default:
- return
- }
- }
-}
-#endif
diff --git a/Radroots/Runtime/RadrootsGeneratedRuntimeBackend.swift b/Radroots/Runtime/RadrootsGeneratedRuntimeBackend.swift
@@ -1,6 +1,45 @@
import Foundation
import RadrootsKitBindings
+private final class RadrootsGeneratedHostSigner: RadrootsHostSigner, @unchecked Sendable {
+ private let signer: any RadrootsRuntimeSigner
+
+ init(signer: any RadrootsRuntimeSigner) {
+ self.signer = signer
+ }
+
+ func signerStatus() async -> SignerStatusRecord {
+ await SignerStatusRecord(
+ schemaVersion: 1,
+ availability: signer.availability().generatedValue
+ )
+ }
+
+ func sign(request: HostSigningRequest) async -> HostSigningResult {
+ let purpose = request.purpose.appValue
+ let outcome = await signer.sign(
+ RadrootsRuntimeSigningRequest(
+ operationID: request.operationId,
+ signerRequestID: request.signerRequestId,
+ publicKeyHex: request.publicKey,
+ purpose: purpose,
+ deadlineUnixMilliseconds: request.deadlineUnixMs,
+ digest: request.eventIdDigest
+ )
+ )
+ return HostSigningResult(
+ schemaVersion: 1,
+ outcome: outcome.generatedOutcome,
+ operationId: request.operationId,
+ signerRequestId: request.signerRequestId,
+ publicKey: request.publicKey,
+ purpose: request.purpose,
+ signatureHex: outcome.signatureHex,
+ completedAtUnixMs: UInt64(Date().timeIntervalSince1970 * 1000)
+ )
+ }
+}
+
private final class RadrootsGeneratedRuntimeObserver: RadrootsRuntimeObserver, @unchecked Sendable {
private let continuation: AsyncStream<RadrootsRuntimeChange>.Continuation
@@ -142,12 +181,13 @@ private final class RadrootsGeneratedRuntimeBackend: RadrootsRuntimeBackend, @un
) async throws -> RadrootsRuntimeBackendStart {
var createdRuntime: RadrootsRuntime?
do {
- let runtime = try await RadrootsRuntime(
+ let runtime = try await RadrootsRuntime.withHostSigner(
applicationSupportDirectory: configuration.applicationSupportDirectory,
publicKeyHex: configuration.publicKeyHex,
sourceGenerationHex: configuration.sourceGenerationHex,
sourceGenerationCreatedAtUnixMs: configuration.sourceGenerationCreatedAtUnixMilliseconds,
- protectedData: configuration.protectedData.generatedValue
+ protectedData: configuration.protectedData.generatedValue,
+ hostSigner: RadrootsGeneratedHostSigner(signer: configuration.signer)
)
createdRuntime = runtime
runtime.setAppInfoPlatform(
@@ -161,13 +201,19 @@ private final class RadrootsGeneratedRuntimeBackend: RadrootsRuntimeBackend, @un
switch configuration.networkProfile {
case .publicNetwork:
try runtime.configurePublicRelays(writableRelays: configuration.writableRelays)
- try runtime.configurePublicBlossom(origins: configuration.blossomOrigins)
+ if !configuration.blossomOrigins.isEmpty {
+ try runtime.configurePublicBlossom(origins: configuration.blossomOrigins)
+ }
case .simulator:
try runtime.configureSimulatorRelays(loopbackRelays: configuration.writableRelays)
- try runtime.configureSimulatorBlossom(origins: configuration.blossomOrigins)
+ if !configuration.blossomOrigins.isEmpty {
+ try runtime.configureSimulatorBlossom(origins: configuration.blossomOrigins)
+ }
case .device:
try runtime.configureDeviceRelays(writableRelays: configuration.writableRelays)
- try runtime.configureDeviceBlossom(origins: configuration.blossomOrigins)
+ if !configuration.blossomOrigins.isEmpty {
+ try runtime.configureDeviceBlossom(origins: configuration.blossomOrigins)
+ }
}
let backend = RadrootsGeneratedRuntimeBackend(runtime: runtime)
@@ -207,6 +253,48 @@ private final class RadrootsGeneratedRuntimeBackend: RadrootsRuntimeBackend, @un
}
}
+private extension RadrootsRuntimeSignerAvailability {
+ var generatedValue: SignerAvailabilityRecord {
+ switch self {
+ case .ready: .ready
+ case .busy: .busy
+ case .locked: .locked
+ case .unavailable: .unavailable
+ }
+ }
+}
+
+private extension HostSigningPurpose {
+ var appValue: RadrootsRuntimeSigningPurpose {
+ switch self {
+ case .nostrEvent: .nostrEvent
+ case .blossomUpload: .blossomUpload
+ }
+ }
+}
+
+private extension RadrootsRuntimeSigningOutcome {
+ var generatedOutcome: HostSigningOutcome {
+ switch self {
+ case .signed: .signed
+ case .locked: .locked
+ case .cancelled: .cancelled
+ case .rejected: .rejected
+ case .timedOut: .timedOut
+ case .unavailable: .unavailable
+ case .invalidated: .invalidated
+ case .failed: .failed
+ }
+ }
+
+ var signatureHex: String? {
+ if case let .signed(signatureHex) = self {
+ return signatureHex
+ }
+ return nil
+ }
+}
+
extension RadrootsRuntimeClient {
static func production() -> RadrootsRuntimeClient {
RadrootsRuntimeClient { configuration in
diff --git a/Radroots/Runtime/RadrootsRuntimeModels.swift b/Radroots/Runtime/RadrootsRuntimeModels.swift
@@ -18,7 +18,44 @@ struct RadrootsRuntimeAppMetadata: Sendable, Equatable {
let buildSHA: String?
}
-struct RadrootsRuntimeLaunchConfiguration: Sendable, Equatable {
+enum RadrootsRuntimeSignerAvailability: Sendable, Equatable {
+ case ready
+ case busy
+ case locked
+ case unavailable
+}
+
+enum RadrootsRuntimeSigningPurpose: Sendable, Equatable {
+ case nostrEvent
+ case blossomUpload
+}
+
+struct RadrootsRuntimeSigningRequest: Sendable, Equatable {
+ let operationID: String
+ let signerRequestID: String
+ let publicKeyHex: String
+ let purpose: RadrootsRuntimeSigningPurpose
+ let deadlineUnixMilliseconds: UInt64
+ let digest: Data
+}
+
+enum RadrootsRuntimeSigningOutcome: Sendable, Equatable {
+ case signed(signatureHex: String)
+ case locked
+ case cancelled
+ case rejected
+ case timedOut
+ case unavailable
+ case invalidated
+ case failed
+}
+
+protocol RadrootsRuntimeSigner: Sendable {
+ func availability() async -> RadrootsRuntimeSignerAvailability
+ func sign(_ request: RadrootsRuntimeSigningRequest) async -> RadrootsRuntimeSigningOutcome
+}
+
+struct RadrootsRuntimeLaunchConfiguration: Sendable {
let applicationSupportDirectory: String
let publicKeyHex: String
let sourceGenerationHex: String
@@ -28,6 +65,24 @@ struct RadrootsRuntimeLaunchConfiguration: Sendable, Equatable {
let writableRelays: [String]
let blossomOrigins: [String]
let app: RadrootsRuntimeAppMetadata
+ let signerGeneration: String
+ let signer: any RadrootsRuntimeSigner
+}
+
+extension RadrootsRuntimeLaunchConfiguration: Equatable {
+ static func == (lhs: Self, rhs: Self) -> Bool {
+ lhs.applicationSupportDirectory == rhs.applicationSupportDirectory
+ && lhs.publicKeyHex == rhs.publicKeyHex
+ && lhs.sourceGenerationHex == rhs.sourceGenerationHex
+ && lhs.sourceGenerationCreatedAtUnixMilliseconds
+ == rhs.sourceGenerationCreatedAtUnixMilliseconds
+ && lhs.protectedData == rhs.protectedData
+ && lhs.networkProfile == rhs.networkProfile
+ && lhs.writableRelays == rhs.writableRelays
+ && lhs.blossomOrigins == rhs.blossomOrigins
+ && lhs.app == rhs.app
+ && lhs.signerGeneration == rhs.signerGeneration
+ }
}
struct RadrootsRuntimeIdentity: Sendable, Equatable {
diff --git a/Radroots/Runtime/RelaySettings.swift b/Radroots/Runtime/RelaySettings.swift
@@ -1,138 +0,0 @@
-import Foundation
-import RadrootsKit
-
-public enum RelaySettingsError: LocalizedError {
- case noRelaysConfigured
- case invalidRelayURL(String)
- case invalidStoredRelaySettings
-
- public var errorDescription: String? {
- switch self {
- case .noRelaysConfigured:
- "No Nostr relays configured. Set 'RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS'."
- case .invalidRelayURL(let value):
- "Invalid Nostr relay URL: \(value)."
- case .invalidStoredRelaySettings:
- "Stored Nostr relay settings are invalid."
- }
- }
-}
-
-public enum RelaySettingsSource: String {
- case buildConfig
- case userImported
-
- var displayName: String {
- switch self {
- case .buildConfig:
- "Build Config"
- case .userImported:
- "Imported"
- }
- }
-}
-
-public struct RelaySettingsSnapshot: Equatable {
- public let source: RelaySettingsSource
- public let relays: [String]
-}
-
-public enum RelaySettings {
- private struct StoredRelaySettingsDocument: Codable {
- static let format = "radroots_field_ios_relay_settings_v1"
-
- let format: String
- let relays: [String]
- }
-
- private static let storedSettingsFile = RadrootsFileReference(
- scope: .data,
- relativePath: "settings/relay_settings.json"
- )
-
- public static func relays() throws -> [String] {
- guard let parts = BuildConfig.array(.nostrRelayUrls) else {
- throw RelaySettingsError.noRelaysConfigured
- }
- return try validatedRelays(parts)
- }
-
- public static func effectiveSnapshot(bundleIdentifier: String) throws -> RelaySettingsSnapshot {
- if let importedRelays = try userImportedRelays(bundleIdentifier: bundleIdentifier) {
- return RelaySettingsSnapshot(source: .userImported, relays: importedRelays)
- }
- return RelaySettingsSnapshot(source: .buildConfig, relays: try relays())
- }
-
- @discardableResult
- public static func storeUserImportedRelays(
- _ relays: [String],
- bundleIdentifier: String
- ) throws -> RelaySettingsSnapshot {
- let normalized = try validatedRelays(relays)
- let encoder = JSONEncoder()
- encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
- let document = StoredRelaySettingsDocument(
- format: StoredRelaySettingsDocument.format,
- relays: normalized
- )
- let data = try encoder.encode(document)
- try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier).write(
- .inline(data),
- to: storedSettingsFile
- )
- return RelaySettingsSnapshot(source: .userImported, relays: normalized)
- }
-
- public static func clearUserImportedRelays(bundleIdentifier: String) throws {
- try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier).delete(storedSettingsFile)
- }
-
- public static func validatedRelays(_ urls: [String]) throws -> [String] {
- var seen = Set<String>()
- var out: [String] = []
- for u in urls {
- let trimmed = u.trimmingCharacters(in: .whitespacesAndNewlines)
- let unquoted = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
- guard !unquoted.isEmpty else {
- continue
- }
- guard let components = URLComponents(string: unquoted),
- let scheme = components.scheme?.lowercased(),
- scheme == "ws" || scheme == "wss",
- components.host != nil,
- unquoted.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else {
- throw RelaySettingsError.invalidRelayURL(u)
- }
- let lower = unquoted.lowercased()
- if seen.insert(lower).inserted {
- out.append(unquoted)
- }
- }
- guard !out.isEmpty else {
- throw RelaySettingsError.noRelaysConfigured
- }
- return out
- }
-
- private static func userImportedRelays(bundleIdentifier: String) throws -> [String]? {
- do {
- let result = try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier).read(
- storedSettingsFile,
- mode: .inline
- )
- guard case .inline(let data) = result else {
- throw RelaySettingsError.invalidStoredRelaySettings
- }
- let document = try JSONDecoder().decode(StoredRelaySettingsDocument.self, from: data)
- guard document.format == StoredRelaySettingsDocument.format else {
- throw RelaySettingsError.invalidStoredRelaySettings
- }
- return try validatedRelays(document.relays)
- } catch RadrootsAppleFileError.notFound(_) {
- return nil
- } catch is DecodingError {
- throw RelaySettingsError.invalidStoredRelaySettings
- }
- }
-}
diff --git a/Radroots/State/RadrootsConfigurationStore.swift b/Radroots/State/RadrootsConfigurationStore.swift
@@ -0,0 +1,447 @@
+import Darwin
+import Foundation
+import RadrootsKit
+import Security
+
+enum RadrootsAppNetworkProfile: String, Codable, Sendable, Equatable {
+ case publicNetwork = "public"
+ case simulator
+ case device
+
+ var runtimeValue: RadrootsRuntimeNetworkProfile {
+ switch self {
+ case .publicNetwork: .publicNetwork
+ case .simulator: .simulator
+ case .device: .device
+ }
+ }
+}
+
+struct RadrootsAppConfiguration: Sendable, Equatable {
+ let profile: RadrootsAppNetworkProfile
+ let writableRelays: [String]
+ let blossomOrigins: [String]
+ let keychainServicePrefix: String
+ let bundleIdentifier: String
+ let appMetadata: RadrootsRuntimeAppMetadata
+}
+
+struct RadrootsConfigurationBootstrap: Sendable, Equatable {
+ let runtimeMode: String
+ let relayURLs: [String]
+ let blossomOrigins: [String]
+ let keychainServicePrefix: String
+ let bundleIdentifier: String
+ let appMetadata: RadrootsRuntimeAppMetadata
+}
+
+struct RadrootsSourceGeneration: Codable, Sendable, Equatable {
+ let schemaVersion: UInt16
+ let generationHex: String
+ let createdAtUnixMilliseconds: UInt64
+}
+
+enum RadrootsConfigurationError: Error, Sendable, Equatable {
+ case missing(String)
+ case invalid(String)
+ case corruptStoredConfiguration
+ case corruptSourceGeneration
+ case persistenceFailed
+}
+
+extension RadrootsConfigurationError: LocalizedError {
+ var errorDescription: String? {
+ switch self {
+ case .missing:
+ "Required Radroots configuration is missing."
+ case .invalid:
+ "Radroots network configuration is invalid."
+ case .corruptStoredConfiguration:
+ "Stored Radroots settings are corrupt and require recovery."
+ case .corruptSourceGeneration:
+ "Stored Radroots local-state identity is corrupt and requires recovery."
+ case .persistenceFailed:
+ "Radroots could not persist its local configuration."
+ }
+ }
+}
+
+actor RadrootsConfigurationStore {
+ private struct StoredConfiguration: Codable {
+ static let format = "radroots_ios_configuration_v2"
+
+ let format: String
+ let profile: RadrootsAppNetworkProfile
+ let writableRelays: [String]
+ let blossomOrigins: [String]
+ }
+
+ private struct LegacyRelaySettings: Codable {
+ static let format = "radroots_field_ios_relay_settings_v1"
+
+ let format: String
+ let relays: [String]
+ }
+
+ private static let configurationFile = RadrootsFileReference(
+ scope: .data,
+ relativePath: "settings/radroots_configuration_v2.json"
+ )
+ private static let legacyRelayFile = RadrootsFileReference(
+ scope: .data,
+ relativePath: "settings/relay_settings.json"
+ )
+ private static let sourceGenerationFile = RadrootsFileReference(
+ scope: .data,
+ relativePath: "state/source_generation_v1.json"
+ )
+
+ private let bootstrap: RadrootsConfigurationBootstrap
+ private let fileAccess: RadrootsAppleFileAccess
+
+ init(bootstrap: RadrootsConfigurationBootstrap, roots: RadrootsAppleFileRoots) {
+ self.bootstrap = bootstrap
+ fileAccess = RadrootsAppleFileAccess(roots: roots)
+ }
+
+ func load() throws -> RadrootsAppConfiguration {
+ let profile = try Self.profile(for: bootstrap.runtimeMode)
+ let stored = try readStoredConfiguration()
+ let selected: StoredConfiguration
+ if let stored {
+ guard stored.format == StoredConfiguration.format, stored.profile == profile else {
+ throw RadrootsConfigurationError.corruptStoredConfiguration
+ }
+ selected = stored
+ } else if let legacy = try readLegacyConfiguration() {
+ guard legacy.format == LegacyRelaySettings.format else {
+ throw RadrootsConfigurationError.corruptStoredConfiguration
+ }
+ selected = StoredConfiguration(
+ format: StoredConfiguration.format,
+ profile: profile,
+ writableRelays: legacy.relays,
+ blossomOrigins: bootstrap.blossomOrigins
+ )
+ try persist(selected)
+ } else {
+ selected = StoredConfiguration(
+ format: StoredConfiguration.format,
+ profile: profile,
+ writableRelays: bootstrap.relayURLs,
+ blossomOrigins: bootstrap.blossomOrigins
+ )
+ }
+
+ let relays = try RadrootsNetworkValidator.relays(
+ selected.writableRelays,
+ profile: profile
+ )
+ let origins = try RadrootsNetworkValidator.blossomOrigins(
+ selected.blossomOrigins,
+ profile: profile
+ )
+ guard !bootstrap.keychainServicePrefix.isEmpty,
+ !bootstrap.bundleIdentifier.isEmpty
+ else {
+ throw RadrootsConfigurationError.missing("identity")
+ }
+ return RadrootsAppConfiguration(
+ profile: profile,
+ writableRelays: relays,
+ blossomOrigins: origins,
+ keychainServicePrefix: bootstrap.keychainServicePrefix,
+ bundleIdentifier: bootstrap.bundleIdentifier,
+ appMetadata: bootstrap.appMetadata
+ )
+ }
+
+ func sourceGeneration() throws -> RadrootsSourceGeneration {
+ if let data = try read(Self.sourceGenerationFile) {
+ guard let value = try? JSONDecoder().decode(RadrootsSourceGeneration.self, from: data),
+ value.schemaVersion == 1,
+ value.generationHex.count == 64,
+ value.generationHex.allSatisfy(\.isHexDigit),
+ value.generationHex == value.generationHex.lowercased(),
+ value.createdAtUnixMilliseconds > 0
+ else {
+ throw RadrootsConfigurationError.corruptSourceGeneration
+ }
+ return value
+ }
+ var bytes = [UInt8](repeating: 0, count: 32)
+ guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else {
+ throw RadrootsConfigurationError.persistenceFailed
+ }
+ let value = RadrootsSourceGeneration(
+ schemaVersion: 1,
+ generationHex: bytes.map { String(format: "%02x", $0) }.joined(),
+ createdAtUnixMilliseconds: max(1, UInt64(Date().timeIntervalSince1970 * 1000))
+ )
+ do {
+ let data = try JSONEncoder.radroots.encode(value)
+ try fileAccess.write(.inline(data), to: Self.sourceGenerationFile)
+ return value
+ } catch let error as RadrootsConfigurationError {
+ throw error
+ } catch {
+ throw RadrootsConfigurationError.persistenceFailed
+ }
+ }
+
+ private func readStoredConfiguration() throws -> StoredConfiguration? {
+ guard let data = try read(Self.configurationFile) else { return nil }
+ guard let stored = try? JSONDecoder().decode(StoredConfiguration.self, from: data) else {
+ throw RadrootsConfigurationError.corruptStoredConfiguration
+ }
+ return stored
+ }
+
+ private func readLegacyConfiguration() throws -> LegacyRelaySettings? {
+ guard let data = try read(Self.legacyRelayFile) else { return nil }
+ guard let legacy = try? JSONDecoder().decode(LegacyRelaySettings.self, from: data) else {
+ throw RadrootsConfigurationError.corruptStoredConfiguration
+ }
+ return legacy
+ }
+
+ private func read(_ file: RadrootsFileReference) throws -> Data? {
+ do {
+ guard case let .inline(data) = try fileAccess.read(file, mode: .inline) else {
+ throw RadrootsConfigurationError.persistenceFailed
+ }
+ return data
+ } catch RadrootsAppleFileError.notFound {
+ return nil
+ } catch let error as RadrootsConfigurationError {
+ throw error
+ } catch {
+ throw RadrootsConfigurationError.persistenceFailed
+ }
+ }
+
+ private func persist(_ configuration: StoredConfiguration) throws {
+ do {
+ let data = try JSONEncoder.radroots.encode(configuration)
+ try fileAccess.write(.inline(data), to: Self.configurationFile)
+ } catch {
+ throw RadrootsConfigurationError.persistenceFailed
+ }
+ }
+
+ private static func profile(for runtimeMode: String) throws -> RadrootsAppNetworkProfile {
+ switch runtimeMode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
+ case "production": .publicNetwork
+ case "localhost-dev", "simulator": .simulator
+ case "device-development", "device": .device
+ default: throw RadrootsConfigurationError.invalid("runtime_mode")
+ }
+ }
+}
+
+enum RadrootsNetworkValidator {
+ static let publicReadOnlyRelay = "wss://radroots.org"
+
+ static func relays(
+ _ values: [String],
+ profile: RadrootsAppNetworkProfile
+ ) throws -> [String] {
+ var output: [String] = []
+ var seen = Set<String>()
+ for raw in values {
+ let canonical = try relay(raw, profile: profile)
+ if profile != .simulator, canonical == publicReadOnlyRelay {
+ continue
+ }
+ guard seen.insert(canonical).inserted else {
+ throw RadrootsConfigurationError.invalid("duplicate_relay")
+ }
+ output.append(canonical)
+ }
+ if profile != .publicNetwork, output.isEmpty {
+ throw RadrootsConfigurationError.invalid("empty_relay_set")
+ }
+ let totalCount = output.count + (profile == .simulator ? 0 : 1)
+ guard totalCount <= 64 else {
+ throw RadrootsConfigurationError.invalid("too_many_relays")
+ }
+ return output
+ }
+
+ static func blossomOrigins(
+ _ values: [String],
+ profile: RadrootsAppNetworkProfile
+ ) throws -> [String] {
+ var output: [String] = []
+ var seen = Set<String>()
+ for raw in values {
+ guard raw == raw.trimmingCharacters(in: .whitespacesAndNewlines),
+ raw.utf8.allSatisfy({ $0 < 128 }),
+ let components = URLComponents(string: raw),
+ components.user == nil,
+ components.password == nil,
+ components.query == nil,
+ components.fragment == nil,
+ components.path.isEmpty || components.path == "/",
+ let scheme = components.scheme?.lowercased(),
+ let host = components.host?.lowercased(),
+ components.port != 0
+ else {
+ throw RadrootsConfigurationError.invalid("blossom_origin")
+ }
+ guard scheme == "https" || scheme == "http" && profile == .simulator,
+ hostAllowed(host, profile: profile)
+ else {
+ throw RadrootsConfigurationError.invalid("blossom_policy")
+ }
+ var canonical = "\(scheme)://\(hostForURL(host))"
+ if let port = components.port,
+ !((scheme == "https" && port == 443) || (scheme == "http" && port == 80))
+ {
+ canonical += ":\(port)"
+ }
+ guard seen.insert(canonical).inserted else {
+ throw RadrootsConfigurationError.invalid("duplicate_blossom_origin")
+ }
+ output.append(canonical)
+ }
+ guard output.count <= 8 else {
+ throw RadrootsConfigurationError.invalid("too_many_blossom_origins")
+ }
+ return output
+ }
+
+ private static func relay(
+ _ raw: String,
+ profile: RadrootsAppNetworkProfile
+ ) throws -> String {
+ guard raw == raw.trimmingCharacters(in: .whitespacesAndNewlines),
+ raw.utf8.count <= 2048,
+ !raw.contains(where: { $0.isASCII && ($0.isWhitespace || $0.asciiValue ?? 32 < 32) }),
+ !raw.contains("?"),
+ !raw.contains("#"),
+ !raw.contains("\\"),
+ let components = URLComponents(string: raw),
+ components.user == nil,
+ components.password == nil,
+ let scheme = components.scheme?.lowercased(),
+ let host = components.host?.lowercased(),
+ components.port != 0,
+ components.path.isEmpty || components.path == "/"
+ else {
+ throw RadrootsConfigurationError.invalid("relay_url")
+ }
+ guard scheme == "wss" || scheme == "ws" && profile == .simulator,
+ hostAllowed(host, profile: profile)
+ else {
+ throw RadrootsConfigurationError.invalid("relay_policy")
+ }
+ var canonical = "\(scheme)://\(hostForURL(host))"
+ if let port = components.port,
+ !((scheme == "wss" && port == 443) || (scheme == "ws" && port == 80))
+ {
+ canonical += ":\(port)"
+ }
+ return canonical
+ }
+
+ private static func hostForURL(_ host: String) -> String {
+ host.contains(":") ? "[\(host)]" : host
+ }
+
+ private static func hostAllowed(
+ _ host: String,
+ profile: RadrootsAppNetworkProfile
+ ) -> Bool {
+ if profile == .simulator {
+ return host == "localhost" || host == "127.0.0.1" || host == "::1"
+ }
+ if host == "localhost" || host.hasSuffix(".localhost") {
+ return false
+ }
+ if let ipv4 = IPv4Address(host) {
+ return profile == .publicNetwork ? ipv4.isPublic : ipv4.isTrustedDevice
+ }
+ if let ipv6 = IPv6Address(host) {
+ return profile == .publicNetwork ? ipv6.isPublic : ipv6.isTrustedDevice
+ }
+ if profile == .device {
+ return true
+ }
+ let normalized = host.trimmingCharacters(in: CharacterSet(charactersIn: "."))
+ return normalized.contains(".")
+ && !normalized.hasSuffix(".local")
+ && !normalized.hasSuffix(".home.arpa")
+ }
+}
+
+private struct IPv4Address {
+ private var address = in_addr()
+
+ init?(_ value: String) {
+ guard inet_pton(AF_INET, value, &address) == 1 else { return nil }
+ }
+
+ var isTrustedDevice: Bool {
+ var address = address
+ let octets = withUnsafeBytes(of: &address.s_addr) { Array($0) }
+ return octets[0] != 0 && octets[0] != 127 && !(224 ... 239).contains(octets[0])
+ && octets != [255, 255, 255, 255]
+ }
+
+ var isPublic: Bool {
+ var address = address
+ let octets = withUnsafeBytes(of: &address.s_addr) { Array($0) }
+ guard isTrustedDevice else { return false }
+ return !(octets[0] == 10
+ || octets[0] == 169 && octets[1] == 254
+ || octets[0] == 172 && (16 ... 31).contains(octets[1])
+ || octets[0] == 192 && octets[1] == 168
+ || octets[0] == 100 && (64 ... 127).contains(octets[1])
+ || octets[0] == 192 && octets[1] == 0 && octets[2] == 0
+ || octets[0] == 192 && octets[1] == 0 && octets[2] == 2
+ || octets[0] == 192 && octets[1] == 88 && octets[2] == 99
+ || octets[0] == 198 && (octets[1] == 18 || octets[1] == 19)
+ || octets[0] == 198 && octets[1] == 51 && octets[2] == 100
+ || octets[0] == 203 && octets[1] == 0 && octets[2] == 113
+ || octets[0] >= 240)
+ }
+}
+
+private struct IPv6Address {
+ private var address = in6_addr()
+
+ init?(_ value: String) {
+ guard inet_pton(AF_INET6, value, &address) == 1 else { return nil }
+ }
+
+ var isTrustedDevice: Bool {
+ var address = address
+ let bytes = withUnsafeBytes(of: &address) { Array($0) }
+ let allZero = bytes.allSatisfy { $0 == 0 }
+ let loopback = bytes.dropLast().allSatisfy { $0 == 0 } && bytes.last == 1
+ let multicast = bytes.first == 0xFF
+ return !allZero && !loopback && !multicast
+ }
+
+ var isPublic: Bool {
+ var address = address
+ let bytes = withUnsafeBytes(of: &address) { Array($0) }
+ guard isTrustedDevice, bytes.count == 16 else { return false }
+ let segment0 = UInt16(bytes[0]) << 8 | UInt16(bytes[1])
+ let segment1 = UInt16(bytes[2]) << 8 | UInt16(bytes[3])
+ return segment0 & 0xE000 == 0x2000
+ && !(segment0 == 0x2001 && segment1 <= 0x01FF)
+ && !(segment0 == 0x2001 && segment1 == 0x0DB8)
+ && segment0 != 0x2002
+ && !(segment0 == 0x3FFF && segment1 & 0xF000 == 0)
+ }
+}
+
+private extension JSONEncoder {
+ static var radroots: JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ return encoder
+ }
+}
diff --git a/Radroots/State/RadrootsIdentityStore.swift b/Radroots/State/RadrootsIdentityStore.swift
@@ -0,0 +1,335 @@
+import CryptoKit
+import Foundation
+import RadrootsKit
+
+enum RadrootsAppIdentityState: String, Sendable, Equatable {
+ case absent
+ case locked
+ case unlocked
+ case protectedDataUnavailable
+ case recoveryRequired
+ case corrupt
+}
+
+struct RadrootsAppIdentity: Sendable, Equatable {
+ let state: RadrootsAppIdentityState
+ let identityHandle: String?
+ let publicKeyHex: String?
+ let label: String?
+ let signerGeneration: String?
+ let recoveryCode: String?
+}
+
+struct RadrootsStableVisualIdentity: Sendable, Equatable {
+ let digestHex: String
+ let paletteIndex: Int
+
+ init(publicKeyHex: String, paletteCount: Int = 12) {
+ let digest = SHA256.hash(data: Data("radroots.avatar.v1:\(publicKeyHex)".utf8))
+ digestHex = digest.map { String(format: "%02x", $0) }.joined()
+ paletteIndex = Int(Array(digest)[0]) % max(1, paletteCount)
+ }
+}
+
+enum RadrootsIdentityStoreError: Error, Sendable, Equatable {
+ case corruptLegacyMetadata
+ case custody(String)
+ case unavailable
+}
+
+extension RadrootsIdentityStoreError: LocalizedError {
+ var errorDescription: String? {
+ switch self {
+ case .corruptLegacyMetadata:
+ "Legacy identity metadata is corrupt and requires recovery."
+ case .custody:
+ "The local identity needs attention before Radroots can continue."
+ case .unavailable:
+ "The local identity is unavailable."
+ }
+ }
+}
+
+actor RadrootsIdentityStore {
+ private struct LegacyMetadata: Codable {
+ let selectedIdentityId: String
+ let publicKeyHex: String
+ let publicKeyNpub: String
+ let label: String?
+ let updatedAtUnix: UInt64
+ }
+
+ private let custody: RadrootsIdentityCustody
+ private let secureStore: any RadrootsSecureStore
+ private let servicePrefix: String
+ private let userDefaults: UserDefaults
+
+ init(
+ custody: RadrootsIdentityCustody,
+ secureStore: any RadrootsSecureStore,
+ servicePrefix: String,
+ userDefaults: UserDefaults = .standard
+ ) {
+ self.custody = custody
+ self.secureStore = secureStore
+ self.servicePrefix = servicePrefix
+ self.userDefaults = userDefaults
+ }
+
+ @MainActor
+ static func production(
+ servicePrefix: String,
+ protectedDataAvailable: Bool
+ ) throws -> RadrootsIdentityStore {
+ let namespace = "radroots_identity_v1"
+ let secureStore = RadrootsAppleKeychainSecureStore(servicePrefix: servicePrefix)
+ let custody = try RadrootsIdentityCustody(
+ configuration: RadrootsIdentityCustodyConfiguration(namespace: namespace),
+ secureStore: secureStore,
+ metadataStore: RadrootsAppleIdentityMetadataStore(
+ namespace: namespace,
+ keyPrefix: "org.radroots.ios.identity"
+ ),
+ userPresence: RadrootsAppleUserPresence(),
+ protectedData: RadrootsProtectedDataProvider {
+ protectedDataAvailable ? .available : .unavailable
+ }
+ )
+ return RadrootsIdentityStore(
+ custody: custody,
+ secureStore: secureStore,
+ servicePrefix: servicePrefix
+ )
+ }
+
+ func loadAndMigrate() async throws -> RadrootsAppIdentity {
+ let initial = await custody.snapshot()
+ guard initial.state == .absent else {
+ return Self.appIdentity(initial)
+ }
+
+ let legacySecretKey = RadrootsSecureStoreKey(
+ namespace: "nostr_identity",
+ name: "selected_secret_hex"
+ )
+ let hasLegacySecret: Bool
+ do {
+ hasLegacySecret = try secureStore.contains(legacySecretKey)
+ } catch {
+ throw RadrootsIdentityStoreError.unavailable
+ }
+ let legacyMetadata = try loadLegacyMetadata()
+ guard hasLegacySecret || legacyMetadata != nil else {
+ return Self.appIdentity(initial)
+ }
+ guard hasLegacySecret else {
+ throw RadrootsIdentityStoreError.corruptLegacyMetadata
+ }
+ return RadrootsAppIdentity(
+ state: .recoveryRequired,
+ identityHandle: nil,
+ publicKeyHex: legacyMetadata?.publicKeyHex.lowercased(),
+ label: legacyMetadata?.label,
+ signerGeneration: nil,
+ recoveryCode: "identity.legacy_migration_required"
+ )
+ }
+
+ private func migrateLegacyIdentity() async throws -> RadrootsAppIdentity {
+ let legacySecretKey = RadrootsSecureStoreKey(
+ namespace: "nostr_identity",
+ name: "selected_secret_hex"
+ )
+ let legacyMetadata = try loadLegacyMetadata()
+ do {
+ let migrated = try await custody.migrateLegacyIdentity(
+ from: legacySecretKey,
+ label: legacyMetadata?.label
+ )
+ if let metadata = legacyMetadata,
+ metadata.publicKeyHex.lowercased() != migrated.identity?.publicKeyHex
+ {
+ throw RadrootsIdentityStoreError.corruptLegacyMetadata
+ }
+ deleteLegacyMetadata()
+ return Self.appIdentity(migrated)
+ } catch let error as RadrootsIdentityStoreError {
+ throw error
+ } catch let error as RadrootsIdentityCustodyError {
+ throw RadrootsIdentityStoreError.custody(error.code)
+ } catch {
+ throw RadrootsIdentityStoreError.unavailable
+ }
+ }
+
+ func create(label: String? = nil) async throws -> RadrootsAppIdentity {
+ try await custody.createIdentity(label: label).appValue
+ }
+
+ func importIdentity(_ text: String, label: String? = nil) async throws -> RadrootsAppIdentity {
+ let material = try RadrootsIdentitySecretMaterial(importText: text)
+ return try await custody.importIdentity(material, label: label).appValue
+ }
+
+ func unlock() async throws -> RadrootsAppIdentity {
+ try await custody.unlockIdentity().appValue
+ }
+
+ func recover() async throws -> RadrootsAppIdentity {
+ let snapshot = await custody.snapshot()
+ if snapshot.state == .absent {
+ let legacyKey = RadrootsSecureStoreKey(
+ namespace: "nostr_identity",
+ name: "selected_secret_hex"
+ )
+ if try secureStore.contains(legacyKey) {
+ return try await migrateLegacyIdentity()
+ }
+ }
+ return try await custody.recover().appValue
+ }
+
+ func lock() async {
+ await custody.lockIdentity()
+ }
+
+ func signer(for identity: RadrootsAppIdentity) throws -> any RadrootsRuntimeSigner {
+ guard identity.state == .unlocked,
+ let signerHandle = identity.signerGeneration,
+ let publicKeyHex = identity.publicKeyHex
+ else {
+ throw RadrootsIdentityStoreError.unavailable
+ }
+ return RadrootsAppleCustodySigner(
+ custody: custody,
+ signerHandle: signerHandle,
+ publicKeyHex: publicKeyHex
+ )
+ }
+
+ private func loadLegacyMetadata() throws -> LegacyMetadata? {
+ let key = "field_ios.identity.public_metadata.\(servicePrefix)"
+ guard let data = userDefaults.data(forKey: key) else { return nil }
+ guard let value = try? JSONDecoder().decode(LegacyMetadata.self, from: data),
+ value.publicKeyHex.count == 64,
+ value.publicKeyHex.allSatisfy(\.isHexDigit)
+ else {
+ throw RadrootsIdentityStoreError.corruptLegacyMetadata
+ }
+ return value
+ }
+
+ private func deleteLegacyMetadata() {
+ userDefaults.removeObject(
+ forKey: "field_ios.identity.public_metadata.\(servicePrefix)"
+ )
+ }
+
+ private static func appIdentity(_ snapshot: RadrootsIdentitySnapshot) -> RadrootsAppIdentity {
+ RadrootsAppIdentity(
+ state: snapshot.state.appValue,
+ identityHandle: snapshot.identity?.identityHandle,
+ publicKeyHex: snapshot.identity?.publicKeyHex,
+ label: snapshot.identity?.label,
+ signerGeneration: snapshot.signerHandle,
+ recoveryCode: snapshot.recoveryCode
+ )
+ }
+}
+
+private final class RadrootsAppleCustodySigner: RadrootsRuntimeSigner, @unchecked Sendable {
+ private let custody: RadrootsIdentityCustody
+ private let signerHandle: String
+ private let publicKeyHex: String
+
+ init(custody: RadrootsIdentityCustody, signerHandle: String, publicKeyHex: String) {
+ self.custody = custody
+ self.signerHandle = signerHandle
+ self.publicKeyHex = publicKeyHex
+ }
+
+ func availability() async -> RadrootsRuntimeSignerAvailability {
+ let snapshot = await custody.snapshot()
+ return switch snapshot.state {
+ case .unlocked where snapshot.signerHandle == signerHandle:
+ RadrootsRuntimeSignerAvailability.ready
+ case .locked:
+ RadrootsRuntimeSignerAvailability.locked
+ default:
+ RadrootsRuntimeSignerAvailability.unavailable
+ }
+ }
+
+ func sign(_ request: RadrootsRuntimeSigningRequest) async -> RadrootsRuntimeSigningOutcome {
+ guard request.publicKeyHex == publicKeyHex,
+ request.digest.count == 32
+ else {
+ return .invalidated
+ }
+ do {
+ let result = try await custody.sign(
+ RadrootsOpaqueSignRequest(
+ operationID: request.signerRequestID,
+ signerHandle: signerHandle,
+ publicKeyHex: request.publicKeyHex,
+ digest: request.digest,
+ purpose: request.purpose.appleValue,
+ deadlineUnixMilliseconds: request.deadlineUnixMilliseconds
+ )
+ )
+ return .signed(signatureHex: result.signature.map { String(format: "%02x", $0) }.joined())
+ } catch let error as RadrootsIdentityCustodyError {
+ return switch error {
+ case .identityLocked, .userPresenceRequired:
+ RadrootsRuntimeSigningOutcome.locked
+ case .cancelled:
+ RadrootsRuntimeSigningOutcome.cancelled
+ case .timedOut:
+ RadrootsRuntimeSigningOutcome.timedOut
+ case .staleSigner, .invalidSignRequest, .invalidSignature:
+ RadrootsRuntimeSigningOutcome.invalidated
+ case .protectedDataUnavailable, .storageUnavailable:
+ RadrootsRuntimeSigningOutcome.unavailable
+ default:
+ RadrootsRuntimeSigningOutcome.failed
+ }
+ } catch {
+ return .failed
+ }
+ }
+}
+
+private extension RadrootsIdentitySnapshot {
+ var appValue: RadrootsAppIdentity {
+ RadrootsAppIdentity(
+ state: state.appValue,
+ identityHandle: identity?.identityHandle,
+ publicKeyHex: identity?.publicKeyHex,
+ label: identity?.label,
+ signerGeneration: signerHandle,
+ recoveryCode: recoveryCode
+ )
+ }
+}
+
+private extension RadrootsIdentityState {
+ var appValue: RadrootsAppIdentityState {
+ switch self {
+ case .absent: .absent
+ case .locked: .locked
+ case .unlocked: .unlocked
+ case .protectedDataUnavailable: .protectedDataUnavailable
+ case .recoveryRequired: .recoveryRequired
+ case .corrupt: .corrupt
+ }
+ }
+}
+
+private extension RadrootsRuntimeSigningPurpose {
+ var appleValue: RadrootsOpaqueSignPurpose {
+ switch self {
+ case .nostrEvent: .nostrEvent
+ case .blossomUpload: .blossomUpload
+ }
+ }
+}
diff --git a/Radroots/State/RadrootsSessionStore.swift b/Radroots/State/RadrootsSessionStore.swift
@@ -0,0 +1,277 @@
+import Foundation
+import RadrootsKit
+import UIKit
+
+enum RadrootsSessionPhase: Sendable, Equatable {
+ case starting
+ case identityRequired
+ case identityLocked(RadrootsAppIdentity)
+ case protectedDataUnavailable(RadrootsAppIdentity)
+ case recoveryRequired(RadrootsAppIdentity)
+ case corruptIdentity(RadrootsAppIdentity)
+ case running(RadrootsRuntimeSnapshot)
+ case stopped
+ case failed(RadrootsRuntimeFailure)
+}
+
+actor RadrootsSessionStore {
+ private let configurationStore: RadrootsConfigurationStore
+ private let identityStore: RadrootsIdentityStore
+ private let runtimeClient: RadrootsRuntimeClient
+ private let roots: RadrootsAppleFileRoots
+ private let protectedDataAvailable: Bool
+ private var generation: UInt64 = 0
+ private var phase: RadrootsSessionPhase = .starting
+
+ init(
+ configurationStore: RadrootsConfigurationStore,
+ identityStore: RadrootsIdentityStore,
+ runtimeClient: RadrootsRuntimeClient,
+ roots: RadrootsAppleFileRoots,
+ protectedDataAvailable: Bool
+ ) {
+ self.configurationStore = configurationStore
+ self.identityStore = identityStore
+ self.runtimeClient = runtimeClient
+ self.roots = roots
+ self.protectedDataAvailable = protectedDataAvailable
+ }
+
+ @MainActor
+ static func production(
+ bundle: Bundle = .main,
+ runtimeClient: RadrootsRuntimeClient = .production()
+ ) throws -> RadrootsSessionStore {
+ guard let bundleIdentifier = bundle.bundleIdentifier else {
+ throw RadrootsConfigurationError.missing("bundle_identifier")
+ }
+ let servicePrefix = try requiredString(
+ "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX",
+ bundle: bundle
+ )
+ let bootstrap = try RadrootsConfigurationBootstrap(
+ runtimeMode: requiredString("RADROOTS_FIELD_IOS_RUNTIME_MODE", bundle: bundle),
+ relayURLs: array("RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS", bundle: bundle),
+ 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 protectedDataAvailable = UIApplication.shared.isProtectedDataAvailable
+ return try RadrootsSessionStore(
+ configurationStore: RadrootsConfigurationStore(bootstrap: bootstrap, roots: roots),
+ identityStore: .production(
+ servicePrefix: servicePrefix,
+ protectedDataAvailable: protectedDataAvailable
+ ),
+ runtimeClient: runtimeClient,
+ roots: roots,
+ protectedDataAvailable: protectedDataAvailable
+ )
+ }
+
+ func currentPhase() -> RadrootsSessionPhase {
+ phase
+ }
+
+ func start() async -> RadrootsSessionPhase {
+ generation &+= 1
+ let requestedGeneration = generation
+ phase = .starting
+ do {
+ let configuration = try await configurationStore.load()
+ let identity = try await identityStore.loadAndMigrate()
+ guard generation == requestedGeneration else {
+ throw RadrootsRuntimeClientError.superseded
+ }
+ switch identity.state {
+ case .absent:
+ phase = .identityRequired
+ case .locked:
+ phase = .identityLocked(identity)
+ case .protectedDataUnavailable:
+ phase = .protectedDataUnavailable(identity)
+ case .recoveryRequired:
+ phase = .recoveryRequired(identity)
+ case .corrupt:
+ phase = .corruptIdentity(identity)
+ case .unlocked:
+ phase = try await startRuntime(
+ configuration: configuration,
+ identity: identity,
+ generation: requestedGeneration
+ )
+ }
+ } 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 importIdentity(_ text: String, label: String? = nil) async -> RadrootsSessionPhase {
+ do {
+ _ = try await identityStore.importIdentity(text, label: label)
+ } 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 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
+ ) 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: protectedDataAvailable ? .available : .unavailable
+ )
+ let sourceGeneration = try await configurationStore.sourceGeneration()
+ let signer = try await identityStore.signer(for: identity)
+ let snapshot = try await runtimeClient.start(
+ configuration: RadrootsRuntimeLaunchConfiguration(
+ applicationSupportDirectory: mobileStore.applicationSupportDirectory.path,
+ publicKeyHex: publicKeyHex,
+ sourceGenerationHex: sourceGeneration.generationHex,
+ sourceGenerationCreatedAtUnixMilliseconds: sourceGeneration.createdAtUnixMilliseconds,
+ protectedData: protectedDataAvailable ? .available : .unavailable,
+ networkProfile: configuration.profile.runtimeValue,
+ writableRelays: configuration.writableRelays,
+ blossomOrigins: configuration.blossomOrigins,
+ app: configuration.appMetadata,
+ signerGeneration: signerGeneration,
+ signer: signer
+ )
+ )
+ guard generation == requestedGeneration else {
+ _ = try? await runtimeClient.stop()
+ throw RadrootsRuntimeClientError.superseded
+ }
+ return .running(snapshot)
+ }
+
+ 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 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 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/RuntimeStatusView.swift b/Radroots/Views/RuntimeStatusView.swift
@@ -3,6 +3,9 @@ import SwiftUI
struct RuntimeStatusView: View {
let phase: RadrootsAppModel.Phase
let retry: () -> Void
+ let createIdentity: () -> Void
+ let unlockIdentity: () -> Void
+ let recoverIdentity: () -> Void
var body: some View {
NavigationStack {
@@ -18,7 +21,19 @@ struct RuntimeStatusView: View {
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
- if case .failed = phase {
+ if case .identityRequired = phase {
+ Button("Create identity", action: createIdentity)
+ .buttonStyle(.borderedProminent)
+ .accessibilityIdentifier("radroots.identity.create")
+ } else if case .identityLocked = phase {
+ Button("Unlock identity", action: unlockIdentity)
+ .buttonStyle(.borderedProminent)
+ .accessibilityIdentifier("radroots.identity.unlock")
+ } else if case .recoveryRequired = phase {
+ Button("Recover identity", action: recoverIdentity)
+ .buttonStyle(.borderedProminent)
+ .accessibilityIdentifier("radroots.identity.recover")
+ } else if case .failed = phase {
Button("Retry", action: retry)
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("radroots.runtime.retry")
@@ -35,6 +50,10 @@ struct RuntimeStatusView: View {
switch phase {
case .starting: "leaf"
case .identityRequired: "person.badge.key"
+ case .identityLocked: "lock"
+ case .protectedDataUnavailable: "lock.iphone"
+ case .recoveryRequired: "wrench.and.screwdriver"
+ case .corruptIdentity: "exclamationmark.shield"
case .running: "checkmark.circle"
case .failed: "exclamationmark.triangle"
case .stopped: "pause.circle"
@@ -43,7 +62,7 @@ struct RuntimeStatusView: View {
private var symbolColor: Color {
switch phase {
- case .failed: .red
+ case .failed, .corruptIdentity: .red
case .running: .green
default: .accentColor
}
@@ -53,6 +72,10 @@ struct RuntimeStatusView: View {
switch phase {
case .starting: "Starting Radroots"
case .identityRequired: "Set up your identity"
+ case .identityLocked: "Unlock your identity"
+ case .protectedDataUnavailable: "Unlock this device"
+ case .recoveryRequired: "Recover your identity"
+ case .corruptIdentity: "Identity data needs repair"
case .running: "Radroots is ready"
case .failed: "Radroots needs attention"
case .stopped: "Radroots is paused"
@@ -65,6 +88,14 @@ struct RuntimeStatusView: View {
"Preparing your local Radroots data."
case .identityRequired:
"Create or import an identity to connect your local food network."
+ case .identityLocked:
+ "Your local Nostr secret remains protected until you explicitly unlock it."
+ case .protectedDataUnavailable:
+ "Protected local data is unavailable while this device is locked."
+ case let .recoveryRequired(identity):
+ identity.recoveryCode ?? "A previous identity operation needs recovery."
+ case let .corruptIdentity(identity):
+ identity.recoveryCode ?? "Stored identity state is corrupt; it was not treated as absent."
case let .running(snapshot):
"Runtime \(snapshot.crateVersion) is connected to your local data."
case let .failed(failure):
diff --git a/RadrootsTests/RadrootsRuntimeClientTests.swift b/RadrootsTests/RadrootsRuntimeClientTests.swift
@@ -154,7 +154,9 @@ final class RadrootsRuntimeClientTests: XCTestCase {
version: "0.1.0-alpha",
buildNumber: "1",
buildSHA: nil
- )
+ ),
+ signerGeneration: generation,
+ signer: TestRuntimeSigner()
)
}
@@ -168,6 +170,16 @@ final class RadrootsRuntimeClientTests: XCTestCase {
}
}
+private struct TestRuntimeSigner: RadrootsRuntimeSigner {
+ func availability() async -> RadrootsRuntimeSignerAvailability {
+ .ready
+ }
+
+ func sign(_: RadrootsRuntimeSigningRequest) async -> RadrootsRuntimeSigningOutcome {
+ .failed
+ }
+}
+
private actor RuntimeHarness {
private let startDelayNanoseconds: UInt64
private let shutdownFailure: RadrootsRuntimeFailure?
diff --git a/RadrootsTests/RadrootsStateMigrationTests.swift b/RadrootsTests/RadrootsStateMigrationTests.swift
@@ -0,0 +1,286 @@
+import Foundation
+@testable import Radroots
+import RadrootsKit
+import XCTest
+
+final class RadrootsStateMigrationTests: XCTestCase {
+ func testRelayValidationMatchesRustProfiles() throws {
+ XCTAssertEqual(
+ try RadrootsNetworkValidator.relays(
+ ["wss://radroots.org", "WSS://WRITE.EXAMPLE:443/"],
+ profile: .publicNetwork
+ ),
+ ["wss://write.example"]
+ )
+ XCTAssertEqual(
+ try RadrootsNetworkValidator.relays(
+ ["ws://127.0.0.1:7447"],
+ profile: .simulator
+ ),
+ ["ws://127.0.0.1:7447"]
+ )
+ XCTAssertNoThrow(
+ try RadrootsNetworkValidator.relays(
+ ["wss://10.0.0.5:7447"],
+ profile: .device
+ )
+ )
+ for denied in [
+ "ws://public.example",
+ "wss://localhost",
+ "wss://10.0.0.1",
+ "wss://user@example.com",
+ "wss://relay.example?token=value",
+ ] {
+ XCTAssertThrowsError(
+ try RadrootsNetworkValidator.relays([denied], profile: .publicNetwork),
+ "Expected public policy to deny \(denied)"
+ )
+ }
+ XCTAssertThrowsError(
+ try RadrootsNetworkValidator.relays(
+ ["wss://127.0.0.1:7447"],
+ profile: .device
+ )
+ )
+ }
+
+ func testLegacyRelayMigrationIsIdempotentAndCorruptionIsNotAbsence() async throws {
+ let fixture = try StateFixture()
+ defer { fixture.remove() }
+ let fileAccess = RadrootsAppleFileAccess(roots: fixture.roots)
+ try fileAccess.write(
+ .inline(
+ Data(
+ """
+ {"format":"radroots_field_ios_relay_settings_v1","relays":["ws://127.0.0.1:7447"]}
+ """.utf8
+ )
+ ),
+ to: RadrootsFileReference(
+ scope: .data,
+ relativePath: "settings/relay_settings.json"
+ )
+ )
+ let store = RadrootsConfigurationStore(
+ bootstrap: fixture.bootstrap,
+ roots: fixture.roots
+ )
+ let first = try await store.load()
+ let second = try await store.load()
+ XCTAssertEqual(first, second)
+ XCTAssertEqual(first.writableRelays, ["ws://127.0.0.1:7447"])
+
+ try fileAccess.write(
+ .inline(Data("not-json".utf8)),
+ to: RadrootsFileReference(
+ scope: .data,
+ relativePath: "settings/radroots_configuration_v2.json"
+ )
+ )
+ do {
+ _ = try await store.load()
+ XCTFail("Corrupt stored configuration must fail closed")
+ } catch {
+ XCTAssertEqual(error as? RadrootsConfigurationError, .corruptStoredConfiguration)
+ }
+ }
+
+ func testSourceGenerationAndVisualIdentitySurviveStoreRecreation() async throws {
+ let fixture = try StateFixture()
+ defer { fixture.remove() }
+ let firstStore = RadrootsConfigurationStore(
+ bootstrap: fixture.bootstrap,
+ roots: fixture.roots
+ )
+ let first = try await firstStore.sourceGeneration()
+ let secondStore = RadrootsConfigurationStore(
+ bootstrap: fixture.bootstrap,
+ roots: fixture.roots
+ )
+ let second = try await secondStore.sourceGeneration()
+ XCTAssertEqual(first, second)
+
+ let key = String(repeating: "ab", count: 32)
+ XCTAssertEqual(
+ RadrootsStableVisualIdentity(publicKeyHex: key),
+ RadrootsStableVisualIdentity(publicKeyHex: key)
+ )
+ XCTAssertNotEqual(
+ RadrootsStableVisualIdentity(publicKeyHex: key).digestHex,
+ RadrootsStableVisualIdentity(publicKeyHex: String(repeating: "cd", count: 32)).digestHex
+ )
+ }
+
+ func testLegacyIdentityMigrationIsTransactionalAndIdempotent() async throws {
+ let secureStore = InMemorySecureStore()
+ let metadataStore = InMemoryIdentityMetadataStore()
+ let servicePrefix = "org.radroots.tests.identity.\(UUID().uuidString.lowercased())"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: servicePrefix))
+ defer {
+ UserDefaults(suiteName: servicePrefix)?.removePersistentDomain(forName: servicePrefix)
+ }
+ let legacyKey = RadrootsSecureStoreKey(
+ namespace: "nostr_identity",
+ name: "selected_secret_hex"
+ )
+ try secureStore.put(
+ Data(String(repeating: "01", count: 32).utf8),
+ for: legacyKey,
+ policy: .secureLocalSecret
+ )
+ let custody = try RadrootsIdentityCustody(
+ configuration: RadrootsIdentityCustodyConfiguration(
+ namespace: "radroots_identity_v1",
+ secretPolicy: .secureLocalSecret
+ ),
+ secureStore: secureStore,
+ metadataStore: metadataStore,
+ userPresence: AllowingUserPresence()
+ )
+ let store = RadrootsIdentityStore(
+ custody: custody,
+ secureStore: secureStore,
+ servicePrefix: servicePrefix,
+ userDefaults: defaults
+ )
+ let first = try await store.loadAndMigrate()
+ XCTAssertEqual(first.state, .recoveryRequired)
+ XCTAssertTrue(try secureStore.contains(legacyKey))
+ let migrated = try await store.recover()
+ let second = try await store.loadAndMigrate()
+ XCTAssertEqual(migrated.publicKeyHex, second.publicKeyHex)
+ XCTAssertEqual(migrated.state, .unlocked)
+ XCTAssertFalse(try secureStore.contains(legacyKey))
+ }
+
+ func testMalformedLegacyIdentityMetadataIsNotTreatedAsMissing() async throws {
+ let secureStore = InMemorySecureStore()
+ let servicePrefix = "org.radroots.tests.corrupt.\(UUID().uuidString.lowercased())"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: servicePrefix))
+ defer {
+ UserDefaults(suiteName: servicePrefix)?.removePersistentDomain(forName: servicePrefix)
+ }
+ defaults.set(
+ Data("not-json".utf8),
+ forKey: "field_ios.identity.public_metadata.\(servicePrefix)"
+ )
+ let custody = try RadrootsIdentityCustody(
+ configuration: RadrootsIdentityCustodyConfiguration(
+ namespace: "radroots_identity_v1",
+ secretPolicy: .secureLocalSecret
+ ),
+ secureStore: secureStore,
+ metadataStore: InMemoryIdentityMetadataStore(),
+ userPresence: AllowingUserPresence()
+ )
+ let store = RadrootsIdentityStore(
+ custody: custody,
+ secureStore: secureStore,
+ servicePrefix: servicePrefix,
+ userDefaults: defaults
+ )
+ do {
+ _ = try await store.loadAndMigrate()
+ XCTFail("Malformed legacy metadata must be classified as corrupt")
+ } catch {
+ XCTAssertEqual(error as? RadrootsIdentityStoreError, .corruptLegacyMetadata)
+ }
+ }
+}
+
+private struct StateFixture {
+ let root: URL
+ let roots: RadrootsAppleFileRoots
+ let bootstrap: RadrootsConfigurationBootstrap
+
+ init() throws {
+ root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("radroots-state-tests-\(UUID().uuidString.lowercased())")
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false)
+ roots = try RadrootsAppleFileRoots(
+ appIdentifier: "org.radroots.tests",
+ dataRoot: root.appendingPathComponent("data"),
+ cacheRoot: root.appendingPathComponent("cache"),
+ temporaryRoot: root.appendingPathComponent("tmp")
+ )
+ bootstrap = RadrootsConfigurationBootstrap(
+ runtimeMode: "localhost-dev",
+ relayURLs: ["ws://127.0.0.1:8080"],
+ blossomOrigins: ["http://127.0.0.1:3000"],
+ keychainServicePrefix: "org.radroots.tests",
+ bundleIdentifier: "org.radroots.tests",
+ appMetadata: RadrootsRuntimeAppMetadata(
+ bundleIdentifier: "org.radroots.tests",
+ version: "0.1.0-alpha",
+ buildNumber: "1",
+ buildSHA: nil
+ )
+ )
+ }
+
+ func remove() {
+ try? FileManager.default.removeItem(at: root)
+ }
+}
+
+private final class InMemorySecureStore: RadrootsSecureStore, @unchecked Sendable {
+ private let lock = NSLock()
+ private var values: [RadrootsSecureStoreKey: Data] = [:]
+
+ func put(
+ _ value: Data,
+ for key: RadrootsSecureStoreKey,
+ policy _: RadrootsSecretAccessPolicy
+ ) throws {
+ lock.withLock { values[key] = value }
+ }
+
+ func contains(_ key: RadrootsSecureStoreKey) throws -> Bool {
+ lock.withLock { values[key] != nil }
+ }
+
+ func get(_ key: RadrootsSecureStoreKey) throws -> Data? {
+ lock.withLock { values[key] }
+ }
+
+ func delete(_ key: RadrootsSecureStoreKey) throws {
+ lock.withLock { _ = values.removeValue(forKey: key) }
+ }
+
+ func deleteNamespace(_ namespace: String) throws {
+ lock.withLock { values = values.filter { $0.key.namespace != namespace } }
+ }
+}
+
+private final class InMemoryIdentityMetadataStore: RadrootsIdentityMetadataStore, @unchecked Sendable {
+ private let lock = NSLock()
+ private var values: [RadrootsIdentityMetadataSlot: Data] = [:]
+
+ func data(for slot: RadrootsIdentityMetadataSlot) throws -> Data? {
+ lock.withLock { values[slot] }
+ }
+
+ func put(_ data: Data, for slot: RadrootsIdentityMetadataSlot) throws {
+ lock.withLock { values[slot] = data }
+ }
+
+ func delete(_ slot: RadrootsIdentityMetadataSlot) throws {
+ lock.withLock { _ = values.removeValue(forKey: slot) }
+ }
+}
+
+private struct AllowingUserPresence: RadrootsUserPresence {
+ 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)
+ }
+}
diff --git a/project.yml b/project.yml
@@ -42,6 +42,9 @@ targets:
- path: Radroots/Runtime/RadrootsGeneratedRuntimeBackend.swift
- path: Radroots/Runtime/RadrootsRuntimeClient.swift
- path: Radroots/Runtime/RadrootsRuntimeModels.swift
+ - path: Radroots/State/RadrootsConfigurationStore.swift
+ - path: Radroots/State/RadrootsIdentityStore.swift
+ - path: Radroots/State/RadrootsSessionStore.swift
- path: Radroots/Views/RuntimeStatusView.swift
- path: Radroots/Resources
settings:
@@ -68,6 +71,7 @@ targets:
deploymentTarget: "18.0"
sources:
- path: RadrootsTests/RadrootsRuntimeClientTests.swift
+ - path: RadrootsTests/RadrootsStateMigrationTests.swift
settings:
base:
"EXCLUDED_ARCHS[sdk=iphonesimulator*]": x86_64