field_ios

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

commit 54642535cfc06b34f7e56ca73a6779b56ee3d65d
parent a14b733f5972e16c9ff3d5293b6df197bb37038d
Author: triesap <tyson@radroots.org>
Date:   Fri,  7 Aug 2026 10:24:29 +0000

Require durable SQLite for mobile runtimes

- validate Apple host store paths and authenticated identities
- expose typed protected-data and storage failures across UniFFI
- classify corruption, schema, and writer-lock failures at SDK boundary
- prove create, reopen, fencing, recovery, and test-only memory behavior

Diffstat:
Mcore/crates/tera_ffi/src/error.rs | 5++++-
Mcore/crates/tera_ffi/src/lib.rs | 4++--
Mcore/crates/tera_ffi/src/remote.rs | 12+++++++++++-
Mcore/crates/tera_ffi/src/runtime.rs | 36++++++++++++++++++++++++++++++++++--
Mcore/crates/tera_ffi/tests/runtime_delegation.rs | 8+++++---
Mcore/crates/tera_ffi/tests/runtime_lifecycle.rs | 12++++++++----
Acore/crates/tera_ffi/tests/support/mod.rs | 24++++++++++++++++++++++++
Mcore/crates/tera_ffi/tests/uniffi_contract.rs | 37++++++++++++++++++++++++++++++++++---
8 files changed, 122 insertions(+), 16 deletions(-)

