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:
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());
+ }
+}