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 85505409d2f15767980148253c970c8034de6e33
parent 1c24d377dee4d9681e46dc1c5f55d32b87ab8f05
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 17:18:01 +0000

app-rt: restore sdk-backed mobile social bridge

- compose signer relay and sync slots around one sdk client
- restore host-custodied identity and categorical relay contracts
- expose bounded async profile post fetch and publish operations
- preserve wasm isolation and remove Rust-owned stream behavior

Diffstat:
Mcore/crates/tera_core/Cargo.toml | 5+++++
Acore/crates/tera_core/src/runtime/key_management.rs | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_core/src/runtime/mod.rs | 32+++++++++++++++++++++++++++++---
Acore/crates/tera_core/src/runtime/nostr.rs | 241+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 489 insertions(+), 3 deletions(-)

diff --git a/core/crates/tera_core/Cargo.toml b/core/crates/tera_core/Cargo.toml @@ -20,6 +20,11 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } [features] default = [] +mobile-social = [ + "radroots_sdk/local-signing", + "radroots_sdk/nostr", + "radroots_sdk/sync", +] [dependencies] radroots_log = { workspace = true } diff --git a/core/crates/tera_core/src/runtime/key_management.rs b/core/crates/tera_core/src/runtime/key_management.rs @@ -0,0 +1,214 @@ +//! Host-custodied mobile identity presentation over the SDK signer slot. + +use super::RadrootsRuntime; +use crate::RadrootsAppError; + +#[derive(uniffi::Record, Debug, Clone, Eq, PartialEq)] +pub struct NostrIdentityRecord { + pub id: String, + pub public_key_hex: String, + pub public_key_npub: String, + pub label: Option<String>, + pub is_selected: bool, +} + +#[derive(uniffi::Record, Debug, Clone, Eq, PartialEq)] +pub struct NostrIdentitySnapshot { + pub has_selected_signing_identity: bool, + pub selected_identity_id: Option<String>, + pub selected_npub: Option<String>, + pub identities: Vec<NostrIdentityRecord>, +} + +#[derive(uniffi::Record, Debug, Clone, Eq, PartialEq)] +pub struct NostrHostCustodyIdentity { + pub id: String, + pub public_key_hex: String, + pub public_key_npub: String, +} + +fn host_identity(identity: &radroots_sdk::signing::LocalIdentity) -> NostrHostCustodyIdentity { + let public_key_hex = identity.public_key_hex(); + NostrHostCustodyIdentity { + id: public_key_hex.clone(), + public_key_hex, + public_key_npub: identity.npub().to_owned(), + } +} + +fn identity_record( + identity: &radroots_sdk::signing::LocalIdentity, + label: Option<String>, +) -> NostrIdentityRecord { + let identity = host_identity(identity); + NostrIdentityRecord { + id: identity.id, + public_key_hex: identity.public_key_hex, + public_key_npub: identity.public_key_npub, + label, + is_selected: true, + } +} + +#[cfg_attr(not(coverage_nightly), uniffi::export)] +impl RadrootsRuntime { + pub fn nostr_identity_has_selected_signing_identity(&self) -> bool { + self.signing_slot.identity().is_some() + } + + pub fn nostr_identity_selected_npub(&self) -> Option<String> { + self.signing_slot + .identity() + .map(|identity| identity.npub().to_owned()) + } + + pub fn nostr_identity_list(&self) -> Result<Vec<NostrIdentityRecord>, RadrootsAppError> { + let Some(identity) = self.signing_slot.identity() else { + return Ok(Vec::new()); + }; + Ok(vec![identity_record(&identity, self.identity_label())]) + } + + pub fn nostr_identity_list_ids(&self) -> Result<Vec<String>, RadrootsAppError> { + Ok(self + .nostr_identity_list()? + .into_iter() + .map(|identity| identity.id) + .collect()) + } + + pub fn nostr_identity_snapshot(&self) -> Result<NostrIdentitySnapshot, RadrootsAppError> { + let identities = self.nostr_identity_list()?; + let selected = identities.first(); + Ok(NostrIdentitySnapshot { + has_selected_signing_identity: selected.is_some(), + selected_identity_id: selected.map(|identity| identity.id.clone()), + selected_npub: selected.map(|identity| identity.public_key_npub.clone()), + identities, + }) + } + + pub fn nostr_identity_validate_host_custody_secret( + &self, + secret_key: String, + ) -> Result<NostrHostCustodyIdentity, RadrootsAppError> { + let slot = radroots_sdk::signing::Slot::new(); + let identity = slot + .install(secret_key.as_str()) + .map_err(|_| RadrootsAppError::runtime("identity secret is invalid"))?; + slot.clear(); + Ok(host_identity(&identity)) + } + + pub fn nostr_identity_restore_host_custody_secret( + &self, + secret_key: String, + label: Option<String>, + make_selected: bool, + ) -> Result<NostrIdentityRecord, RadrootsAppError> { + if !make_selected { + let identity = self.nostr_identity_validate_host_custody_secret(secret_key)?; + return Ok(NostrIdentityRecord { + id: identity.id, + public_key_hex: identity.public_key_hex, + public_key_npub: identity.public_key_npub, + label, + is_selected: false, + }); + } + let identity = self + .signing_slot + .install(secret_key.as_str()) + .map_err(|_| RadrootsAppError::runtime("identity secret is invalid"))?; + self.set_identity_label(label.clone())?; + Ok(identity_record(&identity, label)) + } + + pub fn nostr_identity_select(&self, identity_id: String) -> Result<(), RadrootsAppError> { + let current = self + .signing_slot + .identity() + .ok_or_else(|| RadrootsAppError::runtime("identity is not installed"))?; + if current.public_key_hex() != identity_id { + return Err(RadrootsAppError::runtime("identity is not installed")); + } + Ok(()) + } + + pub fn nostr_identity_remove(&self, identity_id: String) -> Result<(), RadrootsAppError> { + if self + .signing_slot + .identity() + .is_some_and(|identity| identity.public_key_hex() == identity_id) + { + self.signing_slot.clear(); + self.set_identity_label(None)?; + } + Ok(()) + } + + pub fn nostr_identity_lock_host_custody_runtime(&self) -> Result<(), RadrootsAppError> { + self.signing_slot.clear(); + self.set_identity_label(None) + } + + pub fn nostr_identity_reset_host_custody_runtime(&self) -> Result<(), RadrootsAppError> { + self.nostr_identity_lock_host_custody_runtime() + } + + fn identity_label(&self) -> Option<String> { + self.identity_label + .read() + .ok() + .and_then(|label| label.clone()) + } + + fn set_identity_label(&self, label: Option<String>) -> Result<(), RadrootsAppError> { + let mut current = self + .identity_label + .write() + .map_err(|_| RadrootsAppError::runtime("identity label state is unavailable"))?; + *current = label; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + + #[test] + fn validation_does_not_select_and_restore_is_single_slot() { + let runtime = RadrootsRuntime::new().expect("runtime"); + let validated = runtime + .nostr_identity_validate_host_custody_secret(SECRET.to_owned()) + .expect("valid secret"); + assert!(!runtime.nostr_identity_has_selected_signing_identity()); + + let staged = runtime + .nostr_identity_restore_host_custody_secret( + SECRET.to_owned(), + Some("staged".to_owned()), + false, + ) + .expect("staged"); + assert_eq!(staged.id, validated.id); + assert!(!staged.is_selected); + + let selected = runtime + .nostr_identity_restore_host_custody_secret( + SECRET.to_owned(), + Some("selected".to_owned()), + true, + ) + .expect("selected"); + assert!(selected.is_selected); + assert_eq!(runtime.nostr_identity_list().expect("list"), vec![selected]); + runtime + .nostr_identity_lock_host_custody_runtime() + .expect("lock"); + assert!(runtime.nostr_identity_list().expect("list").is_empty()); + } +} diff --git a/core/crates/tera_core/src/runtime/mod.rs b/core/crates/tera_core/src/runtime/mod.rs @@ -1,6 +1,10 @@ pub mod app_info; pub mod builder; pub mod info; +#[cfg(feature = "mobile-social")] +pub mod key_management; +#[cfg(feature = "mobile-social")] +pub mod nostr; pub mod product_surface; pub mod sdk; @@ -20,6 +24,12 @@ use crate::RadrootsAppError; #[derive(uniffi::Object)] pub struct RadrootsRuntime { pub(crate) client: Client, + #[cfg(feature = "mobile-social")] + pub(crate) signing_slot: radroots_sdk::signing::Slot, + #[cfg(feature = "mobile-social")] + pub(crate) nostr_slot: radroots_sdk::transport::NostrSlot, + #[cfg(feature = "mobile-social")] + pub(crate) identity_label: RwLock<Option<String>>, pub(crate) started_unix_ms: i64, pub(crate) shutting_down: AtomicBool, pub(crate) platform_app: RwLock<Option<AppInfoPlatform>>, @@ -29,12 +39,28 @@ pub struct RadrootsRuntime { impl RadrootsRuntime { #[cfg_attr(not(coverage_nightly), uniffi::constructor)] pub fn new() -> Result<Self, RadrootsAppError> { - let client = ClientBuilder::memory_default() - .build() - .map_err(RadrootsAppError::from_sdk)?; + #[cfg(feature = "mobile-social")] + let signing_slot = radroots_sdk::signing::Slot::new(); + #[cfg(feature = "mobile-social")] + let nostr_slot = radroots_sdk::transport::NostrSlot::new( + radroots_sdk::transport::RelayUrlPolicy::Public, + ); + let builder = ClientBuilder::memory_default(); + #[cfg(feature = "mobile-social")] + let builder = builder + .signing(radroots_sdk::signing::Provider::slot(signing_slot.clone())) + .nostr(nostr_slot.clone()) + .host_sync(radroots_sdk::sync::HostPolicy::standard()); + let client = builder.build().map_err(RadrootsAppError::from_sdk)?; Ok(Self { client, + #[cfg(feature = "mobile-social")] + signing_slot, + #[cfg(feature = "mobile-social")] + nostr_slot, + #[cfg(feature = "mobile-social")] + identity_label: RwLock::new(None), started_unix_ms: Utc::now().timestamp_millis(), shutting_down: AtomicBool::new(false), platform_app: RwLock::new(None), diff --git a/core/crates/tera_core/src/runtime/nostr.rs b/core/crates/tera_core/src/runtime/nostr.rs @@ -0,0 +1,241 @@ +//! Bounded mobile Nostr presentation over shared SDK operations. + +use super::RadrootsRuntime; +use crate::RadrootsAppError; + +#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum NostrLight { + Red, + Yellow, + Green, +} + +#[derive(uniffi::Record, Debug, Clone, Eq, PartialEq)] +pub struct NostrConnectionStatus { + pub light: NostrLight, + pub configured: bool, + pub source_available: bool, + pub sink_available: bool, + pub last_error: Option<String>, +} + +#[derive(uniffi::Record, Debug, Clone, Default, Eq, PartialEq)] +pub struct NostrProfile { + pub name: Option<String>, + pub display_name: Option<String>, + pub nip05: Option<String>, + pub about: Option<String>, + pub website: Option<String>, + pub picture: Option<String>, + pub banner: Option<String>, + pub lud06: Option<String>, + pub lud16: Option<String>, + pub bot: Option<String>, +} + +#[derive(uniffi::Record, Debug, Clone, Eq, PartialEq)] +pub struct NostrProfileEventMetadata { + pub id: String, + pub author: String, + pub published_at: u64, + pub profile: NostrProfile, +} + +#[derive(uniffi::Record, Debug, Clone, Eq, PartialEq)] +pub struct NostrPost { + pub content: String, +} + +#[derive(uniffi::Record, Debug, Clone, Eq, PartialEq)] +pub struct NostrPostEventMetadata { + pub id: String, + pub author: String, + pub published_at: u64, + pub post: NostrPost, +} + +fn map_profile(event: radroots_sdk::client::ProfileEvent) -> NostrProfileEventMetadata { + NostrProfileEventMetadata { + id: event.event_id().to_owned(), + author: event.author().to_owned(), + published_at: event.created_at(), + profile: NostrProfile { + name: event.name().map(str::to_owned), + display_name: event.display_name().map(str::to_owned), + nip05: event.nip05().map(str::to_owned), + about: event.about().map(str::to_owned), + website: None, + picture: event.picture().map(str::to_owned), + banner: event.banner().map(str::to_owned), + lud06: None, + lud16: None, + bot: event.bot().map(|value| value.to_string()), + }, + } +} + +fn map_post(event: radroots_sdk::client::PostEvent) -> NostrPostEventMetadata { + NostrPostEventMetadata { + id: event.event_id().to_owned(), + author: event.author().to_owned(), + published_at: event.created_at(), + post: NostrPost { + content: event.content().to_owned(), + }, + } +} + +#[cfg_attr(not(coverage_nightly), uniffi::export)] +impl RadrootsRuntime { + pub fn nostr_set_default_relays(&self, relays: Vec<String>) -> Result<(), RadrootsAppError> { + self.nostr_slot + .configure(relays) + .map_err(RadrootsAppError::from_sdk) + } + + /// Validates readiness; relay connections remain operation-scoped. + pub fn nostr_connect_if_key_present(&self) -> Result<(), RadrootsAppError> { + if self.signing_slot.identity().is_none() { + return Err(RadrootsAppError::runtime("identity is not installed")); + } + if self.nostr_slot.targets().is_none() { + return Err(RadrootsAppError::runtime( + "relay selection is not configured", + )); + } + Ok(()) + } + + pub async fn nostr_connection_status(&self) -> Result<NostrConnectionStatus, RadrootsAppError> { + let social = self.client.social().map_err(RadrootsAppError::from_sdk)?; + let health = social + .transport_health() + .await + .map_err(RadrootsAppError::from_sdk)?; + let light = if health.is_source_available() && health.is_sink_available() { + NostrLight::Green + } else if health.is_configured() { + NostrLight::Yellow + } else { + NostrLight::Red + }; + Ok(NostrConnectionStatus { + light, + configured: health.is_configured(), + source_available: health.is_source_available(), + sink_available: health.is_sink_available(), + last_error: None, + }) + } + + pub async fn nostr_profile_for_self( + &self, + ) -> Result<Option<NostrProfileEventMetadata>, RadrootsAppError> { + self.client + .social() + .map_err(RadrootsAppError::from_sdk)? + .fetch_profile_for_signer() + .await + .map(|profile| profile.map(map_profile)) + .map_err(RadrootsAppError::from_sdk) + } + + pub async fn nostr_post_profile( + &self, + name: Option<String>, + display_name: Option<String>, + nip05: Option<String>, + about: Option<String>, + ) -> Result<String, RadrootsAppError> { + let name = name + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| RadrootsAppError::runtime("profile name is required"))?; + let mut draft = radroots_sdk::client::ProfileDraft::new(name); + if let Some(value) = display_name.filter(|value| !value.is_empty()) { + draft = draft.with_display_name(value); + } + if let Some(value) = nip05.filter(|value| !value.is_empty()) { + draft = draft.with_nip05(value); + } + if let Some(value) = about.filter(|value| !value.is_empty()) { + draft = draft.with_about(value); + } + self.client + .social() + .map_err(RadrootsAppError::from_sdk)? + .publish_profile(draft) + .await + .map(|receipt| receipt.event_id().to_owned()) + .map_err(RadrootsAppError::from_sdk) + } + + pub async fn nostr_post_text_note(&self, content: String) -> Result<String, RadrootsAppError> { + self.client + .social() + .map_err(RadrootsAppError::from_sdk)? + .publish_text(content) + .await + .map(|receipt| receipt.event_id().to_owned()) + .map_err(RadrootsAppError::from_sdk) + } + + pub async fn nostr_fetch_text_notes( + &self, + limit: u16, + since_unix: Option<u64>, + ) -> Result<Vec<NostrPostEventMetadata>, RadrootsAppError> { + self.client + .social() + .map_err(RadrootsAppError::from_sdk)? + .fetch_posts(limit, since_unix) + .await + .map(|events| events.into_iter().map(map_post).collect()) + .map_err(RadrootsAppError::from_sdk) + } + + pub async fn nostr_post_reply( + &self, + parent_event_id_hex: String, + parent_author_hex: String, + content: String, + root_event_id_hex: Option<String>, + ) -> Result<String, RadrootsAppError> { + if root_event_id_hex + .as_deref() + .is_some_and(|root| root != parent_event_id_hex.as_str()) + { + return Err(RadrootsAppError::unsupported( + "nested reply author context is required", + )); + } + self.client + .social() + .map_err(RadrootsAppError::from_sdk)? + .publish_reply( + content, + parent_event_id_hex.as_str(), + parent_author_hex.as_str(), + None, + ) + .await + .map(|receipt| receipt.event_id().to_owned()) + .map_err(RadrootsAppError::from_sdk) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn relay_configuration_is_explicit_and_status_is_categorical() { + let runtime = RadrootsRuntime::new().expect("runtime"); + let initial = runtime + .nostr_connection_status() + .await + .expect("initial status"); + assert_eq!(initial.light, NostrLight::Red); + assert!(!initial.configured); + assert!(runtime.nostr_set_default_relays(Vec::new()).is_err()); + } +}