diff --git a/core/crates/tera_ffi/src/error.rs b/core/crates/tera_ffi/src/error.rs @@ -1,6 +1,6 @@ use thiserror::Error; -pub use radroots_mobile_core::SdkErrorRecord; +pub use radroots_mobile_core::{SdkErrorRecord, StoreErrorRecord}; /// Versioned, secret-safe failure exposed across the native language boundary. #[derive(Debug, Error, uniffi::Error)] @@ -9,6 +9,8 @@ pub enum RadrootsAppError { Initialization(String), #[error("sdk: {report:?}")] Sdk { report: SdkErrorRecord }, + #[error("store: {report:?}")] + Store { report: StoreErrorRecord }, #[error("runtime: {0}")] Runtime(String), #[error("unsupported: {0}")] @@ -24,6 +26,7 @@ impl From<radroots_mobile_core::RadrootsAppError> for RadrootsAppError { Self::Initialization(message) } radroots_mobile_core::RadrootsAppError::Sdk { report } => Self::Sdk { report }, + radroots_mobile_core::RadrootsAppError::Store { report } => Self::Store { report }, radroots_mobile_core::RadrootsAppError::Runtime(message) => Self::Runtime(message), radroots_mobile_core::RadrootsAppError::Unsupported(message) => { Self::Unsupported(message) diff --git a/core/crates/tera_ffi/src/lib.rs b/core/crates/tera_ffi/src/lib.rs @@ -9,8 +9,8 @@ pub mod logging; mod remote; mod runtime; -pub use error::{RadrootsAppError, SdkErrorRecord}; -pub use runtime::RadrootsRuntime; +pub use error::{RadrootsAppError, SdkErrorRecord, StoreErrorRecord}; +pub use runtime::{ProtectedDataAvailability, RadrootsRuntime}; mod error; diff --git a/core/crates/tera_ffi/src/remote.rs b/core/crates/tera_ffi/src/remote.rs @@ -1,12 +1,12 @@ //! UniFFI converter ownership for ordinary Rust DTOs defined by mobile core. -use radroots_mobile_core::SdkErrorRecord; use radroots_mobile_core::runtime::app_info::*; use radroots_mobile_core::runtime::info::*; use radroots_mobile_core::runtime::key_management::*; use radroots_mobile_core::runtime::nostr::*; use radroots_mobile_core::runtime::product_surface::*; use radroots_mobile_core::runtime::sdk::*; +use radroots_mobile_core::{SdkErrorRecord, StoreErrorRecord}; #[uniffi::remote(Record)] pub struct SdkErrorRecord { @@ -21,6 +21,16 @@ pub struct SdkErrorRecord { } #[uniffi::remote(Record)] +pub struct StoreErrorRecord { + pub schema_version: u16, + pub code: String, + pub class: String, + pub retryable: bool, + pub recovery_actions: Vec<String>, + pub message: String, +} + +#[uniffi::remote(Record)] pub struct AppInfoPlatform { pub platform: Option<String>, pub bundle_id: Option<String>, diff --git a/core/crates/tera_ffi/src/runtime.rs b/core/crates/tera_ffi/src/runtime.rs @@ -8,6 +8,23 @@ use radroots_mobile_core::runtime::{ use crate::RadrootsAppError; +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum ProtectedDataAvailability { + Available, + Unavailable, +} + +impl From<ProtectedDataAvailability> + for radroots_mobile_core::runtime::store::ProtectedDataAvailability +{ + fn from(value: ProtectedDataAvailability) -> Self { + match value { + ProtectedDataAvailability::Available => Self::Available, + ProtectedDataAvailability::Unavailable => Self::Unavailable, + } + } +} + /// Native boundary object delegating all behavior to the ordinary Rust core. #[derive(uniffi::Object)] pub struct RadrootsRuntime { @@ -17,8 +34,23 @@ pub struct RadrootsRuntime { #[cfg_attr(not(coverage_nightly), uniffi::export)] impl RadrootsRuntime { #[cfg_attr(not(coverage_nightly), uniffi::constructor)] - pub fn new() -> Result<Self, RadrootsAppError> { - radroots_mobile_core::RadrootsRuntime::new() + pub async fn new( + application_support_directory: String, + public_key_hex: String, + source_generation_hex: String, + source_generation_created_at_unix_ms: u64, + protected_data: ProtectedDataAvailability, + ) -> Result<Self, RadrootsAppError> { + let store = radroots_mobile_core::runtime::store::MobileUserStoreConfig::from_encoded( + application_support_directory, + public_key_hex.as_str(), + source_generation_hex.as_str(), + source_generation_created_at_unix_ms, + protected_data.into(), + )?; + radroots_mobile_core::runtime::builder::RuntimeBuilder::new(store) + .build() + .await .map(|inner| Self { inner }) .map_err(Into::into) } diff --git a/core/crates/tera_ffi/tests/runtime_delegation.rs b/core/crates/tera_ffi/tests/runtime_delegation.rs @@ -1,11 +1,13 @@ use radroots_mobile_core::runtime::product_surface::{AddCommandType, TodayCardType}; -use radroots_mobile_ffi::{RadrootsAppError, RadrootsRuntime}; +use radroots_mobile_ffi::RadrootsAppError; + +mod support; const SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001"; #[tokio::test] async fn native_boundary_delegates_the_complete_core_surface() { - let runtime = RadrootsRuntime::new().expect("runtime"); + let (_root, runtime) = support::runtime().await; assert!(runtime.uptime_millis() >= 0); assert!(runtime.info_json().contains("sdk")); runtime.set_app_info_platform( @@ -22,7 +24,7 @@ async fn native_boundary_delegates_the_complete_core_surface() { assert!(!runtime.sdk_capabilities().is_empty()); assert_eq!( runtime.sdk_storage_status().await.expect("storage").backend, - "memory" + "sqlite" ); assert!(!runtime.nostr_identity_has_selected_signing_identity()); diff --git a/core/crates/tera_ffi/tests/runtime_lifecycle.rs b/core/crates/tera_ffi/tests/runtime_lifecycle.rs @@ -1,10 +1,13 @@ use std::{sync::Arc, time::Duration}; -use radroots_mobile_ffi::{RadrootsAppError, RadrootsRuntime}; +use radroots_mobile_ffi::RadrootsAppError; + +mod support; #[tokio::test] async fn host_release_ordering_retains_close_and_finishes_within_deadline() { - let host = Arc::new(RadrootsRuntime::new().expect("runtime")); + let (_root, runtime) = support::runtime().await; + let host = Arc::new(runtime); let closing_owner = Arc::clone(&host); let close = tokio::spawn(async move { closing_owner.shutdown().await }); drop(host); @@ -20,7 +23,8 @@ async fn host_release_ordering_retains_close_and_finishes_within_deadline() { #[tokio::test] async fn concurrent_host_references_converge_and_repeated_close_is_idempotent() { - let runtime = Arc::new(RadrootsRuntime::new().expect("runtime")); + let (_root, runtime) = support::runtime().await; + let runtime = Arc::new(runtime); let first = Arc::clone(&runtime); let second = Arc::clone(&runtime); let (first, second) = tokio::join!(first.shutdown(), second.shutdown()); @@ -31,7 +35,7 @@ async fn concurrent_host_references_converge_and_repeated_close_is_idempotent() || matches!( outcome, Err(RadrootsAppError::Sdk { report }) - if report.code == "close_in_progress" + if report.code == "client_close_in_progress" ) ); } diff --git a/core/crates/tera_ffi/tests/support/mod.rs b/core/crates/tera_ffi/tests/support/mod.rs @@ -0,0 +1,24 @@ +use radroots_mobile_ffi::{ProtectedDataAvailability, RadrootsRuntime}; + +pub const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +pub const GENERATION: &str = "0404040404040404040404040404040404040404040404040404040404040404"; + +pub fn prepare(root: &std::path::Path) { + std::fs::create_dir_all(root.join("radroots").join("users").join(PUBLIC_KEY)) + .expect("owner directory"); +} + +pub async fn runtime() -> (tempfile::TempDir, RadrootsRuntime) { + let root = tempfile::tempdir().expect("tempdir"); + prepare(root.path()); + let runtime = RadrootsRuntime::new( + root.path().to_string_lossy().into_owned(), + PUBLIC_KEY.to_owned(), + GENERATION.to_owned(), + 1_800_000_000_000, + ProtectedDataAvailability::Available, + ) + .await + .expect("runtime"); + (root, runtime) +} diff --git a/core/crates/tera_ffi/tests/uniffi_contract.rs b/core/crates/tera_ffi/tests/uniffi_contract.rs @@ -1,4 +1,8 @@ -use radroots_mobile_ffi::{RadrootsAppError, RadrootsRuntime, SdkErrorRecord}; +use radroots_mobile_ffi::{ + ProtectedDataAvailability, RadrootsAppError, RadrootsRuntime, SdkErrorRecord, +}; + +mod support; #[test] fn swift_module_names_preserve_the_host_contract() { @@ -10,10 +14,37 @@ fn swift_module_names_preserve_the_host_contract() { } #[tokio::test] +async fn protected_data_failure_is_typed_and_opens_no_store() { + let root = tempfile::tempdir().expect("tempdir"); + support::prepare(root.path()); + let result = RadrootsRuntime::new( + root.path().to_string_lossy().into_owned(), + support::PUBLIC_KEY.to_owned(), + support::GENERATION.to_owned(), + 1_800_000_000_000, + ProtectedDataAvailability::Unavailable, + ) + .await; + let Err(RadrootsAppError::Store { report }) = result else { + panic!("protected data failure must remain typed across UniFFI"); + }; + assert_eq!(report.code, "protected_data_unavailable"); + assert!(report.retryable); + assert!( + !root + .path() + .join("radroots/users") + .join(support::PUBLIC_KEY) + .join("runtime.sqlite") + .exists() + ); +} + +#[tokio::test] async fn final_mobile_abi_uses_async_sdk_dtos_and_versioned_errors() { - let runtime = RadrootsRuntime::new().expect("runtime"); + let (_root, runtime) = support::runtime().await; let storage = runtime.sdk_storage_status().await.expect("storage status"); - assert_eq!(storage.backend, "memory"); + assert_eq!(storage.backend, "sqlite"); runtime.shutdown().await.expect("shutdown"); let error = runtime